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>
150 lines
7.8 KiB
JavaScript
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 });
|