From 411090875bed5d1293d451836c03e2d90123530c Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 10 Apr 2026 18:18:01 +0700 Subject: [PATCH] feat(api): add per-type file size limits and 413 responses for media uploads - FileValidationPipe now supports maxSizeByMimeType for per-MIME-type size limits - Images: max 10MB, Video (MP4): max 100MB - Oversized files return 413 Payload Too Large instead of 400 Bad Request - MIME type validation runs before size check for clearer error messages - Multer module limit raised to 100MB (per-type enforcement in pipe) - Added 413 ApiResponse to Swagger docs on upload endpoint - Added comprehensive unit tests for FileValidationPipe (16 test cases) Co-Authored-By: Paperclip --- .../src/modules/listings/listings.module.ts | 2 +- .../controllers/listings.controller.ts | 6 +- .../__tests__/file-validation.pipe.spec.ts | 192 ++++++++++++++++++ .../pipes/file-validation.pipe.ts | 27 ++- 4 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/modules/shared/infrastructure/__tests__/file-validation.pipe.spec.ts diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts index 88ff33c..45f0a10 100644 --- a/apps/api/src/modules/listings/listings.module.ts +++ b/apps/api/src/modules/listings/listings.module.ts @@ -37,7 +37,7 @@ const QueryHandlers = [ imports: [ CqrsModule, MulterModule.register({ - limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB + limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB — per-type limits enforced by FileValidationPipe }), ], controllers: [ListingsController], diff --git a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts index 974b846..195617b 100644 --- a/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts +++ b/apps/api/src/modules/listings/presentation/controllers/listings.controller.ts @@ -169,13 +169,17 @@ export class ListingsController { @ApiParam({ name: 'id', description: 'Listing UUID' }) @ApiResponse({ status: 201, description: 'Media uploaded successfully' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 413, description: 'File too large (images: max 10MB, video: max 100MB)' }) @UseGuards(JwtAuthGuard) @UseInterceptors(FileInterceptor('file')) @Post(':id/media') async uploadMedia( @Param('id') id: string, @UploadedFile(new FileValidationPipe({ - maxSizeBytes: 10 * 1024 * 1024, // 10 MB + maxSizeBytes: 10 * 1024 * 1024, // 10 MB default for images + maxSizeByMimeType: { + 'video/mp4': 100 * 1024 * 1024, // 100 MB for video + }, allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'], })) file: ValidatedFile, diff --git a/apps/api/src/modules/shared/infrastructure/__tests__/file-validation.pipe.spec.ts b/apps/api/src/modules/shared/infrastructure/__tests__/file-validation.pipe.spec.ts new file mode 100644 index 0000000..f96d739 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/__tests__/file-validation.pipe.spec.ts @@ -0,0 +1,192 @@ +import { BadRequestException, PayloadTooLargeException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { FileValidationPipe, type UploadedFile } from '../pipes/file-validation.pipe'; + +function makeFile(overrides: Partial = {}): UploadedFile { + // Minimal valid JPEG: starts with FF D8 FF + const jpegBuffer = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]); + return { + fieldname: 'file', + originalname: 'test.jpg', + encoding: '7bit', + mimetype: 'image/jpeg', + size: 1024, + buffer: jpegBuffer, + ...overrides, + }; +} + +function makeMp4File(size: number): UploadedFile { + // MP4 magic bytes: "ftyp" at offset 4 + const buf = Buffer.alloc(Math.max(size, 12)); + buf.write('ftyp', 4, 'ascii'); + return { + fieldname: 'file', + originalname: 'test.mp4', + encoding: '7bit', + mimetype: 'video/mp4', + size, + buffer: buf, + }; +} + +describe('FileValidationPipe', () => { + describe('file required', () => { + it('should throw BadRequestException when no file provided', () => { + const pipe = new FileValidationPipe(); + expect(() => pipe.transform(null as unknown as UploadedFile)).toThrow(BadRequestException); + }); + }); + + describe('MIME type validation', () => { + it('should accept allowed MIME types', () => { + const pipe = new FileValidationPipe({ + allowedMimeTypes: ['image/jpeg', 'image/png'], + }); + const file = makeFile({ mimetype: 'image/jpeg' }); + expect(pipe.transform(file)).toBe(file); + }); + + it('should reject disallowed MIME types', () => { + const pipe = new FileValidationPipe({ + allowedMimeTypes: ['image/jpeg'], + }); + const file = makeFile({ mimetype: 'application/pdf' }); + expect(() => pipe.transform(file)).toThrow(BadRequestException); + expect(() => pipe.transform(file)).toThrow(/not allowed/); + }); + }); + + describe('size validation', () => { + it('should accept files within default size limit', () => { + const pipe = new FileValidationPipe(); + const file = makeFile({ size: 1024 }); + expect(pipe.transform(file)).toBe(file); + }); + + it('should throw PayloadTooLargeException for oversized files', () => { + const pipe = new FileValidationPipe({ maxSizeBytes: 1024 }); + const file = makeFile({ size: 2048 }); + expect(() => pipe.transform(file)).toThrow(PayloadTooLargeException); + }); + + it('should accept file exactly at the size limit', () => { + const pipe = new FileValidationPipe({ maxSizeBytes: 1024 }); + const file = makeFile({ size: 1024 }); + expect(pipe.transform(file)).toBe(file); + }); + + it('should reject file 1 byte over the limit', () => { + const pipe = new FileValidationPipe({ maxSizeBytes: 1024 }); + const file = makeFile({ size: 1025 }); + expect(() => pipe.transform(file)).toThrow(PayloadTooLargeException); + }); + }); + + describe('per-MIME-type size limits', () => { + const pipe = new FileValidationPipe({ + maxSizeBytes: 10 * 1024 * 1024, // 10 MB default + maxSizeByMimeType: { + 'video/mp4': 100 * 1024 * 1024, // 100 MB for video + }, + allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'], + }); + + it('should enforce default limit for images', () => { + const oversizedImage = makeFile({ size: 11 * 1024 * 1024 }); + expect(() => pipe.transform(oversizedImage)).toThrow(PayloadTooLargeException); + }); + + it('should allow images within default limit', () => { + const validImage = makeFile({ size: 5 * 1024 * 1024 }); + expect(pipe.transform(validImage)).toBe(validImage); + }); + + it('should allow videos up to per-type limit (100MB)', () => { + const validVideo = makeMp4File(50 * 1024 * 1024); + expect(pipe.transform(validVideo)).toBe(validVideo); + }); + + it('should reject videos exceeding per-type limit', () => { + const oversizedVideo = makeMp4File(101 * 1024 * 1024); + expect(() => pipe.transform(oversizedVideo)).toThrow(PayloadTooLargeException); + expect(() => pipe.transform(makeMp4File(101 * 1024 * 1024))).toThrow(/100MB/); + }); + + it('should use per-type limit instead of default when available', () => { + // 50MB video — over 10MB default but under 100MB per-type limit + const largeVideo = makeMp4File(50 * 1024 * 1024); + expect(pipe.transform(largeVideo)).toBe(largeVideo); + }); + }); + + describe('magic bytes validation', () => { + it('should reject file with mismatched magic bytes', () => { + const pipe = new FileValidationPipe({ + allowedMimeTypes: ['image/jpeg'], + verifyMagicBytes: true, + }); + // PNG magic bytes but declared as JPEG + const file = makeFile({ + mimetype: 'image/jpeg', + buffer: Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), + }); + expect(() => pipe.transform(file)).toThrow(BadRequestException); + expect(() => pipe.transform(makeFile({ + mimetype: 'image/jpeg', + buffer: Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), + }))).toThrow(/does not match/); + }); + + it('should accept file with correct magic bytes', () => { + const pipe = new FileValidationPipe({ + allowedMimeTypes: ['image/png'], + }); + const file = makeFile({ + mimetype: 'image/png', + buffer: Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), + size: 100, + }); + expect(pipe.transform(file)).toBe(file); + }); + + it('should skip magic byte check when disabled', () => { + const pipe = new FileValidationPipe({ + allowedMimeTypes: ['image/jpeg'], + verifyMagicBytes: false, + }); + // Wrong magic bytes but verification disabled + const file = makeFile({ + buffer: Buffer.from([0x00, 0x00, 0x00, 0x00]), + }); + expect(pipe.transform(file)).toBe(file); + }); + + it('should validate MP4 ftyp signature at offset 4', () => { + const pipe = new FileValidationPipe({ + allowedMimeTypes: ['video/mp4'], + }); + const validMp4 = makeMp4File(1024); + expect(pipe.transform(validMp4)).toBe(validMp4); + }); + }); + + describe('error messages', () => { + it('should include MIME type in size error message', () => { + const pipe = new FileValidationPipe({ + maxSizeBytes: 1024, + allowedMimeTypes: ['image/jpeg'], + }); + const file = makeFile({ size: 2048 }); + expect(() => pipe.transform(file)).toThrow(/image\/jpeg/); + }); + + it('should list allowed types in MIME error message', () => { + const pipe = new FileValidationPipe({ + allowedMimeTypes: ['image/jpeg', 'image/png'], + }); + const file = makeFile({ mimetype: 'text/plain' }); + expect(() => pipe.transform(file)).toThrow(/image\/jpeg, image\/png/); + }); + }); +}); diff --git a/apps/api/src/modules/shared/infrastructure/pipes/file-validation.pipe.ts b/apps/api/src/modules/shared/infrastructure/pipes/file-validation.pipe.ts index 50ee19b..b28fe44 100644 --- a/apps/api/src/modules/shared/infrastructure/pipes/file-validation.pipe.ts +++ b/apps/api/src/modules/shared/infrastructure/pipes/file-validation.pipe.ts @@ -1,4 +1,9 @@ -import { BadRequestException, Injectable, type PipeTransform } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + PayloadTooLargeException, + type PipeTransform, +} from '@nestjs/common'; export interface UploadedFile { fieldname: string; @@ -10,8 +15,10 @@ export interface UploadedFile { } export interface FileValidationOptions { - /** Max file size in bytes. Default: 5 MB */ + /** Max file size in bytes (fallback when no per-type limit matches). Default: 5 MB */ maxSizeBytes?: number; + /** Per-MIME-type size limits in bytes. Takes precedence over maxSizeBytes for matching types. */ + maxSizeByMimeType?: Record; /** Allowed MIME types. Default: common image types + PDF */ allowedMimeTypes?: string[]; /** Whether to verify file content matches declared MIME type via magic bytes. Default: true */ @@ -47,11 +54,13 @@ const MAGIC_BYTES: Record = { @Injectable() export class FileValidationPipe implements PipeTransform { private readonly maxSize: number; + private readonly maxSizeByMimeType: Record; private readonly allowedMimes: string[]; private readonly verifyMagicBytes: boolean; constructor(options?: FileValidationOptions) { this.maxSize = options?.maxSizeBytes ?? DEFAULT_MAX_SIZE; + this.maxSizeByMimeType = options?.maxSizeByMimeType ?? {}; this.allowedMimes = options?.allowedMimeTypes ?? DEFAULT_ALLOWED_MIMES; this.verifyMagicBytes = options?.verifyMagicBytes ?? true; } @@ -61,18 +70,20 @@ export class FileValidationPipe implements PipeTransform { throw new BadRequestException('File is required'); } - if (file.size > this.maxSize) { - throw new BadRequestException( - `File size ${file.size} exceeds maximum allowed size of ${this.maxSize} bytes`, - ); - } - if (!this.allowedMimes.includes(file.mimetype)) { throw new BadRequestException( `File type '${file.mimetype}' is not allowed. Allowed types: ${this.allowedMimes.join(', ')}`, ); } + const effectiveMaxSize = this.maxSizeByMimeType[file.mimetype] ?? this.maxSize; + if (file.size > effectiveMaxSize) { + const maxSizeMB = (effectiveMaxSize / (1024 * 1024)).toFixed(0); + throw new PayloadTooLargeException( + `File size exceeds maximum of ${maxSizeMB}MB for type '${file.mimetype}'`, + ); + } + if (this.verifyMagicBytes) { this.validateMagicBytes(file); }