Analytics: Heatmap-Endpunkt nachgeschaerft
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
28c7a90d8b
commit
add7fa4add
4 files changed
+97
-13
No files matched your search
+20
-6
@@ -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 }));
|
||||
|
||||
+13
-6
@@ -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); } };
|
||||
}
|
||||
Reference in new issue
Block a user