import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import Fastify, { type FastifyInstance, type FastifyServerOptions } from 'fastify'; import multipart from '@fastify/multipart'; import secureSession from '@fastify/secure-session'; import type { AppConfig } from './config.js'; import { registerErrorHandler } from './error-handler.js'; import { ensureUploadDir, removeFile } from './lib/storage.js'; import { createUploadSessionStore } from './lib/upload-sessions.js'; import { createOidcClient, type OidcClient } from './oidc.js'; import { authRoutes } from './routes/auth.js'; import { chunkedUploadRoutes } from './routes/upload-chunked.js'; import { healthRoutes } from './routes/health.js'; import { uploadRoutes } from './routes/upload.js'; const INDEX_HTML = readFileSync( fileURLToPath(new URL('../public/index.html', import.meta.url)), 'utf8', ); const SESSION_COOKIE_NAME = 'mediadrop_session'; export interface BuildAppOptions { /** Passer `false` pour couper les logs (utile en test). */ logger?: FastifyServerOptions['logger']; /** Façade OIDC injectée (test). Par défaut : découverte réelle de l'issuer. */ oidcClient?: OidcClient; } /** * Construit l'instance Fastify : session chiffrée, façade OIDC, plugins, dossier * de dépôt, gestionnaire d'erreurs, page (`GET /`) et routes (`/auth/*`, * `/health`, `/upload`). Exportée pour les tests. */ export async function buildApp( config: AppConfig, options: BuildAppOptions = {}, ): Promise { const oidcClient = options.oidcClient ?? (await createOidcClient(config)); const app = Fastify({ logger: options.logger ?? { level: config.logLevel }, // Uploads pouvant durer longtemps (fichiers de plusieurs Go) : pas de // timeout serveur imposé. Régler les timeouts sur le reverse proxy. requestTimeout: 0, connectionTimeout: 0, }); const uploadSessions = createUploadSessionStore((session) => { void removeFile(session.partPath); }); app.decorate('appConfig', config); app.decorate('oidcClient', oidcClient); app.decorate('uploadSessions', uploadSessions); app.addHook('onClose', () => { uploadSessions.stop(); }); // Corps brut (flux) pour les morceaux d'upload fragmenté ; écrit en streaming. app.addContentTypeParser('application/octet-stream', (_request, payload, taskDone) => { taskDone(null, payload); }); await app.register(secureSession, { key: config.session.key, cookieName: SESSION_COOKIE_NAME, cookie: { path: '/', httpOnly: true, sameSite: 'lax', secure: config.publicUrl.startsWith('https://'), maxAge: config.session.maxAgeSeconds, }, }); await app.register(multipart, { throwFileSizeLimit: true, limits: { files: 1, fileSize: config.maxFileSizeBytes, }, }); for (const target of config.targets) { await ensureUploadDir(target.dir); } registerErrorHandler(app); app.get('/', (_request, reply): void => { reply.type('text/html; charset=utf-8').send(INDEX_HTML); }); await app.register(authRoutes); await app.register(healthRoutes); await app.register(uploadRoutes); await app.register(chunkedUploadRoutes); return app; }