openvibers/openvibe.live Commits

A clip of a stream recorded in parts: the recording's page lists it, and a private clip's bytes are reachable (#445)

OP OpenVibers committed 15 days ago · 5216a2f794 · 4 files, +218 −6

* A clip of a stream recorded in parts: the recording's page lists it, and a private clip's bytes are reachable

Clip 505 (stream 2795, cut from part 2 = VOD 6026 while the recording's page is part 1 = VOD 6025) answered 404. Two lines did it: GET /api/vods/:id/context listed only the clips of that one VOD, so the recording's page listed none of the stream's clips, and a private clip's playback URL was Media's plain /c/:id, which Media answers with the missing-clip 404 for every reader but the owning app (only a signed URL opens a private clip's bytes) - so the clip's own player URL was a 404 for its owner and for staff too.

The context route now lists the whole stream's clips and, for the streamer and staff, the stream's private ones (never in the shared cache). A private clip's file URL and playback URL are signed for whoever may see it; a stranger still gets the missing-clip answer.

* VOD context: an answer with the viewer's private clips is private from the cache too; the redaction test reads the route to its catch

On a cache hit the route added the streamer's private clips but kept the public VOD's 'public, max-age=60', so a shared cache (Cloudflare ignores Vary) could keep one person's private clip list for everyone. Both paths now send private, no-store when the answer carries private clips, and security-redaction.test.js checks it (the route no longer ends in res.json(data)).

Changes

ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 7c1e2285..21eed0ea 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -83,6 +83,22 @@ async function withUserFields(clip) {
return clip;
}
++/**
++ * A private clip's playback URL, signed: Media answers an unsigned /c/:id with the missing-clip 404 (only the
++ * owning app, the owner's JWT or a signed URL may read a private clip's bytes), so the player had nothing to load.
++ */
++async function signedPlayback(clip) {
++ try {
++ const signed = await media.signedMediaUrl('clip', clip.id, 900);
++ if (signed && signed.url) {
++ const url = media.publicUrl(signed.url);
++ clip.playback_url = url;
++ clip.file_url = url;
++ }
++ } catch { /* best effort: the plain URL still serves a public clip */ }
++ return clip;
++}
++
// The clipped channel's owner (Live user id), from the canonical resolver (server/lineage): the
// clip's stream, else its VOD's lineage; null when neither answers or Media is down. Kept to those
// two links as before, so the channel Media recorded on the clip (legacy_metadata) does not decide
@@@ -211,10 +227,22 @@ router.get('/', async (req, res) => {
// ── Clip detail ──────────────────────────────────────────────
// The player's file source: 302 to Media's clip route by id (Media applies the clip's visibility there).
--router.get('/:id/file', (req, res) => {
++router.get('/:id/file', optionalAuth, async (req, res) => {
const id = Number(req.params.id);
if (!Number.isSafeInteger(id) || id <= 0) return res.status(400).json({ error: 'Bad clip id' });
res.set('Cache-Control', 'private, max-age=0');
++ // A private clip's bytes are signed for whoever may see it. Media answers an unsigned /c/:id with the
++ // missing-clip 404 (its private rule: only the owning app, the owner's JWT or a signed URL may read it),
++ // so the clip's own player URL 404ed for its owner and for staff too.
++ let clip = null;
++ try { clip = await media.getClip(id); } catch { /* missing, or Media is down */ }
++ if (clip && access.isPrivate(clip)) {
++ if (!await access.canView(req.user, clip)) return clipNotFound(res);
++ try {
++ const signed = await media.signedMediaUrl('clip', id, 900);
++ if (signed && signed.url) return res.redirect(302, media.publicUrl(signed.url));
++ } catch { /* fall back to the plain URL */ }
++ }
res.redirect(302, media.clipUrl(id));
});
@@@ -269,6 +297,9 @@ router.get('/:id', optionalAuth, async (req, res) => {
}
} catch { /* */ }
++ // A private clip's bytes are signed (see signedPlayback): the plain /c/:id is a 404 for everyone.
++ if (access.isPrivate(clip)) await signedPlayback(clip);
++
if (clip.stream_id) {
if (stream) {
clip.stream_started_at = stream.started_at;
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 6e283938..29d2dec2 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -462,6 +462,23 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {
* and cards. Cached 2 min.
*/
const _ctxCache = new Map();
++
++/**
++ * The private clips of a stream's recording, for someone who may see them (its streamer, staff). Media's public
++ * clip list leaves private rows out, so a clip cut from a later part of a multi-part stream (stream 2795: VODs
++ * 6025 and 6026) was missing from the recording's page for everyone but the app that owns it.
++ */
++async function privateStreamClips(req, vod) {
++ if (!vod || !vod.stream_id) return [];
++ if (!await access.canSeePrivate(req.user, vod)) return [];
++ try {
++ const co = await media.listClips({ stream_id: vod.stream_id, include_private: 1, order: 'views', limit: 24 });
++ return (co?.clips || (Array.isArray(co) ? co : []))
++ .filter((c) => c.visibility === 'private' && (c.status === 'ready' || !c.status))
++ .map((c) => ({ id: c.id, title: c.title, thumbnail_url: c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : null, duration_seconds: c.duration_seconds || c.duration || 0, start_time: c.start_time, view_count: Number(c.view_count) || 0, by: c.display_name || c.username || null, created_at: c.created_at }));
++ } catch { return []; }
++}
++
router.get('/:id/context', optionalAuth, async (req, res) => {
try {
if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });
@@@ -479,7 +496,12 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
const cacheHeader = isPrivate ? cache.htmlHeaders({ private: true }) : 'public, max-age=60';
const hit = _ctxCache.get(id);
-- if (hit && Date.now() - hit.at < 120000) { res.set('Cache-Control', cacheHeader); return res.json(hit.data); }
++ if (hit && Date.now() - hit.at < 120000) {
++ const priv = await privateStreamClips(req, vod);
++ // An answer with someone's private clips in it is theirs alone: never a shareable cache entry.
++ res.set('Cache-Control', priv.length ? cache.htmlHeaders({ private: true }) : cacheHeader);
++ return res.json(priv.length ? { ...hit.data, clips: (hit.data.clips || []).concat(priv) } : hit.data);
++ }
const stream = vod.stream_id ? await db.getStreamById(vod.stream_id) : null;
const streamer = await db.getUserById(vod.user_id || (stream && stream.user_id));
const safe = async (fn, d) => { try { const v = await fn(); return v == null ? d : v; } catch { return d; } };
@@@ -506,9 +528,15 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
}
let clips = [];
try {
-- const co = await media.listClips({ vod_id: id, order: 'views', limit: 12 });
++ // The whole stream's clips, not only this VOD's: a stream recorded in parts is one video whose
++ // page is its first part's VOD, and a clip cut from a later part (stream 2795: VOD 6025 and 6026)
++ // belongs to that page (the same list the VOD detail route answers, by stream).
++ const co = await media.listClips(vod.stream_id ? { stream_id: vod.stream_id, order: 'views', limit: 12 } : { vod_id: id, order: 'views', limit: 12 });
clips = (co?.clips || (Array.isArray(co) ? co : [])).filter(c => c.status === 'ready' || !c.status).map(c => ({ id: c.id, title: c.title, thumbnail_url: c.thumbnail_url ? media.publicUrl(c.thumbnail_url) : null, duration_seconds: c.duration_seconds || c.duration || 0, start_time: c.start_time, view_count: Number(c.view_count) || 0, by: c.display_name || c.username || null, created_at: c.created_at }));
} catch { clips = []; }
++ // The recording's private clips: Media's public list has none of them, and the streamer (or staff)
++ // must still find them on the recording's page. They never go into the shared cache below.
++ const privateClips = await privateStreamClips(req, vod);
let recap = null;
try { const r = stream ? await require('../recap/recap').getRecap(stream.id) : null; if (r) recap = { stream_id: stream.id, grade: r.write.grade, headline: r.write.headline, moment: r.write.moment || null }; } catch { recap = null; }
const data = {
@@@ -519,8 +547,8 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
};
_ctxCache.set(id, { at: Date.now(), data });
if (_ctxCache.size > 500) { const k = _ctxCache.keys().next().value; _ctxCache.delete(k); }
-- res.set('Cache-Control', cacheHeader);
-- res.json(data);
++ res.set('Cache-Control', privateClips.length ? cache.htmlHeaders({ private: true }) : cacheHeader);
++ res.json(privateClips.length ? { ...data, clips: (data.clips || []).concat(privateClips) } : data);
} catch (err) {
console.error('[VODs] context error:', err.message);
res.status(500).json({ error: 'Failed to load context' });
ddiff --git a/test/clip-multi-part.test.js b/test/clip-multi-part.test.js
nnew file mode 100644
iindex 00000000..987158f4
---- /dev/null
++++ b/test/clip-multi-part.test.js
@@@ -0,0 +1,150 @@
++/**
++ * A clip of a stream recorded in parts: the recording's page lists it, and a private clip's bytes are reachable.
++ *
++ * Clip 505 (stream 2795, cut from part 2 = VOD 6026 while the recording's page is part 1 = VOD 6025) answered
++ * 404: the VOD context route listed only the clips of that one VOD (so the recording's page listed none), and a
++ * private clip's playback URL was Media's plain /c/:id, which Media answers with the missing-clip 404 for every
++ * reader but the owning app (only a signed URL opens a private clip's bytes).
++ *
++ * The real routers run on the test database with Media stubbed in-process and sign-in stubbed by an
++ * `x-test-user` header (as in media-privacy.test.js).
++ *
++ * node test/clip-multi-part.test.js
++ */
++'use strict';
++const assert = require('assert');
++const http = require('http');
++
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); quiet(' \u2713', name); }
++ catch (e) { failures++; quiet(' \u2717', name, '\n ', e.message); }
++}
++
++(async () => {
++ const db = require('../server/db/database');
++ await db.initDb();
++ const raw = db.getDb();
++
++ const auth = require('../server/auth/auth');
++ const signIn = async (req) => {
++ const id = Number(req.headers['x-test-user'] || 0);
++ const u = id ? await db.getUserById(id) : null;
++ if (u) { req.user = u; req.authSource = 'network'; }
++ return u;
++ };
++ auth.requireAuth = async (req, res, next) => ((await signIn(req)) ? next() : res.status(401).json({ error: 'Authentication required' }));
++ auth.optionalAuth = async (req, res, next) => { await signIn(req); next(); };
++
++ const addUser = (id, username, role) => raw.prepare(
++ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE
++ VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);
++ await addUser(1, 'admin', 'admin');
++ await addUser(3, 'alice', 'streamer'); // the streamer: her channel's clips, her recording
++ await addUser(5, 'carol', 'user'); // a viewer who cut a clip of alice's stream
++ await addUser(7, 'mallory', 'user');
++ await db.ensureChannel(3);
++ const chanA = await db.getChannelByUserId(3);
++ const streamA = Number((await db.createStream({ user_id: 3, channel_id: chanA.id, title: 'A', protocol: 'webrtc' })).lastInsertRowid);
++
++ // ── Media stub: a stream recorded in parts (6025 = part 1, the recording's page; 6026 = part 2) ──
++ const media = require('../server/media-client');
++ const VODS = {
++ 6025: { id: 6025, user_id: 3, stream_id: streamA, title: 'Part 1', visibility: 'public', is_public: 1, status: 'ready', duration_seconds: 679 },
++ 6026: { id: 6026, user_id: 3, stream_id: streamA, title: 'Part 2', visibility: 'public', is_public: 1, status: 'ready', duration_seconds: 1921 },
++ };
++ // Clip 505: cut from part 2, private (the clipper's own).
++ const CLIPS = {
++ 505: { id: 505, user_id: 5, channel_user_id: 3, stream_id: streamA, vod_id: 6026, title: 'A moment', visibility: 'private', is_public: 0, status: 'ready', start_time: 871.5, end_time: 901.499, duration_seconds: 29.999 },
++ 506: { id: 506, user_id: 5, channel_user_id: 3, stream_id: streamA, vod_id: 6025, title: 'A public moment', visibility: 'public', is_public: 1, status: 'ready' },
++ };
++ const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });
++ media.getVod = async (id) => { const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; };
++ media.getClip = async (id) => { const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };
++ media.listClips = async (q) => {
++ const all = Object.values(CLIPS).map((c) => ({ ...c }));
++ const out = all.filter((c) => {
++ if (q.vod_id != null && Number(c.vod_id) !== Number(q.vod_id)) return false;
++ if (q.stream_id != null && Number(c.stream_id) !== Number(q.stream_id)) return false;
++ if (!q.include_private && c.visibility === 'private') return false;
++ return true;
++ });
++ return { clips: out, total: out.length };
++ };
++ media.listVods = async () => ({ vods: [] });
++ media.signedMediaUrl = async (kind, id) => ({ url: `/c/${id}?exp=9999999999&sig=stub`, expires_at: '9999-01-01T00:00:00Z' });
++ media.updateClip = async () => ({});
++
++ const express = require('express');
++ const app = express();
++ app.use(express.json());
++ app.use('/api/vods', require('../server/media-proxy/vods'));
++ app.use('/api/clips', require('../server/media-proxy/clips'));
++ const server = http.createServer(app).listen(0);
++ if (!server.listening) await new Promise((r) => server.once('listening', r));
++
++ let ipSeq = 0;
++ function call(method, p, user, body) {
++ return new Promise((resolve, reject) => {
++ const data = body ? JSON.stringify(body) : null;
++ const headers = { 'content-type': 'application/json', 'cf-connecting-ip': `10.0.0.${++ipSeq}` };
++ if (user) headers['x-test-user'] = String(user);
++ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {
++ let text = '';
++ res.on('data', (c) => { text += c; });
++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json, text, headers: res.headers }); });
++ });
++ req.on('error', reject);
++ if (data) req.write(data);
++ req.end();
++ });
++ }
++
++ await check('the recording\u2019s page (part 1) lists the clip cut from part 2', async () => {
++ const r = await call('GET', '/api/vods/6025/context', 3);
++ assert.strictEqual(r.status, 200, `context → ${r.status} ${r.text}`);
++ const ids = (r.json?.clips || []).map((c) => Number(c.id));
++ assert.ok(ids.includes(505), `clip 505 must be listed on the recording\u2019s page, got ${JSON.stringify(ids)}`);
++ });
++
++ await check('a stranger does not see the recording\u2019s private clip', async () => {
++ const r = await call('GET', '/api/vods/6025/context', 7);
++ assert.strictEqual(r.status, 200, `context → ${r.status}`);
++ const ids = (r.json?.clips || []).map((c) => Number(c.id));
++ assert.ok(!ids.includes(505), `a private clip must not be listed to a stranger, got ${JSON.stringify(ids)}`);
++ });
++
++ await check('a private clip\u2019s file URL is signed for the streamer (it was a 404)', async () => {
++ const r = await call('GET', '/api/clips/505/file', 3);
++ assert.strictEqual(r.status, 302, `file → ${r.status}`);
++ const loc = String(r.headers.location || '');
++ assert.ok(/[?&]sig=/.test(loc), `the owner must get a signed URL, got ${loc}`);
++ });
++
++ await check('a private clip\u2019s file URL is not handed to someone else', async () => {
++ const r = await call('GET', '/api/clips/505/file', 7);
++ assert.strictEqual(r.status, 404, `a stranger must get the missing-clip answer, got ${r.status}`);
++ });
++
++ await check('a private clip\u2019s detail carries a signed playback URL', async () => {
++ const r = await call('GET', '/api/clips/505', 3);
++ assert.strictEqual(r.status, 200, `clip → ${r.status} ${r.text}`);
++ const clip = r.json?.clip || r.json || {};
++ assert.ok(/[?&]sig=/.test(String(clip.playback_url || '')), `playback_url must be signed, got ${clip.playback_url}`);
++ });
++
++ await check('a public clip keeps its plain file URL', async () => {
++ const r = await call('GET', '/api/clips/506/file');
++ assert.strictEqual(r.status, 302, `file → ${r.status}`);
++ assert.ok(!/[?&]sig=/.test(String(r.headers.location || '')), 'a public clip needs no signature');
++ });
++
++ server.close();
++ quiet(failures ? `\n${failures} check(s) failed\n` : '\nall checks passed\n');
++ process.exit(failures ? 1 : 0);
++})().catch((e) => { quiet('test error:', e && e.stack || e); process.exit(1); });
ddiff --git a/test/security-redaction.test.js b/test/security-redaction.test.js
iindex 35ad9df5..b271ce50 100644
---- a/test/security-redaction.test.js
++++ b/test/security-redaction.test.js
@@@ -234,13 +234,16 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
// ── A private VOD's context must be authorised before the cache is consulted ──
{
const src = fs.readFileSync(path.join(ROOT, 'server/media-proxy/vods.js'), 'utf8');
-- const h = src.match(/router\.get\('\/:id\/context'[\s\S]*?res\.json\(data\);/);
++ const h = src.match(/router\.get\('\/:id\/context'[\s\S]*?\} catch \(err\) \{/);
assert(h, 'the VOD context route should exist');
const checkAt = h[0].indexOf('access.canView(req.user, vod)');
const cacheAt = h[0].indexOf('_ctxCache.get(id)');
assert(checkAt > 0 && cacheAt > 0, 'both the visibility check and the cache read should be present');
assert(checkAt < cacheAt, 'the visibility check must run before the cache is read');
assert(/cache\.htmlHeaders\(\{ private: true \}\)/.test(h[0]) && require('openvibe-shared/cache-policy').htmlHeaders({ private: true }) === 'private, no-store', 'a private VOD context must not be sent with a shareable Cache-Control');
++ // A public VOD's answer that carries the viewer's private clips (the streamer, staff) is private too, from the cache or not.
++ const priv = h[0].match(/privateStreamClips\(req, vod\)[\s\S]*?res\.set\('Cache-Control', ([^;]+)\);/g) || [];
++ assert(priv.length === 2 && priv.every((x) => /\.length \? cache\.htmlHeaders\(\{ private: true \}\)/.test(x)), 'an answer with private clips is never shareable (cache hit and miss)');
ok('private VOD context is authorised before the cache, and never shareable');
}