Files
CC-Manager/server/src/app.ts
T
Harlekin c82391e73f Vergangene Mitgliedschaften sichtbar, Antworten nicht mehr aus dem Cache
Wer sich aus einem Gruppen-CC herausredesignt hat, war trotzdem darin - der
CC verschwand aber aus seiner Liste. Jetzt steht er am Fuss der Kette, in die
der Spieler von dort aus gewechselt ist, gekennzeichnet als "war dabei" und
mit dem Hinweis, wem er gehoert und wohin es weiterging. Verschieben oder
entscheiden laesst sich daran nichts: es ist der Platz eines anderen.
Frueheres, das zu keinem heutigen Platz fuehrt, steht unter "Frueher
genutzt". Dasselbe gilt fuer einen gewoehnlichen Austritt.

Neu eingetragene CCs tauchten manchmal erst nach einem Neuladen auf. Die
Antworten der Schnittstelle trugen keinen Cache-Header, und ohne Angabe darf
ein Browser selbst entscheiden, wie lange er eine GET-Antwort aufhebt - dann
steht nach dem Anlegen die alte Liste auf dem Schirm. Sie tragen jetzt
no-store, was nebenbei verhindert, dass Spielerdaten im Cache liegen bleiben.

Ausserdem: die Pfeile zwischen den Kettengliedern sind groesser und tragen
die Akzentfarbe - sie sind das Einzige, was die Leserichtung verraet. Und
"Zurueknehmen" heisst jetzt "Loeschen", weil genau das passiert.
2026-09-21 13:35:48 +02:00

183 lines
6.4 KiB
TypeScript

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<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);
/**
* 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;
}