import { existsSync } from 'node:fs'; import { dirname, join, sep } 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'; import { scraperRoutes } from './routes/scraper.routes.js'; import { forumRoutes } from './routes/forum.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); /** * Antworten der Schnittstelle gehören nicht in den Browser-Cache. * * Ohne Angabe darf ein Browser selbst entscheiden, wie lange er eine * GET-Antwort aufhebt. Dann steht nach dem Anlegen eines CCs die alte * Liste auf dem Schirm, und erst ein Neuladen bringt sie in Ordnung. * Nebenbei bleiben so keine Spielerdaten im Cache liegen. */ app.addHook('onSend', async (request, reply) => { if (request.url.startsWith('/api/')) { reply.header('cache-control', 'no-store'); } }); 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) ?? 'Ungültige 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); await app.register(scraperRoutes); await app.register(forumRoutes); if (existsSync(webRoot)) { await app.register(fastifyStatic, { root: webRoot, prefix: '/', // maxAge würde den Header für *alle* Dateien setzen und dabei auch die // index.html erwischen - die muss aber bei jedem Aufruf neu geholt werden, // sonst zeigt der Browser nach einem Update monatelang die alte Oberfläche. // Deshalb hier abschalten und beides unten selbst setzen. cacheControl: false, setHeaders: (res, filePath) => { if (filePath.endsWith('.html')) { res.setHeader('Cache-Control', 'no-cache, must-revalidate'); } else if (filePath.includes(`${sep}assets${sep}`)) { // Nur die gebauten Assets tragen einen Inhalts-Hash im Namen: ändert // sich der Inhalt, ändert sich der Dateiname. Die dürfen liegen bleiben. res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); } else { // Alles andere - Bilder, Favicon - behält den Namen bei neuem Inhalt. // Ein Tag mit Rückfrage, damit ein Austausch auch ankommt. res.setHeader('Cache-Control', 'public, max-age=86400, must-revalidate'); } }, }); } // 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; }