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).
Changes
@@@ -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;@@@ -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;@@@ -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) });@@@ -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.fetcreturn { 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 };@@@ -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' });@@@ -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');
@@ -15,6 +15,7 @@ server {
@@ -15,6 +15,7 @@ server {
server_name edge.openvibe.media;
server_name edge.openvibe.media;
ssl_certificate /etc/letsencrypt/live/edge.openvibe.media/fullchain.pem;
ssl_certificate /etc/letsencrypt/live/edge.openvibe.media/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/edge.openvibe.media/privkey.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).
# As on openvibe.media: large uploads, long Node responses (DVR, recordings, SSE).
client_max_body_size 4096m;
client_max_body_size 4096m;
@@ -12,6 +12,7 @@ server {
@@ -12,6 +12,7 @@ server {
ssl_certificate /etc/letsencrypt/live/openvibe.media/fullchain.pem;
ssl_certificate /etc/letsencrypt/live/openvibe.media/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.media/privkey.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.media/privkey.pem;
+ include snippets/security-txt.conf;
client_max_body_size 4096m;
client_max_body_size 4096m;
proxy_read_timeout 3600s;
proxy_read_timeout 3600s;
@@ -776,6 +776,10 @@ publicRouter.get('/:id', async (req, res) => {
@@ -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
// 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).
// slice cache, with a long internal presign that never reaches the viewer (only on the DNS-only edge host).
const shield = require('../placement/shield');
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') {
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 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) });
const t = inner && shield.target({ provider, url: inner, visibility: obj.visibility, sandbox, edge: shield.edgeOf(req) });
@@ -26,6 +26,10 @@
@@ -26,6 +26,10 @@
* response fetches later slices with it), the viewer-facing 302 keeps its short TTL.
* 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)
* 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
* target({ provider, url, visibility, sandbox, edge }) → '/_media_shield/b2/<bucket>/<key>?<signed query>' | null
* send(res, target, headers) → the 200 + X-Accel-Redirect answer
* 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
* 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(
@@ -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. */
/** 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; }
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
/** 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. */
* 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 }) {
function target({ provider, url, visibility = 'public', sandbox = false, edge = null }) {
@@ -101,4 +113,4 @@ async function purge({ provider, key, size, presign, fetchImpl = globalThis.fetc
@@ -101,4 +113,4 @@ async function purge({ provider, key, size, presign, fetchImpl = globalThis.fetc
return { purged, failed, slices };
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 };
@@ -178,7 +178,10 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -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
// its slice cache with a long internal presign (only on the DNS-only edge host); everything else keeps the
// short-lived 302.
// short-lived 302.
const shield = require('../placement/shield');
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 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) });
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' });
if (t) return shield.send(res, t, { 'Cache-Control': visibility === 'public' ? 'public, max-age=300' : 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
@@ -29,6 +29,12 @@ const assert = require('assert');
@@ -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({ '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({ 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);
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).
// The nginx side of the same rules (the config is what enforces them on the host).
const fs = require('fs'), path = require('path');
const fs = require('fs'), path = require('path');