feat: Admin Area Challenges: List, Import/Export and Add/Edit Modal

This commit is contained in:
OpenVelo Agent
2026-07-22 20:26:27 +00:00
parent 6c18603a7d
commit 216795b5db
33 changed files with 4256 additions and 105 deletions
+8
View File
@@ -19,6 +19,14 @@ export const ERROR_CODES = {
PASSWORDS_DO_NOT_MATCH: 'PASSWORDS_DO_NOT_MATCH',
CATEGORY_HAS_CHALLENGES: 'CATEGORY_HAS_CHALLENGES',
SYSTEM_PROTECTED: 'SYSTEM_PROTECTED',
CHALLENGE_NAME_TAKEN: 'CHALLENGE_NAME_TAKEN',
CHALLENGE_INVALID_CATEGORY: 'CHALLENGE_INVALID_CATEGORY',
CHALLENGE_INVALID_POINTS: 'CHALLENGE_INVALID_POINTS',
CHALLENGE_INVALID_PORT: 'CHALLENGE_INVALID_PORT',
CHALLENGE_INVALID_IP: 'CHALLENGE_INVALID_IP',
CHALLENGE_FILE_LIMIT: 'CHALLENGE_FILE_LIMIT',
IMPORT_VALIDATION_FAILED: 'IMPORT_VALIDATION_FAILED',
IMPORT_PARTIAL_FAILURE: 'IMPORT_PARTIAL_FAILURE',
INTERNAL: 'INTERNAL',
} as const;
+2
View File
@@ -16,6 +16,7 @@ import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSyst
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from './migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
import { UpdateSystemCategoryKeys1700000000300 } from './migrations/1700000000300-UpdateSystemCategoryKeys';
import { RepairCategorySchemaAndSystemCategories1700000000400 } from './migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from './migrations/1700000000500-UpgradeChallengeAdminSchema';
import { DatabaseInitService } from './database-init.service';
const ENTITIES = [
@@ -35,6 +36,7 @@ const MIGRATIONS = [
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
];
@Global()
@@ -1,4 +1,12 @@
import { Entity, PrimaryColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import {
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { ChallengeEntity } from './challenge.entity';
@Entity('challenge_file')
@@ -10,13 +18,23 @@ export class ChallengeFileEntity {
@Column('text', { name: 'challenge_id' })
challengeId!: string;
@ManyToOne(() => ChallengeEntity)
@ManyToOne(() => ChallengeEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'challenge_id' })
challenge?: ChallengeEntity;
@Column('text', { name: 'original_filename' })
originalFilename!: string;
@Column('text', { name: 'stored_path' })
storedPath!: string;
@Index({ unique: true })
@Column('text', { name: 'stored_filename' })
storedFilename!: string;
@Column('text', { name: 'mime_type', default: 'application/octet-stream' })
mimeType!: string;
@Column('integer', { name: 'size_bytes', default: 0 })
sizeBytes!: number;
@CreateDateColumn({ type: 'text', name: 'created_at' })
createdAt!: string;
}
@@ -1,8 +1,17 @@
import { Entity, PrimaryColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
import {
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { CategoryEntity } from './category.entity';
export type Difficulty = 'low' | 'med' | 'high';
export type Protocol = 'nc' | 'web';
export type Difficulty = 'LOW' | 'MEDIUM' | 'HIGH';
export type Protocol = 'NC' | 'WEB';
@Entity('challenge')
export class ChallengeEntity {
@@ -23,22 +32,22 @@ export class ChallengeEntity {
@JoinColumn({ name: 'category_id' })
category?: CategoryEntity;
@Column('text', { default: 'low' })
@Column('text', { default: 'MEDIUM' })
difficulty!: Difficulty;
@Column('integer', { name: 'initial_points', default: 0 })
@Column('integer', { name: 'initial_points', default: 100 })
initialPoints!: number;
@Column('integer', { name: 'minimum_points', default: 0 })
@Column('integer', { name: 'minimum_points', default: 50 })
minimumPoints!: number;
@Column('integer', { name: 'decay_solves', default: 0 })
@Column('integer', { name: 'decay_solves', default: 10 })
decaySolves!: number;
@Column('text', { default: '' })
flag!: string;
@Column('text', { default: 'nc' })
@Column('text', { default: 'WEB' })
protocol!: Protocol;
@Column('integer', { nullable: true })
@@ -47,6 +56,12 @@ export class ChallengeEntity {
@Column('text', { name: 'ip_address', default: '' })
ipAddress!: string;
@Column('integer', { default: 1 })
enabled!: boolean;
@CreateDateColumn({ type: 'text', name: 'created_at' })
createdAt!: string;
@UpdateDateColumn({ type: 'text', name: 'updated_at' })
updatedAt!: string;
}
+23 -1
View File
@@ -1,4 +1,13 @@
import { Entity, PrimaryColumn, Column, Index, Unique } from 'typeorm';
import {
Entity,
PrimaryColumn,
Column,
Index,
Unique,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ChallengeEntity } from './challenge.entity';
@Entity('solve')
@Unique('uq_solve_challenge_user', ['challengeId', 'userId'])
@@ -11,6 +20,10 @@ export class SolveEntity {
@Column('text', { name: 'challenge_id' })
challengeId!: string;
@ManyToOne(() => ChallengeEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'challenge_id' })
challenge?: ChallengeEntity;
@Column('text', { name: 'user_id' })
userId!: string;
@@ -25,4 +38,13 @@ export class SolveEntity {
@Column('integer', { name: 'rank_bonus', default: 0 })
rankBonus!: number;
@Column('integer', { name: 'is_first', default: 0 })
isFirst!: boolean;
@Column('integer', { name: 'is_second', default: 0 })
isSecond!: boolean;
@Column('integer', { name: 'is_third', default: 0 })
isThird!: boolean;
}
@@ -0,0 +1,173 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Upgrades the challenge/file/solve schema to match the admin Challenges
* contract (Job 861):
*
* - canonical enums ('LOW'|'MEDIUM'|'HIGH', 'NC'|'WEB') with safer defaults;
* - `enabled`, `updated_at`, lower(name) unique index, scoring defaults;
* - ChallengeFile gains `stored_filename`, `mime_type`, `size_bytes`,
* `created_at` (unique stored_filename) and ON DELETE CASCADE from
* challenge;
* - Solve keeps the (challenge_id,user_id) uniqueness and gains the
* is_first/second/third award flags plus ON DELETE CASCADE from
* challenge while leaving the user FK at RESTRICT;
* - Category FK stays RESTRICT so the existing
* CATEGORY_HAS_CHALLENGES business rule is preserved.
*
* Strategy:
* - SQLite cannot alter FK actions in place. We rebuild the affected
* tables atomically (PRAGMA foreign_keys=OFF), preserving legacy data
* via INSERT...SELECT.
* - The migration is forward-only.
* - The migration runs inside the TypeORM-per-migration transaction
* (`runMigrations({ transaction: 'each' })`), so it must NOT issue an
* explicit BEGIN/COMMIT — those would nest and SQLite rejects nested
* transactions.
*/
export class UpgradeChallengeAdminSchema1700000000500 implements MigrationInterface {
name = 'UpgradeChallengeAdminSchema1700000000500';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`PRAGMA foreign_keys = OFF;`);
// Drop dependent indexes that reference columns we are about to drop.
await queryRunner.query(`DROP INDEX IF EXISTS "idx_challenge_category";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_challenge_file_challenge";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_solve_challenge";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_solve_user";`);
await queryRunner.query(`DROP INDEX IF EXISTS "uq_solve_challenge_user";`);
await queryRunner.query(`ALTER TABLE "challenge" RENAME TO "_challenge_old";`);
await queryRunner.query(`ALTER TABLE "challenge_file" RENAME TO "_challenge_file_old";`);
await queryRunner.query(`ALTER TABLE "solve" RENAME TO "_solve_old";`);
await queryRunner.query(`
CREATE TABLE "challenge" (
"id" TEXT PRIMARY KEY,
"name" TEXT NOT NULL,
"description_md" TEXT NOT NULL DEFAULT '',
"category_id" TEXT NOT NULL,
"difficulty" TEXT NOT NULL DEFAULT 'MEDIUM',
"initial_points" INTEGER NOT NULL DEFAULT 100,
"minimum_points" INTEGER NOT NULL DEFAULT 50,
"decay_solves" INTEGER NOT NULL DEFAULT 10,
"flag" TEXT NOT NULL DEFAULT '',
"protocol" TEXT NOT NULL DEFAULT 'WEB',
"port" INTEGER,
"ip_address" TEXT NOT NULL DEFAULT '',
"enabled" INTEGER NOT NULL DEFAULT 1,
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
"updated_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
CONSTRAINT "fk_challenge_category" FOREIGN KEY ("category_id") REFERENCES "category"("id") ON DELETE RESTRICT
);
`);
await queryRunner.query(`
INSERT INTO "challenge" (
"id","name","description_md","category_id","difficulty",
"initial_points","minimum_points","decay_solves","flag","protocol",
"port","ip_address","enabled","created_at","updated_at"
)
SELECT
"id","name","description_md","category_id",
CASE UPPER("difficulty")
WHEN 'LOW' THEN 'LOW'
WHEN 'MED' THEN 'MEDIUM'
WHEN 'HIGH' THEN 'HIGH'
ELSE 'LOW'
END,
CASE WHEN "initial_points" <= 0 THEN 100 ELSE "initial_points" END,
CASE
WHEN "minimum_points" <= 0 THEN MAX(1, CAST((CASE WHEN "initial_points" <= 0 THEN 100 ELSE "initial_points" END) / 2 AS INTEGER))
WHEN "minimum_points" > "initial_points" THEN "initial_points"
ELSE "minimum_points"
END,
CASE WHEN "decay_solves" <= 0 THEN 1 ELSE "decay_solves" END,
"flag",
CASE UPPER(COALESCE("protocol",'nc')) WHEN 'NC' THEN 'NC' ELSE 'WEB' END,
"port",
COALESCE("ip_address", ''),
1,
"created_at",
COALESCE(NULLIF("created_at",''), strftime('%Y-%m-%dT%H:%M:%fZ','now'))
FROM "_challenge_old";
`);
await queryRunner.query(`DROP TABLE "_challenge_old";`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_challenge_category" ON "challenge"("category_id");`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_challenge_name_lower" ON "challenge"(LOWER("name"));`);
await queryRunner.query(`
CREATE TABLE "challenge_file" (
"id" TEXT PRIMARY KEY,
"challenge_id" TEXT NOT NULL,
"original_filename" TEXT NOT NULL,
"stored_filename" TEXT NOT NULL,
"mime_type" TEXT NOT NULL DEFAULT 'application/octet-stream',
"size_bytes" INTEGER NOT NULL DEFAULT 0,
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
CONSTRAINT "fk_challenge_file_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE CASCADE
);
`);
await queryRunner.query(`
INSERT INTO "challenge_file" (
"id","challenge_id","original_filename","stored_filename","mime_type","size_bytes","created_at"
)
SELECT
"id",
"challenge_id",
COALESCE(NULLIF("original_filename",''), "stored_path"),
COALESCE(NULLIF("stored_path",''), "id"),
'application/octet-stream',
0,
strftime('%Y-%m-%dT%H:%M:%fZ','now')
FROM "_challenge_file_old";
`);
await queryRunner.query(`DROP TABLE "_challenge_file_old";`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_challenge_file_stored_filename" ON "challenge_file"("stored_filename");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_challenge_file_challenge" ON "challenge_file"("challenge_id");`);
await queryRunner.query(`
CREATE TABLE "solve" (
"id" TEXT PRIMARY KEY,
"challenge_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"solved_at" TEXT NOT NULL,
"points_awarded" INTEGER NOT NULL DEFAULT 0,
"base_points" INTEGER NOT NULL DEFAULT 0,
"rank_bonus" INTEGER NOT NULL DEFAULT 0,
"is_first" INTEGER NOT NULL DEFAULT 0,
"is_second" INTEGER NOT NULL DEFAULT 0,
"is_third" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "fk_solve_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE CASCADE,
CONSTRAINT "fk_solve_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE RESTRICT
);
`);
await queryRunner.query(`
INSERT INTO "solve" (
"id","challenge_id","user_id","solved_at","points_awarded","base_points","rank_bonus","is_first","is_second","is_third"
)
SELECT "id","challenge_id","user_id","solved_at","points_awarded","base_points","rank_bonus",0,0,0
FROM "_solve_old";
`);
await queryRunner.query(`DROP TABLE "_solve_old";`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_solve_challenge_user" ON "solve"("challenge_id","user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_user" ON "solve"("user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_challenge" ON "solve"("challenge_id");`);
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
await queryRunner.query(`PRAGMA foreign_key_check;`);
}
public async down(): Promise<void> {
// Forward-only.
}
}
@@ -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,
};
}
}
}