openvibers/openvibe.bot Commits

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

OP OpenVibers committed 12 days ago · 05adc31ddb · 4 files, +65 −24

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

A browser that asks for a path Bot does not serve gets the site's own 404 page (the OpenVibe Frame, noindex, one line naming the host, links home, to robots and to the docs) instead of a plain page with no Frame. An API path or a request whose Accept is not text/html keeps the problem document.

* Cover the browser and non-HTML 404 answers in the legal test

The app's 404 page now needs Accept: text/html (a plain */* request is an API-style client and keeps problem+json), so the legal test asks for the page as a browser and checks the JSON answer separately.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex ea6341b..8816104 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -43,7 +43,7 @@ const { createJobs } = require('./jobs');
const { createActorLimits } = require('./api/actor-limits');
const { createSessionRoutes, viewerMiddleware } = require('./web/session');
const { createWebRoutes } = require('./web/routes');
--const { HOME_CSP } = require('./web/home');
++const { HOME_CSP, renderNotFound } = require('./web/home');
const { createSubmissions } = require('./submissions');
const { createRoutines } = require('./routines');
const { createWebhooks } = require('./webhooks');
@@@ -53,18 +53,6 @@ const { createBotReadiness, registerBotGauges } = require('./observability');
const VERSION = require('../package.json').version;
--/**
-- * The browser-facing 404 (the API's is problem+json): a plain, JavaScript-free page with a way back.
-- * The app's CSP applies, so it carries no inline styles or scripts.
-- */
--const NOT_FOUND_HTML = `<!doctype html>
--<html lang="en">
--<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
--<meta name="robots" content="noindex"><title>Not found — OpenVibe.Bot</title></head>
--<body><main><h1>Not found</h1><p>There is nothing at this address. Go to the <a href="/">OpenVibe.Bot home page</a>.</p></main></body>
--</html>
--`;
--
function createApp(opts = {}) {
const config = opts.config || loadConfig();
const db = opts.db || openDb(config);
@@@ -205,8 +193,11 @@ function createApp(opts = {}) {
app.use(webRoutes);
app.use((req, res) => {
-- if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
-- return res.status(404).type('html').send(NOT_FOUND_HTML);
++ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/') || req.accepts(['application/json', 'text/html']) !== 'text/html') {
++ return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
++ }
++ res.setHeader('Content-Security-Policy', HOME_CSP);
++ return res.status(404).type('html').set('Cache-Control', 'no-store').set('X-Robots-Tag', 'noindex').send(renderNotFound({ config }));
});
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
ddiff --git a/server/web/home.js b/server/web/home.js
iindex 5e8b2f4..9f8328a 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -269,7 +269,7 @@ function sections({ signedIn = false, config, models = [], robots = [], kit: k =
const footerBoot = (footer) => `<script>document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(${JSON.stringify(footer)}); } catch (e) { /* the Frame is optional */ } });</script>`;
/** The OpenVibe Frame around one of Bot's public pages (the front page, the Build docs): navbar, footer, theme. */
--function framePage({ config, title, description, canonicalPath = '/', body, head = '' }) {
++function framePage({ config, title, description, canonicalPath = '/', body, head = '', noindex = false }) {
const nav = {
service: 'bot',
apiBase: NETWORK_URL,
@@@ -281,9 +281,11 @@ function framePage({ config, title, description, canonicalPath = '/', body, head
notificationsRealtime: true,
};
return shell.page({
-- name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description, summary: description,
-- canonical: `${config.baseUrl}${canonicalPath}`, robots: 'index, follow',
-- agentLayer: require('../agent-pages').TWIN_PATH_RE.test(canonicalPath), llms: '/llms.txt',
++ name: SITE_NAME, service: 'bot', lang: 'en', title, siteName: SITE_NAME, description,
++ ...(noindex ? {} : { summary: description }),
++ canonical: noindex ? undefined : `${config.baseUrl}${canonicalPath}`,
++ robots: noindex ? 'noindex, follow' : 'index, follow',
++ agentLayer: !noindex && require('../agent-pages').TWIN_PATH_RE.test(canonicalPath), llms: '/llms.txt',
navbar: nav, footer: { service: 'bot', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, home: '/',
navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
head: [appIcon.headTags({ site: 'bot' }), '<link rel="manifest" href="/manifest.webmanifest">', `<link rel="apple-touch-icon" href="${require('openvibe-shared/app-icons').url('bot', 192)}">`, head].filter(Boolean).join('\n'),
@@@ -291,6 +293,30 @@ function framePage({ config, title, description, canonicalPath = '/', body, head
});
}
++/** The page a browser gets for a path nothing serves: Bot's Frame, one way back, and no clue about what was asked for. */
++function renderNotFound({ config }) {
++ const host = String(config.baseUrl).replace(/^https?:\/\//, '').replace(/\/.*$/, '');
++ return framePage({
++ config,
++ title: `Page not found · ${SITE_NAME}`,
++ description: `There is no page at this address on ${host}.`,
++ noindex: true,
++ head: [
++ `<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
++ UPDATES_FONT,
++ ].join('\n'),
++ body: `<main id="main" class="page">
++<h1>Page not found</h1>
++<p>There is no page at this address on ${esc(host)}. The link may be old or mistyped.</p>
++<div class="sc-actions">
++<a class="sc-btn sc-primary" href="/">${SITE_NAME} home</a>
++<a class="sc-btn" href="/robots">Robots</a>
++<a class="sc-btn" href="/docs">Build</a>
++</div>
++</main>`,
++ });
++}
++
// The page's type: shell.page sets no font and showcase.css styles only its own sections, so without these rules
// /updates fell back to the browser's Times New Roman (domain sweep, 2026-11-01). Zero specificity: any site rule wins.
const UPDATES_FONT = "<style>:where(body){font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif)}:where(main.page){display:block;max-width:880px;margin:0 auto;padding:24px max(16px,env(safe-area-inset-right,0px)) 56px max(16px,env(safe-area-inset-left,0px))}:where(.ov-updates h1){font-size:clamp(1.6rem,5.4vw,2.2rem);line-height:1.15;margin:0 0 10px;text-wrap:balance}</style>";
@@@ -354,4 +380,4 @@ ${footerBoot(footer)}`,
});
}
--module.exports = { renderHome, renderUpdates, framePage, HOME_CSP, sections, faqItems };
++module.exports = { renderHome, renderUpdates, renderNotFound, framePage, HOME_CSP, sections, faqItems };
ddiff --git a/test/home.test.js b/test/home.test.js
iindex cede8d6..325e17b 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -102,6 +102,25 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.headers.get('content-security-policy'), "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; frame-ancestors 'self'; object-src 'none'; base-uri 'self'");
});
++ await check('a browser that asks for a missing page gets the site 404 page in the Frame', async () => {
++ const r = await fetch(t.base + '/no-such-page-xyz', { headers: { Accept: 'text/html,*/*;q=0.8' }, redirect: 'manual' });
++ assert.strictEqual(r.status, 404);
++ assert.match(r.headers.get('content-type'), /^text\/html/);
++ const html = await r.text();
++ assert.ok(html.includes('<h1>Page not found</h1>'), html.slice(0, 300));
++ assert.ok(html.includes('There is no page at this address on bot.test. The link may be old or mistyped.'));
++ assert.ok(html.includes('id="navbar-mount"'), 'the page is in the site Frame');
++ assert.ok(html.includes('<meta name="robots" content="noindex, follow">'));
++ assert.strictEqual(r.headers.get('x-robots-tag'), 'noindex');
++ assert.match(r.headers.get('content-security-policy'), /style-src [^;]*'unsafe-inline'/);
++ assert.ok(html.includes('class="sc-btn sc-primary" href="/"'), 'home is the first way back');
++ const api = await fetch(t.base + '/api/no-such', { headers: { Accept: 'text/html' } });
++ assert.strictEqual(api.status, 404);
++ assert.match(api.headers.get('content-type'), /problem\+json/);
++ const notHtml = await fetch(t.base + '/no-such-page-xyz', { headers: { Accept: 'application/json' } });
++ assert.match(notHtml.headers.get('content-type'), /problem\+json/);
++ });
++
await check('the vhost proxies / and /shared/ to the app; the legal pages are the app\'s, not the Sites files', async () => {
const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'openvibe.bot.conf'), 'utf8');
assert.match(conf, /location \^~ \/shared\/ \{\s*proxy_pass http:\/\/127\.0\.0\.1:4630;\s*\}/);
ddiff --git a/test/legal.test.js b/test/legal.test.js
iindex eea56f2..b4c207a 100644
---- a/test/legal.test.js
++++ b/test/legal.test.js
@@@ -20,7 +20,7 @@ const { boot, check, done } = require('./helpers/app');
(async () => {
const t = await boot({ openre: false });
-- const get = (p) => fetch(t.base + p, { redirect: 'manual' });
++ const get = (p, init = {}) => fetch(t.base + p, { redirect: 'manual', ...init });
await check('GET /terms, /privacy, /dmca: OpenVibe.Bot legal pages served by the app', async () => {
for (const p of ['/terms', '/privacy', '/dmca']) {
@@@ -69,14 +69,19 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(Array.isArray(m.icons) && m.icons.length > 0, 'has icons');
});
-- await check('an unknown page is the app\'s 404 HTML page; the API keeps its problem+json 404', async () => {
-- const page = await get('/no-such-page');
++ await check('an unknown page is the app\'s 404 HTML page for a browser; the API keeps its problem+json 404', async () => {
++ const page = await get('/no-such-page', { headers: { Accept: 'text/html' } });
assert.strictEqual(page.status, 404);
assert.match(page.headers.get('content-type'), /^text\/html/);
-- assert.match(await page.text(), /Not found/);
++ const html = await page.text();
++ assert.ok(html.includes('<h1>Page not found</h1>'), html.slice(0, 300));
++ assert.ok(html.includes('id="navbar-mount"'), 'the page is in the site Frame');
const api = await fetch(t.base + '/api/no-such-route');
assert.strictEqual(api.status, 404);
assert.match(api.headers.get('content-type'), /application\/problem\+json/);
++ const notHtml = await get('/no-such-page');
++ assert.strictEqual(notHtml.status, 404);
++ assert.match(notHtml.headers.get('content-type'), /application\/problem\+json/, 'a client that does not accept HTML keeps JSON');
});
await check('the vhost no longer serves anything from a Sites checkout', async () => {