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.
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
server/public
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/*.log
|
||||||
|
coverage
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
@@ -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
|
||||||
+54
@@ -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"]
|
||||||
@@ -1,3 +1,178 @@
|
|||||||
# CC-Manager
|
# CC-Manager
|
||||||
|
|
||||||
For managing Custom Characters on a Server
|
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).
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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 "$@"
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user