Bisher entschied allein das Redesign darueber, ob eine Rueckkehr etwas kostet. Es fehlten zwei Faelle: ein Entzug ohne Anspruch auf kostenlose Rueckkehr, und der Tod im Rollenspiel. Ein toter CC laesst sich weder gratis noch gegen Guthaben auf dem normalen Weg zurueckholen - das entscheidet ein Administrator ueber einen eigenen Weg, mit Pflicht-Begruendung, und waehlt dabei, ob ein Guthaben fliesst. Schon das Eintragen des Todes verlangt eine Notiz. Dazu koennen sich mehrere Spieler einen CC teilen. Die Zugehoerigkeit laeuft ab jetzt durchgehend ueber eine eigene Tabelle, auch bei Einzel-CCs; die Migration traegt bestehende CCs als erstes Mitglied ihres Erstellers nach. Ohne das waere nach dem Update kein CC mehr beim Spieler aufgetaucht. Fuer jeden Mitnutzer gelten dieselben Regeln wie fuer einen eigenen CC: der Platz kostet ein eigenes Guthaben, wahlweise aus dem CC-Paket oder ueber ein Redesign. Ein Austritt gibt nichts zurueck, denn bis dahin wurde der CC genutzt; fuer eine Fehleingabe ist das Storno der richtige Weg. Der letzte Nutzer laesst sich nicht austragen - dann wird der CC deaktiviert. Ein Redesign nimmt die Gruppe mit.
318 lines
15 KiB
Markdown
318 lines
15 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. **Der Grund entscheidet über die 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 redesignt oder entzogen, kostet die Rückkehr ein Redesign- oder CC-Guthaben. Ist er
|
|
im Rollenspiel gestorben, entscheidet darüber ausschließlich ein Administrator, mit Begründung.
|
|
|
|
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. Vier Gründe stehen
|
|
zur Wahl:
|
|
|
|
| Grund | Rückkehr |
|
|
|---|---|
|
|
| Spieler-Inaktivität | gratis |
|
|
| Anderer Grund (z. B. Einheit zeitweise entfernt) | gratis |
|
|
| Entzogen | kostet ein Guthaben |
|
|
| Im Rollenspiel gestorben | nur durch einen Administrator, mit Begründung |
|
|
|
|
## Gruppen-CCs
|
|
|
|
Manche CCs teilen sich mehrere Spieler. Für jeden gelten dabei dieselben Regeln wie für einen
|
|
eigenen CC: **der Platz kostet ein eigenes Guthaben** - entweder aus dem CC-Paket oder über ein
|
|
Redesign, je nachdem, worüber der Spieler hinzukommt.
|
|
|
|
Über *Nutzer aufnehmen* auf der Spielerseite kommt jemand dazu; das Guthaben wird von seinem
|
|
Konto gebucht, nicht vom Konto des Gründers. Wer die Gruppe verlässt, wird über das ✕ an seinem
|
|
Namen ausgetragen - **verbrauchtes Guthaben gibt es dabei nicht zurück**, denn bis dahin hat er
|
|
den CC genutzt. Für eine Fehleingabe ist das Storno der Buchung der richtige Weg.
|
|
|
|
Ausgetretene bleiben als „früher: …" sichtbar, damit nachvollziehbar ist, wer den CC wann genutzt
|
|
hat. Der letzte verbliebene Nutzer lässt sich nicht austragen - dann wird der CC deaktiviert.
|
|
|
|
Ein Redesign nimmt die Gruppe mit: Wer zum Zeitpunkt des Redesigns dabei war, ist es danach auch.
|
|
Bezahlt wird es von dem, der es beantragt.
|
|
|
|
## 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).
|