openvibers/openvibe.media Commits

Media: the file-name routes go: /v/<name>, /api/thumbnails/<name>, /f/screenshots/<name> (C-75) (#88)

OP OpenVibers committed 11 Oct 2026 · b48d1cc0eb · 6 files, +24 −81

Compatibility register C-75: Media retires its predecessor-shaped public routes now that nothing builds their URLs.

- **`GET /v/:id` takes numeric ids only.** The file-name fallback (`/v/<basename>`, and clips by basename) served Live's old `/api/vods/file/<name>` redirect. Live#98 asks by id (`/api/vods/:id/file` → `/v/:id`, `/api/clips/:id/file` → `/c/:id`). A file name now gets the missing-id 404.
- **`GET /api/thumbnails/:name` is gone.** Live's only caller (its `GET /api/thumbnails/generate/...` fallback) resolves the current thumbnail through the API since Live#98. No stored URL in any service database points at it: Live's own relative `/api/thumbnails/stream-*` names are Live's route, not this one.
- **`GET /f/screenshots/:name` is gone.** The only stored URLs (three legacy avatars, 666 rows across Live, Network, Chat, Blog and Community) were rewritten to their object URLs (`/o/<id>`, same bytes) on 2026-10-11, with backups. The files stay where they are, and `/o/<id>` and `/p/:slug/screenshot` serve them.
- **`/tos` → `/terms` stays.** The shared footer links `/tos` on every site, so it is not a shim. The register lists it as kept.

The dual write the register also named is already object-first (`model.withObject`, the same transaction, since 2026-10-10; see `docs/object-model.md`). `db.getVodByFileBasename`/`getClipByFileBasename` stay for `vod/orphans-report.js`.

**Tests:**
- `private-not-found`: the three forms answer 404 for public rows too.
- `drill-mode` drops them from its byte-route list.
- `security-private`'s header no longer cites them.

`ov test`: 21 covering files pass.

Changes

ddiff --git a/README.md b/README.md
iindex f548b13..e283820 100644
---- a/README.md
++++ b/README.md
@@@ -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
tiers; 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 and
listens 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` Ope
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`)
++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
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
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 88f7e99..3bd935e 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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;
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex 1e5befe..edf17d2 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -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;
ddiff --git a/test/drill-mode.test.js b/test/drill-mode.test.js
iindex 0f5421e..02c4c73 100644
---- a/test/drill-mode.test.js
++++ b/test/drill-mode.test.js
@@@ -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)}`);
ddiff --git a/test/private-not-found.test.js b/test/private-not-found.test.js
iindex 8304505..186b53b 100644
---- a/test/private-not-found.test.js
++++ b/test/private-not-found.test.js
@@@ -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');
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex e46210a..02f0337 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -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: