// GET /a/heat?token= (öffentlicher Listener): Klickpunkte für das Heatmap-Overlay. // Token (R3): ., e = Ablauf in Unix-Sekunden. import crypto from 'node:crypto'; import { hostOf, cleanPath } from './ingest.js'; const SITE_KEY = /^[a-z0-9]{16,40}$/; const MAX_AHEAD = 30 * 60; // Sekunden const POINTS = 5000; const CLICK_DAYS = 30; export function signHeatToken({ siteKey, path, vp, exp }, secret) { const part = Buffer.from(JSON.stringify({ s: siteKey, p: path, v: vp, e: exp })).toString('base64url'); return `${part}.${crypto.createHmac('sha256', secret).update(part).digest('hex')}`; } // Liefert { siteKey, path, vp, exp } oder null (Signatur, Format, abgelaufen, > 30 Min voraus). // 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; const expect = crypto.createHmac('sha256', secret).update(m[1]).digest(); const got = Buffer.from(m[2], 'hex'); if (got.length !== expect.length || !crypto.timingSafeEqual(got, expect)) return null; let o; try { o = JSON.parse(Buffer.from(m[1], 'base64url').toString('utf8')); } catch { return null; } if (!o || typeof o !== 'object' || typeof o.s !== 'string' || !SITE_KEY.test(o.s) || typeof o.p !== 'string' || !o.p.startsWith('/') || !['m', 't', 'd'].includes(o.v) || !Number.isInteger(o.e)) return null; const nowS = nowMs / 1000; 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, 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, id DESC LIMIT ?'); // CORS: nur die eigenen https-Domains der Website (das Overlay läuft auf der Kundenseite). function cors(res, req, domains) { const origin = req.headers.origin; const host = hostOf(origin); res.setHeader('Vary', 'Origin'); if (host && domains.includes(host)) res.setHeader('Access-Control-Allow-Origin', origin); } const domainsOf = (site) => { try { return JSON.parse(site.domains); } catch { return []; } }; // true, wenn die Anfrage /a/heat betraf. return function handle(req, res, url) { if (url.pathname !== '/a/heat') return false; const end = (status, body, extra = {}) => { const b = body ? JSON.stringify(body) : ''; res.writeHead(status, { 'Cache-Control': 'no-store', ...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(b) } : {}), ...extra }); 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: 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(token, config.heatSecret, clock()); const site = t && getSite.get(t.siteKey); 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, cleanPath(t.path) || t.path, t.vp, since, POINTS).map((r) => ({ x: r.x, y: r.y })); return end(200, { points }); }; }