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;
|
||||
}
|
||||
Reference in New Issue
Block a user