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`, {