232 lines
9.3 KiB
JavaScript
232 lines
9.3 KiB
JavaScript
// Annahme von POST /a/e. Datenschutz-Kern: IP und User-Agent leben nur in lokalen
|
|
// Variablen (Geo, Limit-Schlüssel, Besucher-Hash) und werden nie gespeichert oder geloggt.
|
|
import { classifyUa } from './ua.js';
|
|
|
|
const MAX_BODY = 4096;
|
|
const SITE_KEY = /^[a-z0-9]{16,40}$/;
|
|
const HOST = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
|
const LIMIT = 120;
|
|
const HOUR = 3600 * 1000;
|
|
const MINUTE = 60 * 1000;
|
|
const FIELDS = Object.assign(Object.create(null), {
|
|
pv: ['t', 'site', 'path', 'ref', 'vp'],
|
|
cx: ['t', 'site', 'path', 'ref', 'vp'],
|
|
bn: ['t', 'site', 'a'],
|
|
en: ['t', 'site', 'path', 'vp', 'sec', 'scroll'],
|
|
ck: ['t', 'site', 'path', 'vp', 'x', 'y', 'sel'],
|
|
ct: ['t', 'site', 'path', 'kind', 'host'],
|
|
});
|
|
const SITES_TTL = 30 * 1000;
|
|
|
|
function tooBig() { return Object.assign(new Error('zu groß'), { status: 413 }); }
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
if (Number(req.headers['content-length']) > MAX_BODY) return reject(tooBig());
|
|
const chunks = [];
|
|
let size = 0;
|
|
req.on('data', (c) => {
|
|
size += c.length;
|
|
if (size > MAX_BODY) { chunks.length = 0; reject(tooBig()); return; }
|
|
chunks.push(c);
|
|
});
|
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
// Rechtester X-Forwarded-For-Eintrag (von Traefik angehängt), sonst Socket-Adresse.
|
|
function clientIp(req) {
|
|
const xff = String(req.headers['x-forwarded-for'] || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
const ip = xff.length ? xff[xff.length - 1] : (req.socket.remoteAddress || '');
|
|
return ip.replace(/^::ffff:/i, '');
|
|
}
|
|
|
|
// Nur https ohne expliziten Port zählt; alles andere ergibt '' (kein Treffer).
|
|
export function hostOf(value) {
|
|
if (typeof value !== 'string' || !value) return '';
|
|
try {
|
|
const u = new URL(value);
|
|
return u.protocol === 'https:' && !u.port ? u.hostname.toLowerCase() : '';
|
|
} catch { return ''; }
|
|
}
|
|
|
|
// Für cleanRef: Host einer beliebigen Referrer-URL (auch http, mit Port).
|
|
function anyHostOf(value) {
|
|
try { return new URL(value).hostname.toLowerCase(); } catch { return ''; }
|
|
}
|
|
|
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
// Kennungsartige Segmente (lange Ziffernfolge, UUID, E-Mail) werden nie gespeichert.
|
|
function looksLikeId(seg) {
|
|
let d = seg;
|
|
try { d = decodeURIComponent(seg); } catch { /* Rohwert */ }
|
|
return /\d{6,}/.test(d) || UUID.test(d) || d.includes('@');
|
|
}
|
|
|
|
export function cleanPath(p) {
|
|
if (typeof p !== 'string') return null;
|
|
const s = p.split(/[?#]/)[0];
|
|
if (!s.startsWith('/') || s.length > 300 || /[\u0000-\u001f\u007f]/.test(s)) return null;
|
|
return s.split('/').map((seg) => (looksLikeId(seg) ? ':id' : seg)).join('/');
|
|
}
|
|
|
|
function cleanHost(h) {
|
|
if (typeof h !== 'string') return null;
|
|
const s = h.toLowerCase();
|
|
return s.length <= 253 && HOST.test(s) ? s : null;
|
|
}
|
|
|
|
function cleanRef(ref, domains) {
|
|
if (typeof ref !== 'string' || !ref) return null;
|
|
const host = cleanHost(ref.includes('://') ? anyHostOf(ref) : ref);
|
|
return host && !domains.includes(host) ? host : null;
|
|
}
|
|
|
|
const cleanVp = (v) => (v === 'm' || v === 't' || v === 'd' ? v : null);
|
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
|
|
export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.now }) {
|
|
const getSite = db.prepare('SELECT domains, active FROM sites WHERE siteKey = ?');
|
|
const activeSites = db.prepare('SELECT domains FROM sites WHERE active = 1');
|
|
let hostCache = null; // { at, hosts: Set } für Preflight-Prüfungen
|
|
let hostCacheStale = true;
|
|
const insEvent = db.prepare(`INSERT INTO events_raw (site, ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind, host)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
const insClick = db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y, sel) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
|
const windows = new Map(); // rateKey -> { w, n }; rateKey ist ein HMAC, keine IP
|
|
let lastPrune = 0;
|
|
|
|
const originHost = (req) => hostOf(req.headers.origin) || (req.headers.origin ? '' : hostOf(req.headers.referer));
|
|
const parseDomains = (s) => { try { return JSON.parse(s); } catch { return []; } };
|
|
|
|
function activeHosts() {
|
|
const t = now();
|
|
if (hostCacheStale || !hostCache || t - hostCache.at >= SITES_TTL || t < hostCache.at) {
|
|
const hosts = new Set();
|
|
for (const s of activeSites.all()) for (const d of parseDomains(s.domains)) hosts.add(d);
|
|
hostCache = { at: t, hosts };
|
|
hostCacheStale = false;
|
|
}
|
|
return hostCache.hosts;
|
|
}
|
|
|
|
function limited(key) {
|
|
const t = now();
|
|
const w = Math.floor(t / MINUTE);
|
|
if (w !== lastPrune) { // alte Fenster entfernen
|
|
for (const [k, v] of windows) if (v.w !== w) windows.delete(k);
|
|
lastPrune = w;
|
|
}
|
|
let e = windows.get(key);
|
|
if (!e || e.w !== w) { e = { w, n: 0 }; windows.set(key, e); }
|
|
return ++e.n > LIMIT;
|
|
}
|
|
|
|
function cors(res, origin) {
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
res.setHeader('Vary', 'Origin');
|
|
}
|
|
|
|
function store(ev, site, domains, ip, ua) {
|
|
if (typeof ev.t !== 'string') return;
|
|
const spec = FIELDS[ev.t];
|
|
if (!spec) return;
|
|
const e = {};
|
|
for (const k of spec) if (k in ev) e[k] = ev[k]; // unbekannte Felder werden nie übernommen
|
|
const t = now();
|
|
if (e.t === 'bn') {
|
|
if (!['shown', 'accept', 'reject'].includes(e.a)) return;
|
|
insEvent.run(site, Math.floor(t / HOUR) * HOUR, 'bn', null, null, null, null, null, null, null, null, e.a, null);
|
|
return;
|
|
}
|
|
const path = cleanPath(e.path);
|
|
if (!path) return;
|
|
const hour = Math.floor(t / HOUR) * HOUR;
|
|
if (e.t === 'cx') {
|
|
const vp = cleanVp(e.vp);
|
|
if (!vp) return;
|
|
insEvent.run(site, hour, 'cx', path, vp, cleanRef(e.ref, domains), null, null, null, null, null, null, null);
|
|
return;
|
|
}
|
|
if (e.t === 'pv') {
|
|
const vp = e.vp === undefined ? null : cleanVp(e.vp);
|
|
if (e.vp !== undefined && !vp) return;
|
|
const c = String(geo.lookup(ip) || '');
|
|
const country = /^[A-Za-z]{2}$/.test(c) ? c.toUpperCase() : '';
|
|
insEvent.run(site, hour, 'pv', path, vp, vp ? cleanRef(e.ref, domains) : null, country, classifyUa(ua).device, null, null, null, null, null);
|
|
return;
|
|
}
|
|
if (e.t === 'ck') {
|
|
const vp = cleanVp(e.vp);
|
|
const x = num(e.x);
|
|
const y = num(e.y);
|
|
if (!vp || x === null || y === null) return;
|
|
const sel = typeof e.sel === 'string' ? e.sel.slice(0, 200) : null;
|
|
insClick.run(site, hour, path, vp, Math.round(clamp(x, 0, 1) * 1000) / 1000, Math.round(clamp(y, 0, 1) * 1000) / 1000, sel);
|
|
return;
|
|
}
|
|
const visitor = salt.visitorId(site, ip, ua);
|
|
if (e.t === 'en') {
|
|
const vp = cleanVp(e.vp);
|
|
const sec = num(e.sec);
|
|
const scroll = num(e.scroll);
|
|
if (!vp || sec === null || scroll === null) return;
|
|
insEvent.run(site, hour, 'en', path, vp, null, null, null, visitor, Math.floor(clamp(sec, 0, 3600)), Math.floor(clamp(scroll, 0, 100)), null, null);
|
|
return;
|
|
}
|
|
if (e.t === 'ct') {
|
|
if (!['tel', 'mail', 'wa', 'out'].includes(e.kind)) return;
|
|
let host = null;
|
|
if (e.kind === 'out') { host = cleanHost(e.host); if (!host) return; }
|
|
insEvent.run(site, hour, 'ct', path, null, null, null, null, visitor, null, null, e.kind, host);
|
|
}
|
|
}
|
|
|
|
// Liefert true, wenn die Anfrage /a/e betraf.
|
|
async function handle(req, res, pathname) {
|
|
if (pathname !== '/a/e') return false;
|
|
const done = (status) => {
|
|
if (status === 413) { // Rest des Bodys nicht abwarten: Antwort senden, danach Verbindung kappen
|
|
res.writeHead(413, { Connection: 'close' });
|
|
res.end(() => req.destroy());
|
|
return true;
|
|
}
|
|
res.writeHead(status); res.end(); return true;
|
|
};
|
|
if (req.method === 'OPTIONS') {
|
|
const host = originHost(req);
|
|
const origin = req.headers.origin;
|
|
if (host && origin && activeHosts().has(host)) {
|
|
cors(res, origin);
|
|
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
res.setHeader('Access-Control-Max-Age', '86400');
|
|
}
|
|
return done(204);
|
|
}
|
|
if (req.method !== 'POST') { res.setHeader('Allow', 'POST, OPTIONS'); return done(405); }
|
|
let raw;
|
|
try { raw = await readBody(req); } catch (e) { return done(e.status === 413 ? 413 : 204); }
|
|
let ev;
|
|
try { ev = JSON.parse(raw); } catch { return done(204); }
|
|
if (!ev || typeof ev !== 'object' || Array.isArray(ev) || typeof ev.site !== 'string' || !SITE_KEY.test(ev.site)) return done(204);
|
|
const site = getSite.get(ev.site);
|
|
if (!site || !site.active) return done(204);
|
|
const domains = parseDomains(site.domains);
|
|
const host = originHost(req);
|
|
if (!host || !domains.includes(host)) return done(204);
|
|
if (req.headers.origin) cors(res, req.headers.origin);
|
|
const ua = String(req.headers['user-agent'] || '');
|
|
if (classifyUa(ua).bot) return done(204);
|
|
const ip = clientIp(req);
|
|
if (limited(salt.rateKey(ev.site, ip))) return done(204);
|
|
try { store(ev, ev.site, domains, ip, ua); } catch (e) { console.error('Analytics-Annahme:', e.message); }
|
|
return done(204);
|
|
}
|
|
|
|
handle.invalidateSites = () => { hostCacheStale = true; };
|
|
return handle;
|
|
}
|