openvibers/openvibe.live Commits

Live: players and thumbnails ask Media by id; no file-name Media URLs (C-75) (#98)

OP OpenVibers committed 11 Oct 2026 · 3c0b8054e8 · 16 files, +147 −73

Live stops building the predecessor-shaped Media URLs that compatibility register C-75 retires. Media can then drop its `/v/<file name>` fallback, `/api/thumbnails/<name>` and `/f/screenshots/<name>` routes.

**Players: recordings by id.** The VOD page, the live-DVR seek, the clip page, the clip preview and the trim modal asked for `/api/vods/file/<basename>`, which 302'd to Media's `/v/<basename>`. That was about 1,170 requests a week on Media's basename fallback, all from these players.
- New `GET /api/vods/:id/file` and `GET /api/clips/:id/file` 302 to Media's `/v/:id` and `/c/:id`. They send `private, max-age=0`, as before, and refuse non-numeric ids (400). Media still applies visibility, exactly as it did for the basename.
- `/api/vods/file/:filename` is removed.

**Thumbnail by id.** `GET /api/thumbnails/generate/{vod,clip}/:id` (AI moment pastes, `<img>` fallbacks) used to bounce through Media's legacy `/api/thumbnails/<kind>-<id>-0.jpg`. It now reads the recording from Media's API and 302s to its current `thumbnail_url`. As before, a private recording looks exactly like a missing one (404).

**Screenshots.**
- `media-client.screenshotUrl` (Media `/f/screenshots/<name>`) is gone, with its callers: the paste SEO image and the avatar history only use the `screenshot_url` Community returns.
- `/data/pastes/screenshots/:filename` is removed; it had no requests in a week of logs. The avatar allowlist no longer accepts that prefix.
- In production I rewrote the only stored `/f/screenshots/` URLs to object URLs (`https://openvibe.media/o/<id>`, same bytes). That was three legacy avatars, in 666 rows: Live users and projection, Network users and notifications, Chat, Blog, Community. Backups are in `/var/backups/openvibe/c75-avatar-urls-2026-10-11`.

**Tests:**
- New `test/media-legacy-urls.test.js` fails if any of the retired forms comes back under `server/`, `public/` or `scripts/`.
- `media-privacy` covers the thumbnail redirect (public, unlisted, private, legacy-hidden) and the two file routes.
- `vod-tail-finalize` and `vod-hls-source` follow the id form.

`ov test`: 46 covering files pass.

Changes

ddiff --git a/public/fragments/documentation.html b/public/fragments/documentation.html
iindex 0acefcaf..0398a767 100644
---- a/public/fragments/documentation.html
++++ b/public/fragments/documentation.html
@@@ -1147,7 +1147,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
<tr><td>PUT</td><td>/api/vods/:id</td><td>Owner/mod</td><td>Edit <code>title, description, visibility</code> (<code>public|unlisted|private</code>)</td></tr>
<tr><td>DELETE</td><td>/api/vods/:id</td><td>Owner/mod</td><td>Delete VOD + media (local + B2/R2)</td></tr>
<tr><td>POST</td><td>/api/vods/bulk</td><td>Yes</td><td>Bulk <code>delete|public|unlisted|private</code> (<code>{ ids[], action }</code>)</td></tr>
--<tr><td>GET</td><td>/api/vods/file/:filename</td><td>Opt</td><td>Serve VOD/clip media (range requests)</td></tr>
++<tr><td>GET</td><td>/api/vods/:id/file</td><td>No</td><td>302 to the recording on openvibe.media (<code>/v/:id</code>, range requests; Media applies its visibility)</td></tr>
<tr><td>POST</td><td>/api/vods/clips</td><td>Yes</td><td><strong>Create clip.</strong> From an uploaded blob, a live server-cut (<code>stream_id, live:true, duration, at</code>), or a VOD cut (<code>vod_id, start_time, end_time</code>) + <code>title?</code></td></tr>
</tbody>
</table>
@@@ -1159,6 +1159,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
<tr><td>GET</td><td>/api/clips</td><td>No</td><td>List public clips (<code>?limit,offset,username,sort</code>)</td></tr>
<tr><td>GET</td><td>/api/clips/mine &middot; /my-stream</td><td>Yes</td><td>Clips I made / clips others took of my streams</td></tr>
<tr><td>GET</td><td>/api/clips/:id</td><td>Opt</td><td>Clip detail (tracks a view)</td></tr>
++<tr><td>GET</td><td>/api/clips/:id/file</td><td>No</td><td>302 to the clip on openvibe.media (<code>/c/:id</code>)</td></tr>
<tr><td>PUT</td><td>/api/clips/:id/title &middot; /visibility</td><td>Yes</td><td>Rename / set visibility</td></tr>
<tr><td>DELETE</td><td>/api/clips/:id</td><td>Streamer/mod</td><td>Delete clip (creator only if channel opted in)</td></tr>
</tbody>
ddiff --git a/public/js/app-media.js b/public/js/app-media.js
iindex 60f2f5e9..cbac7411 100644
---- a/public/js/app-media.js
++++ b/public/js/app-media.js
@@@ -262,7 +262,6 @@ async function loadVodPlayer(vodId, seekTo) {
// scrubber seek past the real footage → permanent black screen).
window._liveVodIsLive = false;
window._liveVodDuration = 0;
-- window._liveVodFilename = null;
// Clean up chat replay
if (window._chatReplayTimer) {
cancelAnimationFrame(window._chatReplayTimer);
@@@ -345,7 +344,7 @@ async function loadVodPlayer(vodId, seekTo) {
if (typeof renderVodContextStrip === 'function') renderVodContextStrip(v.id);
}
-- const filename = v.file_path.split('/').pop();
++ const fileUrl = `/api/vods/${Number(v.id)}/file`;
// Record the server-probed duration (ffprobe truth). The player clamps
// to this when the browser mis-reports the WebM container duration.
const serverDur = Number(v.duration_seconds || v.duration || 0);
@@@ -355,7 +354,7 @@ async function loadVodPlayer(vodId, seekTo) {
video.dataset.hlsFallbackBound = '1';
video.addEventListener('error', () => { vodFallBackToFile(video); });
}
-- attachVodSource(video, v.is_recording ? null : v.hls_url, `/api/vods/file/${filename}?t=${Date.now()}`, routeGen);
++ attachVodSource(video, v.is_recording ? null : v.hls_url, `${fileUrl}?t=${Date.now()}`, routeGen);
video.style.display = 'block';
if (v.is_recording) {
@@@ -365,12 +364,11 @@ async function loadVodPlayer(vodId, seekTo) {
window._liveVodDuration = v.duration_seconds || 0;
window._liveVodId = v.id;
-- window._liveVodFilename = filename;
window._liveVodIsLive = true;
if (jumpLiveBtn) {
jumpLiveBtn.onclick = () => {
-- video.src = `/api/vods/file/${filename}?t=${Date.now()}`;
++ video.src = `${fileUrl}?t=${Date.now()}`;
video.addEventListener('loadedmetadata', function _jumpOnce() {
video.removeEventListener('loadedmetadata', _jumpOnce);
const dur = isFinite(video.duration) ? video.duration : window._liveVodDuration;
@@@ -407,7 +405,7 @@ async function loadVodPlayer(vodId, seekTo) {
window._liveVodLastSeekableRefresh = now;
const currentTime = video.currentTime;
const wasPaused = video.paused;
-- video.src = `/api/vods/file/${filename}?t=${now}`;
++ video.src = `${fileUrl}?t=${now}`;
video.addEventListener('loadedmetadata', function _restore() {
video.removeEventListener('loadedmetadata', _restore);
const dur = isFinite(video.duration) ? video.duration : window._liveVodDuration;
@@@ -561,8 +559,7 @@ function openClipCreator() {
const preview = document.getElementById('clip-preview-video');
if (preview) {
// The main player may be on the HLS playlist (a blob: URL): the preview cuts from the file.
-- const filename = (video.dataset.fileUrl || video.src).split('/').pop().split('?')[0];
-- preview.src = `/api/vods/file/${filename}`;
++ preview.src = video.dataset.fileUrl || video.src;
preview.currentTime = _vpClipStart;
preview.muted = true;
}
@@@ -1124,7 +1121,6 @@ async function loadClipPlayer(clipId) {
}
}
if (cl.file_path) {
-- const filename = cl.file_path.split('/').pop();
// Handle video load errors (corrupt files, codec issues)
video.onerror = () => {
if (vodFallBackToFile(video)) return; // the playlist failed: the file gets its chance first
@@@ -1138,7 +1134,7 @@ async function loadClipPlayer(clipId) {
</div>`;
}
};
-- attachVodSource(video, cl.hls_url, `/api/vods/file/${filename}`, routeGen);
++ attachVodSource(video, cl.hls_url, `/api/clips/${Number(cl.id)}/file`, routeGen);
video.style.display = 'block';
setupCustomVideoControls('clp');
ddiff --git a/public/js/pastes.js b/public/js/pastes.js
iindex 908c523b..2a33498b 100644
---- a/public/js/pastes.js
++++ b/public/js/pastes.js
@@@ -292,7 +292,7 @@ async function loadPasteViewer(slug) {
let contentHtml;
if (isScreenshot) {
-- const screenshotUrl = p.screenshot_url || `/data/pastes/screenshots/${p.screenshot_path?.split('/').pop()}`;
++ const screenshotUrl = p.screenshot_url || '';
contentHtml = `
<div class="paste-screenshot-view">
<img src="${escapeHtml(screenshotUrl)}" alt="Screenshot" class="paste-screenshot-img"
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex f49c70a2..1d10659e 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -612,7 +612,7 @@ let dvrState = {
active: false, // DVR feature is available (live VOD exists)
isLive: true, // Currently showing real-time stream (vs rewound)
vodId: null, // Live VOD ID
-- vodFilename: null, // Live VOD filename for HTTP src
++ vodFile: null, // the live VOD's file URL (HTTP src while seeking back)
duration: 0, // Current total duration (seconds) from server
seekable: false, // Seekable copy available
pollTimer: null, // Interval for polling live-info
@@@ -2310,7 +2310,7 @@ async function pollDVR(streamId) {
const vod = data.vod;
dvrState.vodId = vod.id;
if (vod.file_path) {
-- dvrState.vodFilename = vod.file_path.split('/').pop();
++ dvrState.vodFile = `/api/vods/${Number(vod.id)}/file`;
}
// Fetch live-info for seekable status and duration
@@@ -2321,7 +2321,7 @@ async function pollDVR(streamId) {
} catch {}
// Show DVR controls when we have a seekable copy
-- if (dvrState.seekable && dvrState.vodFilename) {
++ if (dvrState.seekable && dvrState.vodFile) {
dvrState.active = true;
showDVRControls();
}
@@@ -2485,7 +2485,7 @@ function setupDVRSeek() {
* @param {number} targetTime - Seek position in seconds
*/
function switchToDVR(targetTime) {
-- if (!dvrState.vodFilename || !dvrState.seekable) return;
++ if (!dvrState.vodFile || !dvrState.seekable) return;
const video = document.getElementById('video-element');
const canvas = document.getElementById('video-canvas');
@@@ -2525,7 +2525,7 @@ function switchToDVR(targetTime) {
// Switch video source from live to HTTP VOD file
video.srcObject = null;
-- video.src = `/api/vods/file/${dvrState.vodFilename}?t=${Date.now()}`;
++ video.src = `${dvrState.vodFile}?t=${Date.now()}`;
video.addEventListener('loadedmetadata', function _seekOnLoad() {
video.removeEventListener('loadedmetadata', _seekOnLoad);
@@@ -2617,7 +2617,7 @@ function destroyDVR() {
dvrState.active = false;
dvrState.isLive = true;
dvrState.vodId = null;
-- dvrState.vodFilename = null;
++ dvrState.vodFile = null;
dvrState.duration = 0;
dvrState.seekable = false;
dvrState.streamStartTime = 0;
@@@ -3216,15 +3216,14 @@ async function openClipTrimEditor(clipId) {
// Can't preview — fall back to the simple title prompt.
return promptClipTitle(clipId);
}
-- const fileName = String(clip.file_path).split('/').pop();
const dur = Number(clip.duration_seconds);
-- const show = () => _buildClipTrimModal(clipId, fileName, dur, clip.title || '');
++ const show = () => _buildClipTrimModal(clipId, dur, clip.title || '');
if (document.fullscreenElement) document.exitFullscreen().then(show).catch(show);
else show();
}
--function _buildClipTrimModal(clipId, fileName, dur, defaultTitle) {
++function _buildClipTrimModal(clipId, dur, defaultTitle) {
const fmt = (s) => {
s = Math.max(0, s);
const m = Math.floor(s / 60);
@@@ -3243,7 +3242,7 @@ function _buildClipTrimModal(clipId, fileName, dur, defaultTitle) {
<h3 style="margin:0 0 4px 0"><i class="fa-solid fa-scissors"></i> Trim &amp; Name Your Clip</h3>
<p style="margin:0 0 14px 0;opacity:0.7;font-size:0.88rem">Drag the handles to trim the start and end. Only the final cut is saved.</p>
<div style="position:relative;background:#000;border-radius:10px;overflow:hidden;margin-bottom:14px">
-- <video id="clip-trim-video" src="/api/vods/file/${fileName}" muted playsinline autoplay
++ <video id="clip-trim-video" src="/api/clips/${Number(clipId)}/file" muted playsinline autoplay
style="width:100%;max-height:46vh;display:block;background:#000"></video>
</div>
<div style="margin-bottom:10px">
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex a5526752..bca42897 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -16,7 +16,6 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const multer = require('multer');
--const path = require('path');
const fs = require('fs');
const db = require('../db/database');
const { requireAuth, optionalAuth } = require('./auth');
@@@ -53,7 +52,6 @@ function sanitizeDisplayName(raw) {
function isAllowedAvatarUrl(value) {
if (!value) return true;
if (value.startsWith('/data/avatars/')) return true;
-- if (value.startsWith('/data/pastes/screenshots/')) return true;
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
@@@ -242,7 +240,7 @@ router.get('/avatar/history', requireAuth, async (req, res) => {
const activePasteId = me?.avatar_paste_id || null;
const media = require('../media-client');
const rows = (await require('../media-proxy/lookups').avatarPastes(me)).map(r => {
-- const url = media.publicUrl(r.screenshot_url) || (r.screenshot_path ? media.screenshotUrl(path.basename(r.screenshot_path)) : null);
++ const url = media.publicUrl(r.screenshot_url);
return {
slug: r.slug,
title: r.title,
ddiff --git a/server/index.js b/server/index.js
iindex ced5287a..b141d959 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -703,13 +703,6 @@ app.use('/data/avatars', express.static(paths.data('avatars'), {
},
}));
--// Paste screenshots live in OpenVibe.Media now — legacy /data/pastes/screenshots/<name>
--// URLs (avatars, old pastes, hero moments) 302-redirect to the Media public host.
--app.get('/data/pastes/screenshots/:filename', (req, res) => {
-- res.set('Cache-Control', 'public, max-age=300');
-- res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));
--});
--
// Old relative paste URLs on THIS origin: screenshot bytes still live on the Media public host;
// raw text is served by OpenVibe.Community, the paste's home.
app.get('/p/:slug/screenshot', (req, res) => {
ddiff --git a/server/integrations/powerchat-platform.js b/server/integrations/powerchat-platform.js
iindex f664ec30..34f00332 100644
---- a/server/integrations/powerchat-platform.js
++++ b/server/integrations/powerchat-platform.js
@@@ -32,7 +32,7 @@ const TIP_CURRENCY_KEY = 'openvibe_vibes';
// PowerChat's /chat avatarUrl only renders when it's a full absolute http(s) URL — relative
// paths, data:/blob:, or bare values are silently treated as "no avatar" (an initial-letter
// placeholder shows instead). OpenVibe.Live stores avatars as site-relative paths
--// (e.g. /data/pastes/screenshots/..), so we resolve them against the public base URL and
++// (e.g. /data/avatars/..), so we resolve them against the public base URL and
// prefer https so the avatar actually displays on the overlay.
function _absoluteAvatarUrl(raw) {
if (!raw || typeof raw !== 'string') return null;
ddiff --git a/server/media-client.js b/server/media-client.js
iindex 06ee9718..2269cf22 100644
---- a/server/media-client.js
++++ b/server/media-client.js
@@@ -307,9 +307,6 @@ function clipUrl(id) { return `${MEDIA_PUBLIC_URL}/c/${id}`; }
function pasteScreenshotUrl(slug) { return `${MEDIA_PUBLIC_URL}/p/${encodeURIComponent(slug)}/screenshot`; }
function thumbUrl(id) { return `${MEDIA_PUBLIC_URL}/t/${id}`; }
function fileUrl(key) { return `${MEDIA_PUBLIC_URL}/f/${key}`; }
--// TODO(contract): paste screenshots have no explicit public route in the contract;
--// legacy /data/pastes/screenshots/<name> URLs are mapped onto the files route.
--function screenshotUrl(filename) { return `${MEDIA_PUBLIC_URL}/f/screenshots/${encodeURIComponent(filename)}`; }
/** Absolute-ize a Media-relative URL (e.g. thumbnail_url from an API response). */
function publicUrl(u) {
@@@ -416,5 +413,5 @@ module.exports = {
uploadFile, getFileMeta, deleteFile,
uploadThumbnail, generateThumbnail,
// URL builders
-- vodPlaybackUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, screenshotUrl, publicUrl,
++ vodPlaybackUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl,
};
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 9dcc0eec..2ff48efd 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -210,6 +210,14 @@ router.get('/', async (req, res) => {
});
// ── Clip detail ──────────────────────────────────────────────
++// The player's file source: 302 to Media's clip route by id (Media applies the clip's visibility there).
++router.get('/:id/file', (req, res) => {
++ const id = Number(req.params.id);
++ if (!Number.isSafeInteger(id) || id <= 0) return res.status(400).json({ error: 'Bad clip id' });
++ res.set('Cache-Control', 'private, max-age=0');
++ res.redirect(302, media.clipUrl(id));
++});
++
router.get('/:id', optionalAuth, async (req, res) => {
try {
if (!/^\d+$/.test(req.params.id)) return clipNotFound(res);
ddiff --git a/server/media-proxy/thumbnails.js b/server/media-proxy/thumbnails.js
iindex e809c016..4bf54bab 100644
---- a/server/media-proxy/thumbnails.js
++++ b/server/media-proxy/thumbnails.js
@@@ -69,15 +69,22 @@ async function generateFor(kind, id, req, res) {
}
router.post('/generate/vod/:id', optionalAuth, async (req, res) => await generateFor('vod', req.params.id, req, res));
router.post('/generate/clip/:id', optionalAuth, async (req, res) => await generateFor('clip', req.params.id, req, res));
--// GET variants: <img> onerror fallbacks point here. Media's legacy
--// /api/thumbnails/<kind>-<id>-<ts>.jpg route resolves the CURRENT thumbnail for
--// that id (names drift on regeneration), so bounce through it with a fake ts.
++// GET variants: image URLs that name a recording (AI moment pastes, <img> fallbacks) point here and
++// 302 to its CURRENT thumbnail (file names change on regeneration). Never for a private recording, or the
++// redirect would hand its thumbnail URL to anyone who guesses the id.
function _generateRedirect(kind) {
-- return (req, res) => {
-- const id = parseInt(req.params.id, 10);
-- if (!Number.isFinite(id)) return res.status(400).json({ error: 'Bad id' });
++ return async (req, res) => {
++ const id = Number(req.params.id);
++ if (!Number.isSafeInteger(id) || id <= 0) return res.status(400).json({ error: 'Bad id' });
++ let meta = null;
++ try { meta = kind === 'vod' ? await media.getVod(id) : await media.getClip(id); } catch (err) {
++ if (!(err && err.status === 404)) return res.status(502).json({ error: 'Media service unavailable' });
++ }
++ const visibility = meta && (meta.visibility || (Number(meta.is_public) ? 'public' : 'private'));
++ const url = meta && visibility !== 'private' && media.publicUrl(meta.thumbnail_url);
++ if (!url) return res.status(404).json({ error: 'No thumbnail' });
res.set('Cache-Control', 'public, max-age=300');
-- res.redirect(302, media.publicUrl(`/api/thumbnails/${kind}-${id}-0.jpg`));
++ res.redirect(302, url);
};
}
router.get('/generate/vod/:id', _generateRedirect('vod'));
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex b649cb10..abab62b6 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -603,13 +603,13 @@ router.post('/:id/publish', requireAuth, async (req, res) => {
}
});
--// Big media payloads live on openvibe.media now — 302 out instead of streaming through.
--// TODO(contract): legacy /api/vods/file/<basename> URLs are mapped onto Media's /v/ route
--// by filename; Media's inherited serving resolves both ids and legacy basenames.
--router.get('/file/:filename', optionalAuth, (req, res) => {
-- const filename = require('path').basename(req.params.filename);
++// The player's file source: the bytes live on openvibe.media, so 302 to Media's playback route by id
++// (Media applies the recording's visibility there).
++router.get('/:id/file', (req, res) => {
++ const id = Number(req.params.id);
++ if (!Number.isSafeInteger(id) || id <= 0) return res.status(400).json({ error: 'Bad VOD id' });
res.set('Cache-Control', 'private, max-age=0');
-- res.redirect(302, `${media.MEDIA_PUBLIC_URL}/v/${encodeURIComponent(filename)}`);
++ res.redirect(302, media.vodPlaybackUrl(id));
});
router.post('/upload', requireAuth, memUpload.single('video'), async (req, res) => {
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex 97ce606c..0d7088bb 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -14,7 +14,6 @@
* "AI Moments" below). People's work keeps normal, indexable pages.
*/
'use strict';
--const path = require('node:path');
const seo = require('openvibe-shared/seo');
const { esc } = seo;
const db = require('../db/database');
@@@ -738,7 +737,6 @@ async function _pasteMeta(slug) {
const desc = clean(p.ai_summary || (isScreenshot ? `A screenshot shared on ${SITE_NAME}.` : String(p.content || '').slice(0, 220)) || `A paste on ${SITE_NAME}.`, 200);
let image = DEFAULT_OG_IMAGE;
if (isScreenshot && p.screenshot_url) image = media.publicUrl(p.screenshot_url);
-- else if (isScreenshot && p.screenshot_path) image = media.screenshotUrl(path.basename(p.screenshot_path));
const canonicalPath = `/p/${slug}`;
let tags = [];
try { tags = p.ai_tags ? JSON.parse(p.ai_tags) : []; } catch { tags = []; }
ddiff --git a/test/media-legacy-urls.test.js b/test/media-legacy-urls.test.js
nnew file mode 100644
iindex 00000000..f16ee510
---- /dev/null
++++ b/test/media-legacy-urls.test.js
@@@ -0,0 +1,46 @@
++'use strict';
++
++// Compatibility register C-75: Media retires its predecessor-shaped public routes (the /v/<file name>
++// fallback, /api/thumbnails/<name> and /f/screenshots/<name>). Live builds none of them any more: players
++// ask for a recording by id (/api/vods/:id/file, /api/clips/:id/file → Media /v/:id, /c/:id), a thumbnail
++// by id resolves through Media's API, and avatars and screenshots are object URLs (/o/<id>). This fails
++// on any file under server/, public/js/ or scripts/ that builds one of the retired forms again.
++//
++// node test/media-legacy-urls.test.js
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const RETIRED = [
++ [/\/api\/vods\/file\//, 'a recording by file name (/api/vods/file/<name> → Media /v/<name>)'],
++ [/\/f\/screenshots\//, 'a screenshot by file name (Media /f/screenshots/<name>)'],
++ [/\/data\/pastes\/screenshots\//, 'a pre-Media screenshot path (/data/pastes/screenshots/<name>)'],
++ [/publicUrl\(\s*`\/api\/thumbnails\//, "Media's legacy /api/thumbnails/<name> route"],
++];
++
++function walk(dir, out = []) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'vendor') walk(p, out); } else if (/\.(js|html)$/.test(e.name)) out.push(p);
++ }
++ return out;
++}
++
++const files = ['server', 'public/js', 'public/fragments', 'scripts']
++ .map((d) => path.join(ROOT, d)).filter((d) => fs.existsSync(d)).flatMap((d) => walk(d));
++assert.ok(files.length > 100, `scanned ${files.length} files`);
++
++const found = [];
++for (const file of files) {
++ const lines = fs.readFileSync(file, 'utf8').split('\n');
++ lines.forEach((line, i) => {
++ for (const [re, what] of RETIRED) if (re.test(line)) found.push(`${path.relative(ROOT, file)}:${i + 1} builds ${what}`);
++ });
++}
++assert.deepStrictEqual(found, [], `retired Media URL forms are back:\n ${found.join('\n ')}`);
++
++const client = require('../server/media-client');
++assert.strictEqual(client.screenshotUrl, undefined, 'media-client has no file-name screenshot URL builder');
++console.log(` ✓ no retired Media URL form in ${files.length} files`);
ddiff --git a/test/media-privacy.test.js b/test/media-privacy.test.js
iindex 18a22131..b47929e1 100644
---- a/test/media-privacy.test.js
++++ b/test/media-privacy.test.js
@@@ -219,6 +219,37 @@ async function check(name, fn) {
await sameAsMissing('POST', '/api/thumbnails/generate/clip/201', '/api/thumbnails/generate/clip/999', 7);
});
++ await check('thumbnail by id (image URLs): the current thumbnail of a visible item, a private one looks missing', async () => {
++ for (const id of [100, 101, 102, 103]) VODS[id].thumbnail_url = `/t/vod-${id}-7.jpg`;
++ CLIPS[200].thumbnail_url = '/t/clip-200-7.jpg';
++ CLIPS[201].thumbnail_url = '/t/clip-201-7.jpg';
++ try {
++ const pub = await call('GET', '/api/thumbnails/generate/vod/100', null);
++ assert.deepStrictEqual([pub.status, pub.headers.location], [302, media.publicUrl('/t/vod-100-7.jpg')]);
++ const unlisted = await call('GET', '/api/thumbnails/generate/vod/103', null);
++ assert.deepStrictEqual([unlisted.status, unlisted.headers.location], [302, media.publicUrl('/t/vod-103-7.jpg')], 'unlisted: whoever has the link');
++ const clip = await call('GET', '/api/thumbnails/generate/clip/200', null);
++ assert.deepStrictEqual([clip.status, clip.headers.location], [302, media.publicUrl('/t/clip-200-7.jpg')]);
++ await sameAsMissing('GET', '/api/thumbnails/generate/vod/101', '/api/thumbnails/generate/vod/999', null);
++ await sameAsMissing('GET', '/api/thumbnails/generate/vod/102', '/api/thumbnails/generate/vod/999', 7);
++ await sameAsMissing('GET', '/api/thumbnails/generate/clip/201', '/api/thumbnails/generate/clip/999', 7);
++ assert.strictEqual((await call('GET', '/api/thumbnails/generate/vod/abc', null)).status, 400);
++ } finally {
++ for (const id of [100, 101, 102, 103]) delete VODS[id].thumbnail_url;
++ delete CLIPS[200].thumbnail_url;
++ delete CLIPS[201].thumbnail_url;
++ }
++ });
++
++ await check('player file sources: by id, straight to Media (which applies visibility)', async () => {
++ const v = await call('GET', '/api/vods/100/file?t=5', null);
++ assert.deepStrictEqual([v.status, v.headers.location], [302, media.vodPlaybackUrl(100)]);
++ const c = await call('GET', '/api/clips/200/file', null);
++ assert.deepStrictEqual([c.status, c.headers.location], [302, media.clipUrl(200)]);
++ assert.match(c.headers['cache-control'], /max-age=0/);
++ assert.strictEqual((await call('GET', '/api/clips/x1/file', null)).status, 400);
++ });
++
await check('comments on a private item: unreadable and unpostable for strangers', async () => {
const posted = await call('POST', '/api/comments/vod/101', 3, { message: 'secret note' });
assert.strictEqual(posted.status, 201, 'the owner may comment');
ddiff --git a/test/vod-hls-source.test.js b/test/vod-hls-source.test.js
iindex acde24c3..5343f947 100644
---- a/test/vod-hls-source.test.js
++++ b/test/vod-hls-source.test.js
@@@ -11,11 +11,11 @@ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-media
const between = (startMark, endMark) => { const a = js.indexOf(startMark); const b = js.indexOf(endMark, a); assert.ok(a >= 0 && b > a, startMark); return js.slice(a, b); };
const helpers = between('let _vodHls = null;', 'async function loadVodPlayer(');
--assert.match(js, /attachVodSource\(video, v\.is_recording \? null : v\.hls_url, `\/api\/vods\/file\/\$\{filename\}\?t=\$\{Date\.now\(\)\}`, routeGen\)/, 'a finished VOD prefers its playlist; a recording keeps the file');
--assert.match(js, /attachVodSource\(video, cl\.hls_url, `\/api\/vods\/file\/\$\{filename\}`, routeGen\)/, 'a clip prefers its playlist');
++assert.match(js, /attachVodSource\(video, v\.is_recording \? null : v\.hls_url, `\$\{fileUrl\}\?t=\$\{Date\.now\(\)\}`, routeGen\)/, 'a finished VOD prefers its playlist; a recording keeps the file');
++assert.match(js, /attachVodSource\(video, cl\.hls_url, `\/api\/clips\/\$\{Number\(cl\.id\)\}\/file`, routeGen\)/, 'a clip prefers its playlist; its file is asked by id');
const appJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');
assert.match(appJs, /stop\('vodDropHls'\);/, 'leaving the page drops the hls.js instance');
--assert.match(js, /\(video\.dataset\.fileUrl \|\| video\.src\)\.split\('\/'\)/, 'the clip preview cuts from the file, not a blob: URL');
++assert.match(js, /preview\.src = video\.dataset\.fileUrl \|\| video\.src;/, 'the clip preview cuts from the file, not a blob: URL');
assert.match(js, /if \(vodFallBackToFile\(video\)\) return;/, "the clip player's error message waits for the file fallback");
class FakeVideo {
@@@ -48,14 +48,14 @@ function sandbox({ hlsSupported = true } = {}) {
{ // hls.js path
const { ctx, made } = sandbox();
const v = new FakeVideo();
-- assert.strictEqual(await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_1/master.m3u8', '/api/vods/file/a.webm?t=1'), 'hls');
++ assert.strictEqual(await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_1/master.m3u8', '/api/vods/1/file?t=1'), 'hls');
assert.strictEqual(made[0].url, 'https://openvibe.media/o/obj_1/master.m3u8');
assert.strictEqual(made[0].media, v);
-- assert.strictEqual(v.dataset.fileUrl, '/api/vods/file/a.webm?t=1');
++ assert.strictEqual(v.dataset.fileUrl, '/api/vods/1/file?t=1');
v.currentTime = 42;
made[0].handlers.hlsError(null, { fatal: true }); // the playlist fails: the file takes over at the same spot
assert.strictEqual(made[0].destroyed, true);
-- assert.strictEqual(v.src, '/api/vods/file/a.webm?t=1');
++ assert.strictEqual(v.src, '/api/vods/1/file?t=1');
v.fire('loadedmetadata');
assert.strictEqual(v.currentTime, 42);
assert.strictEqual(ctx.vodFallBackToFile(v), false, 'it falls back once');
@@@ -63,7 +63,7 @@ function sandbox({ hlsSupported = true } = {}) {
{ // a non-fatal hls.js error changes nothing
const { ctx, made } = sandbox();
const v = new FakeVideo();
-- await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_2/master.m3u8', '/api/vods/file/b.webm');
++ await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_2/master.m3u8', '/api/vods/2/file');
made[0].handlers.hlsError(null, { fatal: false });
assert.strictEqual(v.src, '');
assert.strictEqual(made[0].destroyed, false);
@@@ -71,22 +71,22 @@ function sandbox({ hlsSupported = true } = {}) {
{ // native HLS (Safari): the playlist is the src; a media error falls back
const { ctx, made } = sandbox();
const v = new FakeVideo(true);
-- assert.strictEqual(await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_3/master.m3u8', '/api/vods/file/c.webm'), 'hls');
++ assert.strictEqual(await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_3/master.m3u8', '/api/vods/3/file'), 'hls');
assert.strictEqual(made.length, 0);
assert.strictEqual(v.src, 'https://openvibe.media/o/obj_3/master.m3u8');
assert.strictEqual(ctx.vodFallBackToFile(v), true);
-- assert.strictEqual(v.src, '/api/vods/file/c.webm');
++ assert.strictEqual(v.src, '/api/vods/3/file');
}
{ // no playlist, or no HLS support: the file
const { ctx } = sandbox();
const v = new FakeVideo();
-- assert.strictEqual(await ctx.attachVodSource(v, null, '/api/vods/file/d.webm'), 'file');
-- assert.strictEqual(v.src, '/api/vods/file/d.webm');
++ assert.strictEqual(await ctx.attachVodSource(v, null, '/api/vods/4/file'), 'file');
++ assert.strictEqual(v.src, '/api/vods/4/file');
assert.strictEqual(ctx.vodFallBackToFile(v), false);
const { ctx: ctx2 } = sandbox({ hlsSupported: false });
const w = new FakeVideo();
-- assert.strictEqual(await ctx2.attachVodSource(w, 'https://openvibe.media/o/obj_4/master.m3u8', '/api/vods/file/e.webm'), 'file');
-- assert.strictEqual(w.src, '/api/vods/file/e.webm');
++ assert.strictEqual(await ctx2.attachVodSource(w, 'https://openvibe.media/o/obj_4/master.m3u8', '/api/vods/5/file'), 'file');
++ assert.strictEqual(w.src, '/api/vods/5/file');
}
{ // switching VODs drops the previous hls.js instance
const { ctx, made } = sandbox();
ddiff --git a/test/vod-tail-finalize.test.js b/test/vod-tail-finalize.test.js
iindex 127c318e..65eae86f 100644
---- a/test/vod-tail-finalize.test.js
++++ b/test/vod-tail-finalize.test.js
@@@ -160,16 +160,15 @@ async function check(name, fn) {
'a growing recording with bytes is seekable, so the page refreshes toward the live edge');
});
-- await check('live tail: the page plays the recording through /api/vods/file/<basename> → Media /v/<basename>', async () => {
++ await check('live tail: the page plays the recording through /api/vods/<id>/file → Media /v/<id>', async () => {
const detail = await call('GET', `/api/vods/${rtmpVod}`);
assert.strictEqual(detail.status, 200);
-- assert.strictEqual(detail.json.vod.file_path, `vod-${rtmpVod}.mp4`);
-- const r = await call('GET', `/api/vods/file/${detail.json.vod.file_path}?t=123`);
++ const r = await call('GET', `/api/vods/${rtmpVod}/file?t=123`);
assert.strictEqual(r.status, 302);
-- assert.strictEqual(r.headers.location, `https://media.test/v/vod-${rtmpVod}.mp4`);
++ assert.strictEqual(r.headers.location, `https://media.test/v/${rtmpVod}`);
assert.match(r.headers['cache-control'], /max-age=0/, 'the redirect is never cached: the tail refresh must reach Media');
-- const sneaky = await call('GET', '/api/vods/file/..%2F..%2Fetc%2Fpasswd');
-- assert.strictEqual(sneaky.headers.location, 'https://media.test/v/passwd', 'only a basename is forwarded');
++ assert.strictEqual((await call('GET', '/api/vods/..%2Fetc/file')).status, 400, 'only a numeric id is forwarded');
++ assert.strictEqual((await call('GET', `/api/vods/file/vod-${rtmpVod}.mp4`)).status, 404, 'file names are not a route any more (C-75)');
});
await check('finalise: stream end stops the RTMP recording once, and Media finalises it', async () => {
@@@ -234,8 +233,9 @@ async function check(name, fn) {
await check('the VOD page wires tail, finalise and seek to those endpoints', async () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-media.js'), 'utf8');
const player = src.slice(src.indexOf('async function loadVodPlayer('), src.indexOf('// Navigate to streamer on click'));
-- assert.match(player, /const filename = v\.file_path\.split\('\/'\)\.pop\(\);/, 'playback is by the basename Media returns');
-- assert.match(player, /video\.src = `\/api\/vods\/file\/\$\{filename\}\?t=\$\{Date\.now\(\)\}`/, 'and through Live\'s redirect, cache-busted');
++ assert.match(player, /const fileUrl = `\/api\/vods\/\$\{Number\(v\.id\)\}\/file`;/, 'playback is by the VOD\'s id, never a file name (C-75)');
++ assert.match(player, /video\.src = `\$\{fileUrl\}\?t=\$\{Date\.now\(\)\}`/, 'and through Live\'s redirect, cache-busted');
++ assert.ok(!/api\/vods\/file\//.test(src), 'no player asks for a file by name');
assert.match(player, /api\(`\/vods\/\$\{v\.id\}\/live-info`\)/, 'a recording is polled through live-info');
assert.match(player, /if \(!info\.isRecording\) \{[\s\S]{0,400}loadVodPlayer\(v\.id\);/, 'finalisation reloads it as a finished VOD');
assert.match(player, /if \(info\.seekable\) \{[\s\S]{0,1400}video\.currentTime = Math\.min\(currentTime, dur\);/, 'the tail refresh keeps the viewer\'s position');