fix(logs): nom du fichier déposé dans les logs et les webhooks d'erreur #16
5 changed files with 47 additions and 8 deletions
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,22 @@ export interface SaveFileParams {
|
|||
placement?: FilePlacement;
|
||||
}
|
||||
|
||||
/** Suffixe du fichier partiel d'un upload fragmenté (`<fichier>.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<T extends Error>(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;
|
||||
|
|
|
|||
|
|
@ -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<InitResponse> => {
|
||||
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<ChunkAck> => {
|
||||
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<CompleteResponse> => {
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue