import express from 'express'; import { randomUUID } from 'node:crypto'; import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { authRoutes, requireAuth } from './auth.js'; const ROOT = dirname(fileURLToPath(import.meta.url)); const DATA_DIR = join(ROOT, 'data'); const DB_FILE = join(DATA_DIR, 'db.json'); const PATCH_DIR = join(DATA_DIR, 'patches'); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST; // optional, z. B. 127.0.0.1, damit nur ein Proxy auf demselben Rechner zugreifen kann // ---------- Datenspeicher (JSON-Datei) ---------- function loadDb() { if (!existsSync(DB_FILE)) return { members: [], events: [], meetingPoints: [], statsArchive: [], patches: [] }; const db = JSON.parse(readFileSync(DB_FILE, 'utf8')); const members = db.members || []; // Früher war die manuelle Korrektur eine einzelne Zahl – sie gilt für das laufende Jahr for (const m of members) { if (typeof m.playsManual === 'number') m.playsManual = { [new Date().getFullYear()]: m.playsManual }; } // Ältere Termine haben kein Spiel-Feld – das waren alles Spieltage const events = (db.events || []).map((e) => ({ isGame: true, ...e })); return { members, events, meetingPoints: db.meetingPoints || [], statsArchive: db.statsArchive || [], // Ältere Patches haben keinen Status – die gab es schon patches: (db.patches || []).map((p) => ({ status: 'vorhanden', ...p })), }; } function saveDb() { mkdirSync(DATA_DIR, { recursive: true }); const tmp = DB_FILE + '.tmp'; writeFileSync(tmp, JSON.stringify(db, null, 2)); renameSync(tmp, DB_FILE); } const db = loadDb(); // ---------- Validierung ---------- class BadRequest extends Error {} // memberIds: erlaubte IDs für "fährt am liebsten mit bei", pointIds: erlaubte Standard-Treffpunkte function cleanMember(body, { memberIds, pointIds = new Set(db.meetingPoints.map((p) => p.id)), selfId }) { const name = String(body?.name ?? '').trim(); if (!name) throw new BadRequest('Vorname fehlt.'); const hasCar = !!body.hasCar; const seats = hasCar ? Math.min(8, Math.max(1, parseInt(body.seats, 10) || 1)) : 0; const alwaysDrives = hasCar && !!body.alwaysDrives; const ridesWith = memberIds.has(body.ridesWith) && body.ridesWith !== selfId ? body.ridesWith : null; const meetingPoint = hasCar && pointIds.has(body.meetingPoint) ? body.meetingPoint : null; const external = !!body.external; return { name: name.slice(0, 50), external, hasCar, seats, alwaysDrives, meetingPoint, ridesWith }; } function cleanEvent(body) { const date = String(body?.date ?? ''); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new BadRequest('Ungültiges Datum.'); const title = String(body.title ?? '').trim(); if (!title) throw new BadRequest('Titel fehlt.'); const note = String(body.note ?? '').trim(); const time = cleanTime(body.time); const endTime = time ? cleanTime(body.endTime) : null; return { date, title: title.slice(0, 80), note: note.slice(0, 500), isGame: body.isGame !== false, time, endTime }; } function cleanTime(value) { const t = String(value ?? ''); return /^([01]\d|2[0-3]):[0-5]\d$/.test(t) ? t : null; } // Bild als data:-URL; erlaubt sind PNG, JPEG und WebP (geprüft am Dateianfang, nicht nur am Typ) const IMAGE_TYPES = [ { ext: 'png', magic: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) }, { ext: 'jpg', magic: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff }, { ext: 'webp', magic: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP' }, ]; const MAX_IMAGE_BYTES = 8 * 1024 * 1024; const MAX_SVG_BYTES = 2 * 1024 * 1024; // SVG ist Text und kann Skripte enthalten. Alles, was Code ausführen oder fremde Dateien nachladen könnte, // wird abgelehnt; zusätzlich liefert /patch-img SVGs mit einer CSP aus, die Skripte sperrt. const SVG_FORBIDDEN = [ [/