AI Implementation feature(861): Admin Area Challenges: List, Import/Export and Add/Edit Modal (#40)

This commit was merged in pull request #40.
This commit is contained in:
2026-07-22 20:26:30 +00:00
parent 1a40e5708a
commit 8dcbd48045
41 changed files with 4548 additions and 145 deletions
@@ -0,0 +1,162 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Put,
Query,
Req,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { AdminGuard } from '../../common/guards/admin.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import {
ChallengeIdParamSchema,
CHALLENGE_FORMAT,
CHALLENGE_FORMAT_VERSION,
CreateChallengeSchema,
ImportDocumentSchema,
ImportQuerySchema,
ListChallengesQuerySchema,
UpdateChallengeSchema,
} from './dto/challenges.dto';
import {
AdminChallengesService,
ChallengeDetailView,
ChallengeListItem,
ImportPreview,
ImportResult,
} from './challenges.service';
import { ChallengeFilesService } from './challenge-files.service';
@ApiTags('admin')
@ApiBearerAuth()
@Controller('api/v1/admin/challenges')
@UseGuards(AdminGuard)
@Roles('admin')
export class AdminChallengesController {
constructor(
private readonly challenges: AdminChallengesService,
private readonly files: ChallengeFilesService,
) {}
@Get()
@ApiOperation({ summary: 'List all challenges (admin only)' })
async list(
@Query(new ZodValidationPipe(ListChallengesQuerySchema)) query: { q?: string; categoryId?: string },
): Promise<ChallengeListItem[]> {
return this.challenges.list(query);
}
@Get('export')
@ApiOperation({ summary: 'Export all challenges (admin only)' })
async exportAll(@Res() res: Response): Promise<void> {
const doc = await this.challenges.exportAll();
const date = new Date().toISOString().slice(0, 10);
res
.status(HttpStatus.OK)
.setHeader('Content-Type', 'application/json; charset=utf-8')
.setHeader(
'Content-Disposition',
`attachment; filename="challenges-export-${date}.json"`,
)
.send(JSON.stringify(doc));
}
@Post('import')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Import challenges; two-phase (preview/confirm) (admin only)' })
async import(
@Body(new ZodValidationPipe(ImportDocumentSchema)) body: any,
@Query(new ZodValidationPipe(ImportQuerySchema)) query: { confirm?: string },
): Promise<ImportPreview | ImportResult> {
if (query.confirm !== 'overwrite') {
const preview = await this.challenges.previewImport(body);
return preview;
}
const result = await this.challenges.importConfirmed(body);
return result;
}
@Post('files/stage')
@HttpCode(HttpStatus.CREATED)
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: 'Stage a challenge attachment for later commit (admin only)' })
async stageFile(@UploadedFile() file: any): Promise<{
token: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
}> {
if (!file) throw new BadRequestException('No file uploaded under field "file"');
const buffer = file.buffer ?? Buffer.alloc(0);
const record = this.files.stage(buffer, file.originalname ?? 'file', file.mimetype ?? 'application/octet-stream');
return {
token: record.token,
originalFilename: record.originalFilename,
mimeType: record.mimeType,
sizeBytes: record.sizeBytes,
};
}
@Delete('files/stage/:token')
@ApiOperation({ summary: 'Discard a staged challenge attachment (admin only)' })
discardFile(@Param('token') token: string): { ok: true } {
this.files.discard(token);
return { ok: true };
}
@Get(':id')
@ApiOperation({ summary: 'Get a single challenge with details (admin only)' })
async detail(
@Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string },
): Promise<ChallengeDetailView> {
return this.challenges.getDetail(params.id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a challenge (admin only)' })
async create(
@Body(new ZodValidationPipe(CreateChallengeSchema)) body: any,
): Promise<ChallengeDetailView> {
return this.challenges.create(body);
}
@Put(':id')
@ApiOperation({ summary: 'Update a challenge (admin only)' })
async update(
@Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdateChallengeSchema)) body: any,
): Promise<ChallengeDetailView> {
return this.challenges.update(params.id, body);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a challenge (admin only)' })
async remove(
@Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string },
): Promise<void> {
await this.challenges.remove(params.id);
}
}
// Expose constants for tests/UI without circular imports.
export { CHALLENGE_FORMAT, CHALLENGE_FORMAT_VERSION };
export { ERROR_CODES };
export { ApiError };
+19 -3
View File
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../../database/entities/user.entity';
import { CategoryEntity } from '../../database/entities/category.entity';
import { ChallengeEntity } from '../../database/entities/challenge.entity';
import { ChallengeFileEntity } from '../../database/entities/challenge-file.entity';
import { SolveEntity } from '../../database/entities/solve.entity';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { SettingsModule } from '../settings/settings.module';
@@ -13,16 +15,30 @@ import { AdminGeneralController } from './admin-general.controller';
import { AdminGeneralService } from './general.service';
import { AdminCategoriesController } from './admin-categories.controller';
import { AdminCategoriesService } from './categories.service';
import { AdminChallengesController } from './admin-challenges.controller';
import { AdminChallengesService } from './challenges.service';
import { ChallengeFilesService } from './challenge-files.service';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity]),
TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity]),
AuthModule,
UsersModule,
SettingsModule,
CommonModule,
],
providers: [AdminService, AdminGeneralService, AdminCategoriesService],
controllers: [AdminController, AdminGeneralController, AdminCategoriesController],
providers: [
AdminService,
AdminGeneralService,
AdminCategoriesService,
AdminChallengesService,
ChallengeFilesService,
],
controllers: [
AdminController,
AdminGeneralController,
AdminCategoriesController,
AdminChallengesController,
],
})
export class AdminModule {}
@@ -0,0 +1,198 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import { v4 as uuid } from 'uuid';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { parseUploadSizeLimit } from '../../common/utils/upload';
export interface StagedFileRecord {
token: string;
storedFilename: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
stagedAt: number;
}
@Injectable()
export class ChallengeFilesService implements OnModuleDestroy {
private readonly logger = new Logger(ChallengeFilesService.name);
private readonly uploadDir: string;
private readonly finalDir: string;
private readonly stagingDir: string;
private readonly globalLimit: number;
private readonly staging = new Map<string, StagedFileRecord>();
constructor(private readonly config: ConfigService) {
this.uploadDir = path.resolve(this.config.get<string>('UPLOAD_DIR', './data/uploads'));
this.finalDir = path.join(this.uploadDir, 'challenges');
this.stagingDir = path.join(this.finalDir, '.staging');
this.globalLimit = parseUploadSizeLimit(this.config.get<string>('UPLOAD_SIZE_LIMIT', '100mb'));
this.ensureDirs();
}
private ensureDirs(): void {
for (const dir of [this.finalDir, this.stagingDir]) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
}
getFinalDir(): string {
return this.finalDir;
}
getGlobalLimit(): number {
return this.globalLimit;
}
/** Stage a buffer and return the staging record. */
stage(
buffer: Buffer,
originalFilename: string,
mimeType: string,
): StagedFileRecord {
this.ensureDirs();
if (buffer.length > this.globalLimit) {
throw ApiError.validation(
`File exceeds the global size limit (${buffer.length} > ${this.globalLimit}).`,
[{ path: 'file', message: `File exceeds UPLOAD_SIZE_LIMIT (${this.globalLimit})` }],
);
}
const token = uuid();
const storedFilename = `${token}`;
const target = path.join(this.stagingDir, storedFilename);
try {
fs.writeFileSync(target, buffer);
} catch (err) {
throw ApiError.internal('Failed to write staged file');
}
const record: StagedFileRecord = {
token,
storedFilename,
originalFilename: originalFilename || 'file',
mimeType: mimeType || 'application/octet-stream',
sizeBytes: buffer.length,
stagedAt: Date.now(),
};
this.staging.set(token, record);
return record;
}
/** Look up a staged record by token. */
lookup(token: string): StagedFileRecord | undefined {
return this.staging.get(token);
}
/** Move a staged file to the final directory and return the final filename. */
commit(token: string): string {
const record = this.staging.get(token);
if (!record) throw ApiError.notFound('Staged file not found');
const stagedPath = path.join(this.stagingDir, record.storedFilename);
const finalPath = path.join(this.finalDir, record.storedFilename);
try {
fs.renameSync(stagedPath, finalPath);
} catch {
// Fallback: copy + unlink (handles cross-device moves).
try {
fs.copyFileSync(stagedPath, finalPath);
fs.unlinkSync(stagedPath);
} catch {
throw ApiError.internal('Failed to commit staged file');
}
}
this.staging.delete(token);
return record.storedFilename;
}
/** Remove a staged file and clear the record. Best-effort, logs failures. */
discard(token: string): void {
const record = this.staging.get(token);
if (!record) return;
const stagedPath = path.join(this.stagingDir, record.storedFilename);
try {
if (fs.existsSync(stagedPath)) fs.unlinkSync(stagedPath);
} catch (err) {
this.logger.warn(`Failed to discard staged file ${token}: ${(err as Error).message}`);
}
this.staging.delete(token);
}
/** Best-effort unlink of a committed file. Logs failures, never throws. */
removeFinal(storedFilename: string): void {
if (!storedFilename) return;
const target = path.join(this.finalDir, storedFilename);
if (!fs.existsSync(target)) return;
try {
fs.unlinkSync(target);
} catch (err) {
this.logger.warn(
`Failed to remove challenge file ${storedFilename}: ${(err as Error).message}`,
);
}
}
/** Best-effort rename of an already-committed file to a new stored filename. */
renameFinal(fromStored: string, toStored: string): void {
if (!fromStored || !toStored || fromStored === toStored) return;
const from = path.join(this.finalDir, fromStored);
const to = path.join(this.finalDir, toStored);
try {
if (!fs.existsSync(from)) {
if (fs.existsSync(to)) return;
return;
}
if (fs.existsSync(to)) fs.unlinkSync(to);
fs.renameSync(from, to);
} catch (err) {
this.logger.warn(
`Failed to rename challenge file ${fromStored} -> ${toStored}: ${(err as Error).message}`,
);
}
}
/** Resolve an absolute disk path for a committed stored filename. */
pathForFinal(storedFilename: string): string {
return path.join(this.finalDir, storedFilename);
}
/** Read a committed file from disk and return its bytes. */
readFinal(storedFilename: string): Buffer | null {
const target = path.join(this.finalDir, storedFilename);
try {
if (!fs.existsSync(target)) return null;
return fs.readFileSync(target);
} catch (err) {
this.logger.warn(
`Failed to read challenge file ${storedFilename}: ${(err as Error).message}`,
);
return null;
}
}
/** Write a new file to the final directory; returns the stored filename. */
writeFinal(buffer: Buffer, storedFilename: string): string {
this.ensureDirs();
const target = path.join(this.finalDir, storedFilename);
fs.writeFileSync(target, buffer);
return storedFilename;
}
onModuleDestroy(): void {
// Clean up any in-process staging state. Files are best-effort unlinked
// so abandoned sessions do not leak; committed files are intentionally
// preserved because they are owned by persisted challenges.
for (const token of Array.from(this.staging.keys())) {
this.discard(token);
}
}
/** Helper for tests: surface the staging map without exposing internals. */
hasStaging(token: string): boolean {
return this.staging.has(token);
}
}
// Re-export the limit error code for callers that need to render it.
export const CHALLENGE_FILE_LIMIT_CODE = ERROR_CODES.CHALLENGE_FILE_LIMIT;
@@ -0,0 +1,636 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { ChallengeEntity } from '../../database/entities/challenge.entity';
import { ChallengeFileEntity } from '../../database/entities/challenge-file.entity';
import { CategoryEntity } from '../../database/entities/category.entity';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { ChallengeFilesService, StagedFileRecord } from './challenge-files.service';
import {
CHALLENGE_FORMAT,
CHALLENGE_FORMAT_VERSION,
CreateChallengePayload,
ImportChallengePayload,
ImportDocumentPayload,
UpdateChallengePayload,
} from './dto/challenges.dto';
export interface ChallengeListItem {
id: string;
name: string;
categoryAbbreviation: string;
categoryIconPath: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
protocol: 'NC' | 'WEB';
enabled: boolean;
createdAt: string;
updatedAt: string;
fileCount: number;
solveCount: number;
}
export interface ChallengeFileView {
id: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
createdAt: string;
}
export interface ChallengeDetailView {
id: string;
name: string;
descriptionMd: string;
categoryId: string;
categoryAbbreviation: string;
categoryIconPath: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
protocol: 'NC' | 'WEB';
port: number | null;
ipAddress: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
files: ChallengeFileView[];
}
export interface ImportPreview {
total: number;
conflicts: string[];
unknownCategories: string[];
warnings: string[];
}
export interface ImportResult {
imported: number;
skipped: { name: string; reason: string }[];
warnings: string[];
}
const NAME_TAKEN_CODES = new Set(['SQLITE_CONSTRAINT_UNIQUE', 'SQLITE_CONSTRAINT']);
function isValidIpOrHostname(value: string): boolean {
if (!value) return true;
if (value.length > 255) return false;
if (value.includes(':')) {
// IPv6 (allow simple bracketed forms).
return /^[0-9A-Fa-f:.]+$/.test(value);
}
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/.exec(value);
if (ipv4) {
return value.split('.').every((p) => {
if (p.length === 0 || p.length > 3) return false;
if (p.length > 1 && p.startsWith('0')) return false;
const n = Number(p);
return Number.isInteger(n) && n >= 0 && n <= 255;
});
}
if (value.length === 0 || value.length > 253) return false;
if (value.startsWith('.') || value.endsWith('.')) return false;
const labels = value.split('.');
if (labels.length < 2) return false;
if (/^[0-9]+$/.test(labels[0])) return false;
return labels.every((label) => /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/.test(label));
}
@Injectable()
export class AdminChallengesService {
constructor(
@InjectRepository(ChallengeEntity) private readonly challenges: Repository<ChallengeEntity>,
@InjectRepository(ChallengeFileEntity) private readonly files: Repository<ChallengeFileEntity>,
@InjectRepository(CategoryEntity) private readonly categories: Repository<CategoryEntity>,
private readonly dataSource: DataSource,
private readonly fileService: ChallengeFilesService,
) {}
async list(query: { q?: string; categoryId?: string }): Promise<ChallengeListItem[]> {
const qb = this.challenges
.createQueryBuilder('c')
.leftJoin(CategoryEntity, 'cat', 'cat.id = c.categoryId')
.leftJoin(ChallengeFileEntity, 'f', 'f.challenge_id = c.id')
.leftJoin('solve', 's', 's.challenge_id = c.id')
.select('c.id', 'id')
.addSelect('c.name', 'name')
.addSelect('cat.abbreviation', 'categoryAbbreviation')
.addSelect('cat.icon_path', 'categoryIconPath')
.addSelect('c.difficulty', 'difficulty')
.addSelect('c.protocol', 'protocol')
.addSelect('c.enabled', 'enabled')
.addSelect('c.created_at', 'createdAt')
.addSelect('c.updated_at', 'updatedAt')
.addSelect('COUNT(DISTINCT f.id)', 'fileCount')
.addSelect('COUNT(DISTINCT s.id)', 'solveCount')
.groupBy('c.id')
.addGroupBy('cat.abbreviation')
.addGroupBy('cat.icon_path')
.orderBy('LOWER(c.name)', 'ASC')
.addOrderBy('c.name', 'ASC');
const q = (query.q ?? '').trim();
if (q) {
qb.andWhere('LOWER(c.name) LIKE LOWER(:q)', { q: `%${q}%` });
}
if (query.categoryId) {
qb.andWhere('c.category_id = :categoryId', { categoryId: query.categoryId });
}
const rows: any[] = await qb.getRawMany();
return rows.map((row) => ({
id: row.id,
name: row.name,
categoryAbbreviation: row.categoryAbbreviation ?? '',
categoryIconPath: row.categoryIconPath ?? '',
difficulty: row.difficulty,
protocol: row.protocol,
enabled: Boolean(row.enabled),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
fileCount: Number(row.fileCount ?? 0),
solveCount: Number(row.solveCount ?? 0),
}));
}
async getDetail(id: string): Promise<ChallengeDetailView> {
const row = await this.challenges
.createQueryBuilder('c')
.leftJoinAndSelect(CategoryEntity, 'cat', 'cat.id = c.category_id')
.where('c.id = :id', { id })
.getRawOne();
if (!row) throw ApiError.notFound('Challenge not found');
const files = await this.files.find({ where: { challengeId: id } });
return {
id: row.c_id,
name: row.c_name,
descriptionMd: row.c_description_md ?? '',
categoryId: row.c_category_id,
categoryAbbreviation: row.cat_abbreviation ?? '',
categoryIconPath: row.cat_icon_path ?? '',
difficulty: row.c_difficulty,
initialPoints: row.c_initial_points,
minimumPoints: row.c_minimum_points,
decaySolves: row.c_decay_solves,
flag: row.c_flag ?? '',
protocol: row.c_protocol,
port: row.c_port ?? null,
ipAddress: row.c_ip_address ?? '',
enabled: Boolean(row.c_enabled),
createdAt: row.c_created_at,
updatedAt: row.c_updated_at,
files: files.map((f) => ({
id: f.id,
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
createdAt: f.createdAt,
})),
};
}
async create(payload: CreateChallengePayload): Promise<ChallengeDetailView> {
await this.assertCategoryExists(payload.categoryId);
this.assertPort(payload.protocol, payload.port ?? null);
this.assertPoints(payload.initialPoints, payload.minimumPoints);
this.assertIpAddress(payload.ipAddress);
const id = uuid();
try {
const created = await this.dataSource.transaction(async (manager) => {
const row = manager.create(ChallengeEntity, {
id,
name: payload.name,
descriptionMd: payload.descriptionMd ?? '',
categoryId: payload.categoryId,
difficulty: payload.difficulty,
initialPoints: payload.initialPoints,
minimumPoints: payload.minimumPoints,
decaySolves: payload.decaySolves,
flag: payload.flag,
protocol: payload.protocol,
port: payload.protocol === 'NC' ? payload.port ?? null : payload.port ?? null,
ipAddress: payload.ipAddress ?? '',
enabled: payload.enabled ?? true,
});
await manager.save(row);
const finalIds = await this.applyFiles(manager, id, payload.files ?? [], []);
return { id, finalIds };
});
// commit files outside transaction: rename staged → final
const finalIdMap = new Map<string, string>();
for (const manifest of payload.files ?? []) {
if (manifest.stagedToken && created.finalIds.includes(manifest.stagedToken)) {
const final = this.fileService.commit(manifest.stagedToken);
finalIdMap.set(manifest.stagedToken, final);
}
}
// any staged tokens still in pending state are discarded
for (const manifest of payload.files ?? []) {
if (manifest.stagedToken && !finalIdMap.has(manifest.stagedToken)) {
this.fileService.discard(manifest.stagedToken);
}
}
return this.getDetail(id);
} catch (err: any) {
this.handleNameTaken(err);
throw err;
}
}
async update(id: string, payload: UpdateChallengePayload): Promise<ChallengeDetailView> {
const existing = await this.challenges.findOne({ where: { id } });
if (!existing) throw ApiError.notFound('Challenge not found');
if (payload.categoryId) await this.assertCategoryExists(payload.categoryId);
const effectiveProtocol = payload.protocol ?? existing.protocol;
const effectivePort = payload.port !== undefined ? payload.port : existing.port;
this.assertPort(effectiveProtocol, effectivePort ?? null);
if (payload.initialPoints !== undefined && payload.minimumPoints !== undefined) {
this.assertPoints(payload.initialPoints, payload.minimumPoints);
}
if (payload.ipAddress !== undefined) this.assertIpAddress(payload.ipAddress);
const beforeFiles = await this.files.find({ where: { challengeId: id } });
const keepIds = new Set(
(payload.files ?? [])
.map((f) => f.id)
.filter((x): x is string => typeof x === 'string'),
);
try {
await this.dataSource.transaction(async (manager) => {
if (payload.name !== undefined) existing.name = payload.name;
if (payload.descriptionMd !== undefined) existing.descriptionMd = payload.descriptionMd;
if (payload.categoryId !== undefined) existing.categoryId = payload.categoryId;
if (payload.difficulty !== undefined) existing.difficulty = payload.difficulty;
if (payload.initialPoints !== undefined) existing.initialPoints = payload.initialPoints;
if (payload.minimumPoints !== undefined) existing.minimumPoints = payload.minimumPoints;
if (payload.decaySolves !== undefined) existing.decaySolves = payload.decaySolves;
if (payload.flag !== undefined) existing.flag = payload.flag;
if (payload.protocol !== undefined) existing.protocol = payload.protocol;
if (payload.port !== undefined) {
existing.port = payload.protocol === 'WEB' && payload.port === null ? null : payload.port;
}
if (payload.ipAddress !== undefined) existing.ipAddress = payload.ipAddress;
if (payload.enabled !== undefined) existing.enabled = payload.enabled;
existing.updatedAt = new Date().toISOString();
await manager.save(existing);
// Mark file removals.
const toRemove = beforeFiles.filter((f) => !keepIds.has(f.id));
if (toRemove.length > 0) {
await manager.delete(ChallengeFileEntity, { id: In(toRemove.map((f) => f.id)) });
}
await this.applyFiles(manager, id, payload.files ?? [], Array.from(keepIds));
});
// Best-effort post-commit filesystem cleanup for removed files.
for (const f of beforeFiles) {
if (!keepIds.has(f.id)) {
this.fileService.removeFinal(f.storedFilename);
}
}
// Commit remaining staged tokens.
for (const manifest of payload.files ?? []) {
if (manifest.stagedToken) {
try {
this.fileService.commit(manifest.stagedToken);
} catch {
this.fileService.discard(manifest.stagedToken);
}
}
}
return this.getDetail(id);
} catch (err: any) {
this.handleNameTaken(err);
throw err;
}
}
async remove(id: string): Promise<void> {
const stored = await this.files.find({ where: { challengeId: id } });
await this.dataSource.transaction(async (manager) => {
const res = await manager.delete(ChallengeEntity, { id });
if (!res.affected) throw ApiError.notFound('Challenge not found');
});
for (const f of stored) {
this.fileService.removeFinal(f.storedFilename);
}
}
async exportAll(): Promise<{
format: typeof CHALLENGE_FORMAT;
version: number;
exportedAt: string;
challenges: any[];
}> {
const cats = await this.categories.find();
const catMap = new Map<string, CategoryEntity>();
for (const c of cats) catMap.set(c.id, c);
const rows = await this.challenges
.createQueryBuilder('c')
.orderBy('LOWER(c.name)', 'ASC')
.addOrderBy('c.name', 'ASC')
.getMany();
const challenges: any[] = [];
for (const c of rows) {
const cat = catMap.get(c.categoryId);
if (!cat) continue;
const catKey = cat.systemKey ?? cat.abbreviation;
const files = await this.files.find({ where: { challengeId: c.id } });
const outFiles = files.map((f) => {
const data = this.fileService.readFinal(f.storedFilename);
const dataBase64 = data ? data.toString('base64') : '';
return {
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
dataBase64,
};
});
challenges.push({
name: c.name,
descriptionMd: c.descriptionMd,
categorySystemKey: catKey,
difficulty: c.difficulty,
initialPoints: c.initialPoints,
minimumPoints: c.minimumPoints,
decaySolves: c.decaySolves,
flag: c.flag,
protocol: c.protocol,
port: c.port ?? null,
ipAddress: c.ipAddress,
enabled: c.enabled,
files: outFiles,
});
}
return {
format: CHALLENGE_FORMAT,
version: CHALLENGE_FORMAT_VERSION,
exportedAt: new Date().toISOString(),
challenges,
};
}
async previewImport(doc: ImportDocumentPayload): Promise<ImportPreview> {
const allCats = await this.categories.find();
const catMap = new Map<string, CategoryEntity>();
for (const c of allCats) {
const key = c.systemKey ?? c.abbreviation;
catMap.set(key.toLowerCase(), c);
}
const existingNames = await this.challenges
.createQueryBuilder('c')
.select('LOWER(c.name)', 'lname')
.getRawMany()
.then((rows) => rows.map((r) => r.lname));
const existingSet = new Set(existingNames);
const preview: ImportPreview = {
total: doc.challenges.length,
conflicts: [],
unknownCategories: [],
warnings: [],
};
for (const ch of doc.challenges) {
const cat = catMap.get(ch.categorySystemKey.toLowerCase());
if (!cat) preview.unknownCategories.push(ch.categorySystemKey);
if (existingSet.has(ch.name.toLowerCase())) preview.conflicts.push(ch.name);
}
if (preview.unknownCategories.length > 0) {
preview.warnings.push(
`${preview.unknownCategories.length} challenge(s) reference categories that do not exist locally and will be skipped.`,
);
}
return preview;
}
async importConfirmed(doc: ImportDocumentPayload): Promise<ImportResult> {
const allCats = await this.categories.find();
const catMap = new Map<string, CategoryEntity>();
for (const c of allCats) {
const key = (c.systemKey ?? c.abbreviation).toLowerCase();
catMap.set(key, c);
}
// Stage every file up front so any FS failure aborts before DB writes.
const stagedByName = new Map<string, Array<{ storedFilename: string; manifest: any; staged: StagedFileRecord }>>();
for (const ch of doc.challenges) {
const stagedList: Array<{ storedFilename: string; manifest: any; staged: StagedFileRecord }> = [];
for (const f of ch.files ?? []) {
let bytes: Buffer;
try {
bytes = Buffer.from(f.dataBase64, 'base64');
} catch {
this.rollbackStaged(stagedByName);
throw ApiError.validation(
'File payload is not valid base64',
[{ path: 'challenges.files.dataBase64', message: 'Not valid base64' }],
);
}
if (bytes.length !== f.sizeBytes) {
this.rollbackStaged(stagedByName);
throw ApiError.validation(
'File sizeBytes does not match decoded data',
[{ path: 'challenges.files.sizeBytes', message: 'sizeBytes mismatch' }],
);
}
const staged = this.fileService.stage(bytes, f.originalFilename, f.mimeType);
stagedList.push({ storedFilename: staged.storedFilename, manifest: f, staged });
}
stagedByName.set(ch.name, stagedList);
}
const result: ImportResult = { imported: 0, skipped: [], warnings: [] };
try {
await this.dataSource.transaction(async (manager) => {
const challengeRepo = manager.getRepository(ChallengeEntity);
const fileRepo = manager.getRepository(ChallengeFileEntity);
for (const ch of doc.challenges) {
const cat = catMap.get(ch.categorySystemKey.toLowerCase());
if (!cat) {
result.skipped.push({ name: ch.name, reason: `Unknown category: ${ch.categorySystemKey}` });
continue;
}
// Upsert by case-insensitive name.
const existing = await challengeRepo
.createQueryBuilder('c')
.where('LOWER(c.name) = :n', { n: ch.name.toLowerCase() })
.getOne();
if (existing) {
await manager.delete(ChallengeEntity, { id: existing.id });
}
const row = challengeRepo.create({
id: uuid(),
name: ch.name,
descriptionMd: ch.descriptionMd ?? '',
categoryId: cat.id,
difficulty: ch.difficulty,
initialPoints: ch.initialPoints,
minimumPoints: ch.minimumPoints,
decaySolves: ch.decaySolves,
flag: ch.flag,
protocol: ch.protocol,
port: ch.port ?? null,
ipAddress: ch.ipAddress ?? '',
enabled: ch.enabled ?? true,
});
await manager.save(row);
const staged = stagedByName.get(ch.name) ?? [];
for (const item of staged) {
const fileRow = fileRepo.create({
id: uuid(),
challengeId: row.id,
originalFilename: item.staged.originalFilename,
storedFilename: item.storedFilename,
mimeType: item.staged.mimeType,
sizeBytes: item.staged.sizeBytes,
});
await manager.save(fileRow);
}
result.imported += 1;
}
});
} catch (err) {
// DB write failed: roll back every staged file and re-throw.
this.rollbackStaged(stagedByName);
throw err;
}
// Commit each staged file by renaming to its final filename (the
// ChallengeFile rows already reference those names).
for (const list of stagedByName.values()) {
for (const item of list) {
try {
this.fileService.commit(item.staged.token);
} catch (err) {
this.fileService.discard(item.staged.token);
console.log(
`[import] Failed to commit file ${item.storedFilename}: ${(err as Error).message}`,
);
}
}
}
if (result.skipped.length > 0) {
result.warnings.push(`${result.skipped.length} challenge(s) skipped.`);
}
return result;
}
private rollbackStaged(
map: Map<string, Array<{ storedFilename: string; manifest: any; staged: StagedFileRecord }>>,
): void {
for (const list of map.values()) {
for (const item of list) this.fileService.discard(item.staged.token);
}
}
private async assertCategoryExists(categoryId: string): Promise<void> {
const cat = await this.categories.findOne({ where: { id: categoryId } });
if (!cat) {
throw new ApiError(
ERROR_CODES.CHALLENGE_INVALID_CATEGORY,
'Invalid challenge category',
400,
[{ path: 'categoryId', message: 'Category does not exist' }],
);
}
}
private assertPort(protocol: 'NC' | 'WEB', port: number | null): void {
if (protocol === 'NC') {
if (port === null || port === undefined || !Number.isInteger(port) || port < 1 || port > 65535) {
throw ApiError.validation('Port is required for NC protocol', [
{ path: 'port', message: 'Port is required for NC' },
]);
}
return;
}
if (port !== null && port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) {
throw ApiError.validation('Port must be between 1 and 65535', [
{ path: 'port', message: 'Port out of range' },
]);
}
}
private assertPoints(initial: number, minimum: number): void {
if (!Number.isInteger(initial) || initial < 1 || initial > 100000) {
throw ApiError.validation('Initial points out of range', [
{ path: 'initialPoints', message: 'Initial points must be between 1 and 100000' },
]);
}
if (!Number.isInteger(minimum) || minimum < 1 || minimum > 100000) {
throw ApiError.validation('Minimum points out of range', [
{ path: 'minimumPoints', message: 'Minimum points must be between 1 and 100000' },
]);
}
if (minimum > initial) {
throw ApiError.validation('Minimum points must be less than or equal to initial points', [
{ path: 'minimumPoints', message: 'Minimum points must be less than or equal to initial points' },
]);
}
}
private assertIpAddress(value: string | undefined): void {
if (!value) return;
if (!isValidIpOrHostname(value)) {
throw ApiError.validation('Invalid IP or hostname', [
{ path: 'ipAddress', message: 'Must be a valid IPv4/IPv6 address or hostname' },
]);
}
}
private handleNameTaken(err: any): void {
if (err && typeof err === 'object' && NAME_TAKEN_CODES.has(err.code)) {
throw new ApiError(
ERROR_CODES.CHALLENGE_NAME_TAKEN,
'Challenge name is already taken',
409,
[{ path: 'name', message: 'Name must be unique' }],
);
}
}
private async applyFiles(
manager: any,
challengeId: string,
manifests: Array<{
id?: string;
stagedToken?: string;
originalFilename?: string;
mimeType?: string;
sizeBytes?: number;
remove?: boolean;
}>,
keepIds: string[],
): Promise<string[]> {
const committed: string[] = [];
const fileRepo = manager.getRepository
? manager.getRepository(ChallengeFileEntity)
: manager;
for (const m of manifests) {
if (m.remove) continue;
if (m.id) continue; // existing files are kept
if (m.stagedToken) {
const staged = this.fileService.lookup(m.stagedToken);
if (!staged) continue;
const fileRow = fileRepo.create({
id: uuid(),
challengeId,
originalFilename: staged.originalFilename,
storedFilename: staged.storedFilename,
mimeType: staged.mimeType,
sizeBytes: staged.sizeBytes,
});
await fileRepo.save(fileRow);
committed.push(m.stagedToken);
}
}
void keepIds;
return committed;
}
}
@@ -0,0 +1,150 @@
import { z } from 'zod';
export const DIFFICULTIES = ['LOW', 'MEDIUM', 'HIGH'] as const;
export const PROTOCOLS = ['NC', 'WEB'] as const;
export const CHALLENGE_FORMAT_VERSION = 1;
export const CHALLENGE_FORMAT = 'hipctf-challenges';
export const ChallengeNameSchema = z
.string()
.trim()
.min(1, 'Name is required')
.max(128, 'Name must be 128 characters or fewer');
export const ChallengeDifficultySchema = z.enum(DIFFICULTIES);
export const ChallengeProtocolSchema = z.enum(PROTOCOLS);
export const ChallengeIdParamSchema = z.object({
id: z.string().min(1).max(64),
});
export const ListChallengesQuerySchema = z.object({
q: z.string().trim().min(1).max(128).optional(),
categoryId: z.string().min(1).max(64).optional(),
});
export type ListChallengesQuery = z.infer<typeof ListChallengesQuerySchema>;
export const ChallengeFileManifestSchema = z.object({
id: z.string().optional(),
stagedToken: z.string().min(1).max(128).optional(),
originalFilename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(255),
sizeBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
remove: z.boolean().optional(),
});
export type ChallengeFileManifest = z.infer<typeof ChallengeFileManifestSchema>;
const portField = z
.number()
.int()
.min(1)
.max(65535)
.nullable()
.optional();
const baseShape = {
descriptionMd: z.string().max(64_000),
categoryId: z.string().min(1).max(64),
difficulty: ChallengeDifficultySchema,
initialPoints: z.number().int().min(1).max(100000),
minimumPoints: z.number().int().min(1).max(100000),
decaySolves: z.number().int().min(1).max(10000),
flag: z.string().min(1, 'Flag is required').max(4096),
protocol: ChallengeProtocolSchema,
port: portField,
ipAddress: z.string().max(255).default(''),
enabled: z.boolean().default(true),
files: z.array(ChallengeFileManifestSchema).max(64).optional(),
};
export const CreateChallengeSchema = z
.object({
name: ChallengeNameSchema,
...baseShape,
})
.superRefine((val, ctx) => {
if (val.minimumPoints > val.initialPoints) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['minimumPoints'],
message: 'Minimum points must be less than or equal to initial points',
});
}
if (val.protocol === 'NC' && (val.port === null || val.port === undefined)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['port'],
message: 'Port is required for NC protocol',
});
}
});
export type CreateChallengePayload = z.infer<typeof CreateChallengeSchema>;
export const UpdateChallengeSchema = z
.object({
name: ChallengeNameSchema.optional(),
...baseShape,
files: z.array(ChallengeFileManifestSchema).max(64).optional(),
})
.superRefine((val, ctx) => {
if (val.initialPoints !== undefined && val.minimumPoints !== undefined && val.minimumPoints > val.initialPoints) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['minimumPoints'],
message: 'Minimum points must be less than or equal to initial points',
});
}
if (val.protocol === 'NC' && (val.port === null || val.port === undefined)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['port'],
message: 'Port is required for NC protocol',
});
}
});
export type UpdateChallengePayload = z.infer<typeof UpdateChallengeSchema>;
export const ImportQuerySchema = z.object({
confirm: z.string().optional(),
});
export const ImportChallengeFileSchema = z.object({
originalFilename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(255),
sizeBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
dataBase64: z.string().min(1).max(8 * 1024 * 1024),
});
export type ImportChallengeFilePayload = z.infer<typeof ImportChallengeFileSchema>;
export const ImportChallengeSchema = z.object({
name: ChallengeNameSchema,
descriptionMd: z.string().max(64_000),
categorySystemKey: z.string().min(1).max(64),
difficulty: ChallengeDifficultySchema,
initialPoints: z.number().int().min(1).max(100000),
minimumPoints: z.number().int().min(1).max(100000),
decaySolves: z.number().int().min(1).max(10000),
flag: z.string().min(1).max(4096),
protocol: ChallengeProtocolSchema,
port: portField,
ipAddress: z.string().max(255).default(''),
enabled: z.boolean().default(true),
files: z.array(ImportChallengeFileSchema).max(64).optional(),
});
export type ImportChallengePayload = z.infer<typeof ImportChallengeSchema>;
export const ImportDocumentSchema = z.object({
format: z.literal(CHALLENGE_FORMAT),
version: z.number().int().min(1).max(CHALLENGE_FORMAT_VERSION),
exportedAt: z.string().min(1),
challenges: z.array(ImportChallengeSchema),
});
export type ImportDocumentPayload = z.infer<typeof ImportDocumentSchema>;
export const StageChallengeFileResponseSchema = z.object({
token: z.string(),
originalFilename: z.string(),
mimeType: z.string(),
sizeBytes: z.number().int(),
});
export type StageChallengeFileResponse = z.infer<typeof StageChallengeFileResponseSchema>;
@@ -9,6 +9,15 @@ 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')
@@ -32,15 +41,6 @@ export class UploadsController {
return this.handleCategoryIcon(req);
}
@Post('challenge-file')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Upload a challenge attachment (admin only)' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file'))
async uploadChallengeFile(@Req() req: any) {
return this.handleGenericUpload(req, 'challenges');
}
@Post('logo')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Upload the site logo (admin only)' })
@@ -78,9 +78,6 @@ export class UploadsController {
const finalDir = path.join(this.uploadDir, 'icons');
if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true });
// When categoryId is provided we normalize to 128x128 and store at a
// deterministic URL keyed by id. When it is omitted (legacy / pre-existing
// tests) we fall back to a safe version of the original filename.
const hasId = !!categoryId;
const fileName = hasId ? `${categoryId}.png` : safeFilename(file.originalname || 'file.png');
const finalPath = path.join(finalDir, fileName);
@@ -110,28 +107,6 @@ export class UploadsController {
};
}
private handleGenericUpload(req: any, subdir: 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 safeName = (file.originalname || 'file').replace(/[^a-zA-Z0-9._-]/g, '_');
const finalDir = path.join(this.uploadDir, subdir);
if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true });
const finalPath = path.join(finalDir, safeName);
fs.writeFileSync(finalPath, file.buffer);
const publicUrl = `/uploads/${subdir}/${safeName}`;
return {
id: safeName,
originalFilename: file.originalname,
storedPath: finalPath,
publicUrl,
size: file.size,
mimeType: file.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"');
@@ -149,7 +124,6 @@ export class UploadsController {
throw new BadRequestException(UploadsController.LOGO_ERROR_MESSAGE);
}
const safeName = safeFilename(file.originalname || 'logo');
// Files live at the top level of UPLOAD_DIR (served directly at /uploads/...).
const finalPath = path.join(this.uploadDir, safeName);
fs.writeFileSync(finalPath, file.buffer);
return {
@@ -157,4 +131,4 @@ export class UploadsController {
originalFilename: file.originalname || safeName,
};
}
}
}