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.
154 lines
4.9 KiB
TypeScript
154 lines
4.9 KiB
TypeScript
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;
|
|
}
|