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 => { 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 => { 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 => { 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 => { 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(); };