openvibers/openvibe.media Commits
Private items answer exactly like missing ones: /v and /c returned 403 "This media is private", which confirmed that a private recording (or, through the basename form of /v, a file name) exists; now the same 404 and body as an unknown id, for the bytes and the watch page. Same rule on /v/:id/transcript.json (legacy rows with no visibility and is_public 0 count as private, clips-only sources are hidden like on /v), the legacy /api/thumbnails redirect (no longer hands out a private row's current thumbnail URL), /p/:slug/raw (no screenshot bounce or Community redirect that only real private pastes got), /o/:id (a deleted private object said 410 before the signature check), and the v1 detail routes when an app acts for a user via X-OV-User-Id (GET /vods/:id, /clips/:id, cutting a clip from someone else's private VOD, GET /pastes/:slug, which also no longer counts a view before refusing). test/private-not-found.test.js compares every refused answer with the missing-id answer
Changes
@@@ -266,12 +266,17 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.| `GET /v/:id/transcript.json` | **VOD transcript API** — transcript + AI overview for one existing VOD id (`{ vod_id, title, duration_seconds, ai_overview, transcript, ai_analyzed_at }`). Private VODs → 404. Cached 30s, CORS-open. || `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |--Private items respond 403/404 unless the request bears the owning app's API--key or the owning user's JWT.++Private items (and legacy rows with no visibility and `is_public = 0`) answer++exactly like a missing id — the same 404 and body — unless the request bears the++owning app's API key, optionally acting for the owner via `X-OV-User-Id`. That holds++for the watch page, the bytes, `transcript.json`, the legacy `/api/thumbnails` redirect++and `/p/:slug/raw`; the v1 detail routes (`GET /vods/:id`, `/clips/:id`, `/pastes/:slug`)++apply it to an app acting for someone other than the owner.`GET /o/:id` serves object bytes by canonical id (`med_…`): public/unlistedobjects openly, private ones only with a valid signature from--`/api/v2/:app/objects/:id/download`, deleted ones 410 (see below).++`/api/v2/:app/objects/:id/download`, deleted ones 410 (a deleted private object++without a signature is a 404 like any other private one).## Object model and API v2@@@ -145,7 +145,7 @@ All routes live under `/api/v2/:app/objects`.- Public and unlisted `ready` objects are served openly.- Private objects need a valid `?exp=&sig=` from `/download`. Without one the answer is 404, so a private object cannot be told apart from a missing one.--- A deleted object answers 410.++- A deleted object answers 410 — after the check above, so a deleted private object is a 404 without a signature.- A local copy streams with Range support. Otherwise the route redirects 302 to a presigned R2 URL, then a presigned B2 URL.- Headers: `X-Content-Type-Options: nosniff` and `X-Robots-Tag: noindex`. Content is `inline` only for images (not SVG), video, audio, PDF and plain text, and `attachment` for everything else.@@@ -379,13 +379,14 @@ publicRouter.get('/:id', async (req, res) => {try {const obj = model.getObject(String(req.params.id || ''));if (!obj) return res.status(404).json({ error: 'Not found' });-- if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });-- if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });const signed = !!req.query.sig && signing.verifyDownload(obj.id, req.query.exp, req.query.sig);// Private objects (and every developer-project sandbox object) are indistinguishable from-- // missing ones without a valid signature.++ // missing ones without a valid signature — checked before the lifecycle answers, or a 410++ // for a deleted private object would still say it existed.const sandbox = db.isSandboxTenant(obj.app_id);if ((obj.visibility === 'private' || sandbox) && !signed) return res.status(404).json({ error: 'Not found' });++ if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });++ if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });const mime = obj.mime_type || 'application/octet-stream';const md = model.parseJson(obj.metadata, {});@@@ -451,6 +451,11 @@ router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {try {const paste = _getPasteScoped(req, res);if (!paste) return;++ // Private pastes: owner (or the app itself) only — decided before anything is counted.++ if (paste.visibility === 'private') {++ const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);++ if (!allowed) return res.status(404).json({ error: 'Paste not found' });++ }// The owning app renders the paste page itself — count the view here (real client// IP via X-Forwarded-For, viewer via the user JWT / X-OV-User-Id) unless it says// this fetch is not a page view (?no_view=1 for edit forms, embeds, bots).@@@ -458,11 +463,6 @@ router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {try { const r = require('../views/service').recordView('paste', paste.id, { req, ownerUserId: paste.user_id }); if (r.view_count != null) { paste.views = r.view_count; paste.unique_views = r.unique_views; } } catch { /* */ }}-- // Private pastes: owner (or the app itself) only.-- if (paste.visibility === 'private') {-- const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);-- if (!allowed) return res.status(404).json({ error: 'Paste not found' });-- }paste.liked = req.userId != null ? db.hasUserLikedPaste(paste.id, req.userId) : false;res.json({ paste: { ...pastePublic(paste), liked: paste.liked } });@@@ -155,8 +155,11 @@ function getTranscriptTimeline(appId, sel, limit) {/** Transcript + AI overview for one existing VOD id. */function getVodTranscript(vodId) {return cached(`vt:${vodId}`, async () => {-- const v = db.get(`SELECT ${VOD_COLS}, app_id, is_public FROM vods WHERE id = ?`, [vodId]);-- if (!v || v.visibility === 'private') return { status: 404, body: { error: 'VOD not found' } };++ const v = db.get(`SELECT ${VOD_COLS}, app_id, is_public, clips_only FROM vods WHERE id = ?`, [vodId]);++ // Private (including legacy rows with no visibility and is_public = 0) and clips-only++ // source recordings answer exactly like a missing id — the same rule /v/:id applies.++ const hidden = v && ((v.visibility || (v.is_public ? 'public' : 'private')) === 'private' || v.clips_only);++ if (!v || hidden) return { status: 404, body: { error: 'VOD not found' } };const txs = await _appTranscripts(v.app_id, [v.id]);return { status: 200, body: { ..._vodEntry(v, { tx: txs[v.id] }), app_id: v.app_id, generated_at: new Date().toISOString() } };});@@@ -47,6 +47,16 @@ function trackUniqueView(kind, id, req, ownerUserId = null) {try { if (views.isInitialPlaybackRequest(req)) views.recordView(kind, id, { req, ownerUserId }); } catch { /* non-critical */ }}++/** public | unlisted | private. Rows without a visibility fall back to is_public (0 = private). */++function recordVisibility(record) {++ return record.visibility || (record.is_public ? 'public' : 'private');++}++++/** The single "no such thing" answer for every id-addressed route, used for private items too. */++function notFound(res) {++ return res.status(404).json({ error: 'Not found' });++}++function canAccessPrivate(record, req) {// 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;@@@ -111,10 +121,10 @@ async function serveMediaRecord(kind, record, req, res) {const vodStorage = require('../vod/vod-storage');const noindex = { 'X-Robots-Tag': 'noindex' };-- const visibility = record.visibility || (record.is_public ? 'public' : 'private');-- if (visibility === 'private' && !canAccessPrivate(record, req)) {-- return res.status(403).json({ error: 'This media is private' });-- }++ 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);// 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.@@@ -181,14 +191,14 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {try {if (/^\d+$/.test(req.params.id)) {const vod = db.getVodById(parseInt(req.params.id, 10));-- if (!vod || vod.clips_only) return res.status(404).json({ error: 'Not found' });++ if (!vod || vod.clips_only) return notFound(res);return await serveMediaRecord('vod', vod, req, res);}const vod = db.getVodByFileBasename(req.params.id);if (vod && !vod.clips_only) return await serveMediaRecord('vod', vod, req, res);const clip = db.getClipByFileBasename(req.params.id);if (clip) return await serveMediaRecord('clip', clip, req, res);-- res.status(404).json({ error: 'Not found' });++ notFound(res);} catch (err) {console.error('[Public] /v error:', err.message);if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });@@@ -198,9 +208,9 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {// ── Clip playback ────────────────────────────────────────────router.get('/c/:id', optionalIdentity, async (req, res) => {try {-- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Not found' });++ if (!/^\d+$/.test(req.params.id)) return notFound(res);const clip = db.getClipById(parseInt(req.params.id, 10));-- if (!clip || !clip.file_path) return res.status(404).json({ error: 'Not found' });++ if (!clip || !clip.file_path) return notFound(res);await serveMediaRecord('clip', clip, req, res);} catch (err) {console.error('[Public] /c error:', err.message);@@@ -355,7 +365,9 @@ router.get('/api/thumbnails/:name', (req, res) => {const row = m[1].toLowerCase() === 'vod'? db.getVodById(parseInt(m[2], 10), 'live'): db.getClipById(parseInt(m[2], 10), 'live');-- if (row && row.thumbnail_url && !row.thumbnail_url.endsWith(`/${name}`)) {++ // Never for a private row: the redirect would hand out the current thumbnail URL of any++ // private recording to whoever guesses vod-<id>-0.jpg. The exact file name still serves.++ if (row && row.thumbnail_url && recordVisibility(row) !== 'private' && !row.thumbnail_url.endsWith(`/${name}`)) {res.set('Cache-Control', 'public, max-age=3600');return res.redirect(302, row.thumbnail_url);}@@@ -484,16 +496,18 @@ router.get('/p/:slug', optionalIdentity, (req, res) => {router.get('/p/:slug/raw', (req, res) => {try {-- const paste = db.getPasteBySlug(String(req.params.slug));++ const found = db.getPasteBySlug(String(req.params.slug));++ // A private paste answers exactly like a missing slug (no redirect that proves it exists).++ const paste = found && found.visibility !== 'private' ? found : null;// Image pastes have no raw text — bounce to the screenshot (stale// consumers stored /raw URLs for hero-moment images).if (paste && paste.type === 'screenshot') {res.set('Cache-Control', 'public, max-age=3600');return res.redirect(302, `/p/${encodeURIComponent(paste.slug)}/screenshot`);}++ // Moved: every slug goes to Community, found or not, like /p/:slug.++ if (movedTo()) return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(String(req.params.slug))}/raw`);if (!paste || paste.type !== 'paste') return res.status(404).send('Not found');-- if (movedTo()) return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(paste.slug)}/raw`);-- if (paste.visibility === 'private') return res.status(404).send('Not found');// Burn after readif (paste.burn_after_read && paste.views > 0) {@@@ -523,3 +537,4 @@ router.get('/p/:slug/screenshot', (req, res) => {module.exports = router;module.exports.streamFileWithRange = streamFileWithRange;++module.exports.recordVisibility = recordVisibility;@@@ -98,6 +98,11 @@ function sanitizeClipTitle(title, fallback = 'Untitled Clip') {return title.replace(/<[^>]*>/g, '').replace(/&[a-z]+;/gi, '').trim().slice(0, 200) || fallback;}++/** Private, including legacy rows that have no visibility and is_public = 0. */++function _isPrivate(row) {++ return (row.visibility || (row.is_public ? 'public' : 'private')) === 'private';++}++function _getClipScoped(req, res) {const id = parseInt(req.params.id, 10);if (!Number.isFinite(id)) { res.status(404).json({ error: 'Clip not found' }); return null; }@@@ -120,6 +125,10 @@ router.post('/', tenantAuth({ allowUser: true }), clipUpload.single('video'), asif (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });const vod = db.getVodById(vodId, req.appId);if (!vod) return res.status(404).json({ error: 'VOD not found' });++ // An acting user cannot cut someone else's private VOD, and is not told it exists.++ if (req.authType === 'user' && _isPrivate(vod) && !(vod.user_id != null && String(vod.user_id) === String(req.userId))) {++ return res.status(404).json({ error: 'VOD not found' });++ }let startTime = Number.parseFloat(body.start_s ?? body.start_time);let endTime = Number.parseFloat(body.end_s ?? body.end_time);@@@ -313,6 +322,10 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {try {const clip = _getClipScoped(req, res);if (!clip) return;++ // Acting for one of the app's users: another user's private clip looks missing (see GET /vods/:id).++ if (req.authType === 'user' && _isPrivate(clip) && !(clip.user_id != null && String(clip.user_id) === String(req.userId))) {++ return res.status(404).json({ error: 'Clip not found' });++ }// Bare object, mirroring GET /vods/:id per CONTRACTS.md.// Detail responses carry the transcript too (lists stay light).res.json({ ...clipPublic(clip), ai_transcript: clip.ai_transcript || null });@@@ -66,6 +66,11 @@ const chunkUpload = multer({*/const activeChunkUploads = new Map();++/** Private, including legacy rows that have no visibility and is_public = 0. */++function _isPrivate(row) {++ return (row.visibility || (row.is_public ? 'public' : 'private')) === 'private';++}++function _getVodScoped(req, res) {const id = parseInt(req.params.id, 10);if (!Number.isFinite(id)) { res.status(404).json({ error: 'VOD not found' }); return null; }@@@ -308,6 +313,11 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {try {const vod = _getVodScoped(req, res);if (!vod) return;++ // Acting for one of the app's users (X-OV-User-Id): their private VODs only — anyone++ // else's answers exactly like a missing id. The app key alone sees the namespace.++ if (req.authType === 'user' && _isPrivate(vod) && !(vod.user_id != null && String(vod.user_id) === String(req.userId))) {++ return res.status(404).json({ error: 'VOD not found' });++ }// Self-heal a missing duration on a finished local VOD.if (!vod.is_recording && (!vod.duration_seconds || vod.duration_seconds <= 0) && vod.file_path && fs.existsSync(vod.file_path)) {nnew file mode 100644@@@ -0,0 +1,173 @@++'use strict';++// Privacy: anyone who may not see a private item gets exactly the answer a missing id gets.++// A 403 ("This media is private"), a 410, or a redirect that happens only for real items all++// confirm that the item exists. Covers the public serving routes (/v /c /o /p/:slug/raw,++// /v/:id/transcript.json, legacy /api/thumbnails) and the v1 detail routes when an app acts++// for one of its users (X-OV-User-Id).++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-private404-'));++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.OV_NETWORK_URL = 'https://openvibe.network';++process.env.MEDIA_PUBLIC_URL = 'https://media.test';++process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';++delete process.env.PASTES_MOVED_TO;++++const db = require('../server/db/database');++require('../server/views/service').ensureSchema();++const model = require('../server/objects/model');++const signing = require('../server/objects/signing');++const express = require('express');++++db.upsertApp({ app_id: 'live', api_key: 'live-key' });++const raw = db.getDb();++const vodFile = (n) => { const p = path.join(process.env.VOD_PATH, n); fs.writeFileSync(p, Buffer.alloc(64, 1)); return p; };++const clipFile = (n) => { const p = path.join(process.env.CLIPS_PATH, n); fs.writeFileSync(p, Buffer.alloc(32, 2)); return p; };++++const insVod = raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds, clips_only, thumbnail_url)++ VALUES (?, 'live', ?, ?, ?, ?, ?, 10, ?, ?)`);++insVod.run(1, 5, 'Public', vodFile('vod-1.webm'), 1, 'public', 0, '/t/vod-1-111.jpg');++insVod.run(2, 5, 'Secret title', vodFile('vod-2-secret.webm'), 0, 'private', 0, '/t/vod-2-222.jpg');++insVod.run(3, 5, 'Legacy hidden', vodFile('vod-3.webm'), 0, null, 0, null);++insVod.run(4, 5, 'Clips only', vodFile('vod-4.webm'), 0, 'unlisted', 1, null);++insVod.run(6, 5, 'Unlisted', vodFile('vod-6.webm'), 0, 'unlisted', 0, null);++const insClip = raw.prepare(`INSERT INTO clips (id, app_id, vod_id, user_id, title, file_path, is_public, visibility, status)++ VALUES (?, 'live', 1, ?, ?, ?, ?, ?, 'ready')`);++insClip.run(10, 5, 'Public clip', clipFile('clip-10.webm'), 1, 'public');++insClip.run(11, 5, 'Private clip', clipFile('clip-11-secret.webm'), 0, 'private');++const insPaste = raw.prepare(`INSERT INTO pastes (slug, app_id, user_id, title, type, content, language, visibility, screenshot_path)++ VALUES (?, 'live', 5, 'P', ?, ?, 'text', ?, ?)`);++insPaste.run('pub-text', 'paste', 'hello', 'public', null);++insPaste.run('priv-text', 'paste', 'secret', 'private', null);++insPaste.run('priv-shot', 'screenshot', 'caption', 'private', '/nonexistent.png');++++const mkObj = (visibility, lifecycle_status) => model.createObject({++ app_id: 'live', namespace: 'live', kind: 'file', owner_subject: null, owner_app: 'live', owner_user_id: 5,++ visibility, lifecycle_status, mime_type: 'text/plain', size_bytes: 5, metadata: {},++});++const privDeleted = mkObj('private', 'deleted');++const privUploading = mkObj('private', 'uploading');++const pubDeleted = mkObj('public', 'deleted');++++const app = express();++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/pastes', require('../server/pastes/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 get = async (p, headers = {}, method = 'GET', body) => {++ const h = { ...headers };++ let payload;++ if (body !== undefined) { 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();++ return { status: res.status, text, 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 nav = { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' };++ const video = { accept: 'video/webm,*/*;q=0.5', 'sec-fetch-dest': 'video' };++ const asUser = (id) => ({ authorization: 'Bearer live-key', 'x-ov-user-id': String(id) });++++ // ── /v and /c: private = missing, for bytes and for the watch page ──++ let r = await same(['/v/2', video], ['/v/999', video], 'private VOD bytes look missing');++ assert.strictEqual(r.status, 404);++ assert.ok(!r.text.includes('private'), 'the answer never says "private"');++ await same(['/v/2', nav], ['/v/999', nav], 'private VOD watch page looks missing');++ await same(['/v/3', video], ['/v/999', video], 'legacy row (no visibility, is_public 0) is private');++ await same(['/v/4', video], ['/v/999', video], 'clips-only source recordings are not served');++ await same(['/v/vod-2-secret.webm', video], ['/v/no-such-file.webm', video], 'basename form: private file names are not confirmed');++ await same(['/v/clip-11-secret.webm', video], ['/v/no-such-file.webm', video], 'basename form reaching a private clip');++ await same(['/c/11', video], ['/c/999', video], 'private clip looks missing');++ await same(['/c/11', nav], ['/c/999', nav], 'private clip watch page looks missing');++ await same(['/v/2', { ...video, ...asUser(77) }], ['/v/999', { ...video, ...asUser(77) }], 'another user of the app is nobody special');++++ assert.strictEqual((await get('/v/1', video)).status, 200, 'public VOD serves');++ assert.strictEqual((await get('/v/6', video)).status, 200, 'unlisted VOD serves by direct link');++ assert.strictEqual((await get('/c/10', video)).status, 200, 'public clip serves');++ assert.strictEqual((await get('/v/2', { ...video, ...asUser(5) })).status, 200, 'the owner gets their private VOD');++ assert.strictEqual((await get('/v/2', { ...video, authorization: 'Bearer live-key' })).status, 200, 'the owning app gets it');++ assert.strictEqual((await get('/c/11', { ...video, ...asUser(5) })).status, 200, 'the owner gets their private clip');++ console.log('✅ /v /c: private looks missing');++++ // ── Transcript companion ──++ r = await same(['/v/2/transcript.json'], ['/v/999/transcript.json'], 'private VOD transcript looks missing');++ assert.strictEqual(r.status, 404);++ assert.ok(!r.text.includes('Secret title'));++ await same(['/v/3/transcript.json'], ['/v/999/transcript.json'], 'legacy private transcript looks missing');++ await same(['/v/4/transcript.json'], ['/v/999/transcript.json'], 'clips-only transcript looks missing');++ console.log('✅ /v/:id/transcript.json: private looks missing');++++ // ── Legacy thumbnail names never hand out a private row's current thumbnail URL ──++ r = await get('/api/thumbnails/vod-1-0.jpg');++ assert.deepStrictEqual([r.status, r.location], [302, '/t/vod-1-111.jpg'], 'public rows still redirect to the current thumbnail');++ await same(['/api/thumbnails/vod-2-0.jpg'], ['/api/thumbnails/vod-999-0.jpg'], 'private row: same answer as a missing one');++ r = await get('/api/thumbnails/vod-2-0.jpg');++ assert.strictEqual(r.location, null, 'no redirect for a private row');++ console.log('✅ /api/thumbnails: no redirect for private rows');++++ // ── /o: a private object is missing without a signature, whatever its lifecycle ──++ await same([`/o/${privDeleted}`], ['/o/med_01JAB2C3D4E5F6G7H8J9K0MNPQ'], 'deleted private object: 404, not 410');++ await same([`/o/${privUploading}`], ['/o/med_01JAB2C3D4E5F6G7H8J9K0MNPQ'], 'unfinished private object: 404');++ assert.strictEqual((await get(`/o/${pubDeleted}`)).status, 410, 'a deleted public object may say Gone');++ const s = signing.signedDownloadUrl(privDeleted, 60);++ assert.strictEqual((await get(s.url.replace('https://media.test', ''))).status, 410, 'with a valid signature the lifecycle answer is fine');++ console.log('✅ /o: private objects look missing before any lifecycle answer');++++ // ── Paste raw: no redirect or answer that singles out a private paste ──++ await same(['/p/priv-text/raw'], ['/p/no-such/raw'], 'private text paste looks missing');++ await same(['/p/priv-shot/raw'], ['/p/no-such/raw'], 'private screenshot paste: no redirect to its screenshot');++ assert.strictEqual((await get('/p/pub-text/raw')).text, 'hello', 'public paste raw still serves');++ process.env.PASTES_MOVED_TO = 'https://community.test';++ r = await get('/p/no-such/raw');++ assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/no-such/raw'], 'moved: every slug goes to Community');++ r = await get('/p/priv-shot/raw');++ assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/priv-shot/raw'], 'moved: a private screenshot is not bounced to /screenshot');++ delete process.env.PASTES_MOVED_TO;++ console.log('✅ /p/:slug/raw: private looks missing');++++ // ── v1 detail routes when the app acts for a user ──++ await same(['/api/v1/live/vods/2', asUser(77)], ['/api/v1/live/vods/999', asUser(77)], 'acting non-owner: private VOD looks missing');++ await same(['/api/v1/live/vods/3', asUser(77)], ['/api/v1/live/vods/999', asUser(77)], 'acting non-owner: legacy private VOD looks missing');++ await same(['/api/v1/live/clips/11', asUser(77)], ['/api/v1/live/clips/999', asUser(77)], 'acting non-owner: private clip looks missing');++ await same(['/api/v1/live/clips', asUser(77), 'POST', { vod_id: 2, start_s: 0, end_s: 5 }],++ ['/api/v1/live/clips', asUser(77), 'POST', { vod_id: 999, start_s: 0, end_s: 5 }], 'acting non-owner cannot probe a private VOD by clipping it');++ r = await get('/api/v1/live/vods/2', asUser(5));++ assert.strictEqual(r.status, 200, 'the owner, acting, sees their VOD');++ assert.strictEqual(JSON.parse(r.text).title, 'Secret title');++ assert.strictEqual((await get('/api/v1/live/vods/2', { authorization: 'Bearer live-key' })).status, 200, 'the app key alone sees the namespace');++ assert.strictEqual((await get('/api/v1/live/clips/11', asUser(5))).status, 200);++ assert.strictEqual((await get('/api/v1/live/vods/6', asUser(77))).status, 200, 'unlisted stays reachable by id');++++ const views = () => raw.prepare("SELECT views FROM pastes WHERE slug = 'priv-text'").get().views;++ const before = views();++ await same(['/api/v1/live/pastes/priv-text', asUser(77)], ['/api/v1/live/pastes/no-such-slug', asUser(77)], 'acting non-owner: private paste looks missing');++ assert.strictEqual(views(), before, 'a refused read counts no view');++ assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 200);++ console.log('✅ v1 detail: acting non-owners get the missing answer');++++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('private-not-found: all checks passed');++ process.exit(0);++})().catch((err) => { console.error(err); server.close(); process.exit(1); });
@@ -266,12 +266,17 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
@@ -266,12 +266,17 @@ the app's webhook_secret>`. 3 attempts with backoff, 10 s timeout.
| `GET /v/:id/transcript.json` | **VOD transcript API** — transcript + AI overview for one existing VOD id (`{ vod_id, title, duration_seconds, ai_overview, transcript, ai_analyzed_at }`). Private VODs → 404. Cached 30s, CORS-open. |
| `GET /v/:id/transcript.json` | **VOD transcript API** — transcript + AI overview for one existing VOD id (`{ vod_id, title, duration_seconds, ai_overview, transcript, ai_analyzed_at }`). Private VODs → 404. Cached 30s, CORS-open. |
| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |
| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |
-Private items respond 403/404 unless the request bears the owning app's API
-key or the owning user's JWT.
+Private items (and legacy rows with no visibility and `is_public = 0`) answer
+exactly like a missing id — the same 404 and body — unless the request bears the
+owning app's API key, optionally acting for the owner via `X-OV-User-Id`. That holds
+for the watch page, the bytes, `transcript.json`, the legacy `/api/thumbnails` redirect
+and `/p/:slug/raw`; the v1 detail routes (`GET /vods/:id`, `/clips/:id`, `/pastes/:slug`)
+apply it to an app acting for someone other than the owner.
`GET /o/:id` serves object bytes by canonical id (`med_…`): public/unlisted
`GET /o/:id` serves object bytes by canonical id (`med_…`): public/unlisted
objects openly, private ones only with a valid signature from
objects openly, private ones only with a valid signature from
-`/api/v2/:app/objects/:id/download`, deleted ones 410 (see below).
+`/api/v2/:app/objects/:id/download`, deleted ones 410 (a deleted private object
+without a signature is a 404 like any other private one).
## Object model and API v2
## Object model and API v2
@@ -145,7 +145,7 @@ All routes live under `/api/v2/:app/objects`.
@@ -145,7 +145,7 @@ All routes live under `/api/v2/:app/objects`.
- Public and unlisted `ready` objects are served openly.
- Public and unlisted `ready` objects are served openly.
- Private objects need a valid `?exp=&sig=` from `/download`. Without one the answer is 404, so a private object cannot be told apart from a missing one.
- Private objects need a valid `?exp=&sig=` from `/download`. Without one the answer is 404, so a private object cannot be told apart from a missing one.
-- A deleted object answers 410.
+- A deleted object answers 410 — after the check above, so a deleted private object is a 404 without a signature.
- A local copy streams with Range support. Otherwise the route redirects 302 to a presigned R2 URL, then a presigned B2 URL.
- A local copy streams with Range support. Otherwise the route redirects 302 to a presigned R2 URL, then a presigned B2 URL.
- Headers: `X-Content-Type-Options: nosniff` and `X-Robots-Tag: noindex`. Content is `inline` only for images (not SVG), video, audio, PDF and plain text, and `attachment` for everything else.
- Headers: `X-Content-Type-Options: nosniff` and `X-Robots-Tag: noindex`. Content is `inline` only for images (not SVG), video, audio, PDF and plain text, and `attachment` for everything else.
@@ -379,13 +379,14 @@ publicRouter.get('/:id', async (req, res) => {
@@ -379,13 +379,14 @@ publicRouter.get('/:id', async (req, res) => {
try {
try {
const obj = model.getObject(String(req.params.id || ''));
const obj = model.getObject(String(req.params.id || ''));
if (!obj) return res.status(404).json({ error: 'Not found' });
if (!obj) return res.status(404).json({ error: 'Not found' });
- if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });
- if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });
const signed = !!req.query.sig && signing.verifyDownload(obj.id, req.query.exp, req.query.sig);
const signed = !!req.query.sig && signing.verifyDownload(obj.id, req.query.exp, req.query.sig);
// Private objects (and every developer-project sandbox object) are indistinguishable from
// Private objects (and every developer-project sandbox object) are indistinguishable from
- // missing ones without a valid signature.
+ // missing ones without a valid signature — checked before the lifecycle answers, or a 410
+ // for a deleted private object would still say it existed.
const sandbox = db.isSandboxTenant(obj.app_id);
const sandbox = db.isSandboxTenant(obj.app_id);
if ((obj.visibility === 'private' || sandbox) && !signed) return res.status(404).json({ error: 'Not found' });
if ((obj.visibility === 'private' || sandbox) && !signed) return res.status(404).json({ error: 'Not found' });
+ if (obj.lifecycle_status === 'deleted') return res.status(410).json({ error: 'Gone' });
+ if (obj.lifecycle_status !== 'ready') return res.status(404).json({ error: 'Not found' });
const mime = obj.mime_type || 'application/octet-stream';
const mime = obj.mime_type || 'application/octet-stream';
const md = model.parseJson(obj.metadata, {});
const md = model.parseJson(obj.metadata, {});
@@ -451,6 +451,11 @@ router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
@@ -451,6 +451,11 @@ router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
try {
try {
const paste = _getPasteScoped(req, res);
const paste = _getPasteScoped(req, res);
if (!paste) return;
if (!paste) return;
+ // Private pastes: owner (or the app itself) only — decided before anything is counted.
+ if (paste.visibility === 'private') {
+ const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);
+ if (!allowed) return res.status(404).json({ error: 'Paste not found' });
+ }
// The owning app renders the paste page itself — count the view here (real client
// The owning app renders the paste page itself — count the view here (real client
// IP via X-Forwarded-For, viewer via the user JWT / X-OV-User-Id) unless it says
// IP via X-Forwarded-For, viewer via the user JWT / X-OV-User-Id) unless it says
// this fetch is not a page view (?no_view=1 for edit forms, embeds, bots).
// this fetch is not a page view (?no_view=1 for edit forms, embeds, bots).
@@ -458,11 +463,6 @@ router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
@@ -458,11 +463,6 @@ router.get('/:slug', tenantAuth({ allowUser: true }), (req, res) => {
try { const r = require('../views/service').recordView('paste', paste.id, { req, ownerUserId: paste.user_id }); if (r.view_count != null) { paste.views = r.view_count; paste.unique_views = r.unique_views; } } catch { /* */ }
try { const r = require('../views/service').recordView('paste', paste.id, { req, ownerUserId: paste.user_id }); if (r.view_count != null) { paste.views = r.view_count; paste.unique_views = r.unique_views; } } catch { /* */ }
}
}
- // Private pastes: owner (or the app itself) only.
- if (paste.visibility === 'private') {
- const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);
- if (!allowed) return res.status(404).json({ error: 'Paste not found' });
- }
paste.liked = req.userId != null ? db.hasUserLikedPaste(paste.id, req.userId) : false;
paste.liked = req.userId != null ? db.hasUserLikedPaste(paste.id, req.userId) : false;
res.json({ paste: { ...pastePublic(paste), liked: paste.liked } });
res.json({ paste: { ...pastePublic(paste), liked: paste.liked } });
@@ -155,8 +155,11 @@ function getTranscriptTimeline(appId, sel, limit) {
@@ -155,8 +155,11 @@ function getTranscriptTimeline(appId, sel, limit) {
/** Transcript + AI overview for one existing VOD id. */
/** Transcript + AI overview for one existing VOD id. */
function getVodTranscript(vodId) {
function getVodTranscript(vodId) {
return cached(`vt:${vodId}`, async () => {
return cached(`vt:${vodId}`, async () => {
- const v = db.get(`SELECT ${VOD_COLS}, app_id, is_public FROM vods WHERE id = ?`, [vodId]);
- if (!v || v.visibility === 'private') return { status: 404, body: { error: 'VOD not found' } };
+ const v = db.get(`SELECT ${VOD_COLS}, app_id, is_public, clips_only FROM vods WHERE id = ?`, [vodId]);
+ // Private (including legacy rows with no visibility and is_public = 0) and clips-only
+ // source recordings answer exactly like a missing id — the same rule /v/:id applies.
+ const hidden = v && ((v.visibility || (v.is_public ? 'public' : 'private')) === 'private' || v.clips_only);
+ if (!v || hidden) return { status: 404, body: { error: 'VOD not found' } };
const txs = await _appTranscripts(v.app_id, [v.id]);
const txs = await _appTranscripts(v.app_id, [v.id]);
return { status: 200, body: { ..._vodEntry(v, { tx: txs[v.id] }), app_id: v.app_id, generated_at: new Date().toISOString() } };
return { status: 200, body: { ..._vodEntry(v, { tx: txs[v.id] }), app_id: v.app_id, generated_at: new Date().toISOString() } };
});
});
@@ -47,6 +47,16 @@ function trackUniqueView(kind, id, req, ownerUserId = null) {
@@ -47,6 +47,16 @@ function trackUniqueView(kind, id, req, ownerUserId = null) {
try { if (views.isInitialPlaybackRequest(req)) views.recordView(kind, id, { req, ownerUserId }); } catch { /* non-critical */ }
try { if (views.isInitialPlaybackRequest(req)) views.recordView(kind, id, { req, ownerUserId }); } catch { /* non-critical */ }
}
}
+/** public | unlisted | private. Rows without a visibility fall back to is_public (0 = private). */
+function recordVisibility(record) {
+ return record.visibility || (record.is_public ? 'public' : 'private');
+}
+
+/** The single "no such thing" answer for every id-addressed route, used for private items too. */
+function notFound(res) {
+ return res.status(404).json({ error: 'Not found' });
+}
+
function canAccessPrivate(record, req) {
function canAccessPrivate(record, req) {
// 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;
@@ -111,10 +121,10 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -111,10 +121,10 @@ async function serveMediaRecord(kind, record, req, res) {
const vodStorage = require('../vod/vod-storage');
const vodStorage = require('../vod/vod-storage');
const noindex = { 'X-Robots-Tag': 'noindex' };
const noindex = { 'X-Robots-Tag': 'noindex' };
- const visibility = record.visibility || (record.is_public ? 'public' : 'private');
- if (visibility === 'private' && !canAccessPrivate(record, req)) {
- return res.status(403).json({ error: 'This media is private' });
- }
+ 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);
// 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.
// page; its <video> comes back here with ?raw=1 for the bytes.
@@ -181,14 +191,14 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {
@@ -181,14 +191,14 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {
try {
try {
if (/^\d+$/.test(req.params.id)) {
if (/^\d+$/.test(req.params.id)) {
const vod = db.getVodById(parseInt(req.params.id, 10));
const vod = db.getVodById(parseInt(req.params.id, 10));
- if (!vod || vod.clips_only) return res.status(404).json({ error: 'Not found' });
+ if (!vod || vod.clips_only) return notFound(res);
return await serveMediaRecord('vod', vod, req, res);
return await serveMediaRecord('vod', vod, req, res);
}
}
const vod = db.getVodByFileBasename(req.params.id);
const vod = db.getVodByFileBasename(req.params.id);
if (vod && !vod.clips_only) return await serveMediaRecord('vod', vod, req, res);
if (vod && !vod.clips_only) return await serveMediaRecord('vod', vod, req, res);
const clip = db.getClipByFileBasename(req.params.id);
const clip = db.getClipByFileBasename(req.params.id);
if (clip) return await serveMediaRecord('clip', clip, req, res);
if (clip) return await serveMediaRecord('clip', clip, req, res);
- res.status(404).json({ error: 'Not found' });
+ notFound(res);
} catch (err) {
} catch (err) {
console.error('[Public] /v error:', err.message);
console.error('[Public] /v error:', err.message);
if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });
if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });
@@ -198,9 +208,9 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {
@@ -198,9 +208,9 @@ router.get('/v/:id', optionalIdentity, async (req, res) => {
// ── Clip playback ────────────────────────────────────────────
// ── Clip playback ────────────────────────────────────────────
router.get('/c/:id', optionalIdentity, async (req, res) => {
router.get('/c/:id', optionalIdentity, async (req, res) => {
try {
try {
- if (!/^\d+$/.test(req.params.id)) return res.status(404).json({ error: 'Not found' });
+ if (!/^\d+$/.test(req.params.id)) return notFound(res);
const clip = db.getClipById(parseInt(req.params.id, 10));
const clip = db.getClipById(parseInt(req.params.id, 10));
- if (!clip || !clip.file_path) return res.status(404).json({ error: 'Not found' });
+ if (!clip || !clip.file_path) return notFound(res);
await serveMediaRecord('clip', clip, req, res);
await serveMediaRecord('clip', clip, req, res);
} catch (err) {
} catch (err) {
console.error('[Public] /c error:', err.message);
console.error('[Public] /c error:', err.message);
@@ -355,7 +365,9 @@ router.get('/api/thumbnails/:name', (req, res) => {
@@ -355,7 +365,9 @@ router.get('/api/thumbnails/:name', (req, res) => {
const row = m[1].toLowerCase() === 'vod'
const row = m[1].toLowerCase() === 'vod'
? db.getVodById(parseInt(m[2], 10), 'live')
? db.getVodById(parseInt(m[2], 10), 'live')
: db.getClipById(parseInt(m[2], 10), 'live');
: db.getClipById(parseInt(m[2], 10), 'live');
- if (row && row.thumbnail_url && !row.thumbnail_url.endsWith(`/${name}`)) {
+ // Never for a private row: the redirect would hand out the current thumbnail URL of any
+ // private recording to whoever guesses vod-<id>-0.jpg. The exact file name still serves.
+ if (row && row.thumbnail_url && recordVisibility(row) !== 'private' && !row.thumbnail_url.endsWith(`/${name}`)) {
res.set('Cache-Control', 'public, max-age=3600');
res.set('Cache-Control', 'public, max-age=3600');
return res.redirect(302, row.thumbnail_url);
return res.redirect(302, row.thumbnail_url);
}
}
@@ -484,16 +496,18 @@ router.get('/p/:slug', optionalIdentity, (req, res) => {
@@ -484,16 +496,18 @@ router.get('/p/:slug', optionalIdentity, (req, res) => {
router.get('/p/:slug/raw', (req, res) => {
router.get('/p/:slug/raw', (req, res) => {
try {
try {
- const paste = db.getPasteBySlug(String(req.params.slug));
+ const found = db.getPasteBySlug(String(req.params.slug));
+ // A private paste answers exactly like a missing slug (no redirect that proves it exists).
+ const paste = found && found.visibility !== 'private' ? found : null;
// Image pastes have no raw text — bounce to the screenshot (stale
// Image pastes have no raw text — bounce to the screenshot (stale
// consumers stored /raw URLs for hero-moment images).
// consumers stored /raw URLs for hero-moment images).
if (paste && paste.type === 'screenshot') {
if (paste && paste.type === 'screenshot') {
res.set('Cache-Control', 'public, max-age=3600');
res.set('Cache-Control', 'public, max-age=3600');
return res.redirect(302, `/p/${encodeURIComponent(paste.slug)}/screenshot`);
return res.redirect(302, `/p/${encodeURIComponent(paste.slug)}/screenshot`);
}
}
+ // Moved: every slug goes to Community, found or not, like /p/:slug.
+ if (movedTo()) return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(String(req.params.slug))}/raw`);
if (!paste || paste.type !== 'paste') return res.status(404).send('Not found');
if (!paste || paste.type !== 'paste') return res.status(404).send('Not found');
- if (movedTo()) return res.redirect(301, `${movedTo()}/p/${encodeURIComponent(paste.slug)}/raw`);
- if (paste.visibility === 'private') return res.status(404).send('Not found');
// Burn after read
// Burn after read
if (paste.burn_after_read && paste.views > 0) {
if (paste.burn_after_read && paste.views > 0) {
@@ -523,3 +537,4 @@ router.get('/p/:slug/screenshot', (req, res) => {
@@ -523,3 +537,4 @@ router.get('/p/:slug/screenshot', (req, res) => {
module.exports = router;
module.exports = router;
module.exports.streamFileWithRange = streamFileWithRange;
module.exports.streamFileWithRange = streamFileWithRange;
+module.exports.recordVisibility = recordVisibility;
@@ -98,6 +98,11 @@ function sanitizeClipTitle(title, fallback = 'Untitled Clip') {
@@ -98,6 +98,11 @@ function sanitizeClipTitle(title, fallback = 'Untitled Clip') {
return title.replace(/<[^>]*>/g, '').replace(/&[a-z]+;/gi, '').trim().slice(0, 200) || fallback;
return title.replace(/<[^>]*>/g, '').replace(/&[a-z]+;/gi, '').trim().slice(0, 200) || fallback;
}
}
+/** Private, including legacy rows that have no visibility and is_public = 0. */
+function _isPrivate(row) {
+ return (row.visibility || (row.is_public ? 'public' : 'private')) === 'private';
+}
+
function _getClipScoped(req, res) {
function _getClipScoped(req, res) {
const id = parseInt(req.params.id, 10);
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) { res.status(404).json({ error: 'Clip not found' }); return null; }
if (!Number.isFinite(id)) { res.status(404).json({ error: 'Clip not found' }); return null; }
@@ -120,6 +125,10 @@ router.post('/', tenantAuth({ allowUser: true }), clipUpload.single('video'), as
@@ -120,6 +125,10 @@ router.post('/', tenantAuth({ allowUser: true }), clipUpload.single('video'), as
if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });
if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });
const vod = db.getVodById(vodId, req.appId);
const vod = db.getVodById(vodId, req.appId);
if (!vod) return res.status(404).json({ error: 'VOD not found' });
if (!vod) return res.status(404).json({ error: 'VOD not found' });
+ // An acting user cannot cut someone else's private VOD, and is not told it exists.
+ if (req.authType === 'user' && _isPrivate(vod) && !(vod.user_id != null && String(vod.user_id) === String(req.userId))) {
+ return res.status(404).json({ error: 'VOD not found' });
+ }
let startTime = Number.parseFloat(body.start_s ?? body.start_time);
let startTime = Number.parseFloat(body.start_s ?? body.start_time);
let endTime = Number.parseFloat(body.end_s ?? body.end_time);
let endTime = Number.parseFloat(body.end_s ?? body.end_time);
@@ -313,6 +322,10 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
@@ -313,6 +322,10 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
try {
try {
const clip = _getClipScoped(req, res);
const clip = _getClipScoped(req, res);
if (!clip) return;
if (!clip) return;
+ // Acting for one of the app's users: another user's private clip looks missing (see GET /vods/:id).
+ if (req.authType === 'user' && _isPrivate(clip) && !(clip.user_id != null && String(clip.user_id) === String(req.userId))) {
+ return res.status(404).json({ error: 'Clip not found' });
+ }
// Bare object, mirroring GET /vods/:id per CONTRACTS.md.
// Bare object, mirroring GET /vods/:id per CONTRACTS.md.
// Detail responses carry the transcript too (lists stay light).
// Detail responses carry the transcript too (lists stay light).
res.json({ ...clipPublic(clip), ai_transcript: clip.ai_transcript || null });
res.json({ ...clipPublic(clip), ai_transcript: clip.ai_transcript || null });
@@ -66,6 +66,11 @@ const chunkUpload = multer({
@@ -66,6 +66,11 @@ const chunkUpload = multer({
*/
*/
const activeChunkUploads = new Map();
const activeChunkUploads = new Map();
+/** Private, including legacy rows that have no visibility and is_public = 0. */
+function _isPrivate(row) {
+ return (row.visibility || (row.is_public ? 'public' : 'private')) === 'private';
+}
+
function _getVodScoped(req, res) {
function _getVodScoped(req, res) {
const id = parseInt(req.params.id, 10);
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id)) { res.status(404).json({ error: 'VOD not found' }); return null; }
if (!Number.isFinite(id)) { res.status(404).json({ error: 'VOD not found' }); return null; }
@@ -308,6 +313,11 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
@@ -308,6 +313,11 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
try {
try {
const vod = _getVodScoped(req, res);
const vod = _getVodScoped(req, res);
if (!vod) return;
if (!vod) return;
+ // Acting for one of the app's users (X-OV-User-Id): their private VODs only — anyone
+ // else's answers exactly like a missing id. The app key alone sees the namespace.
+ if (req.authType === 'user' && _isPrivate(vod) && !(vod.user_id != null && String(vod.user_id) === String(req.userId))) {
+ return res.status(404).json({ error: 'VOD not found' });
+ }
// Self-heal a missing duration on a finished local VOD.
// Self-heal a missing duration on a finished local VOD.
if (!vod.is_recording && (!vod.duration_seconds || vod.duration_seconds <= 0) && vod.file_path && fs.existsSync(vod.file_path)) {
if (!vod.is_recording && (!vod.duration_seconds || vod.duration_seconds <= 0) && vod.file_path && fs.existsSync(vod.file_path)) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -0,0 +1,173 @@
+'use strict';
+// Privacy: anyone who may not see a private item gets exactly the answer a missing id gets.
+// A 403 ("This media is private"), a 410, or a redirect that happens only for real items all
+// confirm that the item exists. Covers the public serving routes (/v /c /o /p/:slug/raw,
+// /v/:id/transcript.json, legacy /api/thumbnails) and the v1 detail routes when an app acts
+// for one of its users (X-OV-User-Id).
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-private404-'));
+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.OV_NETWORK_URL = 'https://openvibe.network';
+process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
+delete process.env.PASTES_MOVED_TO;
+
+const db = require('../server/db/database');
+require('../server/views/service').ensureSchema();
+const model = require('../server/objects/model');
+const signing = require('../server/objects/signing');
+const express = require('express');
+
+db.upsertApp({ app_id: 'live', api_key: 'live-key' });
+const raw = db.getDb();
+const vodFile = (n) => { const p = path.join(process.env.VOD_PATH, n); fs.writeFileSync(p, Buffer.alloc(64, 1)); return p; };
+const clipFile = (n) => { const p = path.join(process.env.CLIPS_PATH, n); fs.writeFileSync(p, Buffer.alloc(32, 2)); return p; };
+
+const insVod = raw.prepare(`INSERT INTO vods (id, app_id, user_id, title, file_path, is_public, visibility, duration_seconds, clips_only, thumbnail_url)
+ VALUES (?, 'live', ?, ?, ?, ?, ?, 10, ?, ?)`);
+insVod.run(1, 5, 'Public', vodFile('vod-1.webm'), 1, 'public', 0, '/t/vod-1-111.jpg');
+insVod.run(2, 5, 'Secret title', vodFile('vod-2-secret.webm'), 0, 'private', 0, '/t/vod-2-222.jpg');
+insVod.run(3, 5, 'Legacy hidden', vodFile('vod-3.webm'), 0, null, 0, null);
+insVod.run(4, 5, 'Clips only', vodFile('vod-4.webm'), 0, 'unlisted', 1, null);
+insVod.run(6, 5, 'Unlisted', vodFile('vod-6.webm'), 0, 'unlisted', 0, null);
+const insClip = raw.prepare(`INSERT INTO clips (id, app_id, vod_id, user_id, title, file_path, is_public, visibility, status)
+ VALUES (?, 'live', 1, ?, ?, ?, ?, ?, 'ready')`);
+insClip.run(10, 5, 'Public clip', clipFile('clip-10.webm'), 1, 'public');
+insClip.run(11, 5, 'Private clip', clipFile('clip-11-secret.webm'), 0, 'private');
+const insPaste = raw.prepare(`INSERT INTO pastes (slug, app_id, user_id, title, type, content, language, visibility, screenshot_path)
+ VALUES (?, 'live', 5, 'P', ?, ?, 'text', ?, ?)`);
+insPaste.run('pub-text', 'paste', 'hello', 'public', null);
+insPaste.run('priv-text', 'paste', 'secret', 'private', null);
+insPaste.run('priv-shot', 'screenshot', 'caption', 'private', '/nonexistent.png');
+
+const mkObj = (visibility, lifecycle_status) => model.createObject({
+ app_id: 'live', namespace: 'live', kind: 'file', owner_subject: null, owner_app: 'live', owner_user_id: 5,
+ visibility, lifecycle_status, mime_type: 'text/plain', size_bytes: 5, metadata: {},
+});
+const privDeleted = mkObj('private', 'deleted');
+const privUploading = mkObj('private', 'uploading');
+const pubDeleted = mkObj('public', 'deleted');
+
+const app = express();
+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/pastes', require('../server/pastes/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 get = async (p, headers = {}, method = 'GET', body) => {
+ const h = { ...headers };
+ let payload;
+ if (body !== undefined) { 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();
+ return { status: res.status, text, 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 nav = { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' };
+ const video = { accept: 'video/webm,*/*;q=0.5', 'sec-fetch-dest': 'video' };
+ const asUser = (id) => ({ authorization: 'Bearer live-key', 'x-ov-user-id': String(id) });
+
+ // ── /v and /c: private = missing, for bytes and for the watch page ──
+ let r = await same(['/v/2', video], ['/v/999', video], 'private VOD bytes look missing');
+ assert.strictEqual(r.status, 404);
+ assert.ok(!r.text.includes('private'), 'the answer never says "private"');
+ await same(['/v/2', nav], ['/v/999', nav], 'private VOD watch page looks missing');
+ await same(['/v/3', video], ['/v/999', video], 'legacy row (no visibility, is_public 0) is private');
+ await same(['/v/4', video], ['/v/999', video], 'clips-only source recordings are not served');
+ await same(['/v/vod-2-secret.webm', video], ['/v/no-such-file.webm', video], 'basename form: private file names are not confirmed');
+ await same(['/v/clip-11-secret.webm', video], ['/v/no-such-file.webm', video], 'basename form reaching a private clip');
+ await same(['/c/11', video], ['/c/999', video], 'private clip looks missing');
+ await same(['/c/11', nav], ['/c/999', nav], 'private clip watch page looks missing');
+ await same(['/v/2', { ...video, ...asUser(77) }], ['/v/999', { ...video, ...asUser(77) }], 'another user of the app is nobody special');
+
+ assert.strictEqual((await get('/v/1', video)).status, 200, 'public VOD serves');
+ assert.strictEqual((await get('/v/6', video)).status, 200, 'unlisted VOD serves by direct link');
+ assert.strictEqual((await get('/c/10', video)).status, 200, 'public clip serves');
+ assert.strictEqual((await get('/v/2', { ...video, ...asUser(5) })).status, 200, 'the owner gets their private VOD');
+ assert.strictEqual((await get('/v/2', { ...video, authorization: 'Bearer live-key' })).status, 200, 'the owning app gets it');
+ assert.strictEqual((await get('/c/11', { ...video, ...asUser(5) })).status, 200, 'the owner gets their private clip');
+ console.log('✅ /v /c: private looks missing');
+
+ // ── Transcript companion ──
+ r = await same(['/v/2/transcript.json'], ['/v/999/transcript.json'], 'private VOD transcript looks missing');
+ assert.strictEqual(r.status, 404);
+ assert.ok(!r.text.includes('Secret title'));
+ await same(['/v/3/transcript.json'], ['/v/999/transcript.json'], 'legacy private transcript looks missing');
+ await same(['/v/4/transcript.json'], ['/v/999/transcript.json'], 'clips-only transcript looks missing');
+ console.log('✅ /v/:id/transcript.json: private looks missing');
+
+ // ── Legacy thumbnail names never hand out a private row's current thumbnail URL ──
+ r = await get('/api/thumbnails/vod-1-0.jpg');
+ assert.deepStrictEqual([r.status, r.location], [302, '/t/vod-1-111.jpg'], 'public rows still redirect to the current thumbnail');
+ await same(['/api/thumbnails/vod-2-0.jpg'], ['/api/thumbnails/vod-999-0.jpg'], 'private row: same answer as a missing one');
+ r = await get('/api/thumbnails/vod-2-0.jpg');
+ assert.strictEqual(r.location, null, 'no redirect for a private row');
+ console.log('✅ /api/thumbnails: no redirect for private rows');
+
+ // ── /o: a private object is missing without a signature, whatever its lifecycle ──
+ await same([`/o/${privDeleted}`], ['/o/med_01JAB2C3D4E5F6G7H8J9K0MNPQ'], 'deleted private object: 404, not 410');
+ await same([`/o/${privUploading}`], ['/o/med_01JAB2C3D4E5F6G7H8J9K0MNPQ'], 'unfinished private object: 404');
+ assert.strictEqual((await get(`/o/${pubDeleted}`)).status, 410, 'a deleted public object may say Gone');
+ const s = signing.signedDownloadUrl(privDeleted, 60);
+ assert.strictEqual((await get(s.url.replace('https://media.test', ''))).status, 410, 'with a valid signature the lifecycle answer is fine');
+ console.log('✅ /o: private objects look missing before any lifecycle answer');
+
+ // ── Paste raw: no redirect or answer that singles out a private paste ──
+ await same(['/p/priv-text/raw'], ['/p/no-such/raw'], 'private text paste looks missing');
+ await same(['/p/priv-shot/raw'], ['/p/no-such/raw'], 'private screenshot paste: no redirect to its screenshot');
+ assert.strictEqual((await get('/p/pub-text/raw')).text, 'hello', 'public paste raw still serves');
+ process.env.PASTES_MOVED_TO = 'https://community.test';
+ r = await get('/p/no-such/raw');
+ assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/no-such/raw'], 'moved: every slug goes to Community');
+ r = await get('/p/priv-shot/raw');
+ assert.deepStrictEqual([r.status, r.location], [301, 'https://community.test/p/priv-shot/raw'], 'moved: a private screenshot is not bounced to /screenshot');
+ delete process.env.PASTES_MOVED_TO;
+ console.log('✅ /p/:slug/raw: private looks missing');
+
+ // ── v1 detail routes when the app acts for a user ──
+ await same(['/api/v1/live/vods/2', asUser(77)], ['/api/v1/live/vods/999', asUser(77)], 'acting non-owner: private VOD looks missing');
+ await same(['/api/v1/live/vods/3', asUser(77)], ['/api/v1/live/vods/999', asUser(77)], 'acting non-owner: legacy private VOD looks missing');
+ await same(['/api/v1/live/clips/11', asUser(77)], ['/api/v1/live/clips/999', asUser(77)], 'acting non-owner: private clip looks missing');
+ await same(['/api/v1/live/clips', asUser(77), 'POST', { vod_id: 2, start_s: 0, end_s: 5 }],
+ ['/api/v1/live/clips', asUser(77), 'POST', { vod_id: 999, start_s: 0, end_s: 5 }], 'acting non-owner cannot probe a private VOD by clipping it');
+ r = await get('/api/v1/live/vods/2', asUser(5));
+ assert.strictEqual(r.status, 200, 'the owner, acting, sees their VOD');
+ assert.strictEqual(JSON.parse(r.text).title, 'Secret title');
+ assert.strictEqual((await get('/api/v1/live/vods/2', { authorization: 'Bearer live-key' })).status, 200, 'the app key alone sees the namespace');
+ assert.strictEqual((await get('/api/v1/live/clips/11', asUser(5))).status, 200);
+ assert.strictEqual((await get('/api/v1/live/vods/6', asUser(77))).status, 200, 'unlisted stays reachable by id');
+
+ const views = () => raw.prepare("SELECT views FROM pastes WHERE slug = 'priv-text'").get().views;
+ const before = views();
+ await same(['/api/v1/live/pastes/priv-text', asUser(77)], ['/api/v1/live/pastes/no-such-slug', asUser(77)], 'acting non-owner: private paste looks missing');
+ assert.strictEqual(views(), before, 'a refused read counts no view');
+ assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 200);
+ console.log('✅ v1 detail: acting non-owners get the missing answer');
+
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('private-not-found: all checks passed');
+ process.exit(0);
+})().catch((err) => { console.error(err); server.close(); process.exit(1); });