openvibers/openvibe.live Commits
Live: players and thumbnails ask Media by id; no file-name Media URLs (C-75) (#98)
Changes
@@@ -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 · /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 · /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>@@@ -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 replayif (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');@@@ -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"@@@ -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 serverseekable: false, // Seekable copy availablepollTimer: 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 filevideo.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 & 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 autoplaystyle="width:100%;max-height:46vh;display:block;background:#000"></video></div><div style="margin-bottom:10px">@@@ -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,@@@ -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) => {@@@ -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;@@@ -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,};@@@ -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);@@@ -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'));@@@ -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) => {@@@ -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 = []; }nnew file mode 100644@@@ -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`);@@@ -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');@@@ -11,11 +11,11 @@ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-mediaconst 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 pathconst { 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 spotassert.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 nothingconst { 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 backconst { 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 fileconst { 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 instanceconst { ctx, made } = sandbox();@@@ -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');
@@ -1147,7 +1147,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
@@ -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>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>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>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>
<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>
</tbody>
</table>
</table>
@@ -1159,6 +1159,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
@@ -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</td><td>No</td><td>List public clips (<code>?limit,offset,username,sort</code>)</td></tr>
<tr><td>GET</td><td>/api/clips/mine · /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/mine · /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</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 · /visibility</td><td>Yes</td><td>Rename / set visibility</td></tr>
<tr><td>PUT</td><td>/api/clips/:id/title · /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>
<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>
</tbody>
@@ -262,7 +262,6 @@ async function loadVodPlayer(vodId, seekTo) {
@@ -262,7 +262,6 @@ async function loadVodPlayer(vodId, seekTo) {
// scrubber seek past the real footage → permanent black screen).
// scrubber seek past the real footage → permanent black screen).
window._liveVodIsLive = false;
window._liveVodIsLive = false;
window._liveVodDuration = 0;
window._liveVodDuration = 0;
- window._liveVodFilename = null;
// Clean up chat replay
// Clean up chat replay
if (window._chatReplayTimer) {
if (window._chatReplayTimer) {
cancelAnimationFrame(window._chatReplayTimer);
cancelAnimationFrame(window._chatReplayTimer);
@@ -345,7 +344,7 @@ async function loadVodPlayer(vodId, seekTo) {
@@ -345,7 +344,7 @@ async function loadVodPlayer(vodId, seekTo) {
if (typeof renderVodContextStrip === 'function') renderVodContextStrip(v.id);
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
// Record the server-probed duration (ffprobe truth). The player clamps
// to this when the browser mis-reports the WebM container duration.
// to this when the browser mis-reports the WebM container duration.
const serverDur = Number(v.duration_seconds || v.duration || 0);
const serverDur = Number(v.duration_seconds || v.duration || 0);
@@ -355,7 +354,7 @@ async function loadVodPlayer(vodId, seekTo) {
@@ -355,7 +354,7 @@ async function loadVodPlayer(vodId, seekTo) {
video.dataset.hlsFallbackBound = '1';
video.dataset.hlsFallbackBound = '1';
video.addEventListener('error', () => { vodFallBackToFile(video); });
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';
video.style.display = 'block';
if (v.is_recording) {
if (v.is_recording) {
@@ -365,12 +364,11 @@ async function loadVodPlayer(vodId, seekTo) {
@@ -365,12 +364,11 @@ async function loadVodPlayer(vodId, seekTo) {
window._liveVodDuration = v.duration_seconds || 0;
window._liveVodDuration = v.duration_seconds || 0;
window._liveVodId = v.id;
window._liveVodId = v.id;
- window._liveVodFilename = filename;
window._liveVodIsLive = true;
window._liveVodIsLive = true;
if (jumpLiveBtn) {
if (jumpLiveBtn) {
jumpLiveBtn.onclick = () => {
jumpLiveBtn.onclick = () => {
- video.src = `/api/vods/file/${filename}?t=${Date.now()}`;
+ video.src = `${fileUrl}?t=${Date.now()}`;
video.addEventListener('loadedmetadata', function _jumpOnce() {
video.addEventListener('loadedmetadata', function _jumpOnce() {
video.removeEventListener('loadedmetadata', _jumpOnce);
video.removeEventListener('loadedmetadata', _jumpOnce);
const dur = isFinite(video.duration) ? video.duration : window._liveVodDuration;
const dur = isFinite(video.duration) ? video.duration : window._liveVodDuration;
@@ -407,7 +405,7 @@ async function loadVodPlayer(vodId, seekTo) {
@@ -407,7 +405,7 @@ async function loadVodPlayer(vodId, seekTo) {
window._liveVodLastSeekableRefresh = now;
window._liveVodLastSeekableRefresh = now;
const currentTime = video.currentTime;
const currentTime = video.currentTime;
const wasPaused = video.paused;
const wasPaused = video.paused;
- video.src = `/api/vods/file/${filename}?t=${now}`;
+ video.src = `${fileUrl}?t=${now}`;
video.addEventListener('loadedmetadata', function _restore() {
video.addEventListener('loadedmetadata', function _restore() {
video.removeEventListener('loadedmetadata', _restore);
video.removeEventListener('loadedmetadata', _restore);
const dur = isFinite(video.duration) ? video.duration : window._liveVodDuration;
const dur = isFinite(video.duration) ? video.duration : window._liveVodDuration;
@@ -561,8 +559,7 @@ function openClipCreator() {
@@ -561,8 +559,7 @@ function openClipCreator() {
const preview = document.getElementById('clip-preview-video');
const preview = document.getElementById('clip-preview-video');
if (preview) {
if (preview) {
// The main player may be on the HLS playlist (a blob: URL): the preview cuts from the file.
// 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.currentTime = _vpClipStart;
preview.muted = true;
preview.muted = true;
}
}
@@ -1124,7 +1121,6 @@ async function loadClipPlayer(clipId) {
@@ -1124,7 +1121,6 @@ async function loadClipPlayer(clipId) {
}
}
}
}
if (cl.file_path) {
if (cl.file_path) {
- const filename = cl.file_path.split('/').pop();
// Handle video load errors (corrupt files, codec issues)
// Handle video load errors (corrupt files, codec issues)
video.onerror = () => {
video.onerror = () => {
if (vodFallBackToFile(video)) return; // the playlist failed: the file gets its chance first
if (vodFallBackToFile(video)) return; // the playlist failed: the file gets its chance first
@@ -1138,7 +1134,7 @@ async function loadClipPlayer(clipId) {
@@ -1138,7 +1134,7 @@ async function loadClipPlayer(clipId) {
</div>`;
</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';
video.style.display = 'block';
setupCustomVideoControls('clp');
setupCustomVideoControls('clp');
@@ -292,7 +292,7 @@ async function loadPasteViewer(slug) {
@@ -292,7 +292,7 @@ async function loadPasteViewer(slug) {
let contentHtml;
let contentHtml;
if (isScreenshot) {
if (isScreenshot) {
- const screenshotUrl = p.screenshot_url || `/data/pastes/screenshots/${p.screenshot_path?.split('/').pop()}`;
+ const screenshotUrl = p.screenshot_url || '';
contentHtml = `
contentHtml = `
<div class="paste-screenshot-view">
<div class="paste-screenshot-view">
<img src="${escapeHtml(screenshotUrl)}" alt="Screenshot" class="paste-screenshot-img"
<img src="${escapeHtml(screenshotUrl)}" alt="Screenshot" class="paste-screenshot-img"
@@ -612,7 +612,7 @@ let dvrState = {
@@ -612,7 +612,7 @@ let dvrState = {
active: false, // DVR feature is available (live VOD exists)
active: false, // DVR feature is available (live VOD exists)
isLive: true, // Currently showing real-time stream (vs rewound)
isLive: true, // Currently showing real-time stream (vs rewound)
vodId: null, // Live VOD ID
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
duration: 0, // Current total duration (seconds) from server
seekable: false, // Seekable copy available
seekable: false, // Seekable copy available
pollTimer: null, // Interval for polling live-info
pollTimer: null, // Interval for polling live-info
@@ -2310,7 +2310,7 @@ async function pollDVR(streamId) {
@@ -2310,7 +2310,7 @@ async function pollDVR(streamId) {
const vod = data.vod;
const vod = data.vod;
dvrState.vodId = vod.id;
dvrState.vodId = vod.id;
if (vod.file_path) {
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
// Fetch live-info for seekable status and duration
@@ -2321,7 +2321,7 @@ async function pollDVR(streamId) {
@@ -2321,7 +2321,7 @@ async function pollDVR(streamId) {
} catch {}
} catch {}
// Show DVR controls when we have a seekable copy
// Show DVR controls when we have a seekable copy
- if (dvrState.seekable && dvrState.vodFilename) {
+ if (dvrState.seekable && dvrState.vodFile) {
dvrState.active = true;
dvrState.active = true;
showDVRControls();
showDVRControls();
}
}
@@ -2485,7 +2485,7 @@ function setupDVRSeek() {
@@ -2485,7 +2485,7 @@ function setupDVRSeek() {
* @param {number} targetTime - Seek position in seconds
* @param {number} targetTime - Seek position in seconds
*/
*/
function switchToDVR(targetTime) {
function switchToDVR(targetTime) {
- if (!dvrState.vodFilename || !dvrState.seekable) return;
+ if (!dvrState.vodFile || !dvrState.seekable) return;
const video = document.getElementById('video-element');
const video = document.getElementById('video-element');
const canvas = document.getElementById('video-canvas');
const canvas = document.getElementById('video-canvas');
@@ -2525,7 +2525,7 @@ function switchToDVR(targetTime) {
@@ -2525,7 +2525,7 @@ function switchToDVR(targetTime) {
// Switch video source from live to HTTP VOD file
// Switch video source from live to HTTP VOD file
video.srcObject = null;
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.addEventListener('loadedmetadata', function _seekOnLoad() {
video.removeEventListener('loadedmetadata', _seekOnLoad);
video.removeEventListener('loadedmetadata', _seekOnLoad);
@@ -2617,7 +2617,7 @@ function destroyDVR() {
@@ -2617,7 +2617,7 @@ function destroyDVR() {
dvrState.active = false;
dvrState.active = false;
dvrState.isLive = true;
dvrState.isLive = true;
dvrState.vodId = null;
dvrState.vodId = null;
- dvrState.vodFilename = null;
+ dvrState.vodFile = null;
dvrState.duration = 0;
dvrState.duration = 0;
dvrState.seekable = false;
dvrState.seekable = false;
dvrState.streamStartTime = 0;
dvrState.streamStartTime = 0;
@@ -3216,15 +3216,14 @@ async function openClipTrimEditor(clipId) {
@@ -3216,15 +3216,14 @@ async function openClipTrimEditor(clipId) {
// Can't preview — fall back to the simple title prompt.
// Can't preview — fall back to the simple title prompt.
return promptClipTitle(clipId);
return promptClipTitle(clipId);
}
}
- const fileName = String(clip.file_path).split('/').pop();
const dur = Number(clip.duration_seconds);
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);
if (document.fullscreenElement) document.exitFullscreen().then(show).catch(show);
else show();
else show();
}
}
-function _buildClipTrimModal(clipId, fileName, dur, defaultTitle) {
+function _buildClipTrimModal(clipId, dur, defaultTitle) {
const fmt = (s) => {
const fmt = (s) => {
s = Math.max(0, s);
s = Math.max(0, s);
const m = Math.floor(s / 60);
const m = Math.floor(s / 60);
@@ -3243,7 +3242,7 @@ function _buildClipTrimModal(clipId, fileName, dur, defaultTitle) {
@@ -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 & Name Your Clip</h3>
<h3 style="margin:0 0 4px 0"><i class="fa-solid fa-scissors"></i> Trim & 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>
<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">
<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>
style="width:100%;max-height:46vh;display:block;background:#000"></video>
</div>
</div>
<div style="margin-bottom:10px">
<div style="margin-bottom:10px">
@@ -16,7 +16,6 @@
@@ -16,7 +16,6 @@
const express = require('express');
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const { v4: uuidv4 } = require('uuid');
const multer = require('multer');
const multer = require('multer');
-const path = require('path');
const fs = require('fs');
const fs = require('fs');
const db = require('../db/database');
const db = require('../db/database');
const { requireAuth, optionalAuth } = require('./auth');
const { requireAuth, optionalAuth } = require('./auth');
@@ -53,7 +52,6 @@ function sanitizeDisplayName(raw) {
@@ -53,7 +52,6 @@ function sanitizeDisplayName(raw) {
function isAllowedAvatarUrl(value) {
function isAllowedAvatarUrl(value) {
if (!value) return true;
if (!value) return true;
if (value.startsWith('/data/avatars/')) return true;
if (value.startsWith('/data/avatars/')) return true;
- if (value.startsWith('/data/pastes/screenshots/')) return true;
try {
try {
const url = new URL(value);
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
return url.protocol === 'http:' || url.protocol === 'https:';
@@ -242,7 +240,7 @@ router.get('/avatar/history', requireAuth, async (req, res) => {
@@ -242,7 +240,7 @@ router.get('/avatar/history', requireAuth, async (req, res) => {
const activePasteId = me?.avatar_paste_id || null;
const activePasteId = me?.avatar_paste_id || null;
const media = require('../media-client');
const media = require('../media-client');
const rows = (await require('../media-proxy/lookups').avatarPastes(me)).map(r => {
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 {
return {
slug: r.slug,
slug: r.slug,
title: r.title,
title: r.title,
@@ -703,13 +703,6 @@ app.use('/data/avatars', express.static(paths.data('avatars'), {
@@ -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;
// 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.
// raw text is served by OpenVibe.Community, the paste's home.
app.get('/p/:slug/screenshot', (req, res) => {
app.get('/p/:slug/screenshot', (req, res) => {
@@ -32,7 +32,7 @@ const TIP_CURRENCY_KEY = 'openvibe_vibes';
@@ -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
// 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
// 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
// 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.
// prefer https so the avatar actually displays on the overlay.
function _absoluteAvatarUrl(raw) {
function _absoluteAvatarUrl(raw) {
if (!raw || typeof raw !== 'string') return null;
if (!raw || typeof raw !== 'string') return null;
@@ -307,9 +307,6 @@ function clipUrl(id) { return `${MEDIA_PUBLIC_URL}/c/${id}`; }
@@ -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 pasteScreenshotUrl(slug) { return `${MEDIA_PUBLIC_URL}/p/${encodeURIComponent(slug)}/screenshot`; }
function thumbUrl(id) { return `${MEDIA_PUBLIC_URL}/t/${id}`; }
function thumbUrl(id) { return `${MEDIA_PUBLIC_URL}/t/${id}`; }
function fileUrl(key) { return `${MEDIA_PUBLIC_URL}/f/${key}`; }
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). */
/** Absolute-ize a Media-relative URL (e.g. thumbnail_url from an API response). */
function publicUrl(u) {
function publicUrl(u) {
@@ -416,5 +413,5 @@ module.exports = {
@@ -416,5 +413,5 @@ module.exports = {
uploadFile, getFileMeta, deleteFile,
uploadFile, getFileMeta, deleteFile,
uploadThumbnail, generateThumbnail,
uploadThumbnail, generateThumbnail,
// URL builders
// URL builders
- vodPlaybackUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, screenshotUrl, publicUrl,
+ vodPlaybackUrl, clipUrl, pasteScreenshotUrl, thumbUrl, fileUrl, publicUrl,
};
};
@@ -210,6 +210,14 @@ router.get('/', async (req, res) => {
@@ -210,6 +210,14 @@ router.get('/', async (req, res) => {
});
});
// ── Clip detail ──────────────────────────────────────────────
// ── 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) => {
router.get('/:id', optionalAuth, async (req, res) => {
try {
try {
if (!/^\d+$/.test(req.params.id)) return clipNotFound(res);
if (!/^\d+$/.test(req.params.id)) return clipNotFound(res);
@@ -69,15 +69,22 @@ async function generateFor(kind, id, req, res) {
@@ -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/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));
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) {
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.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'));
router.get('/generate/vod/:id', _generateRedirect('vod'));
@@ -603,13 +603,13 @@ router.post('/:id/publish', requireAuth, async (req, res) => {
@@ -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.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) => {
router.post('/upload', requireAuth, memUpload.single('video'), async (req, res) => {
@@ -14,7 +14,6 @@
@@ -14,7 +14,6 @@
* "AI Moments" below). People's work keeps normal, indexable pages.
* "AI Moments" below). People's work keeps normal, indexable pages.
*/
*/
'use strict';
'use strict';
-const path = require('node:path');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const { esc } = seo;
const { esc } = seo;
const db = require('../db/database');
const db = require('../db/database');
@@ -738,7 +737,6 @@ async function _pasteMeta(slug) {
@@ -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);
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;
let image = DEFAULT_OG_IMAGE;
if (isScreenshot && p.screenshot_url) image = media.publicUrl(p.screenshot_url);
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}`;
const canonicalPath = `/p/${slug}`;
let tags = [];
let tags = [];
try { tags = p.ai_tags ? JSON.parse(p.ai_tags) : []; } catch { tags = []; }
try { tags = p.ai_tags ? JSON.parse(p.ai_tags) : []; } catch { tags = []; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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`);
@@ -219,6 +219,37 @@ async function check(name, fn) {
@@ -219,6 +219,37 @@ async function check(name, fn) {
await sameAsMissing('POST', '/api/thumbnails/generate/clip/201', '/api/thumbnails/generate/clip/999', 7);
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 () => {
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' });
const posted = await call('POST', '/api/comments/vod/101', 3, { message: 'secret note' });
assert.strictEqual(posted.status, 201, 'the owner may comment');
assert.strictEqual(posted.status, 201, 'the owner may comment');
@@ -11,11 +11,11 @@ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-media
@@ -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 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(');
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');
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(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");
assert.match(js, /if \(vodFallBackToFile\(video\)\) return;/, "the clip player's error message waits for the file fallback");
class FakeVideo {
class FakeVideo {
@@ -48,14 +48,14 @@ function sandbox({ hlsSupported = true } = {}) {
@@ -48,14 +48,14 @@ function sandbox({ hlsSupported = true } = {}) {
{ // hls.js path
{ // hls.js path
const { ctx, made } = sandbox();
const { ctx, made } = sandbox();
const v = new FakeVideo();
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].url, 'https://openvibe.media/o/obj_1/master.m3u8');
assert.strictEqual(made[0].media, v);
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;
v.currentTime = 42;
made[0].handlers.hlsError(null, { fatal: true }); // the playlist fails: the file takes over at the same spot
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(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');
v.fire('loadedmetadata');
assert.strictEqual(v.currentTime, 42);
assert.strictEqual(v.currentTime, 42);
assert.strictEqual(ctx.vodFallBackToFile(v), false, 'it falls back once');
assert.strictEqual(ctx.vodFallBackToFile(v), false, 'it falls back once');
@@ -63,7 +63,7 @@ function sandbox({ hlsSupported = true } = {}) {
@@ -63,7 +63,7 @@ function sandbox({ hlsSupported = true } = {}) {
{ // a non-fatal hls.js error changes nothing
{ // a non-fatal hls.js error changes nothing
const { ctx, made } = sandbox();
const { ctx, made } = sandbox();
const v = new FakeVideo();
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 });
made[0].handlers.hlsError(null, { fatal: false });
assert.strictEqual(v.src, '');
assert.strictEqual(v.src, '');
assert.strictEqual(made[0].destroyed, false);
assert.strictEqual(made[0].destroyed, false);
@@ -71,22 +71,22 @@ function sandbox({ hlsSupported = true } = {}) {
@@ -71,22 +71,22 @@ function sandbox({ hlsSupported = true } = {}) {
{ // native HLS (Safari): the playlist is the src; a media error falls back
{ // native HLS (Safari): the playlist is the src; a media error falls back
const { ctx, made } = sandbox();
const { ctx, made } = sandbox();
const v = new FakeVideo(true);
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(made.length, 0);
assert.strictEqual(v.src, 'https://openvibe.media/o/obj_3/master.m3u8');
assert.strictEqual(v.src, 'https://openvibe.media/o/obj_3/master.m3u8');
assert.strictEqual(ctx.vodFallBackToFile(v), true);
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
{ // no playlist, or no HLS support: the file
const { ctx } = sandbox();
const { ctx } = sandbox();
const v = new FakeVideo();
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);
assert.strictEqual(ctx.vodFallBackToFile(v), false);
const { ctx: ctx2 } = sandbox({ hlsSupported: false });
const { ctx: ctx2 } = sandbox({ hlsSupported: false });
const w = new FakeVideo();
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
{ // switching VODs drops the previous hls.js instance
const { ctx, made } = sandbox();
const { ctx, made } = sandbox();
@@ -160,16 +160,15 @@ async function check(name, fn) {
@@ -160,16 +160,15 @@ async function check(name, fn) {
'a growing recording with bytes is seekable, so the page refreshes toward the live edge');
'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}`);
const detail = await call('GET', `/api/vods/${rtmpVod}`);
assert.strictEqual(detail.status, 200);
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.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');
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 () => {
await check('finalise: stream end stops the RTMP recording once, and Media finalises it', async () => {
@@ -234,8 +233,9 @@ async function check(name, fn) {
@@ -234,8 +233,9 @@ async function check(name, fn) {
await check('the VOD page wires tail, finalise and seek to those endpoints', async () => {
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 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'));
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, /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\.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');
assert.match(player, /if \(info\.seekable\) \{[\s\S]{0,1400}video\.currentTime = Math\.min\(currentTime, dur\);/, 'the tail refresh keeps the viewer\'s position');