mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-15 23:13:28 +02:00
- 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>
677 lines
26 KiB
JavaScript
677 lines
26 KiB
JavaScript
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}`);
|
||
});
|