diff --git a/src/app.ts b/src/app.ts index 8b0a54f..1faee0d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,7 +5,7 @@ import secureSession from '@fastify/secure-session'; import type { AppConfig } from './config.js'; import { registerErrorHandler } from './error-handler.js'; import { createNotifier } from './lib/notifications.js'; -import { ensureUploadDir, removeFile } from './lib/storage.js'; +import { ensureUploadDir, hidePartSuffix, removeFile } from './lib/storage.js'; import { createUploadSessionStore } from './lib/upload-sessions.js'; import { createOidcClient, type OidcClient } from './oidc.js'; import { authRoutes } from './routes/auth.js'; @@ -46,7 +46,7 @@ export async function buildApp( const uploadSessions = createUploadSessionStore((session) => { removeFile(session.partPath).catch((error: unknown) => { app.log.warn( - { err: error }, + { err: error instanceof Error ? hidePartSuffix(error) : error, file: session.storedName }, "Impossible de supprimer le fichier partiel d'une session expirée", ); }); @@ -62,6 +62,8 @@ export async function buildApp( await notifier.close(); }); + app.decorateRequest('uploadFile', undefined); + const refreshSession = createSessionRefresher(config.session.maxAgeSeconds); app.addHook('preHandler', (request, _reply, hookDone) => { refreshSession(request); diff --git a/src/error-handler.ts b/src/error-handler.ts index 29e25f9..bca4099 100644 --- a/src/error-handler.ts +++ b/src/error-handler.ts @@ -1,6 +1,7 @@ import type { FastifyError, FastifyInstance, FastifyRequest } from 'fastify'; import { AppError, ClientAbortedError } from './errors.js'; +import { hidePartSuffix } from './lib/storage.js'; import { getSessionUser } from './session.js'; interface ErrorBody { @@ -40,7 +41,7 @@ function resolveError( return { status: 400, body: { error: 'ValidationError', message: error.message } }; } - request.log.error({ err: error }, 'Erreur non gérée'); + request.log.error({ err: hidePartSuffix(error) }, 'Erreur non gérée'); return { status: 500, body: { error: 'InternalServerError', message: 'Erreur interne' } }; } @@ -76,7 +77,7 @@ export function registerErrorHandler(app: FastifyInstance): void { // 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'); + request.log.debug({ err: hidePartSuffix(error) }, 'Requête interrompue par le client'); return; } @@ -88,6 +89,7 @@ export function registerErrorHandler(app: FastifyInstance): void { method: request.method, path, target: requestedTarget(request), + file: request.uploadFile, user: getSessionUser(request)?.sub, }); } diff --git a/src/fastify.d.ts b/src/fastify.d.ts index a0b9df7..b2ec6d6 100644 --- a/src/fastify.d.ts +++ b/src/fastify.d.ts @@ -17,6 +17,13 @@ declare module 'fastify' { } } +declare module 'fastify' { + interface FastifyRequest { + /** Nom du fichier concerné par la requête d'upload, une fois connu (voir `bindUploadFile`). */ + uploadFile: string | undefined; + } +} + declare module '@fastify/secure-session' { interface SessionData { user?: SessionUser; diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 5473b03..837af9e 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -29,6 +29,8 @@ export interface UploadFailedInput { method: string; path: string; target?: string; + /** Nom du fichier concerné (nom déposé, jamais celui du `.part`), s'il est connu. */ + file?: string; user?: string; } @@ -70,7 +72,8 @@ function uploadSummary(input: UploadCompletedInput): string { } function errorSummary(input: UploadFailedInput): string { - return `⚠️ Échec d'upload (${String(input.statusCode)}) : ${input.message} [${input.method} ${input.path}]`; + const file = input.file !== undefined ? ` — fichier « ${input.file} »` : ''; + return `⚠️ Échec d'upload (${String(input.statusCode)}) : ${input.message}${file} [${input.method} ${input.path}]`; } function uploadBody(input: UploadCompletedInput): Record { @@ -101,6 +104,7 @@ function errorBody(input: UploadFailedInput): Record { method: input.method, path: input.path, ...(input.target !== undefined ? { target: input.target } : {}), + ...(input.file !== undefined ? { file: input.file } : {}), ...(input.user !== undefined ? { user: input.user } : {}), text: summary, content: summary, diff --git a/src/lib/request-file.ts b/src/lib/request-file.ts new file mode 100644 index 0000000..e5737cb --- /dev/null +++ b/src/lib/request-file.ts @@ -0,0 +1,10 @@ +import type { FastifyRequest } from 'fastify'; + +/** + * Associe le nom du fichier déposé à la requête : il est ajouté à tous ses logs + * (`file`) et repris dans la notification webhook d'échec. + */ +export function bindUploadFile(request: FastifyRequest, file: string): void { + request.uploadFile = file; + request.log = request.log.child({ file }); +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index a39e43a..6e8be5f 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -48,6 +48,22 @@ export interface SaveFileParams { placement?: FilePlacement; } +/** Suffixe du fichier partiel d'un upload fragmenté (`.mediadrop-part`). */ +export const PART_SUFFIX = '.mediadrop-part'; + +/** + * Retire le suffixe du fichier partiel du message et de la pile d'une erreur (les erreurs + * du système de fichiers citent le chemin du `.part`) : les logs affichent le nom du + * fichier déposé. Modifie et renvoie l'erreur reçue. + */ +export function hidePartSuffix(error: T): T { + error.message = error.message.replaceAll(PART_SUFFIX, ''); + if (error.stack !== undefined) { + error.stack = error.stack.replaceAll(PART_SUFFIX, ''); + } + return error; +} + const MAX_BASE_LENGTH = 60; /** Tampon d'écriture large : meilleur débit sur les très gros fichiers. */ const WRITE_HIGH_WATER_MARK = 1 << 20; diff --git a/src/routes/upload-chunked.ts b/src/routes/upload-chunked.ts index f1228b4..466eb04 100644 --- a/src/routes/upload-chunked.ts +++ b/src/routes/upload-chunked.ts @@ -8,10 +8,12 @@ import { isAllowedMedia } from '../lib/media-types.js'; import { createEmptyFile, finalizeFile, + PART_SUFFIX, removeFile, resolveDestination, writeChunkAt, } from '../lib/storage.js'; +import { bindUploadFile } from '../lib/request-file.js'; import { resolvePlacement, resolveTarget } from '../lib/targets.js'; import type { UploadSession } from '../lib/upload-sessions.js'; import { getSessionUser, requireSession } from '../session.js'; @@ -19,7 +21,6 @@ import { getSessionUser, requireSession } from '../session.js'; const MIN_CHUNK = 1024 * 1024; const MAX_CHUNK = 32 * 1024 * 1024; const DEFAULT_CHUNK = 16 * 1024 * 1024; -const PART_SUFFIX = '.mediadrop-part'; interface InitBody { target?: unknown; @@ -87,6 +88,8 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done) async (request, reply): Promise => { const body = (request.body ?? {}) as InitBody; const query = (request.query ?? {}) as InitQuery; + const originalName = nonEmptyString(body.name, 'fichier'); + bindUploadFile(request, originalName); // 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); @@ -104,7 +107,6 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done) const chunkSize = clampChunkSize(body.chunkSize); const chunkCount = Math.ceil(totalSize / chunkSize); - const originalName = nonEmptyString(body.name, 'fichier'); const { storedName, destination } = await resolveDestination({ targetDir: target.dir, originalName, @@ -144,6 +146,7 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done) async (request): Promise => { const { id, index } = request.params as { id: string; index: string }; const session = app.uploadSessions.use(id); + bindUploadFile(request, session.storedName); const chunkIndex = Number(index); if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= session.chunkCount) { @@ -168,6 +171,7 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done) async (request, reply): Promise => { const { id } = request.params as { id: string }; const session = app.uploadSessions.use(id); + bindUploadFile(request, session.storedName); if (session.received.size !== session.chunkCount) { throw new ValidationError('Upload incomplet : des morceaux manquent'); diff --git a/src/routes/upload.ts b/src/routes/upload.ts index 3d8c182..ac80e0d 100644 --- a/src/routes/upload.ts +++ b/src/routes/upload.ts @@ -3,6 +3,7 @@ import type { FastifyPluginCallback } from 'fastify'; import type { DropTarget } from '../config.js'; import { ValidationError } from '../errors.js'; import { isAllowedMedia, type MimePolicy } from '../lib/media-types.js'; +import { bindUploadFile } from '../lib/request-file.js'; import { resolvePlacement, resolveTarget } from '../lib/targets.js'; import { getSessionUser, requireSession } from '../session.js'; import { saveFile, type StoredFile } from '../lib/storage.js'; @@ -78,6 +79,7 @@ export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => { if (!part) { throw new ValidationError(`Aucun fichier reçu dans le champ « ${FIELD_NAME} »`); } + bindUploadFile(request, part.filename); if (part.fieldname !== FIELD_NAME) { part.file.resume(); throw new ValidationError(`Le fichier doit être envoyé dans le champ « ${FIELD_NAME} »`); diff --git a/tests/stream-errors.test.ts b/tests/stream-errors.test.ts index 386a827..63ffe19 100644 --- a/tests/stream-errors.test.ts +++ b/tests/stream-errors.test.ts @@ -6,12 +6,26 @@ import { PayloadTooLargeError, ValidationError, } from '../src/errors.js'; -import { normalizeStreamError } from '../src/lib/storage.js'; +import { hidePartSuffix, normalizeStreamError } from '../src/lib/storage.js'; function withCode(code: string): Error { return Object.assign(new Error('boom'), { code }); } +describe('hidePartSuffix', () => { + it('retire le suffixe .mediadrop-part du message et de la pile', () => { + const error = new Error("ENOENT: open '/data/videos/clip.mp4.mediadrop-part'"); + const result = hidePartSuffix(error); + expect(result).toBe(error); + expect(result.message).toBe("ENOENT: open '/data/videos/clip.mp4'"); + expect(result.stack).not.toContain('.mediadrop-part'); + }); + + it('laisse intact un message sans suffixe', () => { + expect(hidePartSuffix(new Error('autre')).message).toBe('autre'); + }); +}); + describe('normalizeStreamError', () => { it('conserve les AppError telles quelles', () => { const error = new ValidationError('x'); diff --git a/tests/webhooks-integration.test.ts b/tests/webhooks-integration.test.ts index 5969856..19851b6 100644 --- a/tests/webhooks-integration.test.ts +++ b/tests/webhooks-integration.test.ts @@ -155,9 +155,39 @@ describe('webhooks — intégration', () => { method: 'POST', path: '/upload', target: 'videos', + file: 'x.zip', }); }); + it('`error` d’un upload fragmenté : nom du fichier déposé, jamais celui du .part', async () => { + const init = await fetch(`${baseUrl}/upload/init`, { + method: 'POST', + headers: { 'content-type': 'application/json', cookie }, + body: JSON.stringify({ + target: 'videos', + path: 'a/clip.mp4', + name: 'clip.mp4', + size: MIB, + type: 'video/mp4', + chunkSize: MIB, + }), + }); + const { id } = (await init.json()) as { id: string }; + const res = await fetch(`${baseUrl}/upload/${id}/9`, { + method: 'PUT', + headers: { 'content-type': 'application/octet-stream', cookie }, + body: new Uint8Array(10), + }); + expect(res.status).toBe(400); + + await app.close(); + const errors = receiver.calls.filter((c) => c.event === 'error'); + expect(errors).toHaveLength(1); + expect(errors[0]!.body).toMatchObject({ statusCode: 400, file: 'a/clip.mp4' }); + expect(JSON.stringify(errors[0]!.body)).not.toContain('.mediadrop-part'); + expect(String(errors[0]!.body.text)).toContain('a/clip.mp4'); + }); + it('notifie `upload` (via: chunked) après finalisation fragmentée', async () => { const data = randomBytes(200 * 1024); const init = await fetch(`${baseUrl}/upload/init`, {