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
Changes
nnew file mode 100644@@@ -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 };@@@ -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 → Nodeapp.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 }); }});@@@ -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 → Nodeapp.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 }); }});@@@ -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 → Nodeapp.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 }); }});@@@ -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')); }@@@ -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 → Nodeapp.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 }); }});@@@ -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 }); }});@@@ -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 }); }});@@@ -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 }); }});
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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 };
@@ -26,7 +26,7 @@ const { probe, getDuration, cleanTmp } = require('./tools/ffmpeg-helper');
@@ -26,7 +26,7 @@ const { probe, getDuration, cleanTmp } = require('./tools/ffmpeg-helper');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
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');
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);
@@ -35,6 +35,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-audio');
@@ -35,6 +35,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-audio');
const app = express();
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 ─────────────────────────────────────────────────
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.use(helmet({
app.use(helmet({
@@ -239,12 +242,12 @@ app.get('/api/preview/:id', (req, res) => {
@@ -239,12 +242,12 @@ app.get('/api/preview/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
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 }) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
app.get('/api/internal/analytics/bots', (req, res) => {
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -24,7 +24,7 @@ const retention = require('./retention/manager');
@@ -24,7 +24,7 @@ const retention = require('./retention/manager');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
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');
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);
@@ -33,6 +33,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-docs');
@@ -33,6 +33,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-docs');
const app = express();
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 ─────────────────────────────────────────────────
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.use(helmet({
app.use(helmet({
@@ -249,12 +252,12 @@ app.get('/api/download/:id', (req, res) => {
@@ -249,12 +252,12 @@ app.get('/api/download/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
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 }) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
app.get('/api/internal/analytics/bots', (req, res) => {
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -15,7 +15,7 @@ const cookieParser = require('cookie-parser');
@@ -15,7 +15,7 @@ const cookieParser = require('cookie-parser');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
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');
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);
@@ -27,6 +27,9 @@ const MAPS_API = process.env.MAPS_API || 'http://127.0.0.1:4010';
@@ -27,6 +27,9 @@ const MAPS_API = process.env.MAPS_API || 'http://127.0.0.1:4010';
const app = express();
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.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.use(cors({
app.use(cors({
origin(origin, callback) {
origin(origin, callback) {
@@ -83,12 +86,12 @@ app.get('/api/geocode', proxyToMaps('/api/geocode'));
@@ -83,12 +86,12 @@ app.get('/api/geocode', proxyToMaps('/api/geocode'));
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
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 }) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
app.get('/api/internal/analytics/bots', (req, res) => {
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -212,6 +212,35 @@ function sendTool(req, res, file) {
@@ -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.
// 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')); }
{ 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')); }
@@ -24,7 +24,7 @@ const retention = require('./retention/manager');
@@ -24,7 +24,7 @@ const retention = require('./retention/manager');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
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');
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);
@@ -33,6 +33,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-img');
@@ -33,6 +33,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-img');
const app = express();
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 ─────────────────────────────────────────────────
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.set('trust proxy', 2); // Cloudflare → Nginx → Node
app.use(helmet({
app.use(helmet({
@@ -222,12 +225,12 @@ app.get('/api/download/:id', (req, res) => {
@@ -222,12 +225,12 @@ app.get('/api/download/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
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 }) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
app.get('/api/internal/analytics/bots', (req, res) => {
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -20,7 +20,7 @@ const config = require('./config');
@@ -20,7 +20,7 @@ const config = require('./config');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
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');
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);
@@ -28,6 +28,9 @@ analyticsDb.pragma('journal_mode = WAL');
@@ -28,6 +28,9 @@ analyticsDb.pragma('journal_mode = WAL');
const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-maps');
const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-maps');
const app = express();
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 });
const cache = new NodeCache({ stdTTL: config.cache.search, checkperiod: 60 });
// ── Middleware ──────────────────────────────────────────────
// ── Middleware ──────────────────────────────────────────────
@@ -412,12 +415,12 @@ app.get('/api/meal-plan', (req, res) => {
@@ -412,12 +415,12 @@ app.get('/api/meal-plan', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
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 }) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
app.get('/api/internal/analytics/bots', (req, res) => {
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -13,7 +13,7 @@ const config = require('./config');
@@ -13,7 +13,7 @@ const config = require('./config');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
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');
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);
@@ -22,6 +22,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-text');
@@ -22,6 +22,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-text');
const app = express();
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 ─────────────────────────────────────────────────
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2);
app.set('trust proxy', 2);
app.use(helmet({
app.use(helmet({
@@ -150,12 +153,12 @@ function getHostname(req) {
@@ -150,12 +153,12 @@ function getHostname(req) {
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
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 }) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
app.get('/api/internal/analytics/bots', (req, res) => {
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -22,7 +22,7 @@ const { hostGuard } = require('../../_shared/host-role');
@@ -22,7 +22,7 @@ const { hostGuard } = require('../../_shared/host-role');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics');
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');
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);
@@ -31,6 +31,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-yt');
@@ -31,6 +31,9 @@ const analytics = new AnalyticsTracker(analyticsDb, 'openvibe-yt');
const app = express();
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 ─────────────────────────────────────────────────
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2);
app.set('trust proxy', 2);
// API answers are live state, never revalidated: a 304 on /api/status froze the progress poll.
// 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) => {
@@ -217,12 +220,12 @@ app.get('/api/download/:id', (req, res) => {
// ── Internal Analytics API ────────────────────────────────────
// ── Internal Analytics API ────────────────────────────────────
app.get('/api/internal/analytics', (req, res) => {
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 }) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
app.get('/api/internal/analytics/bots', (req, res) => {
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});