openvibers/openvibe.media Commits

Origin shield: viewers keep the one public URL. A shield-eligible read (a public or unlisted B2 copy, never private, sandbox or a recording) that arrives on openvibe.media answers a 302 to the same path on the DNS-only edge host, where the bytes come from the slice cache; on the edge it is served, never redirected again; never a scheme-relative target. Nothing changes until MEDIA_SHIELD and MEDIA_SHIELD_HOST are set. The repo's nginx files carry the security.txt include the host has. npm test 74/75 (N-1 skipped as designed).

OP OpenVibers committed 29 Sept 2026 · 2ab62eb0d8 · 6 files, +29 −2


Changes

ddiff --git a/deploy/nginx/edge.openvibe.media.conf b/deploy/nginx/edge.openvibe.media.conf
iindex ca8ed7a..b00b210 100644
---- a/deploy/nginx/edge.openvibe.media.conf
++++ b/deploy/nginx/edge.openvibe.media.conf
@@@ -15,6 +15,7 @@ server {
server_name edge.openvibe.media;
ssl_certificate /etc/letsencrypt/live/edge.openvibe.media/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/edge.openvibe.media/privkey.pem;
++ include snippets/security-txt.conf;
# As on openvibe.media: large uploads, long Node responses (DVR, recordings, SSE).
client_max_body_size 4096m;
ddiff --git a/deploy/nginx/openvibe.media.conf b/deploy/nginx/openvibe.media.conf
iindex e5f0f13..dcadfa7 100644
---- a/deploy/nginx/openvibe.media.conf
++++ b/deploy/nginx/openvibe.media.conf
@@@ -12,6 +12,7 @@ server {
ssl_certificate /etc/letsencrypt/live/openvibe.media/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.media/privkey.pem;
++ include snippets/security-txt.conf;
client_max_body_size 4096m;
proxy_read_timeout 3600s;
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex cddf0fc..e7a217c 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -776,6 +776,10 @@ publicRouter.get('/:id', async (req, res) => {
// The origin shield (placement/shield.js): a public copy on a shielded provider is served by nginx through its
// slice cache, with a long internal presign that never reaches the viewer (only on the DNS-only edge host).
const shield = require('../placement/shield');
++ if (shield.enabled(provider) && !sandbox && obj.visibility !== 'private' && !shield.onEdge(req)) {
++ const edge = shield.toEdge(req, provider);
++ if (edge) { res.set('Cache-Control', 'private, max-age=0'); res.set('X-Robots-Tag', 'noindex'); return res.redirect(302, edge); }
++ }
if (shield.enabled(provider) && shield.onEdge(req) && !sandbox && obj.visibility !== 'private') {
const inner = await require('../vod/vod-storage').presignGet(provider, loc.key, shield.SHIELD_TTL_SECONDS, { contentType: mime, contentDisposition: headers['Content-Disposition'] }).catch(() => null);
const t = inner && shield.target({ provider, url: inner, visibility: obj.visibility, sandbox, edge: shield.edgeOf(req) });
ddiff --git a/server/placement/shield.js b/server/placement/shield.js
iindex 188c41e..9218881 100644
---- a/server/placement/shield.js
++++ b/server/placement/shield.js
@@@ -26,6 +26,10 @@
* response fetches later slices with it), the viewer-facing 302 keeps its short TTL.
*
* onEdge(req) → true on the edge host (check it before presigning anything)
++ * toEdge(req, provider) → 'https://<edge><same path>' for a shield-eligible read that came in
++ * on openvibe.media, else null: viewers keep requesting the one
++ * public URL and a tiny 302 through Cloudflare moves them to the
++ * edge, where the bytes come from the shield
* target({ provider, url, visibility, sandbox, edge }) → '/_media_shield/b2/<bucket>/<key>?<signed query>' | null
* send(res, target, headers) → the 200 + X-Accel-Redirect answer
* await purge({ provider, key, size, presign }) → refreshes every cached slice of a deleted or replaced object
@@@ -50,6 +54,14 @@ function edgeOf(req) { const v = req && typeof req.get === 'function' ? req.get(
/** True when this request came in on the edge host: routes check it before presigning anything for the shield. */
function onEdge(req) { const want = edgeHost(); return !!want && edgeOf(req) === want; }
++/** Where to send a shield-eligible read that arrived on the proxied host (callers check visibility and sandbox). */
++function toEdge(req, provider) {
++ const edge = edgeHost();
++ if (!edge || !enabled(provider) || onEdge(req)) return null;
++ const p = String((req && (req.originalUrl || req.url)) || '');
++ return p.startsWith('/') && !p.startsWith('//') ? `https://${edge}${p}` : null;
++}
++
/** The internal location for one presigned URL, or null when this read must not go through the shield. `edge` is
* edgeOf(req): the shield answers only on MEDIA_SHIELD_HOST (a DNS-only edge name), never through Cloudflare. */
function target({ provider, url, visibility = 'public', sandbox = false, edge = null }) {
@@@ -101,4 +113,4 @@ async function purge({ provider, key, size, presign, fetchImpl = globalThis.fetc
return { purged, failed, slices };
}
--module.exports = { target, send, purge, enabled, providers, onEdge, edgeOf, SLICE_BYTES, SHIELD_TTL_SECONDS, B2_UPSTREAM_HOST, EDGE_HEADER };
++module.exports = { target, send, purge, enabled, providers, onEdge, edgeOf, toEdge, SLICE_BYTES, SHIELD_TTL_SECONDS, B2_UPSTREAM_HOST, EDGE_HEADER };
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 9cddf6a..75e7ab6 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -178,7 +178,10 @@ async function serveMediaRecord(kind, record, req, res) {
// its slice cache with a long internal presign (only on the DNS-only edge host); everything else keeps the
// short-lived 302.
const shield = require('../placement/shield');
-- if (shield.enabled(plan.provider) && shield.onEdge(req) && visibility !== 'private' && !record.is_recording) {
++ const shieldable = shield.enabled(plan.provider) && visibility !== 'private' && !record.is_recording;
++ const edge = shieldable && shield.toEdge(req, plan.provider);
++ if (edge) { res.set('Cache-Control', 'private, max-age=0'); res.set('X-Robots-Tag', 'noindex'); return res.redirect(302, edge); }
++ if (shieldable && shield.onEdge(req)) {
const url = await vodStorage.presignGet(plan.provider, vodStorage.keyForVod(record), shield.SHIELD_TTL_SECONDS).catch(() => null);
const t = url && shield.target({ provider: plan.provider, url, visibility, edge: shield.edgeOf(req) });
if (t) return shield.send(res, t, { 'Cache-Control': visibility === 'public' ? 'public, max-age=300' : 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
ddiff --git a/test/placement-shield.test.js b/test/placement-shield.test.js
iindex 0820138..8102b0d 100644
---- a/test/placement-shield.test.js
++++ b/test/placement-shield.test.js
@@@ -29,6 +29,12 @@ const assert = require('assert');
assert.strictEqual(shield.onEdge(req({ 'x-media-shield-host': 'edge.openvibe.media' })), true);
assert.strictEqual(shield.onEdge(req({ host: 'edge.openvibe.media', 'x-forwarded-host': 'edge.openvibe.media' })), false, 'a client-chosen Host or X-Forwarded-Host is not the edge');
assert.strictEqual(shield.onEdge(req({ 'x-media-shield-host': 'openvibe.media' })), false);
++ // A read on the proxied host is sent to the same path on the edge; on the edge it is served, never redirected again.
++ const on = (h, url) => ({ ...req(h), originalUrl: url });
++ assert.strictEqual(shield.toEdge(on({}, '/v/abc.mp4?t=1'), 'b2'), 'https://edge.openvibe.media/v/abc.mp4?t=1');
++ assert.strictEqual(shield.toEdge(on({ 'x-media-shield-host': 'edge.openvibe.media' }, '/v/abc.mp4'), 'b2'), null, 'no redirect loop on the edge');
++ assert.strictEqual(shield.toEdge(on({}, '/v/abc.mp4'), 'r2'), null, 'R2 is not shielded');
++ assert.strictEqual(shield.toEdge(on({}, '//evil.example/x'), 'b2'), null, 'never a scheme-relative target');
// The nginx side of the same rules (the config is what enforces them on the host).
const fs = require('fs'), path = require('path');