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>
211 lines
6.6 KiB
TypeScript
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]);
|
|
},
|
|
};
|
|
}
|