86 lines
4.3 KiB
JavaScript
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 });
|
|
};
|
|
}
|