openvibers/openvibe.media Commits
Private and unlisted recordings' thumbnails are no longer listed on the media index (roadmap WS-R task 5; high): the Thumbnails tab of / and /browse listed every file in the thumbnails directory, so a frame of every private or unlisted VOD and clip was published with its exact file name (which /t/<name> serves, as the capability /api/thumbnails already relies on) and its id, linked to the recording's page. A vod-<id>-/clip-<id>- thumbnail is now listed only while that recording is public (public visibility, is_public, not clips-only); live stream thumbnails and other names as before. test/security-private.test.js extends test/private-not-found.test.js to the paths it did not cover: every tab of the index, /sitemap.xml, /llms.txt and /updates list nothing private or unlisted; the v1 VOD and clip lists and latest-thumbs acting for another user (include_private ignored), the v2 object and job lists; every v2 object read (metadata, download, holds, jobs) by another user, another app through its own tenant and through this one, another developer project and nobody gets exactly the unknown-id answer; /v and /c with another app's key, the /p/:slug page, projected VOD and thumbnail objects on /o; /o and sandbox /f answer an expired, extended, tampered or other object's signature as an unknown id. Checked by breaking it: the previous browse.js fails it.
Changes
@@@ -102,13 +102,29 @@ const C_WHERE = "COALESCE(is_public,1) = 1";const F_WHERE = "app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)";const P_WHERE = "visibility = 'public'";++// A VOD's or clip's thumbnail is listed only while the recording itself is public. The exact file name++// is what /t/<name> serves, so listing a private or unlisted recording's thumbnail would publish a frame++// of it (and its id) to anyone reading the index.++const THUMB_PUBLIC = {++ vod: "is_public = 1 AND COALESCE(visibility, 'public') = 'public' AND COALESCE(clips_only, 0) = 0",++ clip: "COALESCE(is_public, 1) = 1 AND COALESCE(visibility, 'public') = 'public'",++};++function thumbListable(name, publicIds) {++ const m = /^(vod|clip)-(\d+)-/i.exec(name);++ return !m || publicIds[m[1].toLowerCase()].has(Number(m[2]));++}++let _thumbListCache = { at: 0, list: [] };function thumbList() {if (Date.now() - _thumbListCache.at < 60_000) return _thumbListCache.list;let list = [];try {++ const publicIds = {++ vod: new Set(db.all(`SELECT id FROM vods WHERE ${THUMB_PUBLIC.vod}`).map(r => r.id)),++ clip: new Set(db.all(`SELECT id FROM clips WHERE ${THUMB_PUBLIC.clip}`).map(r => r.id)),++ };list = fs.readdirSync(THUMB_DIR)-- .filter(f => /\.(jpe?g|png|webp)$/i.test(f))++ .filter(f => /\.(jpe?g|png|webp)$/i.test(f) && thumbListable(f, publicIds)).map(f => { try { return [f, fs.statSync(path.join(THUMB_DIR, f)).mtimeMs]; } catch { return null; } }).filter(Boolean).sort((a, b) => b[1] - a[1]);nnew file mode 100644@@@ -0,0 +1,251 @@++'use strict';++// Private and unlisted media stay where they belong (roadmap WS-R task 5). Extends++// test/private-not-found.test.js (which covers /v /c /o /p/:slug/raw, transcript.json, the legacy++// /api/thumbnails redirect and the v1 detail routes for an acting non-owner) to the paths it does not:++// - public lists: the media index (/ and /browse, every tab, thumbnails included), /sitemap.xml,++// /llms.txt, the v1 VOD/clip lists and latest-thumbs for a caller acting for another user, the v2++// object and job lists: never a private or unlisted item (sitemap and Search documents in depth:++// test/crawl.test.js);++// - every v2 object read (metadata, download, holds, jobs) for another user, another app (on its own++// tenant and on this one), another developer project, and no credential: the answer an unknown id gets;++// - /v and /c with another app's key, /p/:slug (the page), projected VOD and thumbnail objects on /o;++// - signatures: /o and /f answer an expired, tampered or other object's signature exactly as they++// answer an unknown id; sandbox objects and files need one even when "public".++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-secprivate-'));++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++process.env.DB_PATH = path.join(tmp, 'media.db');++process.env.VOD_PATH = dir('vods');++process.env.CLIPS_PATH = dir('clips');++process.env.FILES_PATH = dir('files');++process.env.OBJECTS_PATH = dir('objects');++process.env.THUMBNAILS_PATH = dir('thumbnails');++process.env.PASTES_PATH = dir('pastes');++process.env.ASSETS_PATH = dir('assets');++process.env.OV_NETWORK_URL = 'https://openvibe.network';++process.env.MEDIA_PUBLIC_URL = 'https://media.test';++process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';++for (const k of ['PASTES_MOVED_TO', 'EVENTS_URL', 'MEDIA_B2_ENDPOINT', 'MEDIA_R2_ENDPOINT']) process.env[k] = '';++++const db = require('../server/db/database');++require('../server/views/service').ensureSchema();++const auth = require('../server/auth');++const signing = require('../server/objects/signing');++const queue = require('../server/jobs/queue');++const { serviceAuth, ids } = require('openvibe-contracts');++const express = require('express');++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++auth._setNetworkPublicKeyForTests(keys.publicKey);++db.upsertApp({ app_id: 'live', api_key: 'live-key' });++db.upsertApp({ app_id: 'games', api_key: 'games-key' });++const raw = db.getDb();++const now = Math.floor(Date.now() / 1000);++const PA = `prj_${ids.ulid()}`, PB = `prj_${ids.ulid()}`;++const appTok = (project, env = 'production') => serviceAuth.signServiceToken({++ iss: 'https://openvibe.network', sub: `app:app_${ids.ulid()}`, actor_type: 'app', aud: ['openvibe.media'], cap: ['media.object.upload', 'media.object.read', 'media.object.list'],++ ns: [project], project_id: project, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`,++}, keys.privateKey);++++// ── VODs, clips and their thumbnails (user 5 owns them all) ──++const thumb = (name) => { fs.writeFileSync(path.join(process.env.THUMBNAILS_PATH, name), Buffer.from('ffd8ffe000104a464946', 'hex')); return `/t/${name}`; };++const media = (d, name) => { const p = path.join(d, name); fs.writeFileSync(p, Buffer.alloc(64, 1)); return p; };++const vod = (title, visibility, extra = {}) => {++ const id = Number(db.createVod({ app_id: 'live', user_id: 5, title, file_path: media(process.env.VOD_PATH, `${title.replace(/\W/g, '')}.webm`), duration_seconds: 10, visibility, ...extra }).lastInsertRowid);++ return id;++};++const pubVod = vod('Public VOD', 'public', { managed_stream_id: 7 });++const privVod = vod('Secret VOD title', 'private', { managed_stream_id: 7 });++const unlVod = vod('Unlisted VOD title', 'unlisted', { managed_stream_id: 8 });++raw.prepare("UPDATE vods SET created_at = datetime('now', ?) WHERE id = ?").run('-2 hours', pubVod); // the private one is the newest of stream 7++for (const [id, name] of [[pubVod, `vod-${pubVod}-111.jpg`], [privVod, `vod-${privVod}-222.jpg`], [unlVod, `vod-${unlVod}-666.jpg`]]) {++ db.withObject('vod', id, () => db.run('UPDATE vods SET thumbnail_url = ? WHERE id = ?', [thumb(name), id]));++}++const clip = (title, visibility, name) => Number(db.createClip({++ app_id: 'live', vod_id: pubVod, user_id: 5, title, file_path: media(process.env.CLIPS_PATH, `${title.replace(/\W/g, '')}.webm`),++ thumbnail_url: thumb(name), duration_seconds: 5, is_public: visibility === 'public' ? 1 : 0, visibility, status: 'ready',++}).lastInsertRowid);++const pubClip = clip('Public clip', 'public', 'clip-9001-1.jpg');++const privClip = clip('Secret clip title', 'private', 'clip-9002-2.jpg');++const unlClip = clip('Unlisted clip title', 'unlisted', 'clip-9003-3.jpg');++// The thumbnail file names carry the clip ids the rows got.++for (const [id, old] of [[pubClip, 'clip-9001-1.jpg'], [privClip, 'clip-9002-2.jpg'], [unlClip, 'clip-9003-3.jpg']]) {++ const name = `clip-${id}-${old.split('-')[2]}`;++ fs.renameSync(path.join(process.env.THUMBNAILS_PATH, old), path.join(process.env.THUMBNAILS_PATH, name));++ db.withObject('clip', id, () => db.run('UPDATE clips SET thumbnail_url = ? WHERE id = ?', [`/t/${name}`, id]));++}++const HIDDEN_TITLES = ['Secret VOD title', 'Unlisted VOD title', 'Secret clip title', 'Unlisted clip title'];++const HIDDEN_THUMBS = [`vod-${privVod}-222.jpg`, `vod-${unlVod}-666.jpg`, `clip-${privClip}-2.jpg`, `clip-${unlClip}-3.jpg`];++raw.prepare("INSERT INTO pastes (slug, app_id, user_id, title, type, content, language, visibility) VALUES ('priv-page', 'live', 5, 'Secret paste title', 'paste', 'secret text', 'text', 'private')").run();++++const app = express();++app.put('/api/v2/:app/objects/:id/content', ...require('../server/objects/routes').contentHandlers);++app.use(express.json());++app.use('/api/v1/:app/vods', require('../server/vod/routes'));++app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));++app.use('/api/v1/:app/files', require('../server/files/routes'));++app.use('/api/v2/:app/objects', require('../server/objects/routes'));++app.use('/api/v2/:app/jobs', require('../server/jobs/routes'));++app.use('/o', require('../server/objects/routes').publicRouter);++app.use('/', require('../server/public/routes'));++const server = http.createServer(app);++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const local = (u) => String(u).replace('https://media.test', '');++ const get = async (p, headers = {}, method = 'GET', body) => {++ const h = { ...headers };++ let payload = body;++ if (body !== undefined && !Buffer.isBuffer(body) && typeof body !== 'string') { h['content-type'] = 'application/json'; payload = JSON.stringify(body); }++ const res = await fetch(base + p, { method, headers: h, body: payload, redirect: 'manual' });++ const text = await res.text();++ let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }++ return { status: res.status, text, json, location: res.headers.get('location') };++ };++ const same = async (a, b, msg) => {++ const [x, y] = [await get(...a), await get(...b)];++ assert.deepStrictEqual([x.status, x.text, x.location], [y.status, y.text, y.location], msg);++ return x;++ };++ const key = (k) => ({ authorization: `Bearer ${k}` });++ const asUser = (id) => ({ ...key('live-key'), 'x-ov-user-id': String(id) });++ const bearer = (t) => ({ authorization: `Bearer ${t}` });++++ // Native objects: init → PUT bytes → complete.++ const mkObject = async (tenant, headers, visibility, body = 'bytes') => {++ let r = await get(`/api/v2/${tenant}/objects`, headers, 'POST', { kind: 'file', mime_type: 'text/plain', size_bytes: Buffer.byteLength(body), visibility, filename: 'x.txt' });++ assert.strictEqual(r.status, 201, r.text);++ const id = r.json.id;++ r = await get(local(r.json.upload.url), { 'content-type': 'text/plain' }, 'PUT', body);++ assert.strictEqual(r.status, 200, r.text);++ r = await get(`/api/v2/${tenant}/objects/${id}/complete`, headers, 'POST', {});++ assert.strictEqual(r.status, 200, r.text);++ return id;++ };++ const privObj = await mkObject('live', asUser(5), 'private');++ const unlObj = await mkObject('live', asUser(5), 'unlisted');++ const pubObj = await mkObject('live', asUser(5), 'public');++ const projObj = await mkObject(PA, bearer(appTok(PA)), 'private');++ const sandboxObj = await mkObject(PA, bearer(appTok(PA, 'sandbox')), 'public');++ const UNKNOWN = 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ';++ const job = queue.enqueue({ appId: 'live', type: 'thumbnail.regenerate', objectId: privObj, params: { kind: 'vod', id: privVod }, ownerUserId: 5, createdBy: 'app:live:user:5' }).job;++++ // ── 1. Public lists: nothing private or unlisted ──++ for (const p of ['/', '/browse', '/browse?tab=videos', '/browse?tab=clips', '/browse?tab=thumbnails', '/?tab=thumbnails&page=1', '/sitemap.xml', '/llms.txt', '/updates']) {++ const r = await get(p);++ assert.ok(r.status < 500, `${p} answers`);++ for (const t of [...HIDDEN_TITLES, ...HIDDEN_THUMBS, 'Secret paste title']) assert.ok(!r.text.includes(t), `${p} lists ${t}`);++ if (/thumbnails/.test(p)) {++ assert.ok(r.text.includes(`vod-${pubVod}-111.jpg`) && r.text.includes(`clip-${pubClip}-1.jpg`), `${p}: public recordings' thumbnails are listed`);++ }++ }++ assert.ok((await get('/browse?tab=videos')).text.includes('Public VOD'), 'the index lists public VODs');++ console.log('✅ media index (every tab, thumbnails too), sitemap, llms.txt, updates: no private or unlisted item');++++ for (const who of [asUser(77), { ...asUser(77), 'x-include': '1' }]) {++ let r = await get('/api/v1/live/vods?include_private=1&include_recording=1', who);++ assert.deepStrictEqual(r.json.vods.map(v => v.id), [pubVod], 'acting for another user: public VODs only, include_private ignored');++ r = await get('/api/v1/live/clips?include_private=1', who);++ assert.deepStrictEqual(r.json.clips.map(c => c.id), [pubClip], 'acting for another user: public clips only');++ }++ let r = await get('/api/v1/live/vods/latest-thumbs?managed_stream_ids=7,8', asUser(77));++ assert.deepStrictEqual(Object.keys(r.json.thumbs), ['7'], 'no thumbnail for a stream whose only VOD is unlisted');++ assert.strictEqual(r.json.thumbs['7'].vod_id, pubVod, 'the newest PUBLIC VOD, not the newer private one');++ r = await get('/api/v1/live/vods?include_private=1', key('live-key'));++ assert.strictEqual(r.json.vods.length, 3, 'the app key itself may list everything (control)');++ r = await get('/api/v2/live/objects', asUser(77));++ assert.deepStrictEqual(r.json.objects.filter(o => [privObj, unlObj, pubObj].includes(o.id)).map(o => o.id).sort(), [pubObj, unlObj].sort(), 'v2 list for another user: public and unlisted (documented), never private');++ r = await get(`/api/v2/live/objects?visibility=private&owner=user:5&user_id=5`, asUser(77));++ assert.ok(!r.json.objects.some(o => o.id === privObj), 'no filter reaches another user\'s private objects');++ r = await get('/api/v2/live/jobs', asUser(77));++ assert.ok(!r.json.jobs.some(j => j.id === job.id), 'another user\'s jobs are not listed');++ r = await get('/api/v2/games/objects', key('games-key'));++ assert.ok(!r.json.objects.some(o => [privObj, unlObj, pubObj].includes(o.id)), 'another app lists none of this app\'s objects');++ r = await get(`/api/v2/${PB}/objects`, bearer(appTok(PB)));++ assert.deepStrictEqual(r.json.objects, [], 'another developer project lists nothing of this one');++ console.log('✅ v1 lists, latest-thumbs, v2 object and job lists: no private item of anyone else');++++ // ── 2. Every v2 object read: another user, another app, another project, nobody → the unknown-id answer ──++ const O = (tenant, id, rest = '') => `/api/v2/${tenant}/objects/${id}${rest}`;++ for (const rest of ['', '/download', '/download?format=json', '/download?redirect=1', '/holds', '/holds?all=1']) {++ r = await same([O('live', privObj, rest), asUser(77)], [O('live', UNKNOWN, rest), asUser(77)], `another user: private ${rest || 'metadata'} looks missing`);++ assert.strictEqual(r.status, 404);++ await same([O('games', privObj, rest), key('games-key')], [O('games', UNKNOWN, rest), key('games-key')], `another app on its own tenant: ${rest || 'metadata'}`);++ await same([O('games', pubObj, rest), key('games-key')], [O('games', UNKNOWN, rest), key('games-key')], `another app does not reach even a public object through its tenant: ${rest || 'metadata'}`);++ await same([O('live', privObj, rest), key('games-key')], [O('live', UNKNOWN, rest), key('games-key')], `another app's key on this tenant: ${rest || 'metadata'}`);++ await same([O('live', privObj, rest)], [O('live', UNKNOWN, rest)], `no credential: ${rest || 'metadata'}`);++ await same([O(PB, projObj, rest), bearer(appTok(PB))], [O(PB, UNKNOWN, rest), bearer(appTok(PB))], `another project: ${rest || 'metadata'}`);++ await same([O(PA, projObj, rest), bearer(appTok(PB))], [O(PA, UNKNOWN, rest), bearer(appTok(PB))], `another project's token on this project: ${rest || 'metadata'}`);++ }++ await same([`/api/v2/live/jobs/${job.id}`, asUser(77)], ['/api/v2/live/jobs/mjob_nosuchjob', asUser(77)], 'another user\'s job looks missing');++ assert.strictEqual((await get(O('live', privObj), asUser(5))).status, 200, 'the owner reads it (control)');++ assert.strictEqual((await get(O('live', unlObj), asUser(77))).status, 200, 'unlisted is readable by id (documented)');++ assert.strictEqual((await get(O(PA, projObj), bearer(appTok(PA)))).status, 200, 'its project reads it (control)');++ console.log('✅ v2 object reads (metadata, download, holds, jobs): other users, apps, projects and nobody get the unknown-id answer');++++ // ── 3. Public serving with another app's identity; projected objects; the paste page ──++ const video = { accept: 'video/webm,*/*;q=0.5', 'sec-fetch-dest': 'video' };++ const nav = { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' };++ for (const h of [video, nav]) {++ await same([`/v/${privVod}`, { ...h, ...key('games-key') }], ['/v/999999', { ...h, ...key('games-key') }], 'another app\'s key is nobody special on /v');++ await same([`/c/${privClip}`, { ...h, ...key('games-key') }], ['/c/999999', { ...h, ...key('games-key') }], 'another app\'s key is nobody special on /c');++ await same([`/c/${privClip}`, { ...h, ...asUser(77) }], ['/c/999999', { ...h, ...asUser(77) }], 'another user on /c');++ }++ await same(['/p/priv-page', nav], ['/p/no-such-paste', nav], 'a private paste page looks missing');++ await same(['/p/priv-page', { ...nav, ...asUser(77) }], ['/p/no-such-paste', { ...nav, ...asUser(77) }], 'a private paste page, for another user');++ await same([`/api/v1/games/vods/${pubVod}`, key('games-key')], ['/api/v1/games/vods/999999', key('games-key')], 'another app cannot read this app\'s VOD by id');++ await same([`/api/v1/games/clips/${privClip}`, key('games-key')], ['/api/v1/games/clips/999999', key('games-key')], 'nor its clip');++ const projected = raw.prepare('SELECT id, visibility FROM media_objects WHERE legacy_ref IS NOT NULL AND id IN (SELECT object_id FROM vods WHERE id IN (?, ?) UNION SELECT id FROM media_objects WHERE legacy_ref LIKE ? OR legacy_ref LIKE ?)')++ .all(privVod, unlVod, `%vod-${privVod}-222.jpg%`, `%clip-${privClip}-2.jpg%`);++ assert.ok(projected.length >= 2, `the private rows have projected objects (${JSON.stringify(projected)})`);++ for (const o of projected.filter(x => x.visibility === 'private')) {++ await same([`/o/${o.id}`], [`/o/${UNKNOWN}`], `projected private object ${o.id} looks missing on /o`);++ }++ assert.ok(projected.some(o => o.visibility === 'private'), 'a private row\'s objects are private');++ console.log('✅ /v /c /p with another app or user, v1 reads across apps, projected VOD/thumbnail objects on /o');++++ // ── 4. Signatures ──++ const miss = [`/o/${UNKNOWN}`];++ const good = signing.signedDownloadUrl(privObj, 60);++ const q = new URL(good.url).searchParams;++ assert.strictEqual((await get(local(good.url))).status, 200, 'a valid signature serves (control)');++ await same([`/o/${privObj}?exp=${now - 10}&sig=${signing.signedDownloadUrl(privObj, 60).url.split('sig=')[1]}`], miss, 'an old signature with a moved expiry');++ const expired = (() => { const real = Date.now; Date.now = () => real() - 3600 * 1000; try { return signing.signedDownloadUrl(privObj, 60); } finally { Date.now = real; } })();++ await same([local(expired.url)], miss, 'an expired signature looks like an unknown id');++ await same([`/o/${privObj}?exp=${Number(q.get('exp')) + 1000}&sig=${q.get('sig')}`], miss, 'a signature with its expiry pushed out');++ await same([`/o/${privObj}?exp=${q.get('exp')}&sig=${q.get('sig').slice(0, -2)}AA`], miss, 'a tampered signature');++ const other = new URL(signing.signedDownloadUrl(projObj, 60).url).searchParams;++ await same([`/o/${privObj}?exp=${other.get('exp')}&sig=${other.get('sig')}`], miss, 'another object\'s signature');++ await same([`/o/${sandboxObj}`], miss, 'a sandbox object is never public, whatever its visibility');++ await same([`/o/${projObj}`], miss, 'a project\'s private object without a signature');++ assert.strictEqual((await get(`/o/${unlObj}`)).status, 200, 'unlisted serves by direct link (control)');++++ // Sandbox files (/f/:key): only a valid signed URL, and a bad one looks like an unknown key.++ const fd = new FormData();++ fd.append('file', new Blob([Buffer.from('sandbox bytes')], { type: 'text/plain' }), 's.txt');++ const up = await fetch(`${base}/api/v1/${PA}/files`, { method: 'POST', headers: bearer(appTok(PA, 'sandbox')), body: fd });++ const file = await up.json();++ assert.strictEqual(up.status, 201, JSON.stringify(file));++ const fq = new URL(file.url).searchParams;++ const k = encodeURIComponent(file.key);++ assert.strictEqual((await get(local(file.url))).status, 200, 'the signed sandbox file URL serves (control)');++ const fmiss = ['/f/no-such-file-key.txt'];++ await same([`/f/${k}`], fmiss, 'a sandbox file without a signature looks missing');++ await same([`/f/${k}?exp=${Number(fq.get('exp')) + 1000}&sig=${fq.get('sig')}`], fmiss, 'a sandbox file signature with its expiry pushed out');++ await same([`/f/${k}?exp=${now - 10}&sig=${signing.signedFileUrl(file.key, 60).url.split('sig=')[1]}`], fmiss, 'an expired sandbox file signature');++ const otherFile = new URL(signing.signedFileUrl('another-key.txt', 60).url).searchParams;++ await same([`/f/${k}?exp=${otherFile.get('exp')}&sig=${otherFile.get('sig')}`], fmiss, 'another file\'s signature');++ console.log('✅ signatures: expired, extended, tampered and other objects\' or files\' signatures answer as an unknown id');++++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('security-private: all checks passed');++ process.exit(0);++})().catch((err) => { console.error(err); server.close(); process.exit(1); });
@@ -102,13 +102,29 @@ const C_WHERE = "COALESCE(is_public,1) = 1";
@@ -102,13 +102,29 @@ const C_WHERE = "COALESCE(is_public,1) = 1";
const F_WHERE = "app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)";
const F_WHERE = "app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)";
const P_WHERE = "visibility = 'public'";
const P_WHERE = "visibility = 'public'";
+// A VOD's or clip's thumbnail is listed only while the recording itself is public. The exact file name
+// is what /t/<name> serves, so listing a private or unlisted recording's thumbnail would publish a frame
+// of it (and its id) to anyone reading the index.
+const THUMB_PUBLIC = {
+ vod: "is_public = 1 AND COALESCE(visibility, 'public') = 'public' AND COALESCE(clips_only, 0) = 0",
+ clip: "COALESCE(is_public, 1) = 1 AND COALESCE(visibility, 'public') = 'public'",
+};
+function thumbListable(name, publicIds) {
+ const m = /^(vod|clip)-(\d+)-/i.exec(name);
+ return !m || publicIds[m[1].toLowerCase()].has(Number(m[2]));
+}
+
let _thumbListCache = { at: 0, list: [] };
let _thumbListCache = { at: 0, list: [] };
function thumbList() {
function thumbList() {
if (Date.now() - _thumbListCache.at < 60_000) return _thumbListCache.list;
if (Date.now() - _thumbListCache.at < 60_000) return _thumbListCache.list;
let list = [];
let list = [];
try {
try {
+ const publicIds = {
+ vod: new Set(db.all(`SELECT id FROM vods WHERE ${THUMB_PUBLIC.vod}`).map(r => r.id)),
+ clip: new Set(db.all(`SELECT id FROM clips WHERE ${THUMB_PUBLIC.clip}`).map(r => r.id)),
+ };
list = fs.readdirSync(THUMB_DIR)
list = fs.readdirSync(THUMB_DIR)
- .filter(f => /\.(jpe?g|png|webp)$/i.test(f))
+ .filter(f => /\.(jpe?g|png|webp)$/i.test(f) && thumbListable(f, publicIds))
.map(f => { try { return [f, fs.statSync(path.join(THUMB_DIR, f)).mtimeMs]; } catch { return null; } })
.map(f => { try { return [f, fs.statSync(path.join(THUMB_DIR, f)).mtimeMs]; } catch { return null; } })
.filter(Boolean)
.filter(Boolean)
.sort((a, b) => b[1] - a[1]);
.sort((a, b) => b[1] - a[1]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,251 @@
@@ -0,0 +1,251 @@
+'use strict';
+// Private and unlisted media stay where they belong (roadmap WS-R task 5). Extends
+// test/private-not-found.test.js (which covers /v /c /o /p/:slug/raw, transcript.json, the legacy
+// /api/thumbnails redirect and the v1 detail routes for an acting non-owner) to the paths it does not:
+// - public lists: the media index (/ and /browse, every tab, thumbnails included), /sitemap.xml,
+// /llms.txt, the v1 VOD/clip lists and latest-thumbs for a caller acting for another user, the v2
+// object and job lists: never a private or unlisted item (sitemap and Search documents in depth:
+// test/crawl.test.js);
+// - every v2 object read (metadata, download, holds, jobs) for another user, another app (on its own
+// tenant and on this one), another developer project, and no credential: the answer an unknown id gets;
+// - /v and /c with another app's key, /p/:slug (the page), projected VOD and thumbnail objects on /o;
+// - signatures: /o and /f answer an expired, tampered or other object's signature exactly as they
+// answer an unknown id; sandbox objects and files need one even when "public".
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-secprivate-'));
+const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+process.env.DB_PATH = path.join(tmp, 'media.db');
+process.env.VOD_PATH = dir('vods');
+process.env.CLIPS_PATH = dir('clips');
+process.env.FILES_PATH = dir('files');
+process.env.OBJECTS_PATH = dir('objects');
+process.env.THUMBNAILS_PATH = dir('thumbnails');
+process.env.PASTES_PATH = dir('pastes');
+process.env.ASSETS_PATH = dir('assets');
+process.env.OV_NETWORK_URL = 'https://openvibe.network';
+process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
+for (const k of ['PASTES_MOVED_TO', 'EVENTS_URL', 'MEDIA_B2_ENDPOINT', 'MEDIA_R2_ENDPOINT']) process.env[k] = '';
+
+const db = require('../server/db/database');
+require('../server/views/service').ensureSchema();
+const auth = require('../server/auth');
+const signing = require('../server/objects/signing');
+const queue = require('../server/jobs/queue');
+const { serviceAuth, ids } = require('openvibe-contracts');
+const express = require('express');
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+auth._setNetworkPublicKeyForTests(keys.publicKey);
+db.upsertApp({ app_id: 'live', api_key: 'live-key' });
+db.upsertApp({ app_id: 'games', api_key: 'games-key' });
+const raw = db.getDb();
+const now = Math.floor(Date.now() / 1000);
+const PA = `prj_${ids.ulid()}`, PB = `prj_${ids.ulid()}`;
+const appTok = (project, env = 'production') => serviceAuth.signServiceToken({
+ iss: 'https://openvibe.network', sub: `app:app_${ids.ulid()}`, actor_type: 'app', aud: ['openvibe.media'], cap: ['media.object.upload', 'media.object.read', 'media.object.list'],
+ ns: [project], project_id: project, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`,
+}, keys.privateKey);
+
+// ── VODs, clips and their thumbnails (user 5 owns them all) ──
+const thumb = (name) => { fs.writeFileSync(path.join(process.env.THUMBNAILS_PATH, name), Buffer.from('ffd8ffe000104a464946', 'hex')); return `/t/${name}`; };
+const media = (d, name) => { const p = path.join(d, name); fs.writeFileSync(p, Buffer.alloc(64, 1)); return p; };
+const vod = (title, visibility, extra = {}) => {
+ const id = Number(db.createVod({ app_id: 'live', user_id: 5, title, file_path: media(process.env.VOD_PATH, `${title.replace(/\W/g, '')}.webm`), duration_seconds: 10, visibility, ...extra }).lastInsertRowid);
+ return id;
+};
+const pubVod = vod('Public VOD', 'public', { managed_stream_id: 7 });
+const privVod = vod('Secret VOD title', 'private', { managed_stream_id: 7 });
+const unlVod = vod('Unlisted VOD title', 'unlisted', { managed_stream_id: 8 });
+raw.prepare("UPDATE vods SET created_at = datetime('now', ?) WHERE id = ?").run('-2 hours', pubVod); // the private one is the newest of stream 7
+for (const [id, name] of [[pubVod, `vod-${pubVod}-111.jpg`], [privVod, `vod-${privVod}-222.jpg`], [unlVod, `vod-${unlVod}-666.jpg`]]) {
+ db.withObject('vod', id, () => db.run('UPDATE vods SET thumbnail_url = ? WHERE id = ?', [thumb(name), id]));
+}
+const clip = (title, visibility, name) => Number(db.createClip({
+ app_id: 'live', vod_id: pubVod, user_id: 5, title, file_path: media(process.env.CLIPS_PATH, `${title.replace(/\W/g, '')}.webm`),
+ thumbnail_url: thumb(name), duration_seconds: 5, is_public: visibility === 'public' ? 1 : 0, visibility, status: 'ready',
+}).lastInsertRowid);
+const pubClip = clip('Public clip', 'public', 'clip-9001-1.jpg');
+const privClip = clip('Secret clip title', 'private', 'clip-9002-2.jpg');
+const unlClip = clip('Unlisted clip title', 'unlisted', 'clip-9003-3.jpg');
+// The thumbnail file names carry the clip ids the rows got.
+for (const [id, old] of [[pubClip, 'clip-9001-1.jpg'], [privClip, 'clip-9002-2.jpg'], [unlClip, 'clip-9003-3.jpg']]) {
+ const name = `clip-${id}-${old.split('-')[2]}`;
+ fs.renameSync(path.join(process.env.THUMBNAILS_PATH, old), path.join(process.env.THUMBNAILS_PATH, name));
+ db.withObject('clip', id, () => db.run('UPDATE clips SET thumbnail_url = ? WHERE id = ?', [`/t/${name}`, id]));
+}
+const HIDDEN_TITLES = ['Secret VOD title', 'Unlisted VOD title', 'Secret clip title', 'Unlisted clip title'];
+const HIDDEN_THUMBS = [`vod-${privVod}-222.jpg`, `vod-${unlVod}-666.jpg`, `clip-${privClip}-2.jpg`, `clip-${unlClip}-3.jpg`];
+raw.prepare("INSERT INTO pastes (slug, app_id, user_id, title, type, content, language, visibility) VALUES ('priv-page', 'live', 5, 'Secret paste title', 'paste', 'secret text', 'text', 'private')").run();
+
+const app = express();
+app.put('/api/v2/:app/objects/:id/content', ...require('../server/objects/routes').contentHandlers);
+app.use(express.json());
+app.use('/api/v1/:app/vods', require('../server/vod/routes'));
+app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
+app.use('/api/v1/:app/files', require('../server/files/routes'));
+app.use('/api/v2/:app/objects', require('../server/objects/routes'));
+app.use('/api/v2/:app/jobs', require('../server/jobs/routes'));
+app.use('/o', require('../server/objects/routes').publicRouter);
+app.use('/', require('../server/public/routes'));
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const local = (u) => String(u).replace('https://media.test', '');
+ const get = async (p, headers = {}, method = 'GET', body) => {
+ const h = { ...headers };
+ let payload = body;
+ if (body !== undefined && !Buffer.isBuffer(body) && typeof body !== 'string') { h['content-type'] = 'application/json'; payload = JSON.stringify(body); }
+ const res = await fetch(base + p, { method, headers: h, body: payload, redirect: 'manual' });
+ const text = await res.text();
+ let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
+ return { status: res.status, text, json, location: res.headers.get('location') };
+ };
+ const same = async (a, b, msg) => {
+ const [x, y] = [await get(...a), await get(...b)];
+ assert.deepStrictEqual([x.status, x.text, x.location], [y.status, y.text, y.location], msg);
+ return x;
+ };
+ const key = (k) => ({ authorization: `Bearer ${k}` });
+ const asUser = (id) => ({ ...key('live-key'), 'x-ov-user-id': String(id) });
+ const bearer = (t) => ({ authorization: `Bearer ${t}` });
+
+ // Native objects: init → PUT bytes → complete.
+ const mkObject = async (tenant, headers, visibility, body = 'bytes') => {
+ let r = await get(`/api/v2/${tenant}/objects`, headers, 'POST', { kind: 'file', mime_type: 'text/plain', size_bytes: Buffer.byteLength(body), visibility, filename: 'x.txt' });
+ assert.strictEqual(r.status, 201, r.text);
+ const id = r.json.id;
+ r = await get(local(r.json.upload.url), { 'content-type': 'text/plain' }, 'PUT', body);
+ assert.strictEqual(r.status, 200, r.text);
+ r = await get(`/api/v2/${tenant}/objects/${id}/complete`, headers, 'POST', {});
+ assert.strictEqual(r.status, 200, r.text);
+ return id;
+ };
+ const privObj = await mkObject('live', asUser(5), 'private');
+ const unlObj = await mkObject('live', asUser(5), 'unlisted');
+ const pubObj = await mkObject('live', asUser(5), 'public');
+ const projObj = await mkObject(PA, bearer(appTok(PA)), 'private');
+ const sandboxObj = await mkObject(PA, bearer(appTok(PA, 'sandbox')), 'public');
+ const UNKNOWN = 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+ const job = queue.enqueue({ appId: 'live', type: 'thumbnail.regenerate', objectId: privObj, params: { kind: 'vod', id: privVod }, ownerUserId: 5, createdBy: 'app:live:user:5' }).job;
+
+ // ── 1. Public lists: nothing private or unlisted ──
+ for (const p of ['/', '/browse', '/browse?tab=videos', '/browse?tab=clips', '/browse?tab=thumbnails', '/?tab=thumbnails&page=1', '/sitemap.xml', '/llms.txt', '/updates']) {
+ const r = await get(p);
+ assert.ok(r.status < 500, `${p} answers`);
+ for (const t of [...HIDDEN_TITLES, ...HIDDEN_THUMBS, 'Secret paste title']) assert.ok(!r.text.includes(t), `${p} lists ${t}`);
+ if (/thumbnails/.test(p)) {
+ assert.ok(r.text.includes(`vod-${pubVod}-111.jpg`) && r.text.includes(`clip-${pubClip}-1.jpg`), `${p}: public recordings' thumbnails are listed`);
+ }
+ }
+ assert.ok((await get('/browse?tab=videos')).text.includes('Public VOD'), 'the index lists public VODs');
+ console.log('✅ media index (every tab, thumbnails too), sitemap, llms.txt, updates: no private or unlisted item');
+
+ for (const who of [asUser(77), { ...asUser(77), 'x-include': '1' }]) {
+ let r = await get('/api/v1/live/vods?include_private=1&include_recording=1', who);
+ assert.deepStrictEqual(r.json.vods.map(v => v.id), [pubVod], 'acting for another user: public VODs only, include_private ignored');
+ r = await get('/api/v1/live/clips?include_private=1', who);
+ assert.deepStrictEqual(r.json.clips.map(c => c.id), [pubClip], 'acting for another user: public clips only');
+ }
+ let r = await get('/api/v1/live/vods/latest-thumbs?managed_stream_ids=7,8', asUser(77));
+ assert.deepStrictEqual(Object.keys(r.json.thumbs), ['7'], 'no thumbnail for a stream whose only VOD is unlisted');
+ assert.strictEqual(r.json.thumbs['7'].vod_id, pubVod, 'the newest PUBLIC VOD, not the newer private one');
+ r = await get('/api/v1/live/vods?include_private=1', key('live-key'));
+ assert.strictEqual(r.json.vods.length, 3, 'the app key itself may list everything (control)');
+ r = await get('/api/v2/live/objects', asUser(77));
+ assert.deepStrictEqual(r.json.objects.filter(o => [privObj, unlObj, pubObj].includes(o.id)).map(o => o.id).sort(), [pubObj, unlObj].sort(), 'v2 list for another user: public and unlisted (documented), never private');
+ r = await get(`/api/v2/live/objects?visibility=private&owner=user:5&user_id=5`, asUser(77));
+ assert.ok(!r.json.objects.some(o => o.id === privObj), 'no filter reaches another user\'s private objects');
+ r = await get('/api/v2/live/jobs', asUser(77));
+ assert.ok(!r.json.jobs.some(j => j.id === job.id), 'another user\'s jobs are not listed');
+ r = await get('/api/v2/games/objects', key('games-key'));
+ assert.ok(!r.json.objects.some(o => [privObj, unlObj, pubObj].includes(o.id)), 'another app lists none of this app\'s objects');
+ r = await get(`/api/v2/${PB}/objects`, bearer(appTok(PB)));
+ assert.deepStrictEqual(r.json.objects, [], 'another developer project lists nothing of this one');
+ console.log('✅ v1 lists, latest-thumbs, v2 object and job lists: no private item of anyone else');
+
+ // ── 2. Every v2 object read: another user, another app, another project, nobody → the unknown-id answer ──
+ const O = (tenant, id, rest = '') => `/api/v2/${tenant}/objects/${id}${rest}`;
+ for (const rest of ['', '/download', '/download?format=json', '/download?redirect=1', '/holds', '/holds?all=1']) {
+ r = await same([O('live', privObj, rest), asUser(77)], [O('live', UNKNOWN, rest), asUser(77)], `another user: private ${rest || 'metadata'} looks missing`);
+ assert.strictEqual(r.status, 404);
+ await same([O('games', privObj, rest), key('games-key')], [O('games', UNKNOWN, rest), key('games-key')], `another app on its own tenant: ${rest || 'metadata'}`);
+ await same([O('games', pubObj, rest), key('games-key')], [O('games', UNKNOWN, rest), key('games-key')], `another app does not reach even a public object through its tenant: ${rest || 'metadata'}`);
+ await same([O('live', privObj, rest), key('games-key')], [O('live', UNKNOWN, rest), key('games-key')], `another app's key on this tenant: ${rest || 'metadata'}`);
+ await same([O('live', privObj, rest)], [O('live', UNKNOWN, rest)], `no credential: ${rest || 'metadata'}`);
+ await same([O(PB, projObj, rest), bearer(appTok(PB))], [O(PB, UNKNOWN, rest), bearer(appTok(PB))], `another project: ${rest || 'metadata'}`);
+ await same([O(PA, projObj, rest), bearer(appTok(PB))], [O(PA, UNKNOWN, rest), bearer(appTok(PB))], `another project's token on this project: ${rest || 'metadata'}`);
+ }
+ await same([`/api/v2/live/jobs/${job.id}`, asUser(77)], ['/api/v2/live/jobs/mjob_nosuchjob', asUser(77)], 'another user\'s job looks missing');
+ assert.strictEqual((await get(O('live', privObj), asUser(5))).status, 200, 'the owner reads it (control)');
+ assert.strictEqual((await get(O('live', unlObj), asUser(77))).status, 200, 'unlisted is readable by id (documented)');
+ assert.strictEqual((await get(O(PA, projObj), bearer(appTok(PA)))).status, 200, 'its project reads it (control)');
+ console.log('✅ v2 object reads (metadata, download, holds, jobs): other users, apps, projects and nobody get the unknown-id answer');
+
+ // ── 3. Public serving with another app's identity; projected objects; the paste page ──
+ const video = { accept: 'video/webm,*/*;q=0.5', 'sec-fetch-dest': 'video' };
+ const nav = { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' };
+ for (const h of [video, nav]) {
+ await same([`/v/${privVod}`, { ...h, ...key('games-key') }], ['/v/999999', { ...h, ...key('games-key') }], 'another app\'s key is nobody special on /v');
+ await same([`/c/${privClip}`, { ...h, ...key('games-key') }], ['/c/999999', { ...h, ...key('games-key') }], 'another app\'s key is nobody special on /c');
+ await same([`/c/${privClip}`, { ...h, ...asUser(77) }], ['/c/999999', { ...h, ...asUser(77) }], 'another user on /c');
+ }
+ await same(['/p/priv-page', nav], ['/p/no-such-paste', nav], 'a private paste page looks missing');
+ await same(['/p/priv-page', { ...nav, ...asUser(77) }], ['/p/no-such-paste', { ...nav, ...asUser(77) }], 'a private paste page, for another user');
+ await same([`/api/v1/games/vods/${pubVod}`, key('games-key')], ['/api/v1/games/vods/999999', key('games-key')], 'another app cannot read this app\'s VOD by id');
+ await same([`/api/v1/games/clips/${privClip}`, key('games-key')], ['/api/v1/games/clips/999999', key('games-key')], 'nor its clip');
+ const projected = raw.prepare('SELECT id, visibility FROM media_objects WHERE legacy_ref IS NOT NULL AND id IN (SELECT object_id FROM vods WHERE id IN (?, ?) UNION SELECT id FROM media_objects WHERE legacy_ref LIKE ? OR legacy_ref LIKE ?)')
+ .all(privVod, unlVod, `%vod-${privVod}-222.jpg%`, `%clip-${privClip}-2.jpg%`);
+ assert.ok(projected.length >= 2, `the private rows have projected objects (${JSON.stringify(projected)})`);
+ for (const o of projected.filter(x => x.visibility === 'private')) {
+ await same([`/o/${o.id}`], [`/o/${UNKNOWN}`], `projected private object ${o.id} looks missing on /o`);
+ }
+ assert.ok(projected.some(o => o.visibility === 'private'), 'a private row\'s objects are private');
+ console.log('✅ /v /c /p with another app or user, v1 reads across apps, projected VOD/thumbnail objects on /o');
+
+ // ── 4. Signatures ──
+ const miss = [`/o/${UNKNOWN}`];
+ const good = signing.signedDownloadUrl(privObj, 60);
+ const q = new URL(good.url).searchParams;
+ assert.strictEqual((await get(local(good.url))).status, 200, 'a valid signature serves (control)');
+ await same([`/o/${privObj}?exp=${now - 10}&sig=${signing.signedDownloadUrl(privObj, 60).url.split('sig=')[1]}`], miss, 'an old signature with a moved expiry');
+ const expired = (() => { const real = Date.now; Date.now = () => real() - 3600 * 1000; try { return signing.signedDownloadUrl(privObj, 60); } finally { Date.now = real; } })();
+ await same([local(expired.url)], miss, 'an expired signature looks like an unknown id');
+ await same([`/o/${privObj}?exp=${Number(q.get('exp')) + 1000}&sig=${q.get('sig')}`], miss, 'a signature with its expiry pushed out');
+ await same([`/o/${privObj}?exp=${q.get('exp')}&sig=${q.get('sig').slice(0, -2)}AA`], miss, 'a tampered signature');
+ const other = new URL(signing.signedDownloadUrl(projObj, 60).url).searchParams;
+ await same([`/o/${privObj}?exp=${other.get('exp')}&sig=${other.get('sig')}`], miss, 'another object\'s signature');
+ await same([`/o/${sandboxObj}`], miss, 'a sandbox object is never public, whatever its visibility');
+ await same([`/o/${projObj}`], miss, 'a project\'s private object without a signature');
+ assert.strictEqual((await get(`/o/${unlObj}`)).status, 200, 'unlisted serves by direct link (control)');
+
+ // Sandbox files (/f/:key): only a valid signed URL, and a bad one looks like an unknown key.
+ const fd = new FormData();
+ fd.append('file', new Blob([Buffer.from('sandbox bytes')], { type: 'text/plain' }), 's.txt');
+ const up = await fetch(`${base}/api/v1/${PA}/files`, { method: 'POST', headers: bearer(appTok(PA, 'sandbox')), body: fd });
+ const file = await up.json();
+ assert.strictEqual(up.status, 201, JSON.stringify(file));
+ const fq = new URL(file.url).searchParams;
+ const k = encodeURIComponent(file.key);
+ assert.strictEqual((await get(local(file.url))).status, 200, 'the signed sandbox file URL serves (control)');
+ const fmiss = ['/f/no-such-file-key.txt'];
+ await same([`/f/${k}`], fmiss, 'a sandbox file without a signature looks missing');
+ await same([`/f/${k}?exp=${Number(fq.get('exp')) + 1000}&sig=${fq.get('sig')}`], fmiss, 'a sandbox file signature with its expiry pushed out');
+ await same([`/f/${k}?exp=${now - 10}&sig=${signing.signedFileUrl(file.key, 60).url.split('sig=')[1]}`], fmiss, 'an expired sandbox file signature');
+ const otherFile = new URL(signing.signedFileUrl('another-key.txt', 60).url).searchParams;
+ await same([`/f/${k}?exp=${otherFile.get('exp')}&sig=${otherFile.get('sig')}`], fmiss, 'another file\'s signature');
+ console.log('✅ signatures: expired, extended, tampered and other objects\' or files\' signatures answer as an unknown id');
+
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('security-private: all checks passed');
+ process.exit(0);
+})().catch((err) => { console.error(err); server.close(); process.exit(1); });