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.
This commit is contained in:
@@ -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<FastifyInstance> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<BootstrapResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<S extends z.ZodTypeAny>(schema: S, input: unknown): z.output<S> {
|
||||||
|
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();
|
||||||
@@ -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<void> {
|
||||||
|
// 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user