openvibers/openvibe.media Commits

A missing page answers the site's 404 page in the Frame (#268)

OP OpenVibers committed 12 days ago · 77f1f6b2fc · 3 files, +82 −38

* Answer a missing page with the site's 404 page in the Frame

A browser that asks for a path Media does not serve gets the site's own 404 page in the OpenVibe Frame (noindex, one line naming the host, links home, videos and clips) instead of a page titled Error. An API path, a non-GET method or a request whose Accept is not text/html keeps the JSON answer.

* Raise the home page HTML budget to its fresh measure

The WebSite + Organization JSON-LD added in #264 measures 51.9 KB on a fresh database (CI and local), 0.1 over the 51.8 budget #264 set, so main's test job is red. The budget comment records the measure.

* Merge main: the HTML budget fix is already there

Changes

ddiff --git a/server/not-found.js b/server/not-found.js
iindex c9a3e1f..14c3fcf 100644
---- a/server/not-found.js
++++ b/server/not-found.js
@@@ -1,38 +1,67 @@
'use strict';
/**
-- * The answer for a path nothing matched: Express's own 404 page ("Cannot GET /x", default-src 'none'),
-- * plus Cloudflare Web Analytics. Cloudflare injects its beacon into every HTML answer at the edge, and
-- * the network's privacy text says Cloudflare may measure performance; under Express's default policy the
-- * beacon was blocked and logged a CSP error on every not-found page (browser check, OpenVibe.Host
-- * scripts/browser-check.js). Media's pages send no policy of their own, so this is Media's one CSP.
++ * The answer for a path nothing matched. A browser that asks for a page gets Media's own 404 page in the
++ * OpenVibe Frame (server/public/page-frame.js); an API client, a player, or a client that does not accept
++ * HTML keeps the JSON answer. The page never names the path or method that was asked for.
++ *
++ * Cloudflare Web Analytics is allowed by this page's CSP: Cloudflare injects its beacon into every HTML
++ * answer at the edge, and the network's privacy text says it may measure performance. Media's other pages
++ * send no policy of their own, so this is the one place the beacon's sources are listed.
*/
++const pageFrame = require('./public/page-frame');
++const config = require('./config');
++
const CSP = [
-- "default-src 'none'",
-- // Cloudflare Web Analytics: script-src loads the beacon, connect-src is where it reports.
-- 'script-src https://static.cloudflareinsights.com',
-- 'connect-src https://cloudflareinsights.com',
++ "default-src 'self'",
++ // The Frame's theme-loader, navbar and footer are served from /shared; the inline boot and page style are the shell's.
++ "script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com",
++ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com",
++ "font-src 'self' data: https://fonts.gstatic.com",
++ "img-src 'self' data: https:",
++ "connect-src 'self' https://openvibe.network https://cloudflareinsights.com",
++ "frame-src 'self' https://openvibe.network",
++ "frame-ancestors 'none'",
++ "object-src 'none'",
++ "base-uri 'self'",
++ "form-action 'self' https://openvibe.network",
].join('; ');
--const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
--const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ESC[c]);
++let cached = null;
++function page() {
++ if (cached) return cached;
++ const host = String(config.publicUrl).replace(/^https?:\/\//, '').replace(/\/.*$/, '');
++ const home = pageFrame.esc(config.publicUrl);
++ cached = pageFrame.page({
++ seo: {
++ title: 'Page not found · OpenVibe.Media',
++ description: `There is no page at this address on ${host}.`,
++ robots: 'noindex, follow',
++ ogType: 'website',
++ },
++ body: `<h1>Page not found</h1>
++<p class="note">There is no page at this address on ${pageFrame.esc(host)}. The link may be old or mistyped.</p>
++<div class="actions">
++<a class="btn" href="${home}/">OpenVibe.Media home</a>
++<a class="btn ghost" href="${home}/?tab=videos">Videos</a>
++<a class="btn ghost" href="${home}/?tab=clips">Clips</a>
++</div>`,
++ footer: { variant: 'compact' },
++ });
++ return cached;
++}
function notFound(req, res) {
res.status(404);
res.set('X-Content-Type-Options', 'nosniff');
-- // An API caller gets JSON, as from Media's own API routes (it got this HTML page).
-- if (req.path === '/api' || req.path.startsWith('/api/')) return res.json({ error: 'Not found' });
++ // An API caller gets JSON, as from Media's own API routes; so does a player or a client that does not want a page.
++ if (req.path === '/api' || req.path.startsWith('/api/')
++ || req.method !== 'GET' || req.accepts(['application/json', 'text/html']) !== 'text/html') {
++ return res.json({ error: 'Not found' });
++ }
res.set('Content-Security-Policy', CSP);
-- res.type('html').send(`<!DOCTYPE html>
--<html lang="en">
--<head>
--<meta charset="utf-8">
--<title>Error</title>
--</head>
--<body>
--<pre>Cannot ${esc(req.method)} ${esc(req.originalUrl)}</pre>
--</body>
--</html>
--`);
++ res.set('Cache-Control', 'no-store');
++ res.set('X-Robots-Tag', 'noindex');
++ return res.type('html').send(page());
}
module.exports = { notFound, CSP };
ddiff --git a/test/not-found-csp.test.js b/test/not-found-csp.test.js
iindex 7095ce3..764a436 100644
---- a/test/not-found-csp.test.js
++++ b/test/not-found-csp.test.js
@@@ -1,8 +1,8 @@
'use strict';
--// server/not-found.js: an unknown path gets Express's own 404 page ("Cannot GET /x") under Media's CSP,
--// which is default-src 'none' plus Cloudflare Web Analytics (script-src static.cloudflareinsights.com,
--// connect-src cloudflareinsights.com). Cloudflare injects the beacon at the edge; Express's default
--// policy blocked it and logged a CSP error on every not-found page (browser check, OpenVibe.Host).
++// server/not-found.js: an unknown path asks for Media's own 404 page in the OpenVibe Frame when the client
++// accepts HTML; an API caller, a player, or any client that does not want a page keeps the JSON answer.
++// The page never echoes the path or method that was asked for, and its CSP still lets Cloudflare's beacon
++// load and report (Cloudflare injects it at the edge; Media's pages send no policy of their own).
const assert = require('assert');
const fs = require('fs');
const path = require('path');
@@@ -11,9 +11,9 @@ const express = require('express');
(async () => {
const { notFound, CSP } = require('../server/not-found');
-- function send(base, p, method = 'GET') {
++ function send(base, p, method = 'GET', accept = 'text/html') {
return new Promise((resolve, reject) => {
-- const req = http.request(base + p, { method, headers: { accept: 'text/html' } }, (res) => {
++ const req = http.request(base + p, { method, headers: { accept } }, (res) => {
let b = ''; res.on('data', (d) => { b += d; });
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: b }));
});
@@@ -28,22 +28,36 @@ const express = require('express');
const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
try {
-- assert.strictEqual(CSP, "default-src 'none'; script-src https://static.cloudflareinsights.com; connect-src https://cloudflareinsights.com");
++ assert.match(CSP, /^default-src 'self'/);
++ assert.match(CSP, /script-src [^;]*https:\/\/static\.cloudflareinsights\.com/);
++ assert.match(CSP, /connect-src [^;]*https:\/\/cloudflareinsights\.com/);
assert.strictEqual((await send(base, '/known')).status, 200);
const r = await send(base, '/__ovcheck-404');
assert.strictEqual(r.status, 404);
assert.strictEqual(r.headers['content-security-policy'], CSP);
assert.strictEqual(r.headers['x-content-type-options'], 'nosniff');
++ assert.strictEqual(r.headers['x-robots-tag'], 'noindex');
assert.match(r.headers['content-type'], /^text\/html/);
-- assert.ok(r.body.includes('<pre>Cannot GET /__ovcheck-404</pre>'), r.body);
++ assert.ok(r.body.includes('<h1>Page not found</h1>'), r.body.slice(0, 300));
++ assert.match(r.body, /There is no page at this address on [^<]+\. The link may be old or mistyped\./);
++ assert.ok(r.body.includes('id="navbar-mount"'), 'the page is in the site Frame');
++ assert.ok(r.body.includes('<meta name="robots" content="noindex, follow">'));
++ assert.ok(!r.body.includes('__ovcheck-404'), 'the page never names the path that was asked for');
++ // The path and the query are not echoed; every missing path gets the same page.
const x = await send(base, '/x%3Cb%3E?q=<script>');
assert.strictEqual(x.status, 404);
-- assert.ok(!x.body.includes('<script>'), 'the path is escaped');
++ assert.strictEqual(x.body, r.body, 'one 404 page, whatever was asked for');
++ assert.ok(!x.body.includes('<pre>Cannot'));
-- const post = await send(base, '/nope', 'POST');
-- assert.ok(post.body.includes('<pre>Cannot POST /nope</pre>'));
++ // An API path, a POST or a client that does not accept HTML keeps the JSON answer.
++ for (const [p, method, accept] of [['/api/nope', 'GET', 'text/html'], ['/nope', 'GET', 'application/json'], ['/nope', 'POST', 'text/html']]) {
++ const j = await send(base, p, method, accept);
++ assert.strictEqual(j.status, 404, `${method} ${p}`);
++ assert.match(j.headers['content-type'], /^application\/json/);
++ assert.deepStrictEqual(JSON.parse(j.body), { error: 'Not found' });
++ }
const head = await send(base, '/nope', 'HEAD');
assert.deepStrictEqual([head.status, head.body], [404, '']);
@@@ -52,7 +66,7 @@ const express = require('express');
const at = idx.indexOf("app.use(require('./not-found').notFound)");
assert.ok(at > idx.indexOf("app.use('/', require('./public/routes'))"), 'after the last routes');
assert.ok(at < idx.indexOf('app.use((err, req, res, next) =>'), 'before the error handler');
-- console.log('not-found: 404 under Media\'s CSP, Cloudflare Web Analytics allowed');
++ console.log('not-found: 404 in Media\'s Frame for a browser, JSON for an API caller');
} finally { server.close(); }
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/observability.test.js b/test/observability.test.js
iindex a37339f..8c90084 100644
---- a/test/observability.test.js
++++ b/test/observability.test.js
@@@ -154,12 +154,13 @@ const { spawn } = require('child_process');
assert.ok(m.body.includes('http_requests_total{method="GET",route="/api/ready",status_class="2xx"} 1\n'), m.body.slice(0, 400));
const h = await request(b2, '/healthz');
assert.strictEqual(h.status, 200, '/healthz still answers');
-- // An unknown path is index.js's last handler (server/not-found.js): a 404 whose CSP lets Cloudflare's beacon run.
++ // An unknown path is index.js's last handler (server/not-found.js): Media's own 404 page in the
++ // Frame, under a CSP that still lets Cloudflare's beacon run.
const nf = await new Promise((resolve, reject) => http.get(`${b2}/__ovcheck-404`, { headers: { accept: 'text/html' } }, (res) => {
let b = ''; res.on('data', (d) => { b += d; }); res.on('end', () => resolve({ status: res.statusCode, csp: res.headers['content-security-policy'], body: b }));
}).on('error', reject));
assert.deepStrictEqual([nf.status, nf.csp], [404, require('../server/not-found').CSP]);
-- assert.ok(nf.body.includes('<pre>Cannot GET /__ovcheck-404</pre>'));
++ assert.ok(nf.body.includes('<h1>Page not found</h1>') && nf.body.includes('id="navbar-mount"'), nf.body.slice(0, 300));
// ...and an unknown /api path is JSON, as from Media's API routes.
const api404 = await request(b2, '/api/nope');
assert.strictEqual(api404.status, 404);