Serveur :
- POST /upload/init -> session { id, chunkSize (16 Mo), chunkCount }
- PUT /upload/:id/:index -> écrit le morceau à son offset (octet-stream brut,
bufferisé, parser dédié) ; renvoie { received, total }
- POST /upload/:id/complete -> vérifie tous les morceaux + la taille, renomme
le .mediadrop-part vers le chemin final ; 201 + métadonnées
- DELETE /upload/:id -> abandon, supprime le fichier partiel
- src/lib/upload-sessions.ts : registre en mémoire, purge après 30 min
d'inactivité, 100 sessions max
- src/lib/targets.ts : resolveTarget / resolvePlacement extraits et partagés
- writeChunkAt / createEmptyFile / finalizeFile / removeFile dans storage.ts
- limites, allowDirectories, jokers MIME et confinement dans la cible
s'appliquent aussi au chemin fragmenté
Page :
- bascule automatique en fragmenté au-delà de 32 Mo : 3 morceaux en parallèle,
3 réessais par morceau, progression et annulation agrégées
- makeReporter partagé (débit + ETA) ; job.cancel unifié (simple / fragmenté /
en file d'attente)
Tests : +tests/upload-chunked.test.ts (9), +writeChunkAt (storage) ; 73 tests.
Vérifié en live : 40 Mio -> 3 morceaux // -> octets exacts, DELETE nettoie.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
143 lines
4.7 KiB
TypeScript
143 lines
4.7 KiB
TypeScript
import { readFile, readdir } from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
import { randomBytes } from 'node:crypto';
|
||
import { Readable } from 'node:stream';
|
||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||
|
||
import {
|
||
createEmptyFile,
|
||
finalizeFile,
|
||
removeFile,
|
||
saveFile,
|
||
writeChunkAt,
|
||
} 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']);
|
||
});
|
||
});
|
||
|
||
describe('writeChunkAt — réassemblage de morceaux', () => {
|
||
it('écrit des morceaux à des offsets disjoints puis finalise, octets exacts', async () => {
|
||
const chunkSize = 64 * 1024;
|
||
const data = randomBytes(chunkSize * 2 + 777);
|
||
const partPath = path.join(uploadDir, 'sub', 'video.bin.part');
|
||
await createEmptyFile(partPath);
|
||
|
||
// Dans le désordre pour vérifier l'indépendance des écritures positionnées.
|
||
for (const index of [2, 0, 1]) {
|
||
const slice = data.subarray(index * chunkSize, (index + 1) * chunkSize);
|
||
const written = await writeChunkAt({
|
||
data: slice,
|
||
filePath: partPath,
|
||
offset: index * chunkSize,
|
||
});
|
||
expect(written).toBe(slice.length);
|
||
}
|
||
|
||
const finalPath = path.join(uploadDir, 'sub', 'video.bin');
|
||
await finalizeFile(partPath, finalPath);
|
||
|
||
expect(await readFile(finalPath)).toEqual(data);
|
||
expect(await readdir(path.join(uploadDir, 'sub'))).toEqual(['video.bin']);
|
||
});
|
||
|
||
it('removeFile ne lève pas si le fichier est absent', async () => {
|
||
await expect(removeFile(path.join(uploadDir, 'absent'))).resolves.toBeUndefined();
|
||
});
|
||
});
|