Files
Webklar-Kundenbereich/analytics/src/heat.js
T

86 lines
4.3 KiB
JavaScript

// GET /a/heat?token= (öffentlicher Listener): Klickpunkte für das Heatmap-Overlay.
// Token (R3): <b64url(JSON {s,p,v,e})>.<hex HMAC-SHA256(heatSecret, b64part)>, 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 });
};
}