Dépôt de dossiers + très gros fichiers

Dépôt de dossiers :
- flag `allowDirectories` (défaut false) par cible dans config.json + schéma.
- POST /upload accepte `?path=<chemin relatif>` : l'arborescence est recréée
  sous `dir`, nom d'origine conservé, fichier écrasé si présent.
- src/lib/paths.ts : assainissement segment par segment (pas de `..`, pas de
  séparateur) + vérification de confinement dans storage.ts -> aucune sortie
  possible de la cible. Refus 400 si la cible n'autorise pas les dossiers.
- GET /targets expose `allowDirectories`.
- Page : glisser-déposer d'un dossier (parcours récursif webkitGetAsEntry) +
  bouton « parcourir un dossier », visibles selon la cible.

Très gros fichiers (~10 Go) :
- `requestTimeout: 0` / `connectionTimeout: 0` : aucun timeout serveur imposé.
- écriture disque avec un tampon de 1 Mio.
- doc : `maxFileSizeMb` à l'échelle Go, réglages reverse proxy.

Tests : +paths.test.ts, +placement (storage), +dossier & gros fichier (upload) ;
60 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-10 00:19:20 +02:00
parent f9f17bd169
commit b85607b096
14 changed files with 568 additions and 104 deletions

View file

@ -97,50 +97,70 @@ remapper côté hôte si besoin.
## Endpoints ## Endpoints
| Route | Auth | Rôle | | Route | Auth | Rôle |
| --------------------------- | ------- | ------------------------------------------------------- | | -------------------------------------- | ------- | ------------------------------------------------------------------ |
| `GET /` | non | Page de dépôt | | `GET /` | non | Page de dépôt |
| `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` | | `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` |
| `GET /auth/login` | non | Démarre le flux OIDC (redirige vers le fournisseur) | | `GET /auth/login` | non | Démarre le flux OIDC (redirige vers le fournisseur) |
| `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 /targets` | session | Cibles de dépôt : `[{ "name", "label" }]` (sans chemin) | | `GET /targets` | session | Cibles : `[{ "name", "label", "allowDirectories" }]` (sans chemin) |
| `POST /upload?target=<nom>` | session | Dépôt d'un fichier `multipart/form-data`, champ `file` | | `POST /upload?target=<nom>[&path=<p>]` | session | Dépôt d'un fichier `multipart/form-data`, champ `file` |
`POST /upload` — le paramètre `?target=<nom>` choisit la cible ; absent, la `POST /upload` — `?target=<nom>` choisit la cible (absent → **première** cible).
**première** cible de `targets` est utilisée. Réponse `201` : `?path=<chemin relatif>` (URL-encodé) dépose dans un **sous-dossier** recréé sous
`dir`, en conservant le nom d'origine ; le fichier est **écrasé** s'il existe.
Refusé (`400`) si la cible n'a pas `allowDirectories: true`. Chaque segment du
chemin est assaini (pas de `..`, pas de séparateur) : impossible de sortir de la
cible. Réponse `201` :
```json ```json
{ {
"target": "photos", "target": "videos",
"storedName": "photo-1725900000000-1a2b3c4d.jpg", "storedName": "vacances/plage/DSC_001.mp4",
"originalName": "photo.jpg", "originalName": "DSC_001.mp4",
"size": 20480, "size": 20480,
"mimeType": "image/jpeg" "mimeType": "video/mp4"
} }
``` ```
| Code | Cas | | Code | Cas |
| ----- | ---------------------------------------------------------------- | | ----- | ------------------------------------------------------------------------------------------------- |
| `400` | pas de fichier, mauvais champ, type non autorisé, cible inconnue | | `400` | pas de fichier, mauvais champ, type non autorisé, cible inconnue, `path` invalide ou non autorisé |
| `401` | session absente ou expirée | | `401` | session absente ou expirée |
| `413` | fichier au-dessus de `maxFileSizeMb` | | `413` | fichier au-dessus de `maxFileSizeMb` |
La page gère aussi le **glisser-déposer d'un dossier entier** et un bouton
« parcourir un dossier » (visibles si la cible sélectionnée autorise les dossiers).
> 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
`maxFileSizeMb` accepte de grandes valeurs (`10240` = 10 Go). Les fichiers sont
écrits en streaming (mémoire constante) et le serveur n'impose **aucun timeout**
de requête. Derrière un reverse proxy, penser à :
- **nginx** : `client_max_body_size 11g;`, `proxy_request_buffering off;`,
`proxy_read_timeout`/`proxy_send_timeout` élevés
- **Caddy / Traefik** : limites de taille de corps et timeouts de lecture
Prévoir aussi l'espace disque sur le volume de la cible.
## Configuration ## Configuration
Un seul fichier JSON (voir [`config.example.json`](config.example.json), schéma dans Un seul fichier JSON (voir [`config.example.json`](config.example.json), schéma dans
[`config.schema.json`](config.schema.json)). Chemin résolu depuis le dossier courant. [`config.schema.json`](config.schema.json)). Chemin résolu depuis le dossier courant.
| Clé JSON | Défaut | Rôle | | Clé JSON | Défaut | Rôle |
| --------------------- | ------------------------------ | --------------------------------------------------------------------------------------- | | ---------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| `port` | `3000` | Port d'écoute | | `port` | `3000` | Port d'écoute |
| `host` | `0.0.0.0` | Interface d'écoute | | `host` | `0.0.0.0` | Interface d'écoute |
| `publicUrl` | — | **Obligatoire.** URL externe de base (redirect_uri, post-logout) | | `publicUrl` | — | **Obligatoire.** URL externe de base (redirect_uri, post-logout) |
| `targets` | — | **Obligatoire.** Liste `[{ name, label?, dir }]` (≥ 1) ; la 1ʳᵉ est la cible par défaut | | `targets` | — | **Obligatoire.** Liste `[{ name, label?, dir, allowDirectories? }]` (≥ 1) ; la 1ʳᵉ est la cible par défaut |
| `maxFileSizeMb` | `512` | Taille maximale par fichier (Mo) | | `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) |
| `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` | | `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` |
| `allowedMimeTypes` | _(liste média)_ | Tableau de types MIME, ou `"all"` ; absent => liste par défaut | | `allowedMimeTypes` | _(liste média)_ | Tableau de types MIME, ou `"all"` ; 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`) |
@ -203,6 +223,7 @@ src/
lib/ lib/
storage.ts écriture disque dans la cible, nom de fichier sûr, plafond de taille storage.ts écriture disque dans la cible, nom de fichier sûr, plafond de taille
media-types.ts liste blanche des types + extensions media-types.ts liste blanche des types + extensions
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)
``` ```

View file

@ -5,7 +5,7 @@
"publicUrl": "http://localhost:3000", "publicUrl": "http://localhost:3000",
"targets": [ "targets": [
{ "name": "photos", "label": "Photos", "dir": "./uploads/photos" }, { "name": "photos", "label": "Photos", "dir": "./uploads/photos" },
{ "name": "videos", "label": "Vidéos", "dir": "./uploads/videos" } { "name": "videos", "label": "Vidéos", "dir": "./uploads/videos", "allowDirectories": true }
], ],
"maxFileSizeMb": 512, "maxFileSizeMb": 512,
"logLevel": "info", "logLevel": "info",

View file

@ -29,7 +29,12 @@
"description": "Identifiant transmis par le client (?target=...)." "description": "Identifiant transmis par le client (?target=...)."
}, },
"label": { "type": "string", "description": "Libellé affiché (défaut : name)." }, "label": { "type": "string", "description": "Libellé affiché (défaut : name)." },
"dir": { "type": "string", "description": "Chemin du dossier de destination." } "dir": { "type": "string", "description": "Chemin du dossier de destination." },
"allowDirectories": {
"type": "boolean",
"default": false,
"description": "Autoriser le dépôt de dossiers (arborescence recréée sous dir)."
}
} }
} }
}, },

View file

@ -199,11 +199,17 @@
</div> </div>
<div id="dropzone" class="card disabled"> <div id="dropzone" class="card disabled">
<strong>Déposez vos fichiers ici</strong><br /> <strong>Déposez fichiers ou dossiers ici</strong><br />
<span class="muted">ou cliquez pour parcourir</span> <span class="muted">ou cliquez pour parcourir</span>
<input id="picker" type="file" multiple hidden /> <input id="picker" type="file" multiple hidden />
</div> </div>
<div id="dirRow" class="card" hidden>
<button id="dirBtn" type="button" class="btn secondary">Parcourir un dossier…</button>
<span class="muted"> — ou glissez un dossier dans la zone ci-dessus</span>
<input id="dirPicker" type="file" webkitdirectory multiple hidden />
</div>
<div id="list"></div> <div id="list"></div>
</main> </main>
@ -216,10 +222,14 @@
const targetSelect = document.getElementById('targetSelect'); const targetSelect = document.getElementById('targetSelect');
const dropzone = document.getElementById('dropzone'); const dropzone = document.getElementById('dropzone');
const picker = document.getElementById('picker'); const picker = document.getElementById('picker');
const dirRow = document.getElementById('dirRow');
const dirBtn = document.getElementById('dirBtn');
const dirPicker = document.getElementById('dirPicker');
const list = document.getElementById('list'); const list = document.getElementById('list');
let authenticated = false; let authenticated = false;
let currentTarget = null; let currentTarget = null;
let currentTargetAllowsDirs = false;
function showBanner(message) { function showBanner(message) {
banner.textContent = message; banner.textContent = message;
@ -237,14 +247,28 @@
logoutForm.hidden = !authenticated; logoutForm.hidden = !authenticated;
dropzone.classList.toggle('disabled', !authenticated); dropzone.classList.toggle('disabled', !authenticated);
authState.textContent = authenticated ? 'Connecté : ' + me.name : 'Non connecté'; authState.textContent = authenticated ? 'Connecté : ' + me.name : 'Non connecté';
if (authenticated) loadTargets(); if (authenticated) {
else targetCard.hidden = true; loadTargets();
} else {
targetCard.hidden = true;
dirRow.hidden = true;
}
}
let targets = [];
function applyTargetSelection() {
const target = targets.find((t) => t.name === targetSelect.value) || targets[0] || null;
currentTarget = target ? target.name : null;
currentTargetAllowsDirs = Boolean(target && target.allowDirectories);
dirRow.hidden = !authenticated || !currentTargetAllowsDirs;
} }
function loadTargets() { function loadTargets() {
fetch('/targets', { headers: { accept: 'application/json' } }) fetch('/targets', { headers: { accept: 'application/json' } })
.then((r) => (r.ok ? r.json() : [])) .then((r) => (r.ok ? r.json() : []))
.then((targets) => { .then((data) => {
targets = Array.isArray(data) ? data : [];
targetSelect.replaceChildren(); targetSelect.replaceChildren();
targets.forEach((target) => { targets.forEach((target) => {
const option = document.createElement('option'); const option = document.createElement('option');
@ -252,18 +276,19 @@
option.textContent = target.label; option.textContent = target.label;
targetSelect.appendChild(option); targetSelect.appendChild(option);
}); });
currentTarget = targets.length ? targets[0].name : null;
targetCard.hidden = targets.length < 2; targetCard.hidden = targets.length < 2;
applyTargetSelection();
}) })
.catch(() => { .catch(() => {
targets = [];
currentTarget = null; currentTarget = null;
currentTargetAllowsDirs = false;
targetCard.hidden = true; targetCard.hidden = true;
dirRow.hidden = true;
}); });
} }
targetSelect.addEventListener('change', () => { targetSelect.addEventListener('change', applyTargetSelection);
currentTarget = targetSelect.value;
});
fetch('/auth/me', { headers: { accept: 'application/json' } }) fetch('/auth/me', { headers: { accept: 'application/json' } })
.then((r) => r.json()) .then((r) => r.json())
@ -271,7 +296,7 @@
.catch(() => render({ authenticated: false })); .catch(() => render({ authenticated: false }));
function formatSize(bytes) { function formatSize(bytes) {
const units = ['o', 'Ko', 'Mo', 'Go']; const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
let value = bytes; let value = bytes;
let unit = 0; let unit = 0;
while (value >= 1024 && unit < units.length - 1) { while (value >= 1024 && unit < units.length - 1) {
@ -281,14 +306,14 @@
return value.toFixed(value < 10 && unit > 0 ? 1 : 0) + ' ' + units[unit]; return value.toFixed(value < 10 && unit > 0 ? 1 : 0) + ' ' + units[unit];
} }
function addItem(file) { function addItem(file, label) {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'item'; item.className = 'item';
item.innerHTML = item.innerHTML =
'<div class="row"><span class="name"></span><span class="muted"></span></div>' + '<div class="row"><span class="name"></span><span class="muted"></span></div>' +
'<div class="bar"><span></span></div>' + '<div class="bar"><span></span></div>' +
'<div class="row"><span class="status muted">En attente…</span></div>'; '<div class="row"><span class="status muted">En attente…</span></div>';
item.querySelector('.name').textContent = file.name; item.querySelector('.name').textContent = label || file.name;
item.querySelector('.row .muted').textContent = formatSize(file.size); item.querySelector('.row .muted').textContent = formatSize(file.size);
list.prepend(item); list.prepend(item);
return { bar: item.querySelector('.bar > span'), status: item.querySelector('.status') }; return { bar: item.querySelector('.bar > span'), status: item.querySelector('.status') };
@ -299,17 +324,19 @@
ui.status.className = 'status ' + (kind || 'muted'); ui.status.className = 'status ' + (kind || 'muted');
} }
function upload(file) { function upload(file, relativePath) {
const ui = addItem(file); const ui = addItem(file, relativePath);
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
const params = new URLSearchParams();
if (currentTarget) params.set('target', currentTarget);
if (relativePath) params.set('path', relativePath);
const query = params.toString();
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
const url = currentTarget xhr.open('POST', query ? '/upload?' + query : '/upload');
? '/upload?target=' + encodeURIComponent(currentTarget)
: '/upload';
xhr.open('POST', url);
xhr.upload.addEventListener('progress', (event) => { xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) { if (event.lengthComputable) {
@ -342,22 +369,81 @@
xhr.send(form); xhr.send(form);
} }
function handleFiles(files) { // items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
function handleBatch(items) {
if (!authenticated) { if (!authenticated) {
showBanner('Connectez-vous avant de déposer des fichiers.'); showBanner('Connectez-vous avant de déposer des fichiers.');
return; return;
} }
let batch = items;
if (!currentTargetAllowsDirs && batch.some((entry) => entry.path)) {
showBanner('La cible sélectionnée n’accepte pas le dépôt de dossiers.');
batch = batch.filter((entry) => !entry.path);
} else {
banner.hidden = true; banner.hidden = true;
for (const file of files) upload(file); }
for (const entry of batch) upload(entry.file, entry.path);
}
function fromFileList(fileList, useRelativePath) {
return Array.from(fileList, (file) => ({
file,
path: useRelativePath ? file.webkitRelativePath || '' : '',
}));
}
function readAllEntries(reader) {
return new Promise((resolve, reject) => {
const acc = [];
const step = () =>
reader.readEntries((batch) => {
if (batch.length === 0) resolve(acc);
else {
acc.push(...batch);
step();
}
}, reject);
step();
});
}
async function walkEntry(entry, prefix, out) {
if (entry.isFile) {
const file = await new Promise((res, rej) => entry.file(res, rej));
out.push({ file, path: prefix ? prefix + entry.name : '' });
} else if (entry.isDirectory) {
const children = await readAllEntries(entry.createReader());
for (const child of children) await walkEntry(child, prefix + entry.name + '/', out);
}
}
async function collectDropItems(dataTransfer) {
const entries = [];
for (const item of Array.from(dataTransfer.items || [])) {
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (entry) entries.push(entry);
}
if (entries.length === 0) {
return fromFileList(dataTransfer.files || [], false);
}
const out = [];
for (const entry of entries) await walkEntry(entry, '', out);
return out;
} }
dropzone.addEventListener('click', () => { dropzone.addEventListener('click', () => {
if (authenticated) picker.click(); if (authenticated) picker.click();
}); });
picker.addEventListener('change', () => { picker.addEventListener('change', () => {
handleFiles(picker.files); handleBatch(fromFileList(picker.files, false));
picker.value = ''; picker.value = '';
}); });
dirBtn.addEventListener('click', () => dirPicker.click());
dirPicker.addEventListener('change', () => {
handleBatch(fromFileList(dirPicker.files, true));
dirPicker.value = '';
});
['dragenter', 'dragover'].forEach((type) => { ['dragenter', 'dragover'].forEach((type) => {
dropzone.addEventListener(type, (event) => { dropzone.addEventListener(type, (event) => {
event.preventDefault(); event.preventDefault();
@ -372,9 +458,12 @@
}); });
}); });
dropzone.addEventListener('drop', (event) => { dropzone.addEventListener('drop', (event) => {
if (event.dataTransfer && event.dataTransfer.files.length) { if (!authenticated || !event.dataTransfer) return;
handleFiles(event.dataTransfer.files); collectDropItems(event.dataTransfer)
} .then((items) => {
if (items.length) handleBatch(items);
})
.catch(() => showBanner('Impossible de lire le contenu déposé.'));
}); });
</script> </script>
</body> </body>

View file

@ -40,6 +40,10 @@ export async function buildApp(
const app = Fastify({ const app = Fastify({
logger: options.logger ?? { level: config.logLevel }, logger: options.logger ?? { level: config.logLevel },
// Uploads pouvant durer longtemps (fichiers de plusieurs Go) : pas de
// timeout serveur imposé. Régler les timeouts sur le reverse proxy.
requestTimeout: 0,
connectionTimeout: 0,
}); });
app.decorate('appConfig', config); app.decorate('appConfig', config);

View file

@ -32,6 +32,11 @@ export interface DropTarget {
label: string; label: string;
/** Chemin du dossier de destination. */ /** Chemin du dossier de destination. */
dir: string; dir: string;
/**
* Autoriser le dépôt de dossiers : le client fournit un chemin relatif
* (`?path=...`), l'arborescence est recréée sous `dir`. Défaut : `false`.
*/
allowDirectories: boolean;
} }
/** Liste non vide de cibles de dépôt ; la première est la cible par défaut. */ /** Liste non vide de cibles de dépôt ; la première est la cible par défaut. */
@ -108,6 +113,16 @@ function optionalPositiveInt(value: unknown, fallback: number, name: string): nu
return parsed; return parsed;
} }
function optionalBoolean(value: unknown, fallback: boolean, name: string): boolean {
if (value === undefined || value === null) {
return fallback;
}
if (typeof value !== 'boolean') {
throw new ConfigError(`${name} doit être un booléen`);
}
return value;
}
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;
@ -222,6 +237,11 @@ function parseTargets(value: unknown): DropTargets {
name, name,
label: optionalString(raw.label, `targets[${String(index)}].label`) ?? name, label: optionalString(raw.label, `targets[${String(index)}].label`) ?? name,
dir: requiredString(raw.dir, `targets[${String(index)}].dir`), dir: requiredString(raw.dir, `targets[${String(index)}].dir`),
allowDirectories: optionalBoolean(
raw.allowDirectories,
false,
`targets[${String(index)}].allowDirectories`,
),
}; };
}; };

62
src/lib/paths.ts Normal file
View file

@ -0,0 +1,62 @@
import { ValidationError } from '../errors.js';
/** Chemin relatif assaini : segments de dossiers + nom de fichier final. */
export interface SafeRelativePath {
/** Segments de sous-dossiers, sûrs (peut être vide). */
segments: string[];
/** Nom de fichier final, sûr. */
fileName: string;
}
const MAX_INPUT_LENGTH = 4096;
const MAX_SEGMENTS = 32;
const MAX_SEGMENT_LENGTH = 100;
const CONTROL_CHARS = /\p{Cc}/gu;
const WINDOWS_HOSTILE = /[<>:"|?*]/g;
const TRAILING_DOT_SPACE = /[. ]+$/;
/**
* Nettoie un composant de chemin fourni par le client :
* - retire les caractères de contrôle et ceux hostiles sous Windows ;
* - neutralise `.`, `..`, `...` (deviennent vides -> ignorés) ;
* - retire les points/espaces en fin (invalides sous Windows).
* Renvoie une chaîne vide si le segment n'a rien de valide.
*/
function sanitizeSegment(segment: string): string {
return segment
.normalize('NFC')
.replace(CONTROL_CHARS, '')
.replace(WINDOWS_HOSTILE, '_')
.replace(/^\.+$/, '')
.replace(/\s+/g, ' ')
.trim()
.replace(TRAILING_DOT_SPACE, '')
.slice(0, MAX_SEGMENT_LENGTH);
}
/**
* Convertit un chemin relatif client (`dossier/sous/fichier.ext`, séparateurs
* `/` ou `\`) en un chemin sûr, garanti à l'intérieur de la cible : aucun
* segment ne peut valoir `..` ni contenir de séparateur. Lève `ValidationError`.
*/
export function parseRelativePath(input: string): SafeRelativePath {
if (input.length > MAX_INPUT_LENGTH) {
throw new ValidationError('Chemin de dépôt trop long');
}
const parts = input
.split(/[/\\]+/)
.map(sanitizeSegment)
.filter((part) => part.length > 0);
if (parts.length === 0) {
throw new ValidationError('Chemin de dépôt invalide');
}
if (parts.length > MAX_SEGMENTS) {
throw new ValidationError('Arborescence de dépôt trop profonde');
}
const fileName = parts[parts.length - 1] ?? '';
return { segments: parts.slice(0, -1), fileName };
}

View file

@ -5,11 +5,12 @@ import { randomUUID } from 'node:crypto';
import { Transform, type Readable } from 'node:stream'; import { Transform, type Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import { PayloadTooLargeError } from '../errors.js'; import { PayloadTooLargeError, ValidationError } from '../errors.js';
import { extensionForMime } from './media-types.js'; import { extensionForMime } from './media-types.js';
/** Métadonnées d'un fichier enregistré sur le disque. */ /** Métadonnées d'un fichier enregistré sur le disque. */
export interface StoredFile { export interface StoredFile {
/** Nom simple, ou chemin relatif `dossier/sous/fichier.ext` en mode dossier. */
storedName: string; storedName: string;
originalName: string; originalName: string;
size: number; size: number;
@ -17,6 +18,14 @@ export interface StoredFile {
path: string; path: string;
} }
/** Emplacement recréé sous `targetDir` pour un dépôt de dossier. */
export interface FilePlacement {
/** Sous-dossiers, déjà assainis (peut être vide). */
segments: readonly string[];
/** Nom de fichier final, déjà assaini, conservé tel quel. */
fileName: string;
}
export interface SaveFileParams { export interface SaveFileParams {
source: Readable; source: Readable;
originalName: string; originalName: string;
@ -24,9 +33,17 @@ export interface SaveFileParams {
/** Dossier de destination (résolu depuis la cible de dépôt choisie). */ /** Dossier de destination (résolu depuis la cible de dépôt choisie). */
targetDir: string; targetDir: string;
maxFileSizeBytes: number; maxFileSizeBytes: number;
/**
* Mode dossier : recrée `segments` sous `targetDir` et conserve `fileName` ;
* un fichier existant au même chemin est écrasé. Absent -> nom dérivé unique
* à la racine de `targetDir`.
*/
placement?: FilePlacement;
} }
const MAX_BASE_LENGTH = 60; const MAX_BASE_LENGTH = 60;
/** Tampon d'écriture large : meilleur débit sur les très gros fichiers. */
const WRITE_HIGH_WATER_MARK = 1 << 20;
/** Crée un dossier de dépôt s'il n'existe pas (idempotent). */ /** Crée un dossier de dépôt s'il n'existe pas (idempotent). */
export async function ensureUploadDir(dir: string): Promise<void> { export async function ensureUploadDir(dir: string): Promise<void> {
@ -75,18 +92,45 @@ function wasTruncated(stream: Readable): boolean {
return (stream as { truncated?: unknown }).truncated === true; return (stream as { truncated?: unknown }).truncated === true;
} }
interface ResolvedDestination {
storedName: string;
destination: string;
}
async function resolveDestination(params: SaveFileParams): Promise<ResolvedDestination> {
if (params.placement === undefined) {
const storedName = buildStoredName(params.originalName, params.mimeType);
return { storedName, destination: path.join(params.targetDir, storedName) };
}
const { segments, fileName } = params.placement;
const directory = path.join(params.targetDir, ...segments);
const root = path.resolve(params.targetDir);
const resolved = path.resolve(directory);
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
throw new ValidationError('Chemin de dépôt hors de la cible');
}
await mkdir(directory, { recursive: true });
return {
storedName: [...segments, fileName].join('/'),
destination: path.join(directory, fileName),
};
}
/** /**
* Écrit le flux `source` sur le disque, dans `targetDir`. * Écrit le flux `source` sur le disque, dans `targetDir` (ou un sous-dossier via
* `placement`).
* *
* - Le nom final est entièrement dérivé côté serveur : aucun segment fourni par le * - Hors mode dossier, le nom final est dérivé côté serveur : aucun segment
* client n'entre dans le chemin (pas de path traversal possible). * fourni par le client n'entre dans le chemin (pas de path traversal).
* - La taille est plafonnée pendant le streaming ; au-delà de `maxFileSizeBytes`, le * - La taille est plafonnée pendant le streaming ; au-delà de `maxFileSizeBytes`,
* fichier partiel est supprimé et `PayloadTooLargeError` est levée. * le fichier partiel est supprimé et `PayloadTooLargeError` est levée.
*/ */
export async function saveFile(params: SaveFileParams): Promise<StoredFile> { export async function saveFile(params: SaveFileParams): Promise<StoredFile> {
const { source, originalName, mimeType, targetDir, maxFileSizeBytes } = params; const { source, originalName, mimeType, maxFileSizeBytes } = params;
const storedName = buildStoredName(originalName, mimeType); const { storedName, destination } = await resolveDestination(params);
const destination = path.join(targetDir, storedName);
let bytesWritten = 0; let bytesWritten = 0;
const limiter = new Transform({ const limiter = new Transform({
@ -101,7 +145,11 @@ export async function saveFile(params: SaveFileParams): Promise<StoredFile> {
}); });
try { try {
await pipeline(source, limiter, createWriteStream(destination)); await pipeline(
source,
limiter,
createWriteStream(destination, { highWaterMark: WRITE_HIGH_WATER_MARK }),
);
} catch (error) { } catch (error) {
await rm(destination, { force: true }); await rm(destination, { force: true });
throw normalizeStreamError(error); throw normalizeStreamError(error);

View file

@ -3,8 +3,9 @@ import type { FastifyPluginCallback } from 'fastify';
import type { DropTarget, DropTargets } from '../config.js'; import type { DropTarget, DropTargets } 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 { requireSession } from '../session.js'; import { requireSession } from '../session.js';
import { saveFile, type StoredFile } from '../lib/storage.js'; import { saveFile, type FilePlacement, type StoredFile } from '../lib/storage.js';
const FIELD_NAME = 'file'; const FIELD_NAME = 'file';
@ -19,6 +20,12 @@ interface UploadResponse {
interface TargetSummary { interface TargetSummary {
name: string; name: string;
label: string; label: string;
allowDirectories: boolean;
}
interface UploadQuery {
target?: unknown;
path?: unknown;
} }
/** Résout la cible demandée (`?target=`), ou la première par défaut ; 400 si inconnue. */ /** Résout la cible demandée (`?target=`), ou la première par défaut ; 400 si inconnue. */
@ -36,6 +43,23 @@ function resolveTarget(targets: DropTargets, requested: unknown): DropTarget {
return match; 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,
@ -47,22 +71,25 @@ function toResponse(target: DropTarget, stored: StoredFile): UploadResponse {
} }
/** /**
* - `GET /targets` — liste des cibles (nom + libellé), session requise * - `GET /targets` — liste des cibles (nom, libellé, dépôt de dossier autorisé)
* - `POST /upload?target=<nom>` — dépôt d'un fichier multipart dans la cible choisie * - `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('/targets', { preHandler: requireSession }, (): TargetSummary[] => app.get('/targets', { preHandler: requireSession }, (): TargetSummary[] =>
app.appConfig.targets.map((target) => ({ name: target.name, label: target.label })), app.appConfig.targets.map((target) => ({
name: target.name,
label: target.label,
allowDirectories: target.allowDirectories,
})),
); );
app.post( app.post(
'/upload', '/upload',
{ preHandler: requireSession }, { preHandler: requireSession },
async (request, reply): Promise<UploadResponse> => { async (request, reply): Promise<UploadResponse> => {
const target = resolveTarget( const query = request.query as UploadQuery;
app.appConfig.targets, const target = resolveTarget(app.appConfig.targets, query.target);
(request.query as { target?: unknown }).target, const placement = resolvePlacement(target, query.path);
);
if (!request.isMultipart()) { if (!request.isMultipart()) {
throw new ValidationError('Requête multipart/form-data attendue'); throw new ValidationError('Requête multipart/form-data attendue');
@ -87,6 +114,7 @@ export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => {
mimeType: part.mimetype, mimeType: part.mimetype,
targetDir: target.dir, targetDir: target.dir,
maxFileSizeBytes: app.appConfig.maxFileSizeBytes, maxFileSizeBytes: app.appConfig.maxFileSizeBytes,
...(placement !== undefined ? { placement } : {}),
}); });
reply.status(201); reply.status(201);

View file

@ -32,7 +32,9 @@ describe('parseConfig', () => {
maxFileSizeBytes: 512 * 1024 * 1024, maxFileSizeBytes: 512 * 1024 * 1024,
logLevel: 'info', logLevel: 'info',
}); });
expect(config.targets).toEqual([{ name: 'photos', label: 'photos', dir: './uploads/photos' }]); expect(config.targets).toEqual([
{ name: 'photos', label: 'photos', dir: './uploads/photos', allowDirectories: false },
]);
expect(config.allowedMimeTypes).toBe(DEFAULT_ALLOWED_MIME); expect(config.allowedMimeTypes).toBe(DEFAULT_ALLOWED_MIME);
expect(config.session.maxAgeSeconds).toBe(12 * 3600); expect(config.session.maxAgeSeconds).toBe(12 * 3600);
expect(config.session.key).toHaveLength(32); expect(config.session.key).toHaveLength(32);
@ -76,9 +78,16 @@ describe('parseConfig', () => {
], ],
}), }),
).toThrow(ConfigError); ).toThrow(ConfigError);
expect(() =>
parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowDirectories: 'yes' }] }),
).toThrow(ConfigError);
expect( expect(
parseConfig({ ...BASE, targets: [{ name: 'x', label: 'X', dir: './x' }] }).targets[0], parseConfig({ ...BASE, targets: [{ name: 'x', label: 'X', dir: './x' }] }).targets[0],
).toEqual({ name: 'x', label: 'X', dir: './x' }); ).toEqual({ name: 'x', label: 'X', dir: './x', allowDirectories: false });
expect(
parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowDirectories: true }] })
.targets[0].allowDirectories,
).toBe(true);
}); });
it('rejette une session.key qui ne fait pas 32 octets', () => { it('rejette une session.key qui ne fait pas 32 octets', () => {

View file

@ -32,8 +32,8 @@ export async function removeDir(dir: string): Promise<void> {
} }
/** Liste à une seule cible pointant vers `dir`. */ /** Liste à une seule cible pointant vers `dir`. */
export function oneTarget(dir: string): DropTargets { export function oneTarget(dir: string, allowDirectories = false): DropTargets {
return [{ name: 'main', label: 'Principal', dir }]; return [{ name: 'main', label: 'Principal', dir, allowDirectories }];
} }
export function makeConfig(overrides: Partial<AppConfig> = {}): AppConfig { export function makeConfig(overrides: Partial<AppConfig> = {}): AppConfig {

53
tests/paths.test.ts Normal file
View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { ValidationError } from '../src/errors.js';
import { parseRelativePath } from '../src/lib/paths.js';
describe('parseRelativePath', () => {
it('sépare dossiers et nom de fichier', () => {
expect(parseRelativePath('vacances/plage/img 001.jpg')).toEqual({
segments: ['vacances', 'plage'],
fileName: 'img 001.jpg',
});
});
it('accepte un simple nom de fichier (aucun sous-dossier)', () => {
expect(parseRelativePath('photo.png')).toEqual({ segments: [], fileName: 'photo.png' });
});
it('accepte les séparateurs `/` et `\\` et les segments accentués', () => {
expect(parseRelativePath('Été 2024\\sous-dossier/clé.mp4')).toEqual({
segments: ['Été 2024', 'sous-dossier'],
fileName: 'clé.mp4',
});
});
it('neutralise `.` et `..` (aucune remontée possible)', () => {
expect(parseRelativePath('../../etc/passwd')).toEqual({
segments: ['etc'],
fileName: 'passwd',
});
expect(parseRelativePath('a/./b/../c/d.txt')).toEqual({
segments: ['a', 'b', 'c'],
fileName: 'd.txt',
});
});
it('retire les caractères de contrôle et remplace les caractères hostiles', () => {
const parsed = parseRelativePath(`a${String.fromCharCode(1)}b/fi:le?.txt`);
expect(parsed.segments).toEqual(['ab']);
expect(parsed.fileName).toBe('fi_le_.txt');
});
it('rejette un chemin vide après nettoyage', () => {
expect(() => parseRelativePath('../..')).toThrow(ValidationError);
expect(() => parseRelativePath('///')).toThrow(ValidationError);
});
it('rejette une arborescence trop profonde ou trop longue', () => {
expect(() => parseRelativePath(Array(40).fill('d').join('/') + '/f.txt')).toThrow(
ValidationError,
);
expect(() => parseRelativePath('a/'.repeat(3000) + 'f.txt')).toThrow(ValidationError);
});
});

View file

@ -1,4 +1,5 @@
import { readFile, readdir } from 'node:fs/promises'; import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
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';
@ -71,3 +72,35 @@ describe('saveFile', () => {
expect(await readdir(uploadDir)).toHaveLength(0); expect(await readdir(uploadDir)).toHaveLength(0);
}); });
}); });
describe('saveFile — mode dossier (placement)', () => {
it('recrée les sous-dossiers et conserve le nom d’origine', async () => {
const stored = await saveFile({
source: Readable.from(TINY_PNG),
originalName: 'ignoré.png',
mimeType: 'image/png',
...params(),
placement: { segments: ['vacances', 'plage'], fileName: 'DSC 001.jpg' },
});
expect(stored.storedName).toBe('vacances/plage/DSC 001.jpg');
expect(stored.path).toBe(path.join(uploadDir, 'vacances', 'plage', 'DSC 001.jpg'));
expect(await readFile(stored.path)).toEqual(TINY_PNG);
});
it('écrase un fichier déjà présent au même chemin', async () => {
const common = { originalName: 'x', mimeType: 'image/png', ...params() };
const placement = { segments: ['dir'], fileName: 'a.bin' };
await saveFile({ ...common, source: Readable.from(Buffer.from('aaaa')), placement });
const second = await saveFile({
...common,
source: Readable.from(Buffer.from('bb')),
placement,
});
expect(second.size).toBe(2);
expect(await readFile(second.path)).toEqual(Buffer.from('bb'));
expect(await readdir(path.join(uploadDir, 'dir'))).toEqual(['a.bin']);
});
});

View file

@ -1,14 +1,17 @@
import { readdir } from 'node:fs/promises'; import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import type { FastifyInstance, LightMyRequestResponse } from 'fastify'; import type { FastifyInstance, LightMyRequestResponse } from 'fastify';
import FormData from 'form-data'; import FormData from 'form-data';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { buildApp } from '../src/app.js'; import { buildApp } from '../src/app.js';
import type { OidcConfig } from '../src/config.js';
import { import {
login, login,
makeConfig, makeConfig,
makeTempDir, makeTempDir,
oneTarget,
removeDir, removeDir,
SESSION_COOKIE, SESSION_COOKIE,
startStubIdp, startStubIdp,
@ -22,6 +25,15 @@ let app: FastifyInstance;
let dirA: string; let dirA: string;
let dirB: string; let dirB: string;
function oidcOverride(idpRef: StubIdp): OidcConfig {
return {
issuer: idpRef.issuer,
clientId: idpRef.clientId,
clientSecret: idpRef.clientSecret,
scopes: ['openid', 'profile', 'email'],
};
}
beforeAll(async () => { beforeAll(async () => {
idp = await startStubIdp(); idp = await startStubIdp();
}); });
@ -36,16 +48,11 @@ beforeEach(async () => {
app = await buildApp( app = await buildApp(
makeConfig({ makeConfig({
targets: [ targets: [
{ name: 'a', label: 'Dossier A', dir: dirA }, { name: 'a', label: 'Dossier A', dir: dirA, allowDirectories: true },
{ name: 'b', label: 'Dossier B', dir: dirB }, { name: 'b', label: 'Dossier B', dir: dirB, allowDirectories: false },
], ],
maxFileSizeBytes: 2000, maxFileSizeBytes: 2000,
oidc: { oidc: oidcOverride(idp),
issuer: idp.issuer,
clientId: idp.clientId,
clientSecret: idp.clientSecret,
scopes: ['openid', 'profile', 'email'],
},
}), }),
{ logger: false }, { logger: false },
); );
@ -61,16 +68,20 @@ afterEach(async () => {
const postUpload = ( const postUpload = (
form: FormData, form: FormData,
opts: { cookie?: string; target?: string } = {}, opts: { cookie?: string; target?: string; path?: string } = {},
): Promise<LightMyRequestResponse> => ): Promise<LightMyRequestResponse> => {
app.inject({ const params = new URLSearchParams();
if (opts.target !== undefined) params.set('target', opts.target);
if (opts.path !== undefined) params.set('path', opts.path);
const query = params.toString();
return app.inject({
method: 'POST', method: 'POST',
url: url: query ? `/upload?${query}` : '/upload',
opts.target !== undefined ? `/upload?target=${encodeURIComponent(opts.target)}` : '/upload',
payload: form, payload: form,
headers: form.getHeaders(), headers: form.getHeaders(),
...(opts.cookie !== undefined ? { cookies: { [SESSION_COOKIE]: opts.cookie } } : {}), ...(opts.cookie !== undefined ? { cookies: { [SESSION_COOKIE]: opts.cookie } } : {}),
}); });
};
function pngForm(field = 'file', body: Buffer = TINY_PNG): FormData { function pngForm(field = 'file', body: Buffer = TINY_PNG): FormData {
const form = new FormData(); const form = new FormData();
@ -92,7 +103,7 @@ describe('GET /targets', () => {
expect(res.statusCode).toBe(401); expect(res.statusCode).toBe(401);
}); });
it('renvoie les cibles (nom + libellé, sans chemin)', async () => { it('renvoie nom, libellé et allowDirectories (sans chemin disque)', async () => {
const res = await app.inject({ const res = await app.inject({
method: 'GET', method: 'GET',
url: '/targets', url: '/targets',
@ -100,8 +111,8 @@ describe('GET /targets', () => {
}); });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.json()).toEqual([ expect(res.json()).toEqual([
{ name: 'a', label: 'Dossier A' }, { name: 'a', label: 'Dossier A', allowDirectories: true },
{ name: 'b', label: 'Dossier B' }, { name: 'b', label: 'Dossier B', allowDirectories: false },
]); ]);
}); });
}); });
@ -123,7 +134,6 @@ describe('POST /upload', () => {
expect(res.statusCode).toBe(201); expect(res.statusCode).toBe(201);
const body = res.json<{ target: string; size: number; mimeType: string }>(); const body = res.json<{ target: string; size: number; mimeType: string }>();
expect(body.target).toBe('a'); expect(body.target).toBe('a');
expect(body.mimeType).toBe('image/png');
expect(body.size).toBe(TINY_PNG.length); expect(body.size).toBe(TINY_PNG.length);
expect(await readdir(dirA)).toHaveLength(1); expect(await readdir(dirA)).toHaveLength(1);
expect(await readdir(dirB)).toHaveLength(0); expect(await readdir(dirB)).toHaveLength(0);
@ -131,10 +141,8 @@ describe('POST /upload', () => {
it('dépose dans la cible demandée via ?target=', async () => { it('dépose dans la cible demandée via ?target=', async () => {
const res = await postUpload(pngForm(), { cookie: session, target: 'b' }); const res = await postUpload(pngForm(), { cookie: session, target: 'b' });
expect(res.statusCode).toBe(201); expect(res.statusCode).toBe(201);
expect(res.json<{ target: string }>().target).toBe('b'); expect(res.json<{ target: string }>().target).toBe('b');
expect(await readdir(dirA)).toHaveLength(0);
expect(await readdir(dirB)).toHaveLength(1); expect(await readdir(dirB)).toHaveLength(1);
}); });
@ -171,3 +179,87 @@ describe('POST /upload', () => {
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
}); });
}); });
describe('POST /upload — dépôt de dossier (?path=)', () => {
it('recrée l’arborescence sous une cible qui l’autorise', async () => {
const res = await postUpload(pngForm(), {
cookie: session,
target: 'a',
path: 'vac/plage/photo.png',
});
expect(res.statusCode).toBe(201);
expect(res.json<{ storedName: string }>().storedName).toBe('vac/plage/photo.png');
expect(await readdir(join(dirA, 'vac', 'plage'))).toEqual(['photo.png']);
});
it('refuse ?path= sur une cible qui n’autorise pas les dossiers (400)', async () => {
const res = await postUpload(pngForm(), {
cookie: session,
target: 'b',
path: 'sub/photo.png',
});
expect(res.statusCode).toBe(400);
expect(await readdir(dirB)).toHaveLength(0);
});
it('ne permet pas de sortir de la cible avec ../', async () => {
const res = await postUpload(pngForm(), {
cookie: session,
target: 'a',
path: '../../evil/x.png',
});
expect(res.statusCode).toBe(201);
expect(res.json<{ storedName: string }>().storedName).toBe('evil/x.png');
expect(await readdir(join(dirA, 'evil'))).toEqual(['x.png']);
});
it('écrase un fichier déjà présent au même chemin', async () => {
const opts = { cookie: session, target: 'a', path: 'd/f.png' };
await postUpload(pngForm('file', Buffer.from('11111')), opts);
await postUpload(pngForm('file', Buffer.from('22')), opts);
expect(await readdir(join(dirA, 'd'))).toEqual(['f.png']);
expect(await readFile(join(dirA, 'd', 'f.png'))).toEqual(Buffer.from('22'));
});
});
describe('POST /upload — au-delà du bodyLimit Fastify', () => {
let bigApp: FastifyInstance;
let bigDir: string;
let bigSession: string;
beforeEach(async () => {
bigDir = await makeTempDir();
bigApp = await buildApp(
makeConfig({
targets: oneTarget(bigDir),
maxFileSizeBytes: 32 * 1024 * 1024,
oidc: oidcOverride(idp),
}),
{ logger: false },
);
await bigApp.ready();
bigSession = await login(bigApp, idp);
});
afterEach(async () => {
await bigApp.close();
await removeDir(bigDir);
});
it('accepte un fichier de 2 Mio (> 1 Mio, bodyLimit par défaut)', async () => {
const form = new FormData();
form.append('file', Buffer.alloc(2 * 1024 * 1024, 7), {
filename: 'clip.png',
contentType: 'image/png',
});
const res = await bigApp.inject({
method: 'POST',
url: '/upload',
payload: form,
headers: form.getHeaders(),
cookies: { [SESSION_COOKIE]: bigSession },
});
expect(res.statusCode).toBe(201);
expect(res.json<{ size: number }>().size).toBe(2 * 1024 * 1024);
});
});