openvibers/openvibe.live Commits
Soft 404s end: a page path that names nothing answers 404 with the SPA shell instead of 200 (requirement ledger D43-D46). The fallback in server/index.js is now server/web/page-status.js spaFallback, which asks statusFor(): / and /vods /clips /pastes /updates /documentation /settings /admin /themes (exact path), /dashboard/* /broadcast/* /chat/* /arena/* (any depth, they route sub-paths themselves), /@user and /@user/<slot> for a user that exists (the client's name rule, case-insensitive), /vod/:id and /clip/:id for a Media item this visitor may see (a private one is a 404 to everyone but its owners and staff, by the same rule in server/media-proxy/access.js that already answers the API), /p/:slug for a paste that exists, and /recap/:id and /stream/:id for a stream that exists stay 200; everything else is 404 (typos, /search, /login, /vods/extra, /@nobody, /vod/999, /p/missing, /wp-login.php). Server routes (API, docs, overlays, popouts, legal pages, static files, the SEO-rendered pages) answer before it and are unchanged; /api/* keeps its JSON 404. Cost: the fixed routes are a table lookup and never wait; users and streams are one SQLite read; Media and paste lookups are cached per id (a minute when found, 30 s when missing, 10 s after an upstream error), shared by concurrent requests, and wait at most 2.5 s; when Media or Community cannot answer the page is 200, never a false 404. The cache keeps only the fields the visibility rule reads. The SEO renderer now remembers a Media/Community 404 too, so a dead link is not a round trip per request. Client: an unknown path renders a Page not found view (showNotFound() in public/js/app.js, filling a new empty #page-not-found shell in index.html) with links home, to VODs, clips and chat, instead of silently showing the home page; an invalid /@name does the same. Links that pointed at pages that never existed: the pastes limit notice's Log in (navigate('/login')) now signs in, the IRL Pro tip names /overlay/chat/<username> instead of /embed/chat/<id>, and the home page's WebSite JSON-LD drops its SearchAction for a /search page that does not exist. docs/architecture.md (Unknown pages) and AGENTS.md (new top-level routes go in routeFromURL() and page-status.js) say so. Tests: test/page-status.test.js runs the SEO middleware and the real fallback over a temp database with Media and Community stubbed: every real route 200 with and without Accept: text/html, unknown paths 404, private VOD/clip 404 to strangers and 200 to owners and staff, the 404 body is the shell and carries no private title, HEAD matches GET, one Media fetch per id, the home page never waits on a lookup, Media down or slow gives 200 within the deadline, index.js mounts this fallback last, and every client route is known to the server. test/browser/smoke.js adds /no-such-page (renders page-not-found at every width). Size budgets unchanged in practice (eager JS 1108.6 / 1150 KB raw, 251.3 / 260 KB brotli)
Changes
@@@ -54,6 +54,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s- **No build step:** Changes to `public/` take effect on deploy without a restart; caching follows content hashes. `npm test` fails if the home page's size budgets grow (scripts/perf/check-budgets.js).- **Inline handlers on lazy features:** a function called from `onclick=` in markup that exists before its feature loads must be listed in that feature's `stubs`.++- **New page routes:** the SPA fallback answers 404 for any path [server/web/page-status.js](server/web/page-status.js) does not know. A new top-level route goes in both `routeFromURL()` and that file's page list.- **innerHTML usage:** Frontend has heavy `innerHTML` — prefer DOM node creation for new code to avoid XSS.- **WebSocket auth lifecycle:** WS connections can start anonymous and upgrade via `join` message. On account switch, the socket must be rebuilt (not just re-joined) — see `openvibe-auth-changed` handling in `chat.js`.- **openvibe-shared:** Pinned release of OpenVibers/OpenVibe.Shared (`"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/vX.Y.Z"`), served at `/shared/*` from `node_modules`. Change it there and bump the tag; never edit `node_modules`.@@@ -47,6 +47,14 @@ public/js/app-*.js route code split out of app.js: home, channel, media, cfeature's markup first (modules bind to it on load), then dependencies, stylesheets and scripts inorder, runs the feature's `after` hook once, and renders only if the route generation is stillcurrent (a slow bundle can no longer start the broadcast desk on the page you moved to).++- **Unknown pages.** Every page path gets the same shell, but a path that names nothing answers++ **404** (server/web/page-status.js, the SPA fallback): an unknown route, a channel whose user does++ not exist, a VOD or clip Media does not have or that this visitor may not see (private items, by++ the rule in server/media-proxy/access.js), an unknown paste, recap or stream. The client renders++ its not-found view (`showNotFound()`) at the same URL. The server's page list mirrors++ `routeFromURL()`: add a new top-level route to both (test/page-status.test.js compares them).++ Media and paste lookups are cached per id for a minute and wait at most 2.5 s; when Media or++ Community cannot answer, the page is 200, never a false 404.- **Stubs.** Inline handlers such as `onclick="openSetupHub()"` work before a feature is loaded: thestub loads the feature, then calls the real function.- **Prefetch.** Hover or keyboard focus on an in-site link prefetches that route's files@@@ -207,7 +207,7 @@<li>Enable <strong>SRTLA Bonding</strong> in IRL Pro for better mobile reliability — combines WiFi + cellular</li><li>Set the codec to <strong>H.264</strong> for best compatibility</li><li>If you see buffering, lower your bitrate to <strong>1500–2000 kbps</strong></li>-- <li>IRL Pro's chat overlay can be pointed at <code>https://openvibe.live/embed/chat/<em>your-stream-id</em></code> for live chat on screen</li>++ <li>IRL Pro's chat overlay can be pointed at <code>https://openvibe.live/overlay/chat/<em>your-username</em></code> for live chat on screen</li></ul></div></div>@@@ -150,14 +150,7 @@"@type": "WebSite","name": "OpenVibe.Live","url": "https://openvibe.live",-- "description": "Open source live streaming platform, community run",-- "potentialAction": {-- "@type": "SearchAction",-- "target": {-- "@type": "EntryPoint",-- "urlTemplate": "https://openvibe.live/search?q={search_term_string}"-- }-- }++ "description": "Open source live streaming platform, community run"}</script></head>@@@ -832,6 +825,8 @@</section><section id="page-documentation" class="page" data-fragment="documentation"><!--ov:fragment:documentation--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>++ <!-- A path no route knows (the server answers it 404): filled by showNotFound() in app.js -->++ <section id="page-not-found" class="page"></section><!-- Paste Create Modal --><div id="paste-create-modal" class="paste-modal-overlay" style="display:none" onclick="if(event.target===this)closePasteModal()">@@@ -1170,14 +1170,40 @@ function routeFromURL() {const legacyStreamParam = new URLSearchParams(window.location.search).get('stream');whenRouteReady('channel', () => loadChannelPage(username, managedStreamRef, legacyStreamParam ? parseInt(legacyStreamParam, 10) : null));} else {-- showPage('home');-- ov.load('home').then(() => loadHome());++ showNotFound();}} else {-- // 404 fallback-- showPage('home');-- ov.load('home').then(() => loadHome());++ // No such page. The server answers these paths 404 with this same shell++ // (server/web/page-status.js), so keep the two lists of routes in step.++ showNotFound();++ }++}++++/** The page for an address no route knows: say so, and offer the way back in. */++function showNotFound() {++ const el = document.getElementById('page-not-found');++ if (el && !el.firstChild) {++ const box = document.createElement('div');++ box.className = 'content-container';++ box.style.cssText = 'text-align:center;padding:12vh 16px';++ const h1 = document.createElement('h1');++ h1.textContent = 'Page not found';++ const p = document.createElement('p');++ p.className = 'page-subtitle';++ p.textContent = 'Nothing lives at this address. The link may be mistyped, or what it pointed to was removed or made private.';++ const links = document.createElement('p');++ links.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:20px';++ for (const [href, label, cls] of [['/', 'Home', 'btn btn-primary'], ['/vods', 'VODs', 'btn btn-outline'], ['/clips', 'Clips', 'btn btn-outline'], ['/chat', 'Chat', 'btn btn-outline']]) {++ const a = document.createElement('a');++ a.href = href; a.className = cls; a.textContent = label;++ a.addEventListener('click', (e) => handleLinkClick(e, href));++ links.appendChild(a);++ }++ box.append(h1, p, links);++ el.appendChild(box);}++ showPage('not-found');++ setPageTitle('Page not found');}/* ═══════════════════════════════════════════════════════════════════════════@@@ -1119,7 +1119,7 @@ function _renderLimitInfo(container, info) {if (isUnlimited) {label = `<i class="fa-solid fa-infinity" style="font-size:0.7em;"></i> Unlimited (staff)`;} else if (isAnon) {-- label = `${li.remaining}/${li.dailyLimit} today · <a href="#" onclick="navigate('/login');return false" style="color:var(--accent);">Log in</a> for more`;++ label = `${li.remaining}/${li.dailyLimit} today · <a href="/api/auth/sso/login" onclick="doLogin();return false" style="color:var(--accent);">Log in</a> for more`;} else {label = `${li.remaining}/${li.dailyLimit} today · ${li.tierLabel}`;}@@@ -997,13 +997,11 @@ app.get('/banned', (req, res) => {});// ── SPA Fallback ─────────────────────────────────────────────--app.get('*', (req, res) => {-- // Don't serve HTML for API routes-- if (req.url.startsWith('/api/') || req.url.startsWith('/ws/')) {-- return res.status(404).json({ error: 'Not found' });-- }-- if (!sendDocument(res, 'index.html', req.path)) res.status(503).type('text/plain').send('Site shell unavailable');--});++// Every page gets the SPA shell, but a path that names nothing (unknown route, channel, VOD, clip,++// paste or stream, or a private item this visitor may not see) gets it with a 404 status, so++// search engines and monitors see a real 404; the client renders its not-found view either way.++// API paths still get the JSON 404. See server/web/page-status.js for the page list.++app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendDocument(res, 'index.html', urlPath)));// ── Global Error Handler ─────────────────────────────────────app.use((err, req, res, _next) => {@@@ -21,12 +21,16 @@ const MC_TTL_MS = 60_000;async function _cached(key, fn) {const e = _mc.get(key);if (e && Date.now() - e.at < MC_TTL_MS) return e.v;-- try {-- const v = await fn();-- _mc.set(key, { v, at: Date.now() });-- if (_mc.size > 300) _mc.delete(_mc.keys().next().value);-- return v;-- } catch { return e ? e.v : null; }++ let v;++ try { v = await fn(); } catch (err) {++ // A missing id is remembered too: otherwise every request for a dead link (the SPA fallback++ // then answers it 404, see server/web/page-status.js) is another round trip to Media.++ if (!err || (err.status !== 404 && err.status !== 410)) return e ? e.v : null;++ v = null;++ }++ _mc.set(key, { v, at: Date.now() });++ if (_mc.size > 300) _mc.delete(_mc.keys().next().value);++ return v;}const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset }))?.clips || []);nnew file mode 100644@@@ -0,0 +1,195 @@++'use strict';++/**++ * The HTTP status of a page path, for the SPA fallback in server/index.js.++ *++ * Every page is the same shell (public/index.html) rendered by the client router, so a typo, a++ * deleted VOD and a channel that never existed all used to answer 200: soft 404s that search++ * engines index and uptime checks cannot see. The fallback now asks statusFor() and sends the same++ * shell with 404 when the path names nothing; the client router still boots and renders its++ * not-found view (showNotFound in public/js/app.js).++ *++ * The pages, mirroring routeFromURL() in public/js/app.js and the routes in public/features.json++ * (server/seo/seo.js renders the crawlable ones first and hands anything it cannot find to the++ * fallback):++ *++ * / home++ * /vods /clips /pastes /updates /documentation exact path++ * /settings /admin /themes exact path (the client redirects them)++ * /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves++ * /@user /@user/<slot> a user that exists++ * /vod/:id /clip/:id a Media item this visitor may see — a private++ * one is missing to everyone but its owners and++ * staff (server/media-proxy/access.js)++ * /p/:slug a paste that exists++ * /recap/:streamId /stream/:id a stream that exists++ *++ * Everything else is 404. Server routes (API, docs, overlays, popouts, legal pages, static files)++ * answer before the fallback and are not affected.++ *++ * Cost: the home page and the fixed routes are a table lookup. Users and streams are one SQLite++ * read. Media items and pastes are fetched once per id per minute (concurrent requests share the++ * fetch) and a visitor waits at most LOOKUP_DEADLINE_MS for one. When Media or Community cannot++ * answer in time, the page answers 200: an outage upstream must never turn real pages into 404s.++ */++const db = require('../db/database');++const media = require('../media-client');++const access = require('../media-proxy/access');++++const OK = 200;++const NOT_FOUND = 404;++++// Pages that exist only at their exact path.++const EXACT = new Set(['vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes']);++// Pages that route their own sub-paths (dashboard tabs, broadcast slots, chat rooms, arena fighters).++const PREFIX = new Set(['dashboard', 'broadcast', 'chat', 'arena']);++++// The client's channel-name rule (CHANNEL_USERNAME_RE in public/js/app.js); anything else it++// never treats as a channel.++const CHANNEL_RE = /^@+([A-Za-z0-9_]{3,24})$/;++const ID_RE = /^\d{1,15}$/;++const SLUG_RE = /^[A-Za-z0-9_-]{1,64}$/;++++const FOUND_TTL_MS = 60_000; // an item's visibility may change; a minute of staleness is fine++const MISSING_TTL_MS = 30_000; // short, so a brand-new item is not a 404 for long++const UNKNOWN_TTL_MS = 10_000; // upstream failed: retry soon, and answer 200 meanwhile++const LOOKUP_DEADLINE_MS = 2500;++const CACHE_MAX = 2000;++++const MISSING = Symbol('missing');++const UNKNOWN = Symbol('unknown');++++const _cache = new Map(); // key -> { at, ttl, value, pending }++++/** Only the fields the visibility rule reads — the cache never holds titles or descriptions. */++function slimMediaRow(row) {++ if (!row || typeof row !== 'object') return MISSING;++ return {++ user_id: row.user_id ?? null,++ channel_user_id: row.channel_user_id ?? null,++ stream_id: row.stream_id ?? null,++ visibility: row.visibility ?? null,++ is_public: row.is_public ?? null,++ };++}++++const isMissingError = (err) => !!err && (err.status === 404 || err.status === 410);++++/**++ * The cached answer for `key`, loading it at most once at a time. Resolves within++ * LOOKUP_DEADLINE_MS; a slower load keeps running and fills the cache for the next request.++ */++function lookup(key, load) {++ const now = Date.now();++ let entry = _cache.get(key);++ if (!entry || (!entry.pending && now - entry.at >= entry.ttl)) {++ entry = { at: now, ttl: UNKNOWN_TTL_MS, value: UNKNOWN, pending: null };++ entry.pending = Promise.resolve()++ .then(load)++ .then(++ (value) => { entry.value = value; entry.ttl = value === MISSING ? MISSING_TTL_MS : FOUND_TTL_MS; },++ (err) => {++ entry.value = isMissingError(err) ? MISSING : UNKNOWN;++ entry.ttl = entry.value === MISSING ? MISSING_TTL_MS : UNKNOWN_TTL_MS;++ },++ )++ .then(() => { entry.at = Date.now(); entry.pending = null; return entry.value; });++ _cache.delete(key);++ _cache.set(key, entry);++ if (_cache.size > CACHE_MAX) _cache.delete(_cache.keys().next().value);++ }++ if (!entry.pending) return Promise.resolve(entry.value);++ let timer;++ const deadline = new Promise((resolve) => { timer = setTimeout(resolve, LOOKUP_DEADLINE_MS, UNKNOWN); });++ return Promise.race([entry.pending, deadline]).finally(() => clearTimeout(timer));++}++++/** The signed-in visitor, or null. Only asked when a private item is at stake. */++function visitor(req) {++ if (req.user !== undefined) return req.user || null;++ try { require('../auth/auth').optionalAuth(req, null, () => {}); } catch { /* treat as anonymous */ }++ return req.user || null;++}++++async function mediaItemStatus(req, kind, id) {++ if (!ID_RE.test(id)) return NOT_FOUND;++ const row = await lookup(`${kind}:${id}`, async () => slimMediaRow(kind === 'vod'++ ? await media.getVod(id, { timeoutMs: 10_000 })++ : await media.getClip(id, { timeoutMs: 10_000 })));++ if (row === UNKNOWN) return OK;++ if (row === MISSING) return NOT_FOUND;++ return access.canView(visitor(req), row) ? OK : NOT_FOUND;++}++++async function pasteStatus(slug) {++ if (!SLUG_RE.test(slug)) return NOT_FOUND;++ const found = await lookup(`paste:${slug}`, async () => {++ const p = await require('../pastes-client').getPaste(slug);++ return p && typeof p === 'object' ? true : MISSING;++ });++ return found === MISSING ? NOT_FOUND : OK;++}++++function channelStatus(segment) {++ const m = CHANNEL_RE.exec(segment);++ if (!m) return NOT_FOUND;++ try {++ return (db.getChannelByUsername(m[1]) || db.getUserByUsername(m[1])) ? OK : NOT_FOUND;++ } catch { return OK; }++}++++function streamStatus(id) {++ if (!ID_RE.test(id)) return NOT_FOUND;++ try { return db.getStreamById(Number(id)) ? OK : NOT_FOUND; } catch { return OK; }++}++++/**++ * 200 or 404 for a GET of `req.path`, which the SPA fallback is about to answer with the shell.++ * Never rejects.++ */++async function statusFor(req) {++ try {++ const segs = String(req.path || '/').split('/').filter(Boolean);++ if (!segs.length) return OK;++ const [first, second] = segs;++ if (segs.length === 1 && EXACT.has(first)) return OK;++ if (PREFIX.has(first)) return OK;++ if (first.startsWith('@')) return segs.length <= 2 ? channelStatus(first) : NOT_FOUND;++ if (segs.length !== 2) return NOT_FOUND;++ switch (first) {++ case 'vod': return await mediaItemStatus(req, 'vod', second);++ case 'clip': return await mediaItemStatus(req, 'clip', second);++ case 'p': return await pasteStatus(second);++ case 'recap':++ case 'stream': return streamStatus(second);++ default: return NOT_FOUND;++ }++ } catch (err) {++ console.warn('[PageStatus]', req.path, '-', err && err.message);++ return OK;++ }++}++++/**++ * The SPA fallback (server/index.js mounts it last, as `app.get('*')`): the shell for every page++ * path, with the status statusFor() decides. `sendShell(res, urlPath)` sends index.html rendered++ * for that path and returns false when the shell is unavailable.++ */++function spaFallback(sendShell) {++ return async (req, res, next) => {++ // Don't serve HTML for API routes++ if (req.url.startsWith('/api/') || req.url.startsWith('/ws/')) {++ return res.status(404).json({ error: 'Not found' });++ }++ const status = await statusFor(req);++ if (res.headersSent) return;++ try {++ res.status(status);++ if (!sendShell(res, req.path)) res.status(503).type('text/plain').send('Site shell unavailable');++ } catch (err) { next(err); } // an async handler's throw would otherwise never reach Express++ };++}++++/** Forget cached lookups (tests; or after a change that must show at once). */++function clearCache() { _cache.clear(); }++++module.exports = { statusFor, spaFallback, clearCache, EXACT, PREFIX, LOOKUP_DEADLINE_MS };@@@ -27,6 +27,8 @@ const ROUTES = [['/', 'page-home'], ['/vods', 'page-vods'], ['/clips', 'page-clips'], ['/pastes', 'page-pastes'],['/chat', 'page-chat'], ['/arena', 'page-arena'], ['/broadcast', 'page-broadcast'], ['/documentation', 'page-documentation'],['/updates', 'page-updates'], ['/dashboard', null], [process.env.CHANNEL || '/@admin', 'page-channel'],++ // Answered 404 by the server (server/web/page-status.js) with the same shell.++ ['/no-such-page', 'page-not-found'],];const ONLY_SIGNED = process.argv.includes('--signed-in-only');const WIDTHS = ONLY_SIGNED ? [] : ROUTES_ONLY ? [1366] : [320, 412, 768, 1440];nnew file mode 100644@@@ -0,0 +1,251 @@++/**++ * Pages that name nothing answer 404 (server/web/page-status.js), with the same SPA shell.++ *++ * Every page path used to get index.html with 200 — a typo, a deleted VOD, a channel that never++ * existed — so search engines indexed soft 404s and monitors could not see a broken link. The SPA++ * fallback now answers 404 for an unknown route, channel, VOD, clip, paste or stream, and for a++ * private VOD or clip seen by anyone but its owners and staff (the rule in media-proxy/access.js);++ * every real route keeps its 200.++ *++ * The SEO middleware and the real fallback run in an express app on a temp database, with Media and++ * Community stubbed in-process and sign-in stubbed by an `x-test-user` header.++ *++ * node test/page-status.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = path.join(os.tmpdir(), `ov-page-status-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.NODE_ENV = 'test';++delete process.env.PASTES_ON_COMMUNITY;++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const express = require('express');++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++++// ── Sign-in stub ──++const auth = require('../server/auth/auth');++auth.optionalAuth = (req, res, next) => {++ const id = Number(req.headers['x-test-user'] || 0);++ const u = id ? db.getUserById(id) : null;++ if (u) req.user = u;++ next();++};++++const addUser = (id, username, role) => raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)++ VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);++addUser(1, 'admin', 'admin');++addUser(3, 'alice', 'streamer'); // owns the private VOD++addUser(4, 'bob', 'user'); // exists, has no channel row yet++addUser(5, 'carol', 'user'); // made the private clip++addUser(7, 'mallory', 'user'); // nobody special++db.ensureChannel(3);++const chanA = db.getChannelByUserId(3);++const streamA = Number(db.createStream({ user_id: 3, channel_id: chanA.id, title: 'A', protocol: 'webrtc' }).lastInsertRowid);++++// ── Media + Community stubs ──++const media = require('../server/media-client');++const VODS = {++ 100: { id: 100, user_id: 3, title: 'Public VOD', visibility: 'public', is_public: 1, status: 'ready' },++ 101: { id: 101, user_id: 3, stream_id: streamA, title: 'Secret VOD', visibility: 'private', is_public: 0, status: 'ready' },++ 102: { id: 102, user_id: 3, title: 'Legacy hidden', is_public: 0, status: 'ready' },++ 103: { id: 103, user_id: 3, title: 'Unlisted VOD', visibility: 'unlisted', is_public: 0, status: 'ready' },++};++const CLIPS = {++ 200: { id: 200, user_id: 5, vod_id: 100, title: 'Public clip', visibility: 'public', is_public: 1, status: 'ready' },++ 201: { id: 201, user_id: 5, channel_user_id: 3, vod_id: 101, title: 'Secret clip', visibility: 'private', is_public: 0, status: 'ready' },++};++const PASTES = { abc123: { slug: 'abc123', title: 'Hello', type: 'text', content: 'hi', visibility: 'public' } };++const calls = { vod: 0, clip: 0, paste: 0 };++let mediaMode = 'ok'; // 'ok' | 'down' | 'slow'++const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });++const upstream = async () => {++ if (mediaMode === 'down') throw new media.MediaApiError('Media unreachable', 0, null);++ if (mediaMode === 'slow') await new Promise((r) => setTimeout(r, 4000));++};++media.getVod = async (id) => { calls.vod++; await upstream(); const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; };++media.getClip = async (id) => { calls.clip++; await upstream(); const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };++media.listVods = async () => ({ vods: [] });++media.listClips = async () => ({ clips: [] });++media.listPastes = async () => ({ pastes: [] });++media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };++const pastesClient = require('../server/pastes-client');++pastesClient.getPaste = async (slug) => { calls.paste++; const p = PASTES[slug]; if (!p) throw missing('Paste'); return { ...p }; };++pastesClient.listPastes = async () => ({ pastes: [] });++++// ── The app: the SEO middleware, then the real SPA fallback (as server/index.js mounts them) ──++const assets = require('../server/web/assets');++const pageStatus = require('../server/web/page-status');++const app = express();++require('../server/seo/seo').register(app);++app.get('*', pageStatus.spaFallback((res, urlPath) => {++ const doc = assets.document('index.html');++ if (!doc) return false;++ res.type('html').send(assets.renderRoute(doc.html, urlPath));++ return true;++}));++++let base;++function get(p, { user, html = false, method = 'GET' } = {}) {++ return new Promise((resolve, reject) => {++ const headers = {};++ if (user) headers['x-test-user'] = String(user);++ // A browser's navigation goes through the SEO middleware first; `*/*` (curl, monitors) does not.++ headers.accept = html ? 'text/html,application/xhtml+xml' : '*/*';++ const req = http.request(base + p, { method, headers }, (res) => {++ let body = '';++ res.on('data', (c) => { body += c; });++ res.on('end', () => resolve({ status: res.statusCode, body, type: res.headers['content-type'] || '' }));++ });++ req.on('error', reject);++ req.end();++ });++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); }++ catch (e) { failures++; console.log(' ✗', name, '\n ', e.message); }++}++const statusOf = async (p, opts) => (await get(p, opts)).status;++async function expectAll(paths, want, opts) {++ const wrong = [];++ for (const p of paths) {++ for (const html of [false, true]) {++ const s = await statusOf(p, { ...opts, html });++ if (s !== want) wrong.push(`${p}${html ? ' (html)' : ''} → ${s}`);++ }++ }++ assert.deepStrictEqual(wrong, [], `expected ${want}: ${wrong.join(', ')}`);++}++++const server = http.createServer(app);++server.listen(0, '127.0.0.1', async () => {++ base = `http://127.0.0.1:${server.address().port}`;++ console.log('Page status (soft 404s)');++++ await check('every real route answers 200', () => expectAll([++ '/', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes',++ '/dashboard', '/dashboard/anything', '/broadcast', '/broadcast/slot-2', '/chat', '/chat/voice/lobby',++ '/arena', '/arena/beef/4', '/arena/alice',++ '/@alice', '/@Alice', '/@alice/', '/@alice/main', '/@bob',++ '/vod/100', '/vod/103', '/clip/200', '/p/abc123',++ `/recap/${streamA}`, `/stream/${streamA}`,++ ], 200));++++ await check('unknown paths answer 404', () => expectAll([++ '/nope', '/search', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1',++ '/@nobody', '/@ab', '/@alice/main/extra', '/@bad.name', '/alice',++ '/vod', '/vod/999', '/vod/abc', '/vod/100/extra', '/clip/999', '/clip/-1',++ '/p/missing', '/p/bad!slug', '/p/abc123/extra',++ '/recap/999999', '/recap/x', '/stream/999999', '/stream',++ ], 404));++++ await check('a private VOD or clip is a 404 to strangers, with or without a sign-in', async () => {++ await expectAll(['/vod/101', '/vod/102', '/clip/201'], 404);++ await expectAll(['/vod/101', '/vod/102', '/clip/201'], 404, { user: 7 });++ });++++ await check('its owners and staff still get 200', async () => {++ await expectAll(['/vod/101', '/vod/102'], 200, { user: 3 }); // uploader++ await expectAll(['/clip/201'], 200, { user: 5 }); // clipper++ await expectAll(['/clip/201'], 200, { user: 3 }); // clipped channel++ await expectAll(['/vod/101', '/vod/102', '/clip/201'], 200, { user: 1 }); // staff++ });++++ await check('a 404 page is the SPA shell (the client renders its not-found view), and leaks nothing', async () => {++ const shell = await get('/vod/101', { html: true });++ assert.strictEqual(shell.status, 404);++ assert.ok(/text\/html/.test(shell.type), shell.type);++ assert.ok(shell.body.includes('id="ov-route-features"'), 'the route boot tag is there');++ assert.ok(shell.body.includes('/js/app.js'), 'the client router is loaded');++ assert.ok(!shell.body.includes('Secret VOD'), 'the private title is not in the page');++ const unknown = await get('/no-such-page', { html: true });++ assert.strictEqual(unknown.status, 404);++ assert.ok(unknown.body.includes('/js/app.js'));++ });++++ await check('HEAD matches GET, and API paths keep their JSON 404', async () => {++ assert.strictEqual(await statusOf('/no-such-page', { method: 'HEAD' }), 404);++ assert.strictEqual(await statusOf('/vods', { method: 'HEAD' }), 200);++ const api = await get('/api/definitely-not-a-route');++ assert.strictEqual(api.status, 404);++ assert.deepStrictEqual(JSON.parse(api.body), { error: 'Not found' });++ });++++ await check('Media lookups are cached and shared: one fetch per id, however many requests', async () => {++ pageStatus.clearCache();++ const before = calls.vod;++ const all = await Promise.all([1, 2, 3, 4, 5].map(() => statusOf('/vod/999')));++ assert.deepStrictEqual(all, [404, 404, 404, 404, 404]);++ await statusOf('/vod/999');++ assert.strictEqual(calls.vod - before, 1, `Media was asked ${calls.vod - before} times`);++ const pastesBefore = calls.paste;++ await statusOf('/p/missing'); await statusOf('/p/missing');++ assert.strictEqual(calls.paste - pastesBefore, 1);++ // A browser navigation: the SEO renderer asks once and remembers the miss, the fallback asks once.++ const htmlBefore = calls.clip;++ for (let i = 0; i < 5; i++) assert.strictEqual(await statusOf('/clip/998', { html: true }), 404);++ assert.ok(calls.clip - htmlBefore <= 2, `Media was asked ${calls.clip - htmlBefore} times`);++ });++++ await check('the home page and fixed routes never wait on a lookup', async () => {++ mediaMode = 'slow';++ pageStatus.clearCache();++ const t0 = Date.now();++ for (const p of ['/', '/vods', '/dashboard', '/@alice', '/nope']) await statusOf(p);++ assert.ok(Date.now() - t0 < 1000, `took ${Date.now() - t0}ms`);++ mediaMode = 'ok';++ });++++ await check('when Media is down or slow, VOD and clip pages answer 200 rather than a false 404', async () => {++ mediaMode = 'down';++ pageStatus.clearCache();++ assert.strictEqual(await statusOf('/vod/999'), 200);++ assert.strictEqual(await statusOf('/clip/201'), 200);++ mediaMode = 'slow';++ pageStatus.clearCache();++ const t0 = Date.now();++ assert.strictEqual(await statusOf('/vod/998'), 200);++ const waited = Date.now() - t0;++ assert.ok(waited < pageStatus.LOOKUP_DEADLINE_MS + 800, `waited ${waited}ms`);++ mediaMode = 'ok';++ });++++ await check('server/index.js mounts this fallback last', () => {++ const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');++ const at = src.indexOf("app.get('*', require('./web/page-status').spaFallback(");++ assert.ok(at > 0, 'the SPA fallback is page-status.spaFallback');++ assert.ok(!/app\.get\('\*'/.test(src.slice(at + 10)), 'no second catch-all after it');++ });++++ await check('the client router knows the same fixed routes', () => {++ const src = fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8');++ const body = src.slice(src.indexOf('function routeFromURL('), src.indexOf('function showNotFound('));++ const clientRoutes = new Set([...body.matchAll(/segments\[0\] === '([a-z-]+)'/g)].map((m) => m[1]));++ // Server-side routes (legal pages, moved games) never reach the fallback; recap, stream, vod,++ // clip and p are looked up.++ const serverOnly = new Set(['dmca', 'tos', 'terms', 'privacy', 'game', 'canvas']);++ const looked = new Set(['vod', 'clip', 'p', 'recap', 'stream']);++ const known = new Set([...pageStatus.EXACT, ...pageStatus.PREFIX, ...looked]);++ const unknownToServer = [...clientRoutes].filter((r) => !known.has(r) && !serverOnly.has(r));++ assert.deepStrictEqual(unknownToServer, [], 'a client route the server would answer 404');++ assert.ok(body.includes('showNotFound()'), 'unknown paths render the not-found view');++ });++++ server.close();++ try { fs.unlinkSync(tmp); } catch { /* */ }++ for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ console.log(failures ? `\n${failures} check(s) failed` : '\npage status: all checks passed');++ process.exit(failures ? 1 : 0);++});
@@ -54,6 +54,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
@@ -54,6 +54,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
- **No build step:** Changes to `public/` take effect on deploy without a restart; caching follows content hashes. `npm test` fails if the home page's size budgets grow (scripts/perf/check-budgets.js).
- **No build step:** Changes to `public/` take effect on deploy without a restart; caching follows content hashes. `npm test` fails if the home page's size budgets grow (scripts/perf/check-budgets.js).
- **Inline handlers on lazy features:** a function called from `onclick=` in markup that exists before its feature loads must be listed in that feature's `stubs`.
- **Inline handlers on lazy features:** a function called from `onclick=` in markup that exists before its feature loads must be listed in that feature's `stubs`.
+- **New page routes:** the SPA fallback answers 404 for any path [server/web/page-status.js](server/web/page-status.js) does not know. A new top-level route goes in both `routeFromURL()` and that file's page list.
- **innerHTML usage:** Frontend has heavy `innerHTML` — prefer DOM node creation for new code to avoid XSS.
- **innerHTML usage:** Frontend has heavy `innerHTML` — prefer DOM node creation for new code to avoid XSS.
- **WebSocket auth lifecycle:** WS connections can start anonymous and upgrade via `join` message. On account switch, the socket must be rebuilt (not just re-joined) — see `openvibe-auth-changed` handling in `chat.js`.
- **WebSocket auth lifecycle:** WS connections can start anonymous and upgrade via `join` message. On account switch, the socket must be rebuilt (not just re-joined) — see `openvibe-auth-changed` handling in `chat.js`.
- **openvibe-shared:** Pinned release of OpenVibers/OpenVibe.Shared (`"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/vX.Y.Z"`), served at `/shared/*` from `node_modules`. Change it there and bump the tag; never edit `node_modules`.
- **openvibe-shared:** Pinned release of OpenVibers/OpenVibe.Shared (`"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/vX.Y.Z"`), served at `/shared/*` from `node_modules`. Change it there and bump the tag; never edit `node_modules`.
@@ -47,6 +47,14 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
@@ -47,6 +47,14 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
feature's markup first (modules bind to it on load), then dependencies, stylesheets and scripts in
feature's markup first (modules bind to it on load), then dependencies, stylesheets and scripts in
order, runs the feature's `after` hook once, and renders only if the route generation is still
order, runs the feature's `after` hook once, and renders only if the route generation is still
current (a slow bundle can no longer start the broadcast desk on the page you moved to).
current (a slow bundle can no longer start the broadcast desk on the page you moved to).
+- **Unknown pages.** Every page path gets the same shell, but a path that names nothing answers
+ **404** (server/web/page-status.js, the SPA fallback): an unknown route, a channel whose user does
+ not exist, a VOD or clip Media does not have or that this visitor may not see (private items, by
+ the rule in server/media-proxy/access.js), an unknown paste, recap or stream. The client renders
+ its not-found view (`showNotFound()`) at the same URL. The server's page list mirrors
+ `routeFromURL()`: add a new top-level route to both (test/page-status.test.js compares them).
+ Media and paste lookups are cached per id for a minute and wait at most 2.5 s; when Media or
+ Community cannot answer, the page is 200, never a false 404.
- **Stubs.** Inline handlers such as `onclick="openSetupHub()"` work before a feature is loaded: the
- **Stubs.** Inline handlers such as `onclick="openSetupHub()"` work before a feature is loaded: the
stub loads the feature, then calls the real function.
stub loads the feature, then calls the real function.
- **Prefetch.** Hover or keyboard focus on an in-site link prefetches that route's files
- **Prefetch.** Hover or keyboard focus on an in-site link prefetches that route's files
@@ -207,7 +207,7 @@
@@ -207,7 +207,7 @@
<li>Enable <strong>SRTLA Bonding</strong> in IRL Pro for better mobile reliability — combines WiFi + cellular</li>
<li>Enable <strong>SRTLA Bonding</strong> in IRL Pro for better mobile reliability — combines WiFi + cellular</li>
<li>Set the codec to <strong>H.264</strong> for best compatibility</li>
<li>Set the codec to <strong>H.264</strong> for best compatibility</li>
<li>If you see buffering, lower your bitrate to <strong>1500–2000 kbps</strong></li>
<li>If you see buffering, lower your bitrate to <strong>1500–2000 kbps</strong></li>
- <li>IRL Pro's chat overlay can be pointed at <code>https://openvibe.live/embed/chat/<em>your-stream-id</em></code> for live chat on screen</li>
+ <li>IRL Pro's chat overlay can be pointed at <code>https://openvibe.live/overlay/chat/<em>your-username</em></code> for live chat on screen</li>
</ul>
</ul>
</div>
</div>
</div>
</div>
@@ -150,14 +150,7 @@
@@ -150,14 +150,7 @@
"@type": "WebSite",
"@type": "WebSite",
"name": "OpenVibe.Live",
"name": "OpenVibe.Live",
"url": "https://openvibe.live",
"url": "https://openvibe.live",
- "description": "Open source live streaming platform, community run",
- "potentialAction": {
- "@type": "SearchAction",
- "target": {
- "@type": "EntryPoint",
- "urlTemplate": "https://openvibe.live/search?q={search_term_string}"
- }
- }
+ "description": "Open source live streaming platform, community run"
}
}
</script>
</script>
</head>
</head>
@@ -832,6 +825,8 @@
@@ -832,6 +825,8 @@
</section>
</section>
<section id="page-documentation" class="page" data-fragment="documentation"><!--ov:fragment:documentation--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<section id="page-documentation" class="page" data-fragment="documentation"><!--ov:fragment:documentation--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
+ <!-- A path no route knows (the server answers it 404): filled by showNotFound() in app.js -->
+ <section id="page-not-found" class="page"></section>
<!-- Paste Create Modal -->
<!-- Paste Create Modal -->
<div id="paste-create-modal" class="paste-modal-overlay" style="display:none" onclick="if(event.target===this)closePasteModal()">
<div id="paste-create-modal" class="paste-modal-overlay" style="display:none" onclick="if(event.target===this)closePasteModal()">
@@ -1170,14 +1170,40 @@ function routeFromURL() {
@@ -1170,14 +1170,40 @@ function routeFromURL() {
const legacyStreamParam = new URLSearchParams(window.location.search).get('stream');
const legacyStreamParam = new URLSearchParams(window.location.search).get('stream');
whenRouteReady('channel', () => loadChannelPage(username, managedStreamRef, legacyStreamParam ? parseInt(legacyStreamParam, 10) : null));
whenRouteReady('channel', () => loadChannelPage(username, managedStreamRef, legacyStreamParam ? parseInt(legacyStreamParam, 10) : null));
} else {
} else {
- showPage('home');
- ov.load('home').then(() => loadHome());
+ showNotFound();
}
}
} else {
} else {
- // 404 fallback
- showPage('home');
- ov.load('home').then(() => loadHome());
+ // No such page. The server answers these paths 404 with this same shell
+ // (server/web/page-status.js), so keep the two lists of routes in step.
+ showNotFound();
+ }
+}
+
+/** The page for an address no route knows: say so, and offer the way back in. */
+function showNotFound() {
+ const el = document.getElementById('page-not-found');
+ if (el && !el.firstChild) {
+ const box = document.createElement('div');
+ box.className = 'content-container';
+ box.style.cssText = 'text-align:center;padding:12vh 16px';
+ const h1 = document.createElement('h1');
+ h1.textContent = 'Page not found';
+ const p = document.createElement('p');
+ p.className = 'page-subtitle';
+ p.textContent = 'Nothing lives at this address. The link may be mistyped, or what it pointed to was removed or made private.';
+ const links = document.createElement('p');
+ links.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:20px';
+ for (const [href, label, cls] of [['/', 'Home', 'btn btn-primary'], ['/vods', 'VODs', 'btn btn-outline'], ['/clips', 'Clips', 'btn btn-outline'], ['/chat', 'Chat', 'btn btn-outline']]) {
+ const a = document.createElement('a');
+ a.href = href; a.className = cls; a.textContent = label;
+ a.addEventListener('click', (e) => handleLinkClick(e, href));
+ links.appendChild(a);
+ }
+ box.append(h1, p, links);
+ el.appendChild(box);
}
}
+ showPage('not-found');
+ setPageTitle('Page not found');
}
}
/* ═══════════════════════════════════════════════════════════════════════════
/* ═══════════════════════════════════════════════════════════════════════════
@@ -1119,7 +1119,7 @@ function _renderLimitInfo(container, info) {
@@ -1119,7 +1119,7 @@ function _renderLimitInfo(container, info) {
if (isUnlimited) {
if (isUnlimited) {
label = `<i class="fa-solid fa-infinity" style="font-size:0.7em;"></i> Unlimited (staff)`;
label = `<i class="fa-solid fa-infinity" style="font-size:0.7em;"></i> Unlimited (staff)`;
} else if (isAnon) {
} else if (isAnon) {
- label = `${li.remaining}/${li.dailyLimit} today · <a href="#" onclick="navigate('/login');return false" style="color:var(--accent);">Log in</a> for more`;
+ label = `${li.remaining}/${li.dailyLimit} today · <a href="/api/auth/sso/login" onclick="doLogin();return false" style="color:var(--accent);">Log in</a> for more`;
} else {
} else {
label = `${li.remaining}/${li.dailyLimit} today · ${li.tierLabel}`;
label = `${li.remaining}/${li.dailyLimit} today · ${li.tierLabel}`;
}
}
@@ -997,13 +997,11 @@ app.get('/banned', (req, res) => {
@@ -997,13 +997,11 @@ app.get('/banned', (req, res) => {
});
});
// ── SPA Fallback ─────────────────────────────────────────────
// ── SPA Fallback ─────────────────────────────────────────────
-app.get('*', (req, res) => {
- // Don't serve HTML for API routes
- if (req.url.startsWith('/api/') || req.url.startsWith('/ws/')) {
- return res.status(404).json({ error: 'Not found' });
- }
- if (!sendDocument(res, 'index.html', req.path)) res.status(503).type('text/plain').send('Site shell unavailable');
-});
+// Every page gets the SPA shell, but a path that names nothing (unknown route, channel, VOD, clip,
+// paste or stream, or a private item this visitor may not see) gets it with a 404 status, so
+// search engines and monitors see a real 404; the client renders its not-found view either way.
+// API paths still get the JSON 404. See server/web/page-status.js for the page list.
+app.get('*', require('./web/page-status').spaFallback((res, urlPath) => sendDocument(res, 'index.html', urlPath)));
// ── Global Error Handler ─────────────────────────────────────
// ── Global Error Handler ─────────────────────────────────────
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
@@ -21,12 +21,16 @@ const MC_TTL_MS = 60_000;
@@ -21,12 +21,16 @@ const MC_TTL_MS = 60_000;
async function _cached(key, fn) {
async function _cached(key, fn) {
const e = _mc.get(key);
const e = _mc.get(key);
if (e && Date.now() - e.at < MC_TTL_MS) return e.v;
if (e && Date.now() - e.at < MC_TTL_MS) return e.v;
- try {
- const v = await fn();
- _mc.set(key, { v, at: Date.now() });
- if (_mc.size > 300) _mc.delete(_mc.keys().next().value);
- return v;
- } catch { return e ? e.v : null; }
+ let v;
+ try { v = await fn(); } catch (err) {
+ // A missing id is remembered too: otherwise every request for a dead link (the SPA fallback
+ // then answers it 404, see server/web/page-status.js) is another round trip to Media.
+ if (!err || (err.status !== 404 && err.status !== 410)) return e ? e.v : null;
+ v = null;
+ }
+ _mc.set(key, { v, at: Date.now() });
+ if (_mc.size > 300) _mc.delete(_mc.keys().next().value);
+ return v;
}
}
const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);
const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);
const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset }))?.clips || []);
const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset }))?.clips || []);
new file mode 100644
new file mode 100644
@@ -0,0 +1,195 @@
@@ -0,0 +1,195 @@
+'use strict';
+/**
+ * The HTTP status of a page path, for the SPA fallback in server/index.js.
+ *
+ * Every page is the same shell (public/index.html) rendered by the client router, so a typo, a
+ * deleted VOD and a channel that never existed all used to answer 200: soft 404s that search
+ * engines index and uptime checks cannot see. The fallback now asks statusFor() and sends the same
+ * shell with 404 when the path names nothing; the client router still boots and renders its
+ * not-found view (showNotFound in public/js/app.js).
+ *
+ * The pages, mirroring routeFromURL() in public/js/app.js and the routes in public/features.json
+ * (server/seo/seo.js renders the crawlable ones first and hands anything it cannot find to the
+ * fallback):
+ *
+ * / home
+ * /vods /clips /pastes /updates /documentation exact path
+ * /settings /admin /themes exact path (the client redirects them)
+ * /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves
+ * /@user /@user/<slot> a user that exists
+ * /vod/:id /clip/:id a Media item this visitor may see — a private
+ * one is missing to everyone but its owners and
+ * staff (server/media-proxy/access.js)
+ * /p/:slug a paste that exists
+ * /recap/:streamId /stream/:id a stream that exists
+ *
+ * Everything else is 404. Server routes (API, docs, overlays, popouts, legal pages, static files)
+ * answer before the fallback and are not affected.
+ *
+ * Cost: the home page and the fixed routes are a table lookup. Users and streams are one SQLite
+ * read. Media items and pastes are fetched once per id per minute (concurrent requests share the
+ * fetch) and a visitor waits at most LOOKUP_DEADLINE_MS for one. When Media or Community cannot
+ * answer in time, the page answers 200: an outage upstream must never turn real pages into 404s.
+ */
+const db = require('../db/database');
+const media = require('../media-client');
+const access = require('../media-proxy/access');
+
+const OK = 200;
+const NOT_FOUND = 404;
+
+// Pages that exist only at their exact path.
+const EXACT = new Set(['vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes']);
+// Pages that route their own sub-paths (dashboard tabs, broadcast slots, chat rooms, arena fighters).
+const PREFIX = new Set(['dashboard', 'broadcast', 'chat', 'arena']);
+
+// The client's channel-name rule (CHANNEL_USERNAME_RE in public/js/app.js); anything else it
+// never treats as a channel.
+const CHANNEL_RE = /^@+([A-Za-z0-9_]{3,24})$/;
+const ID_RE = /^\d{1,15}$/;
+const SLUG_RE = /^[A-Za-z0-9_-]{1,64}$/;
+
+const FOUND_TTL_MS = 60_000; // an item's visibility may change; a minute of staleness is fine
+const MISSING_TTL_MS = 30_000; // short, so a brand-new item is not a 404 for long
+const UNKNOWN_TTL_MS = 10_000; // upstream failed: retry soon, and answer 200 meanwhile
+const LOOKUP_DEADLINE_MS = 2500;
+const CACHE_MAX = 2000;
+
+const MISSING = Symbol('missing');
+const UNKNOWN = Symbol('unknown');
+
+const _cache = new Map(); // key -> { at, ttl, value, pending }
+
+/** Only the fields the visibility rule reads — the cache never holds titles or descriptions. */
+function slimMediaRow(row) {
+ if (!row || typeof row !== 'object') return MISSING;
+ return {
+ user_id: row.user_id ?? null,
+ channel_user_id: row.channel_user_id ?? null,
+ stream_id: row.stream_id ?? null,
+ visibility: row.visibility ?? null,
+ is_public: row.is_public ?? null,
+ };
+}
+
+const isMissingError = (err) => !!err && (err.status === 404 || err.status === 410);
+
+/**
+ * The cached answer for `key`, loading it at most once at a time. Resolves within
+ * LOOKUP_DEADLINE_MS; a slower load keeps running and fills the cache for the next request.
+ */
+function lookup(key, load) {
+ const now = Date.now();
+ let entry = _cache.get(key);
+ if (!entry || (!entry.pending && now - entry.at >= entry.ttl)) {
+ entry = { at: now, ttl: UNKNOWN_TTL_MS, value: UNKNOWN, pending: null };
+ entry.pending = Promise.resolve()
+ .then(load)
+ .then(
+ (value) => { entry.value = value; entry.ttl = value === MISSING ? MISSING_TTL_MS : FOUND_TTL_MS; },
+ (err) => {
+ entry.value = isMissingError(err) ? MISSING : UNKNOWN;
+ entry.ttl = entry.value === MISSING ? MISSING_TTL_MS : UNKNOWN_TTL_MS;
+ },
+ )
+ .then(() => { entry.at = Date.now(); entry.pending = null; return entry.value; });
+ _cache.delete(key);
+ _cache.set(key, entry);
+ if (_cache.size > CACHE_MAX) _cache.delete(_cache.keys().next().value);
+ }
+ if (!entry.pending) return Promise.resolve(entry.value);
+ let timer;
+ const deadline = new Promise((resolve) => { timer = setTimeout(resolve, LOOKUP_DEADLINE_MS, UNKNOWN); });
+ return Promise.race([entry.pending, deadline]).finally(() => clearTimeout(timer));
+}
+
+/** The signed-in visitor, or null. Only asked when a private item is at stake. */
+function visitor(req) {
+ if (req.user !== undefined) return req.user || null;
+ try { require('../auth/auth').optionalAuth(req, null, () => {}); } catch { /* treat as anonymous */ }
+ return req.user || null;
+}
+
+async function mediaItemStatus(req, kind, id) {
+ if (!ID_RE.test(id)) return NOT_FOUND;
+ const row = await lookup(`${kind}:${id}`, async () => slimMediaRow(kind === 'vod'
+ ? await media.getVod(id, { timeoutMs: 10_000 })
+ : await media.getClip(id, { timeoutMs: 10_000 })));
+ if (row === UNKNOWN) return OK;
+ if (row === MISSING) return NOT_FOUND;
+ return access.canView(visitor(req), row) ? OK : NOT_FOUND;
+}
+
+async function pasteStatus(slug) {
+ if (!SLUG_RE.test(slug)) return NOT_FOUND;
+ const found = await lookup(`paste:${slug}`, async () => {
+ const p = await require('../pastes-client').getPaste(slug);
+ return p && typeof p === 'object' ? true : MISSING;
+ });
+ return found === MISSING ? NOT_FOUND : OK;
+}
+
+function channelStatus(segment) {
+ const m = CHANNEL_RE.exec(segment);
+ if (!m) return NOT_FOUND;
+ try {
+ return (db.getChannelByUsername(m[1]) || db.getUserByUsername(m[1])) ? OK : NOT_FOUND;
+ } catch { return OK; }
+}
+
+function streamStatus(id) {
+ if (!ID_RE.test(id)) return NOT_FOUND;
+ try { return db.getStreamById(Number(id)) ? OK : NOT_FOUND; } catch { return OK; }
+}
+
+/**
+ * 200 or 404 for a GET of `req.path`, which the SPA fallback is about to answer with the shell.
+ * Never rejects.
+ */
+async function statusFor(req) {
+ try {
+ const segs = String(req.path || '/').split('/').filter(Boolean);
+ if (!segs.length) return OK;
+ const [first, second] = segs;
+ if (segs.length === 1 && EXACT.has(first)) return OK;
+ if (PREFIX.has(first)) return OK;
+ if (first.startsWith('@')) return segs.length <= 2 ? channelStatus(first) : NOT_FOUND;
+ if (segs.length !== 2) return NOT_FOUND;
+ switch (first) {
+ case 'vod': return await mediaItemStatus(req, 'vod', second);
+ case 'clip': return await mediaItemStatus(req, 'clip', second);
+ case 'p': return await pasteStatus(second);
+ case 'recap':
+ case 'stream': return streamStatus(second);
+ default: return NOT_FOUND;
+ }
+ } catch (err) {
+ console.warn('[PageStatus]', req.path, '-', err && err.message);
+ return OK;
+ }
+}
+
+/**
+ * The SPA fallback (server/index.js mounts it last, as `app.get('*')`): the shell for every page
+ * path, with the status statusFor() decides. `sendShell(res, urlPath)` sends index.html rendered
+ * for that path and returns false when the shell is unavailable.
+ */
+function spaFallback(sendShell) {
+ return async (req, res, next) => {
+ // Don't serve HTML for API routes
+ if (req.url.startsWith('/api/') || req.url.startsWith('/ws/')) {
+ return res.status(404).json({ error: 'Not found' });
+ }
+ const status = await statusFor(req);
+ if (res.headersSent) return;
+ try {
+ res.status(status);
+ if (!sendShell(res, req.path)) res.status(503).type('text/plain').send('Site shell unavailable');
+ } catch (err) { next(err); } // an async handler's throw would otherwise never reach Express
+ };
+}
+
+/** Forget cached lookups (tests; or after a change that must show at once). */
+function clearCache() { _cache.clear(); }
+
+module.exports = { statusFor, spaFallback, clearCache, EXACT, PREFIX, LOOKUP_DEADLINE_MS };
@@ -27,6 +27,8 @@ const ROUTES = [
@@ -27,6 +27,8 @@ const ROUTES = [
['/', 'page-home'], ['/vods', 'page-vods'], ['/clips', 'page-clips'], ['/pastes', 'page-pastes'],
['/', 'page-home'], ['/vods', 'page-vods'], ['/clips', 'page-clips'], ['/pastes', 'page-pastes'],
['/chat', 'page-chat'], ['/arena', 'page-arena'], ['/broadcast', 'page-broadcast'], ['/documentation', 'page-documentation'],
['/chat', 'page-chat'], ['/arena', 'page-arena'], ['/broadcast', 'page-broadcast'], ['/documentation', 'page-documentation'],
['/updates', 'page-updates'], ['/dashboard', null], [process.env.CHANNEL || '/@admin', 'page-channel'],
['/updates', 'page-updates'], ['/dashboard', null], [process.env.CHANNEL || '/@admin', 'page-channel'],
+ // Answered 404 by the server (server/web/page-status.js) with the same shell.
+ ['/no-such-page', 'page-not-found'],
];
];
const ONLY_SIGNED = process.argv.includes('--signed-in-only');
const ONLY_SIGNED = process.argv.includes('--signed-in-only');
const WIDTHS = ONLY_SIGNED ? [] : ROUTES_ONLY ? [1366] : [320, 412, 768, 1440];
const WIDTHS = ONLY_SIGNED ? [] : ROUTES_ONLY ? [1366] : [320, 412, 768, 1440];
new file mode 100644
new file mode 100644
@@ -0,0 +1,251 @@
@@ -0,0 +1,251 @@
+/**
+ * Pages that name nothing answer 404 (server/web/page-status.js), with the same SPA shell.
+ *
+ * Every page path used to get index.html with 200 — a typo, a deleted VOD, a channel that never
+ * existed — so search engines indexed soft 404s and monitors could not see a broken link. The SPA
+ * fallback now answers 404 for an unknown route, channel, VOD, clip, paste or stream, and for a
+ * private VOD or clip seen by anyone but its owners and staff (the rule in media-proxy/access.js);
+ * every real route keeps its 200.
+ *
+ * The SEO middleware and the real fallback run in an express app on a temp database, with Media and
+ * Community stubbed in-process and sign-in stubbed by an `x-test-user` header.
+ *
+ * node test/page-status.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = path.join(os.tmpdir(), `ov-page-status-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.NODE_ENV = 'test';
+delete process.env.PASTES_ON_COMMUNITY;
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const express = require('express');
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+
+// ── Sign-in stub ──
+const auth = require('../server/auth/auth');
+auth.optionalAuth = (req, res, next) => {
+ const id = Number(req.headers['x-test-user'] || 0);
+ const u = id ? db.getUserById(id) : null;
+ if (u) req.user = u;
+ next();
+};
+
+const addUser = (id, username, role) => raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
+ VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);
+addUser(1, 'admin', 'admin');
+addUser(3, 'alice', 'streamer'); // owns the private VOD
+addUser(4, 'bob', 'user'); // exists, has no channel row yet
+addUser(5, 'carol', 'user'); // made the private clip
+addUser(7, 'mallory', 'user'); // nobody special
+db.ensureChannel(3);
+const chanA = db.getChannelByUserId(3);
+const streamA = Number(db.createStream({ user_id: 3, channel_id: chanA.id, title: 'A', protocol: 'webrtc' }).lastInsertRowid);
+
+// ── Media + Community stubs ──
+const media = require('../server/media-client');
+const VODS = {
+ 100: { id: 100, user_id: 3, title: 'Public VOD', visibility: 'public', is_public: 1, status: 'ready' },
+ 101: { id: 101, user_id: 3, stream_id: streamA, title: 'Secret VOD', visibility: 'private', is_public: 0, status: 'ready' },
+ 102: { id: 102, user_id: 3, title: 'Legacy hidden', is_public: 0, status: 'ready' },
+ 103: { id: 103, user_id: 3, title: 'Unlisted VOD', visibility: 'unlisted', is_public: 0, status: 'ready' },
+};
+const CLIPS = {
+ 200: { id: 200, user_id: 5, vod_id: 100, title: 'Public clip', visibility: 'public', is_public: 1, status: 'ready' },
+ 201: { id: 201, user_id: 5, channel_user_id: 3, vod_id: 101, title: 'Secret clip', visibility: 'private', is_public: 0, status: 'ready' },
+};
+const PASTES = { abc123: { slug: 'abc123', title: 'Hello', type: 'text', content: 'hi', visibility: 'public' } };
+const calls = { vod: 0, clip: 0, paste: 0 };
+let mediaMode = 'ok'; // 'ok' | 'down' | 'slow'
+const missing = (what) => new media.MediaApiError(`${what} not found`, 404, { error: `${what} not found` });
+const upstream = async () => {
+ if (mediaMode === 'down') throw new media.MediaApiError('Media unreachable', 0, null);
+ if (mediaMode === 'slow') await new Promise((r) => setTimeout(r, 4000));
+};
+media.getVod = async (id) => { calls.vod++; await upstream(); const v = VODS[Number(id)]; if (!v) throw missing('VOD'); return { ...v }; };
+media.getClip = async (id) => { calls.clip++; await upstream(); const c = CLIPS[Number(id)]; if (!c) throw missing('Clip'); return { ...c }; };
+media.listVods = async () => ({ vods: [] });
+media.listClips = async () => ({ clips: [] });
+media.listPastes = async () => ({ pastes: [] });
+media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };
+const pastesClient = require('../server/pastes-client');
+pastesClient.getPaste = async (slug) => { calls.paste++; const p = PASTES[slug]; if (!p) throw missing('Paste'); return { ...p }; };
+pastesClient.listPastes = async () => ({ pastes: [] });
+
+// ── The app: the SEO middleware, then the real SPA fallback (as server/index.js mounts them) ──
+const assets = require('../server/web/assets');
+const pageStatus = require('../server/web/page-status');
+const app = express();
+require('../server/seo/seo').register(app);
+app.get('*', pageStatus.spaFallback((res, urlPath) => {
+ const doc = assets.document('index.html');
+ if (!doc) return false;
+ res.type('html').send(assets.renderRoute(doc.html, urlPath));
+ return true;
+}));
+
+let base;
+function get(p, { user, html = false, method = 'GET' } = {}) {
+ return new Promise((resolve, reject) => {
+ const headers = {};
+ if (user) headers['x-test-user'] = String(user);
+ // A browser's navigation goes through the SEO middleware first; `*/*` (curl, monitors) does not.
+ headers.accept = html ? 'text/html,application/xhtml+xml' : '*/*';
+ const req = http.request(base + p, { method, headers }, (res) => {
+ let body = '';
+ res.on('data', (c) => { body += c; });
+ res.on('end', () => resolve({ status: res.statusCode, body, type: res.headers['content-type'] || '' }));
+ });
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); }
+ catch (e) { failures++; console.log(' ✗', name, '\n ', e.message); }
+}
+const statusOf = async (p, opts) => (await get(p, opts)).status;
+async function expectAll(paths, want, opts) {
+ const wrong = [];
+ for (const p of paths) {
+ for (const html of [false, true]) {
+ const s = await statusOf(p, { ...opts, html });
+ if (s !== want) wrong.push(`${p}${html ? ' (html)' : ''} → ${s}`);
+ }
+ }
+ assert.deepStrictEqual(wrong, [], `expected ${want}: ${wrong.join(', ')}`);
+}
+
+const server = http.createServer(app);
+server.listen(0, '127.0.0.1', async () => {
+ base = `http://127.0.0.1:${server.address().port}`;
+ console.log('Page status (soft 404s)');
+
+ await check('every real route answers 200', () => expectAll([
+ '/', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes',
+ '/dashboard', '/dashboard/anything', '/broadcast', '/broadcast/slot-2', '/chat', '/chat/voice/lobby',
+ '/arena', '/arena/beef/4', '/arena/alice',
+ '/@alice', '/@Alice', '/@alice/', '/@alice/main', '/@bob',
+ '/vod/100', '/vod/103', '/clip/200', '/p/abc123',
+ `/recap/${streamA}`, `/stream/${streamA}`,
+ ], 200));
+
+ await check('unknown paths answer 404', () => expectAll([
+ '/nope', '/search', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1',
+ '/@nobody', '/@ab', '/@alice/main/extra', '/@bad.name', '/alice',
+ '/vod', '/vod/999', '/vod/abc', '/vod/100/extra', '/clip/999', '/clip/-1',
+ '/p/missing', '/p/bad!slug', '/p/abc123/extra',
+ '/recap/999999', '/recap/x', '/stream/999999', '/stream',
+ ], 404));
+
+ await check('a private VOD or clip is a 404 to strangers, with or without a sign-in', async () => {
+ await expectAll(['/vod/101', '/vod/102', '/clip/201'], 404);
+ await expectAll(['/vod/101', '/vod/102', '/clip/201'], 404, { user: 7 });
+ });
+
+ await check('its owners and staff still get 200', async () => {
+ await expectAll(['/vod/101', '/vod/102'], 200, { user: 3 }); // uploader
+ await expectAll(['/clip/201'], 200, { user: 5 }); // clipper
+ await expectAll(['/clip/201'], 200, { user: 3 }); // clipped channel
+ await expectAll(['/vod/101', '/vod/102', '/clip/201'], 200, { user: 1 }); // staff
+ });
+
+ await check('a 404 page is the SPA shell (the client renders its not-found view), and leaks nothing', async () => {
+ const shell = await get('/vod/101', { html: true });
+ assert.strictEqual(shell.status, 404);
+ assert.ok(/text\/html/.test(shell.type), shell.type);
+ assert.ok(shell.body.includes('id="ov-route-features"'), 'the route boot tag is there');
+ assert.ok(shell.body.includes('/js/app.js'), 'the client router is loaded');
+ assert.ok(!shell.body.includes('Secret VOD'), 'the private title is not in the page');
+ const unknown = await get('/no-such-page', { html: true });
+ assert.strictEqual(unknown.status, 404);
+ assert.ok(unknown.body.includes('/js/app.js'));
+ });
+
+ await check('HEAD matches GET, and API paths keep their JSON 404', async () => {
+ assert.strictEqual(await statusOf('/no-such-page', { method: 'HEAD' }), 404);
+ assert.strictEqual(await statusOf('/vods', { method: 'HEAD' }), 200);
+ const api = await get('/api/definitely-not-a-route');
+ assert.strictEqual(api.status, 404);
+ assert.deepStrictEqual(JSON.parse(api.body), { error: 'Not found' });
+ });
+
+ await check('Media lookups are cached and shared: one fetch per id, however many requests', async () => {
+ pageStatus.clearCache();
+ const before = calls.vod;
+ const all = await Promise.all([1, 2, 3, 4, 5].map(() => statusOf('/vod/999')));
+ assert.deepStrictEqual(all, [404, 404, 404, 404, 404]);
+ await statusOf('/vod/999');
+ assert.strictEqual(calls.vod - before, 1, `Media was asked ${calls.vod - before} times`);
+ const pastesBefore = calls.paste;
+ await statusOf('/p/missing'); await statusOf('/p/missing');
+ assert.strictEqual(calls.paste - pastesBefore, 1);
+ // A browser navigation: the SEO renderer asks once and remembers the miss, the fallback asks once.
+ const htmlBefore = calls.clip;
+ for (let i = 0; i < 5; i++) assert.strictEqual(await statusOf('/clip/998', { html: true }), 404);
+ assert.ok(calls.clip - htmlBefore <= 2, `Media was asked ${calls.clip - htmlBefore} times`);
+ });
+
+ await check('the home page and fixed routes never wait on a lookup', async () => {
+ mediaMode = 'slow';
+ pageStatus.clearCache();
+ const t0 = Date.now();
+ for (const p of ['/', '/vods', '/dashboard', '/@alice', '/nope']) await statusOf(p);
+ assert.ok(Date.now() - t0 < 1000, `took ${Date.now() - t0}ms`);
+ mediaMode = 'ok';
+ });
+
+ await check('when Media is down or slow, VOD and clip pages answer 200 rather than a false 404', async () => {
+ mediaMode = 'down';
+ pageStatus.clearCache();
+ assert.strictEqual(await statusOf('/vod/999'), 200);
+ assert.strictEqual(await statusOf('/clip/201'), 200);
+ mediaMode = 'slow';
+ pageStatus.clearCache();
+ const t0 = Date.now();
+ assert.strictEqual(await statusOf('/vod/998'), 200);
+ const waited = Date.now() - t0;
+ assert.ok(waited < pageStatus.LOOKUP_DEADLINE_MS + 800, `waited ${waited}ms`);
+ mediaMode = 'ok';
+ });
+
+ await check('server/index.js mounts this fallback last', () => {
+ const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
+ const at = src.indexOf("app.get('*', require('./web/page-status').spaFallback(");
+ assert.ok(at > 0, 'the SPA fallback is page-status.spaFallback');
+ assert.ok(!/app\.get\('\*'/.test(src.slice(at + 10)), 'no second catch-all after it');
+ });
+
+ await check('the client router knows the same fixed routes', () => {
+ const src = fs.readFileSync(path.join(__dirname, '../public/js/app.js'), 'utf8');
+ const body = src.slice(src.indexOf('function routeFromURL('), src.indexOf('function showNotFound('));
+ const clientRoutes = new Set([...body.matchAll(/segments\[0\] === '([a-z-]+)'/g)].map((m) => m[1]));
+ // Server-side routes (legal pages, moved games) never reach the fallback; recap, stream, vod,
+ // clip and p are looked up.
+ const serverOnly = new Set(['dmca', 'tos', 'terms', 'privacy', 'game', 'canvas']);
+ const looked = new Set(['vod', 'clip', 'p', 'recap', 'stream']);
+ const known = new Set([...pageStatus.EXACT, ...pageStatus.PREFIX, ...looked]);
+ const unknownToServer = [...clientRoutes].filter((r) => !known.has(r) && !serverOnly.has(r));
+ assert.deepStrictEqual(unknownToServer, [], 'a client route the server would answer 404');
+ assert.ok(body.includes('showNotFound()'), 'unknown paths render the not-found view');
+ });
+
+ server.close();
+ try { fs.unlinkSync(tmp); } catch { /* */ }
+ for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ console.log(failures ? `\n${failures} check(s) failed` : '\npage status: all checks passed');
+ process.exit(failures ? 1 : 0);
+});