openvibers/openvibe.media Commits
Media: a browser that asks for a missing recording gets the site's own 404 page (#265)
Changes
nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++/**++ * The site's own 404 page (server/public/not-found-page.js): a browser that asks for a page that does not exist++ * — a missing, private or unread recording or clip — gets this page instead of a raw JSON answer, in the same++ * frame and look as every other page of the site.++ *++ * The page says nothing about what was asked for: a private item and a missing one must look the same, so no++ * id, no path and no title appear here. Only the answer is the same, never what was asked.++ */++const config = require('../config');++const pageFrame = require('./page-frame');++++const { esc, SITE_NAME } = pageFrame;++++function renderNotFound() {++ return pageFrame.page({++ seo: {++ title: `Not found — ${SITE_NAME}`,++ description: 'This page does not exist, or the recording or clip behind it is not public.',++ // A page that does not exist is never indexed; the site's own pages stay reachable.++ robots: 'noindex, follow',++ ogType: 'website',++ },++ body: `++ <h1>Not found</h1>++ <p class="note">This page does not exist, or the recording or clip behind it is not public. It looks the same either way: nothing here says which.</p>++ <div class="actions">++ <a class="btn" href="${esc(config.publicUrl)}/">Browse everything on Media</a>++ <a class="btn ghost" href="${esc(config.publicUrl)}/?tab=videos">Videos</a>++ <a class="btn ghost" href="${esc(config.publicUrl)}/?tab=clips">Clips</a>++ </div>`,++ footer: { variant: 'compact' },++ });++}++++module.exports = { renderNotFound };@@@ -33,6 +33,7 @@ const tools = require('../vod/media-tools');const { optionalIdentity } = require('../auth');const pages = require('./pages');const sourceVod = require('./source-vod');++const notFoundPage = require('./not-found-page');// A restore drill (MEDIA_DRILL) serves no stored bytes: every byte route answers 503 before it looks// at a file path (the database's paths are production's files). Watch pages still render.const drill = require('../drill');@@@ -59,8 +60,21 @@ function recordVisibility(record) {return record.visibility || (record.is_public ? 'public' : 'private');}--/** The single "no such thing" answer for every id-addressed route, used for private items too. */--function notFound(res) {++/**++ * The single "no such thing" answer for every id-addressed route, used for private items too. A browser that++ * navigates there (or a link-preview crawler) gets the site's own 404 page; an API client or a player keeps the++ * JSON answer. The page never says what was asked for: a private item looks exactly like a missing one.++ */++function notFound(req, res) {++ if (pages.wantsHtmlPage(req)) {++ res.set('Cache-Control', 'no-store');++ return res.status(404).type('html').send(notFoundPage.renderNotFound());++ }++ return notFoundBytes(res);++}++++/** The JSON answer for a byte route (a player, an API client): never a page. */++function notFoundBytes(res) {return res.status(404).json({ error: 'Not found' });}@@@ -208,7 +222,7 @@ async function serveMediaRecord(kind, record, req, res) {const visibility = recordVisibility(record);// Exactly the answer a missing id gets: a 403 here told anyone probing ids which private// recordings exist (and, through the basename form of /v, which file names are real).-- if (visibility === 'private' && !canAccessPrivate(record, req, kind)) return notFound(res);++ if (visibility === 'private' && !canAccessPrivate(record, req, kind)) return notFound(req, res);// A virtual clip (F3.5) has no file: its bytes are its HLS playlist over the source's timeline. Every reader allowed// here (public, the owner, or a 'getc' URL from GET /api/clips/:id/signed-url), a browser navigation included, is sent@@@ -317,9 +331,9 @@ async function serveMediaRecord(kind, record, req, res) {// was retired with compatibility register C-75 once Live asked by id).router.get('/v/:id', optionalIdentity, async (req, res) => {try {-- if (!/^\d+$/.test(req.params.id)) return notFound(res);++ if (!/^\d+$/.test(req.params.id)) return notFound(req, res);const vod = await db.getVodById(parseInt(req.params.id, 10));-- if (!vod || vod.clips_only) return notFound(res);++ if (!vod || vod.clips_only) return notFound(req, res);return await serveMediaRecord('vod', vod, req, res);} catch (err) {console.error('[Public] /v error:', err.message);@@@ -339,14 +353,14 @@ const DVR_PERIOD_INIT = /^init-(\d{1,15})\.mp4$/;const DVR_SEGMENT_MAX_BYTES = 64 * 1024 * 1024;async function dvrSource(req, res) {if (drill.refuseBytes(res)) return null;-- if (!/^\d+$/.test(req.params.id)) { notFound(res); return null; }++ if (!/^\d+$/.test(req.params.id)) { notFoundBytes(res); return null; }const vod = await db.getVodById(parseInt(req.params.id, 10));-- if (!vod || vod.clips_only || !vod.is_recording || recordVisibility(vod) === 'private') { notFound(res); return null; }++ if (!vod || vod.clips_only || !vod.is_recording || recordVisibility(vod) === 'private') { notFoundBytes(res); return null; }const vodStorage = require('../vod/vod-storage');const filePath = [vodStorage.localPathForVod(vod), vod.file_path].find((p) => p && p.endsWith('.mp4') && fs.existsSync(p));-- if (!filePath) { notFound(res); return null; }++ if (!filePath) { notFoundBytes(res); return null; }const index = await require('../vod/fmp4-index').refresh(filePath);-- if (index.broken || !index.initEnd) { notFound(res); return null; }++ if (index.broken || !index.initEnd) { notFoundBytes(res); return null; }res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag': 'noindex' });return { vod, filePath, index };}@@@ -376,7 +390,7 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })try { await fh.read(head, 0, index.initEnd, 0); } finally { await fh.close(); }let bytes = null;try { bytes = require('../vod/fmp4-index').periodInit(head, index, Number(init[1])); } catch (err) { console.warn('[Public] DVR period init:', err.message); }-- if (!bytes) return notFound(res);++ if (!bytes) return notFoundBytes(res);res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': bytes.length, 'Cache-Control': 'public, max-age=3600' });return res.end(bytes);}@@@ -385,7 +399,7 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })const offset = m ? Number(m[1]) : NaN;const length = m ? Number(m[2]) : NaN;// Only bytes already indexed (whole fragments after the init segment): never a part still being written.-- if (!m || !last || length <= 0 || length > DVR_SEGMENT_MAX_BYTES || offset < index.initEnd || offset + length > last.offset + last.length) return notFound(res);++ if (!m || !last || length <= 0 || length > DVR_SEGMENT_MAX_BYTES || offset < index.initEnd || offset + length > last.offset + last.length) return notFoundBytes(res);res.writeHead(200, { 'Content-Type': 'video/iso.segment', 'Content-Length': length, 'Cache-Control': 'public, max-age=3600' });sendFileStream(res, filePath, { start: offset, end: offset + length - 1 });}));@@@ -393,7 +407,7 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })// ── Clip playback ────────────────────────────────────────────router.get('/c/:id', optionalIdentity, async (req, res) => {try {-- if (!/^\d+$/.test(req.params.id)) return notFound(res);++ if (!/^\d+$/.test(req.params.id)) return notFound(req, res);const wantsPage = pages.wantsHtmlPage(req);// The page needs its source VOD's state beside the clip; the bytes path keeps the plain read.const clip = wantsPage@@@ -401,7 +415,7 @@ router.get('/c/:id', optionalIdentity, async (req, res) => {: await db.getClipById(parseInt(req.params.id, 10));// A clip still being cut (or whose cut failed) has no file yet: its bytes are a 404, and a// browser navigation gets the watch page saying so (private clips still look missing).-- if (!clip || (!clip.file_path && clip.storage_provider !== 'timeline' && !wantsPage)) return notFound(res);++ if (!clip || (!clip.file_path && clip.storage_provider !== 'timeline' && !wantsPage)) return notFound(req, res);await serveMediaRecord('clip', clip, req, res);} catch (err) {console.error('[Public] /c error:', err.message);nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++// A missing, private or unread recording or clip: a browser that navigates there gets the site's own 404 page++// (the same frame and look as every other page), an API client or a player keeps the JSON answer, and the page++// never says what was asked for — a private item looks exactly like a missing one.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-404-'));++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++ process.env.VOD_PATH = dir('vods');++ process.env.CLIPS_PATH = dir('clips');++ process.env.FILES_PATH = dir('files');++ process.env.OBJECTS_PATH = dir('objects');++ process.env.THUMBNAILS_PATH = dir('thumbnails');++ process.env.PASTES_PATH = dir('pastes');++ process.env.OV_NETWORK_URL = 'https://openvibe.network';++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';++ process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';++++ const express = require('express');++ const notFoundPage = require('../server/public/not-found-page');++ const app = express();++ app.use('/', require('../server/public/routes'));++ const server = http.createServer(app);++++ const nav = { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' };++ const api = { accept: 'application/json' };++ const player = { accept: 'video/webm,*/*;q=0.5', 'sec-fetch-dest': 'video' };++++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const get = async (p, headers = {}) => {++ const res = await fetch(base + p, { headers });++ return { status: res.status, type: res.headers.get('content-type') || '', text: await res.text() };++ };++ try {++ // ── A browser gets the site's own 404 page ──++ for (const p of ['/v/999999', '/c/999999', '/v/not-a-number']) {++ const r = await get(p, nav);++ assert.strictEqual(r.status, 404, p);++ assert.match(r.type, /^text\/html/, p);++ assert.ok(r.text.includes('<title>Not found — OpenVibe.Media</title>'), p);++ assert.ok(r.text.includes('data-variant="compact"'), 'the page is in the site frame');++ assert.ok(r.text.includes('<meta name="robots" content="noindex, follow">'), 'a page that does not exist is not indexed');++ assert.ok(!r.text.includes('999999') && !r.text.includes('not-a-number'), 'the page never names what was asked for');++ }++ // Every page answers the same way: one 404 page, whatever was asked for.++ const one = await get('/v/999999', nav);++ const two = await get('/c/999999', nav);++ assert.strictEqual(one.text, two.text, 'the same page for every missing item');++++ // ── An API client or a player keeps the JSON answer ──++ for (const [p, headers] of [['/v/999999', api], ['/c/999999', api], ['/v/999999', player], ['/v/999999', {}]]) {++ const r = await get(p, headers);++ assert.strictEqual(r.status, 404, p);++ assert.match(r.type, /^application\/json/, p);++ assert.deepStrictEqual(JSON.parse(r.text), { error: 'Not found' }, p);++ }++++ // ── The page itself ──++ const html = notFoundPage.renderNotFound();++ assert.ok(html.includes('<title>Not found — OpenVibe.Media</title>'));++ assert.ok(html.includes('this site\'s own pinned copy') || html.includes('theme-loader'), 'the site frame is there');++ assert.ok(html.includes('Browse everything on Media'), 'the page offers the way back');++ console.log('public-not-found: a browser gets the site\'s 404 page, an API client keeps the JSON');++ } finally {++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+/**
+ * The site's own 404 page (server/public/not-found-page.js): a browser that asks for a page that does not exist
+ * — a missing, private or unread recording or clip — gets this page instead of a raw JSON answer, in the same
+ * frame and look as every other page of the site.
+ *
+ * The page says nothing about what was asked for: a private item and a missing one must look the same, so no
+ * id, no path and no title appear here. Only the answer is the same, never what was asked.
+ */
+const config = require('../config');
+const pageFrame = require('./page-frame');
+
+const { esc, SITE_NAME } = pageFrame;
+
+function renderNotFound() {
+ return pageFrame.page({
+ seo: {
+ title: `Not found — ${SITE_NAME}`,
+ description: 'This page does not exist, or the recording or clip behind it is not public.',
+ // A page that does not exist is never indexed; the site's own pages stay reachable.
+ robots: 'noindex, follow',
+ ogType: 'website',
+ },
+ body: `
+ <h1>Not found</h1>
+ <p class="note">This page does not exist, or the recording or clip behind it is not public. It looks the same either way: nothing here says which.</p>
+ <div class="actions">
+ <a class="btn" href="${esc(config.publicUrl)}/">Browse everything on Media</a>
+ <a class="btn ghost" href="${esc(config.publicUrl)}/?tab=videos">Videos</a>
+ <a class="btn ghost" href="${esc(config.publicUrl)}/?tab=clips">Clips</a>
+ </div>`,
+ footer: { variant: 'compact' },
+ });
+}
+
+module.exports = { renderNotFound };
@@ -33,6 +33,7 @@ const tools = require('../vod/media-tools');
@@ -33,6 +33,7 @@ const tools = require('../vod/media-tools');
const { optionalIdentity } = require('../auth');
const { optionalIdentity } = require('../auth');
const pages = require('./pages');
const pages = require('./pages');
const sourceVod = require('./source-vod');
const sourceVod = require('./source-vod');
+const notFoundPage = require('./not-found-page');
// A restore drill (MEDIA_DRILL) serves no stored bytes: every byte route answers 503 before it looks
// A restore drill (MEDIA_DRILL) serves no stored bytes: every byte route answers 503 before it looks
// at a file path (the database's paths are production's files). Watch pages still render.
// at a file path (the database's paths are production's files). Watch pages still render.
const drill = require('../drill');
const drill = require('../drill');
@@ -59,8 +60,21 @@ function recordVisibility(record) {
@@ -59,8 +60,21 @@ function recordVisibility(record) {
return record.visibility || (record.is_public ? 'public' : 'private');
return record.visibility || (record.is_public ? 'public' : 'private');
}
}
-/** The single "no such thing" answer for every id-addressed route, used for private items too. */
-function notFound(res) {
+/**
+ * The single "no such thing" answer for every id-addressed route, used for private items too. A browser that
+ * navigates there (or a link-preview crawler) gets the site's own 404 page; an API client or a player keeps the
+ * JSON answer. The page never says what was asked for: a private item looks exactly like a missing one.
+ */
+function notFound(req, res) {
+ if (pages.wantsHtmlPage(req)) {
+ res.set('Cache-Control', 'no-store');
+ return res.status(404).type('html').send(notFoundPage.renderNotFound());
+ }
+ return notFoundBytes(res);
+}
+
+/** The JSON answer for a byte route (a player, an API client): never a page. */
+function notFoundBytes(res) {
return res.status(404).json({ error: 'Not found' });
return res.status(404).json({ error: 'Not found' });
}
}
@@ -208,7 +222,7 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -208,7 +222,7 @@ async function serveMediaRecord(kind, record, req, res) {
const visibility = recordVisibility(record);
const visibility = recordVisibility(record);
// Exactly the answer a missing id gets: a 403 here told anyone probing ids which private
// Exactly the answer a missing id gets: a 403 here told anyone probing ids which private
// recordings exist (and, through the basename form of /v, which file names are real).
// recordings exist (and, through the basename form of /v, which file names are real).
- if (visibility === 'private' && !canAccessPrivate(record, req, kind)) return notFound(res);
+ if (visibility === 'private' && !canAccessPrivate(record, req, kind)) return notFound(req, res);
// A virtual clip (F3.5) has no file: its bytes are its HLS playlist over the source's timeline. Every reader allowed
// A virtual clip (F3.5) has no file: its bytes are its HLS playlist over the source's timeline. Every reader allowed
// here (public, the owner, or a 'getc' URL from GET /api/clips/:id/signed-url), a browser navigation included, is sent
// here (public, the owner, or a 'getc' URL from GET /api/clips/:id/signed-url), a browser navigation included, is sent
@@ -317,9 +331,9 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -317,9 +331,9 @@ async function serveMediaRecord(kind, record, req, res) {
// was retired with compatibility register C-75 once Live asked by id).
// was retired with compatibility register C-75 once Live asked by id).
router.get('/v/:id', optionalIdentity, async (req, res) => {
router.get('/v/:id', optionalIdentity, async (req, res) => {
try {
try {
- if (!/^\d+$/.test(req.params.id)) return notFound(res);
+ if (!/^\d+$/.test(req.params.id)) return notFound(req, res);
const vod = await db.getVodById(parseInt(req.params.id, 10));
const vod = await db.getVodById(parseInt(req.params.id, 10));
- if (!vod || vod.clips_only) return notFound(res);
+ if (!vod || vod.clips_only) return notFound(req, res);
return await serveMediaRecord('vod', vod, req, res);
return await serveMediaRecord('vod', vod, req, res);
} catch (err) {
} catch (err) {
console.error('[Public] /v error:', err.message);
console.error('[Public] /v error:', err.message);
@@ -339,14 +353,14 @@ const DVR_PERIOD_INIT = /^init-(\d{1,15})\.mp4$/;
@@ -339,14 +353,14 @@ const DVR_PERIOD_INIT = /^init-(\d{1,15})\.mp4$/;
const DVR_SEGMENT_MAX_BYTES = 64 * 1024 * 1024;
const DVR_SEGMENT_MAX_BYTES = 64 * 1024 * 1024;
async function dvrSource(req, res) {
async function dvrSource(req, res) {
if (drill.refuseBytes(res)) return null;
if (drill.refuseBytes(res)) return null;
- if (!/^\d+$/.test(req.params.id)) { notFound(res); return null; }
+ if (!/^\d+$/.test(req.params.id)) { notFoundBytes(res); return null; }
const vod = await db.getVodById(parseInt(req.params.id, 10));
const vod = await db.getVodById(parseInt(req.params.id, 10));
- if (!vod || vod.clips_only || !vod.is_recording || recordVisibility(vod) === 'private') { notFound(res); return null; }
+ if (!vod || vod.clips_only || !vod.is_recording || recordVisibility(vod) === 'private') { notFoundBytes(res); return null; }
const vodStorage = require('../vod/vod-storage');
const vodStorage = require('../vod/vod-storage');
const filePath = [vodStorage.localPathForVod(vod), vod.file_path].find((p) => p && p.endsWith('.mp4') && fs.existsSync(p));
const filePath = [vodStorage.localPathForVod(vod), vod.file_path].find((p) => p && p.endsWith('.mp4') && fs.existsSync(p));
- if (!filePath) { notFound(res); return null; }
+ if (!filePath) { notFoundBytes(res); return null; }
const index = await require('../vod/fmp4-index').refresh(filePath);
const index = await require('../vod/fmp4-index').refresh(filePath);
- if (index.broken || !index.initEnd) { notFound(res); return null; }
+ if (index.broken || !index.initEnd) { notFoundBytes(res); return null; }
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag': 'noindex' });
res.set({ 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag': 'noindex' });
return { vod, filePath, index };
return { vod, filePath, index };
}
}
@@ -376,7 +390,7 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })
@@ -376,7 +390,7 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })
try { await fh.read(head, 0, index.initEnd, 0); } finally { await fh.close(); }
try { await fh.read(head, 0, index.initEnd, 0); } finally { await fh.close(); }
let bytes = null;
let bytes = null;
try { bytes = require('../vod/fmp4-index').periodInit(head, index, Number(init[1])); } catch (err) { console.warn('[Public] DVR period init:', err.message); }
try { bytes = require('../vod/fmp4-index').periodInit(head, index, Number(init[1])); } catch (err) { console.warn('[Public] DVR period init:', err.message); }
- if (!bytes) return notFound(res);
+ if (!bytes) return notFoundBytes(res);
res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': bytes.length, 'Cache-Control': 'public, max-age=3600' });
res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Length': bytes.length, 'Cache-Control': 'public, max-age=3600' });
return res.end(bytes);
return res.end(bytes);
}
}
@@ -385,7 +399,7 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })
@@ -385,7 +399,7 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })
const offset = m ? Number(m[1]) : NaN;
const offset = m ? Number(m[1]) : NaN;
const length = m ? Number(m[2]) : NaN;
const length = m ? Number(m[2]) : NaN;
// Only bytes already indexed (whole fragments after the init segment): never a part still being written.
// Only bytes already indexed (whole fragments after the init segment): never a part still being written.
- if (!m || !last || length <= 0 || length > DVR_SEGMENT_MAX_BYTES || offset < index.initEnd || offset + length > last.offset + last.length) return notFound(res);
+ if (!m || !last || length <= 0 || length > DVR_SEGMENT_MAX_BYTES || offset < index.initEnd || offset + length > last.offset + last.length) return notFoundBytes(res);
res.writeHead(200, { 'Content-Type': 'video/iso.segment', 'Content-Length': length, 'Cache-Control': 'public, max-age=3600' });
res.writeHead(200, { 'Content-Type': 'video/iso.segment', 'Content-Length': length, 'Cache-Control': 'public, max-age=3600' });
sendFileStream(res, filePath, { start: offset, end: offset + length - 1 });
sendFileStream(res, filePath, { start: offset, end: offset + length - 1 });
}));
}));
@@ -393,7 +407,7 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })
@@ -393,7 +407,7 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })
// ── Clip playback ────────────────────────────────────────────
// ── Clip playback ────────────────────────────────────────────
router.get('/c/:id', optionalIdentity, async (req, res) => {
router.get('/c/:id', optionalIdentity, async (req, res) => {
try {
try {
- if (!/^\d+$/.test(req.params.id)) return notFound(res);
+ if (!/^\d+$/.test(req.params.id)) return notFound(req, res);
const wantsPage = pages.wantsHtmlPage(req);
const wantsPage = pages.wantsHtmlPage(req);
// The page needs its source VOD's state beside the clip; the bytes path keeps the plain read.
// The page needs its source VOD's state beside the clip; the bytes path keeps the plain read.
const clip = wantsPage
const clip = wantsPage
@@ -401,7 +415,7 @@ router.get('/c/:id', optionalIdentity, async (req, res) => {
@@ -401,7 +415,7 @@ router.get('/c/:id', optionalIdentity, async (req, res) => {
: await db.getClipById(parseInt(req.params.id, 10));
: await db.getClipById(parseInt(req.params.id, 10));
// A clip still being cut (or whose cut failed) has no file yet: its bytes are a 404, and a
// A clip still being cut (or whose cut failed) has no file yet: its bytes are a 404, and a
// browser navigation gets the watch page saying so (private clips still look missing).
// browser navigation gets the watch page saying so (private clips still look missing).
- if (!clip || (!clip.file_path && clip.storage_provider !== 'timeline' && !wantsPage)) return notFound(res);
+ if (!clip || (!clip.file_path && clip.storage_provider !== 'timeline' && !wantsPage)) return notFound(req, res);
await serveMediaRecord('clip', clip, req, res);
await serveMediaRecord('clip', clip, req, res);
} catch (err) {
} catch (err) {
console.error('[Public] /c error:', err.message);
console.error('[Public] /c error:', err.message);
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+// A missing, private or unread recording or clip: a browser that navigates there gets the site's own 404 page
+// (the same frame and look as every other page), an API client or a player keeps the JSON answer, and the page
+// never says what was asked for — a private item looks exactly like a missing one.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-404-'));
+ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+ process.env.VOD_PATH = dir('vods');
+ process.env.CLIPS_PATH = dir('clips');
+ process.env.FILES_PATH = dir('files');
+ process.env.OBJECTS_PATH = dir('objects');
+ process.env.THUMBNAILS_PATH = dir('thumbnails');
+ process.env.PASTES_PATH = dir('pastes');
+ process.env.OV_NETWORK_URL = 'https://openvibe.network';
+ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+ process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
+
+ const express = require('express');
+ const notFoundPage = require('../server/public/not-found-page');
+ const app = express();
+ app.use('/', require('../server/public/routes'));
+ const server = http.createServer(app);
+
+ const nav = { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' };
+ const api = { accept: 'application/json' };
+ const player = { accept: 'video/webm,*/*;q=0.5', 'sec-fetch-dest': 'video' };
+
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const get = async (p, headers = {}) => {
+ const res = await fetch(base + p, { headers });
+ return { status: res.status, type: res.headers.get('content-type') || '', text: await res.text() };
+ };
+ try {
+ // ── A browser gets the site's own 404 page ──
+ for (const p of ['/v/999999', '/c/999999', '/v/not-a-number']) {
+ const r = await get(p, nav);
+ assert.strictEqual(r.status, 404, p);
+ assert.match(r.type, /^text\/html/, p);
+ assert.ok(r.text.includes('<title>Not found — OpenVibe.Media</title>'), p);
+ assert.ok(r.text.includes('data-variant="compact"'), 'the page is in the site frame');
+ assert.ok(r.text.includes('<meta name="robots" content="noindex, follow">'), 'a page that does not exist is not indexed');
+ assert.ok(!r.text.includes('999999') && !r.text.includes('not-a-number'), 'the page never names what was asked for');
+ }
+ // Every page answers the same way: one 404 page, whatever was asked for.
+ const one = await get('/v/999999', nav);
+ const two = await get('/c/999999', nav);
+ assert.strictEqual(one.text, two.text, 'the same page for every missing item');
+
+ // ── An API client or a player keeps the JSON answer ──
+ for (const [p, headers] of [['/v/999999', api], ['/c/999999', api], ['/v/999999', player], ['/v/999999', {}]]) {
+ const r = await get(p, headers);
+ assert.strictEqual(r.status, 404, p);
+ assert.match(r.type, /^application\/json/, p);
+ assert.deepStrictEqual(JSON.parse(r.text), { error: 'Not found' }, p);
+ }
+
+ // ── The page itself ──
+ const html = notFoundPage.renderNotFound();
+ assert.ok(html.includes('<title>Not found — OpenVibe.Media</title>'));
+ assert.ok(html.includes('this site\'s own pinned copy') || html.includes('theme-loader'), 'the site frame is there');
+ assert.ok(html.includes('Browse everything on Media'), 'the page offers the way back');
+ console.log('public-not-found: a browser gets the site\'s 404 page, an API client keeps the JSON');
+ } finally {
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+})().catch((err) => { console.error(err); process.exit(1); });