Files
guyg1474andClaude Opus 5.5 819219fb2d Rollen (Admin/Mitglied), Seite Kooperationen, Textanpassungen
- Gemeinsame Zugänge mit Rollen: Admin darf alles, Mitglied nutzt Kalender,
  Fahrtplaner, Treffpunkte und bearbeitet Mitglieder (kein Hinzufügen,
  Löschen, Import, keine Patches, keine Statistik-Korrektur)
- Server prüft die Rechte (403), Konten ohne Rolle gelten als Admin
- npm run user: add <name> [admin|mitglied], role <name> <rolle>
- Neue öffentliche Seite Kooperationen mit Titelbild, Anführer,
  Social-Media-Links und Beschreibung, Pflege durch Admin
- Kopfzeile bleibt bei mittlerer Breite einzeilig
- Texte auf Wunsch gekürzt

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

677 lines
26 KiB
JavaScript
Raw Permalink 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, createHash } from 'node:crypto';
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, unlinkSync, readdirSync, statSync } from 'node:fs';
import { dirname, join, extname, normalize, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { authRoutes, requireAuth, requireAdmin } 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 KOOP_DIR = join(DATA_DIR, 'kooperationen');
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: [], cooperations: [] };
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 })),
cooperations: db.cooperations || [],
};
}
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 };
}
// Hochgeladene Bilder bekommen einen zufälligen Namen (kein Raten, keine Pfadtricks)
function saveImage(dir, { buf, ext }) {
mkdirSync(dir, { recursive: true });
const file = `${randomUUID()}.${ext}`;
writeFileSync(join(dir, file), buf);
return file;
}
function removeImage(dir, file) {
if (!file) return;
try { unlinkSync(join(dir, file)); } catch { /* schon weg */ }
}
// ---------- Kooperationen ----------
const SOCIALS = ['website', 'instagram', 'youtube', 'tiktok', 'facebook', 'discord'];
function cleanUrl(value, label) {
const raw = String(value ?? '').trim();
if (!raw) return null;
// "instagram.com/xyz" ohne https:// ist bequem – wird ergänzt
const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
let url;
try { url = new URL(withScheme); } catch { throw new BadRequest(`${label}: keine gültige Adresse.`); }
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new BadRequest(`${label}: nur http(s)-Adressen.`);
return url.href.slice(0, 300);
}
function cleanCooperation(body) {
const name = String(body?.name ?? '').trim();
if (!name) throw new BadRequest('Name der Gruppe fehlt.');
const leader = String(body.leader ?? '').trim();
const description = String(body.description ?? '').trim();
const socials = {};
for (const key of SOCIALS) {
const url = cleanUrl(body.socials?.[key], key[0].toUpperCase() + key.slice(1));
if (url) socials[key] = url;
}
return { name: name.slice(0, 80), leader: leader.slice(0, 120), description: description.slice(0, 2000), socials };
}
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;
}
// ---------- Versionierte Seiten ----------
// Proxys (z. B. Nginx Proxy Manager mit "Cache Assets") und Browser halten CSS/JS lange vor. Damit nach einem
// Update nie alte Dateien mit neuen gemischt werden, hängt der Server an jede CSS/JS-Adresse in HTML und an
// jeden Modul-Import ?v=<Prüfsumme aller CSS/JS-Dateien> an. HTML und JS selbst werden immer neu geprüft.
const ASSET_DIRS = ['public/css', 'public/js', 'private/js'].map((d) => join(ROOT, d));
let assetVersion = { key: '', value: '' };
function currentAssetVersion() {
const files = ASSET_DIRS.flatMap((dir) => (existsSync(dir) ? readdirSync(dir).map((f) => join(dir, f)) : []))
.filter((f) => /\.(css|js)$/.test(f)).sort();
// Nur neu rechnen, wenn sich Dateien geändert haben (Änderungszeit + Größe)
const key = files.map((f) => { const st = statSync(f); return `${f}:${st.mtimeMs}:${st.size}`; }).join('|');
if (key !== assetVersion.key) {
const hash = createHash('sha1');
for (const f of files) hash.update(readFileSync(f));
assetVersion = { key, value: hash.digest('hex').slice(0, 10) };
}
return assetVersion.value;
}
function versioned(dir, cacheControl) {
return (req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
let path;
try { path = decodeURIComponent(req.path); } catch { return next(); }
if (path.endsWith('/')) path += 'index.html';
let file = normalize(join(dir, path));
if (!file.startsWith(dir + sep)) return next();
if (!extname(file)) file += '.html';
if (!/\.(html|js)$/.test(file) || !existsSync(file) || !statSync(file).isFile()) return next();
const v = currentAssetVersion();
const text = readFileSync(file, 'utf8')
.replace(/((?:href|src)="\/(?:css|js)\/[\w.-]+\.(?:css|js))"/g, `$1?v=${v}"`)
.replace(/(from\s+'\.\/[\w.-]+\.js)'/g, `$1?v=${v}'`);
res.type(extname(file));
res.setHeader('Cache-Control', cacheControl);
res.send(text);
};
}
// ---------- 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', '/api/kooperationen'], express.json({ limit: '12mb' }));
app.use(express.json({ limit: '1mb' }));
// Öffentlich: Startseite, Login, CSS/Bilder
app.use(versioned(join(ROOT, 'public'), 'no-cache'));
app.use(express.static(join(ROOT, 'public'), { extensions: ['html'] }));
authRoutes(app);
// Hochgeladene Bilder sind öffentlich (Dateinamen sind zufällige IDs)
const imageStatic = (dir) => express.static(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");
},
});
app.use('/patch-img', imageStatic(PATCH_DIR));
app.use('/koop-img', imageStatic(KOOP_DIR));
// 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());
});
const sortedCooperations = () => [...db.cooperations].sort((a, b) => a.name.localeCompare(b.name, 'de'));
app.get('/api/kooperationen', (req, res) => {
res.json(sortedCooperations());
});
// Ab hier nur mit Login
app.use(requireAuth);
app.post('/api/patches', requireAdmin, (req, res) => {
const fields = cleanPatch(req.body);
const image = saveImage(PATCH_DIR, 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', requireAdmin, (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 = saveImage(PATCH_DIR, cleanImage(req.body.image));
removeImage(PATCH_DIR, old);
}
Object.assign(patch, fields);
saveDb();
res.json(patch);
});
app.delete('/api/patches/:id', requireAdmin, (req, res) => {
const patch = db.patches.find((p) => p.id === req.params.id);
if (patch) removeImage(PATCH_DIR, patch.image);
db.patches = db.patches.filter((p) => p.id !== req.params.id);
saveDb();
res.status(204).end();
});
// Kooperationen: anlegen, ändern, löschen nur Admin. Titelbild ist optional.
app.post('/api/kooperationen', requireAdmin, (req, res) => {
const fields = cleanCooperation(req.body);
const image = req.body.image ? saveImage(KOOP_DIR, cleanImage(req.body.image)) : null;
const coop = { id: randomUUID(), ...fields, image, createdAt: new Date().toISOString() };
db.cooperations.push(coop);
saveDb();
res.status(201).json(coop);
});
// image: neue data:-URL ersetzt das Bild, removeImage: true entfernt es, sonst bleibt es
app.put('/api/kooperationen/:id', requireAdmin, (req, res) => {
const coop = findOr404(db.cooperations, req.params.id, res, 'Kooperation');
if (!coop) return;
const fields = cleanCooperation(req.body);
if (req.body.image) {
const old = coop.image;
coop.image = saveImage(KOOP_DIR, cleanImage(req.body.image));
removeImage(KOOP_DIR, old);
} else if (req.body.removeImage) {
removeImage(KOOP_DIR, coop.image);
coop.image = null;
}
Object.assign(coop, fields);
saveDb();
res.json(coop);
});
app.delete('/api/kooperationen/:id', requireAdmin, (req, res) => {
const coop = db.cooperations.find((c) => c.id === req.params.id);
if (coop) removeImage(KOOP_DIR, coop.image);
db.cooperations = db.cooperations.filter((c) => c.id !== req.params.id);
saveDb();
res.status(204).end();
});
app.use(versioned(join(ROOT, 'private'), 'private, no-cache'));
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', requireAdmin, (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', requireAdmin, (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', requireAdmin, (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', requireAdmin, (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}`);
});