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.
This commit is contained in:
+5
-5
@@ -106,7 +106,7 @@ export async function buildApp(): Promise<FastifyInstance> {
|
||||
if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) {
|
||||
reply
|
||||
.code(statusCode)
|
||||
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungueltige Anfrage' });
|
||||
.send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungültige Anfrage' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,17 +128,17 @@ export async function buildApp(): Promise<FastifyInstance> {
|
||||
await app.register(fastifyStatic, {
|
||||
root: webRoot,
|
||||
prefix: '/',
|
||||
// maxAge wuerde den Header fuer *alle* Dateien setzen und dabei auch die
|
||||
// maxAge würde den Header für *alle* Dateien setzen und dabei auch die
|
||||
// index.html erwischen - die muss aber bei jedem Aufruf neu geholt werden,
|
||||
// sonst zeigt der Browser nach einem Update monatelang die alte Oberflaeche.
|
||||
// sonst zeigt der Browser nach einem Update monatelang die alte Oberfläche.
|
||||
// Deshalb hier abschalten und beides unten selbst setzen.
|
||||
cacheControl: false,
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
||||
} else {
|
||||
// Assets tragen einen Inhalts-Hash im Namen: aendert sich der Inhalt,
|
||||
// aendert sich der Dateiname. Die duerfen beliebig lange liegen.
|
||||
// Assets tragen einen Inhalts-Hash im Namen: ändert sich der Inhalt,
|
||||
// ändert sich der Dateiname. Die dürfen beliebig lange liegen.
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ const RANK: Record<Role, number> = { VIEWER: 1, EDITOR: 2, ADMIN: 3 };
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
// Ohne diese Ausnahme koennte sich niemand anmelden: vor dem Login gibt es
|
||||
// Ohne diese Ausnahme könnte sich niemand anmelden: vor dem Login gibt es
|
||||
// weder Session noch ableitbares CSRF-Token.
|
||||
const CSRF_EXEMPT = new Set(['/api/auth/login', '/api/auth/login/totp']);
|
||||
|
||||
@@ -63,7 +63,7 @@ export const blockUntilPasswordChanged: preHandlerHookHandler = async (request)
|
||||
if (request.user?.mustChangePassword && !SAFE_METHODS.has(request.method)) {
|
||||
const path = request.url.split('?')[0] ?? '';
|
||||
if (path !== '/api/account/password' && path !== '/api/auth/logout') {
|
||||
throw forbidden('Bitte zuerst das Passwort aendern');
|
||||
throw forbidden('Bitte zuerst das Passwort ändern');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
|
||||
// OWASP-Empfehlung fuer Argon2id: 19 MiB, 2 Durchlaeufe, Parallelitaet 1.
|
||||
// OWASP-Empfehlung für Argon2id: 19 MiB, 2 Durchläufe, Parallelität 1.
|
||||
const options = {
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
@@ -27,7 +27,7 @@ export function validatePasswordStrength(plain: string): string | null {
|
||||
return `Passwort muss mindestens ${MIN_LENGTH} Zeichen lang sein`;
|
||||
}
|
||||
if (!/[a-z]/.test(plain) || !/[A-Z]/.test(plain)) {
|
||||
return 'Passwort muss Gross- und Kleinbuchstaben enthalten';
|
||||
return 'Passwort muss Groß- und Kleinbuchstaben enthalten';
|
||||
}
|
||||
if (!/[0-9]/.test(plain)) {
|
||||
return 'Passwort muss mindestens eine Ziffer enthalten';
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function destroyAllSessionsFor(userId: number): Promise<void> {
|
||||
await prisma.session.deleteMany({ where: { userId } });
|
||||
}
|
||||
|
||||
/** Beendet alle Sitzungen des Nutzers ausser der gerade verwendeten. */
|
||||
/** Beendet alle Sitzungen des Nutzers außer der gerade verwendeten. */
|
||||
export async function destroyOtherSessionsFor(userId: number, keepToken: string): Promise<void> {
|
||||
await prisma.session.deleteMany({
|
||||
where: { userId, NOT: { tokenHash: hashToken(keepToken) } },
|
||||
|
||||
@@ -3,7 +3,7 @@ import QRCode from 'qrcode';
|
||||
|
||||
const ISSUER = 'CC-Manager';
|
||||
|
||||
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen faengt leicht
|
||||
// Toleranz in Sekunden. Ein Zeitschritt in beide Richtungen fängt leicht
|
||||
// abweichende Uhren zwischen Server und Authenticator-App ab.
|
||||
const EPOCH_TOLERANCE_SECONDS = 30;
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ import { prisma } from './db.js';
|
||||
import { env } from './env.js';
|
||||
import { hashPassword } from './auth/password.js';
|
||||
|
||||
// Startpunkte fuer die Produkterkennung beim Import. Jederzeit im Admin-Bereich
|
||||
// Startpunkte für die Produkterkennung beim Import. Jederzeit im Admin-Bereich
|
||||
// anpassbar, sobald die echten Produktnamen bekannt sind.
|
||||
// Reihenfolge ist entscheidend: "Custom Character Redesign" enthaelt beide
|
||||
// Stichworte und muss als Redesign zaehlen, nicht als CC-Paket. Deshalb steht
|
||||
// Reihenfolge ist entscheidend: "Custom Character Redesign" enthält beide
|
||||
// Stichworte und muss als Redesign zählen, nicht als CC-Paket. Deshalb steht
|
||||
// das Redesign-Muster vorn.
|
||||
const DEFAULT_MAPPINGS = [
|
||||
{ matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 5 },
|
||||
@@ -52,7 +52,7 @@ export async function bootstrap(log: {
|
||||
if (!env.BOOTSTRAP_ADMIN_PASSWORD) {
|
||||
log.warn(`Einmal-Passwort: ${password}`);
|
||||
}
|
||||
log.warn('Das Passwort muss bei der ersten Anmeldung geaendert werden.');
|
||||
log.warn('Das Passwort muss bei der ersten Anmeldung geändert werden.');
|
||||
log.warn('─'.repeat(60));
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -15,23 +15,23 @@ const schema = z.object({
|
||||
BOOTSTRAP_ADMIN_USER: z.string().min(3).default('admin'),
|
||||
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(12).optional(),
|
||||
|
||||
// Hinter einem Reverse Proxy noetig, damit Client-IPs im Audit-Log stimmen.
|
||||
// Hinter einem Reverse Proxy nötig, damit Client-IPs im Audit-Log stimmen.
|
||||
TRUST_PROXY: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((v) => v === 'true' || v === '1'),
|
||||
|
||||
// Muss in Produktion true sein (Cookies nur ueber HTTPS).
|
||||
// Muss in Produktion true sein (Cookies nur über HTTPS).
|
||||
COOKIE_SECURE: z
|
||||
.string()
|
||||
.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.
|
||||
// zugeschnitten; eine völlig andere Software ließe 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.
|
||||
// Aufbewahrung der Audit-Einträge in Tagen. 0 schaltet das Aufräumen ab.
|
||||
// Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft.
|
||||
AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365),
|
||||
|
||||
@@ -42,7 +42,7 @@ const parsed = schema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
|
||||
console.error(`Konfiguration unvollstaendig:\n${issues}`);
|
||||
console.error(`Konfiguration unvollständig:\n${issues}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export class AppError extends Error {
|
||||
export const badRequest = (code: string, message: string) => new AppError(400, code, message);
|
||||
export const unauthorized = (message = 'Nicht angemeldet') =>
|
||||
new AppError(401, 'UNAUTHORIZED', message);
|
||||
export const forbidden = (message = 'Keine Berechtigung fuer diese Aktion') =>
|
||||
export const forbidden = (message = 'Keine Berechtigung für diese Aktion') =>
|
||||
new AppError(403, 'FORBIDDEN', message);
|
||||
export const notFound = (what: string) => new AppError(404, 'NOT_FOUND', `${what} nicht gefunden`);
|
||||
export const conflict = (code: string, message: string) => new AppError(409, code, message);
|
||||
|
||||
+3
-3
@@ -27,13 +27,13 @@ async function main(): Promise<void> {
|
||||
.then((count) => {
|
||||
if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`);
|
||||
})
|
||||
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen'));
|
||||
.catch((error) => app.log.warn({ err: error }, 'Aufräumen der Sitzungen fehlgeschlagen'));
|
||||
|
||||
cleanupOldAuditEntries()
|
||||
.then((count) => {
|
||||
if (count > 0) app.log.info(`${count} abgelaufene Audit-Eintraege entfernt`);
|
||||
if (count > 0) app.log.info(`${count} abgelaufene Audit-Einträge entfernt`);
|
||||
})
|
||||
.catch((error) => app.log.warn({ err: error }, 'Aufraeumen des Audit-Logs fehlgeschlagen'));
|
||||
.catch((error) => app.log.warn({ err: error }, 'Aufräumen des Audit-Logs fehlgeschlagen'));
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
cleanup.unref();
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { normalizeSteamId } from './steamid.js';
|
||||
|
||||
/**
|
||||
* Liest die Paket-Liste eines GExtension-Profils aus. Die Seite bettet die
|
||||
* Kaeufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON
|
||||
* Käufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON
|
||||
* gelesen statt die gerenderte Tabelle geparst - das ist deutlich robuster
|
||||
* gegenueber Layout-Aenderungen und liefert zusaetzlich Kaufdatum, Betrag
|
||||
* gegenüber Layout-Änderungen und liefert zusätzlich Kaufdatum, Betrag
|
||||
* und den Storno-Status.
|
||||
*/
|
||||
|
||||
@@ -91,11 +91,11 @@ export type Classification =
|
||||
| { kind: 'unclear' }
|
||||
| { kind: 'ignore' };
|
||||
|
||||
// Nur der SCP-RP-Bereich fuehrt CC-relevante Pakete.
|
||||
// Nur der SCP-RP-Bereich führt CC-relevante Pakete.
|
||||
const RELEVANT_BUNDLE = 'scp-rp';
|
||||
|
||||
// Faengt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner
|
||||
// Regel sicher zugeordnet werden - die gehen in die Handpruefung statt geraten
|
||||
// Fängt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner
|
||||
// Regel sicher zugeordnet werden - die gehen in die Handprüfung statt geraten
|
||||
// zu werden.
|
||||
const CC_HINT = /(^|\W)(cc|custom\s*charact|custom\s*charakt|redesign)/i;
|
||||
|
||||
@@ -104,8 +104,8 @@ const USAGE_TIMES = /\|\s*(\d+)\s*mal/i;
|
||||
|
||||
/**
|
||||
* Ordnet einen Paket-Titel einer Guthaben-Art zu. Die Reihenfolge ist
|
||||
* bewusst: "Custom Character Redesign" enthaelt beide Stichworte und muss als
|
||||
* Redesign zaehlen, nicht als CC-Paket.
|
||||
* bewusst: "Custom Character Redesign" enthält beide Stichworte und muss als
|
||||
* Redesign zählen, nicht als CC-Paket.
|
||||
*/
|
||||
export function classifyPackage(pkg: ScrapedPackage): Classification {
|
||||
if (pkg.revoked) return { kind: 'ignore' };
|
||||
@@ -132,11 +132,11 @@ export function classifyPackage(pkg: ScrapedPackage): Classification {
|
||||
return { kind: 'ignore' };
|
||||
}
|
||||
|
||||
// Standard ist die Instanz, fuer die der Parser gebaut wurde. Laeuft GExtension
|
||||
// Standard ist die Instanz, für die der Parser gebaut wurde. Läuft 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.
|
||||
// Gemeinsam genutzte Community-Infrastruktur: höchstens eine Anfrage pro Sekunde.
|
||||
const MIN_REQUEST_GAP_MS = 1000;
|
||||
let lastRequestAt = 0;
|
||||
|
||||
@@ -156,7 +156,7 @@ export async function fetchProfile(
|
||||
options: FetchOptions = {},
|
||||
): Promise<ScrapedProfile> {
|
||||
const steamId64 = normalizeSteamId(steamId);
|
||||
if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gueltige SteamID: ${steamId}`);
|
||||
if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gültige SteamID: ${steamId}`);
|
||||
|
||||
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
const timeoutMs = options.timeoutMs ?? 15000;
|
||||
@@ -171,7 +171,7 @@ export async function fetchProfile(
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
// Ehrliche Kennzeichnung statt Browser-Tarnung.
|
||||
'user-agent': 'CC-Manager/1.0 (Paket-Abgleich fuer das CC-Team)',
|
||||
'user-agent': 'CC-Manager/1.0 (Paket-Abgleich für das CC-Team)',
|
||||
accept: 'text/html',
|
||||
},
|
||||
});
|
||||
@@ -183,7 +183,7 @@ export async function fetchProfile(
|
||||
return parseProfile(await response.text(), steamId64);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw badRequest('PROFILE_TIMEOUT', 'Zeitueberschreitung beim Abruf des Profils');
|
||||
throw badRequest('PROFILE_TIMEOUT', 'Zeitüberschreitung beim Abruf des Profils');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -16,7 +16,7 @@ const STEAM3 = /^\[?U:1:(\d+)\]?$/i;
|
||||
const STEAM64 = /^\d{17}$/;
|
||||
|
||||
/**
|
||||
* Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurueck, wenn der Wert
|
||||
* Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurück, wenn der Wert
|
||||
* nach keiner bekannten Steam-Schreibweise aussieht.
|
||||
*/
|
||||
export function normalizeSteamId(input: string): string | null {
|
||||
@@ -37,14 +37,14 @@ export function normalizeSteamId(input: string): string | null {
|
||||
|
||||
if (STEAM64.test(value)) {
|
||||
const numeric = BigInt(value);
|
||||
// Alles unterhalb des Offsets waere keine Nutzer-ID.
|
||||
// Alles unterhalb des Offsets wäre keine Nutzer-ID.
|
||||
return numeric > BASE ? numeric.toString() : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Legacy-Form zur Anzeige, etwa fuer Server-Konsolen. */
|
||||
/** Legacy-Form zur Anzeige, etwa für Server-Konsolen. */
|
||||
export function toLegacySteamId(steamId64: string): string | null {
|
||||
if (!STEAM64.test(steamId64)) return null;
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.delete('/api/admin/users/:id', async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
if (id === request.user?.id) {
|
||||
throw conflict('SELF_DELETE', 'Der eigene Account kann nicht geloescht werden');
|
||||
throw conflict('SELF_DELETE', 'Der eigene Account kann nicht gelöscht werden');
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
|
||||
@@ -77,7 +77,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
sameSite: 'lax',
|
||||
expires: expiresAt,
|
||||
});
|
||||
// Muss fuer das Double-Submit-Verfahren lesbar sein.
|
||||
// Muss für das Double-Submit-Verfahren lesbar sein.
|
||||
reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
@@ -91,7 +91,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
const body = parseWith(loginSchema, request.body);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username: body.username } });
|
||||
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verraet.
|
||||
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verrät.
|
||||
const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const passwordOk = await verifyPassword(digest, body.password);
|
||||
|
||||
@@ -250,7 +250,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => {
|
||||
const secret = generateSecret();
|
||||
// Erst nach bestaetigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
|
||||
// Erst nach bestätigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
|
||||
await prisma.user.update({
|
||||
where: { id: request.user!.id },
|
||||
data: { totpSecret: secret, totpEnabled: false },
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function characterRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post('/api/characters/:id/revert', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }),
|
||||
z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }),
|
||||
request.body,
|
||||
);
|
||||
return revertCharacter(id, body.reason, contextOf(request));
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
||||
z.object({
|
||||
kind: z.nativeEnum(CreditKind),
|
||||
amount: z.number().int().refine((v) => v !== 0, 'Betrag darf nicht 0 sein'),
|
||||
note: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500),
|
||||
note: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
@@ -52,7 +52,7 @@ export async function creditRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post('/api/credits/:id/reverse', { preHandler: requireEditor }, async (request) => {
|
||||
const { id } = parseWith(idParam, request.params);
|
||||
const body = parseWith(
|
||||
z.object({ reason: z.string().trim().min(1, 'Eine Begruendung ist Pflicht').max(500) }),
|
||||
z.object({ reason: z.string().trim().min(1, 'Eine Begründung ist Pflicht').max(500) }),
|
||||
request.body,
|
||||
);
|
||||
return reverseGroup(id, body.reason, contextOf(request));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import { badRequest, unauthorized } from '../errors.js';
|
||||
import type { ActionContext } from '../services/audit.js';
|
||||
|
||||
// z.output statt z.input: nur so zaehlen .default()-Werte als gesetzt.
|
||||
// z.output statt z.input: nur so zählen .default()-Werte als gesetzt.
|
||||
export function parseWith<S extends z.ZodTypeAny>(schema: S, input: unknown): z.output<S> {
|
||||
const result = schema.safeParse(input);
|
||||
if (!result.success) {
|
||||
|
||||
@@ -14,16 +14,16 @@ import {
|
||||
import { contextOf, idParam, parseWith } from './helpers.js';
|
||||
|
||||
const mappingBody = z.object({
|
||||
playerKey: z.string().min(1, 'Spalte fuer die Spieler-Kennung fehlt'),
|
||||
playerKey: z.string().min(1, 'Spalte für die Spieler-Kennung fehlt'),
|
||||
playerName: z.string().optional(),
|
||||
product: z.string().min(1, 'Spalte fuer den Produktnamen fehlt'),
|
||||
product: z.string().min(1, 'Spalte für den Produktnamen fehlt'),
|
||||
orderId: z.string().optional(),
|
||||
purchasedAt: z.string().optional(),
|
||||
createMissingPlayers: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export async function importRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Der Import veraendert Guthaben in grosser Zahl und bleibt deshalb Admins vorbehalten.
|
||||
// Der Import verändert Guthaben in großer Zahl und bleibt deshalb Admins vorbehalten.
|
||||
app.post('/api/imports', { preHandler: requireAdmin }, async (request, reply) => {
|
||||
const file = await request.file();
|
||||
if (!file) throw badRequest('NO_FILE', 'Es wurde keine Datei hochgeladen');
|
||||
|
||||
@@ -15,7 +15,7 @@ import { contextOf, idParam, parseWith } from './helpers.js';
|
||||
export async function scraperRoutes(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* Gleicht die Profile bereits erfasster Spieler ab. Bewusst nur die
|
||||
* bekannten: der Abgleich soll vorhandene Konten ergaenzen, nicht fremde
|
||||
* bekannten: der Abgleich soll vorhandene Konten ergänzen, nicht fremde
|
||||
* Profile auf Vorrat einsammeln.
|
||||
*/
|
||||
app.post('/api/scrape/run', { preHandler: requireAdmin }, async (request) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { prisma } from '../db.js';
|
||||
import { env } from '../env.js';
|
||||
|
||||
export interface ActionContext {
|
||||
// Beide null fuer Vorgaenge ohne bekanntes Konto, etwa einen fehlgeschlagenen
|
||||
// Beide null für Vorgänge ohne bekanntes Konto, etwa einen fehlgeschlagenen
|
||||
// Login auf einen Benutzernamen, den es gar nicht gibt.
|
||||
actorId: number | null;
|
||||
actorName: string | null;
|
||||
@@ -38,9 +38,9 @@ export async function recordAudit(
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt Audit-Eintraege, die aelter sind als die eingestellte Frist. Die
|
||||
* Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit fuer
|
||||
* bezahlte Ansprueche und hat eine andere Aufbewahrung als das Protokoll.
|
||||
* Entfernt Audit-Einträge, die älter sind als die eingestellte Frist. Die
|
||||
* Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit für
|
||||
* bezahlte Ansprüche und hat eine andere Aufbewahrung als das Protokoll.
|
||||
*/
|
||||
export async function cleanupOldAuditEntries(): Promise<number> {
|
||||
if (env.AUDIT_RETENTION_DAYS === 0) return 0;
|
||||
|
||||
@@ -15,13 +15,13 @@ export interface CharacterResult {
|
||||
character: Character;
|
||||
balances: Balances;
|
||||
// Zeigt an, dass ein Topf nach der Buchung im Minus steht. Die Aktion wird
|
||||
// trotzdem ausgefuehrt, das Ergebnis gehoert aber sichtbar gemacht.
|
||||
// trotzdem ausgeführt, das Ergebnis gehört aber sichtbar gemacht.
|
||||
overdrawn: boolean;
|
||||
}
|
||||
|
||||
// Fuer ein Redesign zaehlen nur die beiden Redesign-Toepfe.
|
||||
// Für ein Redesign zählen nur die beiden Redesign-Töpfe.
|
||||
const REDESIGN_KINDS = [CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ] as const;
|
||||
// Beim Wiederbeleben eines reworkten CCs darf zusaetzlich CC-Guthaben gezogen werden.
|
||||
// Beim Wiederbeleben eines reworkten CCs darf zusätzlich CC-Guthaben gezogen werden.
|
||||
const REVIVE_KINDS = [CreditKind.CC, ...REDESIGN_KINDS] as const;
|
||||
|
||||
export type RedesignKind = (typeof REDESIGN_KINDS)[number];
|
||||
@@ -37,7 +37,7 @@ async function loadCharacter(id: number): Promise<Character> {
|
||||
return character;
|
||||
}
|
||||
|
||||
/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafuer nicht einsetzbar. */
|
||||
/** Neuer CC. Kostet ein CC-Guthaben, Kulanz ist dafür nicht einsetzbar. */
|
||||
export async function createCharacter(
|
||||
playerId: number,
|
||||
input: { name: string; loreText?: string | undefined; note?: string | undefined },
|
||||
@@ -87,8 +87,8 @@ export async function createCharacter(
|
||||
|
||||
/**
|
||||
* Redesign als Lineage: der alte CC geht auf INACTIVE/REWORKED, der neue
|
||||
* entsteht als eigener Datensatz in derselben Kette. Damit greift spaeter
|
||||
* automatisch die Regel, dass ein reworkter CC nur bezahlt zurueckkommt.
|
||||
* entsteht als eigener Datensatz in derselben Kette. Damit greift später
|
||||
* automatisch die Regel, dass ein reworkter CC nur bezahlt zurückkommt.
|
||||
*/
|
||||
export async function redesignCharacter(
|
||||
characterId: number,
|
||||
@@ -165,7 +165,7 @@ export async function redesignCharacter(
|
||||
};
|
||||
}
|
||||
|
||||
/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivitaet. */
|
||||
/** Deaktivierung ohne Guthaben-Effekt, etwa beim Serverputz wegen Inaktivität. */
|
||||
export async function deactivateCharacter(
|
||||
characterId: number,
|
||||
reason: InactiveReason,
|
||||
@@ -179,7 +179,7 @@ export async function deactivateCharacter(
|
||||
if (reason === InactiveReason.REWORKED) {
|
||||
throw badRequest(
|
||||
'USE_REDESIGN',
|
||||
'Ein Rework entsteht ueber die Redesign-Aktion, nicht ueber eine Deaktivierung',
|
||||
'Ein Rework entsteht über die Redesign-Aktion, nicht über eine Deaktivierung',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,9 +208,9 @@ export async function deactivateCharacter(
|
||||
}
|
||||
|
||||
/**
|
||||
* Gratis-Reaktivierung. Moeglich, solange der CC nicht redesignt wurde. Der
|
||||
* Grund des Verschwindens spielt dabei keine Rolle - ob Spieler-Inaktivitaet
|
||||
* oder eine zeitweise entfernte Einheit: der CC existiert unveraendert weiter
|
||||
* Gratis-Reaktivierung. Möglich, solange der CC nicht redesignt wurde. Der
|
||||
* Grund des Verschwindens spielt dabei keine Rolle - ob Spieler-Inaktivität
|
||||
* oder eine zeitweise entfernte Einheit: der CC existiert unverändert weiter
|
||||
* und kostet deshalb kein Guthaben.
|
||||
*/
|
||||
export async function reactivateFree(
|
||||
@@ -250,7 +250,7 @@ export async function reactivateFree(
|
||||
}
|
||||
|
||||
/**
|
||||
* Bezahlte Reaktivierung, ausschliesslich fuer redesignte CCs: deren Original
|
||||
* Bezahlte Reaktivierung, ausschließlich für redesignte CCs: deren Original
|
||||
* existiert nicht mehr, es muss also neu erschaffen werden. Kostet ein
|
||||
* Redesign- oder ein CC-Guthaben, der Nutzer entscheidet welches.
|
||||
*/
|
||||
@@ -268,11 +268,11 @@ export async function reactivatePaid(
|
||||
if (existing.inactiveReason !== InactiveReason.REWORKED) {
|
||||
throw conflict(
|
||||
'IS_FREE',
|
||||
'Dieser CC existiert unveraendert weiter und kommt gratis zurueck. Eine Guthaben-Buchung waere hier falsch.',
|
||||
'Dieser CC existiert unverändert weiter und kommt gratis zurück. Eine Guthaben-Buchung wäre hier falsch.',
|
||||
);
|
||||
}
|
||||
if (!REVIVE_KINDS.includes(useKind)) {
|
||||
throw badRequest('INVALID_KIND', 'Ungueltige Guthaben-Art fuer eine Reaktivierung');
|
||||
throw badRequest('INVALID_KIND', 'Ungültige Guthaben-Art für eine Reaktivierung');
|
||||
}
|
||||
|
||||
const character = await prisma.$transaction(async (tx) => {
|
||||
@@ -341,9 +341,9 @@ export async function updateCharacter(
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt einen irrtuemlich angelegten CC komplett zurueck: Verbrauchsbuchungen
|
||||
* werden gegengebucht und ein durch das Redesign deaktivierter Vorgaenger
|
||||
* kehrt in den aktiven Zustand zurueck.
|
||||
* Nimmt einen irrtümlich angelegten CC komplett zurück: Verbrauchsbuchungen
|
||||
* werden gegengebucht und ein durch das Redesign deaktivierter Vorgänger
|
||||
* kehrt in den aktiven Zustand zurück.
|
||||
*/
|
||||
export async function revertCharacter(
|
||||
characterId: number,
|
||||
@@ -359,7 +359,7 @@ export async function revertCharacter(
|
||||
if (existing.successors.length > 0) {
|
||||
throw conflict(
|
||||
'HAS_SUCCESSORS',
|
||||
'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den juengsten CC zuruecknehmen.',
|
||||
'Dieser CC hat Nachfolger in der Redesign-Kette. Bitte zuerst den jüngsten CC zurücknehmen.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -384,7 +384,7 @@ export async function revertCharacter(
|
||||
amount: -entry.amount,
|
||||
source: CreditSource.REVERSAL,
|
||||
groupId: entry.groupId,
|
||||
note: `Ruecknahme "${existing.name}": ${reason}`,
|
||||
note: `Rücknahme "${existing.name}": ${reason}`,
|
||||
reversalOfId: entry.id,
|
||||
createdById: ctx.actorId,
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface Balances {
|
||||
CC: number;
|
||||
REDESIGN_PAID: number;
|
||||
REDESIGN_KULANZ: number;
|
||||
// Fuer ein Redesign zaehlen beide Redesign-Toepfe zusammen.
|
||||
// Für ein Redesign zählen beide Redesign-Töpfe zusammen.
|
||||
redesignTotal: number;
|
||||
}
|
||||
|
||||
@@ -72,8 +72,8 @@ export interface PackageRowParams {
|
||||
|
||||
/**
|
||||
* Baut die Ledger-Zeilen eines gekauften Pakets. Die Regel, dass ein CC-Paket
|
||||
* immer auch ein bezahltes Redesign enthaelt, steht nur hier, damit manuelle
|
||||
* Eingabe und Import nicht auseinanderlaufen koennen.
|
||||
* immer auch ein bezahltes Redesign enthält, steht nur hier, damit manuelle
|
||||
* Eingabe und Import nicht auseinanderlaufen können.
|
||||
*/
|
||||
export function buildPackageRows(
|
||||
target: 'CC_PACKAGE' | 'REDESIGN_PACKAGE',
|
||||
@@ -102,7 +102,7 @@ export function buildPackageRows(
|
||||
/**
|
||||
* Ein CC-Paket erzeugt immer beide Guthaben: einen CC und ein bezahltes
|
||||
* Redesign. Beide Zeilen teilen sich eine groupId, damit ein Storno sie
|
||||
* gemeinsam zurueckholt.
|
||||
* gemeinsam zurückholt.
|
||||
*/
|
||||
export async function bookCcPackage(
|
||||
playerId: number,
|
||||
@@ -196,7 +196,7 @@ export async function bookKulanz(
|
||||
return { groupId, balances: await getBalances(playerId) };
|
||||
}
|
||||
|
||||
/** Freie Korrekturbuchung. Vorbehalten fuer Administratoren. */
|
||||
/** Freie Korrekturbuchung. Vorbehalten für Administratoren. */
|
||||
export async function bookAdjustment(
|
||||
playerId: number,
|
||||
kind: CreditKind,
|
||||
@@ -236,9 +236,9 @@ export async function bookAdjustment(
|
||||
}
|
||||
|
||||
/**
|
||||
* Storniert eine Buchung als Gegenbuchung, nicht als Loeschung: die Historie
|
||||
* bleibt vollstaendig lesbar. Verbrauchszeilen sind ausgenommen, die werden
|
||||
* ueber die zugehoerige CC-Aktion zurueckgenommen.
|
||||
* Storniert eine Buchung als Gegenbuchung, nicht als Löschung: die Historie
|
||||
* bleibt vollständig lesbar. Verbrauchszeilen sind ausgenommen, die werden
|
||||
* über die zugehörige CC-Aktion zurückgenommen.
|
||||
*/
|
||||
export async function reverseGroup(
|
||||
ledgerId: number,
|
||||
@@ -251,7 +251,7 @@ export async function reverseGroup(
|
||||
if (anchor.source === CreditSource.CONSUME) {
|
||||
throw conflict(
|
||||
'CONSUME_NOT_REVERSIBLE',
|
||||
'Verbrauchsbuchungen werden ueber die zugehoerige CC-Aktion zurueckgenommen, nicht direkt storniert',
|
||||
'Verbrauchsbuchungen werden über die zugehörige CC-Aktion zurückgenommen, nicht direkt storniert',
|
||||
);
|
||||
}
|
||||
if (anchor.source === CreditSource.REVERSAL) {
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface ParsedFile {
|
||||
|
||||
const MAX_ROWS = 20000;
|
||||
|
||||
// Deutsche Tabellen-Exporte kommen haeufig mit Semikolon statt Komma.
|
||||
// Deutsche Tabellen-Exporte kommen häufig mit Semikolon statt Komma.
|
||||
function detectDelimiter(sample: string): string {
|
||||
const firstLine = sample.split(/\r?\n/, 1)[0] ?? '';
|
||||
const candidates = [',', ';', '\t', '|'];
|
||||
@@ -78,7 +78,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
||||
} catch {
|
||||
throw badRequest('INVALID_JSON', 'Die JSON-Datei konnte nicht gelesen werden');
|
||||
}
|
||||
// Manche Exporte verpacken die Liste in ein Huellobjekt.
|
||||
// Manche Exporte verpacken die Liste in ein Hüllobjekt.
|
||||
const list = Array.isArray(data)
|
||||
? data
|
||||
: typeof data === 'object' && data !== null
|
||||
@@ -86,7 +86,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
||||
: undefined;
|
||||
|
||||
if (!Array.isArray(list)) {
|
||||
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Eintraegen');
|
||||
throw badRequest('INVALID_JSON', 'Erwartet wird eine Liste von Einträgen');
|
||||
}
|
||||
rows = list
|
||||
.filter((entry): entry is Record<string, unknown> => typeof entry === 'object' && entry !== null)
|
||||
@@ -107,7 +107,7 @@ export function parseUpload(filename: string, content: Buffer): ParsedFile {
|
||||
}
|
||||
}
|
||||
|
||||
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthaelt keine Datenzeilen');
|
||||
if (rows.length === 0) throw badRequest('NO_ROWS', 'Die Datei enthält keine Datenzeilen');
|
||||
if (rows.length > MAX_ROWS) {
|
||||
throw badRequest('TOO_MANY_ROWS', `Die Datei hat mehr als ${MAX_ROWS} Zeilen`);
|
||||
}
|
||||
@@ -165,9 +165,9 @@ function parseDate(value: string | undefined): Date | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsaechlich
|
||||
* zugeordnet wurden. Ein Shop-Export enthaelt oft deutlich mehr - Mailadresse,
|
||||
* Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Laeuft,
|
||||
* Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsächlich
|
||||
* zugeordnet wurden. Ein Shop-Export enthält oft deutlich mehr - Mailadresse,
|
||||
* Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Läuft,
|
||||
* sobald ein Import abgeschlossen oder verworfen ist.
|
||||
*/
|
||||
async function pruneRawRows(
|
||||
@@ -204,8 +204,8 @@ async function pruneRawRows(
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der
|
||||
* Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt.
|
||||
* Rechnet für jede Zeile aus, was beim Buchen passieren würde. Wird vor der
|
||||
* Bestätigung angezeigt und beim Commit unverändert ausgeführt.
|
||||
*/
|
||||
export async function applyMapping(importId: number, mapping: ColumnMapping, ctx: ActionContext) {
|
||||
const record = await prisma.import.findUnique({
|
||||
@@ -229,7 +229,7 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
|
||||
raw: row.raw as Record<string, string>,
|
||||
}));
|
||||
|
||||
// Die Datei kann eine andere Schreibweise liefern als die Datenbank fuehrt.
|
||||
// Die Datei kann eine andere Schreibweise liefern als die Datenbank führt.
|
||||
const rawKeys = rawRows.map((r) => (r.raw[mapping.playerKey] ?? '').trim()).filter(Boolean);
|
||||
const playerKeys = [
|
||||
...new Set(rawKeys.flatMap((key) => {
|
||||
@@ -262,7 +262,7 @@ export async function applyMapping(importId: number, mapping: ColumnMapping, ctx
|
||||
: [],
|
||||
);
|
||||
|
||||
// Faengt Dubletten innerhalb derselben Datei ab.
|
||||
// Fängt Dubletten innerhalb derselben Datei ab.
|
||||
const seenInFile = new Set<string>();
|
||||
const updates: Prisma.PrismaPromise<unknown>[] = [];
|
||||
const summary: Record<Plan, number> = {
|
||||
@@ -356,7 +356,7 @@ export async function getImport(importId: number, includeRows = true) {
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Fuehrt den zuvor angezeigten Plan aus. Alles oder nichts. */
|
||||
/** Führt den zuvor angezeigten Plan aus. Alles oder nichts. */
|
||||
export async function commitImport(importId: number, ctx: ActionContext) {
|
||||
const record = await prisma.import.findUnique({
|
||||
where: { id: importId },
|
||||
@@ -375,7 +375,7 @@ export async function commitImport(importId: number, ctx: ActionContext) {
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Mehrere Zeilen koennen denselben neuen Spieler betreffen.
|
||||
// Mehrere Zeilen können denselben neuen Spieler betreffen.
|
||||
const createdPlayers = new Map<string, number>();
|
||||
let booked = 0;
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export async function getPlayerDetail(playerId: number) {
|
||||
|
||||
/**
|
||||
* Bringt die Eingabe auf die kanonische 64-Bit-Form. Ohne das legt derselbe
|
||||
* Spieler zwei Datensaetze an, sobald eine Quelle STEAM_0:... liefert und die
|
||||
* Spieler zwei Datensätze an, sobald eine Quelle STEAM_0:... liefert und die
|
||||
* andere die 64-Bit-Zahl.
|
||||
*/
|
||||
function canonicalSteamId(input: string | null | undefined): string | null {
|
||||
@@ -169,10 +169,10 @@ export async function updatePlayer(playerId: number, input: Partial<PlayerInput>
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht einen Spieler samt allem, was an ihm haengt. Spuren in den
|
||||
* Import-Zeilen und in frueheren Audit-Eintraegen werden dabei mit entfernt,
|
||||
* damit nach der Loeschung wirklich nichts Personenbezogenes uebrig bleibt.
|
||||
* Die Audit-Eintraege selbst bleiben als Vorgang bestehen, nur ohne Inhalt.
|
||||
* Löscht einen Spieler samt allem, was an ihm hängt. Spuren in den
|
||||
* Import-Zeilen und in früheren Audit-Einträgen werden dabei mit entfernt,
|
||||
* damit nach der Löschung wirklich nichts Personenbezogenes übrig bleibt.
|
||||
* Die Audit-Einträge selbst bleiben als Vorgang bestehen, nur ohne Inhalt.
|
||||
*/
|
||||
export async function deletePlayer(playerId: number, ctx: ActionContext): Promise<void> {
|
||||
const existing = await prisma.player.findUnique({
|
||||
@@ -198,7 +198,7 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
|
||||
},
|
||||
});
|
||||
|
||||
// Fruehere Audit-Eintraege zu diesem Spieler behalten den Vorgang, nicht den Inhalt.
|
||||
// Frühere Audit-Einträge zu diesem Spieler behalten den Vorgang, nicht den Inhalt.
|
||||
await tx.auditLog.updateMany({
|
||||
where: { targetType: 'player', targetId: String(playerId) },
|
||||
data: { details: { redacted: true } },
|
||||
@@ -210,8 +210,8 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis
|
||||
action: 'player.delete',
|
||||
targetType: 'player',
|
||||
targetId: playerId,
|
||||
// Bewusst ohne Name und SteamID: der Eintrag soll die Loeschung belegen,
|
||||
// nicht die geloeschten Daten konservieren.
|
||||
// Bewusst ohne Name und SteamID: der Eintrag soll die Löschung belegen,
|
||||
// nicht die gelöschten Daten konservieren.
|
||||
details: {
|
||||
removedCharacters: existing._count.characters,
|
||||
removedLedgerRows: existing._count.ledger,
|
||||
|
||||
@@ -36,8 +36,8 @@ export interface ScrapeSummary {
|
||||
const externalIdFor = (packageBoughtId: number) => `gext:${packageBoughtId}`;
|
||||
|
||||
/**
|
||||
* Holt die Profile der uebergebenen Spieler und legt daraus einen Import an,
|
||||
* der wie ein Datei-Import in der Vorschau bestaetigt wird. Es wird nichts
|
||||
* Holt die Profile der übergebenen Spieler und legt daraus einen Import an,
|
||||
* der wie ein Datei-Import in der Vorschau bestätigt wird. Es wird nichts
|
||||
* automatisch gebucht.
|
||||
*/
|
||||
export async function scrapePlayers(
|
||||
@@ -170,7 +170,7 @@ export async function scrapePlayers(
|
||||
return summary;
|
||||
}
|
||||
|
||||
// --- Handpruefung der unklaren Eintraege ---
|
||||
// --- Handprüfung der unklaren Einträge ---
|
||||
|
||||
export type Resolution = 'CC_PACKAGE' | 'REDESIGN_PACKAGE' | 'REDESIGN_KULANZ' | 'IGNORE';
|
||||
|
||||
@@ -182,7 +182,7 @@ export async function listUnmatched(onlyOpen = true) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Spieler mit offenen Eintraegen, fuer den Hinweis auf der Uebersicht. */
|
||||
/** Spieler mit offenen Einträgen, für den Hinweis auf der Uebersicht. */
|
||||
export async function unmatchedOverview() {
|
||||
const open = await prisma.unmatchedPackage.findMany({
|
||||
where: { resolved: false },
|
||||
@@ -208,7 +208,7 @@ export async function unmatchedOverview() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
|
||||
* Löst einen unklaren Eintrag auf. Wird er als Paket eingestuft, entsteht
|
||||
* sofort die passende Buchung - inklusive der Regel, dass ein CC-Paket auch
|
||||
* ein Redesign mitbringt.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user