134 lines
5.4 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
} |