Analytics: Annahme gehaertet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 09:41:25 +00:00
1 parent 8b107019c3
commit 6233850bb3
4 files changed
+164 -22

No files matched your search

+3 -2
View File
@@ -12,9 +12,10 @@ const db = openDb(join(config.dataDir, 'analytics.db'));
const geo = loadGeo(config.geoFile || join(config.dataDir, 'geo.csv'));
if (geo.size) console.log(`Analytics: ${geo.size} Länder-Bereiche geladen`);
const publicServer = createPublicServer({ config, db, geo, salt: createSalt() });
const servers = [
['öffentlich', createPublicServer({ config, db, geo, salt: createSalt() }), config.port],
['intern (Admin)', createAdminServer({ config, db }), config.adminPort],
['öffentlich', publicServer, config.port],
['intern (Admin)', createAdminServer({ config, db, onSitesChanged: () => publicServer.invalidateSites() }), config.adminPort],
];
for (const [name, server, port] of servers) {
server.on('error', (e) => { console.error(`Analytics-Listener ${name} (Port ${port}):`, e.message); process.exit(1); });
+48 -13
View File
@@ -8,26 +8,28 @@ const HOST = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z
const LIMIT = 120;
const HOUR = 3600 * 1000;
const MINUTE = 60 * 1000;
const FIELDS = {
const FIELDS = Object.assign(Object.create(null), {
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'],
};
});
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;
let tooBig = false;
req.on('data', (c) => {
if (tooBig) return;
size += c.length;
if (size > MAX_BODY) { tooBig = true; chunks.length = 0; return; }
if (size > MAX_BODY) { chunks.length = 0; reject(tooBig()); 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('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
@@ -39,8 +41,17 @@ function clientIp(req) {
return ip.replace(/^::ffff:/i, '');
}
// Nur https ohne expliziten Port zählt; alles andere ergibt '' (kein Treffer).
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 ''; }
}
@@ -59,7 +70,7 @@ function cleanHost(h) {
function cleanRef(ref, domains) {
if (typeof ref !== 'string' || !ref) return null;
const host = cleanHost(ref.includes('://') ? hostOf(ref) : ref);
const host = cleanHost(ref.includes('://') ? anyHostOf(ref) : ref);
return host && !domains.includes(host) ? host : null;
}
@@ -70,6 +81,8 @@ 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 (?, ?, ?, ?, ?, ?, ?)');
@@ -79,6 +92,17 @@ export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.
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);
@@ -96,7 +120,8 @@ export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.
res.setHeader('Vary', 'Origin');
}
function store(ev, site, domains, req, ip, ua) {
function store(ev, site, domains, ip, ua) {
if (typeof ev.t !== 'string') return;
const spec = FIELDS[ev.t];
if (!spec) return;
const e = {};
@@ -144,13 +169,20 @@ export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.
}
// Liefert true, wenn die Anfrage /a/e betraf.
return async function handle(req, res, pathname) {
async function handle(req, res, pathname) {
if (pathname !== '/a/e') return false;
const done = (status) => { res.writeHead(status); res.end(); return true; };
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 && activeSites.all().some((s) => parseDomains(s.domains).includes(host))) {
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');
@@ -174,7 +206,10 @@ export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.
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); }
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;
}
+8 -3
View File
@@ -55,9 +55,9 @@ function validateSite(b) {
return { site: { projectId: b.projectId, domains: b.domains, active: b.active } };
}
function createHandlerServer({ config, db, role, geo, salt, now }) {
function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged }) {
const ingest = role === 'public' ? createIngest({ db, geo, salt: salt || createSalt({ now }), now }) : null;
return http.createServer(async (req, res) => {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url, 'http://x');
const parts = url.pathname.split('/').filter(Boolean);
@@ -85,6 +85,7 @@ function createHandlerServer({ config, db, role, geo, salt, now }) {
db.exec('ROLLBACK');
throw e;
}
onSitesChanged?.();
return send(res, 200, { ok: true });
}
const v = validateSite(await readJson(req, MAX_ADMIN_BODY));
@@ -96,6 +97,7 @@ function createHandlerServer({ config, db, role, geo, salt, now }) {
ON CONFLICT(siteKey) DO UPDATE SET projectId = excluded.projectId, domains = excluded.domains,
active = excluded.active, offSince = excluded.offSince`)
.run(key, projectId, JSON.stringify(domains), active ? 1 : 0, offSince);
onSitesChanged?.();
return send(res, 200, { ok: true });
}
return send(res, 404, { error: 'Nicht gefunden' });
@@ -105,9 +107,12 @@ function createHandlerServer({ config, db, role, geo, salt, now }) {
if (!res.headersSent) send(res, status, { error: status === 500 ? 'Interner Fehler' : e.message });
}
});
server.invalidateSites = ingest ? ingest.invalidateSites : () => {};
return server;
}
// Ö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' });
// onSitesChanged: wird nach jeder Änderung an Websites gerufen (public.invalidateSites()).
export const createAdminServer = ({ config, db, onSitesChanged }) => createHandlerServer({ config, db, role: 'admin', onSitesChanged });