mediaDrop/src/routes/upload.ts
Nicolas 64a38b1e41
All checks were successful
Publier l'image Docker / test (push) Successful in 17s
Publier l'image Docker / publish (push) Successful in 11s
Upload fragmenté : pool de connexions partagé et configurable
Remplace les 3 workers par fichier par un pool global de morceaux en vol,
réparti équitablement entre les fichiers fragmentés actifs : 1 fichier
utilise tout le pool, N fichiers en reçoivent une part chacun
(ceil(taille / N)), la capacité libérée par un fichier terminé est
redistribuée. Un fichier en cours d'init compte dans la part pour ne pas
être affamé ; les morceaux déjà en vol ne sont jamais interrompus.

La taille du pool est réglable via `chunkPoolSize` (config.json, défaut
30, borné à 256) et transmise au navigateur par un nouveau `GET /config`.

Corps des morceaux repassé en streaming (writeChunkAt sur un flux + garde
`maxBytes`) : mémoire ~constante même à pool élevé, plus de bufferisation
de 16 Mo par requête.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:12:04 +02:00

102 lines
3 KiB
TypeScript

import type { FastifyPluginCallback } from 'fastify';
import type { DropTarget } from '../config.js';
import { ValidationError } from '../errors.js';
import { isAllowedMedia } from '../lib/media-types.js';
import { resolvePlacement, resolveTarget } from '../lib/targets.js';
import { requireSession } from '../session.js';
import { saveFile, type StoredFile } from '../lib/storage.js';
const FIELD_NAME = 'file';
interface UploadResponse {
target: string;
storedName: string;
originalName: string;
size: number;
mimeType: string;
}
interface TargetSummary {
name: string;
label: string;
allowDirectories: boolean;
}
interface ClientConfig {
/** Taille du pool de connexions pour l'upload fragmenté (réglage client). */
chunkPoolSize: number;
}
interface UploadQuery {
target?: unknown;
path?: unknown;
}
function toResponse(target: DropTarget, stored: StoredFile): UploadResponse {
return {
target: target.name,
storedName: stored.storedName,
originalName: stored.originalName,
size: stored.size,
mimeType: stored.mimeType,
};
}
/**
* - `GET /config` — réglages client non sensibles (taille du pool de morceaux)
* - `GET /targets` — liste des cibles (nom, libellé, dépôt de dossier autorisé)
* - `POST /upload?target=<nom>[&path=<chemin relatif>]` — dépôt d'un fichier multipart
*/
export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => {
app.get('/config', (): ClientConfig => ({ chunkPoolSize: app.appConfig.chunkPoolSize }));
app.get('/targets', { preHandler: requireSession }, (): TargetSummary[] =>
app.appConfig.targets.map((target) => ({
name: target.name,
label: target.label,
allowDirectories: target.allowDirectories,
})),
);
app.post(
'/upload',
{ preHandler: requireSession },
async (request, reply): Promise<UploadResponse> => {
const query = request.query as UploadQuery;
const target = resolveTarget(app.appConfig.targets, query.target);
const placement = resolvePlacement(target, query.path);
if (!request.isMultipart()) {
throw new ValidationError('Requête multipart/form-data attendue');
}
const part = await request.file();
if (!part) {
throw new ValidationError(`Aucun fichier reçu dans le champ « ${FIELD_NAME} »`);
}
if (part.fieldname !== FIELD_NAME) {
part.file.resume();
throw new ValidationError(`Le fichier doit être envoyé dans le champ « ${FIELD_NAME} »`);
}
if (!isAllowedMedia(part.mimetype, app.appConfig.allowedMimeTypes)) {
part.file.resume();
throw new ValidationError(`Type de média non autorisé : ${part.mimetype}`);
}
const stored = await saveFile({
source: part.file,
originalName: part.filename,
mimeType: part.mimetype,
targetDir: target.dir,
maxFileSizeBytes: app.appConfig.maxFileSizeBytes,
...(placement !== undefined ? { placement } : {}),
});
reply.status(201);
return toResponse(target, stored);
},
);
done();
};