- init fragmenté : lit target/path dans la query (corps en repli) (#1) - erreurs client interrompu / disque plein / fichier partiel absent mappées en 400/507/404 sans webhook d'échec pour les interruptions (#2) - nettoyage des sessions expirées et des .part orphelins sécurisé, log des unhandledRejection (#2) - plafond de sessions porté à 256 pour l'usage multi-fenêtres (#3) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
115 lines
3.9 KiB
TypeScript
115 lines
3.9 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 { 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();
|
|
});
|
|
|
|
// 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;
|
|
}
|