Upload fragmenté et reprenable pour les gros fichiers
Serveur :
- POST /upload/init -> session { id, chunkSize (16 Mo), chunkCount }
- PUT /upload/:id/:index -> écrit le morceau à son offset (octet-stream brut,
bufferisé, parser dédié) ; renvoie { received, total }
- POST /upload/:id/complete -> vérifie tous les morceaux + la taille, renomme
le .mediadrop-part vers le chemin final ; 201 + métadonnées
- DELETE /upload/:id -> abandon, supprime le fichier partiel
- src/lib/upload-sessions.ts : registre en mémoire, purge après 30 min
d'inactivité, 100 sessions max
- src/lib/targets.ts : resolveTarget / resolvePlacement extraits et partagés
- writeChunkAt / createEmptyFile / finalizeFile / removeFile dans storage.ts
- limites, allowDirectories, jokers MIME et confinement dans la cible
s'appliquent aussi au chemin fragmenté
Page :
- bascule automatique en fragmenté au-delà de 32 Mo : 3 morceaux en parallèle,
3 réessais par morceau, progression et annulation agrégées
- makeReporter partagé (débit + ETA) ; job.cancel unifié (simple / fragmenté /
en file d'attente)
Tests : +tests/upload-chunked.test.ts (9), +writeChunkAt (storage) ; 73 tests.
Vérifié en live : 40 Mio -> 3 morceaux // -> octets exacts, DELETE nettoie.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
5a1219f00d
commit
1b429049c2
12 changed files with 887 additions and 119 deletions
40
README.md
40
README.md
|
|
@ -117,7 +117,11 @@ pouvant écrire les paquets de l'organisation.
|
||||||
| `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 /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 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 }` |
|
||||||
|
| `PUT /upload/:id/:index` | session | Envoie un morceau (corps brut `application/octet-stream`) |
|
||||||
|
| `POST /upload/:id/complete` | session | Assemble et finalise → `201` + métadonnées |
|
||||||
|
| `DELETE /upload/:id` | session | Abandonne (supprime le fichier partiel) |
|
||||||
|
|
||||||
`POST /upload` — `?target=<nom>` choisit la cible (absent → **première** cible).
|
`POST /upload` — `?target=<nom>` choisit la cible (absent → **première** cible).
|
||||||
`?path=<chemin relatif>` (URL-encodé) dépose dans un **sous-dossier** recréé sous
|
`?path=<chemin relatif>` (URL-encodé) dépose dans un **sous-dossier** recréé sous
|
||||||
|
|
@ -148,17 +152,26 @@ La page gère aussi le **glisser-déposer d'un dossier entier** et un bouton
|
||||||
> Le dépôt passe **uniquement par la session** établie via la page. Il n'y a pas
|
> Le dépôt passe **uniquement par la session** établie via la page. Il n'y a pas
|
||||||
> d'accès par jeton Bearer pour les scripts.
|
> d'accès par jeton Bearer pour les scripts.
|
||||||
|
|
||||||
### Très gros fichiers
|
### Gros fichiers — upload fragmenté
|
||||||
|
|
||||||
`maxFileSizeMb` accepte de grandes valeurs (`10240` = 10 Go). Les fichiers sont
|
La page bascule automatiquement en **upload fragmenté** au-delà de **32 Mo** :
|
||||||
écrits en streaming (mémoire constante) et le serveur n'impose **aucun timeout**
|
morceaux de 16 Mo, **3 en parallèle**, chaque morceau ré-essayé jusqu'à 3 fois.
|
||||||
de requête. Derrière un reverse proxy, penser à :
|
Aucune configuration ; `maxFileSizeMb` reste la limite (`10240` = 10 Go).
|
||||||
|
|
||||||
- **nginx** : `client_max_body_size 11g;`, `proxy_request_buffering off;`,
|
- Écriture en place à l'offset du morceau, mémoire constante ; assemblage sans
|
||||||
`proxy_read_timeout`/`proxy_send_timeout` élevés
|
seconde passe. Fichier partiel en `<nom>.mediadrop-part`, renommé à la
|
||||||
- **Caddy / Traefik** : limites de taille de corps et timeouts de lecture
|
finalisation.
|
||||||
|
- Sessions d'upload en mémoire, purgées après **30 min** d'inactivité (le
|
||||||
|
`.part` est alors supprimé) ; 100 uploads simultanés au maximum.
|
||||||
|
- **Chaque requête reste petite (≤ 16 Mo)** : plus besoin de régler
|
||||||
|
`client_max_body_size` ni les timeouts du reverse proxy pour les gros fichiers.
|
||||||
|
|
||||||
Prévoir aussi l'espace disque sur le volume de la cible.
|
Le chemin **direct** (`POST /upload`, fichiers ≤ 32 Mo ou clients tiers) envoie
|
||||||
|
tout le corps d'un coup — le serveur n'impose alors aucun timeout, mais un
|
||||||
|
reverse proxy peut nécessiter un `client_max_body_size` adapté et
|
||||||
|
`proxy_request_buffering off`.
|
||||||
|
|
||||||
|
Prévoir l'espace disque sur le volume de la cible.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|
@ -232,10 +245,13 @@ src/
|
||||||
routes/
|
routes/
|
||||||
auth.ts GET /auth/login, /auth/callback, /auth/me ; POST /auth/logout
|
auth.ts GET /auth/login, /auth/callback, /auth/me ; POST /auth/logout
|
||||||
health.ts GET /health
|
health.ts GET /health
|
||||||
upload.ts GET /targets ; POST /upload?target=... (session requise)
|
upload.ts GET /targets ; POST /upload (dépôt direct)
|
||||||
|
upload-chunked.ts POST /upload/init, PUT /upload/:id/:index, .../complete, DELETE
|
||||||
lib/
|
lib/
|
||||||
storage.ts écriture disque dans la cible, nom de fichier sûr, plafond de taille
|
storage.ts écriture disque, nom sûr, plafond, morceaux (writeChunkAt)
|
||||||
media-types.ts liste blanche des types + extensions
|
upload-sessions.ts registre en mémoire des uploads fragmentés (+ purge TTL)
|
||||||
|
targets.ts résolution cible / chemin relatif (partagé par les deux routes)
|
||||||
|
media-types.ts liste blanche des types + extensions + jokers
|
||||||
paths.ts assainissement des chemins relatifs (dépôt de dossier)
|
paths.ts assainissement des chemins relatifs (dépôt de dossier)
|
||||||
public/
|
public/
|
||||||
index.html page de dépôt (login = lien vers /auth/login, sans build)
|
index.html page de dépôt (login = lien vers /auth/login, sans build)
|
||||||
|
|
|
||||||
|
|
@ -262,7 +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 = 3;
|
const MAX_PARALLEL = 2;
|
||||||
|
const CHUNK_THRESHOLD = 32 * 1024 * 1024; // au-delà : upload fragmenté et reprenable
|
||||||
|
const CHUNK_PARALLEL = 3;
|
||||||
|
const CHUNK_RETRIES = 3;
|
||||||
|
|
||||||
let authenticated = false;
|
let authenticated = false;
|
||||||
let currentTarget = null;
|
let currentTarget = null;
|
||||||
|
|
@ -417,106 +420,242 @@
|
||||||
updateQueueRow();
|
updateQueueRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startUpload(job) {
|
function makeReporter(ui, total) {
|
||||||
const { file, relativePath, ui } = job;
|
let started = 0;
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (currentTarget) params.set('target', currentTarget);
|
|
||||||
if (relativePath) params.set('path', relativePath);
|
|
||||||
const query = params.toString();
|
|
||||||
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', file);
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
job.xhr = xhr;
|
|
||||||
active.add(job);
|
|
||||||
xhr.open('POST', query ? '/upload?' + query : '/upload');
|
|
||||||
|
|
||||||
let startedAt = 0;
|
|
||||||
let lastAt = 0;
|
let lastAt = 0;
|
||||||
let lastLoaded = 0;
|
let lastLoaded = 0;
|
||||||
let speed = 0;
|
let speed = 0;
|
||||||
|
return function report(loaded) {
|
||||||
xhr.upload.addEventListener('progress', (event) => {
|
|
||||||
if (!event.lengthComputable) return;
|
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (!startedAt) {
|
if (!started) {
|
||||||
startedAt = now;
|
started = now;
|
||||||
lastAt = now;
|
lastAt = now;
|
||||||
}
|
}
|
||||||
const dt = (now - lastAt) / 1000;
|
const dt = (now - lastAt) / 1000;
|
||||||
if (dt >= 0.25) {
|
if (dt >= 0.25) {
|
||||||
const inst = (event.loaded - lastLoaded) / dt;
|
const inst = (loaded - lastLoaded) / dt;
|
||||||
speed = speed ? speed * 0.7 + inst * 0.3 : inst;
|
speed = speed ? speed * 0.7 + inst * 0.3 : inst;
|
||||||
lastAt = now;
|
lastAt = now;
|
||||||
lastLoaded = event.loaded;
|
lastLoaded = loaded;
|
||||||
}
|
}
|
||||||
ui.bar.style.width = (event.loaded / event.total) * 100 + '%';
|
const pct = total ? Math.floor((loaded / total) * 100) : 0;
|
||||||
const pct = Math.floor((event.loaded / event.total) * 100);
|
ui.bar.style.width = pct + '%';
|
||||||
const eta = speed > 0 ? formatEta((event.total - event.loaded) / speed) : '';
|
const eta = speed > 0 && total ? formatEta((total - loaded) / speed) : '';
|
||||||
ui.progress.textContent =
|
ui.progress.textContent =
|
||||||
formatSize(event.loaded) +
|
formatSize(loaded) +
|
||||||
' / ' +
|
' / ' +
|
||||||
formatSize(event.total) +
|
formatSize(total) +
|
||||||
' · ' +
|
' · ' +
|
||||||
pct +
|
pct +
|
||||||
' %' +
|
' %' +
|
||||||
(speed > 0 ? ' · ' + formatSize(speed) + '/s' : '') +
|
(speed > 0 ? ' · ' + formatSize(speed) + '/s' : '') +
|
||||||
(eta ? ' · ' + eta : '');
|
(eta ? ' · ' + eta : '');
|
||||||
});
|
|
||||||
|
|
||||||
const finish = () => {
|
|
||||||
active.delete(job);
|
|
||||||
ui.cancelBtn.hidden = true;
|
|
||||||
pump();
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadQuery(relativePath) {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (currentTarget) p.set('target', currentTarget);
|
||||||
|
if (relativePath) p.set('path', relativePath);
|
||||||
|
const s = p.toString();
|
||||||
|
return s ? '?' + s : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(job) {
|
||||||
|
active.delete(job);
|
||||||
|
job.ui.cancelBtn.hidden = true;
|
||||||
|
pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResult(job, status, body) {
|
||||||
|
const ui = job.ui;
|
||||||
|
if (status >= 200 && status < 300) {
|
||||||
|
ui.bar.style.width = '100%';
|
||||||
|
ui.progress.textContent = formatSize(job.file.size);
|
||||||
|
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
|
||||||
|
} else if (status === 401) {
|
||||||
|
render({ authenticated: false });
|
||||||
|
showBanner('Session expirée — reconnectez-vous.');
|
||||||
|
setStatus(ui, 'Non authentifié', 'err');
|
||||||
|
} else {
|
||||||
|
setStatus(ui, 'Erreur ' + status + ' : ' + (body.message || 'échec'), 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startUpload(job) {
|
||||||
|
active.add(job);
|
||||||
|
job.ui.cancelBtn.hidden = false;
|
||||||
|
job.ui.cancelBtn.onclick = () => job.cancel && job.cancel();
|
||||||
|
setStatus(job.ui, 'Envoi…', 'muted');
|
||||||
|
if (job.file.size > CHUNK_THRESHOLD) runChunked(job);
|
||||||
|
else runSimple(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSimple(job) {
|
||||||
|
const { file, relativePath, ui } = job;
|
||||||
|
const report = makeReporter(ui, file.size);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
job.cancel = () => xhr.abort();
|
||||||
|
xhr.open('POST', '/upload' + uploadQuery(relativePath));
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) report(e.loaded);
|
||||||
|
});
|
||||||
xhr.addEventListener('load', () => {
|
xhr.addEventListener('load', () => {
|
||||||
finish();
|
finish(job);
|
||||||
let body = {};
|
let body = {};
|
||||||
try {
|
try {
|
||||||
body = JSON.parse(xhr.responseText);
|
body = JSON.parse(xhr.responseText);
|
||||||
} catch {
|
} catch {
|
||||||
body = {};
|
body = {};
|
||||||
}
|
}
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
applyResult(job, xhr.status, body);
|
||||||
ui.bar.style.width = '100%';
|
|
||||||
ui.progress.textContent = formatSize(file.size);
|
|
||||||
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
|
|
||||||
} else if (xhr.status === 401) {
|
|
||||||
render({ authenticated: false });
|
|
||||||
showBanner('Session expirée — reconnectez-vous.');
|
|
||||||
setStatus(ui, 'Non authentifié', 'err');
|
|
||||||
} else {
|
|
||||||
setStatus(ui, 'Erreur ' + xhr.status + ' : ' + (body.message || 'échec'), 'err');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('error', () => {
|
xhr.addEventListener('error', () => {
|
||||||
finish();
|
finish(job);
|
||||||
setStatus(ui, 'Erreur réseau', 'err');
|
setStatus(ui, 'Erreur réseau', 'err');
|
||||||
});
|
});
|
||||||
|
|
||||||
xhr.addEventListener('abort', () => {
|
xhr.addEventListener('abort', () => {
|
||||||
finish();
|
finish(job);
|
||||||
ui.progress.textContent = '';
|
ui.progress.textContent = '';
|
||||||
setStatus(ui, 'Annulé', 'err');
|
setStatus(ui, 'Annulé', 'err');
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.cancelBtn.hidden = false;
|
|
||||||
ui.cancelBtn.onclick = () => xhr.abort();
|
|
||||||
setStatus(ui, 'Envoi…', 'muted');
|
|
||||||
xhr.send(form);
|
xhr.send(form);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runChunked(job) {
|
||||||
|
const { file, relativePath, ui } = job;
|
||||||
|
const report = makeReporter(ui, file.size);
|
||||||
|
const inflight = new Set();
|
||||||
|
let aborted = false;
|
||||||
|
let failed = false;
|
||||||
|
let sessionId = null;
|
||||||
|
|
||||||
|
const dropSession = () => {
|
||||||
|
if (sessionId) fetch('/upload/' + sessionId, { method: 'DELETE' }).catch(() => {});
|
||||||
|
};
|
||||||
|
job.cancel = () => {
|
||||||
|
aborted = true;
|
||||||
|
for (const x of inflight) x.abort();
|
||||||
|
dropSession();
|
||||||
|
finish(job);
|
||||||
|
ui.progress.textContent = '';
|
||||||
|
setStatus(ui, 'Annulé', 'err');
|
||||||
|
};
|
||||||
|
|
||||||
|
let meta;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/upload/init' + uploadQuery(relativePath), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type || 'application/octet-stream',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
meta = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
finish(job);
|
||||||
|
applyResult(job, res.status, meta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
finish(job);
|
||||||
|
setStatus(ui, 'Erreur réseau (init)', 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (aborted) return;
|
||||||
|
sessionId = meta.id;
|
||||||
|
const chunkSize = meta.chunkSize;
|
||||||
|
const chunkCount = meta.chunkCount;
|
||||||
|
const loadedPer = new Array(chunkCount).fill(0);
|
||||||
|
const emit = () => report(loadedPer.reduce((a, b) => a + b, 0));
|
||||||
|
let nextIndex = 0;
|
||||||
|
|
||||||
|
const sendOnce = (index) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const start = index * chunkSize;
|
||||||
|
const end = Math.min(start + chunkSize, file.size);
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
inflight.add(xhr);
|
||||||
|
xhr.open('PUT', '/upload/' + sessionId + '/' + index);
|
||||||
|
xhr.setRequestHeader('content-type', 'application/octet-stream');
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
loadedPer[index] = e.loaded;
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const settle = (r) => {
|
||||||
|
inflight.delete(xhr);
|
||||||
|
resolve(r);
|
||||||
|
};
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
loadedPer[index] = end - start;
|
||||||
|
emit();
|
||||||
|
settle({ ok: true });
|
||||||
|
} else {
|
||||||
|
settle({ ok: false, status: xhr.status });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
xhr.addEventListener('error', () => settle({ ok: false, status: 0 }));
|
||||||
|
xhr.addEventListener('abort', () => settle({ ok: false, aborted: true }));
|
||||||
|
xhr.send(file.slice(start, end));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (!aborted && !failed && nextIndex < chunkCount) {
|
||||||
|
const index = nextIndex;
|
||||||
|
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 = [];
|
||||||
|
for (let i = 0; i < Math.min(CHUNK_PARALLEL, chunkCount); i += 1) workers.push(worker());
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
|
if (aborted) return;
|
||||||
|
if (failed) {
|
||||||
|
finish(job);
|
||||||
|
dropSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/upload/' + sessionId + '/complete', { method: 'POST' });
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
finish(job);
|
||||||
|
applyResult(job, res.status, body);
|
||||||
|
} catch {
|
||||||
|
finish(job);
|
||||||
|
setStatus(ui, 'Erreur réseau (finalisation)', 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cancelAll() {
|
function cancelAll() {
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const job = queue.shift();
|
const job = queue.shift();
|
||||||
job.ui.cancelBtn.hidden = true;
|
job.ui.cancelBtn.hidden = true;
|
||||||
setStatus(job.ui, 'Annulé', 'err');
|
setStatus(job.ui, 'Annulé', 'err');
|
||||||
}
|
}
|
||||||
for (const job of Array.from(active)) job.xhr.abort();
|
for (const job of Array.from(active)) if (job.cancel) job.cancel();
|
||||||
updateQueueRow();
|
updateQueueRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
22
src/app.ts
22
src/app.ts
|
|
@ -7,9 +7,11 @@ import secureSession from '@fastify/secure-session';
|
||||||
|
|
||||||
import type { AppConfig } from './config.js';
|
import type { AppConfig } from './config.js';
|
||||||
import { registerErrorHandler } from './error-handler.js';
|
import { registerErrorHandler } from './error-handler.js';
|
||||||
import { ensureUploadDir } from './lib/storage.js';
|
import { ensureUploadDir, removeFile } from './lib/storage.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 { healthRoutes } from './routes/health.js';
|
import { healthRoutes } from './routes/health.js';
|
||||||
import { uploadRoutes } from './routes/upload.js';
|
import { uploadRoutes } from './routes/upload.js';
|
||||||
|
|
||||||
|
|
@ -46,8 +48,25 @@ export async function buildApp(
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const uploadSessions = createUploadSessionStore((session) => {
|
||||||
|
void removeFile(session.partPath);
|
||||||
|
});
|
||||||
|
|
||||||
app.decorate('appConfig', config);
|
app.decorate('appConfig', config);
|
||||||
app.decorate('oidcClient', oidcClient);
|
app.decorate('oidcClient', oidcClient);
|
||||||
|
app.decorate('uploadSessions', uploadSessions);
|
||||||
|
app.addHook('onClose', () => {
|
||||||
|
uploadSessions.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Corps brut (bufferisé) pour les morceaux d'upload fragmenté.
|
||||||
|
app.addContentTypeParser(
|
||||||
|
'application/octet-stream',
|
||||||
|
{ 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,
|
||||||
|
|
@ -81,6 +100,7 @@ export async function buildApp(
|
||||||
await app.register(authRoutes);
|
await app.register(authRoutes);
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(uploadRoutes);
|
await app.register(uploadRoutes);
|
||||||
|
await app.register(chunkedUploadRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,3 +49,17 @@ export class PayloadTooLargeError extends AppError {
|
||||||
super(message, 413);
|
super(message, 413);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Session d'upload introuvable ou expirée. */
|
||||||
|
export class UploadSessionNotFoundError extends AppError {
|
||||||
|
public constructor(message = "Session d'upload inconnue ou expirée") {
|
||||||
|
super(message, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trop de sessions d'upload simultanées. */
|
||||||
|
export class TooManyUploadsError extends AppError {
|
||||||
|
public constructor(message = "Trop d'uploads en cours, réessayez plus tard") {
|
||||||
|
super(message, 429);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
3
src/fastify.d.ts
vendored
3
src/fastify.d.ts
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
import type { AppConfig } from './config.js';
|
import type { AppConfig } from './config.js';
|
||||||
import type { OidcClient } from './oidc.js';
|
import type { OidcClient } from './oidc.js';
|
||||||
import type { LoginTransaction, SessionUser } from './session.js';
|
import type { LoginTransaction, SessionUser } from './session.js';
|
||||||
|
import type { UploadSessionStore } from './lib/upload-sessions.js';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
|
|
@ -8,6 +9,8 @@ declare module 'fastify' {
|
||||||
appConfig: AppConfig;
|
appConfig: AppConfig;
|
||||||
/** Façade OIDC (découverte effectuée), injectée par `buildApp`. */
|
/** Façade OIDC (découverte effectuée), injectée par `buildApp`. */
|
||||||
oidcClient: OidcClient;
|
oidcClient: OidcClient;
|
||||||
|
/** Registre des uploads fragmentés en cours. */
|
||||||
|
uploadSessions: UploadSessionStore;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createWriteStream } from 'node:fs';
|
import { createWriteStream } from 'node:fs';
|
||||||
import { mkdir, rm } from 'node:fs/promises';
|
import { mkdir, open, rename, rm } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Transform, type Readable } from 'node:stream';
|
import { Transform, type Readable } from 'node:stream';
|
||||||
|
|
@ -67,7 +67,8 @@ function sanitizeExtension(originalName: string): string {
|
||||||
return /^\.[a-z0-9]{1,12}$/.test(ext) ? ext : '.bin';
|
return /^\.[a-z0-9]{1,12}$/.test(ext) ? ext : '.bin';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStoredName(originalName: string, mimeType: string): string {
|
/** Nom de fichier unique dérivé côté serveur (`nom-timestamp-uuid.ext`). */
|
||||||
|
export function buildStoredName(originalName: string, mimeType: string): string {
|
||||||
const ext = extensionForMime(mimeType) ?? sanitizeExtension(originalName);
|
const ext = extensionForMime(mimeType) ?? sanitizeExtension(originalName);
|
||||||
return `${sanitizeBase(originalName)}-${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
|
return `${sanitizeBase(originalName)}-${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
|
||||||
}
|
}
|
||||||
|
|
@ -92,21 +93,34 @@ function wasTruncated(stream: Readable): boolean {
|
||||||
return (stream as { truncated?: unknown }).truncated === true;
|
return (stream as { truncated?: unknown }).truncated === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedDestination {
|
export interface ResolvedDestination {
|
||||||
|
/** Nom simple, ou chemin relatif `dossier/sous/fichier.ext`. */
|
||||||
storedName: string;
|
storedName: string;
|
||||||
|
/** Chemin absolu du fichier final. */
|
||||||
destination: string;
|
destination: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDestination(params: SaveFileParams): Promise<ResolvedDestination> {
|
export interface DestinationInput {
|
||||||
if (params.placement === undefined) {
|
targetDir: string;
|
||||||
const storedName = buildStoredName(params.originalName, params.mimeType);
|
originalName: string;
|
||||||
return { storedName, destination: path.join(params.targetDir, storedName) };
|
mimeType: string;
|
||||||
|
placement?: FilePlacement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { segments, fileName } = params.placement;
|
/**
|
||||||
const directory = path.join(params.targetDir, ...segments);
|
* Résout le chemin final (crée le sous-dossier au besoin) et garantit qu'il reste
|
||||||
|
* sous `targetDir`.
|
||||||
|
*/
|
||||||
|
export async function resolveDestination(input: DestinationInput): Promise<ResolvedDestination> {
|
||||||
|
if (input.placement === undefined) {
|
||||||
|
const storedName = buildStoredName(input.originalName, input.mimeType);
|
||||||
|
return { storedName, destination: path.join(input.targetDir, storedName) };
|
||||||
|
}
|
||||||
|
|
||||||
const root = path.resolve(params.targetDir);
|
const { segments, fileName } = input.placement;
|
||||||
|
const directory = path.join(input.targetDir, ...segments);
|
||||||
|
|
||||||
|
const root = path.resolve(input.targetDir);
|
||||||
const resolved = path.resolve(directory);
|
const resolved = path.resolve(directory);
|
||||||
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
||||||
throw new ValidationError('Chemin de dépôt hors de la cible');
|
throw new ValidationError('Chemin de dépôt hors de la cible');
|
||||||
|
|
@ -162,3 +176,39 @@ export async function saveFile(params: SaveFileParams): Promise<StoredFile> {
|
||||||
|
|
||||||
return { storedName, originalName, size: bytesWritten, mimeType, path: destination };
|
return { storedName, originalName, size: bytesWritten, mimeType, path: destination };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Crée (ou remet à zéro) un fichier vide, ainsi que son dossier parent. */
|
||||||
|
export async function createEmptyFile(filePath: string): Promise<void> {
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
const handle = await open(filePath, 'w');
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écrit `data` dans `filePath` à partir de l'octet `offset` (le fichier doit
|
||||||
|
* exister), en une passe positionnée : sûr pour des écritures concurrentes sur
|
||||||
|
* des zones disjointes du même fichier.
|
||||||
|
*/
|
||||||
|
export async function writeChunkAt(params: {
|
||||||
|
data: Buffer;
|
||||||
|
filePath: string;
|
||||||
|
offset: number;
|
||||||
|
}): Promise<number> {
|
||||||
|
const handle = await open(params.filePath, 'r+');
|
||||||
|
try {
|
||||||
|
await handle.write(params.data, 0, params.data.length, params.offset);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
return params.data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renomme le fichier partiel vers son chemin final (écrase si présent). */
|
||||||
|
export async function finalizeFile(partPath: string, finalPath: string): Promise<void> {
|
||||||
|
await rename(partPath, finalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Supprime un fichier s'il existe (jamais d'erreur si absent). */
|
||||||
|
export async function removeFile(filePath: string): Promise<void> {
|
||||||
|
await rm(filePath, { force: true });
|
||||||
|
}
|
||||||
|
|
|
||||||
38
src/lib/targets.ts
Normal file
38
src/lib/targets.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import type { DropTarget, DropTargets } from '../config.js';
|
||||||
|
import { ValidationError } from '../errors.js';
|
||||||
|
import { parseRelativePath, type SafeRelativePath } from './paths.js';
|
||||||
|
|
||||||
|
/** Résout la cible demandée (`?target=`), ou la première par défaut ; 400 si inconnue. */
|
||||||
|
export function resolveTarget(targets: DropTargets, requested: unknown): DropTarget {
|
||||||
|
if (requested === undefined || requested === '') {
|
||||||
|
return targets[0];
|
||||||
|
}
|
||||||
|
if (typeof requested !== 'string') {
|
||||||
|
throw new ValidationError('Paramètre « target » invalide');
|
||||||
|
}
|
||||||
|
const match = targets.find((target) => target.name === requested);
|
||||||
|
if (match === undefined) {
|
||||||
|
throw new ValidationError(`Cible de dépôt inconnue : ${requested}`);
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interprète un chemin relatif client : `undefined` -> dépôt simple (nom dérivé) ;
|
||||||
|
* sinon dépôt de dossier, refusé (400) si la cible ne l'autorise pas.
|
||||||
|
*/
|
||||||
|
export function resolvePlacement(
|
||||||
|
target: DropTarget,
|
||||||
|
requested: unknown,
|
||||||
|
): SafeRelativePath | undefined {
|
||||||
|
if (requested === undefined || requested === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof requested !== 'string') {
|
||||||
|
throw new ValidationError('Paramètre « path » invalide');
|
||||||
|
}
|
||||||
|
if (!target.allowDirectories) {
|
||||||
|
throw new ValidationError(`La cible « ${target.name} » n'autorise pas le dépôt de dossiers`);
|
||||||
|
}
|
||||||
|
return parseRelativePath(requested);
|
||||||
|
}
|
||||||
90
src/lib/upload-sessions.ts
Normal file
90
src/lib/upload-sessions.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { DropTarget } from '../config.js';
|
||||||
|
import { TooManyUploadsError, UploadSessionNotFoundError } from '../errors.js';
|
||||||
|
|
||||||
|
/** Un upload fragmenté en cours : morceaux écrits dans `partPath`, finalisé en `finalPath`. */
|
||||||
|
export interface UploadSession {
|
||||||
|
id: string;
|
||||||
|
target: DropTarget;
|
||||||
|
/** Nom simple, ou chemin relatif `dossier/sous/fichier.ext` (mode dossier). */
|
||||||
|
storedName: string;
|
||||||
|
mimeType: string;
|
||||||
|
partPath: string;
|
||||||
|
finalPath: string;
|
||||||
|
totalSize: number;
|
||||||
|
chunkSize: number;
|
||||||
|
chunkCount: number;
|
||||||
|
received: Set<number>;
|
||||||
|
lastActivity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewUploadSession = Omit<UploadSession, 'id' | 'received' | 'lastActivity'>;
|
||||||
|
|
||||||
|
const IDLE_TTL_MS = 30 * 60_000;
|
||||||
|
const SWEEP_INTERVAL_MS = 60_000;
|
||||||
|
const MAX_SESSIONS = 100;
|
||||||
|
|
||||||
|
export interface UploadSessionStore {
|
||||||
|
create(input: NewUploadSession): UploadSession;
|
||||||
|
/** Récupère et rafraîchit la session ; lève `UploadSessionNotFoundError` si absente. */
|
||||||
|
use(id: string): UploadSession;
|
||||||
|
remove(id: string): UploadSession | undefined;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registre en mémoire des uploads fragmentés. Les sessions inactives depuis plus
|
||||||
|
* de 30 min sont purgées et `onExpire` est appelé (pour supprimer le `.part`).
|
||||||
|
*/
|
||||||
|
export function createUploadSessionStore(
|
||||||
|
onExpire: (session: UploadSession) => void,
|
||||||
|
): UploadSessionStore {
|
||||||
|
const sessions = new Map<string, UploadSession>();
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
if (now - session.lastActivity > IDLE_TTL_MS) {
|
||||||
|
sessions.delete(session.id);
|
||||||
|
onExpire(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, SWEEP_INTERVAL_MS);
|
||||||
|
timer.unref();
|
||||||
|
|
||||||
|
return {
|
||||||
|
create(input: NewUploadSession): UploadSession {
|
||||||
|
if (sessions.size >= MAX_SESSIONS) {
|
||||||
|
throw new TooManyUploadsError();
|
||||||
|
}
|
||||||
|
const session: UploadSession = {
|
||||||
|
...input,
|
||||||
|
id: randomUUID(),
|
||||||
|
received: new Set<number>(),
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
};
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
|
||||||
|
use(id: string): UploadSession {
|
||||||
|
const session = sessions.get(id);
|
||||||
|
if (session === undefined) {
|
||||||
|
throw new UploadSessionNotFoundError();
|
||||||
|
}
|
||||||
|
session.lastActivity = Date.now();
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(id: string): UploadSession | undefined {
|
||||||
|
const session = sessions.get(id);
|
||||||
|
sessions.delete(id);
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
clearInterval(timer);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
198
src/routes/upload-chunked.ts
Normal file
198
src/routes/upload-chunked.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import { stat } from 'node:fs/promises';
|
||||||
|
|
||||||
|
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 { 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';
|
||||||
|
|
||||||
|
/** Limite de corps pour un morceau (à appliquer au parser octet-stream). */
|
||||||
|
export const CHUNK_BODY_LIMIT = MAX_CHUNK + 4096;
|
||||||
|
|
||||||
|
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 { storedName, destination } = await resolveDestination({
|
||||||
|
targetDir: target.dir,
|
||||||
|
originalName: nonEmptyString(body.name, 'fichier'),
|
||||||
|
mimeType,
|
||||||
|
...(placement !== undefined ? { placement } : {}),
|
||||||
|
});
|
||||||
|
const partPath = destination + PART_SUFFIX;
|
||||||
|
await createEmptyFile(partPath);
|
||||||
|
|
||||||
|
const session = app.uploadSessions.create({
|
||||||
|
target,
|
||||||
|
storedName,
|
||||||
|
mimeType,
|
||||||
|
partPath,
|
||||||
|
finalPath: destination,
|
||||||
|
totalSize,
|
||||||
|
chunkSize,
|
||||||
|
chunkCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
reply.status(201);
|
||||||
|
return { id: session.id, chunkSize, chunkCount };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put(
|
||||||
|
'/upload/:id/:index',
|
||||||
|
{ preHandler: requireSession, bodyLimit: CHUNK_BODY_LIMIT },
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
data,
|
||||||
|
filePath: session.partPath,
|
||||||
|
offset: chunkIndex * 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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import type { FastifyPluginCallback } from 'fastify';
|
import type { FastifyPluginCallback } from 'fastify';
|
||||||
|
|
||||||
import type { DropTarget, DropTargets } from '../config.js';
|
import type { DropTarget } from '../config.js';
|
||||||
import { ValidationError } from '../errors.js';
|
import { ValidationError } from '../errors.js';
|
||||||
import { isAllowedMedia } from '../lib/media-types.js';
|
import { isAllowedMedia } from '../lib/media-types.js';
|
||||||
import { parseRelativePath } from '../lib/paths.js';
|
import { resolvePlacement, resolveTarget } from '../lib/targets.js';
|
||||||
import { requireSession } from '../session.js';
|
import { requireSession } from '../session.js';
|
||||||
import { saveFile, type FilePlacement, type StoredFile } from '../lib/storage.js';
|
import { saveFile, type StoredFile } from '../lib/storage.js';
|
||||||
|
|
||||||
const FIELD_NAME = 'file';
|
const FIELD_NAME = 'file';
|
||||||
|
|
||||||
|
|
@ -28,38 +28,6 @@ interface UploadQuery {
|
||||||
path?: unknown;
|
path?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Résout la cible demandée (`?target=`), ou la première par défaut ; 400 si inconnue. */
|
|
||||||
function resolveTarget(targets: DropTargets, requested: unknown): DropTarget {
|
|
||||||
if (requested === undefined || requested === '') {
|
|
||||||
return targets[0];
|
|
||||||
}
|
|
||||||
if (typeof requested !== 'string') {
|
|
||||||
throw new ValidationError('Paramètre « target » invalide');
|
|
||||||
}
|
|
||||||
const match = targets.find((target) => target.name === requested);
|
|
||||||
if (match === undefined) {
|
|
||||||
throw new ValidationError(`Cible de dépôt inconnue : ${requested}`);
|
|
||||||
}
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interprète `?path=` : `undefined` -> dépôt simple (nom dérivé) ; sinon dépôt de
|
|
||||||
* dossier, refusé (400) si la cible ne l'autorise pas.
|
|
||||||
*/
|
|
||||||
function resolvePlacement(target: DropTarget, requested: unknown): FilePlacement | undefined {
|
|
||||||
if (requested === undefined || requested === '') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof requested !== 'string') {
|
|
||||||
throw new ValidationError('Paramètre « path » invalide');
|
|
||||||
}
|
|
||||||
if (!target.allowDirectories) {
|
|
||||||
throw new ValidationError(`La cible « ${target.name} » n'autorise pas le dépôt de dossiers`);
|
|
||||||
}
|
|
||||||
return parseRelativePath(requested);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toResponse(target: DropTarget, stored: StoredFile): UploadResponse {
|
function toResponse(target: DropTarget, stored: StoredFile): UploadResponse {
|
||||||
return {
|
return {
|
||||||
target: target.name,
|
target: target.name,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import { readFile, readdir } from 'node:fs/promises';
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { saveFile } from '../src/lib/storage.js';
|
import {
|
||||||
|
createEmptyFile,
|
||||||
|
finalizeFile,
|
||||||
|
removeFile,
|
||||||
|
saveFile,
|
||||||
|
writeChunkAt,
|
||||||
|
} from '../src/lib/storage.js';
|
||||||
import { PayloadTooLargeError } from '../src/errors.js';
|
import { PayloadTooLargeError } from '../src/errors.js';
|
||||||
import { makeTempDir, removeDir, TINY_PNG } from './helpers.js';
|
import { makeTempDir, removeDir, TINY_PNG } from './helpers.js';
|
||||||
|
|
||||||
|
|
@ -104,3 +111,33 @@ describe('saveFile — mode dossier (placement)', () => {
|
||||||
expect(await readdir(path.join(uploadDir, 'dir'))).toEqual(['a.bin']);
|
expect(await readdir(path.join(uploadDir, 'dir'))).toEqual(['a.bin']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('writeChunkAt — réassemblage de morceaux', () => {
|
||||||
|
it('écrit des morceaux à des offsets disjoints puis finalise, octets exacts', async () => {
|
||||||
|
const chunkSize = 64 * 1024;
|
||||||
|
const data = randomBytes(chunkSize * 2 + 777);
|
||||||
|
const partPath = path.join(uploadDir, 'sub', 'video.bin.part');
|
||||||
|
await createEmptyFile(partPath);
|
||||||
|
|
||||||
|
// Dans le désordre pour vérifier l'indépendance des écritures positionnées.
|
||||||
|
for (const index of [2, 0, 1]) {
|
||||||
|
const slice = data.subarray(index * chunkSize, (index + 1) * chunkSize);
|
||||||
|
const written = await writeChunkAt({
|
||||||
|
data: slice,
|
||||||
|
filePath: partPath,
|
||||||
|
offset: index * chunkSize,
|
||||||
|
});
|
||||||
|
expect(written).toBe(slice.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalPath = path.join(uploadDir, 'sub', 'video.bin');
|
||||||
|
await finalizeFile(partPath, finalPath);
|
||||||
|
|
||||||
|
expect(await readFile(finalPath)).toEqual(data);
|
||||||
|
expect(await readdir(path.join(uploadDir, 'sub'))).toEqual(['video.bin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeFile ne lève pas si le fichier est absent', async () => {
|
||||||
|
await expect(removeFile(path.join(uploadDir, 'absent'))).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
195
tests/upload-chunked.test.ts
Normal file
195
tests/upload-chunked.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { buildApp } from '../src/app.js';
|
||||||
|
import type { OidcConfig } from '../src/config.js';
|
||||||
|
import { makeConfig, makeTempDir, removeDir, startStubIdp, type StubIdp } from './helpers.js';
|
||||||
|
|
||||||
|
const MIB = 1024 * 1024;
|
||||||
|
|
||||||
|
let idp: StubIdp;
|
||||||
|
let cookie: string;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let baseUrl: string;
|
||||||
|
let dir: string;
|
||||||
|
|
||||||
|
function oidcOverride(ref: StubIdp): OidcConfig {
|
||||||
|
return {
|
||||||
|
issuer: ref.issuer,
|
||||||
|
clientId: ref.clientId,
|
||||||
|
clientSecret: ref.clientSecret,
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionCookie(res: Response): string {
|
||||||
|
return (res.headers.get('set-cookie') ?? '').split(';')[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Déroule la connexion OIDC via de vraies requêtes HTTP ; renvoie l'en-tête Cookie. */
|
||||||
|
async function loginViaHttp(): Promise<string> {
|
||||||
|
const start = await fetch(`${baseUrl}/auth/login`, { redirect: 'manual' });
|
||||||
|
const txn = sessionCookie(start);
|
||||||
|
const authUrl = new URL(start.headers.get('location') ?? '');
|
||||||
|
idp.setNextIdToken({ nonce: authUrl.searchParams.get('nonce') ?? '' });
|
||||||
|
const state = authUrl.searchParams.get('state') ?? '';
|
||||||
|
const cb = await fetch(`${baseUrl}/auth/callback?code=x&state=${encodeURIComponent(state)}`, {
|
||||||
|
redirect: 'manual',
|
||||||
|
headers: { cookie: txn },
|
||||||
|
});
|
||||||
|
return sessionCookie(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
idp = await startStubIdp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await idp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await makeTempDir();
|
||||||
|
app = await buildApp(
|
||||||
|
makeConfig({
|
||||||
|
targets: [
|
||||||
|
{ name: 'flat', label: 'Plat', dir, allowDirectories: false },
|
||||||
|
{ name: 'tree', label: 'Arbo', dir, allowDirectories: true },
|
||||||
|
],
|
||||||
|
maxFileSizeBytes: 8 * MIB,
|
||||||
|
allowedMimeTypes: ['video/*', 'image/png'],
|
||||||
|
oidc: oidcOverride(idp),
|
||||||
|
}),
|
||||||
|
{ logger: false },
|
||||||
|
);
|
||||||
|
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||||
|
const address = app.server.address();
|
||||||
|
if (address === null || typeof address === 'string') throw new Error('adresse inattendue');
|
||||||
|
baseUrl = `http://127.0.0.1:${String(address.port)}`;
|
||||||
|
cookie = await loginViaHttp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
await removeDir(dir);
|
||||||
|
});
|
||||||
|
|
||||||
|
function initBody(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return { target: 'flat', name: 'clip.mp4', size: 3, type: 'video/mp4', ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InitResult {
|
||||||
|
status: number;
|
||||||
|
body: { id?: string; chunkSize?: number; chunkCount?: number; message?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init(over: Record<string, unknown> = {}, withCookie = true): Promise<InitResult> {
|
||||||
|
const res = await fetch(`${baseUrl}/upload/init`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', ...(withCookie ? { cookie } : {}) },
|
||||||
|
body: JSON.stringify(initBody(over)),
|
||||||
|
});
|
||||||
|
return { status: res.status, body: (await res.json().catch(() => ({}))) as InitResult['body'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putChunk(id: string, index: number, data: Buffer): Promise<number> {
|
||||||
|
const res = await fetch(`${baseUrl}/upload/${id}/${String(index)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/octet-stream', cookie },
|
||||||
|
body: new Uint8Array(data),
|
||||||
|
});
|
||||||
|
return res.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function complete(id: string): Promise<{ status: number; body: unknown }> {
|
||||||
|
const res = await fetch(`${baseUrl}/upload/${id}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { cookie },
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json().catch(() => ({})) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('upload fragmenté', () => {
|
||||||
|
it('init -> morceau -> complete : le fichier est finalisé au bon endroit', async () => {
|
||||||
|
const data = randomBytes(200 * 1024);
|
||||||
|
const started = await init({
|
||||||
|
size: data.length,
|
||||||
|
chunkSize: MIB,
|
||||||
|
path: 'a/b/clip.mp4',
|
||||||
|
target: 'tree',
|
||||||
|
});
|
||||||
|
expect(started.status).toBe(201);
|
||||||
|
const { id, chunkCount } = started.body;
|
||||||
|
expect(chunkCount).toBe(1);
|
||||||
|
|
||||||
|
const ack = await fetch(`${baseUrl}/upload/${id!}/0`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/octet-stream', cookie },
|
||||||
|
body: new Uint8Array(data),
|
||||||
|
});
|
||||||
|
expect(ack.status).toBe(200);
|
||||||
|
expect(await ack.json()).toEqual({ received: 1, total: 1 });
|
||||||
|
|
||||||
|
const done = await complete(id!);
|
||||||
|
expect(done.status).toBe(201);
|
||||||
|
expect(done.body).toMatchObject({ storedName: 'a/b/clip.mp4', size: data.length });
|
||||||
|
|
||||||
|
expect(await readFile(join(dir, 'a', 'b', 'clip.mp4'))).toEqual(data);
|
||||||
|
expect(await readdir(join(dir, 'a', 'b'))).toEqual(['clip.mp4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init refuse une taille au-dessus de la limite (413)', async () => {
|
||||||
|
expect((await init({ size: 9 * MIB })).status).toBe(413);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init refuse un type non autorisé (400)', async () => {
|
||||||
|
expect((await init({ type: 'application/zip' })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init refuse un chemin sur une cible sans allowDirectories (400)', async () => {
|
||||||
|
expect((await init({ target: 'flat', path: 'x/clip.mp4' })).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init exige une session (401)', async () => {
|
||||||
|
expect((await init({}, false)).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT refuse un index hors limites (400)', async () => {
|
||||||
|
const { body } = await init({ size: MIB, chunkSize: MIB });
|
||||||
|
expect(await putChunk(body.id!, 5, Buffer.alloc(10))).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('complete refuse un upload incomplet (400) et ne finalise rien', async () => {
|
||||||
|
const data = randomBytes(2 * MIB + 10);
|
||||||
|
const { body } = await init({ size: data.length, chunkSize: MIB });
|
||||||
|
expect(body.chunkCount).toBe(3);
|
||||||
|
expect(await putChunk(body.id!, 0, data.subarray(0, MIB))).toBe(200);
|
||||||
|
|
||||||
|
expect((await complete(body.id!)).status).toBe(400);
|
||||||
|
const files = await readdir(dir);
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
expect(files[0]).toMatch(/\.mediadrop-part$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404 sur une session inconnue', async () => {
|
||||||
|
expect(await putChunk('inexistante', 0, Buffer.alloc(10))).toBe(404);
|
||||||
|
expect((await complete('inexistante')).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE abandonne : le fichier partiel disparaît, la session aussi', async () => {
|
||||||
|
const { body } = await init({ size: MIB, chunkSize: MIB });
|
||||||
|
expect(await putChunk(body.id!, 0, Buffer.alloc(1000))).toBe(200);
|
||||||
|
|
||||||
|
const del = await fetch(`${baseUrl}/upload/${body.id!}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { cookie },
|
||||||
|
});
|
||||||
|
expect(del.status).toBe(204);
|
||||||
|
expect(await readdir(dir)).toHaveLength(0);
|
||||||
|
expect(await putChunk(body.id!, 0, Buffer.alloc(10))).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue