mediaDrop/src/lib/notifications.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

211 lines
6.6 KiB
TypeScript

import { createHmac } from 'node:crypto';
import type { WebhookConfig, WebhookEvent } from '../config.js';
/** Sous-ensemble du logger Fastify utilisé par le notifieur. */
export interface NotifierLogger {
debug(details: Record<string, unknown>, message: string): void;
warn(details: Record<string, unknown>, message: string): void;
}
/** Données d'un upload finalisé, avant enrichissement par le notifieur. */
export interface UploadCompletedInput {
target: string;
/** Nom simple, ou chemin relatif `dossier/sous/fichier.ext` (mode dossier). */
storedName: string;
originalName?: string;
size: number;
mimeType: string;
/** `sub` de l'utilisateur, si la session est lisible. */
user?: string;
/** Chemin ayant produit l'upload. */
via: 'direct' | 'chunked';
}
/** Données d'un échec d'upload (route `/upload*` terminée en 4xx/5xx). */
export interface UploadFailedInput {
message: string;
statusCode: number;
method: string;
path: string;
target?: string;
user?: string;
}
export interface Notifier {
/** Notifie les webhooks abonnés à `upload`. Ne lève jamais ; n'attend pas la livraison. */
uploadCompleted(input: UploadCompletedInput): void;
/** Notifie les webhooks abonnés à `error`. Ne lève jamais ; n'attend pas la livraison. */
uploadFailed(input: UploadFailedInput): void;
/** Attend la fin des livraisons en cours (arrêt propre). */
close(): Promise<void>;
}
const MAX_ATTEMPTS = 2;
const RETRY_DELAY_MS = 500;
const USER_AGENT = 'mediaDrop-webhook';
const SIZE_UNITS = ['o', 'Ko', 'Mo', 'Go', 'To'] as const;
function formatBytes(bytes: number): string {
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
value /= 1024;
unit += 1;
}
const shown = value < 10 && unit > 0 ? value.toFixed(1) : String(Math.round(value));
return `${shown} ${SIZE_UNITS[unit] ?? 'o'}`;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function uploadSummary(input: UploadCompletedInput): string {
return `✅ Upload terminé : ${input.storedName} (${formatBytes(input.size)}) — cible « ${input.target} »`;
}
function errorSummary(input: UploadFailedInput): string {
return `⚠️ Échec d'upload (${String(input.statusCode)}) : ${input.message} [${input.method} ${input.path}]`;
}
function uploadBody(input: UploadCompletedInput): Record<string, unknown> {
const summary = uploadSummary(input);
return {
event: 'upload' satisfies WebhookEvent,
timestamp: new Date().toISOString(),
target: input.target,
storedName: input.storedName,
...(input.originalName !== undefined ? { originalName: input.originalName } : {}),
size: input.size,
mimeType: input.mimeType,
...(input.user !== undefined ? { user: input.user } : {}),
via: input.via,
// `text` (Slack) et `content` (Discord) : même résumé lisible.
text: summary,
content: summary,
};
}
function errorBody(input: UploadFailedInput): Record<string, unknown> {
const summary = errorSummary(input);
return {
event: 'error' satisfies WebhookEvent,
timestamp: new Date().toISOString(),
message: input.message,
statusCode: input.statusCode,
method: input.method,
path: input.path,
...(input.target !== undefined ? { target: input.target } : {}),
...(input.user !== undefined ? { user: input.user } : {}),
text: summary,
content: summary,
};
}
function buildHeaders(
webhook: WebhookConfig,
event: WebhookEvent,
body: string,
): Record<string, string> {
const headers: Record<string, string> = {
...webhook.headers,
'content-type': 'application/json',
'user-agent': USER_AGENT,
'x-mediadrop-event': event,
};
if (webhook.secret !== undefined) {
const digest = createHmac('sha256', webhook.secret).update(body).digest('hex');
headers['x-mediadrop-signature'] = `sha256=${digest}`;
}
return headers;
}
async function deliver(
webhook: WebhookConfig,
event: WebhookEvent,
body: string,
log: NotifierLogger,
): Promise<void> {
const headers = buildHeaders(webhook, event, body);
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(webhook.url, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(webhook.timeoutMs),
});
// Vider le corps pour libérer la connexion (keep-alive).
await response.text().catch(() => '');
if (response.ok) {
log.debug({ url: webhook.url, status: response.status, event }, 'Webhook livré');
return;
}
const retriable = response.status === 429 || response.status >= 500;
if (!retriable) {
log.warn({ url: webhook.url, status: response.status, event }, 'Webhook rejeté (abandon)');
return;
}
log.warn({ url: webhook.url, status: response.status, event, attempt }, 'Webhook en échec');
} catch (error) {
log.warn({ url: webhook.url, err: errorText(error), event, attempt }, 'Webhook injoignable');
}
if (attempt < MAX_ATTEMPTS) {
await delay(RETRY_DELAY_MS);
}
}
}
/**
* Crée un notifieur qui POSTe un corps JSON à chaque webhook abonné, à chaque
* upload finalisé (`upload`) et à chaque échec d'upload (`error`).
*
* Livraison en parallèle, sans bloquer la réponse HTTP : chaque webhook a son
* propre délai d'attente, une nouvelle tentative sur erreur réseau / 429 / 5xx,
* et les échecs sont seulement journalisés. `close()` attend les envois en cours.
* Sans webhook configuré, toutes les méthodes sont des non-opérations.
*/
export function createNotifier(webhooks: readonly WebhookConfig[], log: NotifierLogger): Notifier {
if (webhooks.length === 0) {
return {
uploadCompleted(): void {},
uploadFailed(): void {},
close: (): Promise<void> => Promise.resolve(),
};
}
const pending = new Set<Promise<void>>();
const dispatch = (event: WebhookEvent, body: Record<string, unknown>): void => {
const json = JSON.stringify(body);
for (const webhook of webhooks) {
if (!webhook.events.includes(event)) {
continue;
}
const task = deliver(webhook, event, json, log).finally(() => {
pending.delete(task);
});
pending.add(task);
}
};
return {
uploadCompleted(input): void {
dispatch('upload', uploadBody(input));
},
uploadFailed(input): void {
dispatch('error', errorBody(input));
},
close: async (): Promise<void> => {
await Promise.allSettled([...pending]);
},
};
}