mediaDrop/src/routes/upload-chunked.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

201 lines
5.9 KiB
TypeScript

import { stat } from 'node:fs/promises';
import type { Readable } from 'node:stream';
import type { FastifyPluginCallback, FastifyReply } from 'fastify';
import { PayloadTooLargeError, ValidationError } from '../errors.js';
import { isAllowedMedia } from '../lib/media-types.js';
import {
createEmptyFile,
finalizeFile,
removeFile,
resolveDestination,
writeChunkAt,
} from '../lib/storage.js';
import { resolvePlacement, resolveTarget } from '../lib/targets.js';
import { getSessionUser, requireSession } from '../session.js';
const MIN_CHUNK = 1024 * 1024;
const MAX_CHUNK = 32 * 1024 * 1024;
const DEFAULT_CHUNK = 16 * 1024 * 1024;
const PART_SUFFIX = '.mediadrop-part';
interface InitBody {
target?: unknown;
path?: unknown;
name?: unknown;
size?: unknown;
type?: unknown;
chunkSize?: unknown;
}
interface InitResponse {
id: string;
chunkSize: number;
chunkCount: number;
}
interface ChunkAck {
received: number;
total: number;
}
interface CompleteResponse {
target: string;
storedName: string;
size: number;
mimeType: string;
}
function positiveInt(value: unknown, name: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
throw new ValidationError(`${name} doit être un entier positif`);
}
return value;
}
function nonEmptyString(value: unknown, fallback: string): string {
return typeof value === 'string' && value.length > 0 ? value : fallback;
}
function clampChunkSize(value: unknown): number {
if (value === undefined) {
return DEFAULT_CHUNK;
}
return Math.min(MAX_CHUNK, Math.max(MIN_CHUNK, positiveInt(value, 'chunkSize')));
}
/**
* Upload fragmenté et reprenable :
* - `POST /upload/init` crée une session, renvoie `{ id, chunkSize, chunkCount }`
* - `PUT /upload/:id/:index` envoie un morceau (corps brut octet-stream)
* - `POST /upload/:id/complete` assemble et finalise, renvoie 201 + métadonnées
* - `DELETE /upload/:id` abandonne (supprime le fichier partiel)
*/
export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done) => {
const cfg = app.appConfig;
app.post(
'/upload/init',
{ preHandler: requireSession },
async (request, reply): Promise<InitResponse> => {
const body = (request.body ?? {}) as InitBody;
const target = resolveTarget(cfg.targets, body.target);
const placement = resolvePlacement(target, body.path);
const mimeType = nonEmptyString(body.type, 'application/octet-stream');
const totalSize = positiveInt(body.size, 'size');
if (totalSize > cfg.maxFileSizeBytes) {
throw new PayloadTooLargeError();
}
if (!isAllowedMedia(mimeType, cfg.allowedMimeTypes)) {
throw new ValidationError(`Type de média non autorisé : ${mimeType}`);
}
const chunkSize = clampChunkSize(body.chunkSize);
const chunkCount = Math.ceil(totalSize / chunkSize);
const originalName = nonEmptyString(body.name, 'fichier');
const { storedName, destination } = await resolveDestination({
targetDir: target.dir,
originalName,
mimeType,
...(placement !== undefined ? { placement } : {}),
});
const partPath = destination + PART_SUFFIX;
await createEmptyFile(partPath);
const session = app.uploadSessions.create({
target,
originalName,
storedName,
mimeType,
partPath,
finalPath: destination,
totalSize,
chunkSize,
chunkCount,
});
reply.status(201);
return { id: session.id, chunkSize, chunkCount };
},
);
app.put(
'/upload/:id/:index',
{ preHandler: requireSession },
async (request): Promise<ChunkAck> => {
const { id, index } = request.params as { id: string; index: string };
const session = app.uploadSessions.use(id);
const chunkIndex = Number(index);
if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= session.chunkCount) {
throw new ValidationError('Index de morceau hors limites');
}
await writeChunkAt({
source: request.body as Readable,
filePath: session.partPath,
offset: chunkIndex * session.chunkSize,
maxBytes: session.chunkSize,
});
session.received.add(chunkIndex);
return { received: session.received.size, total: session.chunkCount };
},
);
app.post(
'/upload/:id/complete',
{ preHandler: requireSession },
async (request, reply): Promise<CompleteResponse> => {
const { id } = request.params as { id: string };
const session = app.uploadSessions.use(id);
if (session.received.size !== session.chunkCount) {
throw new ValidationError('Upload incomplet : des morceaux manquent');
}
const { size } = await stat(session.partPath);
if (size !== session.totalSize) {
throw new ValidationError('Taille finale incohérente');
}
await finalizeFile(session.partPath, session.finalPath);
app.uploadSessions.remove(id);
app.notifier.uploadCompleted({
target: session.target.name,
storedName: session.storedName,
originalName: session.originalName,
size: session.totalSize,
mimeType: session.mimeType,
user: getSessionUser(request)?.sub,
via: 'chunked',
});
reply.status(201);
return {
target: session.target.name,
storedName: session.storedName,
size: session.totalSize,
mimeType: session.mimeType,
};
},
);
app.delete(
'/upload/:id',
{ preHandler: requireSession },
async (request, reply): Promise<FastifyReply> => {
const { id } = request.params as { id: string };
const session = app.uploadSessions.remove(id);
if (session !== undefined) {
await removeFile(session.partPath);
}
return reply.status(204).send();
},
);
done();
};