Files
CC-Manager/README.md
T
Harlekin 724f68644b Umlaute statt Umschreibungen
Oberflaeche, Fehlermeldungen und Kommentare schrieben durchgehend ae, oe, ue
und ss. Dafuer gab es keinen Grund - die Anwendung ist deutschsprachig und
laeuft durchgehend in UTF-8.

Betroffen waren 55 Dateien, darunter alle sichtbaren Texte: Guthaben-Zaehler,
Import, Nutzerverwaltung, Passwortwechsel und die Zwei-Faktor-Einrichtung.

Der Name einer Migration bleibt im README-Beispiel bewusst bei ASCII, weil
daraus ein Verzeichnisname wird.
2026-09-18 16:57:39 +02:00

11 KiB

CC-Manager

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. Nur redesignte CCs kosten bei der Rückkehr. Solange ein CC unverändert existiert, kommt er gratis zurück — gleich ob der Spieler inaktiv war oder die Einheit zeitweise entfernt wurde. Wurde er dagegen redesignt, existiert das Original nicht mehr und die Reaktivierung kostet 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: Will man später A zurück, ist A ein redesignter CC und kostet entsprechend.

Warum ein CC inaktiv wurde, lässt sich beim Deaktivieren als Klartext festhalten („Einheit bis März entfernt") — der Grund steht dann an der Karte, statt nur als Kategorie.

Schnellstart

Vorausgesetzt wird Docker mit Compose-Plugin (oder DockGe).

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:

openssl rand -base64 48   # für SESSION_SECRET

Dann starten:

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.

Damit der Proxy die App erreicht, hängt der app-Service zusätzlich im Netzwerk des Proxys. Die compose.yaml erwartet es unter dem Namen nginx_default — so heißt es bei Nginx Proxy Manager. Heißt es bei dir anders, setz PROXY_NETWORK in der .env:

PROXY_NETWORK=mein-proxy-netz

Das Netzwerk muss existieren, bevor der Stack startet; angelegt wird es vom Proxy. Betreibst du gar keinen Proxy in Docker, entferne den networks-Block am Ende der compose.yaml samt der networks-Liste beim app-Service und mappe stattdessen einen Port — dann aber bitte HTTPS anderweitig davorsetzen.

Zu $ in der .env: Docker Compose deutet $name als Variable und ersetzt es durch nichts, wenn es die Variable nicht gibt. Ein Passwort mit $ kommt dadurch gekürzt in der App an — ohne Fehler, nur mit einer leicht zu übersehenden Warnung. Entweder $ vermeiden oder als $$ schreiben.

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:

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.

GExtension-Abgleich

Auf eine bestimmte Instanz zugeschnitten. Dieser Teil liest Profile von gextension.senseigaming.de und ist auf deren Seitenaufbau gebaut. Läuft bei euch eine andere GExtension-Instanz, genügt GEXTENSION_BASE_URL in der .env. Nutzt ihr ein anderes Shop- oder Spendensystem, funktioniert der Abgleich nicht — dafür müsste server/src/lib/gextension.ts angepasst werden. Der Rest der App ist davon unabhängig und läuft auch ohne diesen Teil; Pakete lassen sich immer von Hand oder per Datei-Import eintragen.

Der Abgleich holt die gekauften Pakete eines Spielers direkt aus seinem GExtension-Profil, damit sie nicht abgetippt werden müssen.

Was er tut: Für jeden Spieler, der im CC-Manager erfasst ist und eine SteamID hinterlegt hat, ruft er die öffentliche Profilseite ab und liest die Paket-Liste. Auslösen lässt sich das auf der Übersicht (alle Spieler) oder auf einer Spielerseite (nur dieser eine) — jeweils als Admin.

Was er nicht tut: Er sucht keine neuen Spieler. Wer nicht in der Datenbank steht, wird nie abgefragt, auch wenn er bei GExtension gekauft hat. Neue Käufer legst du von Hand an, danach findet der Abgleich ihre Pakete. Gebucht wird ebenfalls nichts von allein — eindeutige Treffer landen in derselben Vorschau wie der Datei-Import und warten auf Bestätigung.

Erkannt wird anhand von Titel und Bereich: Nur der SCP-RP-Bereich führt CC-Pakete, und ein Titel mit „Redesign" zählt als Redesign, auch wenn „Custom Character" darin vorkommt. Stornierte Käufe werden übersprungen. Doppelbuchungen verhindert die Bestell-ID von GExtension.

Ungeklärte Einträge: Klingt ein Titel nach CC oder Redesign, passt aber in kein Muster (etwa eine einmalige Sonderaktion), wird nicht geraten. Der Eintrag landet in einer Handprüfung: sichtbar als Hinweis auf der Übersicht und als Box auf der Spielerseite, wo ein Admin ihn als CC-Paket, gekauftes Redesign, Kulanz oder „nichts davon" einordnet. Bei CC-Paket entsteht automatisch auch das inklusive Redesign-Guthaben.

Verbrauchszähler: Meldet GExtension „CC-Paket oder Redesign genutzt | N Mal", wird das beim Spieler angezeigt, aber nie automatisch verrechnet — es ist eine Lesehilfe zum Abgleich mit dem eigenen Ledger, mehr nicht.

Der Abgleich hält eine Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus. Die Seite gehört dem Serverbetreiber, nicht dieser App — ein offizieller Datenzugang wäre der sauberere Weg, falls er zu haben ist.

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.

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:

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, Abgleich
  src/routes/            HTTP-Endpunkte
  src/auth/              Passwörter, Sitzungen, CSRF, Rollen, TOTP
  src/lib/               SteamID-Umrechnung, GExtension-Parser
  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:

npm run migrate:dev -- --name kurze-beschreibung

Der Name wird zum Verzeichnisnamen der Migration — dort also besser bei ASCII bleiben.

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.