diff --git a/README.md b/README.md index dde2182..9189371 100644 --- a/README.md +++ b/README.md @@ -96,59 +96,79 @@ remapper côté hôte si besoin. ## Endpoints -| Route | Auth | Rôle | -| --------------------------- | ------- | ------------------------------------------------------- | -| `GET /` | non | Page de dépôt | -| `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` | -| `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 `/` | -| `POST /auth/logout` | session | Ferme la session (+ déconnexion RP si disponible) | -| `GET /auth/me` | non | `{ "authenticated": bool, "name"?: string }` | -| `GET /targets` | session | Cibles de dépôt : `[{ "name", "label" }]` (sans chemin) | -| `POST /upload?target=` | session | Dépôt d'un fichier `multipart/form-data`, champ `file` | +| Route | Auth | Rôle | +| -------------------------------------- | ------- | ------------------------------------------------------------------ | +| `GET /` | non | Page de dépôt | +| `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` | +| `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 `/` | +| `POST /auth/logout` | session | Ferme la session (+ déconnexion RP si disponible) | +| `GET /auth/me` | non | `{ "authenticated": bool, "name"?: string }` | +| `GET /targets` | session | Cibles : `[{ "name", "label", "allowDirectories" }]` (sans chemin) | +| `POST /upload?target=[&path=

]` | session | Dépôt d'un fichier `multipart/form-data`, champ `file` | -`POST /upload` — le paramètre `?target=` choisit la cible ; absent, la -**première** cible de `targets` est utilisée. Réponse `201` : +`POST /upload` — `?target=` choisit la cible (absent → **première** cible). +`?path=` (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 { - "target": "photos", - "storedName": "photo-1725900000000-1a2b3c4d.jpg", - "originalName": "photo.jpg", + "target": "videos", + "storedName": "vacances/plage/DSC_001.mp4", + "originalName": "DSC_001.mp4", "size": 20480, - "mimeType": "image/jpeg" + "mimeType": "video/mp4" } ``` -| Code | Cas | -| ----- | ---------------------------------------------------------------- | -| `400` | pas de fichier, mauvais champ, type non autorisé, cible inconnue | -| `401` | session absente ou expirée | -| `413` | fichier au-dessus de `maxFileSizeMb` | +| Code | Cas | +| ----- | ------------------------------------------------------------------------------------------------- | +| `400` | pas de fichier, mauvais champ, type non autorisé, cible inconnue, `path` invalide ou non autorisé | +| `401` | session absente ou expirée | +| `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 > 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 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. -| Clé JSON | Défaut | Rôle | -| --------------------- | ------------------------------ | --------------------------------------------------------------------------------------- | -| `port` | `3000` | Port d'écoute | -| `host` | `0.0.0.0` | Interface d'écoute | -| `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 | -| `maxFileSizeMb` | `512` | Taille maximale par fichier (Mo) | -| `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` | -| `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.maxAgeHours` | `12` | Durée de vie absolue de la session | -| `oidc.issuer` | — | **Obligatoire.** Issuer OIDC | -| `oidc.clientId` | — | **Obligatoire.** `client_id` du client confidentiel | -| `oidc.clientSecret` | — | **Obligatoire.** Secret du client confidentiel | -| `oidc.scopes` | `["openid","profile","email"]` | Scopes demandés (`openid` forcé) | +| Clé JSON | Défaut | Rôle | +| ---------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| `port` | `3000` | Port d'écoute | +| `host` | `0.0.0.0` | Interface d'écoute | +| `publicUrl` | — | **Obligatoire.** URL externe de base (redirect_uri, post-logout) | +| `targets` | — | **Obligatoire.** Liste `[{ name, label?, dir, allowDirectories? }]` (≥ 1) ; la 1ʳᵉ est la cible par défaut | +| `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` | +| `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.maxAgeHours` | `12` | Durée de vie absolue de la session | +| `oidc.issuer` | — | **Obligatoire.** Issuer OIDC | +| `oidc.clientId` | — | **Obligatoire.** `client_id` du client confidentiel | +| `oidc.clientSecret` | — | **Obligatoire.** Secret du client confidentiel | +| `oidc.scopes` | `["openid","profile","email"]` | Scopes demandés (`openid` forcé) | Seule variable d'environnement : `MEDIADROP_CONFIG` (chemin du fichier, défaut `./config.json`). @@ -203,6 +223,7 @@ src/ lib/ storage.ts écriture disque dans la cible, nom de fichier sûr, plafond de taille media-types.ts liste blanche des types + extensions + paths.ts assainissement des chemins relatifs (dépôt de dossier) public/ index.html page de dépôt (login = lien vers /auth/login, sans build) ``` diff --git a/config.example.json b/config.example.json index 4c01017..05685bc 100644 --- a/config.example.json +++ b/config.example.json @@ -5,7 +5,7 @@ "publicUrl": "http://localhost:3000", "targets": [ { "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, "logLevel": "info", diff --git a/config.schema.json b/config.schema.json index e439605..29edb4a 100644 --- a/config.schema.json +++ b/config.schema.json @@ -29,7 +29,12 @@ "description": "Identifiant transmis par le client (?target=...)." }, "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)." + } } } }, diff --git a/public/index.html b/public/index.html index f53af03..7652ec8 100644 --- a/public/index.html +++ b/public/index.html @@ -199,11 +199,17 @@

- Déposez vos fichiers ici
+ Déposez fichiers ou dossiers ici
ou cliquez pour parcourir
+ +
@@ -216,10 +222,14 @@ const targetSelect = document.getElementById('targetSelect'); const dropzone = document.getElementById('dropzone'); 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'); let authenticated = false; let currentTarget = null; + let currentTargetAllowsDirs = false; function showBanner(message) { banner.textContent = message; @@ -237,14 +247,28 @@ logoutForm.hidden = !authenticated; dropzone.classList.toggle('disabled', !authenticated); authState.textContent = authenticated ? 'Connecté : ' + me.name : 'Non connecté'; - if (authenticated) loadTargets(); - else targetCard.hidden = true; + if (authenticated) { + 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() { fetch('/targets', { headers: { accept: 'application/json' } }) .then((r) => (r.ok ? r.json() : [])) - .then((targets) => { + .then((data) => { + targets = Array.isArray(data) ? data : []; targetSelect.replaceChildren(); targets.forEach((target) => { const option = document.createElement('option'); @@ -252,18 +276,19 @@ option.textContent = target.label; targetSelect.appendChild(option); }); - currentTarget = targets.length ? targets[0].name : null; targetCard.hidden = targets.length < 2; + applyTargetSelection(); }) .catch(() => { + targets = []; currentTarget = null; + currentTargetAllowsDirs = false; targetCard.hidden = true; + dirRow.hidden = true; }); } - targetSelect.addEventListener('change', () => { - currentTarget = targetSelect.value; - }); + targetSelect.addEventListener('change', applyTargetSelection); fetch('/auth/me', { headers: { accept: 'application/json' } }) .then((r) => r.json()) @@ -271,7 +296,7 @@ .catch(() => render({ authenticated: false })); function formatSize(bytes) { - const units = ['o', 'Ko', 'Mo', 'Go']; + const units = ['o', 'Ko', 'Mo', 'Go', 'To']; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { @@ -281,14 +306,14 @@ return value.toFixed(value < 10 && unit > 0 ? 1 : 0) + ' ' + units[unit]; } - function addItem(file) { + function addItem(file, label) { const item = document.createElement('div'); item.className = 'item'; item.innerHTML = '
' + '
' + '
En attente…
'; - item.querySelector('.name').textContent = file.name; + item.querySelector('.name').textContent = label || file.name; item.querySelector('.row .muted').textContent = formatSize(file.size); list.prepend(item); return { bar: item.querySelector('.bar > span'), status: item.querySelector('.status') }; @@ -299,17 +324,19 @@ ui.status.className = 'status ' + (kind || 'muted'); } - function upload(file) { - const ui = addItem(file); + function upload(file, relativePath) { + const ui = addItem(file, relativePath); const form = new FormData(); 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 url = currentTarget - ? '/upload?target=' + encodeURIComponent(currentTarget) - : '/upload'; - xhr.open('POST', url); + xhr.open('POST', query ? '/upload?' + query : '/upload'); xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable) { @@ -342,22 +369,81 @@ 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) { showBanner('Connectez-vous avant de déposer des fichiers.'); return; } - banner.hidden = true; - for (const file of files) upload(file); + 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; + } + 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', () => { if (authenticated) picker.click(); }); picker.addEventListener('change', () => { - handleFiles(picker.files); + handleBatch(fromFileList(picker.files, false)); picker.value = ''; }); + dirBtn.addEventListener('click', () => dirPicker.click()); + dirPicker.addEventListener('change', () => { + handleBatch(fromFileList(dirPicker.files, true)); + dirPicker.value = ''; + }); + ['dragenter', 'dragover'].forEach((type) => { dropzone.addEventListener(type, (event) => { event.preventDefault(); @@ -372,9 +458,12 @@ }); }); dropzone.addEventListener('drop', (event) => { - if (event.dataTransfer && event.dataTransfer.files.length) { - handleFiles(event.dataTransfer.files); - } + if (!authenticated || !event.dataTransfer) return; + collectDropItems(event.dataTransfer) + .then((items) => { + if (items.length) handleBatch(items); + }) + .catch(() => showBanner('Impossible de lire le contenu déposé.')); }); diff --git a/src/app.ts b/src/app.ts index 9828980..bd4c5eb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -40,6 +40,10 @@ export async function buildApp( const app = Fastify({ 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); diff --git a/src/config.ts b/src/config.ts index ec4e570..3522c96 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,6 +32,11 @@ export interface DropTarget { label: string; /** Chemin du dossier de destination. */ 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. */ @@ -108,6 +113,16 @@ function optionalPositiveInt(value: unknown, fallback: number, name: string): nu 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 { if (value === undefined || value === null) { return DEFAULTS.logLevel; @@ -222,6 +237,11 @@ function parseTargets(value: unknown): DropTargets { name, label: optionalString(raw.label, `targets[${String(index)}].label`) ?? name, dir: requiredString(raw.dir, `targets[${String(index)}].dir`), + allowDirectories: optionalBoolean( + raw.allowDirectories, + false, + `targets[${String(index)}].allowDirectories`, + ), }; }; diff --git a/src/lib/paths.ts b/src/lib/paths.ts new file mode 100644 index 0000000..aa1d55a --- /dev/null +++ b/src/lib/paths.ts @@ -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 }; +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index b9498f0..d9049dd 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -5,11 +5,12 @@ import { randomUUID } from 'node:crypto'; import { Transform, type Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { PayloadTooLargeError } from '../errors.js'; +import { PayloadTooLargeError, ValidationError } from '../errors.js'; import { extensionForMime } from './media-types.js'; /** Métadonnées d'un fichier enregistré sur le disque. */ export interface StoredFile { + /** Nom simple, ou chemin relatif `dossier/sous/fichier.ext` en mode dossier. */ storedName: string; originalName: string; size: number; @@ -17,6 +18,14 @@ export interface StoredFile { 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 { source: Readable; originalName: string; @@ -24,9 +33,17 @@ export interface SaveFileParams { /** Dossier de destination (résolu depuis la cible de dépôt choisie). */ targetDir: string; 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; +/** 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). */ export async function ensureUploadDir(dir: string): Promise { @@ -75,18 +92,45 @@ function wasTruncated(stream: Readable): boolean { return (stream as { truncated?: unknown }).truncated === true; } +interface ResolvedDestination { + storedName: string; + destination: string; +} + +async function resolveDestination(params: SaveFileParams): Promise { + 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 - * client n'entre dans le chemin (pas de path traversal possible). - * - La taille est plafonnée pendant le streaming ; au-delà de `maxFileSizeBytes`, le - * fichier partiel est supprimé et `PayloadTooLargeError` est levée. + * - Hors mode dossier, le nom final est dérivé côté serveur : aucun segment + * 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 fichier partiel est supprimé et `PayloadTooLargeError` est levée. */ export async function saveFile(params: SaveFileParams): Promise { - const { source, originalName, mimeType, targetDir, maxFileSizeBytes } = params; - const storedName = buildStoredName(originalName, mimeType); - const destination = path.join(targetDir, storedName); + const { source, originalName, mimeType, maxFileSizeBytes } = params; + const { storedName, destination } = await resolveDestination(params); let bytesWritten = 0; const limiter = new Transform({ @@ -101,7 +145,11 @@ export async function saveFile(params: SaveFileParams): Promise { }); try { - await pipeline(source, limiter, createWriteStream(destination)); + await pipeline( + source, + limiter, + createWriteStream(destination, { highWaterMark: WRITE_HIGH_WATER_MARK }), + ); } catch (error) { await rm(destination, { force: true }); throw normalizeStreamError(error); diff --git a/src/routes/upload.ts b/src/routes/upload.ts index 09b217b..61748d8 100644 --- a/src/routes/upload.ts +++ b/src/routes/upload.ts @@ -3,8 +3,9 @@ import type { FastifyPluginCallback } from 'fastify'; import type { DropTarget, DropTargets } from '../config.js'; import { ValidationError } from '../errors.js'; import { isAllowedMedia } from '../lib/media-types.js'; +import { parseRelativePath } from '../lib/paths.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'; @@ -19,6 +20,12 @@ interface UploadResponse { interface TargetSummary { name: 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. */ @@ -36,6 +43,23 @@ function resolveTarget(targets: DropTargets, requested: unknown): DropTarget { 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 { return { target: target.name, @@ -47,22 +71,25 @@ function toResponse(target: DropTarget, stored: StoredFile): UploadResponse { } /** - * - `GET /targets` — liste des cibles (nom + libellé), session requise - * - `POST /upload?target=` — dépôt d'un fichier multipart dans la cible choisie + * - `GET /targets` — liste des cibles (nom, libellé, dépôt de dossier autorisé) + * - `POST /upload?target=[&path=]` — dépôt d'un fichier multipart */ export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => { 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( '/upload', { preHandler: requireSession }, async (request, reply): Promise => { - const target = resolveTarget( - app.appConfig.targets, - (request.query as { target?: unknown }).target, - ); + const query = request.query as UploadQuery; + const target = resolveTarget(app.appConfig.targets, query.target); + const placement = resolvePlacement(target, query.path); if (!request.isMultipart()) { throw new ValidationError('Requête multipart/form-data attendue'); @@ -87,6 +114,7 @@ export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => { mimeType: part.mimetype, targetDir: target.dir, maxFileSizeBytes: app.appConfig.maxFileSizeBytes, + ...(placement !== undefined ? { placement } : {}), }); reply.status(201); diff --git a/tests/config.test.ts b/tests/config.test.ts index 3b3a72d..5cdbb82 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -32,7 +32,9 @@ describe('parseConfig', () => { maxFileSizeBytes: 512 * 1024 * 1024, 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.session.maxAgeSeconds).toBe(12 * 3600); expect(config.session.key).toHaveLength(32); @@ -76,9 +78,16 @@ describe('parseConfig', () => { ], }), ).toThrow(ConfigError); + expect(() => + parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowDirectories: 'yes' }] }), + ).toThrow(ConfigError); expect( 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', () => { diff --git a/tests/helpers.ts b/tests/helpers.ts index 8bbfadd..7f5b07a 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -32,8 +32,8 @@ export async function removeDir(dir: string): Promise { } /** Liste à une seule cible pointant vers `dir`. */ -export function oneTarget(dir: string): DropTargets { - return [{ name: 'main', label: 'Principal', dir }]; +export function oneTarget(dir: string, allowDirectories = false): DropTargets { + return [{ name: 'main', label: 'Principal', dir, allowDirectories }]; } export function makeConfig(overrides: Partial = {}): AppConfig { diff --git a/tests/paths.test.ts b/tests/paths.test.ts new file mode 100644 index 0000000..c591085 --- /dev/null +++ b/tests/paths.test.ts @@ -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); + }); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 815be2b..50a85a2 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -1,4 +1,5 @@ import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -71,3 +72,35 @@ describe('saveFile', () => { 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']); + }); +}); diff --git a/tests/upload.test.ts b/tests/upload.test.ts index eda01d7..407077f 100644 --- a/tests/upload.test.ts +++ b/tests/upload.test.ts @@ -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 FormData from 'form-data'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { buildApp } from '../src/app.js'; +import type { OidcConfig } from '../src/config.js'; import { login, makeConfig, makeTempDir, + oneTarget, removeDir, SESSION_COOKIE, startStubIdp, @@ -22,6 +25,15 @@ let app: FastifyInstance; let dirA: 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 () => { idp = await startStubIdp(); }); @@ -36,16 +48,11 @@ beforeEach(async () => { app = await buildApp( makeConfig({ targets: [ - { name: 'a', label: 'Dossier A', dir: dirA }, - { name: 'b', label: 'Dossier B', dir: dirB }, + { name: 'a', label: 'Dossier A', dir: dirA, allowDirectories: true }, + { name: 'b', label: 'Dossier B', dir: dirB, allowDirectories: false }, ], maxFileSizeBytes: 2000, - oidc: { - issuer: idp.issuer, - clientId: idp.clientId, - clientSecret: idp.clientSecret, - scopes: ['openid', 'profile', 'email'], - }, + oidc: oidcOverride(idp), }), { logger: false }, ); @@ -61,16 +68,20 @@ afterEach(async () => { const postUpload = ( form: FormData, - opts: { cookie?: string; target?: string } = {}, -): Promise => - app.inject({ + opts: { cookie?: string; target?: string; path?: string } = {}, +): Promise => { + 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', - url: - opts.target !== undefined ? `/upload?target=${encodeURIComponent(opts.target)}` : '/upload', + url: query ? `/upload?${query}` : '/upload', payload: form, headers: form.getHeaders(), ...(opts.cookie !== undefined ? { cookies: { [SESSION_COOKIE]: opts.cookie } } : {}), }); +}; function pngForm(field = 'file', body: Buffer = TINY_PNG): FormData { const form = new FormData(); @@ -92,7 +103,7 @@ describe('GET /targets', () => { 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({ method: 'GET', url: '/targets', @@ -100,8 +111,8 @@ describe('GET /targets', () => { }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual([ - { name: 'a', label: 'Dossier A' }, - { name: 'b', label: 'Dossier B' }, + { name: 'a', label: 'Dossier A', allowDirectories: true }, + { name: 'b', label: 'Dossier B', allowDirectories: false }, ]); }); }); @@ -123,7 +134,6 @@ describe('POST /upload', () => { expect(res.statusCode).toBe(201); const body = res.json<{ target: string; size: number; mimeType: string }>(); expect(body.target).toBe('a'); - expect(body.mimeType).toBe('image/png'); expect(body.size).toBe(TINY_PNG.length); expect(await readdir(dirA)).toHaveLength(1); expect(await readdir(dirB)).toHaveLength(0); @@ -131,10 +141,8 @@ describe('POST /upload', () => { it('dépose dans la cible demandée via ?target=', async () => { const res = await postUpload(pngForm(), { cookie: session, target: 'b' }); - expect(res.statusCode).toBe(201); expect(res.json<{ target: string }>().target).toBe('b'); - expect(await readdir(dirA)).toHaveLength(0); expect(await readdir(dirB)).toHaveLength(1); }); @@ -171,3 +179,87 @@ describe('POST /upload', () => { 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); + }); +});