openvibers/openvibe.media Commits

Media: a browser that asks for a missing recording gets the site's own 404 page (#265)

OP OpenVibers committed 12 days ago · 39827e6118 · 3 files, +137 −13

A missing, private or unread VOD or clip answered a browser with the raw JSON
{"error":"Not found"}. The page routes now answer a browser navigation with the
site's own 404 page, in the same frame as every other page, while an API client
and a player keep the JSON. The page never names what was asked for, so a private
item looks exactly like a missing one.

Changes

ddiff --git a/server/public/not-found-page.js b/server/public/not-found-page.js
nnew file mode 100644
iindex 0000000..de9c5c8
---- /dev/null
++++ b/server/public/not-found-page.js
@@@ -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 };
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex abf8096..3c245e0 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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);
ddiff --git a/test/public-not-found.test.js b/test/public-not-found.test.js
nnew file mode 100644
iindex 0000000..9730002
---- /dev/null
++++ b/test/public-not-found.test.js
@@@ -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); });