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>
89 lines
2.8 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
});
|
|
}
|