From f7a43a5a0c1056a7546109fa293bc4adeba53765 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 14 Oct 2026 07:58:04 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(logs):=20afficher=20le=20nom=20du=20fic?= =?UTF-8?q?hier=20d=C3=A9pos=C3=A9=20plut=C3=B4t=20que=20celui=20du=20.par?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - les logs de requêtes d'upload fragmenté portent un champ `file` - le suffixe .mediadrop-part est retiré des messages/piles d'erreur loggés - le log de purge des sessions expirées indique aussi le fichier Co-Authored-By: Claude Sonnet 5.5 --- src/app.ts | 4 ++-- src/error-handler.ts | 5 +++-- src/lib/storage.ts | 16 ++++++++++++++++ src/routes/upload-chunked.ts | 14 +++++++++++--- tests/stream-errors.test.ts | 16 +++++++++++++++- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/app.ts b/src/app.ts index 8b0a54f..2dfa1b2 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", ); }); diff --git a/src/error-handler.ts b/src/error-handler.ts index 29e25f9..6523098 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; } 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..162b4b5 100644 --- a/src/routes/upload-chunked.ts +++ b/src/routes/upload-chunked.ts @@ -1,13 +1,14 @@ import { stat } from 'node:fs/promises'; import type { Readable } from 'node:stream'; -import type { FastifyPluginCallback, FastifyReply } from 'fastify'; +import type { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify'; import { PayloadTooLargeError, ValidationError } from '../errors.js'; import { isAllowedMedia } from '../lib/media-types.js'; import { createEmptyFile, finalizeFile, + PART_SUFFIX, removeFile, resolveDestination, writeChunkAt, @@ -19,7 +20,11 @@ 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'; + +/** Les logs de la requête (dont l'erreur éventuelle) portent le nom du fichier déposé. */ +function bindFileToLog(request: FastifyRequest, file: string): void { + request.log = request.log.child({ file }); +} interface InitBody { target?: unknown; @@ -87,6 +92,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'); + bindFileToLog(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 +111,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 +150,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); + bindFileToLog(request, session.storedName); const chunkIndex = Number(index); if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= session.chunkCount) { @@ -168,6 +175,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); + bindFileToLog(request, session.storedName); if (session.received.size !== session.chunkCount) { throw new ValidationError('Upload incomplet : des morceaux manquent'); 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'); From bdae13ba951988225ff889fcfffe1ac240c42d84 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 14 Oct 2026 08:00:07 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(webhooks):=20nom=20du=20fichier=20dans?= =?UTF-8?q?=20la=20notification=20d'=C3=A9chec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nouveau champ `file` (et résumé lisible) dans l'événement `error` - le nom est associé à la requête (bindUploadFile) pour les uploads directs et fragmentés, jamais celui du .part Co-Authored-By: Claude Sonnet 5.5 --- src/app.ts | 2 ++ src/error-handler.ts | 1 + src/fastify.d.ts | 7 +++++++ src/lib/notifications.ts | 6 +++++- src/lib/request-file.ts | 10 ++++++++++ src/routes/upload-chunked.ts | 14 +++++--------- src/routes/upload.ts | 2 ++ tests/webhooks-integration.test.ts | 30 ++++++++++++++++++++++++++++++ 8 files changed, 62 insertions(+), 10 deletions(-) create mode 100644 src/lib/request-file.ts diff --git a/src/app.ts b/src/app.ts index 2dfa1b2..1faee0d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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 6523098..bca4099 100644 --- a/src/error-handler.ts +++ b/src/error-handler.ts @@ -89,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/routes/upload-chunked.ts b/src/routes/upload-chunked.ts index 162b4b5..466eb04 100644 --- a/src/routes/upload-chunked.ts +++ b/src/routes/upload-chunked.ts @@ -1,7 +1,7 @@ import { stat } from 'node:fs/promises'; import type { Readable } from 'node:stream'; -import type { FastifyPluginCallback, FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyPluginCallback, FastifyReply } from 'fastify'; import { PayloadTooLargeError, ValidationError } from '../errors.js'; import { isAllowedMedia } from '../lib/media-types.js'; @@ -13,6 +13,7 @@ import { 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'; @@ -21,11 +22,6 @@ const MIN_CHUNK = 1024 * 1024; const MAX_CHUNK = 32 * 1024 * 1024; const DEFAULT_CHUNK = 16 * 1024 * 1024; -/** Les logs de la requête (dont l'erreur éventuelle) portent le nom du fichier déposé. */ -function bindFileToLog(request: FastifyRequest, file: string): void { - request.log = request.log.child({ file }); -} - interface InitBody { target?: unknown; path?: unknown; @@ -93,7 +89,7 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done) const body = (request.body ?? {}) as InitBody; const query = (request.query ?? {}) as InitQuery; const originalName = nonEmptyString(body.name, 'fichier'); - bindFileToLog(request, originalName); + 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); @@ -150,7 +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); - bindFileToLog(request, session.storedName); + bindUploadFile(request, session.storedName); const chunkIndex = Number(index); if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= session.chunkCount) { @@ -175,7 +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); - bindFileToLog(request, session.storedName); + 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/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`, {