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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 18:18:01 +07:00
parent 3418ab30b0
commit 411090875b
4 changed files with 217 additions and 10 deletions

View File

@@ -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],

View File

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

View File

@@ -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> = {}): 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/);
});
});
});

View File

@@ -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<string, number>;
/** 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<string, { offset: number; bytes: number[] }[]> = {
@Injectable()
export class FileValidationPipe implements PipeTransform {
private readonly maxSize: number;
private readonly maxSizeByMimeType: Record<string, number>;
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);
}