Files
hcrb/server.js
T
guyg1474andClaude Opus 5.5 486e6f6784 Spiel-Events, Kalenderdateien, Patches-Seite und neues Design
- Kalender: Termine als Spiel-Event markieren (nur diese zählen in der
  Statistik), optionale Uhrzeiten, Termine bearbeiten
- .ics-Download für einzelne und alle kommenden Termine
- Neue Seite Patches: Upload (PNG, JPEG, WebP, geprüftes SVG),
  Status "Gibt es schon" / "In Planung", Bilder in data/patches/
- Volle Seitenbreite mit Spaltenlayouts, neues Design mit selbst
  gehosteten Schriften, Startseite mit Zoom und drehendem Zahnrad
- Bestehende Daten werden beim Laden ergänzt, keine Migration nötig

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:13:52 +02:00

560 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = [
[/<script/i, 'Skripte'],
[/<foreignObject/i, 'eingebettetes HTML'],
[/<!ENTITY|<!DOCTYPE[^>]*\[/i, 'eigene Entities'],
[/\son[a-z]+\s*=/i, 'Ereignis-Attribute (onload, onclick, …)'],
[/javascript:|vbscript:|data:text\/html/i, 'Skript-Links'],
[/<(iframe|embed|object|audio|video)\b/i, 'eingebettete Inhalte'],
[/(href|src)\s*=\s*["']\s*(https?:)?\/\//i, 'Verweise auf fremde Dateien'],
[/@import|url\(\s*["']?\s*(https?:)?\/\//i, 'nachgeladene Stile oder Bilder'],
];
function isSvg(buf) {
const head = buf.subarray(0, 1024).toString('utf8').replace(/^\uFEFF/, '');
// XML-Kopf, Kommentare und ein einfacher DOCTYPE (Illustrator) dürfen vor <svg> stehen
return /^\s*(<\?xml[^>]*>\s*)?((<!--[\s\S]*?-->|<!DOCTYPE svg[^>\[]*>)\s*)*<svg[\s>]/i.test(head);
}
function checkSvg(buf) {
if (buf.length > MAX_SVG_BYTES) throw new BadRequest('SVG ist zu groß (max. 2 MB).');
const text = buf.toString('utf8');
for (const [re, what] of SVG_FORBIDDEN) {
if (re.test(text)) throw new BadRequest(`SVG enthält ${what} – bitte als einfache Grafik exportieren.`);
}
}
function cleanImage(dataUrl) {
const m = /^data:image\/[a-z+.-]+;base64,([A-Za-z0-9+/=]+)$/.exec(String(dataUrl ?? ''));
if (!m) throw new BadRequest('Bild fehlt oder ist ungültig.');
const buf = Buffer.from(m[1], 'base64');
if (buf.length > MAX_IMAGE_BYTES) throw new BadRequest('Bild ist zu groß (max. 8 MB).');
if (isSvg(buf)) {
checkSvg(buf);
return { buf, ext: 'svg' };
}
const type = IMAGE_TYPES.find((t) => t.magic(buf));
if (!type) throw new BadRequest('Nur PNG-, JPEG-, WebP- oder SVG-Bilder.');
return { buf, ext: type.ext };
}
const PATCH_STATUS = ['vorhanden', 'geplant'];
function cleanPatch(body) {
const name = String(body?.name ?? '').trim();
if (!name) throw new BadRequest('Name des Patches fehlt.');
const description = String(body.description ?? '').trim();
const status = PATCH_STATUS.includes(body.status) ? body.status : 'vorhanden';
return { name: name.slice(0, 80), description: description.slice(0, 500), status };
}
function savePatchImage({ buf, ext }) {
mkdirSync(PATCH_DIR, { recursive: true });
const file = `${randomUUID()}.${ext}`;
writeFileSync(join(PATCH_DIR, file), buf);
return file;
}
function removePatchImage(file) {
try { unlinkSync(join(PATCH_DIR, file)); } catch { /* schon weg */ }
}
function cleanMeetingPoint(body) {
const name = String(body?.name ?? '').trim();
if (!name) throw new BadRequest('Name des Treffpunkts fehlt.');
const address = String(body.address ?? '').trim();
if (!address) throw new BadRequest('Adresse fehlt.');
return { name: name.slice(0, 60), address: address.slice(0, 200) };
}
function cleanPlaysCount(value) {
return Math.min(999, Math.max(-999, parseInt(value, 10) || 0));
}
// Manuelle Korrekturen pro Jahr: { "2026": 2, ... }
function cleanPlaysManual(value) {
const out = {};
if (value && typeof value === 'object') {
for (const [year, n] of Object.entries(value)) {
if (/^\d{4}$/.test(year) && cleanPlaysCount(n)) out[year] = cleanPlaysCount(n);
}
}
return out;
}
function memberIds() {
return new Set(db.members.map((m) => m.id));
}
function findOr404(list, id, res, what) {
const item = list.find((x) => x.id === id);
if (!item) res.status(404).json({ error: `${what} nicht gefunden.` });
return item;
}
// ---------- App ----------
const app = express();
app.disable('x-powered-by');
// Hinter nginx: echte Client-IP und HTTPS erkennen. Standard: Proxy auf demselben Rechner.
// Im Docker-Container kommt nginx über das Docker-Netz → TRUST_PROXY="loopback, uniquelocal"
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback');
// Patch-Uploads enthalten ein Bild, alles andere bleibt klein
app.use('/api/patches', express.json({ limit: '12mb' }));
app.use(express.json({ limit: '1mb' }));
// Öffentlich: Startseite, Login, CSS/Bilder
app.use(express.static(join(ROOT, 'public'), { extensions: ['html'] }));
authRoutes(app);
// Patch-Bilder sind öffentlich (Dateinamen sind zufällige IDs)
app.use('/patch-img', express.static(PATCH_DIR, {
maxAge: '30d',
immutable: true,
setHeaders: (res, path) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
// SVG direkt aufgerufen: keine Skripte, keine fremden Inhalte
if (path.endsWith('.svg')) res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox");
},
}));
// Nächste Spieltage für die Startseite – nur Datum, Titel und Anzahl der Zusagen
function todayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
app.get('/api/upcoming', (req, res) => {
const today = todayIso();
res.json(db.events
.filter((e) => e.date >= today)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 3)
.map((e) => ({ date: e.date, title: e.title, time: e.time, isGame: e.isGame, attendeeCount: e.attendees.length })));
});
// ---------- Jahresstatistik ----------
// Spiele pro Mitglied (ohne Externe) in einem Jahr: Spiel-Events mit Zusage bis heute + manuelle Korrektur.
// Termine ohne Spiel (Treffen, Grillen, …) zählen nicht.
function yearStats(year) {
const today = todayIso();
const fromCalendar = new Map();
for (const ev of db.events) {
if (!ev.isGame || !ev.date.startsWith(`${year}-`) || ev.date > today) continue;
for (const id of ev.attendees) fromCalendar.set(id, (fromCalendar.get(id) || 0) + 1);
}
return db.members.filter((m) => !m.external).map((m) => {
const calendar = fromCalendar.get(m.id) || 0;
const manual = m.playsManual?.[year] || 0;
return { id: m.id, name: m.name, calendar, manual, total: Math.max(0, calendar + manual) };
}).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name, 'de'));
}
// Abgeschlossene Jahre werden einmalig eingefroren; das neue Jahr startet automatisch bei 0
function archiveFinishedYears() {
const current = new Date().getFullYear();
const years = new Set(db.events.map((e) => +e.date.slice(0, 4)));
for (const m of db.members) for (const y of Object.keys(m.playsManual || {})) years.add(+y);
let changed = false;
for (const year of [...years].sort()) {
if (year >= current || db.statsArchive.some((a) => a.year === year)) continue;
db.statsArchive.push({ year, savedAt: new Date().toISOString(), entries: yearStats(year) });
changed = true;
console.log(`Jahresstatistik ${year} archiviert.`);
}
if (changed) {
db.statsArchive.sort((a, b) => b.year - a.year);
saveDb();
}
}
archiveFinishedYears();
setInterval(archiveFinishedYears, 60 * 60 * 1000);
// ?year=2025 → archiviertes Jahr, sonst das laufende Jahr (live)
app.get('/api/stats', (req, res) => {
archiveFinishedYears();
const current = new Date().getFullYear();
const years = [current, ...db.statsArchive.map((a) => a.year)];
const year = parseInt(req.query.year, 10) || current;
if (year === current) return res.json({ year, live: true, years, entries: yearStats(year) });
const archived = db.statsArchive.find((a) => a.year === year);
if (!archived) return res.status(404).json({ error: `Für ${year} gibt es keine Statistik.` });
res.json({ year, live: false, years, savedAt: archived.savedAt, entries: archived.entries });
});
// ---------- Patches ----------
const sortedPatches = () => [...db.patches].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
app.get('/api/patches', (req, res) => {
res.json(sortedPatches());
});
// Ab hier nur mit Login
app.use(requireAuth);
app.post('/api/patches', (req, res) => {
const fields = cleanPatch(req.body);
const image = savePatchImage(cleanImage(req.body.image));
const patch = { id: randomUUID(), ...fields, image, createdAt: new Date().toISOString() };
db.patches.push(patch);
saveDb();
res.status(201).json(patch);
});
// Bild ist optional – ohne Bild werden nur Name und Beschreibung geändert
app.put('/api/patches/:id', (req, res) => {
const patch = findOr404(db.patches, req.params.id, res, 'Patch');
if (!patch) return;
const fields = cleanPatch(req.body);
if (req.body.image) {
const old = patch.image;
patch.image = savePatchImage(cleanImage(req.body.image));
removePatchImage(old);
}
Object.assign(patch, fields);
saveDb();
res.json(patch);
});
app.delete('/api/patches/:id', (req, res) => {
const patch = db.patches.find((p) => p.id === req.params.id);
if (patch) removePatchImage(patch.image);
db.patches = db.patches.filter((p) => p.id !== req.params.id);
saveDb();
res.status(204).end();
});
app.use(express.static(join(ROOT, 'private'), {
extensions: ['html'],
setHeaders: (res) => res.setHeader('Cache-Control', 'private, no-cache'),
}));
// Mitglieder
app.get('/api/members', (req, res) => {
res.json(db.members);
});
app.post('/api/members', (req, res) => {
const member = { id: randomUUID(), ...cleanMember(req.body, { memberIds: memberIds() }) };
db.members.push(member);
saveDb();
res.status(201).json(member);
});
app.put('/api/members/:id', (req, res) => {
const member = findOr404(db.members, req.params.id, res, 'Mitglied');
if (!member) return;
Object.assign(member, cleanMember(req.body, { memberIds: memberIds(), selfId: member.id }));
saveDb();
res.json(member);
});
app.delete('/api/members/:id', (req, res) => {
const { id } = req.params;
db.members = db.members.filter((m) => m.id !== id);
for (const m of db.members) if (m.ridesWith === id) m.ridesWith = null;
for (const ev of db.events) ev.attendees = ev.attendees.filter((a) => a !== id);
saveDb();
res.status(204).end();
});
// Manuelle Korrektur der gespielten Spiele (z. B. spontane Fahrten, die nicht im Kalender stehen)
app.put('/api/members/:id/plays-manual', (req, res) => {
const member = findOr404(db.members, req.params.id, res, 'Mitglied');
if (!member) return;
const year = new Date().getFullYear();
member.playsManual = { ...member.playsManual, [year]: cleanPlaysCount(req.body?.manual) };
saveDb();
res.json({ id: member.id, year, manual: member.playsManual[year] });
});
// Ersetzt die komplette Gruppe (Import aus JSON-Datei)
app.post('/api/members/import', (req, res) => {
if (!Array.isArray(req.body)) throw new BadRequest('Erwarte eine Liste von Mitgliedern.');
const rows = req.body
.filter((m) => String(m?.name ?? '').trim())
.map((m) => ({ ...m, id: typeof m.id === 'string' && m.id ? m.id : randomUUID() }));
const ids = new Set(rows.map((m) => m.id));
const imported = rows.map((m) => ({
id: m.id,
...cleanMember(m, { memberIds: ids, selfId: m.id }),
playsManual: cleanPlaysManual(m.playsManual),
}));
db.members = imported;
for (const ev of db.events) ev.attendees = ev.attendees.filter((a) => ids.has(a));
saveDb();
res.json(db.members);
});
// Treffpunkte
app.get('/api/meeting-points', (req, res) => {
res.json([...db.meetingPoints].sort((a, b) => a.name.localeCompare(b.name, 'de')));
});
app.post('/api/meeting-points', (req, res) => {
const point = { id: randomUUID(), ...cleanMeetingPoint(req.body) };
db.meetingPoints.push(point);
saveDb();
res.status(201).json(point);
});
app.put('/api/meeting-points/:id', (req, res) => {
const point = findOr404(db.meetingPoints, req.params.id, res, 'Treffpunkt');
if (!point) return;
Object.assign(point, cleanMeetingPoint(req.body));
saveDb();
res.json(point);
});
app.delete('/api/meeting-points/:id', (req, res) => {
db.meetingPoints = db.meetingPoints.filter((p) => p.id !== req.params.id);
for (const m of db.members) if (m.meetingPoint === req.params.id) m.meetingPoint = null;
saveDb();
res.status(204).end();
});
// Spieltage
app.get('/api/events', (req, res) => {
res.json([...db.events].sort((a, b) => a.date.localeCompare(b.date)));
});
// Kalenderdateien (.ics) – alle kommenden Termine oder ein einzelner
app.get('/api/calendar.ics', (req, res) => {
const today = todayIso();
sendIcs(res, 'hcrb-termine.ics', db.events.filter((e) => e.date >= today).sort((a, b) => a.date.localeCompare(b.date)));
});
app.get('/api/events/:id/ics', (req, res) => {
const ev = findOr404(db.events, req.params.id, res, 'Termin');
if (ev) sendIcs(res, `hcrb-${ev.date}.ics`, [ev]);
});
app.get('/api/events/:id', (req, res) => {
const ev = findOr404(db.events, req.params.id, res, 'Termin');
if (ev) res.json(ev);
});
app.post('/api/events', (req, res) => {
const createdBy = db.members.some((m) => m.id === req.body?.createdBy) ? req.body.createdBy : null;
const ev = {
id: randomUUID(),
...cleanEvent(req.body),
createdBy,
attendees: createdBy ? [createdBy] : [],
};
db.events.push(ev);
saveDb();
res.status(201).json(ev);
});
app.put('/api/events/:id', (req, res) => {
const ev = findOr404(db.events, req.params.id, res, 'Termin');
if (!ev) return;
Object.assign(ev, cleanEvent(req.body));
saveDb();
res.json(ev);
});
app.delete('/api/events/:id', (req, res) => {
db.events = db.events.filter((e) => e.id !== req.params.id);
saveDb();
res.status(204).end();
});
// Zusagen / Absagen
app.put('/api/events/:id/attendees/:memberId', (req, res) => {
const ev = findOr404(db.events, req.params.id, res, 'Termin');
if (!ev) return;
if (!findOr404(db.members, req.params.memberId, res, 'Mitglied')) return;
if (!ev.attendees.includes(req.params.memberId)) ev.attendees.push(req.params.memberId);
saveDb();
res.json(ev);
});
app.delete('/api/events/:id/attendees/:memberId', (req, res) => {
const ev = findOr404(db.events, req.params.id, res, 'Termin');
if (!ev) return;
ev.attendees = ev.attendees.filter((a) => a !== req.params.memberId);
saveDb();
res.json(ev);
});
// ---------- iCalendar ----------
function icsText(s) {
return String(s).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
}
// Zeilen länger als 75 Byte werden umgebrochen (RFC 5545), ohne UTF-8-Zeichen zu zerteilen
function icsFold(line) {
const out = [];
let cur = '';
let bytes = 0;
for (const ch of line) {
const n = Buffer.byteLength(ch);
if (bytes + n > (out.length ? 74 : 75)) {
out.push(cur);
cur = '';
bytes = 0;
}
cur += ch;
bytes += n;
}
out.push(cur);
return out.join('\r\n ');
}
const compactDate = (iso) => iso.replaceAll('-', '');
const compactTime = (t) => t.replace(':', '') + '00';
function shiftDate(iso, days) {
const [y, m, d] = iso.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1, d + days));
return dt.toISOString().slice(0, 10);
}
const VTIMEZONE_BERLIN = [
'BEGIN:VTIMEZONE', 'TZID:Europe/Berlin',
'BEGIN:DAYLIGHT', 'TZOFFSETFROM:+0100', 'TZOFFSETTO:+0200', 'TZNAME:CEST',
'DTSTART:19700329T020000', 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', 'END:DAYLIGHT',
'BEGIN:STANDARD', 'TZOFFSETFROM:+0200', 'TZOFFSETTO:+0100', 'TZNAME:CET',
'DTSTART:19701025T030000', 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', 'END:STANDARD',
'END:VTIMEZONE',
];
function icsEvent(ev, stamp) {
const names = ev.attendees.map((id) => db.members.find((m) => m.id === id)?.name).filter(Boolean)
.sort((a, b) => a.localeCompare(b, 'de'));
const description = [ev.note, names.length ? `Zusagen (${names.length}): ${names.join(', ')}` : '']
.filter(Boolean).join('\n\n');
const lines = ['BEGIN:VEVENT', `UID:${ev.id}@hcrb`, `DTSTAMP:${stamp}`];
if (ev.time) {
// Ohne Ende: 6 Stunden, höchstens bis Mitternacht
const [h, m] = ev.time.split(':').map(Number);
const end = ev.endTime && ev.endTime > ev.time
? ev.endTime
: `${String(Math.min(23, h + 6)).padStart(2, '0')}:${h + 6 > 23 ? '59' : String(m).padStart(2, '0')}`;
lines.push(`DTSTART;TZID=Europe/Berlin:${compactDate(ev.date)}T${compactTime(ev.time)}`);
lines.push(`DTEND;TZID=Europe/Berlin:${compactDate(ev.date)}T${compactTime(end)}`);
} else {
lines.push(`DTSTART;VALUE=DATE:${compactDate(ev.date)}`);
lines.push(`DTEND;VALUE=DATE:${compactDate(shiftDate(ev.date, 1))}`);
}
lines.push(`SUMMARY:${icsText(ev.title)}`);
if (description) lines.push(`DESCRIPTION:${icsText(description)}`);
lines.push(`CATEGORIES:${ev.isGame ? 'Airsoft,Spiel' : 'HCRB'}`, 'END:VEVENT');
return lines;
}
function sendIcs(res, filename, events) {
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
const lines = [
'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//HCRB//Plan-Tool//DE', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH',
'X-WR-CALNAME:HCRB Termine', 'X-WR-TIMEZONE:Europe/Berlin',
...(events.some((e) => e.time) ? VTIMEZONE_BERLIN : []),
...events.flatMap((ev) => icsEvent(ev, stamp)),
'END:VCALENDAR',
];
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(lines.map(icsFold).join('\r\n') + '\r\n');
}
app.use('/api', (req, res) => {
res.status(404).json({ error: 'Unbekannte Anfrage.' });
});
app.use((err, req, res, next) => {
if (err instanceof BadRequest) return res.status(400).json({ error: err.message });
console.error(err);
res.status(500).json({ error: 'Serverfehler.' });
});
app.listen(PORT, HOST, () => {
console.log(`HCRB läuft auf http://${HOST || 'localhost'}:${PORT}`);
});