openvibers/openvibe.tools Commits

Maps, Food and Text honour X-OV-Canonical-Host: canonical, og:url and JSON-LD follow the gateway, custom domains get their tool through X-OV-Tool, gateway aliases redirect, unknown hosts go to the tools index

OP OpenVibers committed 23 Sept 2026 · b6c9d9f5c5 · 5 files, +108 −12


Changes

ddiff --git a/apps/_shared/test/host-role.test.js b/apps/_shared/test/host-role.test.js
nnew file mode 100644
iindex 0000000..273e29c
---- /dev/null
++++ b/apps/_shared/test/host-role.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++// X-OV-Canonical-Host on every satellite: the shared helpers, the text pages (several hosts per
++// file) and the static maps / food pages. Img, audio and docs are covered by their own app tests.
++const assert = require('assert');
++const path = require('path');
++const hr = require('../host-role');
++
++const req = (host, headers = {}, p = '/') => ({ headers: { host, ...headers }, path: p, originalUrl: p, method: 'GET' });
++function res() {
++ const r = { headers: {}, statusCode: 200, body: null, redirected: null };
++ r.setHeader = (k, v) => { r.headers[k.toLowerCase()] = v; };
++ r.send = (b) => { r.body = b; return r; };
++ r.sendFile = (f) => { r.body = `FILE:${f}`; return r; };
++ r.redirect = (code, to) => { r.statusCode = code; r.redirected = to; return r; };
++ return r;
++}
++const via = (tool, canonical, role = 'short', short = '') => ({ 'x-ov-tool': tool, 'x-ov-host-role': role, 'x-ov-canonical-host': canonical, 'x-ov-short-host': short });
++
++// ── Helpers ──────────────────────────────────────────────────
++assert.strictEqual(hr.canonicalHostFor(req('png.openvibe.tools', via('png', 'png-converter.example.com')), 'png.openvibe.tools'), 'png-converter.example.com');
++assert.strictEqual(hr.canonicalHostFor(req('png.openvibe.tools'), 'png.openvibe.tools'), 'png.openvibe.tools', 'no gateway: own host');
++assert.strictEqual(hr.canonicalHostFor(req('png.openvibe.tools', via('png', 'bad host!')), 'png.openvibe.tools'), 'png.openvibe.tools', 'a malformed header is ignored');
++const has = (h) => ['png.openvibe.tools', 'jpg.openvibe.tools'].includes(h);
++assert.strictEqual(hr.ownHost(req('my-custom.example', via('png', 'my-custom.example', 'canonical')), has), 'png.openvibe.tools', 'custom domain → the tool it serves');
++assert.strictEqual(hr.ownHost(req('jpg.openvibe.tools:443'), has), 'jpg.openvibe.tools');
++const html = '<html><head><link rel="canonical" href="https://maps.openvibe.tools/"><script type="application/ld+json">{"url":"https://maps.openvibe.tools/"}</script></head><body><a href="https://maps.openvibe.tools/about">x</a></body></html>';
++const stamped = hr.restampHead(html, 'maps.openvibe.tools', 'survival-map.example.org');
++assert.ok(stamped.includes('href="https://survival-map.example.org/"') && stamped.includes('"url":"https://survival-map.example.org/"'));
++assert.ok(stamped.includes('<a href="https://maps.openvibe.tools/about">'), 'only the head is rewritten');
++assert.strictEqual(hr.restampHead(html, 'maps.openvibe.tools', 'maps.openvibe.tools'), html);
++
++// ── hostGuard: redirects ─────────────────────────────────────
++const guard = hr.hostGuard({ knows: has, aliasOf: (h) => (h === 'jpeg.openvibe.tools' ? 'jpg.openvibe.tools' : '') });
++const run = (r) => { const out = res(); let passed = false; guard(r, out, () => { passed = true; }); return { passed, out }; };
++let g = run(req('pngs.openvibe.tools', via('png', 'png-converter.example.com', 'alias', 'png.openvibe.tools'), '/x?y=1'));
++assert.strictEqual(g.out.statusCode, 301); assert.strictEqual(g.out.redirected, 'https://png.openvibe.tools/x?y=1', 'gateway alias → short host');
++g = run(req('jpeg.openvibe.tools'));
++assert.strictEqual(g.out.redirected, 'https://jpg.openvibe.tools/', 'own alias → its target');
++g = run(req('elsewhere.example'));
++assert.strictEqual(g.out.redirected, hr.TOOLS_HOME, 'not ours → tools index');
++assert.ok(run(req('elsewhere.example', {}, '/api/health')).passed, 'APIs are never redirected');
++assert.ok(run(req('localhost:4012')).passed && run(req('png.openvibe.tools')).passed);
++
++// ── stampedPage: maps and food ───────────────────────────────
++for (const [app, host] of [['maps', 'maps.openvibe.tools'], ['food', 'food.openvibe.tools']]) {
++ const send = hr.stampedPage(path.join(__dirname, '..', '..', app, 'public', 'index.html'), host);
++ let out = res();
++ send(req(host, via(app, `${app}.example.net`, 'canonical')), out);
++ assert.ok(out.body.includes(`<link rel="canonical" href="https://${app}.example.net/">`), `${app}: canonical follows the gateway`);
++ assert.ok(out.body.includes(`<meta property="og:url" content="https://${app}.example.net/">`), `${app}: og:url too`);
++ assert.strictEqual(out.headers.vary, 'X-OV-Canonical-Host');
++ out = res();
++ send(req(host), out);
++ assert.ok(out.body.includes(`<link rel="canonical" href="https://${host}/">`), `${app}: direct → own host`);
++}
++
++// ── Text: several hosts per page ─────────────────────────────
++const textSeo = require('../../text/server/seo');
++const MAP = { 'case.openvibe.tools': 'case.html', 'uppercase.openvibe.tools': 'case.html' };
++const send = textSeo.pageSender(MAP);
++let out = res();
++send(req('uppercase.openvibe.tools'), out);
++assert.ok(out.body.includes('<link rel="canonical" href="https://case.openvibe.tools">'), 'text: an alias page points at its primary host');
++out = res();
++send(req('case-converter.example.com', via('case', 'case-converter.example.com', 'canonical')), out);
++assert.ok(out.body.includes('<link rel="canonical" href="https://case-converter.example.com">'), 'text: the gateway canonical wins');
++assert.ok(/Case/i.test(out.body.match(/<title>([^<]*)<\/title>/)[1]), 'text: a custom domain gets its tool page through X-OV-Tool');
++
++console.log('host-role + canonical host (text, maps, food): all checks passed');
ddiff --git a/apps/food/server/index.js b/apps/food/server/index.js
iindex 0f31f02..866c1d3 100644
---- a/apps/food/server/index.js
++++ b/apps/food/server/index.js
@@@ -16,6 +16,7 @@ const cookieParser = require('cookie-parser');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
const { internalOk } = require('../../_shared/internal-auth');
++const { hostGuard, stampedPage } = require('../../_shared/host-role');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -60,8 +61,16 @@ app.use(cookieParser());
// ── Analytics Middleware ─────────────────────────────────────
app.use(analytics.middleware());
++// ── Hosts + the page ───────────────────────────────────────
++// Through the gateway the X-OV-* headers name the canonical host (a custom domain included) and the
++// page's canonical / og:url / JSON-LD follow it; aliases are redirected; other hosts go to the tools index.
++const HOST = 'food.openvibe.tools';
++app.use(hostGuard({ knows: (h) => h === HOST }));
++const sendIndex = stampedPage(path.join(__dirname, '..', 'public', 'index.html'), HOST);
++app.get(['/', '/index.html'], sendIndex);
++
// Static files
--app.use(express.static(path.join(__dirname, '..', 'public'), { maxAge: '1d' }));
++app.use(express.static(path.join(__dirname, '..', 'public'), { maxAge: '1d', index: false }));
// Proxy food-related API calls to openvibe-maps backend
function proxyToMaps(apiPath) {
@@@ -97,7 +106,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
});
// SPA fallback
--app.get('*', (req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'index.html')));
++app.get('*', (req, res) => sendIndex(req, res));
const server = app.listen(PORT, '127.0.0.1', () => {
console.log(`[Food.OpenVibe] 🍽️ food.openvibe.tools listening on 127.0.0.1:${PORT}`);
ddiff --git a/apps/maps/server/index.js b/apps/maps/server/index.js
iindex f5ca93e..be6c7b4 100644
---- a/apps/maps/server/index.js
++++ b/apps/maps/server/index.js
@@@ -21,6 +21,7 @@ const config = require('./config');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
const { internalOk } = require('../../_shared/internal-auth');
++const { hostGuard, stampedPage } = require('../../_shared/host-role');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -64,10 +65,19 @@ app.use('/api/', apiLimiter);
// ── Analytics Middleware ─────────────────────────────────────
app.use(analytics.middleware());
++// ── Hosts + the page ───────────────────────────────────────
++// Through the gateway the X-OV-* headers name the canonical host (a custom domain included) and the
++// page's canonical / og:url / JSON-LD follow it; aliases are redirected; other hosts go to the tools index.
++const HOST = 'maps.openvibe.tools';
++app.use(hostGuard({ knows: (h) => h === HOST }));
++const sendIndex = stampedPage(path.join(__dirname, '..', 'public', 'index.html'), HOST);
++app.get(['/', '/index.html'], sendIndex);
++
// ── Static files ───────────────────────────────────────────
app.use(express.static(path.join(__dirname, '..', 'public'), {
maxAge: '1h',
extensions: ['html'],
++ index: false,
}));
// ── Source modules ─────────────────────────────────────────
@@@ -427,7 +437,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
// ── SPA fallback ───────────────────────────────────────────
app.get('*', (req, res) => {
-- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
++ sendIndex(req, res);
});
// ── Start ──────────────────────────────────────────────────
ddiff --git a/apps/text/server/index.js b/apps/text/server/index.js
iindex 1b59deb..a9958fe 100644
---- a/apps/text/server/index.js
++++ b/apps/text/server/index.js
@@@ -14,7 +14,8 @@ const config = require('./config');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
const { internalOk } = require('../../_shared/internal-auth');
--const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
++const { hostGuard } = require('../../_shared/host-role');
++const analyticsDbPath = path.resolve(__dirname, '..', process.env.DATA_DIR || 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
analyticsDb.pragma('journal_mode = WAL');
@@@ -147,9 +148,10 @@ const HOSTNAME_MAP = {
'slang.openvibe.tools': 'slang.html',
};
--function getHostname(req) {
-- return String(req.headers.host || '').split(':')[0].toLowerCase();
--}
++// ── Hosts ────────────────────────────────────────────────────
++// Through the gateway the X-OV-* headers name the tool and its canonical host; aliases the gateway
++// missed are redirected. Hosts this app does not serve go to the tools index (APIs never redirect).
++app.use(hostGuard({ knows: (h) => Object.prototype.hasOwnProperty.call(HOSTNAME_MAP, h) }));
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
@@@ -183,7 +185,7 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
// hosts deliberately share one file (smallcaps/fancy, glitch/zalgo); without a canonical those
// are duplicate pages competing with each other in search results.
const textSeo = require('./seo');
--const sendPage = textSeo.pageSender(HOSTNAME_MAP, getHostname);
++const sendPage = textSeo.pageSender(HOSTNAME_MAP);
app.get('/sitemap.xml', (_req, res) => {
res.setHeader('Content-Type', 'application/xml; charset=utf-8');
ddiff --git a/apps/text/server/seo.js b/apps/text/server/seo.js
iindex 51507fb..bd3d1b2 100644
---- a/apps/text/server/seo.js
++++ b/apps/text/server/seo.js
@@@ -11,10 +11,14 @@
//
// Each page already carries a good hand-written <title> and description, so this module reuses
// those rather than inventing a second source of truth.
++//
++// Through the gateway (X-OV-* headers) the tool comes from X-OV-Tool and the canonical host from
++// X-OV-Canonical-Host — a descriptive host or a custom domain; reached directly, the primary host.
// ═══════════════════════════════════════════════════════════════
const fs = require('fs');
const path = require('path');
++const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const PUBLIC = path.join(__dirname, '..', 'public');
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@@ -92,13 +96,15 @@ function renderPage(file, primaryHost) {
}
/** Express handler factory: serves the host's page with its SEO tags applied. */
--function pageSender(hostnameMap, getHostname) {
++function pageSender(hostnameMap) {
const primary = buildPrimaryHosts(hostnameMap);
++ const has = (h) => Object.prototype.hasOwnProperty.call(hostnameMap, h);
return function sendPage(req, res) {
-- const hostname = getHostname(req);
-- const file = hostnameMap[hostname] || 'index.html';
++ const hostname = ownHost(req, has);
++ const file = (has(hostname) && hostnameMap[hostname]) || 'index.html';
res.setHeader('Cache-Control', 'no-cache');
-- const rendered = renderPage(file, primary.get(file) || hostname);
++ res.setHeader('Vary', 'X-OV-Canonical-Host');
++ const rendered = renderPage(file, canonicalHostFor(req, primary.get(file) || hostname));
if (!rendered) return res.sendFile(path.join(PUBLIC, file));
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(rendered);