Analytics: Heatmap-Endpunkt nachgeschaerft

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 09:59:48 +00:00
1 parent 28c7a90d8b
commit add7fa4add
4 files changed
+97 -13

No files matched your search

+20 -6
View File
@@ -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 }));