openvibers/openvibe.events Commits
Events home passes the browser check: CSP allows Cloudflare Web Analytics, browsers get an HTML 404 (lang, title, icon), /favicon.ico serves the app icon (#25)
Changes
@@@ -14,7 +14,8 @@ const { placementRouter } = require('./api/placement');const { resourcesRouter } = require('./api/resources');const { mountLimits } = require('./limits');const { createLimits } = require('./actor-limits');--const { renderHome, HOME_CSP } = require('./home');++const { renderHome, renderNotFound, HOME_CSP } = require('./home');++const appIcon = require('openvibe-shared/app-icon');const { createDiscoveryRoutes } = require('./discovery');const ovServe = require('openvibe-shared/serve');const pkg = require('../package.json');@@@ -126,7 +127,16 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo].join('\n'));});-- app.use((req, res) => http.sendProblem(res, 404, 'events.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));++ // Browsers ask for /favicon.ico on their own: the app icon, as SVG.++ app.get('/favicon.ico', (req, res) => res.type('image/svg+xml').set('Cache-Control', 'public, max-age=86400').send(appIcon.favicon({ site: 'events' })));++++ // A browser asking for a page that is not here gets a page; an API client the problem document.++ app.use((req, res) => {++ if (req.method === 'GET' && !req.path.startsWith('/api/') && req.accepts(['application/json', 'text/html']) === 'text/html') {++ return res.status(404).type('html').set('Content-Security-Policy', HOME_CSP).set('Cache-Control', 'no-store').send(renderNotFound());++ }++ return http.sendProblem(res, 404, 'events.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov });++ });// eslint-disable-next-line no-unused-varsapp.use((err, req, res, _next) => {@@@ -22,11 +22,13 @@ const DESCRIPTION = 'Durable events for the OpenVibe network: publish once, andconst HOME_CSP = ["default-src 'self'",-- "script-src 'self' 'unsafe-inline' https://openvibe.network",++ // Cloudflare Web Analytics: Cloudflare injects its beacon at the edge on this zone; script-src loads it and connect-src++ // is where it reports (performance timing only, no cookies).++ "script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com","style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com","font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com","img-src 'self' data: https:",-- "connect-src 'self' https://openvibe.network https://openvibe.events",++ "connect-src 'self' https://openvibe.network https://openvibe.events https://cloudflareinsights.com","frame-src 'self' https://openvibe.network","frame-ancestors 'none'","object-src 'none'",@@@ -118,4 +120,12 @@ ${sections({ apiUrl })}});}--module.exports = { renderHome, HOME_CSP, DESCRIPTION, sections };++/** The page a browser gets for a path nothing serves: a real document (language, title, the app icon), not JSON. */++function renderNotFound() {++ return ['<!doctype html>', '<html lang="en">', '<head>', '<meta charset="utf-8">', '<meta name="viewport" content="width=device-width, initial-scale=1">',++ `<title>Not found · ${SITE_NAME}</title>`, '<meta name="robots" content="noindex">', appIcon.headTags({ site: 'events' }), '</head>',++ '<body style="font-family:system-ui,sans-serif;margin:3rem auto;max-width:36rem;padding:0 1rem;line-height:1.5">',++ '<h1>Not found</h1>', `<p>Nothing is here. <a href="/">${SITE_NAME}</a> · <a href="${NETWORK_URL}">OpenVibe</a></p>`, '</body>', '</html>'].join('\n');++}++++module.exports = { renderHome, renderNotFound, HOME_CSP, DESCRIPTION, sections };@@@ -69,6 +69,31 @@ t('GET / as a browser: the home page, its CSP and its cache policy', async () =>assert.ok(!r.body.includes('events.openvibe.network'), 'the home names no other origin');});++t('the home CSP lets Cloudflare Web Analytics load and report (it injects its beacon on this zone)', () => {++ assert.match(HOME_CSP, /script-src [^;]*https:\/\/static\.cloudflareinsights\.com/);++ assert.match(HOME_CSP, /connect-src [^;]*https:\/\/cloudflareinsights\.com/);++});++++t('a browser gets a page for a path nothing serves (lang, title, icon); an API client the problem document', async () => {++ const page = await raw(h.base, '/no-such-page', { Accept: 'text/html,*/*;q=0.8' });++ assert.strictEqual(page.status, 404);++ assert.match(page.headers['content-type'], /^text\/html/);++ assert.ok(page.body.includes('<html lang="en">') && page.body.includes('<title>Not found') && page.body.includes('rel="icon"'), page.body.slice(0, 300));++ const api = await raw(h.base, '/no-such-page', { Accept: 'application/json' });++ assert.strictEqual(api.status, 404);++ assert.match(api.headers['content-type'], /problem\+json|json/);++ const apiPath = await raw(h.base, '/api/v1/nothing', { Accept: 'text/html' });++ assert.match(apiPath.headers['content-type'], /json/, 'an /api/ path answers JSON whatever it accepts');++});++++t('/favicon.ico is the app icon (SVG), cached a day', async () => {++ const r = await raw(h.base, '/favicon.ico', {});++ assert.strictEqual(r.status, 200);++ assert.match(r.headers['content-type'], /^image\/svg\+xml/);++ assert.ok(r.body.startsWith('<svg'));++ assert.strictEqual(r.headers['cache-control'], 'public, max-age=86400');++});++t('the home links the pinned Frame stylesheet, and /shared serves it', async () => {const r = await raw(h.base, '/', { Accept: 'text/html' });const href = /<link rel="stylesheet" href="([^"]+)"/.exec(r.body);
@@ -14,7 +14,8 @@ const { placementRouter } = require('./api/placement');
@@ -14,7 +14,8 @@ const { placementRouter } = require('./api/placement');
const { resourcesRouter } = require('./api/resources');
const { resourcesRouter } = require('./api/resources');
const { mountLimits } = require('./limits');
const { mountLimits } = require('./limits');
const { createLimits } = require('./actor-limits');
const { createLimits } = require('./actor-limits');
-const { renderHome, HOME_CSP } = require('./home');
+const { renderHome, renderNotFound, HOME_CSP } = require('./home');
+const appIcon = require('openvibe-shared/app-icon');
const { createDiscoveryRoutes } = require('./discovery');
const { createDiscoveryRoutes } = require('./discovery');
const ovServe = require('openvibe-shared/serve');
const ovServe = require('openvibe-shared/serve');
const pkg = require('../package.json');
const pkg = require('../package.json');
@@ -126,7 +127,16 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
@@ -126,7 +127,16 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
].join('\n'));
].join('\n'));
});
});
- app.use((req, res) => http.sendProblem(res, 404, 'events.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
+ // Browsers ask for /favicon.ico on their own: the app icon, as SVG.
+ app.get('/favicon.ico', (req, res) => res.type('image/svg+xml').set('Cache-Control', 'public, max-age=86400').send(appIcon.favicon({ site: 'events' })));
+
+ // A browser asking for a page that is not here gets a page; an API client the problem document.
+ app.use((req, res) => {
+ if (req.method === 'GET' && !req.path.startsWith('/api/') && req.accepts(['application/json', 'text/html']) === 'text/html') {
+ return res.status(404).type('html').set('Content-Security-Policy', HOME_CSP).set('Cache-Control', 'no-store').send(renderNotFound());
+ }
+ return http.sendProblem(res, 404, 'events.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov });
+ });
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
app.use((err, req, res, _next) => {
@@ -22,11 +22,13 @@ const DESCRIPTION = 'Durable events for the OpenVibe network: publish once, and
@@ -22,11 +22,13 @@ const DESCRIPTION = 'Durable events for the OpenVibe network: publish once, and
const HOME_CSP = [
const HOME_CSP = [
"default-src 'self'",
"default-src 'self'",
- "script-src 'self' 'unsafe-inline' https://openvibe.network",
+ // Cloudflare Web Analytics: Cloudflare injects its beacon at the edge on this zone; script-src loads it and connect-src
+ // is where it reports (performance timing only, no cookies).
+ "script-src 'self' 'unsafe-inline' https://openvibe.network https://static.cloudflareinsights.com",
"style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com",
"style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com",
"font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
"font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
"img-src 'self' data: https:",
"img-src 'self' data: https:",
- "connect-src 'self' https://openvibe.network https://openvibe.events",
+ "connect-src 'self' https://openvibe.network https://openvibe.events https://cloudflareinsights.com",
"frame-src 'self' https://openvibe.network",
"frame-src 'self' https://openvibe.network",
"frame-ancestors 'none'",
"frame-ancestors 'none'",
"object-src 'none'",
"object-src 'none'",
@@ -118,4 +120,12 @@ ${sections({ apiUrl })}
@@ -118,4 +120,12 @@ ${sections({ apiUrl })}
});
});
}
}
-module.exports = { renderHome, HOME_CSP, DESCRIPTION, sections };
+/** The page a browser gets for a path nothing serves: a real document (language, title, the app icon), not JSON. */
+function renderNotFound() {
+ return ['<!doctype html>', '<html lang="en">', '<head>', '<meta charset="utf-8">', '<meta name="viewport" content="width=device-width, initial-scale=1">',
+ `<title>Not found · ${SITE_NAME}</title>`, '<meta name="robots" content="noindex">', appIcon.headTags({ site: 'events' }), '</head>',
+ '<body style="font-family:system-ui,sans-serif;margin:3rem auto;max-width:36rem;padding:0 1rem;line-height:1.5">',
+ '<h1>Not found</h1>', `<p>Nothing is here. <a href="/">${SITE_NAME}</a> · <a href="${NETWORK_URL}">OpenVibe</a></p>`, '</body>', '</html>'].join('\n');
+}
+
+module.exports = { renderHome, renderNotFound, HOME_CSP, DESCRIPTION, sections };
@@ -69,6 +69,31 @@ t('GET / as a browser: the home page, its CSP and its cache policy', async () =>
@@ -69,6 +69,31 @@ t('GET / as a browser: the home page, its CSP and its cache policy', async () =>
assert.ok(!r.body.includes('events.openvibe.network'), 'the home names no other origin');
assert.ok(!r.body.includes('events.openvibe.network'), 'the home names no other origin');
});
});
+t('the home CSP lets Cloudflare Web Analytics load and report (it injects its beacon on this zone)', () => {
+ assert.match(HOME_CSP, /script-src [^;]*https:\/\/static\.cloudflareinsights\.com/);
+ assert.match(HOME_CSP, /connect-src [^;]*https:\/\/cloudflareinsights\.com/);
+});
+
+t('a browser gets a page for a path nothing serves (lang, title, icon); an API client the problem document', async () => {
+ const page = await raw(h.base, '/no-such-page', { Accept: 'text/html,*/*;q=0.8' });
+ assert.strictEqual(page.status, 404);
+ assert.match(page.headers['content-type'], /^text\/html/);
+ assert.ok(page.body.includes('<html lang="en">') && page.body.includes('<title>Not found') && page.body.includes('rel="icon"'), page.body.slice(0, 300));
+ const api = await raw(h.base, '/no-such-page', { Accept: 'application/json' });
+ assert.strictEqual(api.status, 404);
+ assert.match(api.headers['content-type'], /problem\+json|json/);
+ const apiPath = await raw(h.base, '/api/v1/nothing', { Accept: 'text/html' });
+ assert.match(apiPath.headers['content-type'], /json/, 'an /api/ path answers JSON whatever it accepts');
+});
+
+t('/favicon.ico is the app icon (SVG), cached a day', async () => {
+ const r = await raw(h.base, '/favicon.ico', {});
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers['content-type'], /^image\/svg\+xml/);
+ assert.ok(r.body.startsWith('<svg'));
+ assert.strictEqual(r.headers['cache-control'], 'public, max-age=86400');
+});
+
t('the home links the pinned Frame stylesheet, and /shared serves it', async () => {
t('the home links the pinned Frame stylesheet, and /shared serves it', async () => {
const r = await raw(h.base, '/', { Accept: 'text/html' });
const r = await raw(h.base, '/', { Accept: 'text/html' });
const href = /<link rel="stylesheet" href="([^"]+)"/.exec(r.body);
const href = /<link rel="stylesheet" href="([^"]+)"/.exec(r.body);