mediaDrop/src/error-handler.ts
Nicolas 8eb0470474
All checks were successful
Publier l'image Docker / test (push) Successful in 22s
Publier l'image Docker / publish (push) Successful in 10s
Notifications par webhook (upload finalisé / échec)
Nouvelle clé `webhooks` : liste de points de terminaison POST notifiés à
chaque upload finalisé (`upload`, direct comme fragmenté) et à chaque
requête `/upload*` terminée en 4xx/5xx (`error`).

Chaque entrée : `url` (http/https), `events` (défaut : les deux),
`headers` (auth du récepteur), `secret` (signature HMAC-SHA256 dans
`X-MediaDrop-Signature`), `timeoutMs` (défaut 5 s, max 30 s).

Corps JSON structuré + champs `text`/`content` portant un résumé lisible,
pour fonctionner tel quel avec Slack / Discord / ntfy ou un récepteur
custom. Livraison en parallèle, sans bloquer la réponse d'upload ; une
nouvelle tentative sur erreur réseau / 429 / 5xx ; échecs journalisés en
`warn` ; l'arrêt du serveur attend les envois en cours. Sans webhook
configuré, le notifieur est une non-opération.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:29:44 +02:00

89 lines
2.8 KiB
TypeScript

import type { FastifyError, FastifyInstance, FastifyRequest } from 'fastify';
import { AppError } from './errors.js';
import { getSessionUser } from './session.js';
interface ErrorBody {
error: string;
message: string;
}
const TOO_LARGE_MESSAGE = 'Le fichier dépasse la taille maximale autorisée';
/** Détermine le statut HTTP et le corps `{ error, message }` d'une erreur. */
function resolveError(
error: FastifyError,
request: FastifyRequest,
): { status: number; body: ErrorBody } {
if (error instanceof AppError) {
return { status: error.statusCode, body: { error: error.name, message: error.message } };
}
switch (error.code) {
case 'FST_REQ_FILE_TOO_LARGE':
return { status: 413, body: { error: 'PayloadTooLargeError', message: TOO_LARGE_MESSAGE } };
case 'FST_FILES_LIMIT':
return {
status: 400,
body: { error: 'ValidationError', message: 'Un seul fichier par requête' },
};
case 'FST_INVALID_MULTIPART_CONTENT_TYPE':
return {
status: 400,
body: { error: 'ValidationError', message: 'Requête multipart/form-data attendue' },
};
default:
break;
}
if (error.validation) {
return { status: 400, body: { error: 'ValidationError', message: error.message } };
}
request.log.error({ err: error }, 'Erreur non gérée');
return { status: 500, body: { error: 'InternalServerError', message: 'Erreur interne' } };
}
/** Chemin de la requête sans la chaîne de query. */
function pathOf(request: FastifyRequest): string {
return request.url.split('?', 1)[0] ?? request.url;
}
function requestedTarget(request: FastifyRequest): string | undefined {
const query = request.query as { target?: unknown };
return typeof query.target === 'string' ? query.target : undefined;
}
function isUploadPath(path: string): boolean {
return path === '/upload' || path.startsWith('/upload/');
}
/**
* Installe le gestionnaire d'erreurs global.
*
* - `AppError` -> `statusCode` + `{ error, message }`.
* - Erreurs `@fastify/multipart` connues -> 4xx explicites.
* - Erreurs de validation de schéma Fastify -> 400.
* - Tout le reste -> 500 générique (détails seulement dans les logs).
*
* Toute réponse 4xx/5xx sur une route `/upload*` déclenche une notification
* webhook `error`.
*/
export function registerErrorHandler(app: FastifyInstance): void {
app.setErrorHandler((error: FastifyError, request, reply): void => {
const { status, body } = resolveError(error, request);
reply.status(status).send(body);
const path = pathOf(request);
if (status >= 400 && isUploadPath(path)) {
app.notifier.uploadFailed({
message: body.message,
statusCode: status,
method: request.method,
path,
target: requestedTarget(request),
user: getSessionUser(request)?.sub,
});
}
});
}