fix(logs): nom du fichier déposé dans les logs et les webhooks d'erreur #16

Merged
kyuno merged 2 commits from fix/log-file-name into main 2026-10-14 08:54:59 +02:00
8 changed files with 62 additions and 10 deletions
Showing only changes of commit bdae13ba95 - Show all commits

View file

@ -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);

View file

@ -89,6 +89,7 @@ export function registerErrorHandler(app: FastifyInstance): void {
method: request.method,
path,
target: requestedTarget(request),
file: request.uploadFile,
user: getSessionUser(request)?.sub,
});
}

7
src/fastify.d.ts vendored
View file

@ -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;

View file

@ -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<string, unknown> {
@ -101,6 +104,7 @@ function errorBody(input: UploadFailedInput): Record<string, unknown> {
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,

10
src/lib/request-file.ts Normal file
View file

@ -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 });
}

View file

@ -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<ChunkAck> => {
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<CompleteResponse> => {
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');

View file

@ -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} »`);

View file

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