Files
Webklar-Kundenbereich/analytics/src/server.js
T
ClaudeandClaude Opus 5.5 78170567b4 Analyse: alle Beendigungswege schalten den Messdienst ab (Kunde/Projekt weg, Domain, Löschfrist, Widerruf Wechsel)
Messdienst: POST /a/admin/sites/:key/off setzt active=0 und offSince einmalig; Portal-Client stopSite.
analyse.js: endAnalyse als gemeinsamer Kern, endAnalyseForCustomer, analyseOnProjectChanged,
analyseDisabled (Sperre/Löschfrist) und täglicher Abgleich runAnalyseReconcile (Projekt/Kunde weg,
Domains nachziehen, K18-Wiederholung, Messdienst für off/disabled, fehlende Entfernen-Tickets nachholen).
Entfernen-Ticket je Abschaltung gekennzeichnet (kein Doppelticket, auch wenn das Speichern der ID scheiterte).
Hooks: Kunde löschen, updateWebsiteProject, withdrawOrder (Wechsel), siteFeatures.disable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:50:36 +00:00

150 lines
7.8 KiB
JavaScript

import http from 'node:http';
import crypto from 'node:crypto';
import { createIngest } from './ingest.js';
import { createSalt } from './salt.js';
import { createHeat } from './heat.js';
import { computeStats } from './stats.js';
import { createAssets } from './assets.js';
const MAX_ADMIN_BODY = 64 * 1024;
const SITE_KEY = /^[a-z0-9]{16,40}$/;
const HOSTNAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
function tokenOk(header, token) {
if (!token) return false;
const m = /^Bearer (.+)$/.exec(header || '');
if (!m) return false;
const a = crypto.createHash('sha256').update(m[1]).digest();
const b = crypto.createHash('sha256').update(token).digest();
return crypto.timingSafeEqual(a, b);
}
function send(res, status, obj) {
const body = JSON.stringify(obj);
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
res.end(body);
}
function readJson(req, max) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let tooBig = false;
req.on('data', (c) => {
if (tooBig) return; // Rest verwerfen, damit die Antwort noch ausgeliefert wird
size += c.length;
if (size > max) { tooBig = true; chunks.length = 0; return; }
chunks.push(c);
});
req.on('end', () => {
if (tooBig) return reject(Object.assign(new Error('Anfrage zu groß'), { status: 413 }));
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw) return resolve({});
try { resolve(JSON.parse(raw)); } catch { reject(Object.assign(new Error('Ungültiges JSON'), { status: 400 })); }
});
req.on('error', reject);
});
}
function validateSite(b) {
if (!b || typeof b !== 'object' || Array.isArray(b)) return { error: 'Ungültige Anfrage' };
if (typeof b.projectId !== 'string' || !b.projectId || b.projectId.length > 100) return { error: 'Ungültige Projekt-ID' };
if (!Array.isArray(b.domains) || b.domains.length < 1 || b.domains.length > 5
|| !b.domains.every((d) => typeof d === 'string' && d.length <= 253 && HOSTNAME.test(d))) {
return { error: 'Ungültige Domains (1 bis 5 Hostnamen, klein geschrieben, ohne Port und Pfad)' };
}
if (new Set(b.domains).size !== b.domains.length) return { error: 'Doppelte Domains' };
if (typeof b.active !== 'boolean') return { error: 'Ungültiger Status' };
return { site: { projectId: b.projectId, domains: b.domains, active: b.active } };
}
function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged }) {
const ingest = role === 'public' ? createIngest({ db, geo, salt: salt || createSalt({ now }), now }) : null;
const heat = role === 'public' ? createHeat({ db, config, now }) : null;
const assets = role === 'public' ? createAssets({ distDir: config.distDir, isActive: (k) => !!db.prepare('SELECT 1 FROM sites WHERE siteKey = ? AND active = 1').get(k) }) : null;
const clock = now || Date.now;
const server = 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 (heat && heat(req, res, url)) return;
if (assets && assets(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
&& (method === 'PUT' || method === 'DELETE')) {
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
const key = parts[3];
if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' });
if (method === 'DELETE') {
db.exec('BEGIN');
try {
db.prepare('DELETE FROM sites WHERE siteKey = ?').run(key);
db.prepare('DELETE FROM events_raw WHERE site = ?').run(key);
db.prepare('DELETE FROM clicks WHERE site = ?').run(key);
db.prepare('DELETE FROM daily WHERE site = ?').run(key);
db.prepare('DELETE FROM agg_state WHERE site = ?').run(key);
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
onSitesChanged?.();
return send(res, 200, { ok: true });
}
const v = validateSite(await readJson(req, MAX_ADMIN_BODY));
if (v.error) return send(res, 400, { error: v.error });
const { projectId, domains, active } = v.site;
const old = db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(key);
const offSince = active ? null : (old && old.offSince) || new Date().toISOString();
db.prepare(`INSERT INTO sites (siteKey, projectId, domains, active, offSince) VALUES (?, ?, ?, ?, ?)
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 });
}
// Beenden ohne Domainliste: active=0 und offSince einmalig (Portal: stopSite)
if (role === 'admin' && method === 'POST' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 5 && parts[4] === 'off') {
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
const key = parts[3];
if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' });
const old = db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(key);
if (!old) return send(res, 404, { error: 'Website nicht gefunden' });
db.prepare('UPDATE sites SET active = 0, offSince = ? WHERE siteKey = ?').run(old.offSince || new Date().toISOString(), key);
onSitesChanged?.();
return send(res, 200, { ok: true });
}
if (role === 'admin' && method === 'GET' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'stats' && parts.length === 4) {
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
const key = parts[3];
if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' });
const r = url.searchParams.get('range') ?? '30';
if (!['7', '30', '90'].includes(r)) return send(res, 400, { error: 'Ungültiger Zeitraum (7, 30 oder 90)' });
if (!db.prepare('SELECT 1 FROM sites WHERE siteKey = ?').get(key)) return send(res, 404, { error: 'Website nicht gefunden' });
return send(res, 200, computeStats(db, key, Number(r), clock()));
}
return send(res, 404, { error: 'Nicht gefunden' });
} catch (e) {
const status = e.status || 500;
if (status >= 500) console.error('Analytics-Fehler:', e);
if (!res.headersSent) send(res, status, { error: status === 500 ? 'Interner Fehler' : e.message });
}
});
server.invalidateSites = ingest ? ingest.invalidateSites : () => {};
return server;
}
// Öffentlicher Listener: /a/health, /a/e (Erfassung), /a/heat, /a/w.js und /a/h.js; 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.
// onSitesChanged: wird nach jeder Änderung an Websites gerufen (public.invalidateSites()).
export const createAdminServer = ({ config, db, onSitesChanged, now }) => createHandlerServer({ config, db, role: 'admin', onSitesChanged, now });