From 819219fb2d629cdc16cd0797c85d6d396222da19 Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:47:03 +0200 Subject: [PATCH] Rollen (Admin/Mitglied), Seite Kooperationen, Textanpassungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 [admin|mitglied], role - 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 --- DEPLOY.md | 8 +- README.md | 39 +++++-- auth.js | 29 ++++-- private/fahrt.html | 2 +- private/gruppe.html | 7 +- private/js/gruppe.js | 26 ++++- private/kalender.html | 5 +- public/css/style.css | 83 ++++++++++++++- public/index.html | 1 - public/js/common.js | 40 ++++++- public/js/kooperationen.js | 207 +++++++++++++++++++++++++++++++++++++ public/js/patches.js | 37 ++----- public/js/statistik.js | 12 +-- public/kooperationen.html | 106 +++++++++++++++++++ public/patches.html | 2 +- public/statistik.html | 3 +- scripts/user.js | 41 ++++++-- server.js | 119 +++++++++++++++++---- 18 files changed, 661 insertions(+), 106 deletions(-) create mode 100644 public/js/kooperationen.js create mode 100644 public/kooperationen.html diff --git a/DEPLOY.md b/DEPLOY.md index 0dfb7d5..6f33309 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -87,10 +87,11 @@ docker compose ps # Status sollte nach ca. 30 Sekunden "Up … (healt Ohne übernommene Daten jetzt den ersten Login anlegen (Passwort wird verdeckt abgefragt): ```bash -docker compose exec hcrb npm run user -- add elias +docker compose exec hcrb npm run user -- add admin admin # Admin-Zugang +docker compose exec hcrb npm run user -- add hcrb # gemeinsamer Zugang für alle Mitglieder ``` -Prüfen, ob die Seite läuft (sollte `{"user":null}` ausgeben – Server-IP und ggf. Port anpassen): +Prüfen, ob die Seite läuft (sollte `{"user":null,"role":null}` ausgeben – Server-IP und ggf. Port anpassen): ```bash curl -s http://192.168.178.20:3000/api/me @@ -173,7 +174,8 @@ Alle im Ordner `~/festplatte/dockercontainer/hcrb` ausführen: | Log ansehen | `docker compose logs -f` | | Neu starten | `docker compose restart` | | Stoppen / Starten | `docker compose down` / `docker compose up -d` | -| Benutzer anlegen | `docker compose exec hcrb npm run user -- add ` | +| Benutzer anlegen | `docker compose exec hcrb npm run user -- add [admin\|mitglied]` | +| Rolle ändern | `docker compose exec hcrb npm run user -- role ` | | Passwort ändern | `docker compose exec hcrb npm run user -- passwd ` | | Benutzer auflisten | `docker compose exec hcrb npm run user -- list` | | Backup zurückspielen | `docker compose down && sudo tar -xzf /var/backups/hcrb/hcrb-DATUM.tar.gz -C ~/festplatte/dockercontainer/hcrb && sudo chown -R 1000:1000 data && docker compose up -d` | diff --git a/README.md b/README.md index 976021d..490f915 100644 --- a/README.md +++ b/README.md @@ -38,26 +38,47 @@ Anderen Port wählen: `PORT=8080 npm start`. Die Startseite zeigt als „Pile of Shame“, wer im laufenden Jahr noch nicht spielen war. Der Jahreswechsel richtet sich nach der Zeitzone des Servers (im systemd-Dienst `Environment=TZ=Europe/Berlin` setzen). - **Patches** (`/patches`, öffentlich) – Galerie der Gruppen- und Event-Patches, getrennt nach „Gibt es schon“ und „In Planung“. - Angemeldet: Patch hochladen (PNG, JPEG oder WebP – wird im Browser auf max. 1600 px verkleinert – oder SVG bis 2 MB), + Admin-Zugang: Patch hochladen (PNG, JPEG oder WebP – wird im Browser auf max. 1600 px verkleinert – oder SVG bis 2 MB), bearbeiten, löschen. SVGs mit Skripten, Ereignis-Attributen oder Verweisen auf fremde Dateien werden abgelehnt und zusätzlich mit einer Content-Security-Policy ausgeliefert. Die Bilder liegen in `data/patches/` und sind damit im Docker-Volume und im Backup enthalten. +- **Kooperationen** (`/kooperationen`, öffentlich) – Gruppen, mit denen die HCRB zusammenarbeitet. Übersicht als Karten, + jede Kooperation hat eine eigene Ansicht (`/kooperationen#`) mit Titelbild, Anführer, Social-Media-Links + (Website, Instagram, YouTube, TikTok, Facebook, Discord) und Beschreibung. Anlegen, bearbeiten, löschen nur mit dem + Admin-Zugang. Titelbilder liegen in `data/kooperationen/`. - **Gruppe** (`/gruppe`) – feste Mitglieder (Vorname, extern ja/nein, Auto ja/nein, freie Plätze ohne Fahrer, „kommt immer mit eigenem Auto“, Standard-Treffpunkt als Fahrer, „fährt am liebsten mit bei …“) und Treffpunkte (Name, Adresse) verwalten, Export/Import der Mitglieder als JSON. + Hinzufügen, Löschen und Import nur mit dem Admin-Zugang. ## Login Öffentlich sind nur die Startseite (inkl. der nächsten 3 Termine: Datum, Uhrzeit, Titel, Anzahl Zusagen), die Statistik -(Namen und Anzahl gespielter Spieltage) und die Patches. +(Namen und Anzahl gespielter Spieltage), die Patches und die Kooperationen. Kalender, Fahrtplaner, Gruppe und die komplette API sind nur nach dem Anmelden erreichbar. +Die Zugänge sind gemeinsame Konten, keine persönlichen. Es gibt zwei Rollen: + +| | Admin | Mitglied | +| --- | --- | --- | +| Kalender: Termine eintragen, bearbeiten, löschen, zusagen | ✓ | ✓ | +| Fahrtplaner, Treffpunkte | ✓ | ✓ | +| Mitglieder bearbeiten, Gruppe exportieren | ✓ | ✓ | +| Mitglieder hinzufügen, löschen, Gruppe importieren | ✓ | – | +| Patches hochladen, bearbeiten, löschen | ✓ | – | +| Kooperationen anlegen, bearbeiten, löschen | ✓ | – | +| Statistik von Hand korrigieren (− / +) | ✓ | – | + +Typisch: ein Konto `admin` und ein Konto für alle Mitglieder, z. B. `hcrb`. +Konten aus der Zeit vor den Rollen gelten als Admin, bis man ihre Rolle ändert. + Benutzer werden auf dem Server per Kommandozeile verwaltet: ```bash -npm run user -- list # alle Benutzer anzeigen -npm run user -- add # Benutzer anlegen -npm run user -- passwd # Passwort ändern (meldet den Benutzer überall ab) -npm run user -- remove # Benutzer löschen (sofort abgemeldet) +npm run user -- list # alle Benutzer mit Rolle anzeigen +npm run user -- add [admin|mitglied] # Benutzer anlegen (Standard: mitglied) +npm run user -- role # Rolle ändern (meldet den Benutzer überall ab) +npm run user -- passwd # Passwort ändern (meldet den Benutzer überall ab) +npm run user -- remove # Benutzer löschen (sofort abgemeldet) ``` Sicherheit: @@ -71,11 +92,11 @@ Sicherheit: ## Aufbau ``` -server.js Express-Server + REST-API (/api/members, /api/events, /api/meeting-points, /api/patches, .ics-Export) +server.js Express-Server + REST-API (/api/members, /api/events, /api/meeting-points, /api/patches, /api/kooperationen, .ics-Export) auth.js Login, Passwort-Hashing, Sitzungen scripts/user.js Benutzerverwaltung (npm run user) -data/ db.json, users.json, sessions.json, patches/ (automatisch angelegt, nicht in Git) -public/ öffentlich: Startseite, Statistik, Patches, Login, CSS, Bilder, Schriften, common.js +data/ db.json, users.json, sessions.json, patches/, kooperationen/ (automatisch angelegt, nicht in Git) +public/ öffentlich: Startseite, Statistik, Patches, Kooperationen, Login, CSS, Bilder, Schriften, common.js private/ nur angemeldet: Kalender, Fahrtplaner, Gruppe ``` diff --git a/auth.js b/auth.js index 2c448be..3b8fce7 100644 --- a/auth.js +++ b/auth.js @@ -2,6 +2,10 @@ // // Passwörter werden nie im Klartext gespeichert, sondern mit scrypt (eigenes Salt pro Passwort) gehasht. // Sitzungs-Tokens liegen nur im Cookie des Browsers; auf dem Server wird lediglich ihr SHA-256-Hash gespeichert. +// +// Rollen: Die Konten sind gemeinsame Zugänge, keine persönlichen. +// admin – alles, inkl. Mitglieder hinzufügen/löschen, Gruppe importieren, Patches, Statistik-Korrektur +// mitglied – Kalender, Fahrtplaner, Treffpunkte, Mitglieder bearbeiten (nicht hinzufügen/löschen) import { scrypt, randomBytes, timingSafeEqual, createHash } from 'node:crypto'; import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, statSync } from 'node:fs'; @@ -17,6 +21,10 @@ const SESSION_DAYS = 30; const MAX_FAILS = 10; // Fehlversuche pro IP … const FAIL_WINDOW = 15 * 60 * 1000; // … innerhalb von 15 Minuten export const MIN_PASSWORD = 8; +export const ROLES = ['admin', 'mitglied']; + +// Konten aus der Zeit vor den Rollen wurden vom Betreiber angelegt → Admin, damit sich niemand aussperrt +export const roleOf = (user) => (ROLES.includes(user?.role) ? user.role : 'admin'); // ---------- Passwort-Hashing (scrypt) ---------- const SCRYPT = { N: 2 ** 15, r: 8, p: 1, keylen: 64 }; @@ -105,7 +113,7 @@ function setCookie(req, res, value, maxAgeSec) { res.setHeader('Set-Cookie', attrs.join('; ')); } -// Liefert den angemeldeten Benutzernamen oder null +// Liefert den angemeldeten Benutzer ({ name, role }) oder null function sessionUser(req) { const token = readCookie(req, COOKIE); if (!token) return null; @@ -114,7 +122,7 @@ function sessionUser(req) { // Benutzer gelöscht oder Passwort seit der Anmeldung geändert → Sitzung ungültig const user = currentUsers().find((u) => u.name === s.user); if (!user || s.created < (user.changedAt || 0)) return null; - return user.name; + return { name: user.name, role: roleOf(user) }; } // ---------- Schutz vor Passwort-Raten ---------- @@ -156,7 +164,7 @@ export function authRoutes(app) { }); saveSessions(); setCookie(req, res, token, SESSION_DAYS * 86400); - res.json({ user: user.name }); + res.json({ user: user.name, role: roleOf(user) }); } catch (err) { next(err); } @@ -170,15 +178,24 @@ export function authRoutes(app) { }); app.get('/api/me', (req, res) => { - res.json({ user: sessionUser(req) }); + const user = sessionUser(req); + res.json({ user: user?.name ?? null, role: user?.role ?? null }); }); } // Alles, was nach dieser Middleware kommt, ist nur angemeldet erreichbar export function requireAuth(req, res, next) { - req.user = sessionUser(req); - if (req.user) return next(); + const user = sessionUser(req); + req.user = user?.name ?? null; + req.role = user?.role ?? null; + if (user) return next(); if (req.path.startsWith('/api/')) return res.status(401).json({ error: 'Bitte anmelden.' }); if (req.method === 'GET') return res.redirect(`/login?next=${encodeURIComponent(req.originalUrl)}`); res.status(401).end(); } + +// Nur für Admins (nach requireAuth verwenden) +export function requireAdmin(req, res, next) { + if (req.role === 'admin') return next(); + res.status(403).json({ error: 'Das darf nur der Admin-Zugang.' }); +} diff --git a/private/fahrt.html b/private/fahrt.html index 3eb0425..faf21fe 100644 --- a/private/fahrt.html +++ b/private/fahrt.html @@ -79,7 +79,7 @@ - +
    diff --git a/private/gruppe.html b/private/gruppe.html index 5860598..43c7301 100644 --- a/private/gruppe.html +++ b/private/gruppe.html @@ -18,7 +18,7 @@
    -
    +

    Mitglied hinzufügen

    @@ -75,15 +75,16 @@

    Gruppe

    +

      Daten sichern

      -

      Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).

      +

      Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).

      -
      diff --git a/private/js/gruppe.js b/private/js/gruppe.js index 463ec1c..c4bc084 100644 --- a/private/js/gruppe.js +++ b/private/js/gruppe.js @@ -1,7 +1,10 @@ -import { $, api, esc, sortByName, seatsLabel, showError, renderHeader } from './common.js'; +import { $, api, esc, sortByName, seatsLabel, showError, renderHeader, isAdmin } from './common.js'; renderHeader('gruppe'); +// Admin: Mitglieder hinzufügen, löschen, importieren. Mitglied-Zugang: nur bearbeiten. +// Der Server prüft das ebenfalls – hier wird nur ausgeblendet, was ohnehin abgelehnt würde. +let admin = false; let members = []; let points = []; const form = $('#member-form'); @@ -31,6 +34,8 @@ function resetForm() { $('#member-seats-wrap').hidden = true; $('#member-cancel').hidden = true; $('#member-form-title').textContent = 'Mitglied hinzufügen'; + // Ohne Admin-Rechte gibt es das Formular nur zum Bearbeiten + $('#member-form-card').hidden = !admin; } $('#member-cancel').addEventListener('click', resetForm); @@ -70,8 +75,9 @@ function edit(id) { $('#member-seats-wrap').hidden = !m.hasCar; $('#member-cancel').hidden = false; $('#member-form-title').textContent = 'Mitglied bearbeiten'; - $('#member-name').focus(); - window.scrollTo({ top: 0, behavior: 'smooth' }); + $('#member-form-card').hidden = false; + $('#member-form-card').scrollIntoView({ behavior: 'smooth', block: 'start' }); + $('#member-name').focus({ preventScroll: true }); } async function remove(id) { @@ -98,7 +104,7 @@ function render() { ${m.hasCar ? `Auto · ${seatsLabel(m.seats)}${m.alwaysDrives ? ' · fährt immer' : ''}` : 'kein Auto'} - + ${admin ? `` : ''} `).join(''); } @@ -213,4 +219,14 @@ async function load() { render(); } -load().then(loadPoints).catch(showError); +async function init() { + admin = await isAdmin(); + $('#members-role-hint').hidden = admin; + $('#import-btn').hidden = !admin; + if (!admin) $('#backup-text').textContent = 'Gruppe als JSON-Datei sichern.'; + resetForm(); + await load(); + await loadPoints(); +} + +init().catch(showError); diff --git a/private/kalender.html b/private/kalender.html index 2499c10..7e3c6c5 100644 --- a/private/kalender.html +++ b/private/kalender.html @@ -12,7 +12,6 @@
      Spieltage & Termine

      Kalender

      -

      Nur Termine mit Häkchen bei „Spiel-Event“ zählen für die Statistik.

      @@ -61,7 +60,7 @@
      Spiel-Event - Wer zusagt, bekommt ein Spiel in der Statistik. Für Treffen, Grillen oder Basteltage das Häkchen entfernen. + Wer zusagt, bekommt ein Spiel in der Statistik. Für Treffen oder Grillen das Häkchen entfernen.
      diff --git a/public/css/style.css b/public/css/style.css index 15347e3..567ccc9 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -858,6 +858,84 @@ button.small, .button.small { padding: 5px 11px; font-size: .82rem; } } .empty-state svg { width: 56px; height: 56px; color: var(--border); } +/* ---------- Kooperationen ---------- */ +.coop-grid { display: grid; gap: 20px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); } +.coop-card { + display: flex; + flex-direction: column; + text-decoration: none; + color: var(--text); + background: var(--card); + box-shadow: inset 0 0 0 1px var(--border); + clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 0 100%); + transition: background-color .2s; +} +.coop-card:hover { background: var(--card-2); } +.coop-card-img { + aspect-ratio: 16 / 9; + max-width: 100%; + overflow: hidden; + display: grid; + place-items: center; + background: repeating-linear-gradient(135deg, var(--flag) 0 18px, #303b6a 18px 36px); +} +.coop-card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform .6s var(--ease-out); } +.coop-card:hover .coop-card-img img { transform: scale(1.05); } +.coop-initials { font-family: var(--font-stencil); font-size: 3.4rem; color: var(--accent); letter-spacing: .1em; } +.coop-card-body { padding: 16px 18px 18px; display: grid; gap: 4px; } +.coop-card-body h2 { margin: 0; font-size: 1.4rem; font-weight: 700; } +.coop-card-body p { margin: 0; } +.coop-card .go { margin-top: 8px; font-family: var(--font-head); text-transform: uppercase; letter-spacing: .16em; font-size: .85rem; color: var(--accent); } + +.back-link { display: inline-block; margin-bottom: 14px; font-family: var(--font-head); text-transform: uppercase; letter-spacing: .14em; text-decoration: none; } +.coop-hero { + position: relative; + aspect-ratio: 21 / 9; + max-height: 62vh; + width: 100%; + max-width: 100%; + overflow: hidden; + margin-bottom: 20px; + background: repeating-linear-gradient(135deg, var(--flag) 0 22px, #303b6a 22px 44px); + clip-path: polygon(0 0, calc(100% - 28px) 0, 100% 28px, 100% 100%, 28px 100%, 0 calc(100% - 28px)); +} +.coop-hero img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +/* Abdunklung nur unten, damit der Name auf jedem Foto lesbar bleibt */ +.coop-hero::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(13, 17, 38, .9) 0%, rgba(13, 17, 38, .35) 40%, transparent 70%); + pointer-events: none; +} +.coop-hero.no-image::before { + content: attr(data-initials); + position: absolute; + right: 6%; + top: 50%; + transform: translateY(-50%); + font-family: var(--font-stencil); + font-size: clamp(5rem, 16vw, 12rem); + color: var(--accent); + opacity: .35; +} +.coop-hero-text { position: absolute; left: clamp(16px, 3vw, 40px); right: clamp(16px, 3vw, 40px); bottom: clamp(16px, 3vw, 36px); z-index: 1; } +.coop-hero-text h1 { margin: 0; font-size: clamp(2rem, 6vw, 4.4rem); font-weight: 700; letter-spacing: .03em; text-shadow: 0 4px 24px rgba(0, 0, 0, .5); } +@media (max-width: 600px) { .coop-hero { aspect-ratio: 4 / 3; } } + +.coop-info .card { margin: 0; } +.coop-description { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 70ch; margin: 0; line-height: 1.65; } +.coop-leader { margin: 0; font-family: var(--font-head); font-size: 1.5rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; } +.card h2.spaced { margin-top: 24px; } +.coop-socials { display: flex; flex-wrap: wrap; gap: 8px; } +.coop-socials svg { width: 18px; height: 18px; } +.admin-actions { margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--border); } +.socials-fields { border: none; padding: 0; margin: 4px 0 8px; } +.socials-fields legend { font-size: .94rem; color: var(--muted); padding: 0; margin-bottom: 6px; } +.socials-fields .grid2 { gap: 0 12px; } +.dropzone.wide { min-height: 220px; } +.dropzone.wide img { max-height: 260px; width: 100%; object-fit: cover; } + /* ---------- Fahrtplaner ---------- */ .cars { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; margin-top: 14px; } .car-meet { margin: 10px 0 4px; padding-top: 10px; border-top: 1px solid var(--border); } @@ -901,7 +979,10 @@ footer .f-brand img { width: 32px; height: 32px; } /* ---------- Responsiv ---------- */ .nav-toggle { display: none; } -@media (max-width: 960px) { +/* Mittlere Breite: Zusatz neben dem Logo weg, damit alle Menüpunkte in eine Zeile passen */ +@media (max-width: 1280px) { .brand small { display: none; } } + +@media (max-width: 1040px) { .site-header { padding: 8px var(--gutter); } .site-header .inner { gap: 8px; } .nav-toggle { diff --git a/public/index.html b/public/index.html index 904db79..fb2d863 100644 --- a/public/index.html +++ b/public/index.html @@ -112,7 +112,6 @@

      Highcap Rasselbande

      Wir sind die Highcap Rasselbande (HCRB), eine Airsoft-Gruppe aus Berlin. - Gespielt wird bei uns vor allem auf der Darkzone, dort sind wir regelmäßig unterwegs.

      Hier planen wir unsere Spieltage und Fahrgemeinschaften. Videos und Bilder von unseren Spielen diff --git a/public/js/common.js b/public/js/common.js index 2dab0d2..7d67823 100644 --- a/public/js/common.js +++ b/public/js/common.js @@ -81,6 +81,7 @@ const NAV = [ { href: '/gruppe', key: 'gruppe', label: 'Gruppe' }, { href: '/statistik', key: 'statistik', label: 'Statistik' }, { href: '/patches', key: 'patches', label: 'Patches' }, + { href: '/kooperationen', key: 'kooperationen', label: 'Kooperationen' }, ]; // overlay: Kopfzeile liegt durchsichtig über dem Hero und wird erst beim Scrollen fest (Startseite) @@ -129,13 +130,20 @@ export function renderFooter() { document.body.append(footer); } -// Einmal pro Seite abfragen, ob jemand angemeldet ist +// Einmal pro Seite abfragen, wer angemeldet ist: { name, role } oder null. +// role: 'admin' oder 'mitglied'. Die Rechte prüft der Server – hier geht es nur darum, was angezeigt wird. let mePromise; export function currentUser() { - mePromise ??= api('GET', '/me').then(({ user }) => user || null).catch(() => null); + mePromise ??= api('GET', '/me') + .then(({ user, role }) => (user ? { name: user, role } : null)) + .catch(() => null); return mePromise; } +export async function isAdmin() { + return (await currentUser())?.role === 'admin'; +} + async function renderAuthLink(nav) { const user = await currentUser(); if (!user) { @@ -145,7 +153,7 @@ async function renderAuthLink(nav) { const btn = document.createElement('button'); btn.type = 'button'; btn.textContent = 'Abmelden'; - btn.title = `Angemeldet als ${user}`; + btn.title = `Angemeldet als ${user.name} (${user.role === 'admin' ? 'Admin' : 'Mitglied'})`; btn.addEventListener('click', async () => { await api('POST', '/logout').catch(() => {}); location.href = '/'; @@ -158,3 +166,29 @@ export function formatTime(ev) { if (!ev.time) return ''; return `${ev.time}${ev.endTime ? `–${ev.endTime}` : ''} Uhr`; } + +// ---------- Bild-Upload ---------- +// Bild im Browser auf höchstens maxSize px verkleinern; Transparenz bleibt (WebP bzw. PNG). +// Liefert eine data:-URL für die API. +export async function prepareImage(file, maxSize = 1600) { + // SVG bleibt unverändert (Vektorgrafik, gestochen scharf in jeder Größe) + if (file.type === 'image/svg+xml' || /\.svg$/i.test(file.name)) { + if (file.size > 2 * 1024 * 1024) throw new Error('SVG ist zu groß (max. 2 MB).'); + const bytes = new Uint8Array(await file.arrayBuffer()); + let bin = ''; + for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return `data:image/svg+xml;base64,${btoa(bin)}`; + } + if (!/^image\/(png|jpeg|webp)$/.test(file.type)) throw new Error('Bitte ein PNG-, JPEG-, WebP- oder SVG-Bild wählen.'); + const bitmap = await createImageBitmap(file); + const scale = Math.min(1, maxSize / Math.max(bitmap.width, bitmap.height)); + const canvas = document.createElement('canvas'); + canvas.width = Math.round(bitmap.width * scale); + canvas.height = Math.round(bitmap.height * scale); + canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height); + bitmap.close?.(); + let url = canvas.toDataURL('image/webp', 0.9); + // Browser ohne WebP-Export (ältere Safari) liefern PNG – für Fotos ist JPEG dann kleiner + if (!url.startsWith('data:image/webp') && file.type === 'image/jpeg') url = canvas.toDataURL('image/jpeg', 0.88); + return url; +} diff --git a/public/js/kooperationen.js b/public/js/kooperationen.js new file mode 100644 index 0000000..68aa595 --- /dev/null +++ b/public/js/kooperationen.js @@ -0,0 +1,207 @@ +import { $, api, esc, showError, renderHeader, renderFooter, isAdmin, prepareImage } from './common.js'; + +renderHeader('kooperationen'); +renderFooter(); + +let coops = []; +let admin = false; // nur der Admin-Zugang darf anlegen, ändern, löschen +let editingId = null; +let imageData = null; // data:-URL des neu gewählten Titelbildes + +const imgUrl = (c) => `/koop-img/${encodeURIComponent(c.image)}`; + +// ---------- Soziale Medien ---------- +const SOCIALS = [ + { key: 'website', label: 'Website', icon: '' }, + { key: 'instagram', label: 'Instagram', icon: '' }, + { key: 'youtube', label: 'YouTube', icon: '' }, + { key: 'tiktok', label: 'TikTok', icon: '' }, + { key: 'facebook', label: 'Facebook', icon: '' }, + { key: 'discord', label: 'Discord', icon: '' }, +]; + +const icon = (s) => ``; + +function socialsHtml(c) { + const links = SOCIALS.filter((s) => c.socials?.[s.key]); + return links.length + ? links.map((s) => `${icon(s)}${s.label}`).join('') + : '

      Keine Links eingetragen.

      '; +} + +// Ohne Titelbild: Anfangsbuchstaben auf Flaggenblau +const initials = (name) => name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase(); + +// ---------- Übersicht ---------- +function renderOverview() { + $('#coop-empty').hidden = coops.length > 0; + $('#coop-empty-hint').textContent = admin + ? 'Trag über „Kooperation hinzufügen“ die erste Gruppe ein.' + : 'Sobald der Admin eine Kooperation einträgt, erscheint sie hier.'; + $('#coop-grid').innerHTML = coops.map((c) => ` + +
      + ${c.image ? `` : `${esc(initials(c.name))}`} +
      +
      +

      ${esc(c.name)}

      + ${c.leader ? `

      Anführer: ${esc(c.leader)}

      ` : ''} + Ansehen +
      +
      `).join(''); +} + +// ---------- Einzelansicht (#id) ---------- +function renderDetail(c) { + document.title = `${c.name} – Kooperationen – HCRB`; + $('#detail-name').textContent = c.name; + const img = $('#detail-img'); + img.hidden = !c.image; + if (c.image) img.src = imgUrl(c); + $('#detail-hero').classList.toggle('no-image', !c.image); + $('#detail-hero').dataset.initials = initials(c.name); + $('#detail-description').textContent = c.description || 'Noch keine Beschreibung.'; + $('#detail-leader').textContent = c.leader || 'Nicht angegeben'; + $('#detail-socials').innerHTML = socialsHtml(c); + $('#detail-admin').hidden = !admin; +} + +function route() { + const id = location.hash.slice(1); + const coop = coops.find((c) => c.id === id); + $('#overview').hidden = !!coop; + $('#detail').hidden = !coop; + if (coop) { + renderDetail(coop); + scrollTo(0, 0); + } else { + document.title = 'Kooperationen – HCRB'; + renderOverview(); + } +} + +addEventListener('hashchange', () => { + closeForm(); + route(); +}); + +$('#back-link').addEventListener('click', (e) => { + e.preventDefault(); + history.pushState(null, '', location.pathname); + route(); +}); + +// ---------- Formular ---------- +function showPreview(src) { + $('#coop-preview').hidden = !src; + if (src) $('#coop-preview').src = src; + $('#drop-text').innerHTML = src + ? 'Anderes Titelbild wählen' + : 'Titelbild auswählen
      oder hierher ziehen · am besten ein breites Foto (Querformat)'; +} + +function openForm(coop) { + editingId = coop?.id ?? null; + imageData = null; + $('#coop-form').reset(); + $('#coop-error').hidden = true; + $('#coop-form-title').textContent = coop ? 'Kooperation bearbeiten' : 'Kooperation hinzufügen'; + $('#coop-name').value = coop?.name ?? ''; + $('#coop-leader').value = coop?.leader ?? ''; + $('#coop-description').value = coop?.description ?? ''; + for (const s of SOCIALS) $(`#social-${s.key}`).value = coop?.socials?.[s.key] ?? ''; + $('#remove-image-wrap').hidden = !coop?.image; + showPreview(coop?.image ? imgUrl(coop) : null); + $('#coop-form-card').hidden = false; + $('#coop-form-card').scrollIntoView({ behavior: 'smooth', block: 'start' }); + $('#coop-name').focus({ preventScroll: true }); +} + +function closeForm() { + $('#coop-form-card').hidden = true; + editingId = null; + imageData = null; +} + +async function pickFile(file) { + if (!file) return; + $('#coop-error').hidden = true; + try { + imageData = await prepareImage(file, 2000); + showPreview(imageData); + $('#coop-remove-image').checked = false; + } catch (err) { + imageData = null; + $('#coop-error').textContent = err.message || 'Das Bild konnte nicht gelesen werden.'; + $('#coop-error').hidden = false; + } +} + +$('#coop-file').addEventListener('change', (e) => pickFile(e.target.files[0])); + +const drop = $('#dropzone'); +drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); }); +drop.addEventListener('dragleave', () => drop.classList.remove('over')); +drop.addEventListener('drop', (e) => { + e.preventDefault(); + drop.classList.remove('over'); + pickFile(e.dataTransfer.files[0]); +}); + +$('#add-btn').addEventListener('click', () => openForm(null)); +$('#coop-cancel').addEventListener('click', closeForm); +$('#detail-edit').addEventListener('click', () => openForm(coops.find((c) => c.id === location.hash.slice(1)))); + +$('#detail-delete').addEventListener('click', async () => { + const c = coops.find((x) => x.id === location.hash.slice(1)); + if (!c || !confirm(`Kooperation „${c.name}“ wirklich löschen?`)) return; + try { + await api('DELETE', `/kooperationen/${c.id}`); + history.pushState(null, '', location.pathname); + await load(); + } catch (err) { + showError(err); + } +}); + +$('#coop-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const error = $('#coop-error'); + error.hidden = true; + const button = $('#coop-submit'); + button.disabled = true; + try { + const body = { + name: $('#coop-name').value, + leader: $('#coop-leader').value, + description: $('#coop-description').value, + socials: Object.fromEntries(SOCIALS.map((s) => [s.key, $(`#social-${s.key}`).value])), + }; + if (imageData) body.image = imageData; + else if ($('#coop-remove-image').checked) body.removeImage = true; + const saved = editingId + ? await api('PUT', `/kooperationen/${editingId}`, body) + : await api('POST', '/kooperationen', body); + closeForm(); + coops = await api('GET', '/kooperationen'); + // Nach dem Speichern die Seite der Kooperation zeigen + if (location.hash.slice(1) === saved.id) route(); + else location.hash = saved.id; + } catch (err) { + error.textContent = err.message; + error.hidden = false; + } finally { + button.disabled = false; + } +}); + +// ---------- Laden ---------- +async function load() { + coops = await api('GET', '/kooperationen'); + route(); +} + +admin = await isAdmin(); +$('#add-btn').hidden = !admin; +showPreview(null); +await load().catch(showError); diff --git a/public/js/patches.js b/public/js/patches.js index 9e1434e..4af793a 100644 --- a/public/js/patches.js +++ b/public/js/patches.js @@ -1,10 +1,10 @@ -import { $, api, esc, showError, renderHeader, renderFooter, currentUser } from './common.js'; +import { $, api, esc, showError, renderHeader, renderFooter, isAdmin, prepareImage } from './common.js'; renderHeader('patches'); renderFooter(); let patches = []; -let loggedIn = false; +let admin = false; // nur der Admin-Zugang darf Patches hochladen, ändern, löschen let editingId = null; let imageData = null; // data:-URL des gewählten (verkleinerten) Bildes @@ -13,9 +13,9 @@ const imgUrl = (p) => `/patch-img/${encodeURIComponent(p.image)}`; // ---------- Galerie ---------- function render() { $('#patch-empty').hidden = patches.length > 0; - $('#patch-empty-hint').textContent = loggedIn + $('#patch-empty-hint').textContent = admin ? 'Lade über „Patch hochladen“ den ersten Patch hoch.' - : 'Sobald jemand aus der Gruppe einen Patch hochlädt, erscheint er hier.'; + : 'Sobald der Admin einen Patch hochlädt, erscheint er hier.'; for (const status of ['vorhanden', 'geplant']) { const list = patches.filter((p) => p.status === status); $(`#group-${status}`).hidden = list.length === 0; @@ -34,7 +34,7 @@ function patchHtml(p) { ${p.status === 'geplant' ? 'In Planung' : ''}

      ${esc(p.name)}

      ${p.description ? `

      ${esc(p.description)}

      ` : ''} - ${loggedIn ? ` + ${admin ? `
      @@ -107,29 +107,6 @@ function showPreview(src) { : 'Bild auswählen
      oder hierher ziehen · PNG, JPEG, WebP oder SVG'; } -// Bild im Browser auf höchstens 1600 px verkleinern; Transparenz bleibt (WebP bzw. PNG) -async function prepareImage(file) { - // SVG bleibt unverändert (Vektorgrafik, gestochen scharf in jeder Größe) - if (file.type === 'image/svg+xml' || /\.svg$/i.test(file.name)) { - if (file.size > 2 * 1024 * 1024) throw new Error('SVG ist zu groß (max. 2 MB).'); - const bytes = new Uint8Array(await file.arrayBuffer()); - let bin = ''; - for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); - return `data:image/svg+xml;base64,${btoa(bin)}`; - } - if (!/^image\/(png|jpeg|webp)$/.test(file.type)) throw new Error('Bitte ein PNG-, JPEG-, WebP- oder SVG-Bild wählen.'); - const bitmap = await createImageBitmap(file); - const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height)); - const canvas = document.createElement('canvas'); - canvas.width = Math.round(bitmap.width * scale); - canvas.height = Math.round(bitmap.height * scale); - canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height); - bitmap.close?.(); - let url = canvas.toDataURL('image/webp', 0.9); - // Browser ohne WebP-Export (ältere Safari) liefern PNG – für Fotos ist JPEG dann kleiner - if (!url.startsWith('data:image/webp') && file.type === 'image/jpeg') url = canvas.toDataURL('image/jpeg', 0.88); - return url; -} async function pickFile(file) { if (!file) return; @@ -195,8 +172,8 @@ async function load() { render(); } -loggedIn = !!(await currentUser()); -$('#add-btn').hidden = !loggedIn; +admin = await isAdmin(); +$('#add-btn').hidden = !admin; await load().catch(showError); // Link von der Startseite (/patches#id) springt zum Patch if (location.hash) document.getElementById(location.hash.slice(1))?.scrollIntoView({ block: 'center' }); diff --git a/public/js/statistik.js b/public/js/statistik.js index b16302f..561b64c 100644 --- a/public/js/statistik.js +++ b/public/js/statistik.js @@ -1,10 +1,10 @@ -import { $, api, esc, showError, renderHeader, renderFooter } from './common.js'; +import { $, api, esc, showError, renderHeader, renderFooter, isAdmin } from './common.js'; renderHeader('statistik'); renderFooter(); let stats = []; -let canEdit = false; // angemeldet und laufendes Jahr +let canEdit = false; // Admin und laufendes Jahr function detail(s) { if (!s.manual) return ''; @@ -50,12 +50,12 @@ $('#stats-list').addEventListener('click', async (e) => { } }); -let loggedIn = false; +let admin = false; async function load(year) { const data = await api('GET', `/stats${year ? `?year=${year}` : ''}`); stats = data.entries; - canEdit = loggedIn && data.live; + canEdit = admin && data.live; $('#stats-year').innerHTML = data.years .map((y, i) => ``).join(''); $('#stats-info').textContent = data.live @@ -66,6 +66,6 @@ async function load(year) { $('#stats-year').addEventListener('change', (e) => load(e.target.value).catch(showError)); -api('GET', '/me') - .then(({ user }) => { loggedIn = !!user; return load(); }) +isAdmin() + .then((yes) => { admin = yes; return load(); }) .catch(showError); diff --git a/public/kooperationen.html b/public/kooperationen.html new file mode 100644 index 0000000..781626c --- /dev/null +++ b/public/kooperationen.html @@ -0,0 +1,106 @@ + + + + + + Kooperationen – HCRB + + + +
      + +
      +
      +
      + Verbündete Teams +

      Kooperationen

      +

      Gruppen, mit denen die HCRB zusammen spielt, plant und unterwegs ist.

      +
      +
      + +
      +
      +
      + +
      + + + + + + +
      + + + + diff --git a/public/patches.html b/public/patches.html index 4b8581a..219c56f 100644 --- a/public/patches.html +++ b/public/patches.html @@ -64,7 +64,7 @@ diff --git a/public/statistik.html b/public/statistik.html index ba80e4c..1eb8ef1 100644 --- a/public/statistik.html +++ b/public/statistik.html @@ -32,8 +32,7 @@

      So wird gezählt

      • Jeder Termin im Kalender, der als Spiel-Event markiert ist und bei dem du zugesagt hast, zählt als ein Spiel, sobald der Tag vorbei oder heute ist.
      • -
      • Termine ohne Spiel (Treffen, Grillen, Basteltage) zählen nicht.
      • -
      • Externe werden nicht aufgeführt.
      • +
      • Termine ohne Spiel (Treffen, Grillen) zählen nicht.
      • Zu Neujahr wird der Endstand gespeichert und die Zählung beginnt bei 0.
      diff --git a/scripts/user.js b/scripts/user.js index 1eeea46..28042a8 100644 --- a/scripts/user.js +++ b/scripts/user.js @@ -1,12 +1,15 @@ // Benutzerverwaltung für den Login // -// npm run user -- list -// npm run user -- add Benutzer anlegen (Passwort wird abgefragt) -// npm run user -- passwd Passwort ändern (meldet alle Sitzungen des Benutzers ab) -// npm run user -- remove Benutzer löschen +// Die Konten sind gemeinsame Zugänge (z. B. "admin" und "hcrb" für alle Mitglieder), keine persönlichen. +// +// npm run user -- list alle Benutzer mit Rolle +// npm run user -- add [admin|mitglied] Benutzer anlegen (Standard: mitglied, Passwort wird abgefragt) +// npm run user -- role Rolle ändern (meldet alle Sitzungen des Benutzers ab) +// npm run user -- passwd Passwort ändern (meldet alle Sitzungen des Benutzers ab) +// npm run user -- remove Benutzer löschen import { createInterface } from 'node:readline'; -import { hashPassword, readUsers, writeUsers, normalizeName, MIN_PASSWORD, USERS_FILE } from '../auth.js'; +import { hashPassword, readUsers, writeUsers, normalizeName, MIN_PASSWORD, USERS_FILE, ROLES, roleOf } from '../auth.js'; // Passwort verdeckt einlesen (bei umgeleiteter Eingabe zeilenweise) let pipedLines; @@ -52,22 +55,40 @@ async function newPassword() { return hashPassword(pw); } -const [cmd, rawName] = process.argv.slice(2); +const [cmd, rawName, rawRole] = process.argv.slice(2); +const role = String(rawRole ?? '').trim().toLowerCase(); + +function checkRole(value) { + if (!ROLES.includes(value)) throw new Error(`Rolle muss ${ROLES.join(' oder ')} sein.`); + return value; +} const name = normalizeName(rawName); const users = readUsers(); const user = users.find((u) => u.name === name); try { if (cmd === 'list') { - console.log(users.length ? users.map((u) => u.name).join('\n') : 'Noch keine Benutzer angelegt.'); + console.log(users.length + ? users.map((u) => `${u.name.padEnd(20)} ${roleOf(u)}${u.role ? '' : ' (noch ohne Rolle gespeichert)'}`).join('\n') + : 'Noch keine Benutzer angelegt.'); } else if (cmd === 'add' && name) { if (!/^[a-z0-9._-]{2,32}$/.test(name)) { throw new Error('Benutzername: 2–32 Zeichen, nur a–z, 0–9, Punkt, Minus, Unterstrich.'); } if (user) throw new Error(`Benutzer „${name}“ gibt es schon.`); - users.push({ name, hash: await newPassword(), changedAt: Date.now() }); + const newRole = checkRole(role || 'mitglied'); + users.push({ name, role: newRole, hash: await newPassword(), changedAt: Date.now() }); writeUsers(users); - console.log(`Benutzer „${name}“ angelegt.`); + console.log(`Benutzer „${name}“ als ${newRole} angelegt.`); + } else if (cmd === 'role' && name && role) { + if (!user) throw new Error(`Benutzer „${name}“ gibt es nicht.`); + user.role = checkRole(role); + if (user.role === 'mitglied' && !users.some((u) => roleOf(u) === 'admin')) { + throw new Error('Das ist der letzte Admin. Lege zuerst einen anderen Admin an.'); + } + user.changedAt = Date.now(); + writeUsers(users); + console.log(`„${name}“ ist jetzt ${user.role}. Bestehende Anmeldungen sind abgemeldet.`); } else if (cmd === 'passwd' && name) { if (!user) throw new Error(`Benutzer „${name}“ gibt es nicht.`); user.hash = await newPassword(); @@ -79,7 +100,7 @@ try { writeUsers(users.filter((u) => u !== user)); console.log(`Benutzer „${name}“ gelöscht.`); } else { - console.log('Benutzung: npm run user -- list | add | passwd | remove '); + console.log('Benutzung: npm run user -- list | add [admin|mitglied] | role | passwd | remove '); console.log(`Benutzerdatei: ${USERS_FILE}`); process.exitCode = cmd ? 1 : 0; } diff --git a/server.js b/server.js index 67d2f00..54bb218 100644 --- a/server.js +++ b/server.js @@ -3,18 +3,19 @@ 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 } from './auth.js'; +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: [] }; + 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 @@ -30,6 +31,7 @@ function loadDb() { statsArchive: db.statsArchive || [], // Ältere Patches haben keinen Status – die gab es schon patches: (db.patches || []).map((p) => ({ status: 'vorhanden', ...p })), + cooperations: db.cooperations || [], }; } @@ -134,15 +136,44 @@ function cleanPatch(body) { return { name: name.slice(0, 80), description: description.slice(0, 500), status }; } -function savePatchImage({ buf, ext }) { - mkdirSync(PATCH_DIR, { recursive: true }); +// 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(PATCH_DIR, file), buf); + writeFileSync(join(dir, file), buf); return file; } -function removePatchImage(file) { - try { unlinkSync(join(PATCH_DIR, file)); } catch { /* schon weg */ } +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) { @@ -225,7 +256,7 @@ app.disable('x-powered-by'); // 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(['/api/patches', '/api/kooperationen'], express.json({ limit: '12mb' })); app.use(express.json({ limit: '1mb' })); // Öffentlich: Startseite, Login, CSS/Bilder @@ -233,8 +264,8 @@ app.use(versioned(join(ROOT, 'public'), 'no-cache')); 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, { +// Hochgeladene Bilder sind öffentlich (Dateinamen sind zufällige IDs) +const imageStatic = (dir) => express.static(dir, { maxAge: '30d', immutable: true, setHeaders: (res, path) => { @@ -242,7 +273,9 @@ app.use('/patch-img', express.static(PATCH_DIR, { // 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() { @@ -316,12 +349,18 @@ 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', (req, res) => { +app.post('/api/patches', requireAdmin, (req, res) => { const fields = cleanPatch(req.body); - const image = savePatchImage(cleanImage(req.body.image)); + const image = saveImage(PATCH_DIR, cleanImage(req.body.image)); const patch = { id: randomUUID(), ...fields, image, createdAt: new Date().toISOString() }; db.patches.push(patch); saveDb(); @@ -329,27 +368,63 @@ app.post('/api/patches', (req, res) => { }); // Bild ist optional – ohne Bild werden nur Name und Beschreibung geändert -app.put('/api/patches/:id', (req, res) => { +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 = savePatchImage(cleanImage(req.body.image)); - removePatchImage(old); + 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', (req, res) => { +app.delete('/api/patches/:id', requireAdmin, (req, res) => { const patch = db.patches.find((p) => p.id === req.params.id); - if (patch) removePatchImage(patch.image); + 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'], @@ -361,7 +436,7 @@ app.get('/api/members', (req, res) => { res.json(db.members); }); -app.post('/api/members', (req, res) => { +app.post('/api/members', requireAdmin, (req, res) => { const member = { id: randomUUID(), ...cleanMember(req.body, { memberIds: memberIds() }) }; db.members.push(member); saveDb(); @@ -376,7 +451,7 @@ app.put('/api/members/:id', (req, res) => { res.json(member); }); -app.delete('/api/members/:id', (req, res) => { +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; @@ -386,7 +461,7 @@ app.delete('/api/members/:id', (req, res) => { }); // Manuelle Korrektur der gespielten Spiele (z. B. spontane Fahrten, die nicht im Kalender stehen) -app.put('/api/members/:id/plays-manual', (req, res) => { +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(); @@ -396,7 +471,7 @@ app.put('/api/members/:id/plays-manual', (req, res) => { }); // Ersetzt die komplette Gruppe (Import aus JSON-Datei) -app.post('/api/members/import', (req, res) => { +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())