mediaDrop/src/app.ts
Nicolas 8a3fe1407f fix(session): session glissante prolongée par l'activité (#12)
Chaque requête authentifiée repousse l'expiration (au plus une fois par
minute), côté jeton et côté cookie.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 07:36:16 +02:00

122 lines
4.1 KiB
TypeScript

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<FastifyInstance> {
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 <form>.
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;
}