Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
227 changes: 227 additions & 0 deletions src/cdn/cdn.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
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';

describe('CdnController', () => {
let controller: CdnController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CdnController],
}).compile();

controller = module.get<CdnController>(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 Express.Multer.File;

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 Express.Multer.File;

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 Express.Multer.File;

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('<?php echo "hacked"; ?>');

const file = {
fieldname: 'file',
originalname: 'video.mp4',
encoding: '7bit',
mimetype: 'video/mp4',
size: phpContent.length,
buffer: phpContent,
} as Express.Multer.File;

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 Express.Multer.File;

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 Express.Multer.File;

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 Express.Multer.File;

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 Express.Multer.File;

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);
});
});
});
139 changes: 139 additions & 0 deletions src/cdn/cdn.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
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 { UploadContentDto } from './dto/upload-content.dto';
import {
FILE_SIZE_LIMITS,
ALL_ALLOWED_FILE_TYPES,
} from '../media/validation/file-validation.constants';

@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(
@UploadedFile() file: Express.Multer.File,
) {
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)) {
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;
}
}
2 changes: 2 additions & 0 deletions src/cdn/cdn.module.ts
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
4 changes: 2 additions & 2 deletions src/media/validation/file-validation.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading