fix(server): destination des uploads fragmentés et gestion des erreurs #8
13 changed files with 183 additions and 31 deletions
|
|
@ -43,7 +43,12 @@ export async function buildApp(
|
|||
});
|
||||
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -385,7 +385,7 @@ function parseTargets(value: unknown, defaultMimePolicy: MimePolicy): DropTarget
|
|||
allowedMimeTypes:
|
||||
raw.allowedMimeTypes === undefined || raw.allowedMimeTypes === null
|
||||
? defaultMimePolicy
|
||||
: parseMimePolicy(raw.allowedMimeTypes, `targets[${String(index)}].allowedMimeTypes`)
|
||||
: parseMimePolicy(raw.allowedMimeTypes, `targets[${String(index)}].allowedMimeTypes`),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { FastifyError, FastifyInstance, FastifyRequest } from 'fastify';
|
||||
|
||||
import { AppError } from './errors.js';
|
||||
import { AppError, ClientAbortedError } from './errors.js';
|
||||
import { getSessionUser } from './session.js';
|
||||
|
||||
interface ErrorBody {
|
||||
|
|
@ -74,6 +74,12 @@ export function registerErrorHandler(app: FastifyInstance): void {
|
|||
const { status, body } = resolveError(error, request);
|
||||
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);
|
||||
if (status >= 400 && isUploadPath(path)) {
|
||||
app.notifier.uploadFailed({
|
||||
|
|
|
|||
|
|
@ -63,3 +63,17 @@ export class TooManyUploadsError extends AppError {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ async function main(): Promise<void> {
|
|||
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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,14 @@ import { randomUUID } from 'node:crypto';
|
|||
import { Transform, type Readable } from 'node:stream';
|
||||
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';
|
||||
|
||||
/** Métadonnées d'un fichier enregistré sur le disque. */
|
||||
|
|
@ -39,7 +46,6 @@ export interface SaveFileParams {
|
|||
* à la racine de `targetDir`.
|
||||
*/
|
||||
placement?: FilePlacement;
|
||||
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
function normalizeStreamError(error: unknown): Error {
|
||||
if (error instanceof PayloadTooLargeError) {
|
||||
const CLIENT_ABORT_CODES: ReadonlySet<string> = new Set([
|
||||
'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;
|
||||
}
|
||||
const code =
|
||||
|
|
@ -83,6 +101,12 @@ function normalizeStreamError(error: unknown): Error {
|
|||
if (code === 'FST_REQ_FILE_TOO_LARGE') {
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
@ -219,6 +243,10 @@ export async function writeChunkAt(params: {
|
|||
}),
|
||||
);
|
||||
} 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);
|
||||
}
|
||||
return written;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ export type NewUploadSession = Omit<UploadSession, 'id' | 'received' | 'lastActi
|
|||
|
||||
const IDLE_TTL_MS = 30 * 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 {
|
||||
create(input: NewUploadSession): UploadSession;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
writeChunkAt,
|
||||
} from '../lib/storage.js';
|
||||
import { resolvePlacement, resolveTarget } from '../lib/targets.js';
|
||||
import type { UploadSession } from '../lib/upload-sessions.js';
|
||||
import { getSessionUser, requireSession } from '../session.js';
|
||||
|
||||
const MIN_CHUNK = 1024 * 1024;
|
||||
|
|
@ -29,6 +30,11 @@ interface InitBody {
|
|||
chunkSize?: unknown;
|
||||
}
|
||||
|
||||
interface InitQuery {
|
||||
target?: unknown;
|
||||
path?: unknown;
|
||||
}
|
||||
|
||||
interface InitResponse {
|
||||
id: string;
|
||||
chunkSize: number;
|
||||
|
|
@ -80,8 +86,11 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
|
|||
{ preHandler: requireSession },
|
||||
async (request, reply): Promise<InitResponse> => {
|
||||
const body = (request.body ?? {}) as InitBody;
|
||||
const target = resolveTarget(cfg.targets, body.target);
|
||||
const placement = resolvePlacement(target, body.path);
|
||||
const query = (request.query ?? {}) as InitQuery;
|
||||
// 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 totalSize = positiveInt(body.size, 'size');
|
||||
|
|
@ -105,7 +114,9 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
|
|||
const partPath = destination + PART_SUFFIX;
|
||||
await createEmptyFile(partPath);
|
||||
|
||||
const session = app.uploadSessions.create({
|
||||
let session: UploadSession;
|
||||
try {
|
||||
session = app.uploadSessions.create({
|
||||
target,
|
||||
originalName,
|
||||
storedName,
|
||||
|
|
@ -116,6 +127,11 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
|
|||
chunkSize,
|
||||
chunkCount,
|
||||
});
|
||||
} catch (error) {
|
||||
// Trop de sessions : ne pas laisser de fichier partiel orphelin.
|
||||
await removeFile(partPath);
|
||||
throw error;
|
||||
}
|
||||
|
||||
reply.status(201);
|
||||
return { id: session.id, chunkSize, chunkCount };
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => {
|
|||
mimeType: part.mimetype,
|
||||
targetDir: target.dir,
|
||||
maxFileSizeBytes: app.appConfig.maxFileSizeBytes,
|
||||
...(placement !== undefined ? { placement } : {})
|
||||
...(placement !== undefined ? { placement } : {}),
|
||||
});
|
||||
|
||||
app.notifier.uploadCompleted({
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ describe('parseConfig', () => {
|
|||
publicUrl: 'http://localhost:3000',
|
||||
maxFileSizeBytes: 512 * 1024 * 1024,
|
||||
logLevel: 'info',
|
||||
chunkPoolSize: 30
|
||||
chunkPoolSize: 30,
|
||||
});
|
||||
expect(config.targets).toEqual([
|
||||
{
|
||||
|
|
@ -39,7 +39,7 @@ describe('parseConfig', () => {
|
|||
label: 'photos',
|
||||
dir: './uploads/photos',
|
||||
allowDirectories: false,
|
||||
allowedMimeTypes: DEFAULT_ALLOWED_MIME
|
||||
allowedMimeTypes: DEFAULT_ALLOWED_MIME,
|
||||
},
|
||||
]);
|
||||
expect(config.targets[0]?.allowedMimeTypes).toBe(DEFAULT_ALLOWED_MIME);
|
||||
|
|
@ -96,7 +96,7 @@ describe('parseConfig', () => {
|
|||
label: 'X',
|
||||
dir: './x',
|
||||
allowDirectories: false,
|
||||
allowedMimeTypes: DEFAULT_ALLOWED_MIME
|
||||
allowedMimeTypes: DEFAULT_ALLOWED_MIME,
|
||||
});
|
||||
expect(
|
||||
parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowDirectories: true }] })
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export function makeConfig(overrides: ConfigOverrides = {}): AppConfig {
|
|||
label: target.label ?? target.name,
|
||||
dir: target.dir,
|
||||
allowDirectories: target.allowDirectories ?? false,
|
||||
allowedMimeTypes: target.allowedMimeTypes ?? allowedMimeTypes
|
||||
allowedMimeTypes: target.allowedMimeTypes ?? allowedMimeTypes,
|
||||
}));
|
||||
|
||||
return {
|
||||
|
|
|
|||
45
tests/stream-errors.test.ts
Normal file
45
tests/stream-errors.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 { randomBytes } from 'node:crypto';
|
||||
|
||||
|
|
@ -142,6 +142,38 @@ describe('upload fragmenté', () => {
|
|||
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 () => {
|
||||
expect((await init({ size: 9 * MIB })).status).toBe(413);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue