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
Changes
nnew file mode 100644@@@ -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');@@@ -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 backendfunction 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}`);@@@ -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 ──────────────────────────────────────────────────@@@ -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');@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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');
@@ -16,6 +16,7 @@ const cookieParser = require('cookie-parser');
@@ -16,6 +16,7 @@ const cookieParser = require('cookie-parser');
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
const { internalOk } = require('../../_shared/internal-auth');
const { internalOk } = require('../../_shared/internal-auth');
+const { hostGuard, stampedPage } = require('../../_shared/host-role');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
const analyticsDb = new Database(analyticsDbPath);
@@ -60,8 +61,16 @@ app.use(cookieParser());
@@ -60,8 +61,16 @@ app.use(cookieParser());
// ── Analytics Middleware ─────────────────────────────────────
// ── Analytics Middleware ─────────────────────────────────────
app.use(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
// 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
// Proxy food-related API calls to openvibe-maps backend
function proxyToMaps(apiPath) {
function proxyToMaps(apiPath) {
@@ -97,7 +106,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
@@ -97,7 +106,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
});
});
// SPA fallback
// 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', () => {
const server = app.listen(PORT, '127.0.0.1', () => {
console.log(`[Food.OpenVibe] 🍽️ food.openvibe.tools listening on 127.0.0.1:${PORT}`);
console.log(`[Food.OpenVibe] 🍽️ food.openvibe.tools listening on 127.0.0.1:${PORT}`);
@@ -21,6 +21,7 @@ const config = require('./config');
@@ -21,6 +21,7 @@ const config = require('./config');
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
const { internalOk } = require('../../_shared/internal-auth');
const { internalOk } = require('../../_shared/internal-auth');
+const { hostGuard, stampedPage } = require('../../_shared/host-role');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
const analyticsDb = new Database(analyticsDbPath);
@@ -64,10 +65,19 @@ app.use('/api/', apiLimiter);
@@ -64,10 +65,19 @@ app.use('/api/', apiLimiter);
// ── Analytics Middleware ─────────────────────────────────────
// ── Analytics Middleware ─────────────────────────────────────
app.use(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 ───────────────────────────────────────────
// ── Static files ───────────────────────────────────────────
app.use(express.static(path.join(__dirname, '..', 'public'), {
app.use(express.static(path.join(__dirname, '..', 'public'), {
maxAge: '1h',
maxAge: '1h',
extensions: ['html'],
extensions: ['html'],
+ index: false,
}));
}));
// ── Source modules ─────────────────────────────────────────
// ── Source modules ─────────────────────────────────────────
@@ -427,7 +437,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
@@ -427,7 +437,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
// ── SPA fallback ───────────────────────────────────────────
// ── SPA fallback ───────────────────────────────────────────
app.get('*', (req, res) => {
app.get('*', (req, res) => {
- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
+ sendIndex(req, res);
});
});
// ── Start ──────────────────────────────────────────────────
// ── Start ──────────────────────────────────────────────────
@@ -14,7 +14,8 @@ const config = require('./config');
@@ -14,7 +14,8 @@ const config = require('./config');
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
const { internalOk } = require('../../_shared/internal-auth');
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 });
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
const analyticsDb = new Database(analyticsDbPath);
analyticsDb.pragma('journal_mode = WAL');
analyticsDb.pragma('journal_mode = WAL');
@@ -147,9 +148,10 @@ const HOSTNAME_MAP = {
@@ -147,9 +148,10 @@ const HOSTNAME_MAP = {
'slang.openvibe.tools': 'slang.html',
'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 ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
app.get('/api/internal/analytics', (req, res) => {
@@ -183,7 +185,7 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
@@ -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
// hosts deliberately share one file (smallcaps/fancy, glitch/zalgo); without a canonical those
// are duplicate pages competing with each other in search results.
// are duplicate pages competing with each other in search results.
const textSeo = require('./seo');
const textSeo = require('./seo');
-const sendPage = textSeo.pageSender(HOSTNAME_MAP, getHostname);
+const sendPage = textSeo.pageSender(HOSTNAME_MAP);
app.get('/sitemap.xml', (_req, res) => {
app.get('/sitemap.xml', (_req, res) => {
res.setHeader('Content-Type', 'application/xml; charset=utf-8');
res.setHeader('Content-Type', 'application/xml; charset=utf-8');
@@ -11,10 +11,14 @@
@@ -11,10 +11,14 @@
//
//
// Each page already carries a good hand-written <title> and description, so this module reuses
// Each page already carries a good hand-written <title> and description, so this module reuses
// those rather than inventing a second source of truth.
// 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 fs = require('fs');
const path = require('path');
const path = require('path');
+const { canonicalHostFor, ownHost } = require('../../_shared/host-role');
const PUBLIC = path.join(__dirname, '..', 'public');
const PUBLIC = path.join(__dirname, '..', 'public');
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
@@ -92,13 +96,15 @@ function renderPage(file, primaryHost) {
@@ -92,13 +96,15 @@ function renderPage(file, primaryHost) {
}
}
/** Express handler factory: serves the host's page with its SEO tags applied. */
/** 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 primary = buildPrimaryHosts(hostnameMap);
+ const has = (h) => Object.prototype.hasOwnProperty.call(hostnameMap, h);
return function sendPage(req, res) {
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');
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));
if (!rendered) return res.sendFile(path.join(PUBLIC, file));
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(rendered);
res.send(rendered);