Merge pull request 'fix(logs): nom du fichier déposé dans les logs et les webhooks d'erreur' (#16) from fix/log-file-name into main
This commit is contained in:
commit
e0e8bb35b6
10 changed files with 99 additions and 8 deletions
|
|
@ -5,7 +5,7 @@ import secureSession from '@fastify/secure-session';
|
||||||
import type { AppConfig } from './config.js';
|
import type { AppConfig } from './config.js';
|
||||||
import { registerErrorHandler } from './error-handler.js';
|
import { registerErrorHandler } from './error-handler.js';
|
||||||
import { createNotifier } from './lib/notifications.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 { createUploadSessionStore } from './lib/upload-sessions.js';
|
||||||
import { createOidcClient, type OidcClient } from './oidc.js';
|
import { createOidcClient, type OidcClient } from './oidc.js';
|
||||||
import { authRoutes } from './routes/auth.js';
|
import { authRoutes } from './routes/auth.js';
|
||||||
|
|
@ -46,7 +46,7 @@ export async function buildApp(
|
||||||
const uploadSessions = createUploadSessionStore((session) => {
|
const uploadSessions = createUploadSessionStore((session) => {
|
||||||
removeFile(session.partPath).catch((error: unknown) => {
|
removeFile(session.partPath).catch((error: unknown) => {
|
||||||
app.log.warn(
|
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",
|
"Impossible de supprimer le fichier partiel d'une session expirée",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -62,6 +62,8 @@ export async function buildApp(
|
||||||
await notifier.close();
|
await notifier.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.decorateRequest('uploadFile', undefined);
|
||||||
|
|
||||||
const refreshSession = createSessionRefresher(config.session.maxAgeSeconds);
|
const refreshSession = createSessionRefresher(config.session.maxAgeSeconds);
|
||||||
app.addHook('preHandler', (request, _reply, hookDone) => {
|
app.addHook('preHandler', (request, _reply, hookDone) => {
|
||||||
refreshSession(request);
|
refreshSession(request);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { FastifyError, FastifyInstance, FastifyRequest } from 'fastify';
|
import type { FastifyError, FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
import { AppError, ClientAbortedError } from './errors.js';
|
import { AppError, ClientAbortedError } from './errors.js';
|
||||||
|
import { hidePartSuffix } from './lib/storage.js';
|
||||||
import { getSessionUser } from './session.js';
|
import { getSessionUser } from './session.js';
|
||||||
|
|
||||||
interface ErrorBody {
|
interface ErrorBody {
|
||||||
|
|
@ -40,7 +41,7 @@ function resolveError(
|
||||||
return { status: 400, body: { error: 'ValidationError', message: error.message } };
|
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' } };
|
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.
|
// Annulation/pause côté client : comportement normal, pas un échec à signaler.
|
||||||
if (error instanceof ClientAbortedError) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,6 +89,7 @@ export function registerErrorHandler(app: FastifyInstance): void {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
path,
|
path,
|
||||||
target: requestedTarget(request),
|
target: requestedTarget(request),
|
||||||
|
file: request.uploadFile,
|
||||||
user: getSessionUser(request)?.sub,
|
user: getSessionUser(request)?.sub,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
src/fastify.d.ts
vendored
7
src/fastify.d.ts
vendored
|
|
@ -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' {
|
declare module '@fastify/secure-session' {
|
||||||
interface SessionData {
|
interface SessionData {
|
||||||
user?: SessionUser;
|
user?: SessionUser;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ export interface UploadFailedInput {
|
||||||
method: string;
|
method: string;
|
||||||
path: string;
|
path: string;
|
||||||
target?: string;
|
target?: string;
|
||||||
|
/** Nom du fichier concerné (nom déposé, jamais celui du `.part`), s'il est connu. */
|
||||||
|
file?: string;
|
||||||
user?: string;
|
user?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +72,8 @@ function uploadSummary(input: UploadCompletedInput): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorSummary(input: UploadFailedInput): 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> {
|
function uploadBody(input: UploadCompletedInput): Record<string, unknown> {
|
||||||
|
|
@ -101,6 +104,7 @@ function errorBody(input: UploadFailedInput): Record<string, unknown> {
|
||||||
method: input.method,
|
method: input.method,
|
||||||
path: input.path,
|
path: input.path,
|
||||||
...(input.target !== undefined ? { target: input.target } : {}),
|
...(input.target !== undefined ? { target: input.target } : {}),
|
||||||
|
...(input.file !== undefined ? { file: input.file } : {}),
|
||||||
...(input.user !== undefined ? { user: input.user } : {}),
|
...(input.user !== undefined ? { user: input.user } : {}),
|
||||||
text: summary,
|
text: summary,
|
||||||
content: summary,
|
content: summary,
|
||||||
|
|
|
||||||
10
src/lib/request-file.ts
Normal file
10
src/lib/request-file.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,22 @@ export interface SaveFileParams {
|
||||||
placement?: FilePlacement;
|
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;
|
const MAX_BASE_LENGTH = 60;
|
||||||
/** Tampon d'écriture large : meilleur débit sur les très gros fichiers. */
|
/** Tampon d'écriture large : meilleur débit sur les très gros fichiers. */
|
||||||
const WRITE_HIGH_WATER_MARK = 1 << 20;
|
const WRITE_HIGH_WATER_MARK = 1 << 20;
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ import { isAllowedMedia } from '../lib/media-types.js';
|
||||||
import {
|
import {
|
||||||
createEmptyFile,
|
createEmptyFile,
|
||||||
finalizeFile,
|
finalizeFile,
|
||||||
|
PART_SUFFIX,
|
||||||
removeFile,
|
removeFile,
|
||||||
resolveDestination,
|
resolveDestination,
|
||||||
writeChunkAt,
|
writeChunkAt,
|
||||||
} from '../lib/storage.js';
|
} from '../lib/storage.js';
|
||||||
|
import { bindUploadFile } from '../lib/request-file.js';
|
||||||
import { resolvePlacement, resolveTarget } from '../lib/targets.js';
|
import { resolvePlacement, resolveTarget } from '../lib/targets.js';
|
||||||
import type { UploadSession } from '../lib/upload-sessions.js';
|
import type { UploadSession } from '../lib/upload-sessions.js';
|
||||||
import { getSessionUser, requireSession } from '../session.js';
|
import { getSessionUser, requireSession } from '../session.js';
|
||||||
|
|
@ -19,7 +21,6 @@ import { getSessionUser, requireSession } from '../session.js';
|
||||||
const MIN_CHUNK = 1024 * 1024;
|
const MIN_CHUNK = 1024 * 1024;
|
||||||
const MAX_CHUNK = 32 * 1024 * 1024;
|
const MAX_CHUNK = 32 * 1024 * 1024;
|
||||||
const DEFAULT_CHUNK = 16 * 1024 * 1024;
|
const DEFAULT_CHUNK = 16 * 1024 * 1024;
|
||||||
const PART_SUFFIX = '.mediadrop-part';
|
|
||||||
|
|
||||||
interface InitBody {
|
interface InitBody {
|
||||||
target?: unknown;
|
target?: unknown;
|
||||||
|
|
@ -87,6 +88,8 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
|
||||||
async (request, reply): Promise<InitResponse> => {
|
async (request, reply): Promise<InitResponse> => {
|
||||||
const body = (request.body ?? {}) as InitBody;
|
const body = (request.body ?? {}) as InitBody;
|
||||||
const query = (request.query ?? {}) as InitQuery;
|
const query = (request.query ?? {}) as InitQuery;
|
||||||
|
const originalName = nonEmptyString(body.name, 'fichier');
|
||||||
|
bindUploadFile(request, originalName);
|
||||||
// Le client web transmet `target` et `path` dans la query (comme `POST /upload`) ;
|
// Le client web transmet `target` et `path` dans la query (comme `POST /upload`) ;
|
||||||
// le corps JSON reste accepté.
|
// le corps JSON reste accepté.
|
||||||
const target = resolveTarget(cfg.targets, query.target ?? body.target);
|
const target = resolveTarget(cfg.targets, query.target ?? body.target);
|
||||||
|
|
@ -104,7 +107,6 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
|
||||||
const chunkSize = clampChunkSize(body.chunkSize);
|
const chunkSize = clampChunkSize(body.chunkSize);
|
||||||
const chunkCount = Math.ceil(totalSize / chunkSize);
|
const chunkCount = Math.ceil(totalSize / chunkSize);
|
||||||
|
|
||||||
const originalName = nonEmptyString(body.name, 'fichier');
|
|
||||||
const { storedName, destination } = await resolveDestination({
|
const { storedName, destination } = await resolveDestination({
|
||||||
targetDir: target.dir,
|
targetDir: target.dir,
|
||||||
originalName,
|
originalName,
|
||||||
|
|
@ -144,6 +146,7 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
|
||||||
async (request): Promise<ChunkAck> => {
|
async (request): Promise<ChunkAck> => {
|
||||||
const { id, index } = request.params as { id: string; index: string };
|
const { id, index } = request.params as { id: string; index: string };
|
||||||
const session = app.uploadSessions.use(id);
|
const session = app.uploadSessions.use(id);
|
||||||
|
bindUploadFile(request, session.storedName);
|
||||||
|
|
||||||
const chunkIndex = Number(index);
|
const chunkIndex = Number(index);
|
||||||
if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= session.chunkCount) {
|
if (!Number.isInteger(chunkIndex) || chunkIndex < 0 || chunkIndex >= session.chunkCount) {
|
||||||
|
|
@ -168,6 +171,7 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done)
|
||||||
async (request, reply): Promise<CompleteResponse> => {
|
async (request, reply): Promise<CompleteResponse> => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const session = app.uploadSessions.use(id);
|
const session = app.uploadSessions.use(id);
|
||||||
|
bindUploadFile(request, session.storedName);
|
||||||
|
|
||||||
if (session.received.size !== session.chunkCount) {
|
if (session.received.size !== session.chunkCount) {
|
||||||
throw new ValidationError('Upload incomplet : des morceaux manquent');
|
throw new ValidationError('Upload incomplet : des morceaux manquent');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { FastifyPluginCallback } from 'fastify';
|
||||||
import type { DropTarget } from '../config.js';
|
import type { DropTarget } from '../config.js';
|
||||||
import { ValidationError } from '../errors.js';
|
import { ValidationError } from '../errors.js';
|
||||||
import { isAllowedMedia, type MimePolicy } from '../lib/media-types.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 { resolvePlacement, resolveTarget } from '../lib/targets.js';
|
||||||
import { getSessionUser, requireSession } from '../session.js';
|
import { getSessionUser, requireSession } from '../session.js';
|
||||||
import { saveFile, type StoredFile } from '../lib/storage.js';
|
import { saveFile, type StoredFile } from '../lib/storage.js';
|
||||||
|
|
@ -78,6 +79,7 @@ export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => {
|
||||||
if (!part) {
|
if (!part) {
|
||||||
throw new ValidationError(`Aucun fichier reçu dans le champ « ${FIELD_NAME} »`);
|
throw new ValidationError(`Aucun fichier reçu dans le champ « ${FIELD_NAME} »`);
|
||||||
}
|
}
|
||||||
|
bindUploadFile(request, part.filename);
|
||||||
if (part.fieldname !== FIELD_NAME) {
|
if (part.fieldname !== FIELD_NAME) {
|
||||||
part.file.resume();
|
part.file.resume();
|
||||||
throw new ValidationError(`Le fichier doit être envoyé dans le champ « ${FIELD_NAME} »`);
|
throw new ValidationError(`Le fichier doit être envoyé dans le champ « ${FIELD_NAME} »`);
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,26 @@ import {
|
||||||
PayloadTooLargeError,
|
PayloadTooLargeError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
} from '../src/errors.js';
|
} from '../src/errors.js';
|
||||||
import { normalizeStreamError } from '../src/lib/storage.js';
|
import { hidePartSuffix, normalizeStreamError } from '../src/lib/storage.js';
|
||||||
|
|
||||||
function withCode(code: string): Error {
|
function withCode(code: string): Error {
|
||||||
return Object.assign(new Error('boom'), { code });
|
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', () => {
|
describe('normalizeStreamError', () => {
|
||||||
it('conserve les AppError telles quelles', () => {
|
it('conserve les AppError telles quelles', () => {
|
||||||
const error = new ValidationError('x');
|
const error = new ValidationError('x');
|
||||||
|
|
|
||||||
|
|
@ -155,9 +155,39 @@ describe('webhooks — intégration', () => {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/upload',
|
path: '/upload',
|
||||||
target: 'videos',
|
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 () => {
|
it('notifie `upload` (via: chunked) après finalisation fragmentée', async () => {
|
||||||
const data = randomBytes(200 * 1024);
|
const data = randomBytes(200 * 1024);
|
||||||
const init = await fetch(`${baseUrl}/upload/init`, {
|
const init = await fetch(`${baseUrl}/upload/init`, {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue