fix(server): destination des uploads fragmentés et gestion des erreurs #8

Merged
kyuno merged 1 commit from fix/server-upload-errors into main 2026-10-13 20:32:35 +02:00
13 changed files with 183 additions and 31 deletions

View file

@ -43,7 +43,12 @@ export async function buildApp(
}); });
const uploadSessions = createUploadSessionStore((session) => { const uploadSessions = createUploadSessionStore((session) => {
void removeFile(session.partPath); removeFile(session.partPath).catch((error: unknown) => {
app.log.warn(
{ err: error },
"Impossible de supprimer le fichier partiel d'une session expirée",
);
});
}); });
const notifier = createNotifier(config.webhooks, app.log); const notifier = createNotifier(config.webhooks, app.log);

View file

@ -204,7 +204,7 @@ function parseMimePolicy(value: unknown, label = 'allowedMimeTypes'): MimePolicy
if (!valid) { if (!valid) {
throw new ConfigError( throw new ConfigError(
`${label} : entrée invalide « ${entry} » ` + `${label} : entrée invalide « ${entry} » ` +
'(formes acceptées : "type/sous-type", "type/*", "*/*", "*")', '(formes acceptées : "type/sous-type", "type/*", "*/*", "*")',
); );
} }
} }
@ -231,7 +231,7 @@ function parseSessionKey(value: unknown): Buffer {
if (key.length !== SESSION_KEY_BYTES) { if (key.length !== SESSION_KEY_BYTES) {
throw new ConfigError( throw new ConfigError(
`session.key doit être ${String(SESSION_KEY_BYTES)} octets encodés en base64 ` + `session.key doit être ${String(SESSION_KEY_BYTES)} octets encodés en base64 ` +
`(décodé : ${String(key.length)} octets). Générer avec : openssl rand -base64 32`, `(décodé : ${String(key.length)} octets). Générer avec : openssl rand -base64 32`,
); );
} }
return key; return key;
@ -257,7 +257,7 @@ function parseSession(value: unknown): SessionConfig {
key: parseSessionKey(raw.key), key: parseSessionKey(raw.key),
maxAgeSeconds: Math.round( maxAgeSeconds: Math.round(
optionalPositiveNumber(raw.maxAgeHours, DEFAULTS.sessionMaxAgeHours, 'session.maxAgeHours') * optionalPositiveNumber(raw.maxAgeHours, DEFAULTS.sessionMaxAgeHours, 'session.maxAgeHours') *
3600, 3600,
), ),
}; };
} }
@ -385,7 +385,7 @@ function parseTargets(value: unknown, defaultMimePolicy: MimePolicy): DropTarget
allowedMimeTypes: allowedMimeTypes:
raw.allowedMimeTypes === undefined || raw.allowedMimeTypes === null raw.allowedMimeTypes === undefined || raw.allowedMimeTypes === null
? defaultMimePolicy ? defaultMimePolicy
: parseMimePolicy(raw.allowedMimeTypes, `targets[${String(index)}].allowedMimeTypes`) : parseMimePolicy(raw.allowedMimeTypes, `targets[${String(index)}].allowedMimeTypes`),
}; };
}; };

View file

@ -1,6 +1,6 @@
import type { FastifyError, FastifyInstance, FastifyRequest } from 'fastify'; import type { FastifyError, FastifyInstance, FastifyRequest } from 'fastify';
import { AppError } from './errors.js'; import { AppError, ClientAbortedError } from './errors.js';
import { getSessionUser } from './session.js'; import { getSessionUser } from './session.js';
interface ErrorBody { interface ErrorBody {
@ -74,6 +74,12 @@ export function registerErrorHandler(app: FastifyInstance): void {
const { status, body } = resolveError(error, request); const { status, body } = resolveError(error, request);
reply.status(status).send(body); reply.status(status).send(body);
// Annulation/pause côté client : comportement normal, pas un échec à signaler.
if (error instanceof ClientAbortedError) {
request.log.debug({ err: error }, 'Requête interrompue par le client');
return;
}
const path = pathOf(request); const path = pathOf(request);
if (status >= 400 && isUploadPath(path)) { if (status >= 400 && isUploadPath(path)) {
app.notifier.uploadFailed({ app.notifier.uploadFailed({

View file

@ -63,3 +63,17 @@ export class TooManyUploadsError extends AppError {
super(message, 429); super(message, 429);
} }
} }
/** Le client a interrompu la requête (annulation, pause, coupure réseau) avant la fin du corps. */
export class ClientAbortedError extends AppError {
public constructor(message = 'Requête interrompue par le client') {
super(message, 400);
}
}
/** Plus d'espace disque disponible sur la cible de dépôt. */
export class InsufficientStorageError extends AppError {
public constructor(message = 'Espace de stockage insuffisant sur le serveur') {
super(message, 507);
}
}

View file

@ -37,6 +37,11 @@ async function main(): Promise<void> {
void shutdown('SIGTERM'); void shutdown('SIGTERM');
}); });
// Filet de sécurité : une promesse rejetée sans handler ne doit pas passer inaperçue.
process.on('unhandledRejection', (reason: unknown) => {
app.log.error({ err: reason }, 'Promesse rejetée non gérée');
});
await app.listen({ port: config.port, host: config.host }); await app.listen({ port: config.port, host: config.host });
} }

View file

@ -5,7 +5,14 @@ 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, ValidationError } from '../errors.js'; import {
AppError,
ClientAbortedError,
InsufficientStorageError,
PayloadTooLargeError,
UploadSessionNotFoundError,
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. */
@ -39,7 +46,6 @@ export interface SaveFileParams {
* à la racine de `targetDir`. * à la racine de `targetDir`.
*/ */
placement?: FilePlacement; placement?: FilePlacement;
} }
const MAX_BASE_LENGTH = 60; const MAX_BASE_LENGTH = 60;
@ -74,8 +80,20 @@ export function buildStoredName(originalName: string, mimeType: string): string
return `${sanitizeBase(originalName)}-${Date.now()}-${randomUUID().slice(0, 8)}${ext}`; return `${sanitizeBase(originalName)}-${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
} }
function normalizeStreamError(error: unknown): Error { const CLIENT_ABORT_CODES: ReadonlySet<string> = new Set([
if (error instanceof PayloadTooLargeError) { 'ECONNRESET',
'ECONNABORTED',
'ERR_STREAM_PREMATURE_CLOSE',
'FST_ERR_CTP_INVALID_CONTENT_LENGTH',
]);
/**
* Convertit une erreur de flux/disque en `AppError` explicite : dépassement de
* taille (413), requête interrompue par le client (400), disque plein (507).
* Le reste est renvoyé tel quel.
*/
export function normalizeStreamError(error: unknown): Error {
if (error instanceof AppError) {
return error; return error;
} }
const code = const code =
@ -83,6 +101,12 @@ function normalizeStreamError(error: unknown): Error {
if (code === 'FST_REQ_FILE_TOO_LARGE') { if (code === 'FST_REQ_FILE_TOO_LARGE') {
return new PayloadTooLargeError(); return new PayloadTooLargeError();
} }
if (typeof code === 'string' && CLIENT_ABORT_CODES.has(code)) {
return new ClientAbortedError();
}
if (code === 'ENOSPC') {
return new InsufficientStorageError();
}
return error instanceof Error ? error : new Error("Échec de l'écriture du fichier"); return error instanceof Error ? error : new Error("Échec de l'écriture du fichier");
} }
@ -163,7 +187,7 @@ export async function saveFile(params: SaveFileParams): Promise<StoredFile> {
await pipeline( await pipeline(
source, source,
limiter, limiter,
createWriteStream(destination, { highWaterMark: WRITE_HIGH_WATER_MARK ,mode:0o777}), createWriteStream(destination, { highWaterMark: WRITE_HIGH_WATER_MARK, mode: 0o777 }),
); );
} catch (error) { } catch (error) {
await rm(destination, { force: true }); await rm(destination, { force: true });
@ -219,6 +243,10 @@ export async function writeChunkAt(params: {
}), }),
); );
} catch (error) { } catch (error) {
// Fichier partiel supprimé entre-temps (upload annulé ou session expirée).
if ((error as { code?: unknown }).code === 'ENOENT') {
throw new UploadSessionNotFoundError();
}
throw normalizeStreamError(error); throw normalizeStreamError(error);
} }
return written; return written;

View file

@ -25,7 +25,8 @@ export type NewUploadSession = Omit<UploadSession, 'id' | 'received' | 'lastActi
const IDLE_TTL_MS = 30 * 60_000; const IDLE_TTL_MS = 30 * 60_000;
const SWEEP_INTERVAL_MS = 60_000; const SWEEP_INTERVAL_MS = 60_000;
const MAX_SESSIONS = 100; /** Plafond global : plusieurs fenêtres/onglets peuvent déposer en parallèle (32 chacun côté client). */
const MAX_SESSIONS = 256;
export interface UploadSessionStore { export interface UploadSessionStore {
create(input: NewUploadSession): UploadSession; create(input: NewUploadSession): UploadSession;

View file

@ -13,6 +13,7 @@ import {
writeChunkAt, writeChunkAt,
} from '../lib/storage.js'; } from '../lib/storage.js';
import { resolvePlacement, resolveTarget } from '../lib/targets.js'; import { resolvePlacement, resolveTarget } from '../lib/targets.js';
import type { UploadSession } from '../lib/upload-sessions.js';
import { getSessionUser, requireSession } from '../session.js'; import { getSessionUser, requireSession } from '../session.js';
const MIN_CHUNK = 1024 * 1024; const MIN_CHUNK = 1024 * 1024;
@ -29,6 +30,11 @@ interface InitBody {
chunkSize?: unknown; chunkSize?: unknown;
} }
interface InitQuery {
target?: unknown;
path?: unknown;
}
interface InitResponse { interface InitResponse {
id: string; id: string;
chunkSize: number; chunkSize: number;
@ -80,8 +86,11 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
{ preHandler: requireSession }, { preHandler: requireSession },
async (request, reply): Promise<InitResponse> => { async (request, reply): Promise<InitResponse> => {
const body = (request.body ?? {}) as InitBody; const body = (request.body ?? {}) as InitBody;
const target = resolveTarget(cfg.targets, body.target); const query = (request.query ?? {}) as InitQuery;
const placement = resolvePlacement(target, body.path); // Le client web transmet `target` et `path` dans la query (comme `POST /upload`) ;
// le corps JSON reste accepté.
const target = resolveTarget(cfg.targets, query.target ?? body.target);
const placement = resolvePlacement(target, query.path ?? body.path);
const mimeType = nonEmptyString(body.type, 'application/octet-stream'); const mimeType = nonEmptyString(body.type, 'application/octet-stream');
const totalSize = positiveInt(body.size, 'size'); const totalSize = positiveInt(body.size, 'size');
@ -105,17 +114,24 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
const partPath = destination + PART_SUFFIX; const partPath = destination + PART_SUFFIX;
await createEmptyFile(partPath); await createEmptyFile(partPath);
const session = app.uploadSessions.create({ let session: UploadSession;
target, try {
originalName, session = app.uploadSessions.create({
storedName, target,
mimeType, originalName,
partPath, storedName,
finalPath: destination, mimeType,
totalSize, partPath,
chunkSize, finalPath: destination,
chunkCount, totalSize,
}); chunkSize,
chunkCount,
});
} catch (error) {
// Trop de sessions : ne pas laisser de fichier partiel orphelin.
await removeFile(partPath);
throw error;
}
reply.status(201); reply.status(201);
return { id: session.id, chunkSize, chunkCount }; return { id: session.id, chunkSize, chunkCount };

View file

@ -93,7 +93,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 } : {}) ...(placement !== undefined ? { placement } : {}),
}); });
app.notifier.uploadCompleted({ app.notifier.uploadCompleted({

View file

@ -31,7 +31,7 @@ describe('parseConfig', () => {
publicUrl: 'http://localhost:3000', publicUrl: 'http://localhost:3000',
maxFileSizeBytes: 512 * 1024 * 1024, maxFileSizeBytes: 512 * 1024 * 1024,
logLevel: 'info', logLevel: 'info',
chunkPoolSize: 30 chunkPoolSize: 30,
}); });
expect(config.targets).toEqual([ expect(config.targets).toEqual([
{ {
@ -39,7 +39,7 @@ describe('parseConfig', () => {
label: 'photos', label: 'photos',
dir: './uploads/photos', dir: './uploads/photos',
allowDirectories: false, allowDirectories: false,
allowedMimeTypes: DEFAULT_ALLOWED_MIME allowedMimeTypes: DEFAULT_ALLOWED_MIME,
}, },
]); ]);
expect(config.targets[0]?.allowedMimeTypes).toBe(DEFAULT_ALLOWED_MIME); expect(config.targets[0]?.allowedMimeTypes).toBe(DEFAULT_ALLOWED_MIME);
@ -96,7 +96,7 @@ describe('parseConfig', () => {
label: 'X', label: 'X',
dir: './x', dir: './x',
allowDirectories: false, allowDirectories: false,
allowedMimeTypes: DEFAULT_ALLOWED_MIME allowedMimeTypes: DEFAULT_ALLOWED_MIME,
}); });
expect( expect(
parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowDirectories: true }] }) parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowDirectories: true }] })

View file

@ -54,7 +54,7 @@ export function makeConfig(overrides: ConfigOverrides = {}): AppConfig {
label: target.label ?? target.name, label: target.label ?? target.name,
dir: target.dir, dir: target.dir,
allowDirectories: target.allowDirectories ?? false, allowDirectories: target.allowDirectories ?? false,
allowedMimeTypes: target.allowedMimeTypes ?? allowedMimeTypes allowedMimeTypes: target.allowedMimeTypes ?? allowedMimeTypes,
})); }));
return { return {

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import {
ClientAbortedError,
InsufficientStorageError,
PayloadTooLargeError,
ValidationError,
} from '../src/errors.js';
import { normalizeStreamError } from '../src/lib/storage.js';
function withCode(code: string): Error {
return Object.assign(new Error('boom'), { code });
}
describe('normalizeStreamError', () => {
it('conserve les AppError telles quelles', () => {
const error = new ValidationError('x');
expect(normalizeStreamError(error)).toBe(error);
});
it('convertit le dépassement multipart en 413', () => {
expect(normalizeStreamError(withCode('FST_REQ_FILE_TOO_LARGE'))).toBeInstanceOf(
PayloadTooLargeError,
);
});
it.each(['ECONNRESET', 'ECONNABORTED', 'ERR_STREAM_PREMATURE_CLOSE'])(
'convertit %s (client parti) en ClientAbortedError',
(code) => {
expect(normalizeStreamError(withCode(code))).toBeInstanceOf(ClientAbortedError);
},
);
it('convertit ENOSPC en InsufficientStorageError (507)', () => {
const result = normalizeStreamError(withCode('ENOSPC'));
expect(result).toBeInstanceOf(InsufficientStorageError);
expect((result as InsufficientStorageError).statusCode).toBe(507);
});
it('laisse passer les autres erreurs et enveloppe les valeurs non-Error', () => {
const other = withCode('EACCES');
expect(normalizeStreamError(other)).toBe(other);
expect(normalizeStreamError('texte')).toBeInstanceOf(Error);
});
});

View file

@ -1,4 +1,4 @@
import { readFile, readdir } from 'node:fs/promises'; import { readFile, readdir, rm } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
@ -142,6 +142,38 @@ describe('upload fragmenté', () => {
expect(await readdir(join(dir, 'a', 'b'))).toEqual(['clip.mp4']); expect(await readdir(join(dir, 'a', 'b'))).toEqual(['clip.mp4']);
}); });
it('init lit target et path dans la query (comme le client web)', async () => {
const data = randomBytes(1000);
const query = new URLSearchParams({ target: 'tree', path: 'dossier/clip.mp4' });
const res = await fetch(`${baseUrl}/upload/init?${query.toString()}`, {
method: 'POST',
headers: { 'content-type': 'application/json', cookie },
body: JSON.stringify({ name: 'clip.mp4', size: data.length, type: 'video/mp4' }),
});
expect(res.status).toBe(201);
const { id } = (await res.json()) as { id: string };
expect(await putChunk(id, 0, data)).toBe(200);
const done = await complete(id);
expect(done.body).toMatchObject({ target: 'tree', storedName: 'dossier/clip.mp4' });
expect(await readFile(join(dir, 'dossier', 'clip.mp4'))).toEqual(data);
});
it('init refuse une cible inconnue passée dans la query (400)', async () => {
const res = await fetch(`${baseUrl}/upload/init?target=inconnue`, {
method: 'POST',
headers: { 'content-type': 'application/json', cookie },
body: JSON.stringify({ name: 'clip.mp4', size: 3, type: 'video/mp4' }),
});
expect(res.status).toBe(400);
});
it('PUT renvoie 404 (et non 500) si le fichier partiel a disparu', async () => {
const { body } = await init({ size: MIB, chunkSize: MIB });
const [part] = await readdir(dir);
await rm(join(dir, part!));
expect(await putChunk(body.id!, 0, Buffer.alloc(10))).toBe(404);
});
it('init refuse une taille au-dessus de la limite (413)', async () => { it('init refuse une taille au-dessus de la limite (413)', async () => {
expect((await init({ size: 9 * MIB })).status).toBe(413); expect((await init({ size: 9 * MIB })).status).toBe(413);
}); });