From a7228e63d15810b35e4b195a794733100a7e6edd Mon Sep 17 00:00:00 2001 From: Harlekin Date: Fri, 18 Sep 2026 11:37:31 +0200 Subject: [PATCH] HTTP-Endpunkte und Anwendungsaufbau Eingaben werden mit zod validiert, jede aendernde Aktion schreibt ins Audit-Log. Beim ersten Start entsteht ein Administrator samt Startwerten fuer die Produkterkennung; solange ein Erstpasswort gilt, bleiben schreibende Wege bis auf den Passwortwechsel gesperrt. Nicht-API-Pfade fallen auf die SPA zurueck, damit Deep-Links funktionieren. --- server/src/app.ts | 153 ++++++++++++ server/src/bootstrap.ts | 63 +++++ server/src/index.ts | 50 ++++ server/src/routes/admin.routes.ts | 310 +++++++++++++++++++++++++ server/src/routes/auth.routes.ts | 304 ++++++++++++++++++++++++ server/src/routes/characters.routes.ts | 95 ++++++++ server/src/routes/credits.routes.ts | 60 +++++ server/src/routes/helpers.ts | 32 +++ server/src/routes/imports.routes.ts | 71 ++++++ server/src/routes/players.routes.ts | 71 ++++++ 10 files changed, 1209 insertions(+) create mode 100644 server/src/app.ts create mode 100644 server/src/bootstrap.ts create mode 100644 server/src/index.ts create mode 100644 server/src/routes/admin.routes.ts create mode 100644 server/src/routes/auth.routes.ts create mode 100644 server/src/routes/characters.routes.ts create mode 100644 server/src/routes/credits.routes.ts create mode 100644 server/src/routes/helpers.ts create mode 100644 server/src/routes/imports.routes.ts create mode 100644 server/src/routes/players.routes.ts diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..204043d --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,153 @@ +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Fastify, { type FastifyInstance } from 'fastify'; +import cookie from '@fastify/cookie'; +import helmet from '@fastify/helmet'; +import multipart from '@fastify/multipart'; +import rateLimit from '@fastify/rate-limit'; +import fastifyStatic from '@fastify/static'; +import { Prisma } from '@prisma/client'; +import { env } from './env.js'; +import { AppError } from './errors.js'; +import { attachUser, blockUntilPasswordChanged, enforceCsrf } from './auth/guard.js'; +import { authRoutes } from './routes/auth.routes.js'; +import { playerRoutes } from './routes/players.routes.js'; +import { creditRoutes } from './routes/credits.routes.js'; +import { characterRoutes } from './routes/characters.routes.js'; +import { importRoutes } from './routes/imports.routes.js'; +import { adminRoutes } from './routes/admin.routes.js'; + +const here = dirname(fileURLToPath(import.meta.url)); +// Im Container liegt das gebaute Frontend neben dem kompilierten Server. +const webRoot = join(here, '..', 'public'); + +function readStatusCode(error: unknown): number | undefined { + const value = (error as { statusCode?: unknown }).statusCode; + return typeof value === 'number' ? value : undefined; +} + +function readMessage(error: unknown): string | undefined { + const value = (error as { message?: unknown }).message; + return typeof value === 'string' ? value : undefined; +} + +export async function buildApp(): Promise { + const app = Fastify({ + logger: { + level: env.LOG_LEVEL, + redact: ['req.headers.cookie', 'req.headers.authorization'], + }, + trustProxy: env.TRUST_PROXY, + bodyLimit: 2 * 1024 * 1024, + }); + + await app.register(helmet, { + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + // Der QR-Code der 2FA-Einrichtung kommt als data-URL. + imgSrc: ["'self'", 'data:'], + styleSrc: ["'self'", "'unsafe-inline'"], + connectSrc: ["'self'"], + objectSrc: ["'none'"], + baseUri: ["'self'"], + frameAncestors: ["'none'"], + formAction: ["'self'"], + }, + }, + crossOriginEmbedderPolicy: false, + hsts: env.isProduction ? { maxAge: 31536000, includeSubDomains: true } : false, + }); + + await app.register(cookie, { secret: env.SESSION_SECRET }); + + await app.register(rateLimit, { + global: true, + max: 300, + timeWindow: '1 minute', + allowList: () => !env.isProduction, + }); + + await app.register(multipart, { + limits: { fileSize: 10 * 1024 * 1024, files: 1 }, + }); + + app.addHook('onRequest', attachUser); + app.addHook('onRequest', enforceCsrf); + app.addHook('preHandler', blockUntilPasswordChanged); + + app.setErrorHandler((error, request, reply) => { + if (error instanceof AppError) { + reply.code(error.statusCode).send({ error: error.code, message: error.message }); + return; + } + + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === 'P2002') { + reply.code(409).send({ error: 'UNIQUE_VIOLATION', message: 'Dieser Wert ist bereits vergeben' }); + return; + } + if (error.code === 'P2025') { + reply.code(404).send({ error: 'NOT_FOUND', message: 'Datensatz nicht gefunden' }); + return; + } + } + + const statusCode = readStatusCode(error); + + if (statusCode === 429) { + reply.code(429).send({ error: 'RATE_LIMITED', message: 'Zu viele Anfragen, bitte kurz warten' }); + return; + } + + if (statusCode !== undefined && statusCode >= 400 && statusCode < 500) { + reply + .code(statusCode) + .send({ error: 'BAD_REQUEST', message: readMessage(error) ?? 'Ungueltige Anfrage' }); + return; + } + + request.log.error({ err: error }, 'Unbehandelter Fehler'); + reply.code(500).send({ error: 'INTERNAL', message: 'Interner Fehler' }); + }); + + app.get('/api/health', async () => ({ status: 'ok' })); + + await app.register(authRoutes); + await app.register(playerRoutes); + await app.register(creditRoutes); + await app.register(characterRoutes); + await app.register(importRoutes); + await app.register(adminRoutes); + + if (existsSync(webRoot)) { + await app.register(fastifyStatic, { + root: webRoot, + prefix: '/', + // Gehashte Assets duerfen lange liegen bleiben, index.html nie. + maxAge: '1y', + setHeaders: (res, path) => { + if (path.endsWith('index.html')) { + res.setHeader('Cache-Control', 'no-cache'); + } + }, + }); + } + + // Alle nicht-API-Pfade gehen an die SPA, damit Deep-Links funktionieren. + app.setNotFoundHandler((request, reply) => { + if (request.url.startsWith('/api/')) { + reply.code(404).send({ error: 'NOT_FOUND', message: 'Unbekannter Endpunkt' }); + return; + } + if (existsSync(webRoot)) { + reply.sendFile('index.html'); + return; + } + reply.code(404).send({ error: 'NOT_FOUND', message: 'Frontend ist nicht gebaut' }); + }); + + return app; +} diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts new file mode 100644 index 0000000..267c818 --- /dev/null +++ b/server/src/bootstrap.ts @@ -0,0 +1,63 @@ +import { randomBytes } from 'node:crypto'; +import { ProductTarget } from '@prisma/client'; +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 +// anpassbar, sobald die echten Produktnamen bekannt sind. +const DEFAULT_MAPPINGS = [ + { matchPattern: 'cc-paket', target: ProductTarget.CC_PACKAGE, priority: 10 }, + { matchPattern: 'cc paket', target: ProductTarget.CC_PACKAGE, priority: 11 }, + { matchPattern: 'custom character', target: ProductTarget.CC_PACKAGE, priority: 12 }, + { matchPattern: 'redesign', target: ProductTarget.REDESIGN_PACKAGE, priority: 20 }, +]; + +export interface BootstrapResult { + createdAdmin: { username: string; password: string } | null; + createdMappings: number; +} + +/** + * Legt beim allerersten Start einen Administrator an. Ohne gesetztes + * Bootstrap-Passwort wird eines erzeugt und einmalig ins Log geschrieben. + */ +export async function bootstrap(log: { + info: (msg: string) => void; + warn: (msg: string) => void; +}): Promise { + const result: BootstrapResult = { createdAdmin: null, createdMappings: 0 }; + + const userCount = await prisma.user.count(); + if (userCount === 0) { + const password = env.BOOTSTRAP_ADMIN_PASSWORD ?? randomBytes(12).toString('base64url'); + + await prisma.user.create({ + data: { + username: env.BOOTSTRAP_ADMIN_USER, + passwordHash: await hashPassword(password), + role: 'ADMIN', + mustChangePassword: true, + }, + }); + + result.createdAdmin = { username: env.BOOTSTRAP_ADMIN_USER, password }; + + log.warn('─'.repeat(60)); + log.warn(`Administrator angelegt: ${env.BOOTSTRAP_ADMIN_USER}`); + if (!env.BOOTSTRAP_ADMIN_PASSWORD) { + log.warn(`Einmal-Passwort: ${password}`); + } + log.warn('Das Passwort muss bei der ersten Anmeldung geaendert werden.'); + log.warn('─'.repeat(60)); + } + + const mappingCount = await prisma.productMapping.count(); + if (mappingCount === 0) { + const created = await prisma.productMapping.createMany({ data: DEFAULT_MAPPINGS }); + result.createdMappings = created.count; + log.info(`${created.count} Produkt-Mappings als Startwerte angelegt`); + } + + return result; +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..f3151df --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,50 @@ +import { buildApp } from './app.js'; +import { bootstrap } from './bootstrap.js'; +import { cleanupExpiredSessions } from './auth/session.js'; +import { prisma } from './db.js'; +import { env } from './env.js'; + +const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; + +async function main(): Promise { + const app = await buildApp(); + + try { + await prisma.$queryRaw`SELECT 1`; + } catch (error) { + app.log.error({ err: error }, 'Keine Verbindung zur Datenbank'); + process.exit(1); + } + + await bootstrap({ + info: (msg) => app.log.info(msg), + warn: (msg) => app.log.warn(msg), + }); + + const cleanup = setInterval(() => { + cleanupExpiredSessions() + .then((count) => { + if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`); + }) + .catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen')); + }, SESSION_CLEANUP_INTERVAL_MS); + cleanup.unref(); + + const shutdown = async (signal: string): Promise => { + app.log.info(`${signal} empfangen, fahre herunter`); + clearInterval(cleanup); + await app.close(); + await prisma.$disconnect(); + process.exit(0); + }; + + process.on('SIGTERM', () => void shutdown('SIGTERM')); + process.on('SIGINT', () => void shutdown('SIGINT')); + + await app.listen({ port: env.PORT, host: env.HOST }); +} + +main().catch((error) => { + console.error('Start fehlgeschlagen:', error); + process.exit(1); +}); diff --git a/server/src/routes/admin.routes.ts b/server/src/routes/admin.routes.ts new file mode 100644 index 0000000..64533e9 --- /dev/null +++ b/server/src/routes/admin.routes.ts @@ -0,0 +1,310 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { ProductTarget, Role, type Prisma } from '@prisma/client'; +import { prisma } from '../db.js'; +import { requireAdmin } from '../auth/guard.js'; +import { badRequest, conflict, notFound } from '../errors.js'; +import { hashPassword, validatePasswordStrength } from '../auth/password.js'; +import { destroyAllSessionsFor } from '../auth/session.js'; +import { recordAudit } from '../services/audit.js'; +import { contextOf, idParam, parseWith } from './helpers.js'; + +const userSelect = { + id: true, + username: true, + role: true, + isActive: true, + totpEnabled: true, + mustChangePassword: true, + lastLoginAt: true, + createdAt: true, +} satisfies Prisma.UserSelect; + +// Verhindert, dass sich die Installation selbst den letzten Zugang entzieht. +async function assertNotLastAdmin(userId: number): Promise { + const target = await prisma.user.findUnique({ where: { id: userId } }); + if (!target || target.role !== Role.ADMIN || !target.isActive) return; + + const activeAdmins = await prisma.user.count({ where: { role: Role.ADMIN, isActive: true } }); + if (activeAdmins <= 1) { + throw conflict('LAST_ADMIN', 'Der letzte aktive Administrator kann nicht entfernt werden'); + } +} + +export async function adminRoutes(app: FastifyInstance): Promise { + app.addHook('preHandler', requireAdmin); + + // --- Nutzerverwaltung --- + + app.get('/api/admin/users', async () => { + const items = await prisma.user.findMany({ + orderBy: [{ isActive: 'desc' }, { username: 'asc' }], + select: userSelect, + }); + return { items }; + }); + + app.post('/api/admin/users', async (request, reply) => { + const body = parseWith( + z.object({ + username: z + .string() + .trim() + .min(3) + .max(64) + .regex(/^[a-zA-Z0-9._-]+$/, 'Erlaubt sind Buchstaben, Ziffern, Punkt, Strich, Unterstrich'), + password: z.string().min(1), + role: z.nativeEnum(Role).default(Role.EDITOR), + }), + request.body, + ); + + const weak = validatePasswordStrength(body.password); + if (weak) throw badRequest('WEAK_PASSWORD', weak); + + const exists = await prisma.user.findUnique({ + where: { username: body.username }, + select: { id: true }, + }); + if (exists) throw conflict('USERNAME_TAKEN', 'Dieser Benutzername ist vergeben'); + + const created = await prisma.user.create({ + data: { + username: body.username, + passwordHash: await hashPassword(body.password), + role: body.role, + mustChangePassword: true, + }, + select: userSelect, + }); + + await recordAudit(prisma, contextOf(request), { + action: 'admin.user_create', + targetType: 'user', + targetId: created.id, + details: { username: created.username, role: created.role }, + }); + + reply.code(201); + return created; + }); + + app.patch('/api/admin/users/:id', async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + role: z.nativeEnum(Role).optional(), + isActive: z.boolean().optional(), + }), + request.body, + ); + + const target = await prisma.user.findUnique({ where: { id } }); + if (!target) throw notFound('Nutzer'); + + const losesAdmin = + (body.role !== undefined && body.role !== Role.ADMIN) || body.isActive === false; + if (losesAdmin) await assertNotLastAdmin(id); + + const updated = await prisma.user.update({ + where: { id }, + data: { + ...(body.role !== undefined ? { role: body.role } : {}), + ...(body.isActive !== undefined ? { isActive: body.isActive } : {}), + }, + select: userSelect, + }); + + // Ein gesperrter Account verliert seine offenen Sitzungen sofort. + if (body.isActive === false) await destroyAllSessionsFor(id); + + await recordAudit(prisma, contextOf(request), { + action: 'admin.user_update', + targetType: 'user', + targetId: id, + details: { username: updated.username, role: updated.role, isActive: updated.isActive }, + }); + + return updated; + }); + + app.post('/api/admin/users/:id/reset-password', async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith(z.object({ newPassword: z.string().min(1) }), request.body); + + const weak = validatePasswordStrength(body.newPassword); + if (weak) throw badRequest('WEAK_PASSWORD', weak); + + const target = await prisma.user.findUnique({ where: { id } }); + if (!target) throw notFound('Nutzer'); + + await prisma.user.update({ + where: { id }, + data: { + passwordHash: await hashPassword(body.newPassword), + mustChangePassword: true, + }, + }); + await destroyAllSessionsFor(id); + + await recordAudit(prisma, contextOf(request), { + action: 'admin.user_reset_password', + targetType: 'user', + targetId: id, + details: { username: target.username }, + }); + + return { ok: true }; + }); + + 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'); + } + + const target = await prisma.user.findUnique({ where: { id } }); + if (!target) throw notFound('Nutzer'); + await assertNotLastAdmin(id); + + await prisma.user.delete({ where: { id } }); + + await recordAudit(prisma, contextOf(request), { + action: 'admin.user_delete', + targetType: 'user', + targetId: id, + details: { username: target.username }, + }); + + return { ok: true }; + }); + + // --- Produkt-Mappings --- + + app.get('/api/admin/product-mappings', async () => { + const items = await prisma.productMapping.findMany({ + orderBy: [{ priority: 'asc' }, { id: 'asc' }], + }); + return { items }; + }); + + app.post('/api/admin/product-mappings', async (request, reply) => { + const body = parseWith( + z.object({ + matchPattern: z.string().trim().min(1).max(200), + target: z.nativeEnum(ProductTarget), + priority: z.number().int().min(0).max(9999).default(100), + note: z.string().trim().max(300).optional(), + }), + request.body, + ); + + const created = await prisma.productMapping.create({ + data: { + matchPattern: body.matchPattern, + target: body.target, + priority: body.priority, + note: body.note ?? null, + }, + }); + + await recordAudit(prisma, contextOf(request), { + action: 'admin.mapping_create', + targetType: 'product_mapping', + targetId: created.id, + details: { matchPattern: created.matchPattern, target: created.target }, + }); + + reply.code(201); + return created; + }); + + app.patch('/api/admin/product-mappings/:id', async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + matchPattern: z.string().trim().min(1).max(200).optional(), + target: z.nativeEnum(ProductTarget).optional(), + priority: z.number().int().min(0).max(9999).optional(), + isActive: z.boolean().optional(), + note: z.string().trim().max(300).nullable().optional(), + }), + request.body, + ); + + const existing = await prisma.productMapping.findUnique({ where: { id } }); + if (!existing) throw notFound('Mapping'); + + const updated = await prisma.productMapping.update({ where: { id }, data: body }); + + await recordAudit(prisma, contextOf(request), { + action: 'admin.mapping_update', + targetType: 'product_mapping', + targetId: id, + details: { matchPattern: updated.matchPattern, target: updated.target }, + }); + + return updated; + }); + + app.delete('/api/admin/product-mappings/:id', async (request) => { + const { id } = parseWith(idParam, request.params); + const existing = await prisma.productMapping.findUnique({ where: { id } }); + if (!existing) throw notFound('Mapping'); + + await prisma.productMapping.delete({ where: { id } }); + + await recordAudit(prisma, contextOf(request), { + action: 'admin.mapping_delete', + targetType: 'product_mapping', + targetId: id, + details: { matchPattern: existing.matchPattern }, + }); + + return { ok: true }; + }); + + // --- Audit-Log --- + + app.get('/api/admin/audit', async (request) => { + const query = parseWith( + z.object({ + action: z.string().trim().max(80).optional(), + targetType: z.string().trim().max(40).optional(), + targetId: z.string().trim().max(40).optional(), + take: z.coerce.number().int().min(1).max(200).default(100), + skip: z.coerce.number().int().min(0).default(0), + }), + request.query, + ); + + const where: Prisma.AuditLogWhereInput = {}; + if (query.action) where.action = { contains: query.action, mode: 'insensitive' }; + if (query.targetType) where.targetType = query.targetType; + if (query.targetId) where.targetId = query.targetId; + + const [items, total] = await Promise.all([ + prisma.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: query.take, + skip: query.skip, + include: { actor: { select: { username: true } } }, + }), + prisma.auditLog.count({ where }), + ]); + + return { items, total }; + }); + + app.get('/api/admin/stats', async () => { + const [players, characters, activeCharacters, users, imports] = await Promise.all([ + prisma.player.count(), + prisma.character.count(), + prisma.character.count({ where: { status: 'ACTIVE' } }), + prisma.user.count({ where: { isActive: true } }), + prisma.import.count({ where: { status: 'COMMITTED' } }), + ]); + return { players, characters, activeCharacters, users, imports }; + }); +} diff --git a/server/src/routes/auth.routes.ts b/server/src/routes/auth.routes.ts new file mode 100644 index 0000000..4611d14 --- /dev/null +++ b/server/src/routes/auth.routes.ts @@ -0,0 +1,304 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { z } from 'zod'; +import { prisma } from '../db.js'; +import { env } from '../env.js'; +import { badRequest, unauthorized } from '../errors.js'; +import { hashPassword, validatePasswordStrength, verifyPassword } from '../auth/password.js'; +import { + CSRF_COOKIE, + SESSION_COOKIE, + createSession, + csrfTokenFor, + destroyAllSessionsFor, + destroyOtherSessionsFor, + destroySession, +} from '../auth/session.js'; +import { requireAuth } from '../auth/guard.js'; +import { generateSecret, otpauthUrl, qrDataUrl, verifyTotp } from '../auth/totp.js'; +import { recordAudit } from '../services/audit.js'; +import { contextOf, parseWith } from './helpers.js'; + +const CHALLENGE_TTL_MS = 5 * 60 * 1000; + +// Zwischenschritt zwischen Passwort und TOTP-Code. Signiert statt gespeichert, +// damit kein halb angemeldeter Zustand in der Datenbank liegt. +function signChallenge(userId: number): string { + const expiresAt = Date.now() + CHALLENGE_TTL_MS; + const payload = `${userId}.${expiresAt}`; + const signature = createHmac('sha256', env.SESSION_SECRET).update(payload).digest('base64url'); + return `${payload}.${signature}`; +} + +function verifyChallenge(token: string): number | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const [rawUserId, rawExpiry, signature] = parts as [string, string, string]; + + const expected = createHmac('sha256', env.SESSION_SECRET) + .update(`${rawUserId}.${rawExpiry}`) + .digest('base64url'); + const a = Buffer.from(expected); + const b = Buffer.from(signature); + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; + + const expiresAt = Number(rawExpiry); + if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) return null; + + const userId = Number(rawUserId); + return Number.isInteger(userId) ? userId : null; +} + +const loginSchema = z.object({ + username: z.string().trim().min(1).max(64), + password: z.string().min(1).max(256), +}); + +const totpSchema = z.object({ + challenge: z.string().min(1), + code: z.string().trim().min(6).max(10), +}); + +export async function authRoutes(app: FastifyInstance): Promise { + const loginLimit = { + config: { + rateLimit: { + max: 10, + timeWindow: '5 minutes', + }, + }, + }; + + function issueSession(reply: FastifyReply, token: string, expiresAt: Date): void { + reply.setCookie(SESSION_COOKIE, token, { + path: '/', + httpOnly: true, + secure: env.cookieSecure, + sameSite: 'lax', + expires: expiresAt, + }); + // Muss fuer das Double-Submit-Verfahren lesbar sein. + reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), { + path: '/', + httpOnly: false, + secure: env.cookieSecure, + sameSite: 'lax', + expires: expiresAt, + }); + } + + app.post('/api/auth/login', loginLimit, async (request, reply) => { + 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. + const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const passwordOk = await verifyPassword(digest, body.password); + + if (!user || !passwordOk || !user.isActive) { + await recordAudit(prisma, { actorId: null, actorName: body.username, ip: request.ip }, { + action: 'auth.login_failed', + targetType: 'user', + targetId: user?.id ?? null, + }).catch(() => undefined); + throw unauthorized('Benutzername oder Passwort ist falsch'); + } + + if (user.totpEnabled && user.totpSecret) { + return { totpRequired: true, challenge: signChallenge(user.id) }; + } + + const { token, expiresAt } = await createSession( + user.id, + request.ip, + request.headers['user-agent'], + ); + issueSession(reply, token, expiresAt); + + await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); + await recordAudit( + { auditLog: prisma.auditLog }, + { actorId: user.id, actorName: user.username, ip: request.ip }, + { action: 'auth.login', targetType: 'user', targetId: user.id }, + ); + + return { + totpRequired: false, + user: { + id: user.id, + username: user.username, + role: user.role, + mustChangePassword: user.mustChangePassword, + totpEnabled: user.totpEnabled, + }, + }; + }); + + app.post('/api/auth/login/totp', loginLimit, async (request, reply) => { + const body = parseWith(totpSchema, request.body); + + const userId = verifyChallenge(body.challenge); + if (userId === null) { + throw unauthorized('Die Anmeldung ist abgelaufen, bitte erneut starten'); + } + + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user || !user.isActive || !user.totpSecret) throw unauthorized(); + + if (!(await verifyTotp(user.totpSecret, body.code))) { + await recordAudit(prisma, { actorId: user.id, actorName: user.username, ip: request.ip }, { + action: 'auth.totp_failed', + targetType: 'user', + targetId: user.id, + }).catch(() => undefined); + throw unauthorized('Der Code stimmt nicht'); + } + + const { token, expiresAt } = await createSession( + user.id, + request.ip, + request.headers['user-agent'], + ); + issueSession(reply, token, expiresAt); + + await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); + await recordAudit( + { auditLog: prisma.auditLog }, + { actorId: user.id, actorName: user.username, ip: request.ip }, + { action: 'auth.login', targetType: 'user', targetId: user.id }, + ); + + return { + user: { + id: user.id, + username: user.username, + role: user.role, + mustChangePassword: user.mustChangePassword, + totpEnabled: user.totpEnabled, + }, + }; + }); + + app.post('/api/auth/logout', async (request, reply) => { + if (request.sessionToken) { + await destroySession(request.sessionToken); + } + reply.clearCookie(SESSION_COOKIE, { path: '/' }); + reply.clearCookie(CSRF_COOKIE, { path: '/' }); + return { ok: true }; + }); + + app.get('/api/auth/me', async (request) => { + if (!request.user) return { user: null }; + return { user: request.user }; + }); + + // --- Eigener Account --- + + app.post( + '/api/account/password', + { preHandler: requireAuth }, + async (request) => { + const body = parseWith( + z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(1), + }), + request.body, + ); + + const user = await prisma.user.findUnique({ where: { id: request.user!.id } }); + if (!user) throw unauthorized(); + + if (!(await verifyPassword(user.passwordHash, body.currentPassword))) { + throw badRequest('WRONG_PASSWORD', 'Das aktuelle Passwort ist falsch'); + } + + const weak = validatePasswordStrength(body.newPassword); + if (weak) throw badRequest('WEAK_PASSWORD', weak); + + await prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash: await hashPassword(body.newPassword), + mustChangePassword: false, + }, + }); + + // Andere Sitzungen beenden, die eigene bleibt bestehen. + if (request.sessionToken) { + await destroyOtherSessionsFor(user.id, request.sessionToken); + } + + await recordAudit(prisma, contextOf(request), { + action: 'account.password_changed', + targetType: 'user', + targetId: user.id, + }); + + return { ok: true }; + }, + ); + + app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => { + const secret = generateSecret(); + // Erst nach bestaetigtem Code aktivieren, sonst sperrt sich der Nutzer aus. + await prisma.user.update({ + where: { id: request.user!.id }, + data: { totpSecret: secret, totpEnabled: false }, + }); + + const url = otpauthUrl(request.user!.username, secret); + return { secret, otpauth: url, qr: await qrDataUrl(url) }; + }); + + app.post('/api/account/totp/enable', { preHandler: requireAuth }, async (request) => { + const body = parseWith(z.object({ code: z.string().trim().min(6).max(10) }), request.body); + + const user = await prisma.user.findUnique({ where: { id: request.user!.id } }); + if (!user?.totpSecret) { + throw badRequest('NO_SECRET', 'Bitte zuerst die Einrichtung starten'); + } + if (!(await verifyTotp(user.totpSecret, body.code))) { + throw badRequest('WRONG_CODE', 'Der Code stimmt nicht'); + } + + await prisma.user.update({ where: { id: user.id }, data: { totpEnabled: true } }); + await recordAudit(prisma, contextOf(request), { + action: 'account.totp_enabled', + targetType: 'user', + targetId: user.id, + }); + + return { ok: true }; + }); + + app.post('/api/account/totp/disable', { preHandler: requireAuth }, async (request) => { + const body = parseWith(z.object({ password: z.string().min(1) }), request.body); + + const user = await prisma.user.findUnique({ where: { id: request.user!.id } }); + if (!user) throw unauthorized(); + if (!(await verifyPassword(user.passwordHash, body.password))) { + throw badRequest('WRONG_PASSWORD', 'Das Passwort ist falsch'); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { totpEnabled: false, totpSecret: null }, + }); + await recordAudit(prisma, contextOf(request), { + action: 'account.totp_disabled', + targetType: 'user', + targetId: user.id, + }); + + return { ok: true }; + }); + + app.post('/api/account/sessions/revoke', { preHandler: requireAuth }, async (request, reply) => { + await destroyAllSessionsFor(request.user!.id); + reply.clearCookie(SESSION_COOKIE, { path: '/' }); + reply.clearCookie(CSRF_COOKIE, { path: '/' }); + return { ok: true }; + }); +} diff --git a/server/src/routes/characters.routes.ts b/server/src/routes/characters.routes.ts new file mode 100644 index 0000000..90e982d --- /dev/null +++ b/server/src/routes/characters.routes.ts @@ -0,0 +1,95 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { CreditKind, InactiveReason } from '@prisma/client'; +import { requireEditor } from '../auth/guard.js'; +import { + createCharacter, + deactivateCharacter, + reactivateFree, + reactivatePaid, + redesignCharacter, + revertCharacter, + updateCharacter, +} from '../services/characters.js'; +import { contextOf, idParam, optionalNote, parseWith } from './helpers.js'; + +const redesignKind = z.enum([CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]); +const reviveKind = z.enum([CreditKind.CC, CreditKind.REDESIGN_PAID, CreditKind.REDESIGN_KULANZ]); + +export async function characterRoutes(app: FastifyInstance): Promise { + app.post('/api/players/:id/characters', { preHandler: requireEditor }, async (request, reply) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + name: z.string().trim().min(1).max(160), + loreText: z.string().trim().max(5000).optional(), + note: optionalNote, + }), + request.body, + ); + const result = await createCharacter(id, body, contextOf(request)); + reply.code(201); + return result; + }); + + app.patch('/api/characters/:id', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + name: z.string().trim().min(1).max(160).optional(), + loreText: z.string().trim().max(5000).nullable().optional(), + }), + request.body, + ); + return updateCharacter(id, body, contextOf(request)); + }); + + app.post('/api/characters/:id/redesign', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + name: z.string().trim().min(1).max(160), + loreText: z.string().trim().max(5000).optional(), + useKind: redesignKind, + note: optionalNote, + }), + request.body, + ); + return redesignCharacter(id, body, contextOf(request)); + }); + + app.post('/api/characters/:id/deactivate', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ + reason: z.enum([InactiveReason.PLAYER_INACTIVITY, InactiveReason.OTHER]), + note: optionalNote, + }), + request.body, + ); + return deactivateCharacter(id, body.reason, contextOf(request), body.note); + }); + + app.post('/api/characters/:id/reactivate-free', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + return reactivateFree(id, contextOf(request)); + }); + + app.post('/api/characters/:id/reactivate-paid', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + z.object({ useKind: reviveKind, note: optionalNote }), + request.body, + ); + return reactivatePaid(id, body.useKind, contextOf(request), body.note); + }); + + 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) }), + request.body, + ); + return revertCharacter(id, body.reason, contextOf(request)); + }); +} diff --git a/server/src/routes/credits.routes.ts b/server/src/routes/credits.routes.ts new file mode 100644 index 0000000..b897d95 --- /dev/null +++ b/server/src/routes/credits.routes.ts @@ -0,0 +1,60 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { CreditKind } from '@prisma/client'; +import { requireAdmin, requireEditor } from '../auth/guard.js'; +import { + bookAdjustment, + bookCcPackage, + bookKulanz, + bookRedesignPackage, + reverseGroup, +} from '../services/credits.js'; +import { contextOf, idParam, optionalNote, parseWith } from './helpers.js'; + +const noteBody = z.object({ note: optionalNote }); + +export async function creditRoutes(app: FastifyInstance): Promise { + app.post('/api/players/:id/credits/cc-package', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith(noteBody, request.body ?? {}); + return bookCcPackage(id, contextOf(request), { note: body.note }); + }); + + app.post( + '/api/players/:id/credits/redesign-package', + { preHandler: requireEditor }, + async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith(noteBody, request.body ?? {}); + return bookRedesignPackage(id, contextOf(request), { note: body.note }); + }, + ); + + app.post('/api/players/:id/credits/kulanz', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith(noteBody, request.body ?? {}); + return bookKulanz(id, contextOf(request), { note: body.note }); + }); + + app.post('/api/players/:id/credits/adjustment', { preHandler: requireAdmin }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith( + 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), + }), + request.body, + ); + return bookAdjustment(id, body.kind, body.amount, body.note, contextOf(request)); + }); + + 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) }), + request.body, + ); + return reverseGroup(id, body.reason, contextOf(request)); + }); +} diff --git a/server/src/routes/helpers.ts b/server/src/routes/helpers.ts new file mode 100644 index 0000000..102af36 --- /dev/null +++ b/server/src/routes/helpers.ts @@ -0,0 +1,32 @@ +import type { FastifyRequest } from 'fastify'; +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. +export function parseWith(schema: S, input: unknown): z.output { + const result = schema.safeParse(input); + if (!result.success) { + const message = result.error.issues + .map((issue) => { + const path = issue.path.join('.'); + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join('; '); + throw badRequest('VALIDATION_FAILED', message); + } + return result.data; +} + +export function contextOf(request: FastifyRequest): ActionContext { + if (!request.user) throw unauthorized(); + return { + actorId: request.user.id, + actorName: request.user.username, + ip: request.ip, + }; +} + +export const idParam = z.object({ id: z.coerce.number().int().positive() }); + +export const optionalNote = z.string().trim().max(500).optional(); diff --git a/server/src/routes/imports.routes.ts b/server/src/routes/imports.routes.ts new file mode 100644 index 0000000..cc85334 --- /dev/null +++ b/server/src/routes/imports.routes.ts @@ -0,0 +1,71 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { requireAdmin } from '../auth/guard.js'; +import { badRequest } from '../errors.js'; +import { + applyMapping, + commitImport, + createImport, + discardImport, + getImport, + listImports, + parseUpload, +} from '../services/imports.js'; +import { contextOf, idParam, parseWith } from './helpers.js'; + +const mappingBody = z.object({ + playerKey: z.string().min(1, 'Spalte fuer die Spieler-Kennung fehlt'), + playerName: z.string().optional(), + product: z.string().min(1, 'Spalte fuer den Produktnamen fehlt'), + orderId: z.string().optional(), + purchasedAt: z.string().optional(), + createMissingPlayers: z.boolean().default(false), +}); + +export async function importRoutes(app: FastifyInstance): Promise { + // Der Import veraendert Guthaben in grosser 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'); + + const content = await file.toBuffer(); + const parsed = parseUpload(file.filename, content); + const importId = await createImport(file.filename, parsed, contextOf(request)); + + reply.code(201); + return { + importId, + columns: parsed.columns, + rowCount: parsed.rows.length, + sample: parsed.rows.slice(0, 5), + }; + }); + + app.get('/api/imports', { preHandler: requireAdmin }, async () => { + return { items: await listImports() }; + }); + + app.get('/api/imports/:id', { preHandler: requireAdmin }, async (request) => { + const { id } = parseWith(idParam, request.params); + return getImport(id); + }); + + app.post('/api/imports/:id/mapping', { preHandler: requireAdmin }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith(mappingBody, request.body); + const summary = await applyMapping(id, body, contextOf(request)); + const record = await getImport(id); + return { summary, import: record }; + }); + + app.post('/api/imports/:id/commit', { preHandler: requireAdmin }, async (request) => { + const { id } = parseWith(idParam, request.params); + return commitImport(id, contextOf(request)); + }); + + app.post('/api/imports/:id/discard', { preHandler: requireAdmin }, async (request) => { + const { id } = parseWith(idParam, request.params); + await discardImport(id, contextOf(request)); + return { ok: true }; + }); +} diff --git a/server/src/routes/players.routes.ts b/server/src/routes/players.routes.ts new file mode 100644 index 0000000..dc683f8 --- /dev/null +++ b/server/src/routes/players.routes.ts @@ -0,0 +1,71 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { CreditKind } from '@prisma/client'; +import { requireAdmin, requireAuth, requireEditor } from '../auth/guard.js'; +import { listLedger } from '../services/credits.js'; +import { + createPlayer, + deletePlayer, + getPlayerDetail, + searchPlayers, + updatePlayer, +} from '../services/players.js'; +import { contextOf, idParam, parseWith } from './helpers.js'; + +const playerBody = z.object({ + displayName: z.string().trim().min(1).max(120), + steamId: z.string().trim().max(64).nullish(), + forumName: z.string().trim().max(120).nullish(), + notes: z.string().trim().max(2000).nullish(), +}); + +export async function playerRoutes(app: FastifyInstance): Promise { + app.get('/api/players', { preHandler: requireAuth }, async (request) => { + const query = parseWith( + z.object({ + q: z.string().trim().max(120).default(''), + take: z.coerce.number().int().min(1).max(200).default(50), + skip: z.coerce.number().int().min(0).default(0), + }), + request.query, + ); + return searchPlayers(query.q, query.take, query.skip); + }); + + app.get('/api/players/:id', { preHandler: requireAuth }, async (request) => { + const { id } = parseWith(idParam, request.params); + return getPlayerDetail(id); + }); + + app.get('/api/players/:id/ledger', { preHandler: requireAuth }, async (request) => { + const { id } = parseWith(idParam, request.params); + const query = parseWith( + z.object({ + kind: z.nativeEnum(CreditKind).optional(), + take: z.coerce.number().int().min(1).max(500).default(100), + skip: z.coerce.number().int().min(0).default(0), + }), + request.query, + ); + return listLedger({ playerId: id, ...query }); + }); + + app.post('/api/players', { preHandler: requireEditor }, async (request, reply) => { + const body = parseWith(playerBody, request.body); + const created = await createPlayer(body, contextOf(request)); + reply.code(201); + return created; + }); + + app.patch('/api/players/:id', { preHandler: requireEditor }, async (request) => { + const { id } = parseWith(idParam, request.params); + const body = parseWith(playerBody.partial(), request.body); + return updatePlayer(id, body, contextOf(request)); + }); + + app.delete('/api/players/:id', { preHandler: requireAdmin }, async (request) => { + const { id } = parseWith(idParam, request.params); + await deletePlayer(id, contextOf(request)); + return { ok: true }; + }); +}