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

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);
},
};
}