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.
Changes
@@@ -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,};@@@ -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@@@ -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);@@@ -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 {@@@ -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);
@@ -74,6 +74,27 @@ function verifyFile(key, exp, sig) {
@@ -74,6 +74,27 @@ function verifyFile(key, exp, sig) {
return safeEqual(sig, mac('getf', `file:${key}`, e));
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) {
function macOf(parts) {
return crypto.createHmac('sha256', secret()).update(parts.map(String).join('\n')).digest('base64url');
return crypto.createHmac('sha256', secret()).update(parts.map(String).join('\n')).digest('base64url');
}
}
@@ -138,6 +159,6 @@ function verifyMultipartToken(token, { tenant, objectId, uploadId, totalSize })
@@ -138,6 +159,6 @@ function verifyMultipartToken(token, { tenant, objectId, uploadId, totalSize })
}
}
module.exports = {
module.exports = {
- signedDownloadUrl, verifyDownload, signedFileUrl, verifyFile,
+ signedDownloadUrl, verifyDownload, signedFileUrl, verifyFile, signedMediaUrl, verifyMedia,
uploadToken, uploadTtl, checkUploadToken, verifyUploadToken, multipartToken, verifyMultipartToken,
uploadToken, uploadTtl, checkUploadToken, verifyUploadToken, multipartToken, verifyMultipartToken,
};
};
@@ -61,10 +61,14 @@ function notFound(res) {
@@ -61,10 +61,14 @@ function notFound(res) {
return res.status(404).json({ error: 'Not found' });
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.
// 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 === 'app' && req.appId === record.app_id) return true;
if (req.authType === 'user' && req.userId != null && record.user_id === req.userId) 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;
return false;
}
}
@@ -130,7 +134,7 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -130,7 +134,7 @@ async function serveMediaRecord(kind, record, req, res) {
const visibility = recordVisibility(record);
const visibility = recordVisibility(record);
// Exactly the answer a missing id gets: a 403 here told anyone probing ids which private
// 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).
// 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
// 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
// page; its <video> comes back here with ?raw=1 for the bytes. The page offers a player only when
@@ -328,6 +328,15 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
@@ -328,6 +328,15 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
});
});
// ── Get clip ─────────────────────────────────────────────────
// ── 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) => {
router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
try {
try {
const clip = _getClipScoped(req, res);
const clip = _getClipScoped(req, res);
@@ -310,6 +310,18 @@ router.get('/latest-thumbs', tenantAuth({ allowUser: true }), (req, res) => {
@@ -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 ─────────────────────────────────────────────
// ── Get VOD meta ─────────────────────────────────────────────
router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
try {
try {
@@ -110,6 +110,22 @@ const server = http.createServer(app);
@@ -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');
assert.strictEqual((await get('/c/11', { ...video, ...asUser(5) })).status, 200, 'the owner gets their private clip');
console.log('✅ /v /c: private looks missing');
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 ──
// ── Transcript companion ──
r = await same(['/v/2/transcript.json'], ['/v/999/transcript.json'], 'private VOD transcript looks missing');
r = await same(['/v/2/transcript.json'], ['/v/999/transcript.json'], 'private VOD transcript looks missing');
assert.strictEqual(r.status, 404);
assert.strictEqual(r.status, 404);