diff --git a/.env.example b/.env.example index 0ea21a1..824b454 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,14 @@ BOOTSTRAP_ADMIN_PASSWORD= # --- Optional --- +# Adresse der GExtension-Instanz fuer den Paket-Abgleich. Der Parser ist auf +# deren Seitenaufbau gebaut; ein anderes Shop-System laesst sich damit nicht +# auslesen. Leer lassen, wenn der Abgleich nicht genutzt wird. +# GEXTENSION_BASE_URL=https://gextension.senseigaming.de + +# Aufbewahrung der Audit-Eintraege in Tagen (Standard: 365, 0 = nie loeschen). +# AUDIT_RETENTION_DAYS=365 + # Gueltigkeit einer Sitzung in Stunden (Standard: 12). # SESSION_TTL_HOURS=12 diff --git a/README.md b/README.md index 00f05b4..e0d8647 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,47 @@ 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. @@ -148,9 +189,10 @@ und Stornos, Anmeldung mit CSRF- und Rollenprüfung sowie der Import mit Dedup. ``` server/ prisma/schema.prisma Datenmodell und Migrationen - src/services/ Fachlogik: Ledger, CC-Lebenszyklus, Import + 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 diff --git a/compose.yaml b/compose.yaml index 5fdfc60..1eea7fa 100644 --- a/compose.yaml +++ b/compose.yaml @@ -12,6 +12,9 @@ services: BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-admin} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} TRUST_PROXY: "true" + # Nur noetig, wenn der GExtension-Abgleich eine andere Instanz ansprechen soll. + GEXTENSION_BASE_URL: ${GEXTENSION_BASE_URL:-https://gextension.senseigaming.de} + AUDIT_RETENTION_DAYS: ${AUDIT_RETENTION_DAYS:-365} expose: # Bewusst nicht nach aussen gemappt: der Reverse Proxy terminiert HTTPS. - "3000" diff --git a/server/src/env.ts b/server/src/env.ts index d49579f..c9ded68 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -27,6 +27,10 @@ const schema = z.object({ .optional() .transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')), + // Basis-Adresse der GExtension-Instanz. Der Parser ist auf deren Aufbau + // zugeschnitten; eine voellig andere Software liesse sich damit nicht lesen. + GEXTENSION_BASE_URL: z.string().url().default('https://gextension.senseigaming.de'), + // Aufbewahrung der Audit-Eintraege in Tagen. 0 schaltet das Aufraeumen ab. // Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft. AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365), diff --git a/server/src/lib/gextension.ts b/server/src/lib/gextension.ts index 09744d8..f5f048b 100644 --- a/server/src/lib/gextension.ts +++ b/server/src/lib/gextension.ts @@ -132,7 +132,9 @@ export function classifyPackage(pkg: ScrapedPackage): Classification { return { kind: 'ignore' }; } -const PROFILE_URL = 'https://gextension.senseigaming.de/index.php?t=user&id='; +// Standard ist die Instanz, fuer die der Parser gebaut wurde. Laeuft GExtension +// bei einer anderen Community unter eigener Domain, reicht ein anderer Wert. +export const DEFAULT_BASE_URL = 'https://gextension.senseigaming.de'; // Gemeinsam genutzte Community-Infrastruktur: hoechstens eine Anfrage pro Sekunde. const MIN_REQUEST_GAP_MS = 1000; @@ -144,17 +146,28 @@ async function throttle(): Promise { lastRequestAt = Date.now(); } -export async function fetchProfile(steamId: string, timeoutMs = 15000): Promise { +export interface FetchOptions { + baseUrl?: string; + timeoutMs?: number; +} + +export async function fetchProfile( + steamId: string, + options: FetchOptions = {}, +): Promise { const steamId64 = normalizeSteamId(steamId); if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gueltige SteamID: ${steamId}`); + const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); + const timeoutMs = options.timeoutMs ?? 15000; + await throttle(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(`${PROFILE_URL}${steamId64}`, { + const response = await fetch(`${baseUrl}/index.php?t=user&id=${steamId64}`, { signal: controller.signal, headers: { // Ehrliche Kennzeichnung statt Browser-Tarnung. diff --git a/server/src/services/scraper.ts b/server/src/services/scraper.ts index c6a90f8..128c94c 100644 --- a/server/src/services/scraper.ts +++ b/server/src/services/scraper.ts @@ -1,5 +1,6 @@ import { ImportStatus, type Prisma } from '@prisma/client'; import { prisma } from '../db.js'; +import { env } from '../env.js'; import { badRequest } from '../errors.js'; import { classifyPackage, @@ -59,7 +60,7 @@ export async function scrapePlayers( for (const target of targets) { let profile: ScrapedProfile; try { - profile = await fetchProfile(target.steamId); + profile = await fetchProfile(target.steamId, { baseUrl: env.GEXTENSION_BASE_URL }); summary.scannedPlayers += 1; } catch (error) { summary.failedPlayers.push({