Merge pull request 'fix(server): destination des uploads fragmentés et gestion des erreurs' (#8) from fix/server-upload-errors into main
This commit is contained in:
commit
f4bd82f9ad
13 changed files with 183 additions and 31 deletions
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,7 +114,9 @@ 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;
|
||||||
|
try {
|
||||||
|
session = app.uploadSessions.create({
|
||||||
target,
|
target,
|
||||||
originalName,
|
originalName,
|
||||||
storedName,
|
storedName,
|
||||||
|
|
@ -116,6 +127,11 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
|
||||||
chunkSize,
|
chunkSize,
|
||||||
chunkCount,
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 }] })
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
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 { 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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue