From add7fa4addf7b96c4ea3e315c8654c9c1b0cd5ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:59:48 +0000 Subject: [PATCH] Analytics: Heatmap-Endpunkt nachgeschaerft Co-Authored-By: Claude Sonnet 5.5 --- analytics/src/heat.js | 26 ++++++++++++++++++++------ analytics/src/jobs.js | 19 +++++++++++++------ analytics/test/heat.test.js | 36 ++++++++++++++++++++++++++++++++++++ analytics/test/stats.test.js | 29 ++++++++++++++++++++++++++++- 4 files changed, 97 insertions(+), 13 deletions(-) diff --git a/analytics/src/heat.js b/analytics/src/heat.js index 8e35bc1..3e6d4cf 100644 --- a/analytics/src/heat.js +++ b/analytics/src/heat.js @@ -14,7 +14,8 @@ export function signHeatToken({ siteKey, path, vp, exp }, secret) { } // Liefert { siteKey, path, vp, exp } oder null (Signatur, Format, abgelaufen, > 30 Min voraus). -export function verifyHeatToken(token, secret, nowMs) { +// ignoreExpiry: nur Signatur und Format prüfen (um die Website eines abgelaufenen Links zu erkennen). +export function verifyHeatToken(token, secret, nowMs, { ignoreExpiry = false } = {}) { if (!secret || typeof token !== 'string') return null; const m = /^([A-Za-z0-9_-]+)\.([0-9a-f]{64})$/.exec(token); if (!m) return null; @@ -27,13 +28,13 @@ export function verifyHeatToken(token, secret, nowMs) { || typeof o.p !== 'string' || !o.p.startsWith('/') || !['m', 't', 'd'].includes(o.v) || !Number.isInteger(o.e)) return null; const nowS = nowMs / 1000; - if (o.e <= nowS || o.e > nowS + MAX_AHEAD) return null; + if (!ignoreExpiry && (o.e <= nowS || o.e > nowS + MAX_AHEAD)) return null; return { siteKey: o.s, path: o.p, vp: o.v, exp: o.e }; } export function createHeat({ db, config = {}, now }) { const clock = now || Date.now; - const getSite = db.prepare('SELECT domains FROM sites WHERE siteKey = ?'); + const getSite = db.prepare('SELECT domains, active FROM sites WHERE siteKey = ?'); const getClicks = db.prepare('SELECT x, y FROM clicks WHERE site = ? AND path = ? AND vp = ? AND ts >= ? ORDER BY ts DESC LIMIT ?'); // CORS: nur die eigenen https-Domains der Website (das Overlay läuft auf der Kundenseite). @@ -54,15 +55,28 @@ export function createHeat({ db, config = {}, now }) { res.end(b); return true; }; + const token = url.searchParams.get('token'); + // Website zum Token (auch bei abgelaufenem, aber echt signiertem Link), nur für die CORS-Köpfe der Fehlerantworten. + const knownSite = () => { + const t = verifyHeatToken(token, config.heatSecret, clock(), { ignoreExpiry: true }); + return t && getSite.get(t.siteKey); + }; if (req.method === 'OPTIONS') { - // Preflight nur bei eigener Domain mit CORS-Köpfen beantworten; Token steht in der URL, Daten gibt es erst beim GET. + // Preflight: CORS-Köpfe nur, wenn das Token zu einer Website gehört und der Origin deren https-Domain ist. + const site = knownSite(); + if (site) cors(res, req, domainsOf(site)); res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); return end(204); } if (req.method !== 'GET') return end(405, null, { Allow: 'GET, OPTIONS' }); - const t = verifyHeatToken(url.searchParams.get('token'), config.heatSecret, clock()); + const t = verifyHeatToken(token, config.heatSecret, clock()); const site = t && getSite.get(t.siteKey); - if (!t || !site) return end(403, { error: 'Ungültiger oder abgelaufener Link' }); + if (!t || !site || !site.active) { + // Bekannte Website: CORS-Kopf auch bei 403, damit das Overlay "Link abgelaufen oder ungültig" zeigen kann. + const known = site || knownSite(); + if (known) cors(res, req, domainsOf(known)); + return end(403, { error: 'Ungültiger oder abgelaufener Link' }); + } cors(res, req, domainsOf(site)); const since = clock() - CLICK_DAYS * 86400 * 1000; const points = getClicks.all(t.siteKey, t.path, t.vp, since, POINTS).map((r) => ({ x: r.x, y: r.y })); diff --git a/analytics/src/jobs.js b/analytics/src/jobs.js index 1219052..f82bacc 100644 --- a/analytics/src/jobs.js +++ b/analytics/src/jobs.js @@ -103,17 +103,24 @@ export function nextRunAt(now, hour = 2, minute = 30) { } // Plant runNightly täglich 02:30 Berlin und einmal kurz nach dem Start; stop() beendet alle Timer. -export function scheduleNightly(db, { startDelayMs = 60000, log = console } = {}) { +// Test-Hooks: now, timers ({ setTimeout, clearTimeout }) und job (statt runNightly). +export function scheduleNightly(db, { startDelayMs = 60000, log = console, now = Date.now, timers = { setTimeout, clearTimeout }, job = runNightly } = {}) { let timer = null; let stopped = false; const run = () => { try { - const r = runNightly(db, { now: Date.now() }); + const r = job(db, { now: now() }); log.log(`Analytics-Nachtjob: ${r.aggregated} Tage verdichtet, ${r.sitesDeleted} Websites gelöscht`); } catch (e) { log.error('Analytics-Nachtjob:', e.message); } }; - const arm = (delay, fn) => { if (!stopped) timer = setTimeout(fn, delay); }; - const daily = () => { run(); arm(nextRunAt(Date.now()) - Date.now(), daily); }; - arm(startDelayMs, () => { run(); arm(nextRunAt(Date.now()) - Date.now(), daily); }); - return { stop: () => { stopped = true; clearTimeout(timer); } }; + const arm = (delay, fn) => { if (!stopped) timer = timers.setTimeout(fn, delay); }; + // Ein Timer kann früh feuern (Uhrsprung, Timer-Ungenauigkeit): dann nur neu auf den Zielzeitpunkt setzen, + // nie vorzeitig laufen. Nach dem Lauf strikt hinter das Ziel planen, damit 02:30 nie doppelt läuft. + const planDaily = (target) => arm(Math.max(0, target - now()), () => { + if (now() < target) return planDaily(target); + run(); + planDaily(nextRunAt(Math.max(now(), target) + 1000)); + }); + arm(startDelayMs, () => { run(); planDaily(nextRunAt(now())); }); + return { stop: () => { stopped = true; timers.clearTimeout(timer); } }; } diff --git a/analytics/test/heat.test.js b/analytics/test/heat.test.js index d18f8cd..aec582b 100644 --- a/analytics/test/heat.test.js +++ b/analytics/test/heat.test.js @@ -100,3 +100,39 @@ test('/a/heat nur auf dem öffentlichen Listener', async () => { assert.equal(r.status, 404); server.close(); }); + +test('403 bei ausgeschalteter Website (active = 0), mit CORS für die eigene Domain', async () => { + const s = await setup(); + s.db.prepare('UPDATE sites SET active = 0 WHERE siteKey = ?').run(KEY); + const r = await s.get(tok(), { Origin: 'https://kunde.de' }); + assert.equal(r.status, 403); + assert.equal(r.headers.get('access-control-allow-origin'), 'https://kunde.de'); + s.close(); +}); + +test('403 mit CORS-Kopf, wenn die Website bekannt ist (abgelaufen, aus); sonst ohne', async () => { + const s = await setup(); + const origin = { Origin: 'https://www.kunde.de' }; + const abgelaufen = await s.get(tok({ exp: exp(-1) }), origin); + assert.equal(abgelaufen.status, 403); + assert.equal(abgelaufen.headers.get('access-control-allow-origin'), 'https://www.kunde.de'); + // fremder Origin, falsche Signatur, unbekannte Website: kein CORS-Kopf + assert.equal((await s.get(tok({ exp: exp(-1) }), { Origin: 'https://evil.example' })).headers.get('access-control-allow-origin'), null); + assert.equal((await s.get(tok({}, 'anderes-secret'), origin)).headers.get('access-control-allow-origin'), null); + assert.equal((await s.get(tok({ siteKey: 'nichtvorhanden0123456' }), origin)).headers.get('access-control-allow-origin'), null); + assert.equal((await s.get('abc', origin)).headers.get('access-control-allow-origin'), null); + s.close(); +}); + +test('OPTIONS-Preflight: CORS-Köpfe nur für erlaubte Origins bekannter Websites', async () => { + const s = await setup(); + const pre = (token, origin) => fetch(`${s.base}/a/heat?token=${encodeURIComponent(token)}`, { method: 'OPTIONS', headers: { Origin: origin, 'Access-Control-Request-Method': 'GET' } }); + const ok = await pre(tok(), 'https://kunde.de'); + assert.equal(ok.status, 204); + assert.equal(ok.headers.get('access-control-allow-origin'), 'https://kunde.de'); + assert.match(ok.headers.get('access-control-allow-methods'), /GET/); + assert.equal((await pre(tok({ exp: exp(-1) }), 'https://kunde.de')).headers.get('access-control-allow-origin'), 'https://kunde.de'); + assert.equal((await pre(tok(), 'https://evil.example')).headers.get('access-control-allow-origin'), null); + assert.equal((await pre('abc', 'https://kunde.de')).headers.get('access-control-allow-origin'), null); + s.close(); +}); diff --git a/analytics/test/stats.test.js b/analytics/test/stats.test.js index ab33986..2e0f802 100644 --- a/analytics/test/stats.test.js +++ b/analytics/test/stats.test.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { openDb } from '../src/db.js'; -import { runNightly, nextRunAt, dayOf } from '../src/jobs.js'; +import { runNightly, nextRunAt, dayOf, scheduleNightly } from '../src/jobs.js'; import { createAdminServer } from '../src/server.js'; const KEY = 'abcdef0123456789abcd'; @@ -177,3 +177,30 @@ test('Stats: Top 20 absteigend, consentRate 0 ohne Entscheidungen, Fehlerfälle' assert.equal((await a.get(`/a/admin/stats/${KEY}?range=7`, 'Bearer nope')).status, 401); a.close(); }); + +test('scheduleNightly: früh feuernder Timer läuft nicht vorzeitig und nie doppelt für dasselbe 02:30', () => { + let t = Date.UTC(2026, 9, 10, 10, 0, 0); + const pending = []; + const timers = { setTimeout: (fn, ms) => { const h = { fn, at: t + ms }; pending.push(h); return h; }, clearTimeout: (h) => { const i = pending.indexOf(h); if (i >= 0) pending.splice(i, 1); } }; + const runs = []; + const quiet = { log() {}, error() {} }; + const s = scheduleNightly({}, { startDelayMs: 1000, log: quiet, now: () => t, timers, job: (db, o) => { runs.push(o.now); return { aggregated: 0, sitesDeleted: 0 }; } }); + const fire = (clock) => { const h = pending.shift(); t = clock ?? h.at; h.fn(); }; + fire(); // Startlauf + assert.equal(runs.length, 1); + const target = nextRunAt(t); + assert.equal(pending.length, 1); + assert.equal(pending[0].at, target); + fire(target - 500); // Timer feuert 500 ms zu früh + assert.equal(runs.length, 1, 'kein vorzeitiger Lauf'); + assert.equal(pending.length, 1); + assert.equal(pending[0].at, target, 'neu auf das Ziel gesetzt'); + fire(target); // pünktlich + assert.equal(runs.length, 2); + assert.equal(pending[0].at, nextRunAt(target + 1000)); + assert.ok(pending[0].at > target + 20 * 3600000, 'nächster Lauf am Folgetag'); + fire(pending[0].at - 1); // wieder zu früh + assert.equal(runs.length, 2); + s.stop(); + assert.equal(pending.length, 0); +});