diff --git a/analytics/src/index.js b/analytics/src/index.js index 5c760d6..7f679dd 100644 --- a/analytics/src/index.js +++ b/analytics/src/index.js @@ -2,14 +2,17 @@ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { loadConfig } from './config.js'; import { openDb } from './db.js'; +import { createSalt } from './salt.js'; import { createPublicServer, createAdminServer } from './server.js'; +// Platzhalter bis Task 3 (loadGeo aus config.geoFile): kein Land. +const geo = { lookup: () => '' }; const config = loadConfig(process.env); mkdirSync(config.dataDir, { recursive: true }); const db = openDb(join(config.dataDir, 'analytics.db')); const servers = [ - ['öffentlich', createPublicServer({ config, db }), config.port], + ['öffentlich', createPublicServer({ config, db, geo, salt: createSalt() }), config.port], ['intern (Admin)', createAdminServer({ config, db }), config.adminPort], ]; for (const [name, server, port] of servers) { diff --git a/analytics/src/ingest.js b/analytics/src/ingest.js new file mode 100644 index 0000000..aceac4a --- /dev/null +++ b/analytics/src/ingest.js @@ -0,0 +1,180 @@ +// 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 = { + pv: ['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'], +}; + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + let tooBig = false; + req.on('data', (c) => { + if (tooBig) return; + size += c.length; + if (size > MAX_BODY) { tooBig = true; chunks.length = 0; return; } + chunks.push(c); + }); + req.on('end', () => (tooBig ? reject(Object.assign(new Error('zu groß'), { status: 413 })) : 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, ''); +} + +function hostOf(value) { + if (typeof value !== 'string' || !value) return ''; + try { return new URL(value).hostname.toLowerCase(); } catch { return ''; } +} + +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; +} + +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('://') ? hostOf(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'); + 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 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, req, ip, ua) { + 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; + if (e.t === 'pv') { + const vp = cleanVp(e.vp); + if (!vp) return; + const c = String(geo.lookup(ip) || ''); + const country = /^[A-Za-z]{2}$/.test(c) ? c.toUpperCase() : ''; + insEvent.run(site, t, 'pv', path, vp, cleanRef(e.ref, domains), 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, t, 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, t, '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, t, 'ct', path, null, null, null, null, visitor, null, null, e.kind, host); + } + } + + // Liefert true, wenn die Anfrage /a/e betraf. + return async function handle(req, res, pathname) { + if (pathname !== '/a/e') return false; + const done = (status) => { res.writeHead(status); res.end(); return true; }; + if (req.method === 'OPTIONS') { + const host = originHost(req); + const origin = req.headers.origin; + if (host && origin && activeSites.all().some((s) => parseDomains(s.domains).includes(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, req, ip, ua); } catch (e) { console.error('Analytics-Annahme:', e.message); } + return done(204); + }; +} diff --git a/analytics/src/salt.js b/analytics/src/salt.js new file mode 100644 index 0000000..cae9fe2 --- /dev/null +++ b/analytics/src/salt.js @@ -0,0 +1,19 @@ +import crypto from 'node:crypto'; + +const DAY = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' }); + +// Tagessalz nur im RAM; neu erzeugt, sobald sich der Kalendertag in Europe/Berlin ändert. +export function createSalt({ now = Date.now, random = () => crypto.randomBytes(32) } = {}) { + let day = null; + let salt = null; + const current = () => { + const d = DAY.format(now()); + if (d !== day) { day = d; salt = random(); } + return salt; + }; + const hmac = (s) => crypto.createHmac('sha256', current()).update(s).digest('hex'); + return { + visitorId: (site, ip, ua) => hmac(`${site}|${ip}|${ua}`).slice(0, 16), + rateKey: (site, ip) => hmac(`${site}|${ip}`), + }; +} diff --git a/analytics/src/server.js b/analytics/src/server.js index 245812a..b30b093 100644 --- a/analytics/src/server.js +++ b/analytics/src/server.js @@ -1,5 +1,7 @@ import http from 'node:http'; import crypto from 'node:crypto'; +import { createIngest } from './ingest.js'; +import { createSalt } from './salt.js'; const MAX_ADMIN_BODY = 64 * 1024; const SITE_KEY = /^[a-z0-9]{16,40}$/; @@ -53,13 +55,16 @@ function validateSite(b) { return { site: { projectId: b.projectId, domains: b.domains, active: b.active } }; } -function createHandlerServer({ config, db, role }) { +function createHandlerServer({ config, db, role, geo, salt, now }) { + const ingest = role === 'public' ? createIngest({ db, geo, salt: salt || createSalt({ now }), now }) : null; return http.createServer(async (req, res) => { try { const url = new URL(req.url, 'http://x'); const parts = url.pathname.split('/').filter(Boolean); const method = req.method; + if (ingest && await ingest(req, res, url.pathname)) return; + if (method === 'GET' && url.pathname === '/a/health') return send(res, 200, { ok: true }); if (role === 'admin' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 4 @@ -102,7 +107,7 @@ function createHandlerServer({ config, db, role }) { }); } -// Öffentlicher Listener: nur /a/health (Erfassung folgt); Admin-Routen gibt es hier nicht. -export const createPublicServer = ({ config, db }) => createHandlerServer({ config, db, role: 'public' }); +// Öffentlicher Listener: /a/health und /a/e (Erfassung); Admin-Routen gibt es hier nicht. +export const createPublicServer = ({ config, db, geo, salt, now }) => createHandlerServer({ config, db, role: 'public', geo, salt, now }); // Interner Listener für die Website-Verwaltung. export const createAdminServer = ({ config, db }) => createHandlerServer({ config, db, role: 'admin' }); diff --git a/analytics/src/ua.js b/analytics/src/ua.js new file mode 100644 index 0000000..34fcb29 --- /dev/null +++ b/analytics/src/ua.js @@ -0,0 +1,10 @@ +// Gröbste Bot- und Gerätetyp-Erkennung; der User-Agent wird danach verworfen. +const BOT = /bot|crawl|spider|slurp|headless|lighthouse|pingdom|uptime|curl|wget|python|go-http|java\/|facebookexternalhit|preview/i; + +export function classifyUa(ua) { + const s = typeof ua === 'string' ? ua : ''; + if (!s || BOT.test(s)) return { bot: true, device: 'd' }; + if (/Mobi|Android.*Mobile|iPhone/i.test(s)) return { bot: false, device: 'm' }; + if (/iPad|Tablet|Android(?!.*Mobile)/i.test(s)) return { bot: false, device: 't' }; + return { bot: false, device: 'd' }; +} diff --git a/analytics/test/ingest.test.js b/analytics/test/ingest.test.js new file mode 100644 index 0000000..e7b9ef1 --- /dev/null +++ b/analytics/test/ingest.test.js @@ -0,0 +1,251 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb } from '../src/db.js'; +import { createPublicServer } from '../src/server.js'; +import { classifyUa } from '../src/ua.js'; +import { createSalt } from '../src/salt.js'; + +const KEY = 'abcdef0123456789abcd'; +const HOST = 'abc.project.webklar.com'; +const ORIGIN = `https://${HOST}`; +const IP = '203.0.113.77'; +const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148 Safari/604.1 GEHEIMERUA'; + +async function setup({ now = () => Date.UTC(2026, 9, 1, 10, 30, 45), geo = { lookup: () => 'DE' } } = {}) { + const db = openDb(':memory:'); + db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)') + .run(KEY, 'p1', JSON.stringify([HOST, 'www.kunde.de'])); + const server = createPublicServer({ config: {}, db, geo, now }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const base = `http://127.0.0.1:${server.address().port}`; + const post = (ev, { origin = ORIGIN, ua = UA, ip = IP, referer, raw, type = 'text/plain' } = {}) => { + const headers = { 'Content-Type': type, 'User-Agent': ua, 'X-Forwarded-For': `1.2.3.4, ${ip}` }; + if (origin) headers.Origin = origin; + if (referer) headers.Referer = referer; + return fetch(base + '/a/e', { method: 'POST', headers, body: raw ?? JSON.stringify(ev) }); + }; + const rows = () => db.prepare('SELECT * FROM events_raw ORDER BY id').all(); + return { db, post, rows, base, close: () => server.close() }; +} +const pv = (o = {}) => ({ t: 'pv', site: KEY, path: '/', vp: 'd', ...o }); + +test('classifyUa: Bots, Geräte, leer', () => { + assert.equal(classifyUa('').bot, true); + assert.equal(classifyUa('Googlebot/2.1').bot, true); + assert.equal(classifyUa('curl/8.0').bot, true); + assert.equal(classifyUa('Mozilla/5.0 (Windows NT 10.0) Chrome/120').device, 'd'); + assert.equal(classifyUa('Mozilla/5.0 (iPhone; CPU) Mobile/15E').device, 'm'); + assert.equal(classifyUa('Mozilla/5.0 (Linux; Android 13; Pixel) Mobile Safari').device, 'm'); + assert.equal(classifyUa('Mozilla/5.0 (iPad; CPU OS 17)').device, 't'); + assert.equal(classifyUa('Mozilla/5.0 (Linux; Android 13; SM-T500) Safari').device, 't'); + assert.equal(classifyUa('Mozilla/5.0 (X11; Linux) Firefox/120').bot, false); +}); + +test('Salz: stabil am Tag, wechselt um Mitternacht Berlin', () => { + let t = Date.UTC(2026, 9, 1, 10, 0, 0); + let n = 0; + const s = createSalt({ now: () => t, random: () => Buffer.alloc(32, ++n) }); + const a = s.visitorId('s', '1.1.1.1', 'ua'); + assert.match(a, /^[0-9a-f]{16}$/); + t = Date.UTC(2026, 9, 1, 21, 59, 0); // 23:59 Berlin (MESZ) + assert.equal(s.visitorId('s', '1.1.1.1', 'ua'), a); + assert.notEqual(s.visitorId('s', '1.1.1.2', 'ua'), a); + t = Date.UTC(2026, 9, 1, 22, 0, 1); // 00:00 Berlin + assert.notEqual(s.visitorId('s', '1.1.1.1', 'ua'), a); + assert.match(s.rateKey('s', '1.1.1.1'), /^[0-9a-f]{64}$/); + assert.ok(!s.rateKey('s', '1.1.1.1').includes('1.1.1.1')); +}); + +test('pv: gespeichert ohne visitor, mit Land und Gerät, Cookie-frei', async () => { + const t = await setup(); + const res = await t.post(pv({ path: '/preise?x=1#a', ref: 'https://Google.com/search?q=1' })); + assert.equal(res.status, 204); + assert.equal(res.headers.get('set-cookie'), null); + const [r] = t.rows(); + assert.equal(r.type, 'pv'); + assert.equal(r.site, KEY); + assert.equal(r.path, '/preise'); + assert.equal(r.ref, 'google.com'); + assert.equal(r.vp, 'd'); + assert.equal(r.country, 'DE'); + assert.equal(r.device, 'm'); + assert.equal(r.visitor, null); + assert.equal(res.headers.get('access-control-allow-origin'), ORIGIN); + assert.equal(res.headers.get('access-control-allow-credentials'), null); + t.close(); +}); + +test('pv: ref eigene Domain wird verworfen, ungültige Felder', async () => { + const t = await setup(); + await t.post(pv({ ref: 'https://www.kunde.de/x' })); + await t.post(pv({ ref: 'kein host!' })); + assert.equal(t.rows()[0].ref, null); + assert.equal(t.rows()[1].ref, null); + await t.post(pv({ path: 'kein-slash' })); + await t.post(pv({ path: '/' + 'a'.repeat(300) })); + await t.post(pv({ path: '/a\u0001b' })); + await t.post(pv({ vp: 'x' })); + await t.post({ t: 'zz', site: KEY, path: '/', vp: 'd' }); + await t.post({ site: KEY, path: '/', vp: 'd' }); + await t.post(null, { raw: 'kein json' }); + await t.post(null, { raw: '[1]' }); + assert.equal(t.rows().length, 2); + t.close(); +}); + +test('Antwort immer 204; 413 bei großem Body; 405 bei falscher Methode', async () => { + const t = await setup(); + assert.equal((await t.post(null, { raw: 'kein json' })).status, 204); + assert.equal((await t.post(pv({ pad: 'x'.repeat(5000) }))).status, 413); + assert.equal(t.rows().length, 0); + assert.equal((await fetch(t.base + '/a/e')).status, 405); + assert.equal((await fetch(t.base + '/a/e', { method: 'PUT', body: '{}' })).status, 405); + t.close(); +}); + +test('application/json wird ebenfalls angenommen; unbekannte Felder werden nicht gespeichert', async () => { + const t = await setup(); + const res = await t.post(pv({ evil: 'GEHEIM', ip: '9.9.9.9' }), { type: 'application/json' }); + assert.equal(res.status, 204); + assert.equal(t.rows().length, 1); + assert.ok(!JSON.stringify(t.db.prepare('SELECT * FROM events_raw').all()).includes('GEHEIM')); + t.close(); +}); + +test('Origin: fremd, fehlend, Referer-Fallback, inaktiv, unbekannte Site', async () => { + const t = await setup(); + assert.equal((await t.post(pv(), { origin: 'https://evil.example' })).headers.get('access-control-allow-origin'), null); + await t.post(pv(), { origin: null }); + assert.equal(t.rows().length, 0); + await t.post(pv(), { origin: null, referer: `${ORIGIN}/seite` }); + assert.equal(t.rows().length, 1); + await t.post(pv(), { origin: 'https://www.kunde.de' }); + assert.equal(t.rows().length, 2); + await t.post(pv({ site: 'zzzzzzzzzzzzzzzzzzzz' })); + assert.equal(t.rows().length, 2); + t.db.prepare('UPDATE sites SET active = 0').run(); + await t.post(pv()); + assert.equal(t.rows().length, 2); + t.close(); +}); + +test('Bot und leerer User-Agent: nichts gespeichert', async () => { + const t = await setup(); + await t.post(pv(), { ua: 'Googlebot/2.1' }); + await t.post(pv(), { ua: '' }); + assert.equal(t.rows().length, 0); + t.close(); +}); + +test('bn: nur site, ts (Stunde), type, kind', async () => { + const t = await setup(); + await t.post({ t: 'bn', site: KEY, a: 'shown', path: '/x', vp: 'm', visitor: 'v' }); + await t.post({ t: 'bn', site: KEY, a: 'accept' }); + await t.post({ t: 'bn', site: KEY, a: 'foo' }); + const rows = t.rows(); + assert.equal(rows.length, 2); + for (const r of rows) { + assert.equal(r.type, 'bn'); + for (const f of ['path', 'vp', 'country', 'device', 'visitor', 'ref', 'sec', 'scroll', 'host']) assert.equal(r[f], null, f); + assert.equal(r.ts, Date.UTC(2026, 9, 1, 10, 0, 0)); + } + assert.deepEqual(rows.map((r) => r.kind), ['shown', 'accept']); + t.close(); +}); + +test('en: sec/scroll begrenzt, mit visitor', async () => { + const t = await setup(); + await t.post({ t: 'en', site: KEY, path: '/a?b', vp: 't', sec: 99999, scroll: 250 }); + await t.post({ t: 'en', site: KEY, path: '/a', vp: 't', sec: 12.7, scroll: -5 }); + await t.post({ t: 'en', site: KEY, path: '/a', vp: 't', sec: 'x', scroll: 1 }); + const rows = t.rows(); + assert.equal(rows.length, 2); + assert.match(rows[0].visitor, /^[0-9a-f]{16}$/); + assert.equal(rows[0].path, '/a'); + assert.equal(rows[0].sec, 3600); + assert.equal(rows[0].scroll, 100); + assert.equal(rows[1].sec, 12); + assert.equal(rows[1].scroll, 0); + t.close(); +}); + +test('ck: in clicks, geklemmt, ohne visitor', async () => { + const t = await setup(); + await t.post({ t: 'ck', site: KEY, path: '/a#h', vp: 'd', x: 0.123456, y: 1.7, sel: 'div>a'.repeat(100) }); + await t.post({ t: 'ck', site: KEY, path: '/a', vp: 'd', x: 'n', y: 0.5 }); + assert.equal(t.rows().length, 0); + const c = t.db.prepare('SELECT * FROM clicks').all(); + assert.equal(c.length, 1); + assert.equal(c[0].path, '/a'); + assert.equal(c[0].x, 0.123); + assert.equal(c[0].y, 1); + assert.equal(c[0].sel.length, 200); + assert.deepEqual(Object.keys(c[0]).sort(), ['id', 'path', 'sel', 'site', 'ts', 'vp', 'x', 'y']); + t.close(); +}); + +test('ct: kind und host', async () => { + const t = await setup(); + await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'tel', host: 'ignored.de' }); + await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'out', host: 'Instagram.COM' }); + await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'out' }); + await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'sms' }); + const rows = t.rows(); + assert.equal(rows.length, 2); + assert.equal(rows[0].kind, 'tel'); + assert.equal(rows[0].host, null); + assert.equal(rows[1].host, 'instagram.com'); + assert.match(rows[1].visitor, /^[0-9a-f]{16}$/); + t.close(); +}); + +test('Mengenlimit: 121. Ereignis verworfen, neue Minute frei', async () => { + let now = Date.UTC(2026, 9, 1, 10, 30, 0); + const t = await setup({ now: () => now }); + for (let i = 0; i < 125; i++) await t.post(pv()); + assert.equal(t.rows().length, 120); + await t.post(pv(), { ip: '198.51.100.9' }); + assert.equal(t.rows().length, 121); + now += 61000; + await t.post(pv()); + assert.equal(t.rows().length, 122); + t.close(); +}); + +test('Land: nur 2 Großbuchstaben, sonst leer', async () => { + const t = await setup({ geo: { lookup: () => 'xyz' } }); + await t.post(pv()); + assert.equal(t.rows()[0].country, ''); + t.close(); + const u = await setup({ geo: { lookup: () => 'at' } }); + await u.post(pv()); + assert.equal(u.rows()[0].country, 'AT'); + u.close(); +}); + +test('CORS-Preflight nur für erlaubte Origin', async () => { + const t = await setup(); + const ok = await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: ORIGIN, 'Access-Control-Request-Method': 'POST' } }); + assert.equal(ok.status, 204); + assert.equal(ok.headers.get('access-control-allow-origin'), ORIGIN); + assert.equal(ok.headers.get('access-control-allow-credentials'), null); + const bad = await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: 'https://evil.example' } }); + assert.equal(bad.status, 204); + assert.equal(bad.headers.get('access-control-allow-origin'), null); + t.close(); +}); + +test('Datenschutz: weder IP noch User-Agent irgendwo in der Datenbank', async () => { + const t = await setup(); + await t.post(pv({ ref: 'https://google.com' })); + await t.post({ t: 'bn', site: KEY, a: 'accept' }); + await t.post({ t: 'en', site: KEY, path: '/a', vp: 'd', sec: 5, scroll: 50 }); + await t.post({ t: 'ck', site: KEY, path: '/a', vp: 'd', x: 0.5, y: 0.5, sel: 'a' }); + await t.post({ t: 'ct', site: KEY, path: '/a', kind: 'mail' }); + let dump = ''; + for (const { name } of t.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()) { + dump += JSON.stringify(t.db.prepare(`SELECT * FROM ${name}`).all()); + } + for (const needle of [IP, '1.2.3.4', 'GEHEIMERUA', 'Mozilla', 'iPhone']) assert.ok(!dump.includes(needle), needle); + t.close(); +});