openvibers/openvibe.media Commits
Media: the file-name routes go: /v/<name>, /api/thumbnails/<name>, /f/screenshots/<name> (C-75) (#88)
Changes
@@@ -419,7 +419,7 @@ mode (`server/drill.js`) Media:- writes no file and creates no directory (the storage checks leave `/api/ready`, as do the remotetiers; it reports `"mode": "drill"`);- never opens a file path from the database: every byte route (`/v` and `/c` bytes, `/t`, `/a`, `/f`,-- `/o`, `/p/:slug/screenshot`, `/live/:sel/frame.jpg`, `/api/thumbnails/:name`) answers 503;++ `/o`, `/p/:slug/screenshot`, `/live/:sel/frame.jpg`) answers 503;watch pages and the `/browse` index still render from the copy;- connects to nothing and runs no program but `git` (no ffmpeg or ffprobe), opens no UDP socket andlistens on nothing but its port;@@@ -515,14 +515,13 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.| path | behavior ||---|---|--| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex`. Also accepts a legacy **file basename** (old `/api/vods/file/<name>` URLs; clip basenames resolve too) |++| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex`. By numeric id only: the file-name form was retired with C-75 (2026-10-11) || `GET /c/:id` | clip playback, same logic, `noindex` || `GET /p/:slug` | **301** to OpenVibe.Community, the canonical home for pastes since 2026-09-22 (every slug, found or not) || `GET /p/:slug/raw` | **301** to Community's raw text || `GET /p/:slug/screenshot` | 301 to a ready, nonprivate legacy screenshot or avatar object; otherwise 301 to Community || `GET /t/:id` | thumbnail (id = filename), `noindex` || `GET /f/:key` | file with stored Content-Type + Range, `noindex` |--| `GET /f/screenshots/:name` | paste screenshot by filename — serves straight from `PASTES_PATH/screenshots` (migrated legacy files have no files-table rows), `noindex` || `GET /live/:sel/transcript.json` | **transcript + AI timeline API** — full audio-transcription log and AI overview timeline. `:sel` = slot id / slot slug → slot-scoped sessions; **`@username`** → user-scoped (all their slots, works while offline). Returns `{ live, current, sessions[] (each: ai_overview, transcript, duration…), streamer (overview + stream memories), user }`. `?limit=1..50` sessions (default 10), `?app=`. Cached **30s** (that's the rate limit), CORS-open. || `GET /live/:sel/chat-insight.json` | **chat insight API** — a user's chat-related AI insight/timeline (`:sel` = `@username` or numeric user id): today-vs-alltime chat overviews, condensed memory, event timeline, plus their streamer overview + stream memories when they stream. Proxied from the app's public chat-AI API over loopback; 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. |@@@ -543,8 +542,7 @@ wherever they belong, labelled "AI clip", attributed to no person (`creator` OpePrivate items (and legacy rows with no visibility and `is_public = 0`) answerexactly like a missing id — the same 404 and body — unless the request bears theowning 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`)++for the watch page, the bytes, `transcript.json` and `/p/:slug/raw`; the v1 detail routes (`GET /vods/:id`, `/clips/:id`)apply it to an app acting for someone other than the owner. A call acting for a user writesonly that user's VODs and clips (update, delete, ingest, chunks, finalize, re-cut, thumbnail):someone else's private one is the missing answer, anything else 403. The media index lists a@@@ -232,22 +232,14 @@ async function serveMediaRecord(kind, record, req, res) {}// ── VOD playback ─────────────────────────────────────────────--// Accepts a numeric id OR a legacy file basename (old /api/vods/file/<name>--// URLs in the wild redirect here with the basename; migrated rows keep their--// original file_path basenames). The old route served CLIP files too, so a--// basename that matches a clip serves that clip.++// By numeric id only: a file name is not a route (the basename fallback for old /api/vods/file/<name> URLs++// was retired with compatibility register C-75 once Live asked by id).router.get('/v/:id', optionalIdentity, async (req, res) => {try {-- if (/^\d+$/.test(req.params.id)) {-- const vod = await db.getVodById(parseInt(req.params.id, 10));-- if (!vod || vod.clips_only) return notFound(res);-- return await serveMediaRecord('vod', vod, req, res);-- }-- const vod = await db.getVodByFileBasename(req.params.id);-- if (vod && !vod.clips_only) return await serveMediaRecord('vod', vod, req, res);-- const clip = await db.getClipByFileBasename(req.params.id);-- if (clip) return await serveMediaRecord('clip', clip, req, res);-- notFound(res);++ if (!/^\d+$/.test(req.params.id)) return notFound(res);++ const vod = await db.getVodById(parseInt(req.params.id, 10));++ if (!vod || vod.clips_only) return notFound(res);++ return await serveMediaRecord('vod', vod, req, res);} catch (err) {console.error('[Public] /v error:', err.message);if (!res.headersSent) res.status(500).json({ error: 'Failed to serve media' });@@@ -426,53 +418,6 @@ router.get('/v/:id/transcript.json', _devDataRoute((req) => {return require('./dev-data').getVodTranscript(id);}));--// ── Legacy thumbnail URLs ────────────────────────────────────--// Old-stack thumbnails lived at /api/thumbnails/<basename>, and migrated Live--// rows still carry those absolute URLs. Basenames change when a thumbnail is--// regenerated, so resolve the vod/clip id from the name and redirect to the--// current canonical URL; serve the exact file when it still exists.--router.get('/api/thumbnails/:name', async (req, res) => {-- if (drill.refuseBytes(res)) return;-- const name = path.basename(String(req.params.name || ''));-- const m = /^(vod|clip)-(\d+)-\d+\.(?:jpg|jpeg|png)$/i.exec(name);-- if (m) {-- const row = m[1].toLowerCase() === 'vod'-- ? await db.getVodById(parseInt(m[2], 10), 'live')-- : await db.getClipById(parseInt(m[2], 10), 'live');-- // 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}`)) {-- // A regenerated thumbnail keeps its old URL until the row is updated: no stale serving, or the-- // redirect could point at the stale thumbnail for another hour.-- res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600, swr: 0 }));-- return res.redirect(302, row.thumbnail_url);-- }-- }-- req.params.id = name;-- require('../thumbnails/thumbnail-service').serveThumbnail(req, res);--});----// ── Paste screenshots by filename ────────────────────────────--// Legacy /data/pastes/screenshots/<name> URLs (old avatars, hero moments,--// pre-cutover pastes) map here. Migrated screenshot files have arbitrary--// basenames on disk and NO files-table rows, so this serves straight from--// PASTES_PATH/screenshots for exactly that namespace.--router.get('/f/screenshots/:name', (req, res) => {-- if (drill.refuseBytes(res)) return;-- try {-- const name = path.basename(String(req.params.name || ''));-- const dir = path.join(require('../config').pastes.path, 'screenshots');-- const filePath = path.join(dir, name);-- if (!name || !filePath.startsWith(dir) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {-- return res.status(404).json({ error: 'Not found' });-- }-- streamFileWithRange(req, res, filePath, { 'Cache-Control': 'public, max-age=604800, immutable', 'X-Robots-Tag': 'noindex' });-- } catch (err) {-- console.error('[Public] /f/screenshots error:', err.message);-- if (!res.headersSent) res.status(500).json({ error: 'Failed to serve file' });-- }--});--// ── Files ────────────────────────────────────────────────────router.get('/f/:key', async (req, res) => {if (drill.refuseBytes(res)) return;@@@ -22,7 +22,7 @@ const tools = require('./media-tools');const { announce } = require('../announce');const objects = () => require('../objects/model');--/** Absolute-ize a stored Media-relative URL (/t/…, /api/thumbnails/…). */++/** Absolute-ize a stored Media-relative URL (/t/…). */function _absUrl(u) {if (!u) return null;if (/^https?:\/\//i.test(u)) return u;@@@ -236,8 +236,8 @@ const { spawnSync, execFileSync } = require('child_process');assert.deepStrictEqual(spied.dbPathTouches, []);});await check('every route that would stream a stored file answers 503 without opening the database\'s path', async () => {-- const byteRoutes = ['/v/1', '/v/1?raw=1', '/v/vod-1.mp4', '/c/1', '/c/1?raw=1', `/o/${objectId}`, '/t/vod-1-1.jpg', '/api/thumbnails/vod-1-1.jpg',-- '/a/1', '/f/f1.txt', '/f/screenshots/shot-1.png', '/p/shot1/screenshot', '/live/1/frame.jpg'];++ const byteRoutes = ['/v/1', '/v/1?raw=1', '/c/1', '/c/1?raw=1', `/o/${objectId}`, '/t/vod-1-1.jpg',++ '/a/1', '/f/f1.txt', '/p/shot1/screenshot', '/live/1/frame.jpg'];for (const p of byteRoutes) {const r = await request('GET', p, { headers: { Range: 'bytes=0-' } });assert.strictEqual(r.status, 503, `${p}: ${r.status} ${r.text.slice(0, 120)}`);@@@ -2,8 +2,9 @@// 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,--// /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).++// /v/:id/transcript.json) and the v1 detail routes when an app acts for one of its users (X-OV-User-Id).++// The predecessor-shaped routes (/v/<file name>, /api/thumbnails/<name>, /f/screenshots/<name>) are++// retired (C-75): they answer the missing-id 404 even for public rows.const assert = require('assert');const fs = require('fs');const os = require('os');@@@ -88,8 +89,8 @@ const http = require('http');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(['/v/vod-2-secret.webm', video], ['/v/no-such-file.webm', video], 'a file name is not a route: private file names are not confirmed');++ await same(['/v/vod-1.webm', video], ['/v/no-such-file.webm', video], 'nor a public row\'s file name (C-75)');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');@@@ -126,13 +127,12 @@ const http = require('http');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 ──++ // ── Retired predecessor routes (C-75): gone for every row, public ones included ──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');++ assert.deepStrictEqual([r.status, r.location], [404, null], 'legacy thumbnail names neither redirect nor serve');++ assert.strictEqual((await get('/api/thumbnails/vod-1-111.jpg')).status, 404, 'not even the exact current file name');++ assert.strictEqual((await get('/f/screenshots/shot-1.png')).status, 404, 'screenshots by file name are gone (object URLs serve them)');++ console.log('✅ /v/<file name>, /api/thumbnails/<name>, /f/screenshots/<name>: retired');// ── /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');@@@ -1,7 +1,7 @@'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:++// test/private-not-found.test.js (which covers /v /c /o /p/:slug/raw, transcript.json 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:
@@ -419,7 +419,7 @@ mode (`server/drill.js`) Media:
@@ -419,7 +419,7 @@ mode (`server/drill.js`) Media:
- writes no file and creates no directory (the storage checks leave `/api/ready`, as do the remote
- writes no file and creates no directory (the storage checks leave `/api/ready`, as do the remote
tiers; it reports `"mode": "drill"`);
tiers; it reports `"mode": "drill"`);
- never opens a file path from the database: every byte route (`/v` and `/c` bytes, `/t`, `/a`, `/f`,
- never opens a file path from the database: every byte route (`/v` and `/c` bytes, `/t`, `/a`, `/f`,
- `/o`, `/p/:slug/screenshot`, `/live/:sel/frame.jpg`, `/api/thumbnails/:name`) answers 503;
+ `/o`, `/p/:slug/screenshot`, `/live/:sel/frame.jpg`) answers 503;
watch pages and the `/browse` index still render from the copy;
watch pages and the `/browse` index still render from the copy;
- connects to nothing and runs no program but `git` (no ffmpeg or ffprobe), opens no UDP socket and
- connects to nothing and runs no program but `git` (no ffmpeg or ffprobe), opens no UDP socket and
listens on nothing but its port;
listens on nothing but its port;
@@ -515,14 +515,13 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.
@@ -515,14 +515,13 @@ aggregated and stay queued. The runbook is `docs/cutover-media-billing-readings.
| path | behavior |
| path | behavior |
|---|---|
|---|---|
-| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex`. Also accepts a legacy **file basename** (old `/api/vods/file/<name>` URLs; clip basenames resolve too) |
+| `GET /v/:id` | VOD playback — local stream with Range support, live-DVR `.seekable` sidecar while recording, or **302** to a presigned B2/R2 URL. `X-Robots-Tag: noindex`. By numeric id only: the file-name form was retired with C-75 (2026-10-11) |
| `GET /c/:id` | clip playback, same logic, `noindex` |
| `GET /c/:id` | clip playback, same logic, `noindex` |
| `GET /p/:slug` | **301** to OpenVibe.Community, the canonical home for pastes since 2026-09-22 (every slug, found or not) |
| `GET /p/:slug` | **301** to OpenVibe.Community, the canonical home for pastes since 2026-09-22 (every slug, found or not) |
| `GET /p/:slug/raw` | **301** to Community's raw text |
| `GET /p/:slug/raw` | **301** to Community's raw text |
| `GET /p/:slug/screenshot` | 301 to a ready, nonprivate legacy screenshot or avatar object; otherwise 301 to Community |
| `GET /p/:slug/screenshot` | 301 to a ready, nonprivate legacy screenshot or avatar object; otherwise 301 to Community |
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /t/:id` | thumbnail (id = filename), `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
| `GET /f/:key` | file with stored Content-Type + Range, `noindex` |
-| `GET /f/screenshots/:name` | paste screenshot by filename — serves straight from `PASTES_PATH/screenshots` (migrated legacy files have no files-table rows), `noindex` |
| `GET /live/:sel/transcript.json` | **transcript + AI timeline API** — full audio-transcription log and AI overview timeline. `:sel` = slot id / slot slug → slot-scoped sessions; **`@username`** → user-scoped (all their slots, works while offline). Returns `{ live, current, sessions[] (each: ai_overview, transcript, duration…), streamer (overview + stream memories), user }`. `?limit=1..50` sessions (default 10), `?app=`. Cached **30s** (that's the rate limit), CORS-open. |
| `GET /live/:sel/transcript.json` | **transcript + AI timeline API** — full audio-transcription log and AI overview timeline. `:sel` = slot id / slot slug → slot-scoped sessions; **`@username`** → user-scoped (all their slots, works while offline). Returns `{ live, current, sessions[] (each: ai_overview, transcript, duration…), streamer (overview + stream memories), user }`. `?limit=1..50` sessions (default 10), `?app=`. Cached **30s** (that's the rate limit), CORS-open. |
| `GET /live/:sel/chat-insight.json` | **chat insight API** — a user's chat-related AI insight/timeline (`:sel` = `@username` or numeric user id): today-vs-alltime chat overviews, condensed memory, event timeline, plus their streamer overview + stream memories when they stream. Proxied from the app's public chat-AI API over loopback; cached **30s**, CORS-open. |
| `GET /live/:sel/chat-insight.json` | **chat insight API** — a user's chat-related AI insight/timeline (`:sel` = `@username` or numeric user id): today-vs-alltime chat overviews, condensed memory, event timeline, plus their streamer overview + stream memories when they stream. Proxied from the app's public chat-AI API over loopback; 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 /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. |
@@ -543,8 +542,7 @@ wherever they belong, labelled "AI clip", attributed to no person (`creator` Ope
@@ -543,8 +542,7 @@ wherever they belong, labelled "AI clip", attributed to no person (`creator` Ope
Private items (and legacy rows with no visibility and `is_public = 0`) answer
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
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
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`)
+for the watch page, the bytes, `transcript.json` and `/p/:slug/raw`; the v1 detail routes (`GET /vods/:id`, `/clips/:id`)
apply it to an app acting for someone other than the owner. A call acting for a user writes
apply it to an app acting for someone other than the owner. A call acting for a user writes
only that user's VODs and clips (update, delete, ingest, chunks, finalize, re-cut, thumbnail):
only that user's VODs and clips (update, delete, ingest, chunks, finalize, re-cut, thumbnail):
someone else's private one is the missing answer, anything else 403. The media index lists a
someone else's private one is the missing answer, anything else 403. The media index lists a
@@ -232,22 +232,14 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -232,22 +232,14 @@ async function serveMediaRecord(kind, record, req, res) {
}
}
// ── VOD playback ─────────────────────────────────────────────
// ── VOD playback ─────────────────────────────────────────────
-// Accepts a numeric id OR a legacy file basename (old /api/vods/file/<name>
-// URLs in the wild redirect here with the basename; migrated rows keep their
-// original file_path basenames). The old route served CLIP files too, so a
-// basename that matches a clip serves that clip.
+// By numeric id only: a file name is not a route (the basename fallback for old /api/vods/file/<name> URLs
+// was retired with compatibility register C-75 once Live asked by id).
router.get('/v/:id', optionalIdentity, async (req, res) => {
router.get('/v/:id', optionalIdentity, async (req, res) => {
try {
try {
- if (/^\d+$/.test(req.params.id)) {
- const vod = await db.getVodById(parseInt(req.params.id, 10));
- if (!vod || vod.clips_only) return notFound(res);
- return await serveMediaRecord('vod', vod, req, res);
- }
- const vod = await db.getVodByFileBasename(req.params.id);
- if (vod && !vod.clips_only) return await serveMediaRecord('vod', vod, req, res);
- const clip = await db.getClipByFileBasename(req.params.id);
- if (clip) return await serveMediaRecord('clip', clip, req, res);
- notFound(res);
+ if (!/^\d+$/.test(req.params.id)) return notFound(res);
+ const vod = await db.getVodById(parseInt(req.params.id, 10));
+ if (!vod || vod.clips_only) return notFound(res);
+ return await serveMediaRecord('vod', vod, req, 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' });
@@ -426,53 +418,6 @@ router.get('/v/:id/transcript.json', _devDataRoute((req) => {
@@ -426,53 +418,6 @@ router.get('/v/:id/transcript.json', _devDataRoute((req) => {
return require('./dev-data').getVodTranscript(id);
return require('./dev-data').getVodTranscript(id);
}));
}));
-// ── Legacy thumbnail URLs ────────────────────────────────────
-// Old-stack thumbnails lived at /api/thumbnails/<basename>, and migrated Live
-// rows still carry those absolute URLs. Basenames change when a thumbnail is
-// regenerated, so resolve the vod/clip id from the name and redirect to the
-// current canonical URL; serve the exact file when it still exists.
-router.get('/api/thumbnails/:name', async (req, res) => {
- if (drill.refuseBytes(res)) return;
- const name = path.basename(String(req.params.name || ''));
- const m = /^(vod|clip)-(\d+)-\d+\.(?:jpg|jpeg|png)$/i.exec(name);
- if (m) {
- const row = m[1].toLowerCase() === 'vod'
- ? await db.getVodById(parseInt(m[2], 10), 'live')
- : await db.getClipById(parseInt(m[2], 10), 'live');
- // 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}`)) {
- // A regenerated thumbnail keeps its old URL until the row is updated: no stale serving, or the
- // redirect could point at the stale thumbnail for another hour.
- res.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600, swr: 0 }));
- return res.redirect(302, row.thumbnail_url);
- }
- }
- req.params.id = name;
- require('../thumbnails/thumbnail-service').serveThumbnail(req, res);
-});
-
-// ── Paste screenshots by filename ────────────────────────────
-// Legacy /data/pastes/screenshots/<name> URLs (old avatars, hero moments,
-// pre-cutover pastes) map here. Migrated screenshot files have arbitrary
-// basenames on disk and NO files-table rows, so this serves straight from
-// PASTES_PATH/screenshots for exactly that namespace.
-router.get('/f/screenshots/:name', (req, res) => {
- if (drill.refuseBytes(res)) return;
- try {
- const name = path.basename(String(req.params.name || ''));
- const dir = path.join(require('../config').pastes.path, 'screenshots');
- const filePath = path.join(dir, name);
- if (!name || !filePath.startsWith(dir) || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
- return res.status(404).json({ error: 'Not found' });
- }
- streamFileWithRange(req, res, filePath, { 'Cache-Control': 'public, max-age=604800, immutable', 'X-Robots-Tag': 'noindex' });
- } catch (err) {
- console.error('[Public] /f/screenshots error:', err.message);
- if (!res.headersSent) res.status(500).json({ error: 'Failed to serve file' });
- }
-});
-
// ── Files ────────────────────────────────────────────────────
// ── Files ────────────────────────────────────────────────────
router.get('/f/:key', async (req, res) => {
router.get('/f/:key', async (req, res) => {
if (drill.refuseBytes(res)) return;
if (drill.refuseBytes(res)) return;
@@ -22,7 +22,7 @@ const tools = require('./media-tools');
@@ -22,7 +22,7 @@ const tools = require('./media-tools');
const { announce } = require('../announce');
const { announce } = require('../announce');
const objects = () => require('../objects/model');
const objects = () => require('../objects/model');
-/** Absolute-ize a stored Media-relative URL (/t/…, /api/thumbnails/…). */
+/** Absolute-ize a stored Media-relative URL (/t/…). */
function _absUrl(u) {
function _absUrl(u) {
if (!u) return null;
if (!u) return null;
if (/^https?:\/\//i.test(u)) return u;
if (/^https?:\/\//i.test(u)) return u;
@@ -236,8 +236,8 @@ const { spawnSync, execFileSync } = require('child_process');
@@ -236,8 +236,8 @@ const { spawnSync, execFileSync } = require('child_process');
assert.deepStrictEqual(spied.dbPathTouches, []);
assert.deepStrictEqual(spied.dbPathTouches, []);
});
});
await check('every route that would stream a stored file answers 503 without opening the database\'s path', async () => {
await check('every route that would stream a stored file answers 503 without opening the database\'s path', async () => {
- const byteRoutes = ['/v/1', '/v/1?raw=1', '/v/vod-1.mp4', '/c/1', '/c/1?raw=1', `/o/${objectId}`, '/t/vod-1-1.jpg', '/api/thumbnails/vod-1-1.jpg',
- '/a/1', '/f/f1.txt', '/f/screenshots/shot-1.png', '/p/shot1/screenshot', '/live/1/frame.jpg'];
+ const byteRoutes = ['/v/1', '/v/1?raw=1', '/c/1', '/c/1?raw=1', `/o/${objectId}`, '/t/vod-1-1.jpg',
+ '/a/1', '/f/f1.txt', '/p/shot1/screenshot', '/live/1/frame.jpg'];
for (const p of byteRoutes) {
for (const p of byteRoutes) {
const r = await request('GET', p, { headers: { Range: 'bytes=0-' } });
const r = await request('GET', p, { headers: { Range: 'bytes=0-' } });
assert.strictEqual(r.status, 503, `${p}: ${r.status} ${r.text.slice(0, 120)}`);
assert.strictEqual(r.status, 503, `${p}: ${r.status} ${r.text.slice(0, 120)}`);
@@ -2,8 +2,9 @@
@@ -2,8 +2,9 @@
// Privacy: anyone who may not see a private item gets exactly the answer a missing id gets.
// 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
// 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,
// confirm that the item exists. Covers the public serving routes (/v /c /o /p/:slug,
-// /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).
+// /v/:id/transcript.json) and the v1 detail routes when an app acts for one of its users (X-OV-User-Id).
+// The predecessor-shaped routes (/v/<file name>, /api/thumbnails/<name>, /f/screenshots/<name>) are
+// retired (C-75): they answer the missing-id 404 even for public rows.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -88,8 +89,8 @@ const http = require('http');
@@ -88,8 +89,8 @@ const http = require('http');
await same(['/v/2', nav], ['/v/999', nav], 'private VOD watch page looks missing');
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/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/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(['/v/vod-2-secret.webm', video], ['/v/no-such-file.webm', video], 'a file name is not a route: private file names are not confirmed');
+ await same(['/v/vod-1.webm', video], ['/v/no-such-file.webm', video], 'nor a public row\'s file name (C-75)');
await same(['/c/11', video], ['/c/999', video], 'private clip looks missing');
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(['/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');
await same(['/v/2', { ...video, ...asUser(77) }], ['/v/999', { ...video, ...asUser(77) }], 'another user of the app is nobody special');
@@ -126,13 +127,12 @@ const http = require('http');
@@ -126,13 +127,12 @@ const http = require('http');
await same(['/v/4/transcript.json'], ['/v/999/transcript.json'], 'clips-only 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');
console.log('✅ /v/:id/transcript.json: private looks missing');
- // ── Legacy thumbnail names never hand out a private row's current thumbnail URL ──
+ // ── Retired predecessor routes (C-75): gone for every row, public ones included ──
r = await get('/api/thumbnails/vod-1-0.jpg');
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');
+ assert.deepStrictEqual([r.status, r.location], [404, null], 'legacy thumbnail names neither redirect nor serve');
+ assert.strictEqual((await get('/api/thumbnails/vod-1-111.jpg')).status, 404, 'not even the exact current file name');
+ assert.strictEqual((await get('/f/screenshots/shot-1.png')).status, 404, 'screenshots by file name are gone (object URLs serve them)');
+ console.log('✅ /v/<file name>, /api/thumbnails/<name>, /f/screenshots/<name>: retired');
// ── /o: a private object is missing without a signature, whatever its lifecycle ──
// ── /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/${privDeleted}`], ['/o/med_01JAB2C3D4E5F6G7H8J9K0MNPQ'], 'deleted private object: 404, not 410');
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// Private and unlisted media stay where they belong (roadmap WS-R task 5). Extends
// 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:
+// test/private-not-found.test.js (which covers /v /c /o /p/:slug/raw, transcript.json 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,
// - 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
// /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:
// object and job lists: never a private or unlisted item (sitemap and Search documents in depth: