diff --git a/package.json b/package.json index de3a426e..06fd2df0 100644 --- a/package.json +++ b/package.json @@ -165,7 +165,8 @@ "swagger-ui-express": "^5.0.1", "ts-morph": "^24.0.0", "typeorm": "^0.3.28", - "uuid": "^14.0.0" + "uuid": "^14.0.0", + "file-type": "^19.0.0" }, "devDependencies": { "@commitlint/cli": "^19.0.0", diff --git a/src/cdn/cdn.controller.spec.ts b/src/cdn/cdn.controller.spec.ts new file mode 100644 index 00000000..720e6583 --- /dev/null +++ b/src/cdn/cdn.controller.spec.ts @@ -0,0 +1,225 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CdnController } from './cdn.controller'; +import { PayloadTooLargeException, UnsupportedMediaTypeException } from '@nestjs/common'; +import { FILE_SIZE_LIMITS } from '../media/validation/file-validation.constants'; +import { UploadedFile } from '../common/types/file.types'; + +describe('CdnController', () => { + let controller: CdnController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CdnController], + }).compile(); + + controller = module.get(CdnController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('uploadContent', () => { + it('should reject files exceeding image size limit', async () => { + // Create a buffer larger than IMAGE_MAX_SIZE (20MB) + const oversizedBuffer = Buffer.alloc(FILE_SIZE_LIMITS.IMAGE_MAX_SIZE + 1); + // Add PNG magic bytes + oversizedBuffer[0] = 0x89; + oversizedBuffer[1] = 0x50; + oversizedBuffer[2] = 0x4e; + oversizedBuffer[3] = 0x47; + oversizedBuffer[4] = 0x0d; + oversizedBuffer[5] = 0x0a; + oversizedBuffer[6] = 0x1a; + oversizedBuffer[7] = 0x0a; + + const file = { + fieldname: 'file', + originalname: 'large.png', + encoding: '7bit', + mimetype: 'image/png', + size: FILE_SIZE_LIMITS.IMAGE_MAX_SIZE + 1, + buffer: oversizedBuffer, + } as UploadedFile; + + await expect(controller.uploadContent(file)).rejects.toThrow(PayloadTooLargeException); + }); + + it('should reject files exceeding video size limit', async () => { + // Create a buffer larger than VIDEO_MAX_SIZE (500MB) + const oversizedBuffer = Buffer.alloc(FILE_SIZE_LIMITS.VIDEO_MAX_SIZE + 1); + // Add MP4 magic bytes (ftyp) + oversizedBuffer[0] = 0x00; + oversizedBuffer[1] = 0x00; + oversizedBuffer[2] = 0x00; + oversizedBuffer[3] = 0x18; + oversizedBuffer[4] = 0x66; + oversizedBuffer[5] = 0x74; + oversizedBuffer[6] = 0x79; + oversizedBuffer[7] = 0x70; + + const file = { + fieldname: 'file', + originalname: 'large.mp4', + encoding: '7bit', + mimetype: 'video/mp4', + size: FILE_SIZE_LIMITS.VIDEO_MAX_SIZE + 1, + buffer: oversizedBuffer, + } as UploadedFile; + + await expect(controller.uploadContent(file)).rejects.toThrow(PayloadTooLargeException); + }); + + it('should reject files with wrong magic bytes (MIME mismatch)', async () => { + // Create a file with PNG extension but JPEG magic bytes + const jpegBuffer = Buffer.alloc(100); + jpegBuffer[0] = 0xff; + jpegBuffer[1] = 0xd8; + jpegBuffer[2] = 0xff; + + const file = { + fieldname: 'file', + originalname: 'fake.png', + encoding: '7bit', + mimetype: 'image/png', // Declared as PNG + size: 100, + buffer: jpegBuffer, // But actually JPEG + } as UploadedFile; + + await expect(controller.uploadContent(file)).rejects.toThrow(UnsupportedMediaTypeException); + }); + + it('should reject PHP script disguised as video', async () => { + // Create a PHP script with video.mp4 name + const phpContent = Buffer.from(''); + const file = { + fieldname: 'file', + originalname: 'video.mp4', + encoding: '7bit', + mimetype: 'video/mp4', + size: phpContent.length, + buffer: phpContent, + } as UploadedFile; + + await expect(controller.uploadContent(file)).rejects.toThrow(UnsupportedMediaTypeException); + }); + + it('should accept valid PNG file within size limit', async () => { + // Create a valid PNG file within size limit + const pngBuffer = Buffer.alloc(1000); + pngBuffer[0] = 0x89; + pngBuffer[1] = 0x50; + pngBuffer[2] = 0x4e; + pngBuffer[3] = 0x47; + pngBuffer[4] = 0x0d; + pngBuffer[5] = 0x0a; + pngBuffer[6] = 0x1a; + pngBuffer[7] = 0x0a; + + const file = { + fieldname: 'file', + originalname: 'valid.png', + encoding: '7bit', + mimetype: 'image/png', + size: 1000, + buffer: pngBuffer, + } as UploadedFile; + + const result = await controller.uploadContent(file); + expect(result.success).toBe(true); + expect(result.file.mimetype).toBe('image/png'); + expect(result.file.extension).toBe('png'); + }); + + it('should accept valid JPEG file within size limit', async () => { + // Create a valid JPEG file within size limit + const jpegBuffer = Buffer.alloc(1000); + jpegBuffer[0] = 0xff; + jpegBuffer[1] = 0xd8; + jpegBuffer[2] = 0xff; + + const file = { + fieldname: 'file', + originalname: 'valid.jpg', + encoding: '7bit', + mimetype: 'image/jpeg', + size: 1000, + buffer: jpegBuffer, + } as UploadedFile; + + const result = await controller.uploadContent(file); + expect(result.success).toBe(true); + expect(result.file.mimetype).toBe('image/jpeg'); + expect(result.file.extension).toBe('jpg'); + }); + + it('should accept valid MP4 file within size limit', async () => { + // Create a valid MP4 file within size limit + const mp4Buffer = Buffer.alloc(1000); + mp4Buffer[0] = 0x00; + mp4Buffer[1] = 0x00; + mp4Buffer[2] = 0x00; + mp4Buffer[3] = 0x18; + mp4Buffer[4] = 0x66; + mp4Buffer[5] = 0x74; + mp4Buffer[6] = 0x79; + mp4Buffer[7] = 0x70; + + const file = { + fieldname: 'file', + originalname: 'valid.mp4', + encoding: '7bit', + mimetype: 'video/mp4', + size: 1000, + buffer: mp4Buffer, + } as UploadedFile; + + const result = await controller.uploadContent(file); + expect(result.success).toBe(true); + expect(result.file.mimetype).toBe('video/mp4'); + expect(result.file.extension).toBe('mp4'); + }); + + it('should reject file with no magic bytes', async () => { + // Create a buffer with no recognizable magic bytes + const invalidBuffer = Buffer.alloc(100, 0x00); + + const file = { + fieldname: 'file', + originalname: 'invalid.bin', + encoding: '7bit', + mimetype: 'application/octet-stream', + size: 100, + buffer: invalidBuffer, + } as UploadedFile; + + await expect(controller.uploadContent(file)).rejects.toThrow(UnsupportedMediaTypeException); + }); + + it('should reject when no file is provided', async () => { + await expect(controller.uploadContent(null as any)).rejects.toThrow(); + }); + }); + + describe('getMaxSizeForType', () => { + it('should return correct size limit for images', () => { + const maxSize = (controller as any).getMaxSizeForType('image/jpeg'); + expect(maxSize).toBe(FILE_SIZE_LIMITS.IMAGE_MAX_SIZE); + }); + + it('should return correct size limit for videos', () => { + const maxSize = (controller as any).getMaxSizeForType('video/mp4'); + expect(maxSize).toBe(FILE_SIZE_LIMITS.VIDEO_MAX_SIZE); + }); + + it('should return correct size limit for audio', () => { + const maxSize = (controller as any).getMaxSizeForType('audio/mpeg'); + expect(maxSize).toBe(FILE_SIZE_LIMITS.AUDIO_MAX_SIZE); + }); + + it('should return default size limit for unknown types', () => { + const maxSize = (controller as any).getMaxSizeForType('unknown/type'); + expect(maxSize).toBe(FILE_SIZE_LIMITS.DEFAULT_MAX_SIZE); + }); + }); +}); diff --git a/src/cdn/cdn.controller.ts b/src/cdn/cdn.controller.ts new file mode 100644 index 00000000..af40939c --- /dev/null +++ b/src/cdn/cdn.controller.ts @@ -0,0 +1,142 @@ +import { + Controller, + Post, + UseInterceptors, + UploadedFile as UploadedFileDecorator, + BadRequestException, + PayloadTooLargeException, + UnsupportedMediaTypeException, + Logger, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiConsumes, ApiResponse } from '@nestjs/swagger'; +import { fileTypeFromBuffer } from 'file-type'; +import { + FILE_SIZE_LIMITS, + ALL_ALLOWED_FILE_TYPES, +} from '../media/validation/file-validation.constants'; +import { UploadedFile } from '../common/types/file.types'; + +@ApiTags('cdn') +@Controller('cdn') +export class CdnController { + private readonly logger = new Logger(CdnController.name); + + @Post('upload') + @ApiOperation({ summary: 'Upload content to CDN' }) + @ApiConsumes('multipart/form-data') + @ApiResponse({ status: 201, description: 'File uploaded successfully' }) + @ApiResponse({ status: 413, description: 'File too large' }) + @ApiResponse({ status: 415, description: 'Unsupported media type' }) + @UseInterceptors( + FileInterceptor('file', { + limits: { + fileSize: FILE_SIZE_LIMITS.VIDEO_MAX_SIZE, // Max limit for videos + }, + }), + ) + async uploadContent(@UploadedFileDecorator() file: UploadedFile) { + if (!file) { + throw new BadRequestException('No file provided'); + } + + // Validate file size based on detected type + const detectedType = await this.detectFileType(file.buffer); + const maxSize = this.getMaxSizeForType(detectedType?.mime || file.mimetype); + + if (file.size > maxSize) { + this.logger.warn( + `File upload rejected: size ${file.size} exceeds limit ${maxSize} for type ${detectedType?.mime || file.mimetype}`, + ); + throw new PayloadTooLargeException( + `File size ${Math.round(file.size / 1024 / 1024)}MB exceeds maximum allowed size of ${Math.round(maxSize / 1024 / 1024)}MB for this file type`, + ); + } + + // Validate MIME type using magic bytes + if (!detectedType) { + throw new UnsupportedMediaTypeException( + 'Could not determine file type from content. File may be corrupted or format not supported.', + ); + } + + const declaredMimeType = file.mimetype.toLowerCase(); + const detectedMimeType = detectedType.mime.toLowerCase(); + + // Check if detected MIME type is in allowed list + if (!ALL_ALLOWED_FILE_TYPES.includes(detectedMimeType as any)) { + throw new UnsupportedMediaTypeException( + `Detected file type "${detectedMimeType}" is not allowed. Allowed types: ${ALL_ALLOWED_FILE_TYPES.join(', ')}`, + ); + } + + // Compare declared vs detected MIME type + if (declaredMimeType !== detectedMimeType) { + this.logger.warn( + `MIME type mismatch: declared="${declaredMimeType}", detected="${detectedMimeType}"`, + ); + throw new UnsupportedMediaTypeException( + `Declared MIME type "${declaredMimeType}" does not match actual file content "${detectedMimeType}"`, + ); + } + + // If we get here, validation passed + this.logger.log( + `File uploaded successfully: ${file.originalname}, size: ${file.size}, type: ${detectedMimeType}`, + ); + + return { + success: true, + message: 'File uploaded successfully', + file: { + originalname: file.originalname, + size: file.size, + mimetype: detectedMimeType, + extension: detectedType.ext, + }, + }; + } + + /** + * Detect file type from buffer using magic bytes + */ + private async detectFileType(buffer: Buffer) { + try { + const fileType = await fileTypeFromBuffer(buffer); + return fileType; + } catch (error) { + this.logger.error('Error detecting file type:', error); + return null; + } + } + + /** + * Get maximum file size for a given MIME type + */ + private getMaxSizeForType(mimeType: string): number { + const mime = mimeType.toLowerCase(); + + if (mime.startsWith('image/')) { + return FILE_SIZE_LIMITS.IMAGE_MAX_SIZE; + } + if (mime.startsWith('video/')) { + return FILE_SIZE_LIMITS.VIDEO_MAX_SIZE; + } + if (mime.startsWith('audio/')) { + return FILE_SIZE_LIMITS.AUDIO_MAX_SIZE; + } + if ( + mime.includes('pdf') || + mime.includes('document') || + mime.includes('sheet') || + mime.includes('presentation') + ) { + return FILE_SIZE_LIMITS.DOCUMENT_MAX_SIZE; + } + if (mime.includes('zip') || mime.includes('archive')) { + return FILE_SIZE_LIMITS.ARCHIVE_MAX_SIZE; + } + + return FILE_SIZE_LIMITS.DEFAULT_MAX_SIZE; + } +} diff --git a/src/cdn/cdn.module.ts b/src/cdn/cdn.module.ts index 29b636bd..866a0cd4 100644 --- a/src/cdn/cdn.module.ts +++ b/src/cdn/cdn.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { CdnService } from './cdn.service'; +import { CdnController } from './cdn.controller'; @Module({ + controllers: [CdnController], providers: [CdnService], exports: [CdnService], }) diff --git a/src/media/validation/file-validation.constants.ts b/src/media/validation/file-validation.constants.ts index 584cbe84..80994680 100644 --- a/src/media/validation/file-validation.constants.ts +++ b/src/media/validation/file-validation.constants.ts @@ -55,8 +55,8 @@ export const ALLOWED_EXTENSIONS = { ARCHIVES: ['.zip'], } as const; export const FILE_SIZE_LIMITS = { - // Images: 20MB - IMAGE_MAX_SIZE: 20 * 1024 * 1024, + // Images: 10MB + IMAGE_MAX_SIZE: 10 * 1024 * 1024, // Videos: 500MB VIDEO_MAX_SIZE: 500 * 1024 * 1024, // Documents: 50MB diff --git a/src/media/validation/upload-validation.util.ts b/src/media/validation/upload-validation.util.ts index cac180da..c71dcdd8 100644 --- a/src/media/validation/upload-validation.util.ts +++ b/src/media/validation/upload-validation.util.ts @@ -31,6 +31,9 @@ export const MEDIA_UPLOAD_INTERCEPTOR_OPTIONS = { const allowedMimeTypes = ALL_ALLOWED_FILE_TYPES as readonly string[]; const normalizedMimeType = file.mimetype?.toLowerCase().trim() || ''; + // Check if declared MIME type is in allowed list + // Note: Magic bytes validation is handled at the controller/worker level + // where async operations are supported (see cdn.controller.ts and media-processing.worker.ts) if (!allowedMimeTypes.includes(normalizedMimeType)) { req.uploadValidationError = { message: `File type "${file.mimetype || 'unknown'}" is not allowed`, diff --git a/src/workers/processors/media-processing.worker.spec.ts b/src/workers/processors/media-processing.worker.spec.ts new file mode 100644 index 00000000..228c11a6 --- /dev/null +++ b/src/workers/processors/media-processing.worker.spec.ts @@ -0,0 +1,313 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MediaProcessingWorker } from './media-processing.worker'; +import { Job } from 'bull'; +import { + FILE_SIZE_LIMITS, + ALL_ALLOWED_FILE_TYPES, +} from '../../media/validation/file-validation.constants'; + +describe('MediaProcessingWorker', () => { + let worker: MediaProcessingWorker; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MediaProcessingWorker], + }).compile(); + + worker = module.get(MediaProcessingWorker); + }); + + it('should be defined', () => { + expect(worker).toBeDefined(); + }); + + describe('execute', () => { + it('should reject oversized image files', async () => { + const oversizedBuffer = Buffer.alloc(FILE_SIZE_LIMITS.IMAGE_MAX_SIZE + 1); + // Add PNG magic bytes + oversizedBuffer[0] = 0x89; + oversizedBuffer[1] = 0x50; + oversizedBuffer[2] = 0x4e; + oversizedBuffer[3] = 0x47; + + const job = { + data: { + mediaType: 'image', + fileUrl: 'http://example.com/large.png', + fileBuffer: oversizedBuffer, + declaredMimeType: 'image/png', + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + await expect(worker.execute(job)).rejects.toThrow(/File size.*exceeds maximum allowed size/); + }); + + it('should reject oversized video files', async () => { + const oversizedBuffer = Buffer.alloc(FILE_SIZE_LIMITS.VIDEO_MAX_SIZE + 1); + // Add MP4 magic bytes + oversizedBuffer[0] = 0x00; + oversizedBuffer[1] = 0x00; + oversizedBuffer[2] = 0x00; + oversizedBuffer[3] = 0x18; + oversizedBuffer[4] = 0x66; + oversizedBuffer[5] = 0x74; + oversizedBuffer[6] = 0x79; + oversizedBuffer[7] = 0x70; + + const job = { + data: { + mediaType: 'video', + fileUrl: 'http://example.com/large.mp4', + fileBuffer: oversizedBuffer, + declaredMimeType: 'video/mp4', + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + await expect(worker.execute(job)).rejects.toThrow(/File size.*exceeds maximum allowed size/); + }); + + it('should reject files with MIME type mismatch', async () => { + // JPEG magic bytes but declared as PNG + const jpegBuffer = Buffer.alloc(100); + jpegBuffer[0] = 0xff; + jpegBuffer[1] = 0xd8; + jpegBuffer[2] = 0xff; + + const job = { + data: { + mediaType: 'image', + fileUrl: 'http://example.com/fake.png', + fileBuffer: jpegBuffer, + declaredMimeType: 'image/png', // Wrong declaration + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + await expect(worker.execute(job)).rejects.toThrow( + /Declared MIME type.*does not match actual file content/, + ); + }); + + it('should reject PHP script disguised as video', async () => { + const phpContent = Buffer.from(''); + + const job = { + data: { + mediaType: 'video', + fileUrl: 'http://example.com/video.mp4', + fileBuffer: phpContent, + declaredMimeType: 'video/mp4', + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + await expect(worker.execute(job)).rejects.toThrow(/Could not determine file type/); + }); + + it('should reject files with unsupported MIME types', async () => { + // Create a buffer that might be detected as an unsupported type + const buffer = Buffer.alloc(100); + // Add some bytes that might be detected but not in allowed list + buffer[0] = 0x00; + buffer[1] = 0x00; + buffer[2] = 0x00; + + const job = { + data: { + mediaType: 'image', + fileUrl: 'http://example.com/unknown.bin', + fileBuffer: buffer, + declaredMimeType: 'application/octet-stream', + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + // This might pass if file-type can't detect it, or fail if detected as unsupported + // The important thing is that it validates against allowed types + try { + await worker.execute(job); + // If it passes, that's because file-type couldn't detect it + // In production, this should be handled by checking the declared type + } catch (error) { + // Expected to fail if detected as unsupported type + expect(error.message).toMatch(/not allowed/); + } + }); + + it('should reject when media type category does not match detected type', async () => { + // JPEG magic bytes but mediaType is 'video' + const jpegBuffer = Buffer.alloc(100); + jpegBuffer[0] = 0xff; + jpegBuffer[1] = 0xd8; + jpegBuffer[2] = 0xff; + + const job = { + data: { + mediaType: 'video', // Wrong category + fileUrl: 'http://example.com/image.jpg', + fileBuffer: jpegBuffer, + declaredMimeType: 'image/jpeg', + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + await expect(worker.execute(job)).rejects.toThrow(/Expected video file but detected image/); + }); + + it('should accept valid image file within size limit', async () => { + const pngBuffer = Buffer.alloc(1000); + pngBuffer[0] = 0x89; + pngBuffer[1] = 0x50; + pngBuffer[2] = 0x4e; + pngBuffer[3] = 0x47; + pngBuffer[4] = 0x0d; + pngBuffer[5] = 0x0a; + pngBuffer[6] = 0x1a; + pngBuffer[7] = 0x0a; + + const job = { + data: { + mediaType: 'image', + fileUrl: 'http://example.com/valid.png', + fileBuffer: pngBuffer, + declaredMimeType: 'image/png', + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + const result = await worker.execute(job); + expect(result).toBeDefined(); + expect(result.mediaType).toBe('image'); + expect(job.progress).toHaveBeenCalledWith(100); + }); + + it('should accept valid video file within size limit', async () => { + const mp4Buffer = Buffer.alloc(1000); + mp4Buffer[0] = 0x00; + mp4Buffer[1] = 0x00; + mp4Buffer[2] = 0x00; + mp4Buffer[3] = 0x18; + mp4Buffer[4] = 0x66; + mp4Buffer[5] = 0x74; + mp4Buffer[6] = 0x79; + mp4Buffer[7] = 0x70; + + const job = { + data: { + mediaType: 'video', + fileUrl: 'http://example.com/valid.mp4', + fileBuffer: mp4Buffer, + declaredMimeType: 'video/mp4', + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + const result = await worker.execute(job); + expect(result).toBeDefined(); + expect(result.mediaType).toBe('video'); + expect(job.progress).toHaveBeenCalledWith(100); + }); + + it('should accept valid audio file within size limit', async () => { + const mp3Buffer = Buffer.alloc(1000); + mp3Buffer[0] = 0xff; + mp3Buffer[1] = 0xfb; + + const job = { + data: { + mediaType: 'audio', + fileUrl: 'http://example.com/valid.mp3', + fileBuffer: mp3Buffer, + declaredMimeType: 'audio/mpeg', + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + const result = await worker.execute(job); + expect(result).toBeDefined(); + expect(result.mediaType).toBe('audio'); + expect(job.progress).toHaveBeenCalledWith(100); + }); + + it('should process files without buffer (legacy support)', async () => { + const job = { + data: { + mediaType: 'image', + fileUrl: 'http://example.com/legacy.png', + // No fileBuffer provided + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + const result = await worker.execute(job); + expect(result).toBeDefined(); + expect(result.mediaType).toBe('image'); + }); + + it('should reject when required fields are missing', async () => { + const job = { + data: { + // Missing fileUrl and mediaType + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + await expect(worker.execute(job)).rejects.toThrow(/Missing required media fields/); + }); + + it('should reject when buffer is too small for magic byte detection', async () => { + const tinyBuffer = Buffer.alloc(2); // Too small for magic bytes + + const job = { + data: { + mediaType: 'image', + fileUrl: 'http://example.com/tiny.bin', + fileBuffer: tinyBuffer, + declaredMimeType: 'image/png', + }, + progress: jest.fn().mockResolvedValue(undefined), + } as any; + + // Should skip validation if buffer < 4 bytes, but still process + const result = await worker.execute(job); + expect(result).toBeDefined(); + }); + }); + + describe('getMaxSizeForType', () => { + it('should return correct size limit for images', () => { + const maxSize = (worker as any).getMaxSizeForType('image'); + expect(maxSize).toBe(FILE_SIZE_LIMITS.IMAGE_MAX_SIZE); + }); + + it('should return correct size limit for videos', () => { + const maxSize = (worker as any).getMaxSizeForType('video'); + expect(maxSize).toBe(FILE_SIZE_LIMITS.VIDEO_MAX_SIZE); + }); + + it('should return correct size limit for audio', () => { + const maxSize = (worker as any).getMaxSizeForType('audio'); + expect(maxSize).toBe(FILE_SIZE_LIMITS.AUDIO_MAX_SIZE); + }); + + it('should return correct size limit for documents', () => { + const maxSize = (worker as any).getMaxSizeForType('document'); + expect(maxSize).toBe(FILE_SIZE_LIMITS.DOCUMENT_MAX_SIZE); + }); + + it('should return default size limit for unknown types', () => { + const maxSize = (worker as any).getMaxSizeForType('unknown'); + expect(maxSize).toBe(FILE_SIZE_LIMITS.DEFAULT_MAX_SIZE); + }); + + it('should be case-insensitive', () => { + const maxSize1 = (worker as any).getMaxSizeForType('IMAGE'); + const maxSize2 = (worker as any).getMaxSizeForType('Image'); + const maxSize3 = (worker as any).getMaxSizeForType('image'); + expect(maxSize1).toBe(maxSize2); + expect(maxSize2).toBe(maxSize3); + }); + }); +}); diff --git a/src/workers/processors/media-processing.worker.ts b/src/workers/processors/media-processing.worker.ts index 873d2099..348ab9a4 100644 --- a/src/workers/processors/media-processing.worker.ts +++ b/src/workers/processors/media-processing.worker.ts @@ -1,6 +1,11 @@ import { Injectable } from '@nestjs/common'; import { Job } from 'bull'; import { BaseWorker } from '../base/base.worker'; +import { fileTypeFromBuffer } from 'file-type'; +import { + FILE_SIZE_LIMITS, + ALL_ALLOWED_FILE_TYPES, +} from '../../media/validation/file-validation.constants'; /** * Media Processing Worker @@ -16,7 +21,7 @@ export class MediaProcessingWorker extends BaseWorker { * Execute media processing job */ async execute(job: Job): Promise { - const { mediaType, fileUrl, format, options } = job.data; + const { mediaType, fileUrl, format, options, fileBuffer, declaredMimeType } = job.data; await job.progress(20); @@ -25,6 +30,64 @@ export class MediaProcessingWorker extends BaseWorker { throw new Error('Missing required media fields: fileUrl, mediaType'); } + // Validate file size if buffer is provided + if (fileBuffer && Buffer.isBuffer(fileBuffer)) { + const maxSize = this.getMaxSizeForType(mediaType); + if (fileBuffer.length > maxSize) { + this.logger.error( + `File size ${fileBuffer.length} exceeds limit ${maxSize} for type ${mediaType}`, + ); + throw new Error( + `File size ${Math.round(fileBuffer.length / 1024 / 1024)}MB exceeds maximum allowed size of ${Math.round(maxSize / 1024 / 1024)}MB for ${mediaType}`, + ); + } + + // Validate MIME type using magic bytes + if (fileBuffer.length >= 4) { + const detectedType = await fileTypeFromBuffer(fileBuffer); + + if (!detectedType) { + throw new Error( + 'Could not determine file type from content. File may be corrupted or format not supported.', + ); + } + + const detectedMimeType = detectedType.mime.toLowerCase(); + // Check if detected MIME type is in allowed list + if (!ALL_ALLOWED_FILE_TYPES.includes(detectedMimeType as any)) { + throw new Error( + `Detected file type "${detectedMimeType}" is not allowed. Allowed types: ${ALL_ALLOWED_FILE_TYPES.join(', ')}`, + ); + } + + // Compare declared vs detected MIME type if declared + if (declaredMimeType) { + const declared = declaredMimeType.toLowerCase(); + if (declared !== detectedMimeType) { + this.logger.warn( + `MIME type mismatch: declared="${declared}", detected="${detectedMimeType}"`, + ); + throw new Error( + `Declared MIME type "${declared}" does not match actual file content "${detectedMimeType}"`, + ); + } + } + + // Validate that detected type matches expected media type category + const expectedCategory = mediaType.toLowerCase(); + const detectedCategory = detectedMimeType.split('/')[0]; + if (expectedCategory !== detectedCategory) { + throw new Error( + `Expected ${expectedCategory} file but detected ${detectedCategory} (${detectedMimeType})`, + ); + } + + this.logger.log( + `File validation passed: ${fileUrl}, size: ${fileBuffer.length}, type: ${detectedMimeType}`, + ); + } + } + await job.progress(40); try { @@ -54,6 +117,28 @@ export class MediaProcessingWorker extends BaseWorker { } } + /** + * Get maximum file size for a given media type + */ + private getMaxSizeForType(mediaType: string): number { + const type = mediaType.toLowerCase(); + + if (type === 'image') { + return FILE_SIZE_LIMITS.IMAGE_MAX_SIZE; + } + if (type === 'video') { + return FILE_SIZE_LIMITS.VIDEO_MAX_SIZE; + } + if (type === 'audio') { + return FILE_SIZE_LIMITS.AUDIO_MAX_SIZE; + } + if (type === 'document') { + return FILE_SIZE_LIMITS.DOCUMENT_MAX_SIZE; + } + + return FILE_SIZE_LIMITS.DEFAULT_MAX_SIZE; + } + /** * Process image file */