Serveur TypeScript (Fastify 5) permettant de déposer des fichiers médias
dans des dossiers nommés, via une page web de glisser-déposer.
- Authentification OpenID Connect gérée côté serveur (Authorization Code +
PKCE, client confidentiel, openid-client) ; session via cookie chiffré
(@fastify/secure-session).
- Configuration dans un fichier JSON (schéma fourni) ; l'environnement ne
porte que le pointeur MEDIADROP_CONFIG.
- Cibles de dépôt nommées : liste { name, label, dir }, sélection par
?target=, première cible par défaut.
- Upload multipart en streaming : nom de fichier dérivé côté serveur (pas
de path traversal), liste blanche de types MIME, plafond de taille.
- TypeScript strict, ESLint + Prettier sans warning, 46 tests Vitest.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
73 lines
2.2 KiB
TypeScript
73 lines
2.2 KiB
TypeScript
import { readFile, readdir } from 'node:fs/promises';
|
|
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);
|
|
});
|
|
});
|