Files
HIPCTF2/backend/src/modules/uploads/uploads.controller.ts
T

134 lines
5.4 KiB
TypeScript

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<string>('UPLOAD_DIR', './data/uploads'));
this.globalLimit = parseUploadSizeLimit(this.config.get<string>('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<keyof FormatEnum> = new Set<keyof FormatEnum>([
'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,
};
}
}