Upload fragmenté : pool de connexions partagé et configurable
All checks were successful
Publier l'image Docker / test (push) Successful in 17s
Publier l'image Docker / publish (push) Successful in 11s

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>
This commit is contained in:
Nicolas 2026-09-10 19:12:04 +02:00
parent 3a555c0bc9
commit 64a38b1e41
13 changed files with 291 additions and 79 deletions

View file

@ -116,6 +116,7 @@ pouvant écrire les paquets de l'organisation.
| `GET /auth/callback` | non | Fin du flux : ouvre la session, redirige vers `/` | | `GET /auth/callback` | non | Fin du flux : ouvre la session, redirige vers `/` |
| `POST /auth/logout` | session | Ferme la session (+ déconnexion RP si disponible) | | `POST /auth/logout` | session | Ferme la session (+ déconnexion RP si disponible) |
| `GET /auth/me` | non | `{ "authenticated": bool, "name"?: string }` | | `GET /auth/me` | non | `{ "authenticated": bool, "name"?: string }` |
| `GET /config` | non | Réglages client : `{ "chunkPoolSize": number }` |
| `GET /targets` | session | Cibles : `[{ "name", "label", "allowDirectories" }]` (sans chemin) | | `GET /targets` | session | Cibles : `[{ "name", "label", "allowDirectories" }]` (sans chemin) |
| `POST /upload?target=<nom>[&path=<p>]` | session | Dépôt direct d'un fichier `multipart/form-data`, champ `file` | | `POST /upload?target=<nom>[&path=<p>]` | session | Dépôt direct d'un fichier `multipart/form-data`, champ `file` |
| `POST /upload/init` | session | Démarre un upload fragmenté → `{ id, chunkSize, chunkCount }` | | `POST /upload/init` | session | Démarre un upload fragmenté → `{ id, chunkSize, chunkCount }` |
@ -155,9 +156,17 @@ La page gère aussi le **glisser-déposer d'un dossier entier** et un bouton
### Gros fichiers — upload fragmenté ### Gros fichiers — upload fragmenté
La page bascule automatiquement en **upload fragmenté** au-delà de **32 Mo** : La page bascule automatiquement en **upload fragmenté** au-delà de **32 Mo** :
morceaux de 16 Mo, **3 en parallèle**, chaque morceau ré-essayé jusqu'à 3 fois. morceaux de 16 Mo, chacun ré-essayé jusqu'à 3 fois. `maxFileSizeMb` reste la
Aucune configuration ; `maxFileSizeMb` reste la limite (`10240` = 10 Go). limite (`10240` = 10 Go).
- **Pool de connexions partagé** (`chunkPoolSize`, défaut `30`) : budget global de
morceaux en vol, réparti équitablement entre les fichiers fragmentés en cours.
1 fichier ⇒ il utilise tout le pool ; `N` fichiers ⇒ `⌈30 / N⌉` morceaux chacun ;
quand un fichier se termine, sa part est redistribuée. Le navigateur lit la
valeur via `GET /config`.
- **HTTP/2 recommandé** : en HTTP/1.1 le navigateur plafonne à ~6 connexions par
origine, donc un `chunkPoolSize` élevé n'a d'effet réel qu'en HTTP/2 (activer
« HTTP/2 Support » sur l'hôte du reverse proxy).
- Écriture en place à l'offset du morceau, mémoire constante ; assemblage sans - Écriture en place à l'offset du morceau, mémoire constante ; assemblage sans
seconde passe. Fichier partiel en `<nom>.mediadrop-part`, renommé à la seconde passe. Fichier partiel en `<nom>.mediadrop-part`, renommé à la
finalisation. finalisation.
@ -187,6 +196,7 @@ Un seul fichier JSON (voir [`config.example.json`](config.example.json), schéma
| `targets[].allowDirectories` | `false` | Autoriser le dépôt de dossiers (arborescence recréée sous `dir`) | | `targets[].allowDirectories` | `false` | Autoriser le dépôt de dossiers (arborescence recréée sous `dir`) |
| `maxFileSizeMb` | `512` | Taille maximale par fichier, en Mo (`10240` = 10 Go) | | `maxFileSizeMb` | `512` | Taille maximale par fichier, en Mo (`10240` = 10 Go) |
| `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` | | `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` |
| `chunkPoolSize` | `30` | Morceaux en vol simultanés (upload fragmenté), répartis entre fichiers actifs ; `1`–`256`. Utile en HTTP/2 |
| `allowedMimeTypes` | _(liste média)_ | `"all"`, ou tableau de types exacts et/ou jokers de préfixe (`"video/*"`, `"*"`) ; absent => liste par défaut | | `allowedMimeTypes` | _(liste média)_ | `"all"`, ou tableau de types exacts et/ou jokers de préfixe (`"video/*"`, `"*"`) ; absent => liste par défaut |
| `session.key` | — | **Obligatoire.** 32 octets base64 (`openssl rand -base64 32`) | | `session.key` | — | **Obligatoire.** 32 octets base64 (`openssl rand -base64 32`) |
| `session.maxAgeHours` | `12` | Durée de vie absolue de la session | | `session.maxAgeHours` | `12` | Durée de vie absolue de la session |

View file

@ -9,6 +9,7 @@
], ],
"maxFileSizeMb": 512, "maxFileSizeMb": 512,
"logLevel": "info", "logLevel": "info",
"chunkPoolSize": 30,
"allowedMimeTypes": ["image/jpeg", "image/png", "image/webp", "video/mp4", "audio/mpeg"], "allowedMimeTypes": ["image/jpeg", "image/png", "image/webp", "video/mp4", "audio/mpeg"],
"session": { "session": {
"key": "REMPLACER_PAR_32_OCTETS_BASE64_openssl_rand_base64_32", "key": "REMPLACER_PAR_32_OCTETS_BASE64_openssl_rand_base64_32",

View file

@ -43,6 +43,13 @@
"enum": ["fatal", "error", "warn", "info", "debug", "trace", "silent"], "enum": ["fatal", "error", "warn", "info", "debug", "trace", "silent"],
"default": "info" "default": "info"
}, },
"chunkPoolSize": {
"type": "integer",
"minimum": 1,
"maximum": 256,
"default": 30,
"description": "Nombre max de requêtes de morceaux en vol, réparti par le navigateur entre les fichiers fragmentés actifs (1 fichier => tout le pool, N fichiers => une part chacun). Efficace surtout en HTTP/2."
},
"allowedMimeTypes": { "allowedMimeTypes": {
"description": "\"all\", ou liste de types exacts et/ou jokers de préfixe (\"video/*\", \"*/*\", \"*\"). Absent => liste média par défaut.", "description": "\"all\", ou liste de types exacts et/ou jokers de préfixe (\"video/*\", \"*/*\", \"*\"). Absent => liste média par défaut.",
"oneOf": [ "oneOf": [

View file

@ -262,9 +262,10 @@
const queueCancel = document.getElementById('queueCancel'); const queueCancel = document.getElementById('queueCancel');
const list = document.getElementById('list'); const list = document.getElementById('list');
const MAX_PARALLEL = 2; const MAX_PARALLEL = 3; // dépôts directs (petits fichiers) simultanés
const MAX_CHUNKED_JOBS = 32; // fichiers fragmentés actifs à la fois (< 100 côté serveur)
const CHUNK_THRESHOLD = 32 * 1024 * 1024; // au-delà : upload fragmenté et reprenable const CHUNK_THRESHOLD = 32 * 1024 * 1024; // au-delà : upload fragmenté et reprenable
const CHUNK_PARALLEL = 3; let CHUNK_POOL = 30; // budget global de morceaux en vol ; remplacé par GET /config
const CHUNK_RETRIES = 3; const CHUNK_RETRIES = 3;
let authenticated = false; let authenticated = false;
@ -381,7 +382,6 @@
ui.status.className = 'status ' + (kind || 'muted'); ui.status.className = 'status ' + (kind || 'muted');
} }
// File d'attente : au plus MAX_PARALLEL transferts simultanés.
const queue = []; const queue = [];
const active = new Set(); const active = new Set();
@ -398,15 +398,30 @@
} }
function pump() { function pump() {
while (active.size < MAX_PARALLEL && queue.length > 0) { let simple = 0;
startUpload(queue.shift()); let chunked = 0;
for (const j of active) {
if (j.chunked) chunked += 1;
else simple += 1;
} }
const kept = [];
for (const job of queue) {
if (job.chunked ? chunked < MAX_CHUNKED_JOBS : simple < MAX_PARALLEL) {
if (job.chunked) chunked += 1;
else simple += 1;
startUpload(job);
} else {
kept.push(job);
}
}
queue.length = 0;
for (const job of kept) queue.push(job);
updateQueueRow(); updateQueueRow();
} }
function enqueue(file, relativePath) { function enqueue(file, relativePath) {
const ui = addItem(file, relativePath); const ui = addItem(file, relativePath);
const job = { file, relativePath, ui }; const job = { file, relativePath, ui, chunked: file.size > CHUNK_THRESHOLD };
queue.push(job); queue.push(job);
ui.cancelBtn.hidden = false; ui.cancelBtn.hidden = false;
ui.cancelBtn.onclick = () => { ui.cancelBtn.onclick = () => {
@ -420,6 +435,64 @@
updateQueueRow(); updateQueueRow();
} }
// Pool global de morceaux en vol, réparti équitablement entre les fichiers
// fragmentés actifs. Chaque fichier est plafonné à sa part équitable
// (ceil(CHUNK_POOL / nb de fichiers actifs)) : 1 fichier => tout le pool,
// N fichiers => une part chacun. Un fichier encore en cours d'initialisation
// (`priming`) compte dans la part pour lui réserver un slot ; à égalité de
// morceaux en vol, le fichier le moins servi passe en premier.
const chunkPool = {
free: CHUNK_POOL,
entries: [],
add(entry) {
this.entries.push(entry);
this.tick();
},
remove(entry) {
const i = this.entries.indexOf(entry);
if (i >= 0) this.entries.splice(i, 1);
this.tick();
},
tick() {
while (this.free > 0) {
const contenders = this.entries.filter(
(e) => !e.stopped && (e.pending.length > 0 || e.priming),
);
if (contenders.length === 0) return;
const share = Math.max(1, Math.ceil(CHUNK_POOL / contenders.length));
let best = null;
for (const e of this.entries) {
if (e.stopped || e.pending.length === 0 || e.inflight >= share) continue;
if (!best || e.inflight < best.inflight) best = e;
}
if (!best) return;
const target = best;
const index = target.pending.shift();
target.inflight += 1;
this.free -= 1;
target.send(index).finally(() => {
target.inflight -= 1;
this.free += 1;
this.tick();
});
}
},
};
// Taille du pool réglée côté serveur (config.json -> GET /config). Ajuste
// `free` du même delta : sûr même si des morceaux sont déjà en vol.
fetch('/config', { headers: { accept: 'application/json' } })
.then((r) => (r.ok ? r.json() : null))
.then((cfg) => {
const n = cfg && Number(cfg.chunkPoolSize);
if (Number.isInteger(n) && n > 0 && n !== CHUNK_POOL) {
chunkPool.free += n - CHUNK_POOL;
CHUNK_POOL = n;
chunkPool.tick();
}
})
.catch(() => {});
function makeReporter(ui, total) { function makeReporter(ui, total) {
let started = 0; let started = 0;
let lastAt = 0; let lastAt = 0;
@ -527,21 +600,43 @@
async function runChunked(job) { async function runChunked(job) {
const { file, relativePath, ui } = job; const { file, relativePath, ui } = job;
const report = makeReporter(ui, file.size); const report = makeReporter(ui, file.size);
const inflight = new Set(); const inflight = new Set(); // XHR en vol (pour l'annulation)
let aborted = false; let aborted = false;
let failed = false;
let sessionId = null; let sessionId = null;
let resolveAll = null;
const finished = new Promise((r) => {
resolveAll = r;
});
let settled = false;
const finalize = (outcome) => {
if (!settled) {
settled = true;
resolveAll(outcome);
}
};
const dropSession = () => { const dropSession = () => {
if (sessionId) fetch('/upload/' + sessionId, { method: 'DELETE' }).catch(() => {}); if (sessionId) fetch('/upload/' + sessionId, { method: 'DELETE' }).catch(() => {});
}; };
// Inscrit dans le pool dès maintenant, avant l'init réseau : le
// planificateur compte ce fichier dans la part équitable (`priming`) et
// lui réserve un slot même si son init n'a pas encore répondu.
const entry = { inflight: 0, stopped: false, priming: true, pending: [], send: null };
chunkPool.add(entry);
job.cancel = () => { job.cancel = () => {
aborted = true; aborted = true;
entry.stopped = true;
entry.pending.length = 0;
for (const x of inflight) x.abort(); for (const x of inflight) x.abort();
chunkPool.remove(entry);
dropSession(); dropSession();
finish(job); finish(job);
ui.progress.textContent = ''; ui.progress.textContent = '';
setStatus(ui, 'Annulé', 'err'); setStatus(ui, 'Annulé', 'err');
finalize('abort');
}; };
let meta; let meta;
@ -557,11 +652,15 @@
}); });
meta = await res.json().catch(() => ({})); meta = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
entry.stopped = true;
chunkPool.remove(entry);
finish(job); finish(job);
applyResult(job, res.status, meta); applyResult(job, res.status, meta);
return; return;
} }
} catch { } catch {
entry.stopped = true;
chunkPool.remove(entry);
finish(job); finish(job);
setStatus(ui, 'Erreur réseau (init)', 'err'); setStatus(ui, 'Erreur réseau (init)', 'err');
return; return;
@ -571,11 +670,37 @@
const chunkSize = meta.chunkSize; const chunkSize = meta.chunkSize;
const chunkCount = meta.chunkCount; const chunkCount = meta.chunkCount;
const loadedPer = new Array(chunkCount).fill(0); const loadedPer = new Array(chunkCount).fill(0);
const attempts = new Array(chunkCount).fill(0);
const emit = () => report(loadedPer.reduce((a, b) => a + b, 0)); const emit = () => report(loadedPer.reduce((a, b) => a + b, 0));
let nextIndex = 0; let doneCount = 0;
const sendOnce = (index) => function retry(index, status, releasePermit) {
new Promise((resolve) => { if (aborted || entry.stopped) {
releasePermit();
return;
}
attempts[index] += 1;
if (attempts[index] > CHUNK_RETRIES) {
entry.stopped = true;
entry.pending.length = 0;
setStatus(ui, 'Erreur morceau ' + index + ' (' + (status || 'réseau') + ')', 'err');
finalize('fail');
releasePermit();
return;
}
setTimeout(() => {
if (!aborted && !entry.stopped) {
entry.pending.unshift(index);
chunkPool.tick();
}
}, 400 * attempts[index]);
releasePermit(); // libère le permis ; le morceau est re-planifié après le backoff
}
entry.pending = Array.from({ length: chunkCount }, (_, i) => i);
entry.priming = false;
entry.send = (index) =>
new Promise((releasePermit) => {
const start = index * chunkSize; const start = index * chunkSize;
const end = Math.min(start + chunkSize, file.size); const end = Math.min(start + chunkSize, file.size);
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@ -588,51 +713,42 @@
emit(); emit();
} }
}); });
const settle = (r) => {
inflight.delete(xhr);
resolve(r);
};
xhr.addEventListener('load', () => { xhr.addEventListener('load', () => {
inflight.delete(xhr);
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
loadedPer[index] = end - start; loadedPer[index] = end - start;
emit(); emit();
settle({ ok: true }); doneCount += 1;
if (doneCount === chunkCount) finalize('ok');
releasePermit();
} else if (xhr.status === 401) {
entry.stopped = true;
entry.pending.length = 0;
applyResult(job, 401, {});
finalize('fail');
releasePermit();
} else { } else {
settle({ ok: false, status: xhr.status }); retry(index, xhr.status, releasePermit);
} }
}); });
xhr.addEventListener('error', () => settle({ ok: false, status: 0 })); xhr.addEventListener('error', () => {
xhr.addEventListener('abort', () => settle({ ok: false, aborted: true })); inflight.delete(xhr);
retry(index, 0, releasePermit);
});
xhr.addEventListener('abort', () => {
inflight.delete(xhr);
releasePermit();
});
xhr.send(file.slice(start, end)); xhr.send(file.slice(start, end));
}); });
async function worker() { chunkPool.tick();
while (!aborted && !failed && nextIndex < chunkCount) { const outcome = await finished;
const index = nextIndex; chunkPool.remove(entry);
nextIndex += 1;
let r = { ok: false };
for (let attempt = 0; attempt <= CHUNK_RETRIES; attempt += 1) {
r = await sendOnce(index);
if (r.ok || r.aborted || r.status === 401) break;
await new Promise((res) => setTimeout(res, 400 * (attempt + 1)));
if (aborted) return;
}
if (r.ok) continue;
if (r.aborted) return;
failed = true;
if (r.status === 401) applyResult(job, 401, {});
else
setStatus(ui, 'Erreur morceau ' + index + ' (' + (r.status || 'réseau') + ')', 'err');
return;
}
}
const workers = []; if (outcome === 'abort') return;
for (let i = 0; i < Math.min(CHUNK_PARALLEL, chunkCount); i += 1) workers.push(worker()); if (outcome === 'fail') {
await Promise.all(workers); for (const x of inflight) x.abort();
if (aborted) return;
if (failed) {
finish(job); finish(job);
dropSession(); dropSession();
return; return;

View file

@ -11,7 +11,7 @@ import { ensureUploadDir, removeFile } from './lib/storage.js';
import { createUploadSessionStore } from './lib/upload-sessions.js'; import { createUploadSessionStore } from './lib/upload-sessions.js';
import { createOidcClient, type OidcClient } from './oidc.js'; import { createOidcClient, type OidcClient } from './oidc.js';
import { authRoutes } from './routes/auth.js'; import { authRoutes } from './routes/auth.js';
import { chunkedUploadRoutes, CHUNK_BODY_LIMIT } from './routes/upload-chunked.js'; import { chunkedUploadRoutes } from './routes/upload-chunked.js';
import { healthRoutes } from './routes/health.js'; import { healthRoutes } from './routes/health.js';
import { uploadRoutes } from './routes/upload.js'; import { uploadRoutes } from './routes/upload.js';
@ -59,14 +59,10 @@ export async function buildApp(
uploadSessions.stop(); uploadSessions.stop();
}); });
// Corps brut (bufferisé) pour les morceaux d'upload fragmenté. // Corps brut (flux) pour les morceaux d'upload fragmenté ; écrit en streaming.
app.addContentTypeParser( app.addContentTypeParser('application/octet-stream', (_request, payload, taskDone) => {
'application/octet-stream', taskDone(null, payload);
{ parseAs: 'buffer', bodyLimit: CHUNK_BODY_LIMIT }, });
(_request, body, taskDone) => {
taskDone(null, body);
},
);
await app.register(secureSession, { await app.register(secureSession, {
key: config.session.key, key: config.session.key,

View file

@ -52,6 +52,12 @@ export interface AppConfig {
maxFileSizeBytes: number; maxFileSizeBytes: number;
allowedMimeTypes: MimePolicy; allowedMimeTypes: MimePolicy;
logLevel: LogLevel; logLevel: LogLevel;
/**
* Nombre maximum de requêtes de morceaux en vol simultanément, réparti par le
* client entre les fichiers fragmentés actifs (1 fichier => tout le pool,
* N fichiers => une part chacun). Transmis au navigateur via `GET /config`.
*/
chunkPoolSize: number;
session: SessionConfig; session: SessionConfig;
oidc: OidcConfig; oidc: OidcConfig;
} }
@ -66,8 +72,12 @@ const DEFAULTS = {
maxFileSizeMb: 512, maxFileSizeMb: 512,
logLevel: 'info', logLevel: 'info',
sessionMaxAgeHours: 12, sessionMaxAgeHours: 12,
chunkPoolSize: 30,
} as const; } as const;
/** Garde-fou : au-delà, la mémoire tampon et le nombre de sockets deviennent déraisonnables. */
const MAX_CHUNK_POOL_SIZE = 256;
const TARGET_NAME_PATTERN = /^[A-Za-z0-9._-]+$/; const TARGET_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
function asRecord(value: unknown, label: string): Record<string, unknown> { function asRecord(value: unknown, label: string): Record<string, unknown> {
@ -123,6 +133,16 @@ function optionalBoolean(value: unknown, fallback: boolean, name: string): boole
return value; return value;
} }
function parseChunkPoolSize(value: unknown): number {
const parsed = optionalPositiveInt(value, DEFAULTS.chunkPoolSize, 'chunkPoolSize');
if (parsed > MAX_CHUNK_POOL_SIZE) {
throw new ConfigError(
`chunkPoolSize ne doit pas dépasser ${String(MAX_CHUNK_POOL_SIZE)} (reçu : ${String(parsed)})`,
);
}
return parsed;
}
function parseLogLevel(value: unknown): LogLevel { function parseLogLevel(value: unknown): LogLevel {
if (value === undefined || value === null) { if (value === undefined || value === null) {
return DEFAULTS.logLevel; return DEFAULTS.logLevel;
@ -276,6 +296,7 @@ export function parseConfig(raw: unknown): AppConfig {
1024, 1024,
allowedMimeTypes: parseMimePolicy(root.allowedMimeTypes), allowedMimeTypes: parseMimePolicy(root.allowedMimeTypes),
logLevel: parseLogLevel(root.logLevel), logLevel: parseLogLevel(root.logLevel),
chunkPoolSize: parseChunkPoolSize(root.chunkPoolSize),
session: parseSession(root.session), session: parseSession(root.session),
oidc: parseOidc(root.oidc), oidc: parseOidc(root.oidc),
}; };

View file

@ -185,22 +185,42 @@ export async function createEmptyFile(filePath: string): Promise<void> {
} }
/** /**
* Écrit `data` dans `filePath` à partir de l'octet `offset` (le fichier doit * Écrit le flux `source` dans `filePath` à partir de l'octet `offset` (le fichier
* exister), en une passe positionnée : sûr pour des écritures concurrentes sur * doit exister). Refuse au-delà de `maxBytes`. Streaming : mémoire ~constante,
* des zones disjointes du même fichier. * même à forte concurrence. Sûr pour des écritures sur des zones disjointes.
*/ */
export async function writeChunkAt(params: { export async function writeChunkAt(params: {
data: Buffer; source: Readable;
filePath: string; filePath: string;
offset: number; offset: number;
maxBytes: number;
}): Promise<number> { }): Promise<number> {
const handle = await open(params.filePath, 'r+'); let written = 0;
const limiter = new Transform({
transform(chunk: Buffer, _encoding, callback): void {
written += chunk.length;
if (written > params.maxBytes) {
callback(new PayloadTooLargeError('Morceau plus grand que la taille annoncée'));
return;
}
callback(null, chunk);
},
});
try { try {
await handle.write(params.data, 0, params.data.length, params.offset); await pipeline(
} finally { params.source,
await handle.close(); limiter,
createWriteStream(params.filePath, {
flags: 'r+',
start: params.offset,
highWaterMark: WRITE_HIGH_WATER_MARK,
}),
);
} catch (error) {
throw normalizeStreamError(error);
} }
return params.data.length; return written;
} }
/** Renomme le fichier partiel vers son chemin final (écrase si présent). */ /** Renomme le fichier partiel vers son chemin final (écrase si présent). */

View file

@ -1,4 +1,5 @@
import { stat } from 'node:fs/promises'; import { stat } from 'node:fs/promises';
import type { Readable } from 'node:stream';
import type { FastifyPluginCallback, FastifyReply } from 'fastify'; import type { FastifyPluginCallback, FastifyReply } from 'fastify';
@ -19,9 +20,6 @@ const MAX_CHUNK = 32 * 1024 * 1024;
const DEFAULT_CHUNK = 16 * 1024 * 1024; const DEFAULT_CHUNK = 16 * 1024 * 1024;
const PART_SUFFIX = '.mediadrop-part'; const PART_SUFFIX = '.mediadrop-part';
/** Limite de corps pour un morceau (à appliquer au parser octet-stream). */
export const CHUNK_BODY_LIMIT = MAX_CHUNK + 4096;
interface InitBody { interface InitBody {
target?: unknown; target?: unknown;
path?: unknown; path?: unknown;
@ -124,7 +122,7 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
app.put( app.put(
'/upload/:id/:index', '/upload/:id/:index',
{ preHandler: requireSession, bodyLimit: CHUNK_BODY_LIMIT }, { preHandler: requireSession },
async (request): Promise<ChunkAck> => { async (request): Promise<ChunkAck> => {
const { id, index } = request.params as { id: string; index: string }; const { id, index } = request.params as { id: string; index: string };
const session = app.uploadSessions.use(id); const session = app.uploadSessions.use(id);
@ -134,18 +132,11 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
throw new ValidationError('Index de morceau hors limites'); throw new ValidationError('Index de morceau hors limites');
} }
const data = request.body;
if (!Buffer.isBuffer(data)) {
throw new ValidationError('Corps du morceau attendu (application/octet-stream)');
}
if (data.length > session.chunkSize) {
throw new PayloadTooLargeError('Morceau plus grand que la taille annoncée');
}
await writeChunkAt({ await writeChunkAt({
data, source: request.body as Readable,
filePath: session.partPath, filePath: session.partPath,
offset: chunkIndex * session.chunkSize, offset: chunkIndex * session.chunkSize,
maxBytes: session.chunkSize,
}); });
session.received.add(chunkIndex); session.received.add(chunkIndex);

View file

@ -23,6 +23,11 @@ interface TargetSummary {
allowDirectories: boolean; allowDirectories: boolean;
} }
interface ClientConfig {
/** Taille du pool de connexions pour l'upload fragmenté (réglage client). */
chunkPoolSize: number;
}
interface UploadQuery { interface UploadQuery {
target?: unknown; target?: unknown;
path?: unknown; path?: unknown;
@ -39,10 +44,13 @@ function toResponse(target: DropTarget, stored: StoredFile): UploadResponse {
} }
/** /**
* - `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é) * - `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 * - `POST /upload?target=<nom>[&path=<chemin relatif>]` — dépôt d'un fichier multipart
*/ */
export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => { export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => {
app.get('/config', (): ClientConfig => ({ chunkPoolSize: app.appConfig.chunkPoolSize }));
app.get('/targets', { preHandler: requireSession }, (): TargetSummary[] => app.get('/targets', { preHandler: requireSession }, (): TargetSummary[] =>
app.appConfig.targets.map((target) => ({ app.appConfig.targets.map((target) => ({
name: target.name, name: target.name,

View file

@ -31,6 +31,7 @@ describe('parseConfig', () => {
publicUrl: 'http://localhost:3000', publicUrl: 'http://localhost:3000',
maxFileSizeBytes: 512 * 1024 * 1024, maxFileSizeBytes: 512 * 1024 * 1024,
logLevel: 'info', logLevel: 'info',
chunkPoolSize: 30,
}); });
expect(config.targets).toEqual([ expect(config.targets).toEqual([
{ name: 'photos', label: 'photos', dir: './uploads/photos', allowDirectories: false }, { name: 'photos', label: 'photos', dir: './uploads/photos', allowDirectories: false },
@ -121,6 +122,14 @@ describe('parseConfig', () => {
expect(() => parseConfig({ ...BASE, logLevel: 'verbose' })).toThrow(ConfigError); expect(() => parseConfig({ ...BASE, logLevel: 'verbose' })).toThrow(ConfigError);
}); });
it('lit et borne chunkPoolSize', () => {
expect(parseConfig({ ...BASE, chunkPoolSize: 8 }).chunkPoolSize).toBe(8);
expect(() => parseConfig({ ...BASE, chunkPoolSize: 0 })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, chunkPoolSize: -4 })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, chunkPoolSize: 12.5 })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, chunkPoolSize: 257 })).toThrow(ConfigError);
});
it('interprète allowedMimeTypes', () => { it('interprète allowedMimeTypes', () => {
expect( expect(
parseConfig({ ...BASE, allowedMimeTypes: ['image/png', 'IMAGE/WEBP'] }).allowedMimeTypes, parseConfig({ ...BASE, allowedMimeTypes: ['image/png', 'IMAGE/WEBP'] }).allowedMimeTypes,

View file

@ -45,6 +45,7 @@ export function makeConfig(overrides: Partial<AppConfig> = {}): AppConfig {
maxFileSizeBytes: 1024 * 1024, maxFileSizeBytes: 1024 * 1024,
allowedMimeTypes: ['image/png', 'image/jpeg'], allowedMimeTypes: ['image/png', 'image/jpeg'],
logLevel: 'silent', logLevel: 'silent',
chunkPoolSize: 30,
session: { key: Buffer.alloc(32, 7), maxAgeSeconds: 3600 }, session: { key: Buffer.alloc(32, 7), maxAgeSeconds: 3600 },
oidc: { oidc: {
issuer: 'https://idp.test', issuer: 'https://idp.test',

View file

@ -123,9 +123,10 @@ describe('writeChunkAt — réassemblage de morceaux', () => {
for (const index of [2, 0, 1]) { for (const index of [2, 0, 1]) {
const slice = data.subarray(index * chunkSize, (index + 1) * chunkSize); const slice = data.subarray(index * chunkSize, (index + 1) * chunkSize);
const written = await writeChunkAt({ const written = await writeChunkAt({
data: slice, source: Readable.from(slice),
filePath: partPath, filePath: partPath,
offset: index * chunkSize, offset: index * chunkSize,
maxBytes: chunkSize,
}); });
expect(written).toBe(slice.length); expect(written).toBe(slice.length);
} }
@ -137,6 +138,19 @@ describe('writeChunkAt — réassemblage de morceaux', () => {
expect(await readdir(path.join(uploadDir, 'sub'))).toEqual(['video.bin']); expect(await readdir(path.join(uploadDir, 'sub'))).toEqual(['video.bin']);
}); });
it('rejette un morceau plus grand que maxBytes', async () => {
const partPath = path.join(uploadDir, 'p.part');
await createEmptyFile(partPath);
await expect(
writeChunkAt({
source: Readable.from(Buffer.alloc(2048)),
filePath: partPath,
offset: 0,
maxBytes: 1024,
}),
).rejects.toBeInstanceOf(PayloadTooLargeError);
});
it('removeFile ne lève pas si le fichier est absent', async () => { it('removeFile ne lève pas si le fichier est absent', async () => {
await expect(removeFile(path.join(uploadDir, 'absent'))).resolves.toBeUndefined(); await expect(removeFile(path.join(uploadDir, 'absent'))).resolves.toBeUndefined();
}); });

View file

@ -97,6 +97,24 @@ describe('GET /health', () => {
}); });
}); });
describe('GET /config', () => {
it('expose chunkPoolSize sans authentification', async () => {
const res = await app.inject({ method: 'GET', url: '/config' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ chunkPoolSize: 30 });
});
it('reflète la valeur configurée', async () => {
const custom = await buildApp(makeConfig({ chunkPoolSize: 12, oidc: oidcOverride(idp) }), {
logger: false,
});
await custom.ready();
const res = await custom.inject({ method: 'GET', url: '/config' });
expect(res.json<{ chunkPoolSize: number }>().chunkPoolSize).toBe(12);
await custom.close();
});
});
describe('GET /targets', () => { describe('GET /targets', () => {
it('exige une session', async () => { it('exige une session', async () => {
const res = await app.inject({ method: 'GET', url: '/targets' }); const res = await app.inject({ method: 'GET', url: '/targets' });