Files
CC-Manager/server/src/app.ts
T
Harlekin b9364151b0 index.html wurde ein Jahr lang zwischengespeichert
Die maxAge-Option von fastify-static setzt den Cache-Header fuer jede Datei,
auch fuer die index.html. Der setHeaders-Callback, der das fuer HTML wieder
aufheben sollte, kam dagegen nicht an: ausgeliefert wurde durchgehend
public, max-age=31536000.

Damit bekam ein Browser nach einem Update weiter die alte Oberflaeche - ein
Jahr lang, ohne Fehlermeldung, obwohl der Server laengst neue Dateien hatte.
Genau so ist der Abgleich-Knopf nach dem Deploy unsichtbar geblieben.

Jetzt setzt die App beide Faelle selbst: HTML immer neu holen, Assets
unbegrenzt behalten. Deren Dateinamen enthalten einen Inhalts-Hash, sie
aendern sich bei neuem Inhalt ohnehin.

Drei Tests pruefen die Header, weil ein Fehler an dieser Stelle nirgends
auffaellt - er sieht aus, als waere das Update nicht angekommen.
2026-09-18 16:31:08 +02:00

163 lines
5.5 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';
import { scraperRoutes } from './routes/scraper.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);
await app.register(scraperRoutes);
if (existsSync(webRoot)) {
await app.register(fastifyStatic, {
root: webRoot,
prefix: '/',
// maxAge wuerde den Header fuer *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 Oberflaeche.
// Deshalb hier abschalten und beides unten selbst setzen.
cacheControl: false,
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
} else {
// Assets tragen einen Inhalts-Hash im Namen: aendert sich der Inhalt,
// aendert sich der Dateiname. Die duerfen beliebig lange liegen.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
},
});
}
// 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;
}