From f7a43a5a0c1056a7546109fa293bc4adeba53765 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Wed, 14 Oct 2026 07:58:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(logs):=20afficher=20le=20nom=20du=20fichier?= =?UTF-8?q?=20d=C3=A9pos=C3=A9=20plut=C3=B4t=20que=20celui=20du=20.part?= 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');