Der Forums-Abgleich las von 25 Archivseiten nur zwei. WoltLab verlinkt in der Blätterleiste nur die Nachbarseiten und führt die Gesamtzahl in einem eigenen Element; ausgewertet wurden aber die Links. Alles jenseits von Seite zwei war damit unsichtbar - etwa ein Antrag auf Seite vier, der deshalb nie ankam. Die Rollen verlaufen jetzt zwischen erfassen und gewähren statt zwischen Alltag und Verwaltung: Editoren erfassen Käufe und führen Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung über Anträge durch. Kulanz zu vergeben bleibt Administratoren, ebenso das Auflösen unklarer Pakete, weil dabei Kulanz entstehen kann. Dazu lassen sich frühere Steam- und Forumskonten am Spieler hinterlegen. Abgleich und Import prüfen sie mit, sodass ein Kontowechsel die Zuordnung nicht abreißen lässt. Steam-Kennungen werden dabei auf dieselbe Form gebracht wie die Hauptangabe, und eine Kennung kann nicht zwei Spielern gehören.
293 lines
14 KiB
Markdown
293 lines
14 KiB
Markdown
# 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).
|
|
|
|
```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.
|
|
|
|
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:
|
|
|
|
```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.
|
|
|
|
## 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.
|
|
|
|
## Forums-Anträge
|
|
|
|
> Ebenfalls auf eine bestimmte Instanz zugeschnitten: gelesen wird ein **WoltLab-Forum**
|
|
> (Burning Board) unter `senseigaming.de/forum`, konfigurierbar über `FORUM_BASE_URL`. Ein
|
|
> anderes Forensystem bräuchte einen eigenen Parser in `server/src/lib/forum.ts`.
|
|
|
|
Während der GExtension-Abgleich die **Käufe** liefert, steht der **Verbrauch** im Forum: Wurde
|
|
ein Antrag angenommen, abgelehnt, war es ein neuer CC oder ein Redesign? Unter *Anträge* liest
|
|
die App die drei CC-Boards aus und sammelt die Threads.
|
|
|
|
**Entschieden wird ausschließlich von Hand.** Titel und Board werden bewusst nicht ausgewertet —
|
|
Threads werden nicht immer zuverlässig verschoben, ein längst genehmigter Antrag kann noch im
|
|
Antrags-Board liegen. Das aktuelle Board steht deshalb nur als Hinweis an der Zeile.
|
|
|
|
Pro Antrag wählt ein Admin: *angenommen als neuer CC*, *angenommen als Redesign* (mit Auswahl,
|
|
welcher CC ersetzt wurde und woraus bezahlt wird), *angenommen als Reaktivierung* (ein inaktiver
|
|
CC kehrt zurück — gratis, sofern er nicht redesignt wurde), *abgelehnt* oder *kein Antrag*. Die beiden
|
|
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite —
|
|
es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
|
|
|
|
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler —
|
|
und über die **früheren Konten**, die auf der Spielerseite nachgetragen werden können. Wer seinen
|
|
Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche.
|
|
**Threads ohne zuordenbaren Spieler werden gar nicht erst erfasst** — das Archiv reicht Jahre
|
|
zurück und enthält vieles, was kein CC-Antrag ist. Fehlt jemand, trag seinen Forumsnamen beim
|
|
Spieler nach, dann nimmt ihn der nächste Abgleich auf.
|
|
|
|
Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre
|
|
Labels *Wichtig* und *Information* aussortiert. Der Titel wird dagegen **nie** ausgewertet: CC-Anträge
|
|
heißen mal „CC Antrag | Name", mal „\"The Duke\" Rang Update" — daran ist nichts zu erkennen.
|
|
|
|
Trägst du einen Antrag von Hand ein — über *Neuer CC* oder *Redesign* auf der Spielerseite —
|
|
kannst du dort den Thread-Link angeben. Der Antrag gilt damit als erledigt und taucht beim
|
|
nächsten Abgleich nicht erneut auf.
|
|
|
|
Ein Thread behält seine ID, auch wenn er zwischen den Boards verschoben wird — daran hängt die
|
|
Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein anderes Board, etwa
|
|
ins Archiv, erscheint das oben als Hinweis. Es ändert nie automatisch den CC-Status: dafür ist
|
|
das Signal zu unzuverlässig.
|
|
|
|
## Rollen
|
|
|
|
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
|
|
|
|
- **Viewer** — darf nur lesen.
|
|
- **Editor** — Spieler anlegen und bearbeiten, gekaufte Pakete eintragen, CCs erstellen,
|
|
redesignen und reaktivieren, Datei-Import, GExtension- und Forums-Abgleich samt Entscheidung
|
|
über Anträge.
|
|
- **Admin** — zusätzlich Kulanz vergeben, Korrekturbuchungen, unklare Pakete auflösen, Spieler
|
|
löschen, Nutzerverwaltung, Produkt-Mappings und Audit-Log.
|
|
|
|
Die Grenze verläuft zwischen *erfassen* und *gewähren*: Wer einen Kauf einträgt, hält fest, was
|
|
ohnehin passiert ist. Kulanz dagegen ist eine Entscheidung des Teams und bleibt Administratoren
|
|
vorbehalten — ebenso das Auflösen unklarer Pakete, weil dabei Kulanz vergeben werden kann.
|
|
|
|
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, 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:
|
|
|
|
```bash
|
|
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](LICENSE).
|