openvibers/openvibe.tools Commits

Internal analytics need the deployment's INTERNAL_API_KEY and a loopback caller (the repo-visible shared secret is gone); gateway aggregates satellite analytics; tool hosts redirect legal paths to the apex

OP OpenVibers committed 18 Sept 2026 · 845669c2bf · 9 files, +93 −21


Changes

ddiff --git a/apps/_shared/internal-auth.js b/apps/_shared/internal-auth.js
nnew file mode 100644
iindex 0000000..f9bb294
---- /dev/null
++++ b/apps/_shared/internal-auth.js
@@@ -0,0 +1,22 @@
++'use strict';
++// Internal-only routes (analytics totals for the Network's admin and ranking).
++// Two independent conditions, both required:
++// 1. X-Internal-Key equals INTERNAL_API_KEY from the unit's environment (never a value in the repo;
++// with no key configured every internal route is closed).
++// 2. The request did not come through the public proxy: nginx always adds X-Forwarded-For and
++// X-Real-IP, service-to-service calls on loopback never do.
++const crypto = require('crypto');
++
++function internalOk(req) {
++ const want = String(process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '');
++ const got = String(req.headers['x-internal-key'] || '');
++ if (want.length < 16 || got.length !== want.length) return false;
++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return false;
++ const ip = String(req.socket && req.socket.remoteAddress || '');
++ if (!/^(::1|::ffff:127\.|127\.)/.test(ip)) return false;
++ return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
++}
++
++function requireInternal(req, res, next) { return internalOk(req) ? next() : res.status(404).json({ error: 'Not found' }); }
++
++module.exports = { internalOk, requireInternal };
ddiff --git a/apps/audio/server/index.js b/apps/audio/server/index.js
iindex 4d3227e..b74f74e 100644
---- a/apps/audio/server/index.js
++++ b/apps/audio/server/index.js
@@@ -26,7 +26,7 @@ const { probe, getDuration, cleanTmp } = require('./tools/ffmpeg-helper');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
--const INTERNAL_SECRET = 'openvibe-internal-2026';
++const { internalOk } = require('../../_shared/internal-auth');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -35,6 +35,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-audio');
const app = express();
++// Legal documents live on the apex; every tool host points there instead of answering 404.
++app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
++
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.use(helmet({
@@@ -239,12 +242,12 @@ app.get('/api/preview/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
app.get('/api/internal/analytics/bots', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/docs/server/index.js b/apps/docs/server/index.js
iindex 17037d6..f81bfec 100644
---- a/apps/docs/server/index.js
++++ b/apps/docs/server/index.js
@@@ -24,7 +24,7 @@ const retention = require('./retention/manager');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
--const INTERNAL_SECRET = 'openvibe-internal-2026';
++const { internalOk } = require('../../_shared/internal-auth');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -33,6 +33,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-docs');
const app = express();
++// Legal documents live on the apex; every tool host points there instead of answering 404.
++app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
++
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.use(helmet({
@@@ -249,12 +252,12 @@ app.get('/api/download/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
app.get('/api/internal/analytics/bots', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/food/server/index.js b/apps/food/server/index.js
iindex 75fa60c..0f31f02 100644
---- a/apps/food/server/index.js
++++ b/apps/food/server/index.js
@@@ -15,7 +15,7 @@ const cookieParser = require('cookie-parser');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
--const INTERNAL_SECRET = 'openvibe-internal-2026';
++const { internalOk } = require('../../_shared/internal-auth');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -27,6 +27,9 @@ const MAPS_API = process.env.MAPS_API || 'http://127.0.0.1:4010';
const app = express();
++// Legal documents live on the apex; every tool host points there instead of answering 404.
++app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
++
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.use(cors({
origin(origin, callback) {
@@@ -83,12 +86,12 @@ app.get('/api/geocode', proxyToMaps('/api/geocode'));
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
app.get('/api/internal/analytics/bots', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 25111f9..c9f2e6a 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -212,6 +212,35 @@ function sendTool(req, res, file) {
}
}
++// ── Internal analytics (Network admin + ranking) ─────────────
++// The gateway keeps no analytics of its own; it adds up the satellites'. Internal key + loopback only.
++{
++ const { requireInternal } = require('../../_shared/internal-auth');
++ const SATELLITES = { maps: 4010, food: 4011, img: 4012, yt: 4013, audio: 4014, text: 4015, docs: 4016 };
++ app.get('/api/internal/analytics', requireInternal, async (req, res) => {
++ const days = Math.min(parseInt(req.query.days, 10) || 30, 365);
++ const hours = req.query.hours ? Math.min(parseInt(req.query.hours, 10), 8760) : null;
++ const key = String(req.headers['x-internal-key']);
++ const parts = await Promise.all(Object.entries(SATELLITES).map(async ([name, port]) => {
++ try {
++ const r = await fetch(`http://127.0.0.1:${port}/api/internal/analytics?days=${days}${hours ? '&hours=' + hours : ''}`, { headers: { 'X-Internal-Key': key }, signal: AbortSignal.timeout(5000) });
++ const j = r.ok ? await r.json() : null;
++ return j && j.analytics ? { name, analytics: j.analytics } : null;
++ } catch { return null; }
++ }));
++ const live = parts.filter(Boolean);
++ const summary = {};
++ for (const p of live) for (const [k, v] of Object.entries(p.analytics.summary || {})) if (typeof v === 'number' && !/^avg_/.test(k)) summary[k] = (summary[k] || 0) + v;
++ const avg = live.map(p => Number(p.analytics.summary && p.analytics.summary.avg_response_ms) || 0).filter(Boolean);
++ if (avg.length) summary.avg_response_ms = Math.round(avg.reduce((a, b) => a + b, 0) / avg.length);
++ const realtime = {};
++ for (const p of live) for (const [k, v] of Object.entries(p.analytics.realtime || {})) if (typeof v === 'number') realtime[k] = (realtime[k] || 0) + v;
++ // The busiest satellite's detail tables stand in for the family; per-app numbers are listed beside them.
++ const lead = live.slice().sort((a, b) => (b.analytics.summary?.total_pageviews || 0) - (a.analytics.summary?.total_pageviews || 0))[0];
++ res.set('Cache-Control', 'no-store').json({ ok: true, analytics: Object.assign({}, lead ? lead.analytics : {}, { summary, realtime, apps: live.map(p => ({ name: p.name, ...(p.analytics.summary || {}) })) }) });
++ });
++}
++
// Terms, Privacy and DMCA — the same documents on the apex and on every tool host.
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tools', service: 'tools', host: 'openvibe.tools', name: 'OpenVibe.Tools', profile: 'tools' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
ddiff --git a/apps/img/server/index.js b/apps/img/server/index.js
iindex abce0c3..89e2dcf 100644
---- a/apps/img/server/index.js
++++ b/apps/img/server/index.js
@@@ -24,7 +24,7 @@ const retention = require('./retention/manager');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
--const INTERNAL_SECRET = 'openvibe-internal-2026';
++const { internalOk } = require('../../_shared/internal-auth');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -33,6 +33,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-img');
const app = express();
++// Legal documents live on the apex; every tool host points there instead of answering 404.
++app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
++
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.use(helmet({
@@@ -222,12 +225,12 @@ app.get('/api/download/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
app.get('/api/internal/analytics/bots', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/maps/server/index.js b/apps/maps/server/index.js
iindex c805da2..f5ca93e 100644
---- a/apps/maps/server/index.js
++++ b/apps/maps/server/index.js
@@@ -20,7 +20,7 @@ const config = require('./config');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
--const INTERNAL_SECRET = 'openvibe-internal-2026';
++const { internalOk } = require('../../_shared/internal-auth');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -28,6 +28,9 @@ analyticsDb.pragma('journal_mode = WAL');
const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-maps');
const app = express();
++
++// Legal documents live on the apex; every tool host points there instead of answering 404.
++app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
const cache = new NodeCache({ stdTTL: config.cache.search, checkperiod: 60 });
// ── Middleware ──────────────────────────────────────────────
@@@ -412,12 +415,12 @@ app.get('/api/meal-plan', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
app.get('/api/internal/analytics/bots', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/text/server/index.js b/apps/text/server/index.js
iindex 5d2ae1e..1b59deb 100644
---- a/apps/text/server/index.js
++++ b/apps/text/server/index.js
@@@ -13,7 +13,7 @@ const config = require('./config');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
--const INTERNAL_SECRET = 'openvibe-internal-2026';
++const { internalOk } = require('../../_shared/internal-auth');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -22,6 +22,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-text');
const app = express();
++// Legal documents live on the apex; every tool host points there instead of answering 404.
++app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
++
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2);
app.use(helmet({
@@@ -150,12 +153,12 @@ function getHostname(req) {
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
app.get('/api/internal/analytics/bots', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/yt/server/index.js b/apps/yt/server/index.js
iindex ac1d164..5a8c9a5 100644
---- a/apps/yt/server/index.js
++++ b/apps/yt/server/index.js
@@@ -22,7 +22,7 @@ const { hostGuard } = require('../../_shared/host-role');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
--const INTERNAL_SECRET = 'openvibe-internal-2026';
++const { internalOk } = require('../../_shared/internal-auth');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -31,6 +31,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-yt');
const app = express();
++// Legal documents live on the apex; every tool host points there instead of answering 404.
++app.get(['/terms', '/privacy', '/dmca', '/tos'], (req, res) => res.redirect(301, 'https://openvibe.tools' + (req.path === '/tos' ? '/terms' : req.path)));
++
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2);
// API answers are live state, never revalidated: a 304 on /api/status froze the progress poll.
@@@ -217,12 +220,12 @@ app.get('/api/download/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
app.get('/api/internal/analytics/bots', (req, res) => {
-- if (req.headers['x-internal-secret'] !== INTERNAL_SECRET) return res.status(403).json({ error: 'Forbidden' });
++ if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});