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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user