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 { createNotifier } from './lib/notifications.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 { createSessionRefresher } from './session.js'; import { chunkedUploadRoutes } from './routes/upload-chunked.js'; import { healthRoutes } from './routes/health.js'; import { pageRoutes } from './routes/pages.js'; import { uploadRoutes } from './routes/upload.js'; 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 /` — connexion ou dépôt selon la * session) 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) => { removeFile(session.partPath).catch((error: unknown) => { app.log.warn( { err: error }, "Impossible de supprimer le fichier partiel d'une session expirée", ); }); }); const notifier = createNotifier(config.webhooks, app.log); app.decorate('appConfig', config); app.decorate('oidcClient', oidcClient); app.decorate('uploadSessions', uploadSessions); app.decorate('notifier', notifier); app.addHook('onClose', async () => { uploadSessions.stop(); await notifier.close(); }); const refreshSession = createSessionRefresher(config.session.maxAgeSeconds); app.addHook('preHandler', (request, _reply, hookDone) => { refreshSession(request); hookDone(); }); // Corps brut (flux) pour les morceaux d'upload fragmenté ; écrit en streaming. app.addContentTypeParser('application/octet-stream', (_request, payload, taskDone) => { taskDone(null, payload); }); // Formulaires HTML (déconnexion) : sinon Fastify répond 415 sur le POST du
. app.addContentTypeParser( 'application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body: string | Buffer, taskDone) => { try { const raw = typeof body === 'string' ? body : body.toString('utf8'); taskDone(null, Object.fromEntries(new URLSearchParams(raw))); } catch (error) { taskDone(error instanceof Error ? error : new Error('Corps de formulaire invalide')); } }, ); 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); await app.register(pageRoutes); await app.register(authRoutes); await app.register(healthRoutes); await app.register(uploadRoutes); await app.register(chunkedUploadRoutes); return app; }