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>
102 lines
3 KiB
TypeScript
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();
|
|
};
|