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, message: string): void; warn(details: Record, 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; } 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 { 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 { 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 { 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 { const headers: Record = { ...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 { 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 => Promise.resolve(), }; } const pending = new Set>(); const dispatch = (event: WebhookEvent, body: Record): 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 => { await Promise.allSettled([...pending]); }, }; }