Files
CC-Manager/server/src/app.ts
T
Harlekin a7228e63d1 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.
2026-09-18 11:37:31 +02:00

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;
}