import { Controller, Post, UseGuards, UseInterceptors, Req, HttpCode, HttpStatus, BadRequestException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger'; import { FileInterceptor } from '@nestjs/platform-express'; import { ConfigService } from '@nestjs/config'; import * as fs from 'fs'; import * as path from 'path'; import sharp, { FormatEnum } from 'sharp'; import { AdminGuard } from '../../common/guards/admin.guard'; import { Roles } from '../../common/decorators/roles.decorator'; import { parseUploadSizeLimit, safeFilename } from '../../common/utils/upload'; /** * Multipart upload endpoints for site logos and category icons. * * Challenge attachments use the staged lifecycle exposed by * `AdminChallengesController` (`POST /api/v1/admin/challenges/files/stage`), * which guarantees the file only becomes "real" once the admin saves the * challenge. The previous direct-to-final challenge-file route was removed * because it bypassed staging and could overwrite files by name. */ @ApiTags('uploads') @ApiBearerAuth() @Controller('api/v1/uploads') @UseGuards(AdminGuard) @Roles('admin') export class UploadsController { private readonly uploadDir: string; private readonly globalLimit: number; constructor(private readonly config: ConfigService) { this.uploadDir = path.resolve(this.config.get('UPLOAD_DIR', './data/uploads')); this.globalLimit = parseUploadSizeLimit(this.config.get('UPLOAD_SIZE_LIMIT', '50mb')); } @Post('category-icon') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Upload a category icon (admin only)' }) @ApiConsumes('multipart/form-data') @UseInterceptors(FileInterceptor('file')) async uploadCategoryIcon(@Req() req: any): Promise<{ publicUrl: string; width: number; height: number; mimeType: string }> { return this.handleCategoryIcon(req); } @Post('logo') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Upload the site logo (admin only)' }) @ApiConsumes('multipart/form-data') @UseInterceptors(FileInterceptor('file')) async uploadLogo(@Req() req: any): Promise<{ publicUrl: string; originalFilename: string }> { return this.handleLogoUpload(req); } /** * Allowed image formats for the site logo. Detection is performed by * decoding the full payload with sharp — never by trusting the * multipart `mimetype` header, the original filename extension, or * the browser's `accept="image/*"` filter. */ private static readonly ALLOWED_LOGO_FORMATS: ReadonlySet = new Set([ 'png', 'jpeg', 'gif', 'webp', ]); private static readonly LOGO_ERROR_MESSAGE = 'Logo must be a valid PNG, JPEG, GIF, or WebP image.'; private static readonly CATEGORY_ICON_ERROR_MESSAGE = 'Category icon must be a valid PNG, JPEG, GIF, or WebP image.'; private async handleCategoryIcon(req: any): Promise<{ id: string; publicUrl: string; width: number; height: number; mimeType: string; storedPath: string; size: number; originalFilename: string }> { const file = req.file as any; if (!file) throw new BadRequestException('No file uploaded under field "file"'); if (file.size > this.globalLimit) { throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`); } const categoryId = String(req.body?.categoryId ?? '').trim(); const finalDir = path.join(this.uploadDir, 'icons'); if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true }); const hasId = !!categoryId; const fileName = hasId ? `${categoryId}.png` : safeFilename(file.originalname || 'file.png'); const finalPath = path.join(finalDir, fileName); let stored: Buffer; try { stored = await sharp(file.buffer) .resize(128, 128, { fit: 'cover', position: 'centre' }) .png() .toBuffer(); } catch { throw new BadRequestException(UploadsController.CATEGORY_ICON_ERROR_MESSAGE); } fs.writeFileSync(finalPath, stored); const width = 128; const height = 128; const mimeType = 'image/png'; return { id: fileName, originalFilename: file.originalname || 'file.png', storedPath: finalPath, publicUrl: `/uploads/icons/${fileName}`, width, height, size: stored.length, mimeType, }; } private async handleLogoUpload(req: any): Promise<{ publicUrl: string; originalFilename: string }> { const file = req.file as any; if (!file) throw new BadRequestException('No file uploaded under field "file"'); if (file.size > this.globalLimit) { throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`); } let metadata: { format?: keyof FormatEnum; width?: number; height?: number }; try { metadata = await sharp(file.buffer).metadata(); } catch { throw new BadRequestException(UploadsController.LOGO_ERROR_MESSAGE); } const fmt = metadata.format; if (!fmt || !UploadsController.ALLOWED_LOGO_FORMATS.has(fmt)) { throw new BadRequestException(UploadsController.LOGO_ERROR_MESSAGE); } const safeName = safeFilename(file.originalname || 'logo'); const finalPath = path.join(this.uploadDir, safeName); fs.writeFileSync(finalPath, file.buffer); return { publicUrl: `/uploads/${safeName}`, originalFilename: file.originalname || safeName, }; } }