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>
106 lines
3.4 KiB
TypeScript
106 lines
3.4 KiB
TypeScript
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';
|
||
|
||
import { saveFile } from '../src/lib/storage.js';
|
||
import { PayloadTooLargeError } from '../src/errors.js';
|
||
import { makeTempDir, removeDir, TINY_PNG } from './helpers.js';
|
||
|
||
let uploadDir: string;
|
||
|
||
beforeEach(async () => {
|
||
uploadDir = await makeTempDir();
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await removeDir(uploadDir);
|
||
});
|
||
|
||
function params(maxFileSizeBytes = 1024 * 1024): { targetDir: string; maxFileSizeBytes: number } {
|
||
return { targetDir: uploadDir, maxFileSizeBytes };
|
||
}
|
||
|
||
describe('saveFile', () => {
|
||
it('écrit le fichier et renvoie ses métadonnées', async () => {
|
||
const stored = await saveFile({
|
||
source: Readable.from(TINY_PNG),
|
||
originalName: 'photo.png',
|
||
mimeType: 'image/png',
|
||
...params(),
|
||
});
|
||
|
||
expect(stored.size).toBe(TINY_PNG.length);
|
||
expect(stored.storedName).toMatch(/^photo-\d+-[0-9a-f]{8}\.png$/);
|
||
expect(await readFile(stored.path)).toEqual(TINY_PNG);
|
||
});
|
||
|
||
it('produit un nom sans séparateur de chemin même pour un nom hostile', async () => {
|
||
const stored = await saveFile({
|
||
source: Readable.from(TINY_PNG),
|
||
originalName: '../../etc/passwd',
|
||
mimeType: 'image/png',
|
||
...params(),
|
||
});
|
||
|
||
expect(stored.storedName).not.toMatch(/[/\\]/);
|
||
expect(stored.storedName).not.toContain('..');
|
||
expect(stored.storedName).toMatch(/^passwd-\d+-[0-9a-f]{8}\.png$/);
|
||
});
|
||
|
||
it('assainit les noms avec espaces et accents', async () => {
|
||
const stored = await saveFile({
|
||
source: Readable.from(TINY_PNG),
|
||
originalName: 'mon joli cliché.PNG',
|
||
mimeType: 'image/png',
|
||
...params(),
|
||
});
|
||
|
||
expect(stored.storedName).toMatch(/^[A-Za-z0-9_-]+-\d+-[0-9a-f]{8}\.png$/);
|
||
});
|
||
|
||
it('rejette au-delà de la taille maximale sans laisser de fichier partiel', async () => {
|
||
await expect(
|
||
saveFile({
|
||
source: Readable.from(Buffer.alloc(5000, 1)),
|
||
originalName: 'big.png',
|
||
mimeType: 'image/png',
|
||
...params(1000),
|
||
}),
|
||
).rejects.toBeInstanceOf(PayloadTooLargeError);
|
||
|
||
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']);
|
||
});
|
||
});
|