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)
Changes
@@@ -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;@@@ -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' });nnew file mode 100644@@@ -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); });@@@ -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');}
@@ -83,6 +83,22 @@ async function withUserFields(clip) {
@@ -83,6 +83,22 @@ async function withUserFields(clip) {
return 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
// 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
// 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
// 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) => {
@@ -211,10 +227,22 @@ router.get('/', async (req, res) => {
// ── Clip detail ──────────────────────────────────────────────
// ── Clip detail ──────────────────────────────────────────────
// The player's file source: 302 to Media's clip route by id (Media applies the clip's visibility there).
// 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);
const id = Number(req.params.id);
if (!Number.isSafeInteger(id) || id <= 0) return res.status(400).json({ error: 'Bad clip id' });
if (!Number.isSafeInteger(id) || id <= 0) return res.status(400).json({ error: 'Bad clip id' });
res.set('Cache-Control', 'private, max-age=0');
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));
res.redirect(302, media.clipUrl(id));
});
});
@@ -269,6 +297,9 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -269,6 +297,9 @@ router.get('/:id', optionalAuth, async (req, res) => {
}
}
} catch { /* */ }
} 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 (clip.stream_id) {
if (stream) {
if (stream) {
clip.stream_started_at = stream.started_at;
clip.stream_started_at = stream.started_at;
@@ -462,6 +462,23 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {
@@ -462,6 +462,23 @@ router.get('/:id/memories', optionalAuth, async (req, res) => {
* and cards. Cached 2 min.
* and cards. Cached 2 min.
*/
*/
const _ctxCache = new Map();
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) => {
router.get('/:id/context', optionalAuth, async (req, res) => {
try {
try {
if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'VOD not found' });
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) => {
@@ -479,7 +496,12 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
const cacheHeader = isPrivate ? cache.htmlHeaders({ private: true }) : 'public, max-age=60';
const cacheHeader = isPrivate ? cache.htmlHeaders({ private: true }) : 'public, max-age=60';
const hit = _ctxCache.get(id);
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 stream = vod.stream_id ? await db.getStreamById(vod.stream_id) : null;
const streamer = await db.getUserById(vod.user_id || (stream && stream.user_id));
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; } };
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) => {
@@ -506,9 +528,15 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
}
}
let clips = [];
let clips = [];
try {
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 }));
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 = []; }
} 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;
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; }
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 = {
const data = {
@@ -519,8 +547,8 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
@@ -519,8 +547,8 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
};
};
_ctxCache.set(id, { at: Date.now(), data });
_ctxCache.set(id, { at: Date.now(), data });
if (_ctxCache.size > 500) { const k = _ctxCache.keys().next().value; _ctxCache.delete(k); }
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) {
} catch (err) {
console.error('[VODs] context error:', err.message);
console.error('[VODs] context error:', err.message);
res.status(500).json({ error: 'Failed to load context' });
res.status(500).json({ error: 'Failed to load context' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -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); });
@@ -234,13 +234,16 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
@@ -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 ──
// ── 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 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');
assert(h, 'the VOD context route should exist');
const checkAt = h[0].indexOf('access.canView(req.user, vod)');
const checkAt = h[0].indexOf('access.canView(req.user, vod)');
const cacheAt = h[0].indexOf('_ctxCache.get(id)');
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 > 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(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');
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');
ok('private VOD context is authorised before the cache, and never shareable');
}
}