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>
92 lines
2.6 KiB
TypeScript
92 lines
2.6 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
|
|
import type { DropTarget } from '../config.js';
|
|
import { TooManyUploadsError, UploadSessionNotFoundError } from '../errors.js';
|
|
|
|
/** Un upload fragmenté en cours : morceaux écrits dans `partPath`, finalisé en `finalPath`. */
|
|
export interface UploadSession {
|
|
id: string;
|
|
target: DropTarget;
|
|
/** Nom de fichier annoncé par le client (pour les notifications). */
|
|
originalName: string;
|
|
/** Nom simple, ou chemin relatif `dossier/sous/fichier.ext` (mode dossier). */
|
|
storedName: string;
|
|
mimeType: string;
|
|
partPath: string;
|
|
finalPath: string;
|
|
totalSize: number;
|
|
chunkSize: number;
|
|
chunkCount: number;
|
|
received: Set<number>;
|
|
lastActivity: number;
|
|
}
|
|
|
|
export type NewUploadSession = Omit<UploadSession, 'id' | 'received' | 'lastActivity'>;
|
|
|
|
const IDLE_TTL_MS = 30 * 60_000;
|
|
const SWEEP_INTERVAL_MS = 60_000;
|
|
const MAX_SESSIONS = 100;
|
|
|
|
export interface UploadSessionStore {
|
|
create(input: NewUploadSession): UploadSession;
|
|
/** Récupère et rafraîchit la session ; lève `UploadSessionNotFoundError` si absente. */
|
|
use(id: string): UploadSession;
|
|
remove(id: string): UploadSession | undefined;
|
|
stop(): void;
|
|
}
|
|
|
|
/**
|
|
* Registre en mémoire des uploads fragmentés. Les sessions inactives depuis plus
|
|
* de 30 min sont purgées et `onExpire` est appelé (pour supprimer le `.part`).
|
|
*/
|
|
export function createUploadSessionStore(
|
|
onExpire: (session: UploadSession) => void,
|
|
): UploadSessionStore {
|
|
const sessions = new Map<string, UploadSession>();
|
|
|
|
const timer = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const session of sessions.values()) {
|
|
if (now - session.lastActivity > IDLE_TTL_MS) {
|
|
sessions.delete(session.id);
|
|
onExpire(session);
|
|
}
|
|
}
|
|
}, SWEEP_INTERVAL_MS);
|
|
timer.unref();
|
|
|
|
return {
|
|
create(input: NewUploadSession): UploadSession {
|
|
if (sessions.size >= MAX_SESSIONS) {
|
|
throw new TooManyUploadsError();
|
|
}
|
|
const session: UploadSession = {
|
|
...input,
|
|
id: randomUUID(),
|
|
received: new Set<number>(),
|
|
lastActivity: Date.now(),
|
|
};
|
|
sessions.set(session.id, session);
|
|
return session;
|
|
},
|
|
|
|
use(id: string): UploadSession {
|
|
const session = sessions.get(id);
|
|
if (session === undefined) {
|
|
throw new UploadSessionNotFoundError();
|
|
}
|
|
session.lastActivity = Date.now();
|
|
return session;
|
|
},
|
|
|
|
remove(id: string): UploadSession | undefined {
|
|
const session = sessions.get(id);
|
|
sessions.delete(id);
|
|
return session;
|
|
},
|
|
|
|
stop(): void {
|
|
clearInterval(timer);
|
|
},
|
|
};
|
|
}
|