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:
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user