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:
parent
f9f17bd169
commit
b85607b096
14 changed files with 568 additions and 104 deletions
93
README.md
93
README.md
|
|
@ -96,59 +96,79 @@ 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`) |
|
||||||
| `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` |
|
| `maxFileSizeMb` | `512` | Taille maximale par fichier, en Mo (`10240` = 10 Go) |
|
||||||
| `allowedMimeTypes` | _(liste média)_ | Tableau de types MIME, ou `"all"` ; absent => liste par défaut |
|
| `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` |
|
||||||
| `session.key` | — | **Obligatoire.** 32 octets base64 (`openssl rand -base64 32`) |
|
| `allowedMimeTypes` | _(liste média)_ | Tableau de types MIME, ou `"all"` ; absent => liste par défaut |
|
||||||
| `session.maxAgeHours` | `12` | Durée de vie absolue de la session |
|
| `session.key` | — | **Obligatoire.** 32 octets base64 (`openssl rand -base64 32`) |
|
||||||
| `oidc.issuer` | — | **Obligatoire.** Issuer OIDC |
|
| `session.maxAgeHours` | `12` | Durée de vie absolue de la session |
|
||||||
| `oidc.clientId` | — | **Obligatoire.** `client_id` du client confidentiel |
|
| `oidc.issuer` | — | **Obligatoire.** Issuer OIDC |
|
||||||
| `oidc.clientSecret` | — | **Obligatoire.** Secret du client confidentiel |
|
| `oidc.clientId` | — | **Obligatoire.** `client_id` du client confidentiel |
|
||||||
| `oidc.scopes` | `["openid","profile","email"]` | Scopes demandés (`openid` forcé) |
|
| `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`).
|
Seule variable d'environnement : `MEDIADROP_CONFIG` (chemin du fichier, défaut `./config.json`).
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
banner.hidden = true;
|
let batch = items;
|
||||||
for (const file of files) upload(file);
|
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', () => {
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
62
src/lib/paths.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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
53
tests/paths.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue