Rollen (Admin/Mitglied), Seite Kooperationen, Textanpassungen

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-12 19:47:03 +02:00
1 parent f46016487d
commit 819219fb2d
18 files changed
+661 -106

No files matched your search

+5 -3
View File
@@ -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 <name>` |
| Benutzer anlegen | `docker compose exec hcrb npm run user -- add <name> [admin\|mitglied]` |
| Rolle ändern | `docker compose exec hcrb npm run user -- role <name> <admin\|mitglied>` |
| Passwort ändern | `docker compose exec hcrb npm run user -- passwd <name>` |
| 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` |
+30 -9
View File
@@ -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#<id>`) 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 <name> # Benutzer anlegen
npm run user -- passwd <name> # Passwort ändern (meldet den Benutzer überall ab)
npm run user -- remove <name> # Benutzer löschen (sofort abgemeldet)
npm run user -- list # alle Benutzer mit Rolle anzeigen
npm run user -- add <name> [admin|mitglied] # Benutzer anlegen (Standard: mitglied)
npm run user -- role <name> <admin|mitglied> # Rolle ändern (meldet den Benutzer überall ab)
npm run user -- passwd <name> # Passwort ändern (meldet den Benutzer überall ab)
npm run user -- remove <name> # 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
```
+23 -6
View File
@@ -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.' });
}
+1 -1
View File
@@ -79,7 +79,7 @@
<button type="button" id="select-none" class="small">Keine</button>
</div>
</div>
<p id="trip-members-empty" class="muted" hidden>Lege zuerst Mitglieder unter <a href="/gruppe">Gruppe</a> an.</p>
<p id="trip-members-empty" class="muted" hidden>Noch keine Mitglieder. Der Admin legt sie unter <a href="/gruppe">Gruppe</a> an.</p>
<ul id="trip-members" class="list"></ul>
</div>
</div>
+4 -3
View File
@@ -18,7 +18,7 @@
<div class="split wide-right">
<div class="col">
<div class="card">
<div class="card" id="member-form-card">
<h2 id="member-form-title">Mitglied hinzufügen</h2>
<form id="member-form">
<input type="hidden" id="member-id">
@@ -75,15 +75,16 @@
<div class="col">
<div class="card">
<h2>Gruppe</h2>
<p id="members-role-hint" class="muted" hidden>Du kannst Mitglieder bearbeiten.</p>
<p id="members-empty" class="muted" hidden>Noch keine Mitglieder gespeichert.</p>
<ul id="members-list" class="list"></ul>
</div>
<div class="card">
<h2>Daten sichern</h2>
<p class="muted">Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).</p>
<p class="muted" id="backup-text">Gruppe als JSON-Datei sichern oder eine gesicherte Datei wieder einspielen (ersetzt die aktuelle Gruppe).</p>
<div class="row">
<button id="export-btn">Exportieren (JSON)</button>
<label class="button">Importieren
<label class="button" id="import-btn">Importieren
<input type="file" id="import-file" accept="application/json" hidden>
</label>
</div>
+21 -5
View File
@@ -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() {
<span class="badge">${m.hasCar ? `Auto · ${seatsLabel(m.seats)}${m.alwaysDrives ? ' · fährt immer' : ''}` : 'kein Auto'}</span>
<span class="actions">
<button class="link" data-edit="${m.id}">Bearbeiten</button>
<button class="link danger" data-del="${m.id}">Löschen</button>
${admin ? `<button class="link danger" data-del="${m.id}">Löschen</button>` : ''}
</span>
</li>`).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);
+2 -3
View File
@@ -12,7 +12,6 @@
<div>
<span class="eyebrow">Spieltage &amp; Termine</span>
<h1>Kalender</h1>
<p>Nur Termine mit Häkchen bei „Spiel-Event“ zählen für die Statistik.</p>
</div>
<div class="actions">
<a class="button" href="/api/calendar.ics" download title="Alle kommenden Termine als .ics-Datei für Google, Apple oder Outlook">
@@ -46,7 +45,7 @@
<select id="me-select"></select>
</label>
<p id="me-hint" class="muted" hidden>Wähle deinen Namen aus, damit du Termine eintragen und zusagen kannst.
Du fehlst in der Liste? Dann trag dich unter <a href="/gruppe">Gruppe</a> ein.</p>
Du fehlst in der Liste? Dann lass dich vom Admin unter <a href="/gruppe">Gruppe</a> eintragen.</p>
</div>
<div class="card">
@@ -61,7 +60,7 @@
<input type="checkbox" id="event-game" checked>
<div>
<strong>Spiel-Event</strong>
<span>Wer zusagt, bekommt ein Spiel in der Statistik. Für Treffen, Grillen oder Basteltage das Häkchen entfernen.</span>
<span>Wer zusagt, bekommt ein Spiel in der Statistik. Für Treffen oder Grillen das Häkchen entfernen.</span>
</div>
</label>
<div class="grid2">
+82 -1
View File
@@ -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 {
-1
View File
@@ -112,7 +112,6 @@
<h2>Highcap Rasselbande</h2>
<p>
Wir sind die <strong>Highcap Rasselbande (HCRB)</strong>, eine Airsoft-Gruppe aus Berlin.
Gespielt wird bei uns vor allem auf der <strong>Darkzone</strong>, dort sind wir regelmäßig unterwegs.
</p>
<p>
Hier planen wir unsere Spieltage und Fahrgemeinschaften. Videos und Bilder von unseren Spielen
+37 -3
View File
@@ -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;
}
+207
View File
@@ -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: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z"/>' },
{ key: 'instagram', label: 'Instagram', icon: '<rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4.2"/><circle cx="17.5" cy="6.5" r=".9" fill="currentColor" stroke="none"/>' },
{ key: 'youtube', label: 'YouTube', icon: '<rect x="2" y="5" width="20" height="14" rx="4"/><path d="M10 9l5 3-5 3z" fill="currentColor"/>' },
{ key: 'tiktok', label: 'TikTok', icon: '<path d="M14 3v11.5a3.5 3.5 0 1 1-3.5-3.5"/><path d="M14 3c.4 2.6 2.2 4.4 5 4.6"/>' },
{ key: 'facebook', label: 'Facebook', icon: '<path d="M14.5 21v-8h3l.5-3.5h-3.5V7.6c0-1 .4-1.7 1.8-1.7H18V2.8c-.4 0-1.6-.2-2.9-.2-2.8 0-4.6 1.7-4.6 4.8v2.1H7.5V13h3v8"/>' },
{ key: 'discord', label: 'Discord', icon: '<path d="M7 7.5c3.3-1.3 6.7-1.3 10 0 1.7 2.5 2.6 5.3 2.5 8.5-1.5 1.2-3 1.9-4.6 2.3l-1-1.8M7 7.5C5.3 10 4.4 12.8 4.5 16c1.5 1.2 3 1.9 4.6 2.3l1-1.8M8 15.5c2.7 1.2 5.3 1.2 8 0"/><circle cx="9.5" cy="12" r="1.1" fill="currentColor" stroke="none"/><circle cx="14.5" cy="12" r="1.1" fill="currentColor" stroke="none"/>' },
];
const icon = (s) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${s.icon}</svg>`;
function socialsHtml(c) {
const links = SOCIALS.filter((s) => c.socials?.[s.key]);
return links.length
? links.map((s) => `<a class="button" href="${esc(c.socials[s.key])}" target="_blank" rel="noopener noreferrer">${icon(s)}${s.label}</a>`).join('')
: '<p class="muted">Keine Links eingetragen.</p>';
}
// 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) => `
<a class="coop-card" href="#${c.id}">
<div class="coop-card-img">
${c.image ? `<img src="${imgUrl(c)}" alt="" loading="lazy">` : `<span class="coop-initials">${esc(initials(c.name))}</span>`}
</div>
<div class="coop-card-body">
<h2>${esc(c.name)}</h2>
${c.leader ? `<p class="muted">Anführer: ${esc(c.leader)}</p>` : ''}
<span class="go">Ansehen</span>
</div>
</a>`).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
? '<strong>Anderes Titelbild wählen</strong>'
: '<strong>Titelbild auswählen</strong><br>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);
+7 -30
View File
@@ -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' ? '<span class="badge nogame">In Planung</span>' : ''}
<h3>${esc(p.name)}</h3>
${p.description ? `<p>${esc(p.description)}</p>` : ''}
${loggedIn ? `
${admin ? `
<div class="actions">
<button class="link" data-edit="${p.id}">Bearbeiten</button>
<button class="link danger" data-delete="${p.id}">Löschen</button>
@@ -107,29 +107,6 @@ function showPreview(src) {
: '<strong>Bild auswählen</strong><br>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' });
+6 -6
View File
@@ -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) => `<option value="${y}" ${y === data.year ? 'selected' : ''}>${y}${i === 0 ? ' (laufend)' : ''}</option>`).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);
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kooperationen – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main>
<!-- Übersicht aller Kooperationen -->
<section id="overview">
<header class="page-head">
<div>
<span class="eyebrow">Verbündete Teams</span>
<h1>Kooperationen</h1>
<p>Gruppen, mit denen die HCRB zusammen spielt, plant und unterwegs ist.</p>
</div>
<div class="actions">
<button type="button" id="add-btn" class="primary" hidden>Kooperation hinzufügen</button>
</div>
</header>
<div id="coop-grid" class="coop-grid"></div>
<div id="coop-empty" class="empty-state" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M7 11l3-3 3 2 3-3 4 4-6 6-2-1-2 1-6-6z"/><path d="M3 9l4 2M21 9l-4 2"/></svg>
<strong>Noch keine Kooperationen</strong>
<span id="coop-empty-hint">Sobald der Admin eine Kooperation einträgt, erscheint sie hier.</span>
</div>
</section>
<!-- Einzelne Kooperation: Titelbild, Anführer, Soziale Medien, Beschreibung -->
<article id="detail" hidden>
<a href="#" class="back-link" id="back-link">‹ Alle Kooperationen</a>
<div class="coop-hero" id="detail-hero">
<img id="detail-img" alt="" hidden>
<div class="coop-hero-text">
<span class="eyebrow">Kooperation</span>
<h1 id="detail-name"></h1>
</div>
</div>
<div class="split wide-left coop-info">
<div class="card">
<h2>Über die Gruppe</h2>
<p id="detail-description" class="coop-description"></p>
</div>
<div class="card">
<h2>Anführer</h2>
<p id="detail-leader" class="coop-leader"></p>
<h2 class="spaced">Soziale Medien</h2>
<div id="detail-socials" class="coop-socials"></div>
<div id="detail-admin" class="row wrap admin-actions" hidden>
<button type="button" id="detail-edit">Bearbeiten</button>
<button type="button" id="detail-delete" class="link danger">Löschen</button>
</div>
</div>
</div>
</article>
<!-- Formular (nur Admin) -->
<div class="card" id="coop-form-card" hidden>
<h2 id="coop-form-title">Kooperation hinzufügen</h2>
<form id="coop-form" class="split">
<div>
<label class="dropzone wide" id="dropzone" for="coop-file">
<img id="coop-preview" alt="Vorschau" hidden>
<span id="drop-text"></span>
<input type="file" id="coop-file" accept="image/png,image/jpeg,image/webp,image/svg+xml,.svg" hidden>
</label>
<label class="inline" id="remove-image-wrap" hidden>
<input type="checkbox" id="coop-remove-image"> Titelbild entfernen
</label>
</div>
<div>
<label>Name der Gruppe
<input type="text" id="coop-name" required maxlength="80" placeholder="z. B. Black Wolves Airsoft">
</label>
<label>Anführer
<input type="text" id="coop-leader" maxlength="120" placeholder="Name oder Rufname">
</label>
<label>Kurze Beschreibung
<textarea id="coop-description" maxlength="2000" placeholder="Wer sind sie, wo spielen sie, was machen wir zusammen …"></textarea>
</label>
<fieldset class="socials-fields">
<legend>Soziale Medien (optional, Link einfügen)</legend>
<div class="grid2">
<label>Website <input type="text" id="social-website" inputmode="url" placeholder="https://…"></label>
<label>Instagram <input type="text" id="social-instagram" inputmode="url" placeholder="instagram.com/…"></label>
<label>YouTube <input type="text" id="social-youtube" inputmode="url" placeholder="youtube.com/@…"></label>
<label>TikTok <input type="text" id="social-tiktok" inputmode="url" placeholder="tiktok.com/@…"></label>
<label>Facebook <input type="text" id="social-facebook" inputmode="url" placeholder="facebook.com/…"></label>
<label>Discord <input type="text" id="social-discord" inputmode="url" placeholder="discord.gg/…"></label>
</div>
</fieldset>
<p id="coop-error" class="warn" hidden></p>
<div class="row wrap">
<button type="submit" class="primary" id="coop-submit">Speichern</button>
<button type="button" id="coop-cancel">Abbrechen</button>
</div>
</div>
</form>
</div>
</main>
<script type="module" src="/js/kooperationen.js"></script>
</body>
</html>
+1 -1
View File
@@ -64,7 +64,7 @@
<div id="patch-empty" class="empty-state" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M12 2.5l8 3.5v6c0 5-3.5 8.2-8 9.5-4.5-1.3-8-4.5-8-9.5V6z"/></svg>
<strong>Noch keine Patches</strong>
<span id="patch-empty-hint">Sobald jemand aus der Gruppe einen Patch hochlädt, erscheint er hier.</span>
<span id="patch-empty-hint">Sobald der Admin einen Patch hochlädt, erscheint er hier.</span>
</div>
<dialog class="lightbox" id="lightbox">
+1 -2
View File
@@ -32,8 +32,7 @@
<h2>So wird gezählt</h2>
<ul class="facts">
<li>Jeder Termin im Kalender, der als <strong>Spiel-Event</strong> markiert ist und bei dem du zugesagt hast, zählt als ein Spiel, sobald der Tag vorbei oder heute ist.</li>
<li>Termine ohne Spiel (Treffen, Grillen, Basteltage) zählen nicht.</li>
<li>Externe werden nicht aufgeführt.</li>
<li>Termine ohne Spiel (Treffen, Grillen) zählen nicht.</li>
<li>Zu Neujahr wird der Endstand gespeichert und die Zählung beginnt bei 0.</li>
</ul>
</div>
+31 -10
View File
@@ -1,12 +1,15 @@
// Benutzerverwaltung für den Login
//
// npm run user -- list
// npm run user -- add <name> Benutzer anlegen (Passwort wird abgefragt)
// npm run user -- passwd <name> Passwort ändern (meldet alle Sitzungen des Benutzers ab)
// npm run user -- remove <name> 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 <name> [admin|mitglied] Benutzer anlegen (Standard: mitglied, Passwort wird abgefragt)
// npm run user -- role <name> <admin|mitglied> Rolle ändern (meldet alle Sitzungen des Benutzers ab)
// npm run user -- passwd <name> Passwort ändern (meldet alle Sitzungen des Benutzers ab)
// npm run user -- remove <name> 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 <name> | passwd <name> | remove <name>');
console.log('Benutzung: npm run user -- list | add <name> [admin|mitglied] | role <name> <admin|mitglied> | passwd <name> | remove <name>');
console.log(`Benutzerdatei: ${USERS_FILE}`);
process.exitCode = cmd ? 1 : 0;
}
+97 -22
View File
@@ -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())