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 });