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>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-12 00:13:52 +02:00
1 parent 43356a120c
commit 486e6f6784
23 files changed
+1757 -387

No files matched your search

+235 -7
View File
@@ -1,6 +1,6 @@
import express from 'express';
import { randomUUID } from 'node:crypto';
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
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';
@@ -8,23 +8,28 @@ 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: [] };
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: db.events || [],
events,
meetingPoints: db.meetingPoints || [],
statsArchive: db.statsArchive || [],
// Ältere Patches haben keinen Status – die gab es schon
patches: (db.patches || []).map((p) => ({ status: 'vorhanden', ...p })),
};
}
@@ -59,7 +64,85 @@ function cleanEvent(body) {
const title = String(body.title ?? '').trim();
if (!title) throw new BadRequest('Titel fehlt.');
const note = String(body.note ?? '').trim();
return { date, title: title.slice(0, 80), note: note.slice(0, 500) };
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) {
@@ -97,15 +180,29 @@ function findOr404(list, id, res, what) {
// ---------- 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();
@@ -118,16 +215,17 @@ app.get('/api/upcoming', (req, res) => {
.filter((e) => e.date >= today)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 3)
.map((e) => ({ date: e.date, title: e.title, attendeeCount: e.attendees.length })));
.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: Spieltage mit Zusage bis heute + manuelle Korrektur
// 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.date.startsWith(`${year}-`) || ev.date > today) continue;
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) => {
@@ -170,8 +268,47 @@ app.get('/api/stats', (req, res) => {
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'),
@@ -266,6 +403,17 @@ 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);
@@ -316,6 +464,86 @@ app.delete('/api/events/:id/attendees/:memberId', (req, res) => {
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.' });
});