From 3ec0ff4df17aba51cce4c7771ec850cd63c3459a Mon Sep 17 00:00:00 2001 From: Harlekin Date: Fri, 18 Sep 2026 11:55:06 +0200 Subject: [PATCH] Container-Setup, Backups und Dokumentation Mehrstufiges Image: gebaut wird mit allen Abhaengigkeiten, ausgeliefert wird nur die Laufzeit. Migrationen laufen beim Containerstart. Der App-Port wird bewusst nicht nach aussen gemappt - davor gehoert ein Reverse Proxy, der HTTPS terminiert. Der Backup-Service schreibt taeglich einen pg_dump und raeumt Dumps aelter als 14 Tage weg. --- .dockerignore | 14 ++++ .env.example | 24 ++++++ Dockerfile | 54 +++++++++++++ README.md | 177 ++++++++++++++++++++++++++++++++++++++++++- compose.yaml | 57 ++++++++++++++ docker-entrypoint.sh | 9 +++ server/.env.example | 15 ++++ 7 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 compose.yaml create mode 100644 docker-entrypoint.sh create mode 100644 server/.env.example diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9fe5dae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +**/node_modules +**/dist +server/public +.git +.gitignore +*.md +.env +.env.* +!.env.example +**/*.log +coverage +.idea +.vscode diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0ea21a1 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Kopie als .env neben die compose.yaml legen und Werte ersetzen. + +# Passwort des Datenbank-Nutzers. Lang und zufaellig waehlen. +DB_PASSWORD=hier-ein-langes-zufaelliges-passwort + +# Schluessel fuer Sitzungen und CSRF-Token, mindestens 32 Zeichen. +# Erzeugen z. B. mit: openssl rand -base64 48 +SESSION_SECRET=hier-einen-langen-zufallsstring-einsetzen + +# Erster Administrator, wird nur angelegt solange noch kein Konto existiert. +# Bleibt das Passwort leer, erzeugt die App eines und schreibt es einmalig +# ins Container-Log. In beiden Faellen ist ein Wechsel beim ersten Login Pflicht. +BOOTSTRAP_ADMIN_USER=admin +BOOTSTRAP_ADMIN_PASSWORD= + +# --- Optional --- + +# Gueltigkeit einer Sitzung in Stunden (Standard: 12). +# SESSION_TTL_HOURS=12 + +# Nur abschalten, wenn ohne HTTPS getestet wird. +# COOKIE_SECURE=true + +# LOG_LEVEL=info diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..337069f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# --- Build --- +FROM node:22-slim AS build + +# Prisma benoetigt OpenSSL fuer seine Query-Engine. +RUN apt-get update && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY server/package.json ./server/ +COPY web/package.json ./web/ +RUN npm ci + +COPY server/prisma ./server/prisma +RUN npx prisma generate --schema server/prisma/schema.prisma + +COPY server ./server +COPY web ./web + +# Das Frontend landet laut vite.config.ts direkt in server/public. +RUN npm run build --workspace=web \ + && npx tsc -p server/tsconfig.json + +# --- Laufzeit --- +FROM node:22-slim AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends openssl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +ENV NODE_ENV=production + +COPY package.json package-lock.json ./ +COPY server/package.json ./server/ +COPY web/package.json ./web/ +RUN npm ci --omit=dev && npm cache clean --force + +COPY server/prisma ./server/prisma +RUN npx prisma generate --schema server/prisma/schema.prisma + +COPY --from=build /app/server/dist ./server/dist +COPY --from=build /app/server/public ./server/public +COPY docker-entrypoint.sh ./ +RUN chmod +x docker-entrypoint.sh && chown -R node:node /app + +USER node +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +ENTRYPOINT ["./docker-entrypoint.sh"] +CMD ["node", "server/dist/index.js"] diff --git a/README.md b/README.md index b34d643..7255e97 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,178 @@ # CC-Manager -For managing Custom Characters on a Server \ No newline at end of file +Selbst gehostete Web-App zur Verwaltung von Custom Characters (CCs) auf einem SCP-RP-Server. +Sie führt pro Spieler ein Konto über gekaufte Ansprüche und deren Verbrauch, damit die Frage +„darf der gerade einen neuen CC?" nicht mehr von Hand abgeglichen werden muss. + +## Das Prinzip + +Jeder Spieler hat drei Guthaben-Töpfe: + +| Topf | Wird gefüllt durch | Wird verbraucht durch | +|------|--------------------|-----------------------| +| CC-Guthaben | jedes CC-Paket (+1) | ein neuer CC | +| Redesign-Guthaben (bezahlt) | jedes CC-Paket (+1, immer inklusive) und jedes Redesign-Paket (+1) | ein Redesign | +| Kulanz-Redesign | manuell eingetragene Kulanz (+1) | ein Redesign | + +Zwei Regeln sind fest verdrahtet: + +1. **Ein CC-Paket erzeugt beide Guthaben.** Der inklusive Redesign wird automatisch mitgebucht, + in derselben Transaktion und mit gemeinsamer Gruppen-ID — ein Storno nimmt beide zurück. +2. **Inaktive CCs kosten unterschiedlich.** Wurde ein CC nur wegen Spieler-Inaktivität vom Server + entfernt, kommt er gratis zurück. Wurde er redesignt und existiert im Original nicht mehr, + kostet die Reaktivierung ein Redesign- oder CC-Guthaben. + +Guthaben werden als **Kontobuch** geführt: jede Bewegung ist eine eigene Zeile, der Stand eines +Topfes ist deren Summe. Das macht jeden Stand nachvollziehbar und Korrekturen sauber — storniert +wird per Gegenbuchung, nie durch Löschen. Ein überzogenes Konto ist erlaubt (kommt vor, wenn ein +Redesign vor dem zugehörigen Paket erfasst wird), wird aber rot markiert. + +Ein Redesign legt einen **neuen** CC-Datensatz an und verkettet ihn mit dem alten. So bleibt die +Kette „A → B → C" sichtbar und die Reaktivierungsregel greift automatisch richtig. + +## Schnellstart + +Vorausgesetzt wird Docker mit Compose-Plugin (oder DockGe). + +```bash +git clone https://git.harlelux.de/Harlekin/CC-Manager.git +cd CC-Manager +cp .env.example .env +``` + +In der `.env` mindestens `DB_PASSWORD` und `SESSION_SECRET` setzen: + +```bash +openssl rand -base64 48 # für SESSION_SECRET +``` + +Dann starten: + +```bash +docker compose up -d --build +docker compose logs -f app +``` + +Beim ersten Start legt die App einen Administrator an. Ist `BOOTSTRAP_ADMIN_PASSWORD` leer, +erzeugt sie ein Einmal-Passwort und schreibt es **einmalig** ins Log — dort steht es in der +Ausgabe oben. Beim ersten Login ist ein Passwortwechsel Pflicht; bis dahin sind alle anderen +Seiten gesperrt. + +Für DockGe: Repo-Inhalt als Stack anlegen, `.env` daneben, fertig. Datenbank-Migrationen laufen +bei jedem Containerstart automatisch mit. + +## HTTPS + +Der App-Port wird bewusst **nicht** nach außen gemappt. Davor gehört ein Reverse Proxy +(Nginx Proxy Manager, Traefik oder Caddy), der HTTPS terminiert und intern auf `app:3000` +weiterleitet. So ist nur Port 443 offen und die Zertifikate laufen über Let's Encrypt. + +Läuft die App hinter einem Proxy, muss `TRUST_PROXY=true` gesetzt bleiben — sonst landen im +Audit-Log die Proxy-IPs statt der echten. In Produktion werden Cookies automatisch auf `Secure` +gesetzt; nur zum Testen ohne TLS lässt sich das über `COOKIE_SECURE=false` abschalten. + +## Backups + +Der `backup`-Service im Stack schreibt beim Start und danach alle 24 Stunden einen +`pg_dump` nach `./backups/` und räumt Dumps älter als 14 Tage weg. Die Daten bilden bezahlte +Ansprüche ab — das Verzeichnis gehört auf eine Sicherung außerhalb des Hosts. + +Wiederherstellen: + +```bash +gunzip -c backups/ccmanager-2025-01-15-0300.sql.gz | \ + docker compose exec -T db psql -U ccuser -d ccmanager +``` + +## Import + +Kaufdaten lassen sich als CSV oder JSON hochladen; das Format ist nicht festgelegt. Der Ablauf: + +1. Datei hochladen — Spalten und Beispielzeilen werden angezeigt. +2. Spalten zuordnen (Spieler-Kennung, Produktname, optional Bestell-ID und Datum). Die üblichen + Spaltennamen werden vorausgewählt. +3. **Vorschau** prüfen: für jede Zeile steht da, was passieren würde. +4. Buchen. + +Welcher Produktname welches Guthaben ergibt, steht in der Mapping-Tabelle unter *Verwaltung → +Produkt-Mappings* (Teiltreffer, Groß-/Kleinschreibung egal). Neue Produkte lassen sich dort +jederzeit ergänzen, ohne den Code anzufassen. + +Wird eine Bestell-ID zugeordnet, bucht ein zweiter Import dieselben Käufe **nicht** erneut — +Dubletten werden sowohl gegen bereits gebuchte Bestellungen als auch innerhalb der Datei erkannt. +Ohne Bestell-ID entfällt dieser Schutz; darauf weist das Formular hin. + +Kulanz-Redesigns tauchen in keinem Shop-Export auf und werden immer von Hand eingetragen. + +## Rollen + +Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an. + +- **Viewer** — darf nur lesen. +- **Editor** — Spieler anlegen, Guthaben eintragen, CCs erstellen, redesignen, reaktivieren. +- **Admin** — zusätzlich Import, Nutzerverwaltung, Produkt-Mappings, Audit-Log und Korrekturbuchungen. + +Jede ändernde Aktion landet im Audit-Log. Für Administratoren lässt sich unter *Mein Konto* +eine Zwei-Faktor-Anmeldung per TOTP einrichten. + +## Entwicklung + +Gebraucht werden Node 20+ und eine erreichbare PostgreSQL-Instanz. + +```bash +npm install +cp server/.env.example server/.env # DATABASE_URL auf die lokale DB zeigen lassen +npm run migrate:dev +npm run dev # API auf :3000, Frontend auf :5173 +``` + +Das Frontend leitet `/api` im Entwicklungsbetrieb an den API-Port weiter, Anmeldung und Cookies +funktionieren also wie in Produktion. + +Die Tests brauchen **keine** laufende Datenbank — sie starten eine Postgres-Instanz im Speicher: + +```bash +npm test +``` + +Abgedeckt sind die Guthaben-Regeln, der CC-Lebenszyklus samt Reaktivierungslogik, Korrekturen +und Stornos, Anmeldung mit CSRF- und Rollenprüfung sowie der Import mit Dedup. + +### Aufbau + +``` +server/ + prisma/schema.prisma Datenmodell und Migrationen + src/services/ Fachlogik: Ledger, CC-Lebenszyklus, Import + src/routes/ HTTP-Endpunkte + src/auth/ Passwörter, Sitzungen, CSRF, Rollen, TOTP + test/ Integrationstests gegen eine echte Postgres-Instanz +web/ + src/pages/ Login, Übersicht, Spieler-Detail, Import, Verwaltung + src/components/ Wiederverwendete Bausteine +``` + +Backend ist Fastify mit Prisma, Frontend eine React-SPA. Gebaut wird beides in ein einziges +Image: Fastify liefert die API und die fertigen Frontend-Dateien aus. + +Nach Änderungen am Datenmodell: + +```bash +npm run migrate:dev -- --name beschreibung-der-aenderung +``` + +## Sicherheit + +- Passwörter mit Argon2id gehasht, Mindestlänge 12 Zeichen. +- Sitzungen serverseitig; im Cookie steht nur ein Token, in der Datenbank dessen Hash. + Cookie ist `httpOnly`, `SameSite=Lax` und in Produktion `Secure`. +- CSRF-Schutz für alle ändernden Anfragen über ein aus der Sitzung abgeleitetes Token. +- Alle Eingaben werden validiert, alle Datenbankzugriffe laufen parametrisiert über das ORM. +- Rate-Limit auf dem Login, generische Fehlermeldung ohne Hinweis auf existierende Konten. +- Sicherheits-Header inklusive Content-Security-Policy. +- Ein gesperrtes Konto verliert seine offenen Sitzungen sofort; der letzte aktive Administrator + kann sich nicht selbst entfernen. + +## Lizenz + +GPL-3.0-or-later, siehe [LICENSE](LICENSE). diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..5fdfc60 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,57 @@ +services: + app: + build: . + # Alternativ ein vorgebautes Image verwenden: + # image: ghcr.io/harlekin/cc-manager:latest + restart: unless-stopped + environment: + DATABASE_URL: postgres://ccuser:${DB_PASSWORD}@db:5432/ccmanager + SESSION_SECRET: ${SESSION_SECRET} + NODE_ENV: production + # Erstzugang; nach der ersten Anmeldung wird ein Wechsel erzwungen. + BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin} + BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} + TRUST_PROXY: "true" + expose: + # Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS. + - "3000" + depends_on: + db: + condition: service_healthy + + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ccuser + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ccmanager + volumes: + - cc_db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ccuser -d ccmanager"] + interval: 10s + timeout: 5s + retries: 5 + + backup: + image: postgres:16-alpine + restart: unless-stopped + environment: + PGPASSWORD: ${DB_PASSWORD} + volumes: + - ./backups:/backups + depends_on: + db: + condition: service_healthy + # Dump beim Start und danach alle 24 Stunden, Aufbewahrung 14 Tage. + entrypoint: > + sh -c 'while true; do + pg_dump -h db -U ccuser -d ccmanager | gzip > /backups/ccmanager-$$(date +%F-%H%M).sql.gz && + echo "Backup geschrieben: $$(date +%F-%H%M)"; + find /backups -name "ccmanager-*.sql.gz" -mtime +14 -delete; + sleep 86400; + done' + +volumes: + cc_db: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..09d6198 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +# Schema aktuell halten, bevor die App startet. Bereits angewandte +# Migrationen werden dabei uebersprungen. +echo "Wende Datenbank-Migrationen an ..." +npx prisma migrate deploy --schema server/prisma/schema.prisma + +exec "$@" diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..8ac53a7 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,15 @@ +# Nur fuer die lokale Entwicklung. Als server/.env kopieren. +# Im Container kommen diese Werte aus der compose.yaml. + +DATABASE_URL=postgresql://ccuser:passwort@localhost:5432/ccmanager +SESSION_SECRET=lokaler-entwicklungsschluessel-mindestens-32-zeichen + +NODE_ENV=development +PORT=3000 + +# Ohne HTTPS muessen die Cookies unsicher ausgeliefert werden duerfen. +COOKIE_SECURE=false + +# Erstadministrator beim ersten Start. +BOOTSTRAP_ADMIN_USER=admin +BOOTSTRAP_ADMIN_PASSWORD=EntwicklungPasswort1