openvibers/openvibe.media Commits

Signed playback URLs for a VOD or clip, so a reader with no key can read a private recording its owning app asked for: OpenVibe.AI transcribing it (roadmap WS-O task 2). GET /api/v1/:app/vods/:id/signed-url and /clips/:id/signed-url (the owning app's key alone; a call acting for a user gets 403) answer { url, expires_at } for /v/:id or /c/:id with exp and sig. The signature has its own purpose per kind (getv, getc) and id space (vod:<id>, clip:<id>): it is never valid for another recording, another kind or an object. ttl is 30 s to 6 hours (default 1 hour), because a transcript pass reads a long recording window by window. /v and /c accept a valid signature for a private row; anything else still answers exactly like a missing id. test/private-not-found.test.js covers both kinds, other ids, the other kind, a forged signature, acting callers and no key refused, and the 6-hour cap. npm test passes.

OP OpenVibers committed 28 Sept 2026 · 9aac60f0b6 · 5 files, +65 −3


Changes

ddiff --git a/server/objects/signing.js b/server/objects/signing.js
iindex 2117c4c..088fd17 100644
---- a/server/objects/signing.js
++++ b/server/objects/signing.js
@@@ -74,6 +74,27 @@ function verifyFile(key, exp, sig) {
return safeEqual(sig, mac('getf', `file:${key}`, e));
}
++/**
++ * Short-lived GET URL for a private VOD or clip's bytes (/v/:id, /c/:id), for a reader that holds no key: OpenVibe.AI
++ * transcribing a recording its owner's app asked for (roadmap WS-O task 2). Its own purpose per kind ('getv', 'getc')
++ * and id space (vod:<id>, clip:<id>). Up to 6 hours: a transcript pass over a long recording reads it window by window.
++ */
++function signedMediaUrl(kind, id, ttlS = 3600) {
++ const exp = nowS() + Math.min(6 * 3600, Math.max(30, Number(ttlS) || 3600));
++ const purpose = kind === 'clip' ? 'getc' : 'getv';
++ const route = kind === 'clip' ? 'c' : 'v';
++ return {
++ url: `${config.publicUrl}/${route}/${encodeURIComponent(id)}?exp=${exp}&sig=${mac(purpose, `${kind}:${id}`, exp)}`,
++ expires_at: new Date(exp * 1000).toISOString(),
++ };
++}
++
++function verifyMedia(kind, id, exp, sig) {
++ const e = Number(exp);
++ if (!Number.isInteger(e) || e < nowS() || e > nowS() + 6 * 3600 + 60) return false;
++ return safeEqual(sig, mac(kind === 'clip' ? 'getc' : 'getv', `${kind}:${id}`, e));
++}
++
function macOf(parts) {
return crypto.createHmac('sha256', secret()).update(parts.map(String).join('\n')).digest('base64url');
}
@@@ -138,6 +159,6 @@ function verifyMultipartToken(token, { tenant, objectId, uploadId, totalSize })
}
module.exports = {
-- signedDownloadUrl, verifyDownload, signedFileUrl, verifyFile,
++ signedDownloadUrl, verifyDownload, signedFileUrl, verifyFile, signedMediaUrl, verifyMedia,
uploadToken, uploadTtl, checkUploadToken, verifyUploadToken, multipartToken, verifyMultipartToken,
};
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex da690f8..0501f86 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -61,10 +61,14 @@ function notFound(res) {
return res.status(404).json({ error: 'Not found' });
}
--function canAccessPrivate(record, req) {
++function canAccessPrivate(record, req, kind = null) {
// Private items: the owning app (its API key) or the owning user's JWT.
if (req.authType === 'app' && req.appId === record.app_id) return true;
if (req.authType === 'user' && req.userId != null && record.user_id === req.userId) return true;
++ // …or a short-lived URL the owning app signed for a reader that holds no key (GET /api/vods/:id/signed-url).
++ if (kind && req.query && req.query.exp && req.query.sig) {
++ return require('../objects/signing').verifyMedia(kind, record.id, req.query.exp, String(req.query.sig));
++ }
return false;
}
@@@ -130,7 +134,7 @@ async function serveMediaRecord(kind, record, req, res) {
const visibility = recordVisibility(record);
// Exactly the answer a missing id gets: a 403 here told anyone probing ids which private
// recordings exist (and, through the basename form of /v, which file names are real).
-- if (visibility === 'private' && !canAccessPrivate(record, req)) return notFound(res);
++ if (visibility === 'private' && !canAccessPrivate(record, req, kind)) return notFound(res);
// A person (or a link-preview crawler) landing on the URL gets the watch
// page; its <video> comes back here with ?raw=1 for the bytes. The page offers a player only when
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex e23d83e..1e7a461 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -328,6 +328,15 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
});
// ── Get clip ─────────────────────────────────────────────────
++// A short-lived URL of a clip's bytes for a reader with no key (OpenVibe.AI transcribing it); the owning app only.
++router.get('/:id/signed-url', tenantAuth(), (req, res) => {
++ if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app signs a playback URL' });
++ const clip = _getClipScoped(req, res);
++ if (!clip) return;
++ res.set('Cache-Control', 'private, no-store');
++ res.json(require('../objects/signing').signedMediaUrl('clip', clip.id, req.query.ttl));
++});
++
router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
try {
const clip = _getClipScoped(req, res);
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex ad3d976..2ff7a02 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -310,6 +310,18 @@ router.get('/latest-thumbs', tenantAuth({ allowUser: true }), (req, res) => {
}
});
++// ── A signed playback URL for a reader with no key ───────────
++// The owning app (its key alone, not a call acting for one of its users) asks for a short-lived URL of a VOD's bytes
++// to hand to a reader that holds no key: OpenVibe.AI transcribing it (roadmap WS-O task 2). ttl in seconds, 30 s to
++// 6 h (default 1 h). Public VODs get the same kind of URL; it works for any visibility.
++router.get('/:id/signed-url', tenantAuth(), (req, res) => {
++ if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app signs a playback URL' });
++ const vod = _getVodScoped(req, res);
++ if (!vod) return;
++ res.set('Cache-Control', 'private, no-store');
++ res.json(require('../objects/signing').signedMediaUrl('vod', vod.id, req.query.ttl));
++});
++
// ── Get VOD meta ─────────────────────────────────────────────
router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
try {
ddiff --git a/test/private-not-found.test.js b/test/private-not-found.test.js
iindex 79a52b9..8656df2 100644
---- a/test/private-not-found.test.js
++++ b/test/private-not-found.test.js
@@@ -110,6 +110,22 @@ const server = http.createServer(app);
assert.strictEqual((await get('/c/11', { ...video, ...asUser(5) })).status, 200, 'the owner gets their private clip');
console.log('✅ /v /c: private looks missing');
++ // ── Signed playback URLs (a reader with no key, e.g. OpenVibe.AI transcribing): only the owning app signs ──
++ const signedVia = async (p, headers) => { const x = await get(p, headers); return x.status === 200 ? JSON.parse(x.text) : { status: x.status }; };
++ const sv = await signedVia('/api/v1/live/vods/2/signed-url?ttl=600', { authorization: 'Bearer live-key' });
++ assert.ok(/^https:\/\/media\.test\/v\/2\?exp=\d+&sig=/.test(sv.url), sv.url);
++ assert.strictEqual((await get(sv.url.replace('https://media.test', ''), video)).status, 200, 'a signed URL reads the private VOD');
++ const sc = await signedVia('/api/v1/live/clips/11/signed-url', { authorization: 'Bearer live-key' });
++ assert.strictEqual((await get(sc.url.replace('https://media.test', ''), video)).status, 200, 'a signed URL reads the private clip');
++ await same([sv.url.replace('https://media.test', '').replace('/v/2?', '/v/3?'), video], ['/v/999', video], 'a VOD signature is not valid for another VOD');
++ await same([sc.url.replace('https://media.test', '').replace('/c/11?', '/v/11?'), video], ['/v/999', video], 'a clip signature is not a VOD signature');
++ await same([sv.url.replace('https://media.test', '').replace(/sig=[^&]+/, 'sig=AAAA'), video], ['/v/999', video], 'a forged signature is nobody');
++ assert.strictEqual((await signedVia('/api/v1/live/vods/2/signed-url', asUser(5))).status, 403, 'a call acting for a user does not sign');
++ assert.strictEqual((await signedVia('/api/v1/live/vods/2/signed-url', {})).status, 401, 'no key, no URL');
++ const late = signing.signedMediaUrl('vod', 2, 10 * 24 * 3600);
++ assert.ok(Date.parse(late.expires_at) - Date.now() <= 6 * 3600 * 1000 + 5000, 'at most 6 hours');
++ console.log('✅ /v /c: signed URLs for the owning app only');
++
// ── Transcript companion ──
r = await same(['/v/2/transcript.json'], ['/v/999/transcript.json'], 'private VOD transcript looks missing');
assert.strictEqual(r.status, 404);