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

Merged
m0rph3us1987 merged 2 commits from feature-861-1784750202352 into dev 2026-07-22 20:26:31 +00:00
41 changed files with 4548 additions and 145 deletions
+89
View File
@@ -0,0 +1,89 @@
# Implementation Plan: Admin Area Challenges: List, Import/Export and Add/Edit Modal
## 1. Architectural Reconnaissance
- **Status:** Not already implemented. The repository only has the base `ChallengeEntity`, `ChallengeFileEntity`, and `SolveEntity`, plus a direct generic challenge upload route. There is no `/api/v1/admin/challenges` controller/service/DTO layer, the admin Challenges navigation entry is disabled, no Angular challenges admin route or components exist, and the current schema does not meet the Job contract.
- **Codebase style & conventions:** Node.js npm-workspace monorepo; NestJS 10 backend and Angular 17 standalone frontend, both in strict TypeScript. Backend routes use thin controllers, injected services/repositories, Zod schemas through `ZodValidationPipe`, `ApiError`/`GlobalExceptionFilter`, global JWT auth, and controller-level `AdminGuard` plus `@Roles('admin')`. Frontend routes are lazy-loaded; components are standalone, OnPush, signal-driven containers with typed non-nullable reactive forms and input/output-based presentational modals. HTTP calls are centralized in `AdminService`. Reuse `MarkdownService` (`marked` + DOMPurify) for sanitized preview rather than adding a renderer.
- **Data Layer:** TypeORM 0.3 with `better-sqlite3`, explicit forward-only migrations, `synchronize: false` at runtime, UUID text primary keys, snake_case columns, and `DataSource.transaction(...)` for atomic multi-row mutations. Existing challenge tables are incomplete: `challenge` lacks case-insensitive uniqueness, `enabled`, and `updated_at`, uses lowercase `low|med|high` and `nc|web`, and has zero-valued scoring defaults; `challenge_file` only has `stored_path`; challenge-to-file/solve FKs are `ON DELETE RESTRICT`; `solve` has no first/second/third fields. Preserve the existing `(challenge_id,user_id)` unique index and user FK while migrating/rebuilding affected SQLite tables. Implement file staging under `${UPLOAD_DIR}/challenges/.staging` and final files under `${UPLOAD_DIR}/challenges`.
- **Test Framework & Structure:** Root Jest 29 multi-project config with `ts-jest`; backend tests run in Node with Nest testing helpers/Supertest and in-memory SQLite, frontend tests run in jsdom and favor exported pure helpers. All new tests belong under `tests/backend/` and `tests/frontend/`, never beside source, and run together via root `npm test` (or individually via `npm run test:backend` / `npm run test:frontend`). Keep coverage focused on core success paths and key validation/transaction failures without browser or visual checks.
- **Required Tools & Dependencies:** No new package or global CLI is required: TypeORM, Zod, Multer, UUID, RxJS, Angular reactive forms, `marked`, DOMPurify, Jest, Supertest, and jsdom already exist. Node filesystem APIs are sufficient for staging/export. Update `setup.sh` only to ensure `./data/uploads/challenges/.staging` exists (alongside the current upload root); retain npm install/native `better-sqlite3` rebuild/build behavior. Runtime persistence remains under configured `UPLOAD_DIR` (default `./data/uploads`); tests should use generated temporary directories, not `/data`, because no cross-job fixture is needed.
## 2. Impacted Files
- **To Modify:**
- `backend/src/database/entities/challenge.entity.ts` — align fields/defaults/enums, timestamps, enabled flag, relations, and case-insensitive uniqueness contract.
- `backend/src/database/entities/challenge-file.entity.ts` — replace path-only metadata with stored filename, MIME type, byte size, timestamp, and cascade relation.
- `backend/src/database/entities/solve.entity.ts` — model cascade relation and first/second/third award flags while preserving solve uniqueness and current scoring columns.
- `backend/src/database/database.module.ts` — register the new challenge-schema migration.
- `backend/src/modules/admin/admin.module.ts` — register challenge/file/solve repositories, challenge controller, and challenge/file services.
- `backend/src/common/errors/error-codes.ts` — add stable challenge/import codes (`CHALLENGE_NAME_TAKEN`, `CHALLENGE_INVALID_CATEGORY`, `IMPORT_VALIDATION_FAILED`, `IMPORT_PARTIAL_FAILURE`).
- `backend/src/modules/uploads/uploads.controller.ts` — remove or redirect the current direct-to-final challenge upload behavior so challenge files use the new staged-upload lifecycle and cannot overwrite by original name.
- `backend/src/modules/uploads/uploads.module.ts` — import/provide the challenge staging dependency if the staged endpoint remains in the uploads module; otherwise retire the obsolete challenge route registration.
- `frontend/src/app/app.routes.ts` — lazy-load `/admin/challenges` under the existing protected admin shell.
- `frontend/src/app/features/admin/admin-shell.component.ts` — enable the Challenges navigation entry and render it as a semantic keyboard-operable navigation control.
- `frontend/src/app/core/services/admin.service.ts` — add typed list/detail/CRUD, staging/cleanup, import preview/confirm, and export download API methods and contracts.
- `setup.sh` — create challenge and staging upload directories idempotently.
- `tests/backend/migrations.spec.ts` — register/assert the migration, new columns/defaults/indexes, cascade FKs, and retained solve uniqueness.
- `tests/backend/uploads.spec.ts` — replace the legacy immediate challenge-file assertion with staged upload/limit behavior, or remove it if coverage moves to challenge endpoint tests.
- `docs/architecture/backend-modules.md`, `docs/architecture/frontend-structure.md`, `docs/database/challenges.md`, `docs/api/admin.md`, `docs/api/uploads.md`, `docs/guides/admin-shell.md` — update generated project documentation to describe the completed feature and schema/API/navigation changes.
- **To Create:**
- `backend/src/database/migrations/1700000000500-UpgradeChallengeAdminSchema.ts` — rebuild/upgrade challenge-related SQLite tables and indexes safely.
- `backend/src/modules/admin/admin-challenges.controller.ts` — admin-only REST surface for list/detail/create/update/delete, stage/discard files, import preview/confirm, and export.
- `backend/src/modules/admin/challenges.service.ts` — challenge CRUD, list aggregates/filtering, validation/business rules, transactional deletion, DTO mapping, export, and import orchestration.
- `backend/src/modules/admin/challenge-files.service.ts` — collision-safe staging, limit enforcement, commit/discard/delete helpers, base64 export/import file handling, and best-effort filesystem cleanup logging.
- `backend/src/modules/admin/dto/challenges.dto.ts` — strict Zod schemas/types for IDs, list queries, create/update payloads, staged-file manifests, and import format/version validation with field paths.
- `frontend/src/app/features/admin/challenges/challenges.component.ts` — smart admin page for debounced queries, sorting, modal state, request loading locks, refreshes, downloads, and status messages/toasts.
- `frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts` — reusable accessible Add/Edit dialog with General/Connection/Files sections, typed form, sanitized Markdown preview, flag reveal, staged uploads, and attached-file removal.
- `frontend/src/app/features/admin/challenges/challenge-form.pure.ts` — pure form defaults/prefill, points/port validation, minimum-points synchronization, DTO mapping, and server-field-error helpers.
- `frontend/src/app/features/admin/challenges/challenge-delete-modal.component.ts` — non-backdrop-dismissible destructive confirmation dialog.
- `frontend/src/app/features/admin/challenges/challenge-import-modal.component.ts` — non-backdrop-dismissible preview/overwrite confirmation with counts/conflicts/warnings.
- `frontend/src/app/features/admin/challenges/challenges.pure.ts` — pure query normalization/sorting, file-size formatting, import/export filename, and error-summary helpers.
- `tests/backend/admin-challenges-service.spec.ts` — focused CRUD/list/delete service coverage with in-memory SQLite and temporary uploads.
- `tests/backend/admin-challenges-import-export.spec.ts` — focused version-1 export/import preview/overwrite and rollback/staging behavior.
- `tests/backend/admin-challenges-api.spec.ts` — minimal guard and structured validation/error contract coverage for the new controller.
- `tests/frontend/admin-challenges-form.spec.ts` — pure form defaults/prefill, points/NC-port rules, flag omission/presence, and staged-file payload mapping.
- `tests/frontend/admin-challenges-list.spec.ts` — pure debounce-query/sort/empty-state decision and import/export helper coverage.
- `docs/guides/admin-challenges.md` — focused admin Challenges user/architecture guide.
## 3. Proposed Changes
1. **Database / Schema Migration:**
- Add a forward-only SQLite migration after `1700000000400` that disables FK checks only for the controlled rebuild, creates replacement `challenge`, `challenge_file`, and `solve` tables, copies compatible legacy data, swaps tables, recreates indexes, and re-enables/verifies FKs.
- Store canonical API enum values `LOW|MEDIUM|HIGH` and `NC|WEB`; map legacy `low|med|high` (`med``MEDIUM`) and lowercase protocols during copy. Add `enabled INTEGER NOT NULL DEFAULT 1`, `updated_at`, challenge defaults (`initial_points=100`, valid minimum/decay fallback), and a unique index on `LOWER(name)` for case-insensitive uniqueness.
- Keep `category_id NOT NULL` with `ON DELETE RESTRICT`. Change `challenge_file.challenge_id` and `solve.challenge_id` to `ON DELETE CASCADE`; retain `solve.user_id ON DELETE RESTRICT` and `uq_solve_challenge_user`.
- Upgrade `challenge_file` to `stored_filename UNIQUE`, `mime_type`, `size_bytes` (SQLite INTEGER mapped through a safe TypeORM bigint transformer/string boundary if necessary), and `created_at`; derive a basename for legacy `stored_path`, stat legacy files when available, and use safe defaults when metadata cannot be recovered.
- Add `is_first`, `is_second`, and `is_third` boolean columns to `solve` without removing existing `base_points`/`rank_bonus`, avoiding regressions in rank/scoring code.
- Update entity relations to mirror cascade behavior and timestamps, but use the explicit migration rather than runtime synchronization.
2. **Backend Logic & APIs:**
- Add `AdminChallengesController` at `/api/v1/admin/challenges`, guarded at controller level by `AdminGuard` and `@Roles('admin')`; leave global JWT and CSRF enforcement intact. Keep handlers thin and validate every query/param/body with strict Zod schemas and `ZodValidationPipe`.
- Implement `GET /api/v1/admin/challenges` with optional trimmed `q` and `categoryId`, parameterized `LOWER(name) LIKE LOWER(:q)`, category join, aggregate `COUNT(DISTINCT file.id)`/`COUNT(DISTINCT solve.id)`, and deterministic `LOWER(name), name` ordering. Return only the specified summary DTO (including category abbreviation/icon, enabled/timestamps/counts), never `flag`.
- Add `GET /api/v1/admin/challenges/:id` for edit prefill because list rows intentionally omit description, connection/scoring details, secret, and file metadata. This admin-only DTO may include plaintext `flag` and attached-file metadata; make the mapping explicit so ORM entities are never returned directly. Create/update responses may use this admin detail DTO.
- Implement `POST` create and `PUT /:id` update with trimmed 1128 name, required Markdown/category/difficulty/flag, integer point ranges, `minimumPoints <= initialPoints`, decay range, protocol-dependent port rules, optional validated IP/IPv6/hostname string, and enabled default. Resolve categories before writes; translate the lower-name unique index violation to `409 CHALLENGE_NAME_TAKEN` with `{ path: 'name' }`, and invalid category to `CHALLENGE_INVALID_CATEGORY`. For updates, retain the existing flag only if the client deliberately uses an explicit unchanged contract; otherwise require the admin detail value and never use an empty sentinel ambiguously.
- Define staged-file endpoints beneath the required prefix, e.g. `POST /api/v1/admin/challenges/files/stage` (multipart, multiple calls or `FilesInterceptor`) and `DELETE /api/v1/admin/challenges/files/stage/:token`, plus request fields on create/update for staged tokens and existing file IDs to remove. Use UUID-based stored names, `path.basename`/safe path joins, global `UPLOAD_SIZE_LIMIT`, no type restriction, and preserve original filename/MIME/size metadata. Staging responses contain opaque tokens and metadata, not arbitrary filesystem paths.
- On create/update, perform DB changes and `ChallengeFile` metadata changes in one transaction; commit selected staging records by atomically moving staged files to final UUID filenames and associate them with the challenge. On modal cancellation, delete each staged token. Add stale staging cleanup (age-based on use/startup or opportunistically) so abandoned browser sessions do not leak indefinitely.
- Implement `DELETE /:id` by first loading attached stored filenames, deleting the challenge inside one TypeORM transaction so file and solve rows cascade, then unlinking final files after successful commit. Catch each unlink failure and call `console.log` with context without reverting the committed DB deletion.
- Implement `GET /export` before the parameterized `/:id` route. Load all challenges/categories/files in alphabetical order, read each file from `${UPLOAD_DIR}/challenges/<storedFilename>`, and emit `{format:'hipctf-challenges',version:1,exportedAt,challenges}` with canonical enums, category `systemKey`, plaintext flags, numeric `sizeBytes`, and base64 data. Set JSON attachment headers with `challenges-export-<ISO-date>.json`; an empty DB yields an empty array. Treat missing/unreadable source files as a structured server failure rather than silently producing corrupt export data.
- Implement `POST /import` as a two-phase API. Without `confirm=overwrite`, validate top-level format/version/array, every challenge field/rule/category reference, and every file's strict base64/decoded length/limit; return structured `IMPORT_VALIDATION_FAILED` details with stable paths plus preview counts/conflicts. To reconcile the stated preview warning with validation ordering, classify unknown category keys as preview/confirm `skipped` warnings rather than accepting invalid references silently; malformed/empty keys remain validation errors.
- Do not trust a second client submission to be identical: confirm must re-parse/revalidate the same document (or an opaque server-side preview token tied to staged content). On `confirm=overwrite`, decode and write every incoming file to `.staging/<uuid>` before DB replacement. Then open one transaction, resolve categories by immutable `systemKey`, skip missing categories with warnings, find conflicts case-insensitively, delete conflicting challenges (cascading files/solves), recreate challenges, and insert file metadata referencing predetermined final stored filenames. Commit only after all DB operations and staging writes succeed; on pre-commit failure roll back and delete all new staging files.
- After import DB commit, rename staged files to final paths and best-effort delete superseded files. The Job's phrase “inserts ChallengeFile rows after commit” conflicts with the requirement that the DB remain consistent if a post-commit rename fails; preserve consistency by inserting metadata in the transaction and using predetermined final names, then log post-commit rename/delete failures with `console.log` without changing the successful response. Return `{imported, skipped:[{name,reason}], warnings}` and use `IMPORT_PARTIAL_FAILURE` when the contract requires a stable code for a completed import with skipped/warning items; use structured `500` for pre-commit staging failures.
- Extend error constants/types and, where necessary, `ApiError` helpers so Zod/import issues use stable codes and `details` paths while `GlobalExceptionFilter` keeps the existing envelope. Audit current and future player-facing serializers: no non-admin challenge endpoint currently exists, so establish separate admin summary/detail types and ensure any later public DTO is an explicit allowlist without `flag`.
3. **Frontend UI Integration:**
- Enable the Challenges side-nav item and add the lazy `/admin/challenges` child route, inheriting the existing `adminGuard`. Keep the shell as layout-only and put feature files under `features/admin/challenges`.
- Extend `AdminService` with strict request/response interfaces and methods for list filters, edit detail, CRUD, stage/discard, import preview/confirm, and export as `Blob`. Encode query/path values, keep credentials/interceptors, and avoid direct `HttpClient` use in components.
- Build `AdminChallengesComponent` as the smart container. Maintain signals for rows/loading/errors/search/category filter/sort direction/modals/in-flight actions/status. Use a `Subject` or search control `valueChanges` with `debounceTime(250)`, `distinctUntilChanged()`, and `takeUntilDestroyed()` to reload server-filtered results; guard responses against stale searches. Render a semantic table with sortable Name (and deterministic initial name order), 32×32 category image with an `error` fallback to abbreviation, textual difficulty/protocol badges, and labeled pencil/trash buttons. Show separate “No challenges yet” and search-no-results states; only the unfiltered empty state has the single Add button specified.
- Add toolbar controls in required order: search, Add challenge, Import challenges, Export challenges. Disable each relevant control and show an inline spinner/text while its request is active. Export obtains the Blob, creates a temporary object URL/anchor using the server or helper filename, clicks it, and always revokes the URL.
- Build a reusable typed Add/Edit modal with accessible `role="dialog"`, labels, focus management/return, Escape and backdrop cancellation enabled, and General/Connection/Files tabs or accordion in the required order. Prefill edit mode from the detail endpoint; defaults are initial 100, minimum 50, enabled true, difficulty/protocol defaults, and valid decay. Expose all validation messages inline and map `CHALLENGE_NAME_TAKEN` to Name without closing the dialog.
- Implement minimum-points live defaulting only while it remains auto-derived/pristine: changing initial points recomputes `floor(initialPoints/2)`; once the admin edits minimum explicitly, preserve that value and only validate `<= initialPoints`. Apply required/range validators, dynamically require/clear port for NC/WEB, omit an empty WEB port, and submit numeric values rather than strings.
- Render description preview beside/toggled from the textarea using the existing `MarkdownService`; bind only its DOMPurify-sanitized output and never call `bypassSecurityTrustHtml`. Use a password input with an in-field labeled eye button that toggles only the input type. Do not persist flag or import documents in browser storage/logging.
- In Files, list existing metadata and mark removals locally until Save. Support multiple picker/drag-drop files and stage them immediately, displaying per-file progress/error and enforcing the known global limit when available while treating backend enforcement as authoritative. Save sends staged tokens and removal IDs; Cancel/backdrop/Escape calls discard for all unsaved tokens before closing. Prevent Save while staging is active or invalid.
- Build destructive delete confirmation with exact copy, disabled backdrop dismissal, Cancel/Delete, request lock/spinner, then refresh and announce a success status/toast. Build import file selection for `.json`, parse text client-side only for immediate malformed-file feedback, send the document to preview, and show a non-backdrop-dismissible overwrite modal containing counts/conflicts/skips and required message. Confirm reposts the same document, refreshes, and displays imported/skipped/warning summary; all actions prevent double submission.
- Since no shared toast primitive exists, implement a small page-local signal status banner with `role="status"`/`aria-live="polite"` rather than introducing a new UI dependency. Use native buttons/table/form controls, labels and alt text, visible focus states, keyboard operation, and focus trapping/return for dialogs.
## 4. Test Strategy
- **Target Unit Test File:**
- `tests/backend/migrations.spec.ts`: assert canonical columns/defaults, lower-name unique index, file/solve cascade FKs, and unchanged `(challenge_id,user_id)` uniqueness.
- `tests/backend/admin-challenges-service.spec.ts`: create/list/search/category filter success, secret omission from list, duplicate-name/category/point/NC-port rejection, and delete cascading DB rows while tolerating a mocked unlink failure.
- `tests/backend/admin-challenges-import-export.spec.ts`: version-1 empty/non-empty export shape with base64, preview conflicts/warnings, confirmed overwrite by case-insensitive name, and one staged-write failure rolling back DB changes and cleaning staging.
- `tests/backend/admin-challenges-api.spec.ts`: unauthenticated/player denial and one structured Zod/import validation response with stable code/path; avoid duplicating all service cases through Supertest.
- `tests/frontend/admin-challenges-form.spec.ts`: test exported pure helpers for create/edit prefill, half-point synchronization, minimum/initial and NC-port validation, WEB port omission, file-removal/staged-token mapping, and duplicate-name field error mapping.
- `tests/frontend/admin-challenges-list.spec.ts`: test pure query normalization/sorting/empty-state classification and import/export summary/filename logic; no visual assertions or browser automation.
- **Mocking Strategy:** Use real in-memory `better-sqlite3` repositories for backend service/migration behavior and a per-test temporary upload directory generated at runtime, with Node `fs` methods spied only for deterministic write/rename/unlink failure branches; restore spies and remove temp trees after each suite. Mock no DB in the small API guard test—use the existing `AppModule`, `initDb`, JWT, CSRF, and Supertest pattern. Frontend tests should import pure helpers and use lightweight typed fake `AdminService` methods only if container orchestration needs one focused test; stub `FileReader`, object URL, and anchor click rather than requiring a browser. No persistent `/data` fixture is needed; if future shared fixtures become necessary, tests must detect/create them under `/data` dynamically.
-43
View File
@@ -1,43 +0,0 @@
# Implementation Plan: Data and run
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** npm-workspace TypeScript monorepo with a NestJS 10 backend and Angular 17 standalone frontend. Runtime configuration is validated centrally with Zod in `backend/src/config/env.schema.ts` and consumed through Nest `ConfigService`; filesystem paths are normalized with Node `path.resolve`, and root scripts delegate to workspace scripts. The feature is not already implemented: persistent defaults still point at `/data/hipctf`, and the root package has no `dev` script.
- **Data Layer:** TypeORM with `better-sqlite3`. `DatabaseModule` creates the database parent directory before connecting, `DatabaseInitService` runs migrations and seeds, and uploads are written and served from the configured upload directory. No schema migration is required; this job changes only default filesystem locations and development process orchestration.
- **Test Framework & Structure:** Jest 29 with `ts-jest`, configured in `tests/jest.config.js` as backend and frontend projects. Tests live under dedicated `tests/backend` and `tests/frontend` folders and all run from the repository root with `npm test`. Add only focused configuration/script contract tests; no browser, UI, database file, port, or long-running process is required.
- **Required Tools & Dependencies:** Existing Node.js/npm workspace tooling, Angular CLI, `ts-node`, TypeScript, Jest, and `better-sqlite3` remain required. Add the root development dependency `concurrently` (and update `package-lock.json`) so one root command can supervise both long-running workspaces and propagate shutdown/failure. `setup.sh` already installs root/workspace dependencies via `npm ci`; replace its obsolete absolute `/data/hipctf/uploads` preparation with project-local `./data/uploads` preparation. No global CLI or new system package is required.
## 2. Impacted Files
- **To Modify:**
- `backend/src/config/env.schema.ts` — change validated `DATABASE_PATH` and `UPLOAD_DIR` defaults to `./data/db.sqlite` and `./data/uploads`.
- `backend/src/database/database.module.ts` — align the `ConfigService.get` database fallback with the canonical project-local database default.
- `backend/src/database/database-init.service.ts` — align `getDbPath()` fallback with the canonical project-local database default.
- `backend/src/main.ts` — align startup upload-directory fallback with the canonical project-local upload default.
- `backend/src/modules/uploads/uploads.controller.ts` — align upload controller fallback with the project-local upload default.
- `backend/src/common/utils/upload.ts` — align Multer destination fallback with the project-local upload default.
- `backend/src/frontend/uploads-static.middleware.ts` — align static-upload middleware fallback with the project-local upload default.
- `package.json` — add root `dev` orchestration for backend and frontend and retain `start:dev` as a compatible alias if desired.
- `package-lock.json` — lock the new process-runner dependency and root package metadata.
- `setup.sh` — create `./data/uploads` relative to the repository root instead of `/data/hipctf/uploads`, while retaining dependency installation and builds.
- `.gitignore` — ignore the root runtime `data/` directory rather than the unrelated `backend/data/` path, preventing local database/uploads from entering source control.
- `tests/backend/env-schema.spec.ts` — assert the exact project-local data defaults returned by the Zod schema.
- **To Create:**
- `tests/backend/root-dev-script.spec.ts` — focused static contract test for root development orchestration and both workspace targets.
## 3. Proposed Changes
1. **Database / Schema Migration:**
- Do not add a TypeORM migration or alter tables. Update the canonical default database location from `/data/hipctf/db.sqlite` to `./data/db.sqlite`.
- Replace every hard-coded database fallback outside the schema with the same value so direct/unit construction cannot silently revert to `/data` when configuration is absent.
- Preserve environment overrides such as `DATABASE_PATH=:memory:` in tests and custom deployment paths; only defaults change.
2. **Backend Logic & APIs:**
- Change the canonical default upload location from `/data/hipctf/uploads` to `./data/uploads` in the environment schema and every defensive `ConfigService.get` fallback used by startup, upload handling, Multer storage, and static serving.
- Continue resolving relative paths through `path.resolve`; when launched through the root scripts, `./data` resolves from the project root. Preserve recursive directory creation in `DatabaseModule`, `main.ts`, upload helpers, and middleware.
- Update `setup.sh` to derive/use the repository-local data directory and create `data/uploads`, avoiding writes to the containers shared `/data` volume for normal application defaults. Keep setup idempotent.
- Update `.gitignore` for `/data/` so generated SQLite and uploaded files remain local runtime artifacts.
- Add `concurrently` as a root development dependency and define `npm run dev` to launch `npm --workspace backend run start:dev` and `npm --workspace frontend run start` in parallel, with clear process names and kill-on-peer-failure/shutdown behavior. Point the existing root `start:dev` script at `npm run dev` to avoid two conflicting development entry points.
3. **Frontend UI Integration:**
- No Angular source or API URL changes are needed. The Angular dev server remains on port 4200 while the Nest API remains on port 3000; both become available from one root command.
- Because the frontend uses relative `/api` and `/uploads` URLs and no Angular dev-server proxy is currently configured, add a development proxy configuration (and reference it from Angulars `serve` target) routing `/api` and `/uploads` to `http://localhost:3000`. This makes the concurrently started frontend functional rather than merely starting two disconnected processes, including credentialed API calls and SSE streams.
## 4. Test Strategy
- **Target Unit Test File:** Extend `tests/backend/env-schema.spec.ts` with one success-path assertion that empty input resolves to `DATABASE_PATH === './data/db.sqlite'` and `UPLOAD_DIR === './data/uploads'`. Add `tests/backend/root-dev-script.spec.ts` to read root `package.json` and `frontend/angular.json`, asserting that `dev` invokes both workspace development commands and the Angular development serve target references the backend proxy. Both tests execute through the existing single root `npm test` command.
- **Mocking Strategy:** No external boundaries need heavy mocks. Parse checked-in JSON directly with Node filesystem APIs; do not spawn `npm run dev`, bind ports, launch Angular/Nest, touch `/data`, or create persistent files. The environment-schema test calls the pure Zod parser. During implementation, follow Red-Green-Refactor, then verify with `npm test`, backend/frontend builds, and the repositorys available lint/typecheck commands (no lint script currently exists; the builds provide TypeScript compilation checks).
+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,
};
}
}
}
+101
View File
@@ -79,6 +79,107 @@ See [Admin — General Settings](/guides/admin-general-settings.md).
| `PUT` | `/api/v1/admin/categories/:id` | Same. |
| `DELETE` | `/api/v1/admin/categories/:id` | Same. |
See [Admin — Categories](/guides/admin-categories.md).
## Challenges — `/api/v1/admin/challenges`
| Method | Path | Source |
|----------|--------------------------------------------|--------------------------------------------------------------------------------------|
| `GET` | `/api/v1/admin/challenges` | `backend/src/modules/admin/admin-challenges.controller.ts` |
| `GET` | `/api/v1/admin/challenges/:id` | Same. |
| `POST` | `/api/v1/admin/challenges` | Same. |
| `PUT` | `/api/v1/admin/challenges/:id` | Same. |
| `DELETE` | `/api/v1/admin/challenges/:id` | Same. |
| `GET` | `/api/v1/admin/challenges/export` | Same — JSON attachment `challenges-export-<ISO-date>.json`. |
| `POST` | `/api/v1/admin/challenges/import` | Same — preview by default, `?confirm=overwrite` to commit. |
| `POST` | `/api/v1/admin/challenges/files/stage` | Same — multipart upload that returns an opaque staging token. |
| `DELETE` | `/api/v1/admin/challenges/files/stage/:token` | Same — discards a previously staged file. |
List response (admin-only, **never includes the secret flag**):
```json
{
"id": "uuid",
"name": "Welcome",
"categoryAbbreviation": "CRY",
"categoryIconPath": "/uploads/icons/CRY.png",
"difficulty": "MEDIUM",
"protocol": "WEB",
"enabled": true,
"createdAt": "2026-07-22T20:00:00.000Z",
"updatedAt": "2026-07-22T20:00:00.000Z",
"fileCount": 1,
"solveCount": 0
}
```
Optional list query: `?q=substring&categoryId=uuid` (both optional).
Detail response (admin-only, **may include plaintext `flag`**):
```json
{
"id": "uuid",
"name": "Welcome",
"descriptionMd": "...",
"categoryId": "uuid",
"categoryAbbreviation": "CRY",
"categoryIconPath": "/uploads/icons/CRY.png",
"difficulty": "MEDIUM",
"initialPoints": 100,
"minimumPoints": 50,
"decaySolves": 10,
"flag": "flag{...}",
"protocol": "WEB",
"port": null,
"ipAddress": "",
"enabled": true,
"createdAt": "...",
"updatedAt": "...",
"files": [
{ "id": "uuid", "originalFilename": "readme.txt", "mimeType": "text/plain", "sizeBytes": 123, "createdAt": "..." }
]
}
```
Import document shape:
```json
{
"format": "hipctf-challenges",
"version": 1,
"exportedAt": "2026-07-22T20:00:00Z",
"challenges": [
{
"name": "...",
"descriptionMd": "...",
"categorySystemKey": "CRY",
"difficulty": "LOW|MEDIUM|HIGH",
"initialPoints": 100,
"minimumPoints": 50,
"decaySolves": 10,
"flag": "plaintext",
"protocol": "NC|WEB",
"port": 1337,
"ipAddress": "",
"files": [
{ "originalFilename": "...", "mimeType": "...", "sizeBytes": 123, "dataBase64": "..." }
]
}
]
}
```
Validation errors return `400 VALIDATION_FAILED` with stable codes
(`CHALLENGE_NAME_TAKEN`, `CHALLENGE_INVALID_CATEGORY`,
`IMPORT_VALIDATION_FAILED`, `IMPORT_PARTIAL_FAILURE`) and a
`details[]` array of `{ path, message }` entries. Delete cascades
`challenge_file` and `solve` rows inside one DB transaction;
filesystem cleanup of the deleted challenge's files runs best-effort
after the transaction commits.
See [Admin — Challenges](/guides/admin-challenges.md).
Behavior:
* `GET` returns all categories sorted by `LOWER(abbreviation)` ascending.
+1 -1
View File
@@ -12,7 +12,7 @@ timestamp: 2026-07-22T19:18:00Z
|--------|---------------------------------------|-------|---------------------------------------------------------|
| `POST` | `/api/v1/uploads/logo` | Admin | `backend/src/modules/uploads/uploads.controller.ts` |
| `POST` | `/api/v1/uploads/category-icon` | Admin | Same. |
| `POST` | `/api/v1/uploads/challenge-file` | Admin | Same. |
| `POST` | `/api/v1/uploads/challenge-file` | Admin | Same. *(Removed in Job 861 — challenge attachments now flow through `POST /api/v1/admin/challenges/files/stage` so they are only persisted on challenge save.)* |
# Guard chain
+2 -1
View File
@@ -27,7 +27,7 @@ also registers two global providers:
| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant) + `UsersRankService` (`rankOfUser(manager, userId) -> {rank, points}` over the `solve` table). |
| `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. |
| `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. |
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Three controllers (`AdminController` for `/api/v1/admin/users*`, `AdminGeneralController` for `/api/v1/admin/general/*`, `AdminCategoriesController` for `/api/v1/admin/categories*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, and `CommonModule` (for `SseHubService`, `ThemeLoaderService`). |
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Four controllers (`AdminController` for `/api/v1/admin/users*`, `AdminGeneralController` for `/api/v1/admin/general/*`, `AdminCategoriesController` for `/api/v1/admin/categories*`, `AdminChallengesController` for `/api/v1/admin/challenges*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`, `AdminChallengesService`, `ChallengeFilesService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, and `CommonModule` (for `SseHubService`, `ThemeLoaderService`). |
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
| `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController` (`GET /api/v1/blog/posts`) + `BlogService` (lists `status='published'` rows). |
| `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. |
@@ -42,6 +42,7 @@ also registers two global providers:
| `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` |
| `AdminGeneralController` | `/api/v1/admin/general` | Admin only | `backend/src/modules/admin/admin-general.controller.ts` |
| `AdminCategoriesController` | `/api/v1/admin/categories` | Admin only | `backend/src/modules/admin/admin-categories.controller.ts` |
| `AdminChallengesController` | `/api/v1/admin/challenges` | Admin only | `backend/src/modules/admin/admin-challenges.controller.ts` |
| `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` |
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
| `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` |
+1
View File
@@ -19,6 +19,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
| `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. |
| `/blog` | `BlogPage` | inherited | Placeholder blog page. |
| `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. |
| `/admin/challenges` | `AdminChallengesComponent` | inherited | Sortable/searchable admin Challenges list, Add/Edit modal, import/export. |
# Components and services
+46 -36
View File
@@ -3,7 +3,7 @@ type: database
title: Challenge Tables
description: category, challenge, challenge_file, and solve tables — how CTF challenges and scoring are stored.
tags: [database, challenge, category, solve]
timestamp: 2026-07-22T16:44:54Z
timestamp: 2026-07-22T20:30:00Z
---
# Tables
@@ -18,68 +18,80 @@ timestamp: 2026-07-22T16:44:54Z
| `abbreviation` | TEXT | Short label (e.g. `CRY`), 26 chars, server-uppercased. Globally unique — enforced by `uq_category_abbreviation`. |
| `description` | TEXT | Optional description. |
| `icon_path` | TEXT | Default icon URL (e.g. `/uploads/icons/CRY.png`). |
| `created_at` | TEXT | ISO 8601 timestamp the row was created. Declared in the initial `InitSchema1700000000000` migration (defaulting to `strftime('%Y-%m-%dT%H:%M:%fZ','now')`); the `AddCategoryTimestampsAndUniqueAbbrev1700000000200` migration adds it as a no-op for older pre-existing databases. |
| `updated_at` | TEXT | ISO 8601 timestamp the row was last updated (bumped on `PUT /api/v1/admin/categories/:id`). Same provenance as `created_at`. |
| `created_at` | TEXT | ISO 8601 timestamp the row was created. |
| `updated_at` | TEXT | ISO 8601 timestamp the row was last updated. |
System rows are seeded by the
`UpdateSystemCategoryKeys1700000000300` migration so the canonical
keys are `CRY` (Cryptography), `MSC` (Misc), `PWN` (Binary
exploitation), `REV` (Reverse Engineering), `WEB` (Web), and `HW`
(Hardware). A forward-only repair migration,
`RepairCategorySchemaAndSystemCategories1700000000400`, runs after
that one on existing databases that pre-date the canonical six keys
(it adds `created_at`/`updated_at` if missing, deletes obsolete
`FOR`/`OSI` system rows, rewrites name/abbreviation/description/icon
metadata on canonical rows, inserts any missing canonical row, and
guarantees unique indexes on `system_key` and `abbreviation`).
User-created categories leave `system_key` as `NULL`.
The uniqueness on `abbreviation` is enforced both by application
validation (uppercased on save, duplicates rejected) and by the
`uq_category_abbreviation` index created in migration
`1700000000200` (re-asserted by `1700000000400`).
(Hardware). The forward-only
`RepairCategorySchemaAndSystemCategories1700000000400` migration
repairs legacy schemas on existing databases.
## `challenge`
| Column | Type | Description |
|-------------------|----------|-----------------------------------------------------------------------------------|
| `id` | TEXT PK | UUID v4. |
| `name` | TEXT | Display name. |
| `name` | TEXT | Display name. **Case-insensitive uniqueness** enforced by `uq_challenge_name_lower`. |
| `description_md` | TEXT | Markdown description. |
| `category_id` | TEXT | FK → `category.id`. |
| `difficulty` | TEXT | `'low'` / `'med'` / `'high'`. |
| `initial_points` | INTEGER | Starting point value. |
| `minimum_points` | INTEGER | Floor after decay. |
| `decay_solves` | INTEGER | Number of solves at which decay reaches the minimum. |
| `flag` | TEXT | Submission string expected from players. |
| `protocol` | TEXT | `'nc'` (netcat-style connection) or `'web'` (browser-based). |
| `port` | INTEGER | TCP port when relevant (nullable). |
| `ip_address` | TEXT | IP the challenge listens on (defaulted from `setting.defaultChallengeIp`). |
| `created_at` | TEXT | ISO 8601 timestamp. |
| `category_id` | TEXT | FK → `category.id` (`ON DELETE RESTRICT`). |
| `difficulty` | TEXT | `'LOW'` / `'MEDIUM'` / `'HIGH'`. |
| `initial_points` | INTEGER | Starting point value (1100000). |
| `minimum_points` | INTEGER | Floor after decay (1100000, must be ≤ `initial_points`). |
| `decay_solves` | INTEGER | Solves at which decay reaches the minimum (110000). |
| `flag` | TEXT | Submission string expected from players. **Never returned in non-admin DTOs.** |
| `protocol` | TEXT | `'NC'` (netcat-style connection) or `'WEB'` (browser-based). |
| `port` | INTEGER | TCP port (nullable for WEB, required 165535 for NC). |
| `ip_address` | TEXT | IP the challenge listens on (defaults to `setting.defaultChallengeIp`). |
| `enabled` | INTEGER | Boolean — 1 (default) or 0. |
| `created_at` | TEXT | ISO 8601 timestamp the row was created. |
| `updated_at` | TEXT | ISO 8601 timestamp the row was last updated (bumped on every PUT). |
The `UpgradeChallengeAdminSchema1700000000500` migration rebuilt
the `challenge` table atomically (preserving legacy data), introduced
canonical enums, the `enabled` flag, `updated_at`, the
`LOWER(name)` unique index, and tighter scoring defaults. The
category FK stays at `ON DELETE RESTRICT` so the
`CATEGORY_HAS_CHALLENGES` business rule is preserved.
## `challenge_file`
| Column | Type | Description |
|---------------------|---------|--------------------------------------|
| `id` | TEXT PK | UUID v4. |
| `challenge_id` | TEXT | FK → `challenge.id`. |
| `challenge_id` | TEXT | FK → `challenge.id` (`ON DELETE CASCADE`). |
| `original_filename` | TEXT | Filename from upload. |
| `stored_path` | TEXT | Path on disk under `UPLOAD_DIR/challenges/`. |
| `stored_filename` | TEXT | UUID-based filename on disk under `UPLOAD_DIR/challenges/`; unique. |
| `mime_type` | TEXT | Source MIME type (defaults to `application/octet-stream`). |
| `size_bytes` | INTEGER | Decoded file size in bytes. |
| `created_at` | TEXT | ISO 8601 timestamp. |
The upgrade migration added `stored_filename`, `mime_type`,
`size_bytes`, and `created_at`. The challenge → file FK was changed
from `RESTRICT` to `CASCADE` so deleting a challenge in one DB
transaction removes all its `ChallengeFile` rows in lockstep.
## `solve`
| Column | Type | Description |
|------------------|---------|----------------------------------------------------------------------|
| `id` | TEXT PK | UUID v4. |
| `challenge_id` | TEXT | FK → `challenge.id`. |
| `user_id` | TEXT | FK → `user.id`. |
| `challenge_id` | TEXT | FK → `challenge.id` (`ON DELETE CASCADE`). |
| `user_id` | TEXT | FK → `user.id` (`ON DELETE RESTRICT`). |
| `solved_at` | TEXT | ISO 8601 timestamp the solve was recorded. |
| `points_awarded` | INTEGER | Points actually awarded (after decay). |
| `base_points` | INTEGER | Snapshot of `challenge.initial_points` at solve time. |
| `rank_bonus` | INTEGER | Extra points from rank (e.g. first-blood). |
| `is_first` | INTEGER | Boolean — first-blood flag. |
| `is_second` | INTEGER | Boolean — second-blood flag. |
| `is_third` | INTEGER | Boolean — third-blood flag. |
Uniqueness is enforced by `uq_solve_challenge_user` on
`(challenge_id, user_id)` — a user can solve a given challenge only once.
`uq_solve_challenge_user` on `(challenge_id, user_id)` is retained;
deleting a challenge now cascades to its solve rows, while deleting
a user still requires explicit handling (`user_id` remains
`RESTRICT`).
# Scoring formula
@@ -87,11 +99,9 @@ Uniqueness is enforced by `uq_solve_challenge_user` on
where `decay = max(0, base_points - minimum_points) * solvesBefore / decay_solves`,
and additional `rank_bonus` may be added for early solves.
(The exact scoring algorithm lives in the future solve-recording service
and is consumed by `SseHubService` whenever a new solve is published.)
# See also
- [Database Schema Overview](/database/schema.md)
- [System Endpoints](/api/system.md) (SSE scoreboard stream)
- [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Admin — Challenges](/guides/admin-challenges.md)
+136
View File
@@ -0,0 +1,136 @@
---
type: guide
title: Admin — Challenges
description: How an admin lists, searches, creates, edits, deletes, imports, and exports challenges from the /admin/challenges page.
tags: [guide, admin, challenges, import, export, files, tester]
timestamp: 2026-07-22T20:30:00Z
---
# When this view is available
The Challenges admin page renders at `/admin/challenges` for users with `role === 'admin'`. It is reached from the [Admin Shell](/guides/admin-shell.md) side-nav.
| Layer | File | Check |
|------------------|--------------------------------------------------------------------------------------------|------------------------------------|
| Client route | `frontend/src/app/app.routes.ts` | `/admin/challenges` lazy-loaded under `adminGuard`. |
| Client component | `frontend/src/app/features/admin/challenges/challenges.component.ts` | Smart container; owns search, sort, modal state. |
| Client modals | `frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts` | Create / edit modal with General / Connection / Files tabs. |
| | `frontend/src/app/features/admin/challenges/challenge-delete-modal.component.ts` | Non-backdrop-dismissible delete confirmation. |
| | `frontend/src/app/features/admin/challenges/challenge-import-modal.component.ts` | Non-backdrop-dismissible import preview/result. |
| Server route | `backend/src/modules/admin/admin-challenges.controller.ts` | `/api/v1/admin/challenges*`, mounted under `AdminGuard` + `@Roles('admin')`. |
# How to access (tester steps)
1. Sign in as an admin user.
2. Open **Admin area****Challenges** in the side-nav, or visit `/admin/challenges` directly.
3. The page shows `Loading challenges…` (`data-testid="challenges-loading"`) while the request is in flight, then renders the list, an empty state, or a load error.
# Visual elements
| Element | Selector | Purpose |
|------------------------|--------------------------------------------|---------|
| Page section | `[data-testid="admin-challenges"]` | Root container. |
| Search input | `[data-testid="challenges-search"]` | Debounced (~250 ms) case-insensitive substring filter on name. |
| Add button | `[data-testid="challenges-add"]` | Opens the create modal. |
| Import button | `[data-testid="challenges-import"]` | Opens a file picker for `.json`; preview/confirm flow runs in the import modal. |
| Export button | `[data-testid="challenges-export"]` | Downloads `challenges-export-<ISO-date>.json`. |
| Status banner | `[data-testid="challenges-status"]` | `role="status"`, success message after CRUD/import/export. |
| Error banner | `[data-testid="challenges-error"]` | `role="alert"`, red load error. |
| Loading | `[data-testid="challenges-loading"]` | "Loading challenges…" placeholder. |
| Empty (unfiltered) | `[data-testid="challenges-empty"]` | Centered "No challenges yet" + Add challenge button. |
| Empty (filtered) | `[data-testid="challenges-empty-search"]` | "No challenges match '<q>'". |
| Table | `[data-testid="challenges-table"]` | One row per challenge. |
| Row | `[data-testid="challenge-row-<id>"]` | Row in the list. |
| Sortable Name | `[data-testid="challenges-sort-name"]` | Toggle asc/desc. |
| Edit row action | `[data-testid="challenges-row-edit"]` | Opens the edit modal (loads detail). |
| Delete row action | `[data-testid="challenges-row-delete"]` | Opens the delete confirmation modal. |
| Form modal | `[data-testid="challenge-form-modal"]` | Backdrop-dismissible (this is the only non-destructive modal). |
| Form tabs | `[data-testid="cf-tab-general" / -connection / -files"]` | Switch between sections. |
| Delete modal | `[data-testid="challenge-delete-modal"]` | Non-backdrop-dismissible; "Cancel" + "Delete". |
| Import modal | `[data-testid="challenge-import-modal"]` | Non-backdrop-dismissible; preview, confirm, result. |
# Form fields
The form modal has three sections (tabs): General, Connection, Files. Fields marked *required* are not nullable; the server enforces all rules and surfaces structured `400 VALIDATION_FAILED` errors that the modal renders inline (`data-testid="cf-error-<field>"`).
| Label | `data-testid` | Required | Notes |
|-------------------------------|------------------|----------|-------|
| Name (1128 chars) | `cf-name` | yes | Server enforces case-insensitive uniqueness; `CHALLENGE_NAME_TAKEN` is shown inline. |
| Description (Markdown) | `cf-desc` | yes | "Preview" toggle renders sanitized HTML using `MarkdownService`. |
| Category dropdown | `cf-category` | yes | Populated from `GET /api/v1/admin/categories`. |
| Difficulty radios | `cf-difficulty-*` | yes | LOW / MEDIUM / HIGH. |
| Initial points | `cf-initial` | yes | Integer 1100000 (default 100). |
| Minimum points | `cf-minimum` | yes | Integer 1100000; default = `floor(initialPoints / 2)`. Auto-updates while untouched; once edited, the value sticks. |
| Decay solves | `cf-decay` | yes | Integer 110000. |
| Secret flag (password) | `cf-flag` | yes | Stored plaintext server-side; toggled between `password` and `text` via `cf-flag-toggle`. The flag is omitted from any non-admin DTO. |
| Protocol radios | `cf-protocol-*` | yes | NC / WEB. |
| Port (NC: required) | `cf-port` | NC only | Integer 165535; cleared automatically when switching to WEB. |
| IP address | `cf-ip` | no | Free text — falls back to `setting.defaultChallengeIp` at solve time. |
| File picker (multiple) | `cf-upload` | no | Each pick is staged via `POST /api/v1/admin/challenges/files/stage`; the form's `Files` tab lists them with size and MIME. |
| File list | `cf-files` | n/a | Existing files (with `🗑` to mark for removal) plus staged uploads. |
# Expected behavior
## List & search
* The list is fetched via `GET /api/v1/admin/challenges` and contains the admin-only summary DTO (`categoryAbbreviation`, `categoryIconPath`, `difficulty`, `protocol`, `enabled`, `fileCount`, `solveCount`, `createdAt`, `updatedAt`). The secret flag is never in the response.
* Typing in the search input triggers a 250 ms debounced reload with `?q=`.
* Toggling the sort header flips `asc`/`desc` over `LOWER(name)`. The client re-sorts defensively in `applySort`.
* While the list is in flight, the toolbar buttons stay disabled and show an inline spinner.
* The icon column renders `<img src="iconPath">`; on `error` it hides the image and the abbreviation fallback appears as text.
## Create / Edit
1. Click **Add challenge** (or the row's ✎ button) → form modal opens in create (or edit) mode.
2. Edit mode fetches `GET /api/v1/admin/challenges/:id` to populate every field including `flag`, then renders sanitized description preview.
3. Save calls `POST` (or `PUT`) with the manifest of staged file tokens and existing-file removal ids.
4. Server-side errors map to inline field messages (`cf-error-<field>`) and stay on the modal; success closes the modal, refreshes the list, and surfaces a success banner.
5. Cancel/Esc/backdrop discards every pending staged token via `DELETE /files/stage/:token`.
## Delete
1. Click the row's 🗑 button → delete modal opens. Backdrop dismissal and Esc are intentionally disabled.
2. Confirm → `DELETE /api/v1/admin/challenges/:id`. The server runs one transaction that cascades `challenge_file` and `solve` rows, then best-effort unlinks the disk files. Failures to unlink are logged but do not abort the transaction.
3. Success closes the modal, refreshes the list, and announces a status banner.
## Export
* Click **Export challenges**`GET /api/v1/admin/challenges/export` returns a JSON attachment named `challenges-export-<ISO-date>.json`. The browser downloads it; the URL is revoked after a short delay.
## Import
1. Click **Import challenges** → a file picker accepts `.json`. The client reads it and posts it to `POST /api/v1/admin/challenges/import`.
2. On validation success the import modal opens with the preview (`total`, `conflicts`, `unknownCategories`, `warnings`) and the standard copy: *"Import N challenges. Existing challenges with matching names will be overwritten. New categories referenced but missing locally will be skipped with a warning. Continue?"*
3. Click **Overwrite & Import** → the client posts the same document with `?confirm=overwrite`. The server stages every file up front (rolling back any FS failure), then upserts challenges inside one transaction by case-insensitive name.
4. The response renders imported/skipped/warning counts; the list refreshes automatically.
# Validation & error codes (server)
| Code | HTTP | Triggered by |
|---------------------------------|------|-----------------------------------------------------------------------------|
| `VALIDATION_FAILED` | 400 | Zod schema failure on create/update/list query/import document. |
| `CHALLENGE_NAME_TAKEN` | 409 | Case-insensitive duplicate `name`. |
| `CHALLENGE_INVALID_CATEGORY` | 400 | `categoryId` does not match any existing category. |
| `IMPORT_VALIDATION_FAILED` | 400 | Import document fails top-level or per-challenge validation. |
| `IMPORT_PARTIAL_FAILURE` | 200 | Import committed but some entries were skipped (returns `{imported, skipped, warnings}`). |
| `NOT_FOUND` | 404 | `GET/PUT/DELETE` on a non-existent challenge id. |
# Architecture map
| Step | Where | What happens |
|------|--------------------------------------------------------------------------------------|--------------|
| 1 | `frontend/src/app/app.routes.ts` | `/admin/challenges` lazy-loads `AdminChallengesComponent`. |
| 2 | `frontend/src/app/features/admin/challenges/challenges.component.ts` | Owns search debounce, modal state, in-flight locks, refresh. |
| 3 | `frontend/src/app/core/services/admin.service.ts` | Typed `listChallenges`, `getChallengeDetail`, `createChallenge`, `updateChallenge`, `deleteChallenge`, `exportChallenges`, `previewChallengeImport`, `confirmChallengeImport`, `stageChallengeFile`, `discardChallengeFile`. |
| 4 | `frontend/src/app/features/admin/challenges/challenge-form.pure.ts` | Defaults/prefill, points/port validation, payload mapping. |
| 5 | `backend/src/modules/admin/admin-challenges.controller.ts` | `AdminGuard` + `@Roles('admin')` on every handler. |
| 6 | `backend/src/modules/admin/challenges.service.ts` | CRUD, list aggregates, transactional deletion, export, import orchestration. |
| 7 | `backend/src/modules/admin/challenge-files.service.ts` | Staging, commit, discard, read for export, limits. |
| 8 | `backend/src/modules/admin/dto/challenges.dto.ts` | Zod schemas for IDs, list query, create/update, staged-file manifest, import document with field paths. |
# Notes
* The flag is never present in any non-admin DTO. The player-facing challenges route and scoreboard remain unchanged; only admin endpoints can fetch the plaintext.
* All mutating endpoints require the standard CSRF cookie + header pair; the existing `csrfInterceptor` handles the headers automatically.
* The list query enforces a deterministic `LOWER(name), name` order so the UI sort is reproducible.
* Importing the same document twice is idempotent: every incoming challenge either replaces an existing row by lowercased name or inserts a new one.
+1 -1
View File
@@ -46,7 +46,7 @@ The side-nav is defined as a static `ENTRIES` array in
| Label | Path | Enabled | Renders |
|-------------|-----------------------|---------|----------------------------------------------------------------------------------------------|
| General | `/admin/general` | yes | [Admin — General Settings](/guides/admin-general-settings.md) (default — `/admin` redirects here). |
| Challenges | `/admin/challenges` | no | Greyed-out placeholder (`.disabled` class). No route registered. |
| Challenges | `/admin/challenges` | yes | [Admin — Challenges](/guides/admin-challenges.md) — sortable list, search, Add/Edit modal, file staging, import/export. |
| Players | `/admin/players` | no | Greyed-out placeholder. No route registered. |
| Blog | `/admin/blog` | no | Greyed-out placeholder. No route registered. |
| System | `/admin/system` | no | Greyed-out placeholder. No route registered. |
+4 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls.
The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-22T19:54:00Z.
they need. Last regenerated 2026-07-22T20:25:58Z.
# Architecture
@@ -71,6 +71,9 @@ they need. Last regenerated 2026-07-22T19:54:00Z.
* [Admin — Categories](/guides/admin-categories.md) - How an admin lists,
creates, edits, and deletes challenge categories at `/admin/categories`,
including system-row and challenge-attached protection.
* [Admin — Challenges](/guides/admin-challenges.md) - How an admin lists,
searches, creates, edits, deletes, imports, and exports challenges at
`/admin/challenges`, including file staging and overwrite confirm.
* [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED
+ countdown, quick-jump tabs, user menu, and SSE lifecycle that frame
every signed-in page.
+5
View File
@@ -54,6 +54,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () =>
import('./features/admin/categories/categories.component').then((m) => m.AdminCategoriesComponent),
},
{
path: 'challenges',
loadComponent: () =>
import('./features/admin/challenges/challenges.component').then((m) => m.AdminChallengesComponent),
},
],
},
],
@@ -63,6 +63,110 @@ export interface LogoUploadResponse {
originalFilename: string;
}
export interface AdminChallengeFile {
id: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
createdAt: string;
}
export interface AdminChallengeListItem {
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 AdminChallengeDetail {
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: AdminChallengeFile[];
}
export interface ChallengeFileManifestPayload {
id?: string;
stagedToken?: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
remove?: boolean;
}
export interface CreateChallengeBody {
name: string;
descriptionMd: string;
categoryId: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
protocol: 'NC' | 'WEB';
port: number | null;
ipAddress: string;
enabled: boolean;
files?: ChallengeFileManifestPayload[];
}
export interface UpdateChallengeBody {
name?: string;
descriptionMd?: string;
categoryId?: string;
difficulty?: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints?: number;
minimumPoints?: number;
decaySolves?: number;
flag?: string;
protocol?: 'NC' | 'WEB';
port?: number | null;
ipAddress?: string;
enabled?: boolean;
files?: ChallengeFileManifestPayload[];
}
export interface ImportPreviewResponse {
total: number;
conflicts: string[];
unknownCategories: string[];
warnings: string[];
}
export interface ImportResultResponse {
imported: number;
skipped: { name: string; reason: string }[];
warnings: string[];
}
export interface StageChallengeFileResponse {
token: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
}
@Injectable({ providedIn: 'root' })
export class AdminService {
private readonly http = inject(HttpClient);
@@ -139,4 +243,99 @@ export class AdminService {
this.http.post<LogoUploadResponse>('/api/v1/uploads/logo', fd, { withCredentials: true }),
);
}
async listChallenges(query: { q?: string; categoryId?: string } = {}): Promise<AdminChallengeListItem[]> {
const params: Record<string, string> = {};
if (query.q && query.q.trim().length > 0) params['q'] = query.q.trim();
if (query.categoryId) params['categoryId'] = query.categoryId;
return firstValueFrom(
this.http.get<AdminChallengeListItem[]>('/api/v1/admin/challenges', {
params,
withCredentials: true,
}),
);
}
async getChallengeDetail(id: string): Promise<AdminChallengeDetail> {
return firstValueFrom(
this.http.get<AdminChallengeDetail>(
`/api/v1/admin/challenges/${encodeURIComponent(id)}`,
{ withCredentials: true },
),
);
}
async createChallenge(body: CreateChallengeBody): Promise<AdminChallengeDetail> {
return firstValueFrom(
this.http.post<AdminChallengeDetail>('/api/v1/admin/challenges', body, {
withCredentials: true,
}),
);
}
async updateChallenge(id: string, body: UpdateChallengeBody): Promise<AdminChallengeDetail> {
return firstValueFrom(
this.http.put<AdminChallengeDetail>(
`/api/v1/admin/challenges/${encodeURIComponent(id)}`,
body,
{ withCredentials: true },
),
);
}
async deleteChallenge(id: string): Promise<void> {
await firstValueFrom(
this.http.delete<void>(`/api/v1/admin/challenges/${encodeURIComponent(id)}`, {
withCredentials: true,
}),
);
}
async exportChallenges(): Promise<Blob> {
return firstValueFrom(
this.http.get('/api/v1/admin/challenges/export', {
withCredentials: true,
responseType: 'blob',
}),
);
}
async previewChallengeImport(doc: unknown): Promise<ImportPreviewResponse> {
return firstValueFrom(
this.http.post<ImportPreviewResponse>('/api/v1/admin/challenges/import', doc, {
withCredentials: true,
}),
);
}
async confirmChallengeImport(doc: unknown): Promise<ImportResultResponse> {
return firstValueFrom(
this.http.post<ImportResultResponse>(
'/api/v1/admin/challenges/import?confirm=overwrite',
doc,
{ withCredentials: true },
),
);
}
async stageChallengeFile(file: File): Promise<StageChallengeFileResponse> {
const fd = new FormData();
fd.append('file', file);
return firstValueFrom(
this.http.post<StageChallengeFileResponse>(
'/api/v1/admin/challenges/files/stage',
fd,
{ withCredentials: true },
),
);
}
async discardChallengeFile(token: string): Promise<void> {
await firstValueFrom(
this.http.delete<void>(
`/api/v1/admin/challenges/files/stage/${encodeURIComponent(token)}`,
{ withCredentials: true },
),
);
}
}
@@ -11,7 +11,7 @@ interface AdminNavEntry {
const ENTRIES: AdminNavEntry[] = [
{ id: 'general', label: 'General', path: '/admin/general', enabled: true },
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: false },
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true },
{ id: 'players', label: 'Players', path: '/admin/players', enabled: false },
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: false },
{ id: 'system', label: 'System', path: '/admin/system', enabled: false },
@@ -0,0 +1,70 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
input,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminChallengeListItem } from '../../../core/services/admin.service';
@Component({
selector: 'app-challenge-delete-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 70; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(420px, 90vw); }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.error { color: var(--color-danger, #f00); margin-top: 8px; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
`],
template: `
@if (open()) {
<div class="modal-overlay" data-testid="challenge-delete-overlay">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="cd-title" data-testid="challenge-delete-modal">
<h3 id="cd-title">Delete challenge</h3>
@if (challenge(); as c) {
<p>Delete challenge '<b>{{ c.name }}</b>'? This will also remove all files and solve records associated with it.</p>
}
@if (errorMessage(); as msg) {
<p class="error" role="alert" data-testid="challenge-delete-error">{{ msg }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-delete-cancel">Cancel</button>
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-delete-ok">
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
Delete
</button>
</div>
</div>
</div>
}
`,
})
export class ChallengeDeleteModalComponent {
readonly open = input(false);
readonly challenge = input<AdminChallengeListItem | null>(null);
readonly errorMessage = input<string | null>(null);
readonly submitting = input(false);
readonly cancel = output<void>();
readonly confirm = output<void>();
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open() && !this.submitting()) this.onCancel();
}
onCancel(): void {
if (this.submitting()) return;
this.cancel.emit();
}
onConfirm(): void {
if (this.submitting()) return;
this.confirm.emit();
}
}
@@ -0,0 +1,556 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
HostListener,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
AdminCategory,
AdminChallengeDetail,
AdminChallengeFile,
AdminService,
} from '../../../core/services/admin.service';
import { MarkdownService } from '../../../core/services/markdown.service';
import {
ChallengeFormDefaults,
ChallengeFormErrors,
ChallengeFormFileItem,
ChallengeStagedFileUi,
buildInitialFiles,
defaultMinimumPoints,
deriveMinimumPoints,
emptyChallengeFormDefaults,
prefillChallengeFormDefaults,
toCreateBody,
validateChallengeForm,
} from './challenge-form.pure';
export type ChallengeFormMode = 'create' | 'edit';
export interface ChallengeFormSubmitPayload {
body: ReturnType<typeof toCreateBody>;
}
interface ChallengeFormControls {
name: FormControl<string>;
descriptionMd: FormControl<string>;
categoryId: FormControl<string>;
difficulty: FormControl<'LOW' | 'MEDIUM' | 'HIGH'>;
initialPoints: FormControl<number>;
minimumPoints: FormControl<number>;
decaySolves: FormControl<number>;
flag: FormControl<string>;
protocol: FormControl<'NC' | 'WEB'>;
port: FormControl<number | null>;
ipAddress: FormControl<string>;
enabled: FormControl<boolean>;
}
export function buildChallengeFormGroup(fb: FormBuilder): FormGroup<ChallengeFormControls> {
return fb.nonNullable.group({
name: fb.nonNullable.control('', [Validators.required, Validators.maxLength(128)]),
descriptionMd: fb.nonNullable.control('', [Validators.required]),
categoryId: fb.nonNullable.control('', [Validators.required]),
difficulty: fb.nonNullable.control<'LOW' | 'MEDIUM' | 'HIGH'>('MEDIUM'),
initialPoints: fb.nonNullable.control(100, [Validators.required, Validators.min(1), Validators.max(100000)]),
minimumPoints: fb.nonNullable.control(50, [Validators.required, Validators.min(1)]),
decaySolves: fb.nonNullable.control(10, [Validators.required, Validators.min(1), Validators.max(10000)]),
flag: fb.nonNullable.control('', [Validators.required]),
protocol: fb.nonNullable.control<'NC' | 'WEB'>('WEB'),
port: fb.nonNullable.control<number | null>(null),
ipAddress: fb.nonNullable.control(''),
enabled: fb.nonNullable.control(true),
});
}
export function syncChallengeForm(
form: FormGroup<ChallengeFormControls>,
mode: ChallengeFormMode,
detail: AdminChallengeDetail | null,
): ChallengeFormDefaults {
if (mode === 'edit' && detail) {
const defaults = prefillChallengeFormDefaults(detail);
form.patchValue(
{
name: defaults.name,
descriptionMd: defaults.descriptionMd,
categoryId: defaults.categoryId,
difficulty: defaults.difficulty,
initialPoints: defaults.initialPoints,
minimumPoints: defaults.minimumPoints,
decaySolves: defaults.decaySolves,
flag: defaults.flag,
protocol: defaults.protocol,
port: defaults.port,
ipAddress: defaults.ipAddress,
enabled: defaults.enabled,
},
{ emitEvent: false },
);
for (const name of Object.keys(form.controls) as (keyof ChallengeFormControls)[]) {
form.controls[name].markAsPristine();
form.controls[name].markAsUntouched();
}
return defaults;
}
const empty = emptyChallengeFormDefaults();
form.reset({
name: empty.name,
descriptionMd: empty.descriptionMd,
categoryId: empty.categoryId,
difficulty: empty.difficulty,
initialPoints: empty.initialPoints,
minimumPoints: empty.minimumPoints,
decaySolves: empty.decaySolves,
flag: empty.flag,
protocol: empty.protocol,
port: empty.port,
ipAddress: empty.ipAddress,
enabled: empty.enabled,
});
for (const name of Object.keys(form.controls) as (keyof ChallengeFormControls)[]) {
form.controls[name].markAsPristine();
form.controls[name].markAsUntouched();
}
return empty;
}
@Component({
selector: 'app-challenge-form-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ReactiveFormsModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 60; padding: 16px; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(720px, 100%); max-height: 90vh; overflow: auto; }
.tabs { display: flex; gap: 4px; margin-bottom: 8px; }
.tabs button { padding: 6px 12px; border-radius: 4px; border: 1px solid var(--color-secondary, #ccc); background: transparent; cursor: pointer; }
.tabs button.active { background: var(--color-primary, #3b82f6); color: #fff; border-color: var(--color-primary, #3b82f6); }
.row { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
.row label { font-weight: 600; font-size: 13px; }
.row .error { color: var(--color-danger, #f00); font-size: 12px; }
.markdown-preview { border: 1px solid var(--color-secondary, #ccc); padding: 8px; border-radius: 4px; min-height: 60px; }
.field-row { display: flex; gap: 8px; }
.field-row > * { flex: 1; }
.file-list { list-style: none; padding: 0; margin: 0; }
.file-list li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--color-secondary, #ccc); }
.file-list li .remove { margin-left: auto; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.flag-input { display: flex; gap: 4px; }
.flag-input input { flex: 1; }
.preview-toggle { font-size: 12px; }
.upload-error { color: var(--color-danger, #f00); font-size: 12px; }
`],
template: `
@if (open()) {
<div class="modal-overlay" role="presentation" (click)="onCancel()" data-testid="challenge-form-backdrop">
<form class="modal" role="dialog" aria-modal="true" [attr.aria-label]="mode() === 'create' ? 'Add challenge' : 'Edit challenge'" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="challenge-form-modal">
<h2>{{ mode() === 'create' ? 'Add challenge' : 'Edit challenge' }}</h2>
<div class="tabs" role="tablist">
<button type="button" role="tab" [class.active]="tab() === 'general'" [attr.aria-selected]="tab() === 'general'" (click)="setTab('general')" data-testid="cf-tab-general">General</button>
<button type="button" role="tab" [class.active]="tab() === 'connection'" [attr.aria-selected]="tab() === 'connection'" (click)="setTab('connection')" data-testid="cf-tab-connection">Connection</button>
<button type="button" role="tab" [class.active]="tab() === 'files'" [attr.aria-selected]="tab() === 'files'" (click)="setTab('files')" data-testid="cf-tab-files">Files</button>
</div>
<div [formGroup]="form">
@if (tab() === 'general') {
<div class="row">
<label for="cf-name">Name</label>
<input id="cf-name" type="text" formControlName="name" maxlength="128" data-testid="cf-name" />
@if (showError('name'); as msg) { <span class="error" data-testid="cf-error-name">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-desc">Description (Markdown)</label>
<textarea id="cf-desc" rows="4" formControlName="descriptionMd" data-testid="cf-desc"></textarea>
<button type="button" class="preview-toggle" (click)="togglePreview()" data-testid="cf-desc-preview-toggle">
{{ showPreview() ? 'Hide preview' : 'Preview' }}
</button>
@if (showPreview()) {
<div class="markdown-preview" data-testid="cf-desc-preview" [innerHTML]="previewHtml()"></div>
}
@if (showError('descriptionMd'); as msg) { <span class="error" data-testid="cf-error-description">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-category">Category</label>
<select id="cf-category" formControlName="categoryId" data-testid="cf-category">
<option value="">Select a category</option>
@for (cat of categories(); track cat.id) {
<option [value]="cat.id">{{ cat.abbreviation }} {{ cat.name }}</option>
}
</select>
@if (showError('categoryId'); as msg) { <span class="error" data-testid="cf-error-category">{{ msg }}</span> }
</div>
<div class="row">
<label>Difficulty</label>
<div role="radiogroup" aria-label="Difficulty">
@for (d of difficulties; track d) {
<label>
<input type="radio" [value]="d" formControlName="difficulty" [attr.data-testid]="'cf-difficulty-' + d" /> {{ d }}
</label>
}
</div>
</div>
<div class="field-row">
<div class="row">
<label for="cf-initial">Initial points</label>
<input id="cf-initial" type="number" min="1" max="100000" formControlName="initialPoints" data-testid="cf-initial" />
@if (showError('initialPoints'); as msg) { <span class="error" data-testid="cf-error-initial">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-minimum">Minimum points</label>
<input id="cf-minimum" type="number" min="1" max="100000" formControlName="minimumPoints" data-testid="cf-minimum" />
@if (showError('minimumPoints'); as msg) { <span class="error" data-testid="cf-error-minimum">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-decay">Decay solves</label>
<input id="cf-decay" type="number" min="1" max="10000" formControlName="decaySolves" data-testid="cf-decay" />
@if (showError('decaySolves'); as msg) { <span class="error" data-testid="cf-error-decay">{{ msg }}</span> }
</div>
</div>
<div class="row">
<label for="cf-flag">Secret flag</label>
<div class="flag-input">
<input id="cf-flag" [type]="flagVisible() ? 'text' : 'password'" formControlName="flag" data-testid="cf-flag" />
<button type="button" (click)="toggleFlag()" [attr.aria-label]="flagVisible() ? 'Hide flag' : 'Reveal flag'" data-testid="cf-flag-toggle">{{ flagVisible() ? '🙈' : '👁' }}</button>
</div>
@if (showError('flag'); as msg) { <span class="error" data-testid="cf-error-flag">{{ msg }}</span> }
</div>
}
@if (tab() === 'connection') {
<div class="row">
<label>Protocol</label>
<div role="radiogroup" aria-label="Protocol">
@for (p of protocols; track p) {
<label>
<input type="radio" [value]="p" formControlName="protocol" [attr.data-testid]="'cf-protocol-' + p" /> {{ p }}
</label>
}
</div>
</div>
<div class="row">
<label for="cf-port">Port</label>
<input id="cf-port" type="number" min="1" max="65535" formControlName="port" data-testid="cf-port" />
@if (showError('port'); as msg) { <span class="error" data-testid="cf-error-port">{{ msg }}</span> }
</div>
<div class="row">
<label for="cf-ip">IP address (optional; falls back to Default Challenge IP)</label>
<input id="cf-ip" type="text" formControlName="ipAddress" data-testid="cf-ip" />
@if (showError('ipAddress'); as msg) { <span class="error" data-testid="cf-error-ip">{{ msg }}</span> }
</div>
}
@if (tab() === 'files') {
<ul class="file-list" data-testid="cf-files">
@for (file of files(); track $index) {
<li>
<span>{{ file.originalFilename }}</span>
<small>{{ file.mimeType }} · {{ formatSize(file.sizeBytes) }}</small>
<button type="button" class="remove" (click)="removeFile(file)" [attr.aria-label]="'Remove ' + file.originalFilename" data-testid="cf-file-remove">🗑</button>
</li>
}
</ul>
<div class="row">
<label for="cf-upload">Upload files (any file type)</label>
<input id="cf-upload" type="file" multiple (change)="onFileChange($event)" data-testid="cf-upload" />
@if (fileUploadError(); as err) {
<span class="upload-error" data-testid="cf-upload-error">{{ err }}</span>
}
<small>Limit: {{ formatSize(globalFileLimit()) }} per file</small>
</div>
}
@if (errorMessage(); as msg) {
<p class="error" data-testid="cf-error" role="alert" style="margin-top: 8px;">{{ msg }}</p>
}
</div>
<div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="cf-cancel">Cancel</button>
<button type="button" (click)="onOk()" [disabled]="submitting()" data-testid="cf-ok">
{{ submitting() ? 'Saving…' : (mode() === 'create' ? 'Create' : 'Save') }}
</button>
</div>
</form>
</div>
}
`,
})
export class ChallengeFormModalComponent {
private readonly fb = inject(FormBuilder);
private readonly destroyRef = inject(DestroyRef);
private readonly cdr = inject(ChangeDetectorRef);
private readonly markdown = inject(MarkdownService);
private readonly admin = inject(AdminService);
readonly open = input(false);
readonly mode = input<ChallengeFormMode>('create');
readonly detail = input<AdminChallengeDetail | null>(null);
readonly categories = input<AdminCategory[]>([]);
readonly globalFileLimit = input<number>(100 * 1024 * 1024);
readonly errorMessage = input<string | null>(null);
readonly fieldErrors = input<ChallengeFormErrors | Record<string, string> | null>(null);
readonly saving = input(false);
readonly cancel = output<void>();
readonly submit = output<ChallengeFormSubmitPayload>();
readonly form: FormGroup<ChallengeFormControls> = buildChallengeFormGroup(this.fb);
readonly tab = signal<'general' | 'connection' | 'files'>('general');
readonly flagVisible = signal(false);
readonly showPreview = signal(false);
readonly previewHtml = signal('');
readonly files = signal<ChallengeStagedFileUi[]>([]);
readonly fileUploadError = signal<string | null>(null);
readonly submitting = signal(false);
readonly difficulties = ['LOW', 'MEDIUM', 'HIGH'] as const;
readonly protocols = ['NC', 'WEB'] as const;
private minimumTouched = false;
private previousInitial = 100;
private pendingRemoval: string[] = [];
constructor() {
effect(
() => {
const result = syncChallengeForm(this.form, this.mode(), this.detail());
this.minimumTouched = false;
this.previousInitial = result.initialPoints;
this.flagVisible.set(false);
this.showPreview.set(false);
this.previewHtml.set(this.markdown.render(result.descriptionMd));
this.files.set(buildInitialFiles(this.detail()));
this.pendingRemoval = [];
this.fileUploadError.set(null);
this.submitting.set(false);
this.cdr.markForCheck();
},
{ allowSignalWrites: true },
);
this.form.controls.descriptionMd.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => {
this.previewHtml.set(this.markdown.render(v));
});
this.form.controls.initialPoints.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
const next = Number(value);
if (Number.isFinite(next)) {
const currentMin = this.form.controls.minimumPoints.value;
const newMin = deriveMinimumPoints(next, currentMin, this.previousInitial, this.minimumTouched);
this.form.controls.minimumPoints.setValue(newMin, { emitEvent: false });
this.previousInitial = next;
}
});
this.form.controls.minimumPoints.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.minimumTouched = true;
});
this.form.controls.protocol.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((p) => {
if (p === 'WEB') {
this.form.controls.port.setValue(null, { emitEvent: false });
} else if (p === 'NC' && (this.form.controls.port.value === null || this.form.controls.port.value === undefined)) {
this.form.controls.port.setValue(1337, { emitEvent: false });
}
});
}
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open()) this.onCancel();
}
setTab(tab: 'general' | 'connection' | 'files'): void {
this.tab.set(tab);
}
toggleFlag(): void {
this.flagVisible.set(!this.flagVisible());
}
togglePreview(): void {
this.showPreview.set(!this.showPreview());
}
showError(field: keyof ChallengeFormErrors): string | null {
const fromServer = (this.fieldErrors() ?? {})[field as string];
if (fromServer) return fromServer;
const ctrl = this.form.controls[field as keyof ChallengeFormControls];
if (!ctrl || (!ctrl.touched && !ctrl.dirty)) return null;
if (ctrl.errors?.['required']) {
return `${humanLabel(field)} is required`;
}
if (ctrl.errors?.['maxlength']) {
return `${humanLabel(field)} is too long`;
}
if (ctrl.errors?.['min']) {
return `${humanLabel(field)} must be at least ${ctrl.errors['min'].min}`;
}
if (ctrl.errors?.['max']) {
return `${humanLabel(field)} must be at most ${ctrl.errors['max'].max}`;
}
return null;
}
onCancel(): void {
// discard any pending staged tokens
for (const f of this.files()) {
if (f.kind === 'staged' && f.token) {
void this.admin.discardChallengeFile(f.token).catch(() => undefined);
}
}
this.files.set([]);
this.pendingRemoval = [];
this.cancel.emit();
}
async onFileChange(ev: Event): Promise<void> {
const input = ev.target as HTMLInputElement;
const list = input.files ? Array.from(input.files) : [];
input.value = '';
this.fileUploadError.set(null);
for (const file of list) {
if (file.size > this.globalFileLimit()) {
this.fileUploadError.set(
`File '${file.name}' exceeds the size limit of ${this.formatSize(this.globalFileLimit())}.`,
);
continue;
}
const placeholder: ChallengeStagedFileUi = {
kind: 'staged',
token: undefined,
originalFilename: file.name,
mimeType: file.type || 'application/octet-stream',
sizeBytes: file.size,
uploading: true,
};
this.files.update((arr) => [...arr, placeholder]);
try {
const res = await this.admin.stageChallengeFile(file);
this.files.update((arr) =>
arr.map((entry) =>
entry === placeholder
? { ...entry, token: res.token, uploading: false }
: entry,
),
);
} catch (e: any) {
this.files.update((arr) => arr.filter((entry) => entry !== placeholder));
this.fileUploadError.set(
`Failed to upload '${file.name}': ${e?.error?.message ?? e?.message ?? 'unknown error'}`,
);
}
}
}
removeFile(file: ChallengeStagedFileUi): void {
if (file.kind === 'staged' && file.token) {
void this.admin.discardChallengeFile(file.token).catch(() => undefined);
this.files.update((arr) => arr.filter((f) => f !== file));
} else if (file.kind === 'existing' && file.id) {
this.pendingRemoval.push(file.id);
this.files.update((arr) => arr.filter((f) => f !== file));
}
}
onOk(): void {
if (this.files().some((f) => f.uploading)) {
this.fileUploadError.set('Wait for uploads to finish before saving.');
return;
}
const values: ChallengeFormDefaults = {
name: this.form.controls.name.value,
descriptionMd: this.form.controls.descriptionMd.value,
categoryId: this.form.controls.categoryId.value,
difficulty: this.form.controls.difficulty.value,
initialPoints: this.form.controls.initialPoints.value,
minimumPoints: this.form.controls.minimumPoints.value,
decaySolves: this.form.controls.decaySolves.value,
flag: this.form.controls.flag.value,
protocol: this.form.controls.protocol.value,
port: this.form.controls.port.value,
ipAddress: this.form.controls.ipAddress.value,
enabled: this.form.controls.enabled.value,
};
const clientErrors = validateChallengeForm(values);
if (Object.keys(clientErrors).length > 0) {
this.form.markAllAsTouched();
return;
}
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.form.markAllAsTouched();
const filePayload: ChallengeFormFileItem[] = this.files().map((f) => ({
id: f.kind === 'existing' ? f.id : undefined,
stagedToken: f.kind === 'staged' ? f.token : undefined,
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
}));
for (const id of this.pendingRemoval) {
filePayload.push({
id,
originalFilename: '',
mimeType: '',
sizeBytes: 0,
remove: true,
});
}
const body = toCreateBody(values, filePayload);
this.submit.emit({ body });
}
formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
}
function humanLabel(field: keyof ChallengeFormErrors): string {
switch (field) {
case 'name': return 'Name';
case 'descriptionMd': return 'Description';
case 'categoryId': return 'Category';
case 'initialPoints': return 'Initial points';
case 'minimumPoints': return 'Minimum points';
case 'decaySolves': return 'Decay solves';
case 'flag': return 'Flag';
case 'port': return 'Port';
case 'ipAddress': return 'IP address';
case 'protocol': return 'Protocol';
default: return 'Field';
}
}
// Re-export so tests can find default helpers.
export { defaultMinimumPoints };
@@ -0,0 +1,242 @@
import {
AdminChallengeDetail,
AdminChallengeFile,
CreateChallengeBody,
UpdateChallengeBody,
} from '../../../core/services/admin.service';
export type Difficulty = 'LOW' | 'MEDIUM' | 'HIGH';
export type Protocol = 'NC' | 'WEB';
export const DEFAULT_INITIAL_POINTS = 100;
export const DEFAULT_DECAY_SOLVES = 10;
export function defaultMinimumPoints(initial: number): number {
if (!Number.isFinite(initial) || initial < 1) return 1;
return Math.max(1, Math.floor(initial / 2));
}
export interface ChallengeFormDefaults {
name: string;
descriptionMd: string;
categoryId: string;
difficulty: Difficulty;
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
protocol: Protocol;
port: number | null;
ipAddress: string;
enabled: boolean;
}
export function emptyChallengeFormDefaults(): ChallengeFormDefaults {
return {
name: '',
descriptionMd: '',
categoryId: '',
difficulty: 'MEDIUM',
initialPoints: DEFAULT_INITIAL_POINTS,
minimumPoints: defaultMinimumPoints(DEFAULT_INITIAL_POINTS),
decaySolves: DEFAULT_DECAY_SOLVES,
flag: '',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
};
}
export function prefillChallengeFormDefaults(detail: AdminChallengeDetail): ChallengeFormDefaults {
return {
name: detail.name,
descriptionMd: detail.descriptionMd,
categoryId: detail.categoryId,
difficulty: detail.difficulty,
initialPoints: detail.initialPoints,
minimumPoints: detail.minimumPoints,
decaySolves: detail.decaySolves,
flag: detail.flag,
protocol: detail.protocol,
port: detail.port,
ipAddress: detail.ipAddress,
enabled: detail.enabled,
};
}
export interface ChallengeFormErrors {
name?: string;
descriptionMd?: string;
categoryId?: string;
initialPoints?: string;
minimumPoints?: string;
decaySolves?: string;
flag?: string;
protocol?: string;
port?: string;
ipAddress?: string;
}
export function validateChallengeForm(values: ChallengeFormDefaults): ChallengeFormErrors {
const errs: ChallengeFormErrors = {};
if (!values.name.trim()) errs.name = 'Name is required';
else if (values.name.length > 128) errs.name = 'Name must be 128 characters or fewer';
if (!values.descriptionMd.trim()) errs.descriptionMd = 'Description is required';
if (!values.categoryId) errs.categoryId = 'Category is required';
if (!Number.isInteger(values.initialPoints) || values.initialPoints < 1 || values.initialPoints > 100000) {
errs.initialPoints = 'Initial points must be between 1 and 100000';
}
if (!Number.isInteger(values.minimumPoints) || values.minimumPoints < 1) {
errs.minimumPoints = 'Minimum points must be at least 1';
} else if (values.minimumPoints > values.initialPoints) {
errs.minimumPoints = 'Minimum points must be less than or equal to initial points';
}
if (!Number.isInteger(values.decaySolves) || values.decaySolves < 1 || values.decaySolves > 10000) {
errs.decaySolves = 'Decay solves must be between 1 and 10000';
}
if (!values.flag.trim()) errs.flag = 'Flag is required';
if (values.protocol === 'NC') {
if (values.port === null || values.port === undefined || !Number.isInteger(values.port)) {
errs.port = 'Port is required for NC';
} else if (values.port < 1 || values.port > 65535) {
errs.port = 'Port must be between 1 and 65535';
}
} else if (values.protocol === 'WEB' && values.port !== null && values.port !== undefined) {
if (!Number.isInteger(values.port) || values.port < 1 || values.port > 65535) {
errs.port = 'Port must be between 1 and 65535';
}
}
if (values.ipAddress && !isValidIpOrHostname(values.ipAddress)) {
errs.ipAddress = 'Must be a valid IPv4/IPv6 address or hostname';
}
return errs;
}
const IPV4_PART = /^(0|[1-9][0-9]{0,2})$/;
export function isValidIpOrHostname(value: string): boolean {
if (!value) return true;
if (value.length > 255) return false;
if (value.includes(':')) {
return /^[0-9A-Fa-f:.]+$/.test(value);
}
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(value)) {
return value.split('.').every((p) => IPV4_PART.test(p) && Number(p) <= 255);
}
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));
}
export function deriveMinimumPoints(
initial: number,
previousMinimum: number,
previousInitial: number,
minimumTouched: boolean,
): number {
if (minimumTouched) {
return previousMinimum;
}
if (!minimumTouched && previousMinimum === defaultMinimumPoints(previousInitial)) {
return defaultMinimumPoints(initial);
}
return previousMinimum;
}
export interface ChallengeFormFileItem {
id?: string;
stagedToken?: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
remove?: boolean;
}
export function toCreateBody(
values: ChallengeFormDefaults,
files: ChallengeFormFileItem[],
): CreateChallengeBody {
const protocolPort =
values.protocol === 'NC'
? values.port ?? null
: values.port === null || values.port === undefined
? null
: values.port;
return {
name: values.name.trim(),
descriptionMd: values.descriptionMd,
categoryId: values.categoryId,
difficulty: values.difficulty,
initialPoints: values.initialPoints,
minimumPoints: values.minimumPoints,
decaySolves: values.decaySolves,
flag: values.flag,
protocol: values.protocol,
port: protocolPort,
ipAddress: values.ipAddress ?? '',
enabled: values.enabled,
files: files.map((f) => ({
id: f.id,
stagedToken: f.stagedToken,
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
remove: f.remove,
})),
};
}
export function toUpdateBody(
values: ChallengeFormDefaults,
files: ChallengeFormFileItem[],
): UpdateChallengeBody {
return toCreateBody(values, files) as UpdateChallengeBody;
}
export function mapServerFieldErrors(details: any[] | undefined): ChallengeFormErrors {
if (!details || !Array.isArray(details)) return {};
const out: ChallengeFormErrors = {};
for (const d of details) {
const path = String(d.path ?? '');
const message = String(d.message ?? '');
switch (path) {
case 'name': out.name = message; break;
case 'descriptionMd': out.descriptionMd = message; break;
case 'categoryId': out.categoryId = message; break;
case 'initialPoints': out.initialPoints = message; break;
case 'minimumPoints': out.minimumPoints = message; break;
case 'decaySolves': out.decaySolves = message; break;
case 'flag': out.flag = message; break;
case 'protocol': out.protocol = message; break;
case 'port': out.port = message; break;
case 'ipAddress': out.ipAddress = message; break;
default: break;
}
}
return out;
}
export interface ChallengeStagedFileUi {
kind: 'existing' | 'staged';
id?: string;
token?: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
error?: string;
uploading?: boolean;
}
export function buildInitialFiles(detail?: AdminChallengeDetail | null): ChallengeStagedFileUi[] {
if (!detail) return [];
return detail.files.map((f: AdminChallengeFile) => ({
kind: 'existing' as const,
id: f.id,
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
}));
}
@@ -0,0 +1,119 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
computed,
input,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ImportPreviewResponse, ImportResultResponse } from '../../../core/services/admin.service';
@Component({
selector: 'app-challenge-import-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 70; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(520px, 90vw); }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.error { color: var(--color-danger, #f00); margin-top: 8px; }
.warnings { color: var(--color-warning, #b45309); margin-top: 8px; }
.result { color: var(--color-success, #065f46); margin-top: 8px; }
ul { margin: 0; padding-left: 18px; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
`],
template: `
@if (open()) {
<div class="modal-overlay" data-testid="challenge-import-overlay">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="ci-title" data-testid="challenge-import-modal">
<h3 id="ci-title">Import challenges</h3>
@if (result(); as r) {
<p class="result" data-testid="challenge-import-result">
Imported {{ r.imported }} challenge{{ r.imported === 1 ? '' : 's' }}.
@if (r.skipped.length > 0) { (Skipped {{ r.skipped.length }}.) }
@if (r.warnings.length > 0) { ({{ r.warnings.length }} warning(s).) }
</p>
@if (r.skipped.length > 0) {
<ul data-testid="challenge-import-skipped">
@for (s of r.skipped; track s.name) { <li>{{ s.name }} {{ s.reason }}</li> }
</ul>
}
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="challenge-import-close">Close</button>
</div>
} @else if (preview()) {
@if (preview(); as p) {
<p data-testid="challenge-import-preview">
Import {{ p.total }} challenge{{ p.total === 1 ? '' : 's' }}.
Existing challenges with matching names will be overwritten.
New categories referenced but missing locally will be skipped with a warning.
Continue?
</p>
@if (p.conflicts.length > 0) {
<p>Will overwrite:</p>
<ul>
@for (name of p.conflicts; track name) { <li>{{ name }}</li> }
</ul>
}
@if (p.unknownCategories.length > 0) {
<p class="warnings">Missing categories (will be skipped):</p>
<ul>
@for (key of p.unknownCategories; track key) { <li>{{ key }}</li> }
</ul>
}
}
@if (errorMessage(); as msg) {
<p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="challenge-import-cancel">Cancel</button>
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="challenge-import-confirm">
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
Overwrite &amp; Import
</button>
</div>
} @else if (errorMessage()) {
<p class="error" role="alert" data-testid="challenge-import-error">{{ msg }}</p>
<div class="actions">
<button type="button" (click)="onCancel()" data-testid="challenge-import-close">Close</button>
</div>
} @else {
<p>Reading file</p>
}
</div>
</div>
}
`,
})
export class ChallengeImportModalComponent {
readonly open = input(false);
readonly document = input<unknown>(null);
readonly preview = input<ImportPreviewResponse | null>(null);
readonly result = input<ImportResultResponse | null>(null);
readonly submitting = input(false);
readonly errorMessage = input<string | null>(null);
readonly cancel = output<void>();
readonly confirm = output<void>();
readonly summary = computed(() => this.preview());
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open() && !this.submitting() && !this.result()) this.onCancel();
}
onCancel(): void {
if (this.submitting()) return;
this.cancel.emit();
}
onConfirm(): void {
if (this.submitting() || this.result()) return;
this.confirm.emit();
}
}
@@ -0,0 +1,501 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs';
import {
AdminService,
AdminCategory,
AdminChallengeListItem,
ImportPreviewResponse,
ImportResultResponse,
} from '../../../core/services/admin.service';
import {
buildExportFilename,
categoryIconSrc,
deriveEmptyState,
difficultyClass,
summarizeImportPreview,
summarizeImportResult,
} from './challenges.pure';
import { ChallengeFormModalComponent } from './challenge-form-modal.component';
import { ChallengeDeleteModalComponent } from './challenge-delete-modal.component';
import { ChallengeImportModalComponent } from './challenge-import-modal.component';
@Component({
selector: 'app-admin-challenges',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
ChallengeFormModalComponent,
ChallengeDeleteModalComponent,
ChallengeImportModalComponent,
],
styles: [`
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.toolbar input[type="search"] { flex: 1; min-width: 220px; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc); text-align: left; }
.icon-cell img { width: 32px; height: 32px; object-fit: cover; border-radius: 4px; background: #eee; }
.icon-cell .fallback { display: inline-block; min-width: 32px; text-align: center; font-weight: bold; }
.badge { padding: 2px 6px; border-radius: 4px; font-size: 12px; }
.difficulty-LOW { background: #d1fae5; color: #065f46; }
.difficulty-MEDIUM { background: #fef3c7; color: #92400e; }
.difficulty-HIGH { background: #fee2e2; color: #991b1b; }
.empty { text-align: center; padding: 32px; }
.row-actions button { margin-left: 4px; }
.status-banner { padding: 8px; margin: 8px 0; border-radius: 4px; }
.status-banner.success { background: #ecfdf5; color: #065f46; }
.status-banner.error { background: #fee2e2; color: #991b1b; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
button[disabled] { opacity: 0.5; cursor: not-allowed; }
`],
template: `
<section data-testid="admin-challenges">
<div class="toolbar">
<input
type="search"
placeholder="Search challenges…"
[value]="searchInput()"
(input)="onSearchInput($event)"
data-testid="challenges-search"
aria-label="Search challenges"
/>
<button type="button" (click)="openCreate()" [disabled]="actionInFlight()" data-testid="challenges-add">
Add challenge
</button>
<button type="button" (click)="openImport()" [disabled]="actionInFlight()" data-testid="challenges-import">
Import challenges
</button>
<button type="button" (click)="onExport()" [disabled]="actionInFlight()" data-testid="challenges-export">
<span *ngIf="exporting()" class="spinner" aria-hidden="true"></span>
Export challenges
</button>
</div>
@if (statusMessage(); as msg) {
<p class="status-banner success" role="status" aria-live="polite" data-testid="challenges-status">{{ msg }}</p>
}
@if (errorMessage(); as msg) {
<p class="status-banner error" role="alert" aria-live="assertive" data-testid="challenges-error">{{ msg }}</p>
}
@if (loading()) {
<p data-testid="challenges-loading">Loading challenges</p>
} @else if (emptyState()) {
@if (emptyState(); as state) {
@if (state.kind === 'empty-list') {
<div class="empty" data-testid="challenges-empty">
<p>No challenges yet</p>
<button type="button" (click)="openCreate()" [disabled]="actionInFlight()" data-testid="challenges-empty-add">
Add challenge
</button>
</div>
} @else {
<div class="empty" data-testid="challenges-empty-search">
<p>No challenges match "{{ state.search }}"</p>
</div>
}
}
} @else {
<table class="table" data-testid="challenges-table">
<thead>
<tr>
<th scope="col">Category</th>
<th scope="col">
<button type="button" (click)="toggleSort()" data-testid="challenges-sort-name">
Name {{ sortDirection() === 'asc' ? '↑' : '↓' }}
</button>
</th>
<th scope="col">Difficulty</th>
<th scope="col">Protocol</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
@for (row of rows(); track row.id) {
<tr [attr.data-testid]="'challenge-row-' + row.id">
<td class="icon-cell">
@if (row.categoryIconPath) {
<img [src]="iconSrc(row)" [alt]="row.categoryAbbreviation" (error)="onIconError($event)" />
} @else {
<span class="fallback">{{ row.categoryAbbreviation }}</span>
}
</td>
<td>{{ row.name }}</td>
<td><span [class]="difficultyClass(row.difficulty)">{{ row.difficulty }}</span></td>
<td>{{ row.protocol }}</td>
<td class="row-actions">
<button type="button" (click)="openEdit(row)" aria-label="Edit" data-testid="challenges-row-edit"></button>
<button type="button" (click)="openDelete(row)" aria-label="Delete" data-testid="challenges-row-delete">🗑</button>
</td>
</tr>
}
</tbody>
</table>
}
<app-challenge-form-modal
[open]="formOpen()"
[mode]="formMode()"
[detail]="formDetail()"
[categories]="categories()"
[globalFileLimit]="globalFileLimit()"
[errorMessage]="formError()"
[fieldErrors]="fieldErrors()"
[saving]="saving()"
(cancel)="closeForm()"
(submit)="onFormSubmit($event)"
/>
<app-challenge-delete-modal
[open]="deleteOpen()"
[challenge]="deleteTarget()"
[errorMessage]="deleteError()"
[submitting]="deleting()"
(cancel)="closeDelete()"
(confirm)="onDeleteConfirm()"
/>
<app-challenge-import-modal
[open]="importOpen()"
[document]="importDoc()"
[preview]="importPreview()"
[submitting]="importing()"
[errorMessage]="importError()"
[result]="importResult()"
(cancel)="closeImport()"
(confirm)="onImportConfirm()"
/>
</section>
`,
})
export class AdminChallengesComponent implements OnInit, OnDestroy {
private readonly admin = inject(AdminService);
private readonly destroyRef = inject(DestroyRef);
private readonly searchSubject = new Subject<string>();
private readonly objectUrls = new Set<string>();
private requestSeq = 0;
readonly categories = signal<AdminCategory[]>([]);
readonly rows = signal<AdminChallengeListItem[]>([]);
readonly loading = signal(true);
readonly searchInput = signal('');
readonly activeSearch = signal('');
readonly sortDirection = signal<'asc' | 'desc'>('asc');
readonly formOpen = signal(false);
readonly formMode = signal<'create' | 'edit'>('create');
readonly formDetail = signal<any>(null);
readonly formError = signal<string | null>(null);
readonly fieldErrors = signal<Record<string, string> | null>(null);
readonly saving = signal(false);
readonly deleteOpen = signal(false);
readonly deleteTarget = signal<AdminChallengeListItem | null>(null);
readonly deleteError = signal<string | null>(null);
readonly deleting = signal(false);
readonly importOpen = signal(false);
readonly importDoc = signal<any>(null);
readonly importPreview = signal<ImportPreviewResponse | null>(null);
readonly importResult = signal<ImportResultResponse | null>(null);
readonly importError = signal<string | null>(null);
readonly importing = signal(false);
readonly statusMessage = signal<string | null>(null);
readonly errorMessage = signal<string | null>(null);
readonly exporting = signal(false);
readonly globalFileLimit = signal<number>(100 * 1024 * 1024);
readonly emptyState = computed(() =>
deriveEmptyState(this.rows(), this.activeSearch(), this.loading()),
);
readonly actionInFlight = computed(
() => this.saving() || this.deleting() || this.exporting() || this.importing(),
);
ngOnInit(): void {
this.searchSubject
.pipe(debounceTime(250), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
this.activeSearch.set(value);
void this.load();
});
void this.loadCategories();
void this.load();
}
ngOnDestroy(): void {
for (const url of this.objectUrls) {
try {
URL.revokeObjectURL(url);
} catch {
// ignore
}
}
}
async loadCategories(): Promise<void> {
try {
const cats = await this.admin.listCategories();
this.categories.set(cats);
} catch {
// non-fatal; form will surface category load failure
}
}
async load(): Promise<void> {
const seq = ++this.requestSeq;
this.loading.set(true);
this.errorMessage.set(null);
try {
const q = this.activeSearch();
const list = await this.admin.listChallenges({ q: q.length > 0 ? q : undefined });
if (seq !== this.requestSeq) return;
const sorted = this.applySort(list);
this.rows.set(sorted);
} catch (e: any) {
if (seq !== this.requestSeq) return;
this.errorMessage.set(e?.error?.message ?? e?.message ?? 'Failed to load challenges');
} finally {
if (seq === this.requestSeq) this.loading.set(false);
}
}
applySort(list: AdminChallengeListItem[]): AdminChallengeListItem[] {
const sorted = [...list].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
return this.sortDirection() === 'desc' ? sorted.reverse() : sorted;
}
toggleSort(): void {
this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : 'asc');
this.rows.set(this.applySort(this.rows()));
}
onSearchInput(ev: Event): void {
const value = (ev.target as HTMLInputElement).value;
this.searchInput.set(value);
this.searchSubject.next(value);
}
difficultyClass(d: 'LOW' | 'MEDIUM' | 'HIGH'): string {
return difficultyClass(d);
}
iconSrc(row: AdminChallengeListItem): string {
return categoryIconSrc(row.categoryIconPath, row.categoryAbbreviation);
}
onIconError(ev: Event): void {
const img = ev.target as HTMLImageElement;
img.style.display = 'none';
}
openCreate(): void {
this.formMode.set('create');
this.formDetail.set(null);
this.formError.set(null);
this.fieldErrors.set(null);
this.formOpen.set(true);
}
async openEdit(row: AdminChallengeListItem): Promise<void> {
this.formMode.set('edit');
this.formError.set(null);
this.fieldErrors.set(null);
this.formOpen.set(true);
this.formDetail.set(null);
try {
const detail = await this.admin.getChallengeDetail(row.id);
this.formDetail.set(detail);
} catch (e: any) {
this.formOpen.set(false);
this.errorMessage.set(e?.error?.message ?? e?.message ?? 'Failed to load challenge');
}
}
closeForm(): void {
this.formOpen.set(false);
this.formDetail.set(null);
this.formError.set(null);
this.fieldErrors.set(null);
}
async onFormSubmit(payload: any): Promise<void> {
this.saving.set(true);
this.formError.set(null);
this.fieldErrors.set(null);
try {
if (this.formMode() === 'create') {
await this.admin.createChallenge(payload);
} else {
const detail = this.formDetail();
if (!detail) throw new Error('Missing challenge detail');
await this.admin.updateChallenge(detail.id, payload);
}
this.closeForm();
await this.load();
this.statusMessage.set(this.formMode() === 'create' ? 'Challenge created.' : 'Challenge updated.');
} catch (e: any) {
const code = e?.error?.code ?? e?.code;
const message = e?.error?.message ?? e?.message ?? 'Failed to save challenge';
this.formError.set(this.mapFormErrorMessage(code, message));
const details = e?.error?.details;
if (Array.isArray(details)) {
const map: Record<string, string> = {};
for (const d of details) {
if (d && typeof d === 'object' && d.path && d.message) {
map[String(d.path)] = String(d.message);
}
}
this.fieldErrors.set(Object.keys(map).length > 0 ? map : null);
}
} finally {
this.saving.set(false);
}
}
mapFormErrorMessage(code: string | undefined, fallback: string): string {
if (code === 'CHALLENGE_NAME_TAKEN') return 'A challenge with this name already exists.';
if (code === 'CHALLENGE_INVALID_CATEGORY') return 'Selected category does not exist.';
return fallback;
}
openDelete(row: AdminChallengeListItem): void {
this.deleteTarget.set(row);
this.deleteError.set(null);
this.deleteOpen.set(true);
}
closeDelete(): void {
this.deleteOpen.set(false);
this.deleteTarget.set(null);
this.deleteError.set(null);
}
async onDeleteConfirm(): Promise<void> {
const target = this.deleteTarget();
if (!target) return;
this.deleting.set(true);
this.deleteError.set(null);
try {
await this.admin.deleteChallenge(target.id);
this.closeDelete();
await this.load();
this.statusMessage.set(`Challenge '${target.name}' deleted.`);
} catch (e: any) {
this.deleteError.set(e?.error?.message ?? e?.message ?? 'Failed to delete challenge');
} finally {
this.deleting.set(false);
}
}
openImport(): void {
this.importDoc.set(null);
this.importPreview.set(null);
this.importResult.set(null);
this.importError.set(null);
this.importOpen.set(true);
void this.pickImportFile();
}
async pickImportFile(): Promise<void> {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) {
this.closeImport();
return;
}
const text = await file.text();
let parsed: any;
try {
parsed = JSON.parse(text);
} catch {
this.importError.set('Selected file is not valid JSON.');
return;
}
try {
const preview = await this.admin.previewChallengeImport(parsed);
this.importDoc.set(parsed);
this.importPreview.set(preview);
} catch (e: any) {
this.importError.set(e?.error?.message ?? e?.message ?? 'Failed to validate import');
}
});
input.click();
}
closeImport(): void {
this.importOpen.set(false);
this.importDoc.set(null);
this.importPreview.set(null);
this.importResult.set(null);
this.importError.set(null);
}
async onImportConfirm(): Promise<void> {
const doc = this.importDoc();
if (!doc) return;
this.importing.set(true);
this.importError.set(null);
try {
const result = await this.admin.confirmChallengeImport(doc);
this.importResult.set(result);
this.statusMessage.set(summarizeImportResult(result));
await this.load();
} catch (e: any) {
this.importError.set(e?.error?.message ?? e?.message ?? 'Failed to import challenges');
} finally {
this.importing.set(false);
}
}
async onExport(): Promise<void> {
this.exporting.set(true);
this.errorMessage.set(null);
try {
const blob = await this.admin.exportChallenges();
const url = URL.createObjectURL(blob);
this.objectUrls.add(url);
const a = document.createElement('a');
a.href = url;
a.download = buildExportFilename();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => {
try {
URL.revokeObjectURL(url);
this.objectUrls.delete(url);
} catch {
// ignore
}
}, 60_000);
this.statusMessage.set('Export ready.');
} catch (e: any) {
this.errorMessage.set(e?.error?.message ?? e?.message ?? 'Failed to export challenges');
} finally {
this.exporting.set(false);
}
}
summarizePreview(p: ImportPreviewResponse | null): string {
return p ? summarizeImportPreview(p) : '';
}
}
@@ -0,0 +1,74 @@
import { AdminChallengeListItem, ImportPreviewResponse, ImportResultResponse } from '../../../core/services/admin.service';
export function normalizeSearch(value: string | null | undefined): string {
return (value ?? '').trim();
}
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function buildExportFilename(now: Date = new Date()): string {
const iso = now.toISOString();
const date = iso.slice(0, 10);
return `challenges-export-${date}.json`;
}
export type EmptyState =
| { kind: 'no-results-search'; search: string }
| { kind: 'empty-list' };
export function deriveEmptyState(
rows: AdminChallengeListItem[],
search: string,
loading: boolean,
): EmptyState | null {
if (loading) return null;
if (rows.length > 0) return null;
const trimmed = search.trim();
if (trimmed.length > 0) return { kind: 'no-results-search', search: trimmed };
return { kind: 'empty-list' };
}
export function summarizeImportPreview(p: ImportPreviewResponse): string {
if (p.total === 0) return 'Import file contains no challenges.';
const lines: string[] = [`Import ${p.total} challenge${p.total === 1 ? '' : 's'}.`];
if (p.conflicts.length > 0) {
lines.push(
`${p.conflicts.length} existing challenge${p.conflicts.length === 1 ? '' : 's'} will be overwritten.`,
);
}
if (p.unknownCategories.length > 0) {
lines.push(
`${p.unknownCategories.length} challenge(s) reference missing categories and will be skipped.`,
);
}
return lines.join(' ');
}
export function summarizeImportResult(r: ImportResultResponse): string {
const lines: string[] = [];
lines.push(`Imported ${r.imported} challenge${r.imported === 1 ? '' : 's'}.`);
if (r.skipped.length > 0) {
lines.push(`Skipped ${r.skipped.length}.`);
}
if (r.warnings.length > 0) {
lines.push(`${r.warnings.length} warning(s).`);
}
return lines.join(' ');
}
export function difficultyClass(difficulty: 'LOW' | 'MEDIUM' | 'HIGH'): string {
return `difficulty-badge difficulty-${difficulty.toLowerCase()}`;
}
export function categoryIconSrc(iconPath: string, abbreviation: string): string {
return iconPath && iconPath.length > 0 ? iconPath : '';
}
export function categoryAltText(abbreviation: string, name: string): string {
return `${abbreviation}${name}`;
}
+1 -1
View File
@@ -15,7 +15,7 @@
"@types/jest": "^29.5.12",
"@types/jsdom": "^28.0.3",
"@types/node": "^20.11.0",
"concurrently": "^9.2.4",
"concurrently": "^9.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^29.1.1",
+3
View File
@@ -3,6 +3,9 @@ set -euo pipefail
echo "[setup] Ensuring ./data/uploads exists..."
mkdir -p ./data/uploads
mkdir -p ./data/uploads/challenges
mkdir -p ./data/uploads/challenges/.staging
mkdir -p ./data/uploads/icons
echo "[setup] Installing root dependencies..."
npm ci --no-audit --no-fund || npm install --no-audit --no-fund
@@ -98,12 +98,12 @@ describe('AdminCategoriesService - CRUD + business rules', () => {
name: 'challenge',
descriptionMd: '',
categoryId: c.id,
difficulty: 'low',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 5,
flag: 'flag{...}',
protocol: 'nc',
protocol: 'NC',
ipAddress: '',
}));
await expect(categories.remove(c.id)).rejects.toMatchObject({ status: 409 });
+102
View File
@@ -0,0 +1,102 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-api-test';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import * as express from 'express';
import request from 'supertest';
import { CookieAccessInfo } from 'cookiejar';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
import { ConfigService } from '@nestjs/config';
import { initDb } from './db-helper';
describe('Admin Challenges API - guards + structured validation', () => {
let app: INestApplication;
let adminToken: string;
let csrf: string;
const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-challenges-api-'));
beforeAll(async () => {
process.env.UPLOAD_DIR = uploadDir;
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
app.useGlobalPipes(new (require('@nestjs/common').ValidationPipe)({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
await initDb(app);
const server = app.getHttpServer();
await request(server).post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const csrfPrime = await request(server).get('/api/v1/auth/csrf');
const csrfCookie = (csrfPrime.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
csrf = csrfCookie ? decodeURIComponent(csrfCookie.split(';')[0].split('=')[1]) : '';
const login = await request(server).post('/api/v1/auth/login')
.set('Cookie', `csrf=${csrf}`)
.set('X-CSRF-Token', csrf)
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
adminToken = login.body.accessToken;
});
afterAll(async () => {
await app.close();
if (fs.existsSync(uploadDir)) {
try {
fs.rmSync(uploadDir, { recursive: true, force: true });
} catch {
// ignore
}
}
});
it('rejects unauthenticated GET /api/v1/admin/challenges with 401', async () => {
await request(app.getHttpServer()).get('/api/v1/admin/challenges').expect(401);
});
it('rejects malformed import body with 400 and structured VALIDATION_FAILED details', async () => {
const server = app.getHttpServer();
const agent = request.agent(server);
await agent.get('/api/v1/auth/csrf');
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
const csrfValue = cookies.find((c: any) => c.name === 'csrf').value;
const res = await agent
.post('/api/v1/admin/challenges/import')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrfValue)
.send({ format: 'wrong', version: 'abc', challenges: 'not-an-array' });
expect(res.status).toBe(400);
expect(res.body?.code).toBe('VALIDATION_FAILED');
expect(Array.isArray(res.body?.details)).toBe(true);
});
it('returns 200 with the versioned envelope on export', async () => {
const res = await request(app.getHttpServer())
.get('/api/v1/admin/challenges/export')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/application\/json/);
expect(res.headers['content-disposition']).toMatch(/attachment.*challenges-export-/);
expect(res.body?.format).toBe('hipctf-challenges');
expect(res.body?.version).toBe(1);
expect(Array.isArray(res.body?.challenges)).toBe(true);
});
});
@@ -0,0 +1,290 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-import-test';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
process.env.NODE_ENV = 'test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Test } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { validateEnv } from '../../backend/src/config/env.schema';
import { AdminChallengesService } from '../../backend/src/modules/admin/challenges.service';
import { ChallengeFilesService } from '../../backend/src/modules/admin/challenge-files.service';
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
import { ChallengeFileEntity } from '../../backend/src/database/entities/challenge-file.entity';
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
import { Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { InitSchema1700000000000 } from '../../backend/src/database/migrations/1700000000000-InitSchema';
import { SeedSystemData1700000000100 } from '../../backend/src/database/migrations/1700000000100-SeedSystemData';
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
import { UpdateSystemCategoryKeys1700000000300 } from '../../backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys';
import { RepairCategorySchemaAndSystemCategories1700000000400 } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from '../../backend/src/database/migrations/1700000000500-UpgradeChallengeAdminSchema';
import { CHALLENGE_FORMAT, CHALLENGE_FORMAT_VERSION } from '../../backend/src/modules/admin/dto/challenges.dto';
describe('AdminChallengesService - import/export', () => {
let service: AdminChallengesService;
let fileService: ChallengeFilesService;
let catRepo: Repository<CategoryEntity>;
let chRepo: Repository<ChallengeEntity>;
let cryCategoryId: string;
let webCategoryId: string;
let uploadDir: string;
beforeAll(async () => {
uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-challenges-import-'));
process.env.UPLOAD_DIR = uploadDir;
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
TypeOrmModule.forRoot({
type: 'better-sqlite3',
database: ':memory:',
entities: [UserEntity, SettingEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity],
migrations: [
InitSchema1700000000000,
SeedSystemData1700000000100,
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
],
migrationsRun: true,
synchronize: false,
}),
TypeOrmModule.forFeature([CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity]),
],
providers: [AdminChallengesService, ChallengeFilesService],
}).compile();
moduleRef.init();
service = moduleRef.get(AdminChallengesService);
fileService = moduleRef.get(ChallengeFilesService);
catRepo = moduleRef.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
chRepo = moduleRef.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
const cry = await catRepo.findOne({ where: { systemKey: 'CRY' } });
const web = await catRepo.findOne({ where: { systemKey: 'WEB' } });
if (!cry || !web) throw new Error('expected seeded categories');
cryCategoryId = cry.id;
webCategoryId = web.id;
});
afterAll(async () => {
if (uploadDir && fs.existsSync(uploadDir)) {
try {
fs.rmSync(uploadDir, { recursive: true, force: true });
} catch {
// ignore
}
}
});
it('exports the current challenges with the versioned envelope', async () => {
// seed one challenge
const staged = fileService.stage(Buffer.from('hello'), 'doc.txt', 'text/plain');
await service.create({
name: 'Exportable',
descriptionMd: 'd',
categoryId: cryCategoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{X}',
protocol: 'NC',
port: 1337,
ipAddress: '',
enabled: true,
files: [
{
stagedToken: staged.token,
originalFilename: staged.originalFilename,
mimeType: staged.mimeType,
sizeBytes: staged.sizeBytes,
},
],
} as any);
const doc = await service.exportAll();
expect(doc.format).toBe(CHALLENGE_FORMAT);
expect(doc.version).toBe(CHALLENGE_FORMAT_VERSION);
expect(doc.exportedAt).toMatch(/T.+Z$/);
const ours = doc.challenges.find((c: any) => c.name === 'Exportable');
expect(ours).toBeDefined();
expect(ours.categorySystemKey).toBe('CRY');
expect(ours.flag).toBe('flag{X}');
expect(ours.port).toBe(1337);
expect(ours.files).toHaveLength(1);
expect(Buffer.from(ours.files[0].dataBase64, 'base64').toString()).toBe('hello');
});
it('returns an empty challenges array when there are no challenges', async () => {
// remove everything for the assertion
const all = await service.list({});
for (const row of all) await service.remove(row.id);
const doc = await service.exportAll();
expect(doc.challenges).toEqual([]);
});
it('previewImport flags conflicts and unknown categories without mutating', async () => {
// add one challenge to make a conflict
await service.create({
name: 'Conflict X',
descriptionMd: 'd',
categoryId: cryCategoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
} as any);
const preview = await service.previewImport({
format: CHALLENGE_FORMAT,
version: CHALLENGE_FORMAT_VERSION,
exportedAt: '2026-07-22T00:00:00Z',
challenges: [
{
name: 'CONFLICT X',
descriptionMd: 'd',
categorySystemKey: 'CRY',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [],
},
{
name: 'New One',
descriptionMd: 'd',
categorySystemKey: 'UNKNOWN_CAT',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [],
},
],
} as any);
expect(preview.total).toBe(2);
expect(preview.conflicts).toContain('CONFLICT X');
expect(preview.unknownCategories).toContain('UNKNOWN_CAT');
});
it('confirmed import overwrites by case-insensitive name and creates new entries', async () => {
const payload = {
format: CHALLENGE_FORMAT,
version: CHALLENGE_FORMAT_VERSION,
exportedAt: '2026-07-22T00:00:00Z',
challenges: [
{
name: 'Conflict X',
descriptionMd: 'updated body',
categorySystemKey: 'CRY',
difficulty: 'MEDIUM',
initialPoints: 200,
minimumPoints: 100,
decaySolves: 5,
flag: 'flag{new}',
protocol: 'NC',
port: 9000,
ipAddress: '127.0.0.1',
enabled: true,
files: [
{
originalFilename: 'replace.txt',
mimeType: 'text/plain',
sizeBytes: Buffer.byteLength('replaced'),
dataBase64: Buffer.from('replaced').toString('base64'),
},
],
},
{
name: 'Brand New',
descriptionMd: 'fresh',
categorySystemKey: 'WEB',
difficulty: 'HIGH',
initialPoints: 250,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{new2}',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [],
},
],
};
const result = await service.importConfirmed(payload as any);
expect(result.imported).toBe(2);
expect(result.skipped).toEqual([]);
const all = await service.list({});
const updated = all.find((r) => r.name.toLowerCase() === 'conflict x');
const fresh = all.find((r) => r.name.toLowerCase() === 'brand new');
expect(updated).toBeDefined();
expect(fresh).toBeDefined();
// (name is lowercased on the second import; the row stored is the new one)
expect(fresh!.categoryAbbreviation).toBe('WEB');
});
it('importConfirmed rolls back DB changes when staging a file fails (sizeBytes mismatch)', async () => {
const before = await chRepo.count();
const payload = {
format: CHALLENGE_FORMAT,
version: CHALLENGE_FORMAT_VERSION,
exportedAt: '2026-07-22T00:00:00Z',
challenges: [
{
name: 'Bad Size',
descriptionMd: 'd',
categorySystemKey: 'CRY',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [
{
originalFilename: 'bad.txt',
mimeType: 'text/plain',
sizeBytes: 999,
dataBase64: Buffer.from('short').toString('base64'),
},
],
},
],
};
await expect(service.importConfirmed(payload as any)).rejects.toMatchObject({
code: 'VALIDATION_FAILED',
});
const after = await chRepo.count();
expect(after).toBe(before);
});
});
@@ -0,0 +1,218 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_DIR = '/tmp/hipctf-challenges-uploads';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
process.env.NODE_ENV = 'test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Test } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { validateEnv } from '../../backend/src/config/env.schema';
import { AdminChallengesService } from '../../backend/src/modules/admin/challenges.service';
import { ChallengeFilesService } from '../../backend/src/modules/admin/challenge-files.service';
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
import { ChallengeFileEntity } from '../../backend/src/database/entities/challenge-file.entity';
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
import { v4 as uuid } from 'uuid';
import { Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { InitSchema1700000000000 } from '../../backend/src/database/migrations/1700000000000-InitSchema';
import { SeedSystemData1700000000100 } from '../../backend/src/database/migrations/1700000000100-SeedSystemData';
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
import { UpdateSystemCategoryKeys1700000000300 } from '../../backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys';
import { RepairCategorySchemaAndSystemCategories1700000000400 } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from '../../backend/src/database/migrations/1700000000500-UpgradeChallengeAdminSchema';
describe('AdminChallengesService - CRUD + business rules', () => {
let service: AdminChallengesService;
let catRepo: Repository<CategoryEntity>;
let chRepo: Repository<ChallengeEntity>;
let fileRepo: Repository<ChallengeFileEntity>;
let fileService: ChallengeFilesService;
let categoryId: string;
let uploadDir: string;
beforeAll(async () => {
uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-challenges-'));
process.env.UPLOAD_DIR = uploadDir;
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
TypeOrmModule.forRoot({
type: 'better-sqlite3',
database: ':memory:',
entities: [UserEntity, SettingEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity],
migrations: [
InitSchema1700000000000,
SeedSystemData1700000000100,
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
],
migrationsRun: true,
synchronize: false,
}),
TypeOrmModule.forFeature([CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity]),
],
providers: [AdminChallengesService, ChallengeFilesService],
}).compile();
moduleRef.init();
service = moduleRef.get(AdminChallengesService);
catRepo = moduleRef.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
chRepo = moduleRef.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
fileRepo = moduleRef.get<Repository<ChallengeFileEntity>>(getRepositoryToken(ChallengeFileEntity));
fileService = moduleRef.get(ChallengeFilesService);
const cat = await catRepo.findOne({ where: { systemKey: 'CRY' } });
if (!cat) throw new Error('expected CRY category seed');
categoryId = cat.id;
});
afterAll(async () => {
if (uploadDir && fs.existsSync(uploadDir)) {
try {
fs.rmSync(uploadDir, { recursive: true, force: true });
} catch {
// ignore
}
}
});
it('creates a WEB challenge with a single staged file and exposes it in the list', async () => {
const staged = fileService.stage(Buffer.from('hello'), 'greet.txt', 'text/plain');
const created = await service.create({
name: 'Welcome',
descriptionMd: 'intro',
categoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{...}',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [
{
stagedToken: staged.token,
originalFilename: staged.originalFilename,
mimeType: staged.mimeType,
sizeBytes: staged.sizeBytes,
},
],
} as any);
expect(created.id).toBeDefined();
expect(created.files).toHaveLength(1);
expect(created.files[0].originalFilename).toBe('greet.txt');
expect(fs.existsSync(path.join(uploadDir, 'challenges', staged.storedFilename))).toBe(true);
const list = await service.list({});
const row = list.find((r) => r.id === created.id);
expect(row).toBeDefined();
// Secret flag must NOT be in list output.
expect((row as any).flag).toBeUndefined();
expect(row!.fileCount).toBe(1);
});
it('rejects creating an NC challenge without a port', async () => {
await expect(
service.create({
name: 'Bad NC',
descriptionMd: 'd',
categoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f',
protocol: 'NC',
port: null,
ipAddress: '',
enabled: true,
} as any),
).rejects.toMatchObject({ status: 400 });
});
it('rejects duplicate challenge names case-insensitively', async () => {
await service.create({
name: 'Alpha',
descriptionMd: 'd',
categoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f1',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
} as any);
await expect(
service.create({
name: 'ALPHA',
descriptionMd: 'd',
categoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f2',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
} as any),
).rejects.toMatchObject({ code: 'CHALLENGE_NAME_TAKEN' });
});
it('deletes a challenge, removes its ChallengeFile rows and best-effort unlinks the disk file', async () => {
const staged = fileService.stage(Buffer.from('bye'), 'b.txt', 'text/plain');
const created = await service.create({
name: 'Ephemeral',
descriptionMd: 'd',
categoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [
{
stagedToken: staged.token,
originalFilename: staged.originalFilename,
mimeType: staged.mimeType,
sizeBytes: staged.sizeBytes,
},
],
} as any);
const storedRow = await fileRepo.findOne({ where: { challengeId: created.id } });
const storedName = storedRow?.storedFilename;
expect(storedName).toBeDefined();
await service.remove(created.id);
const files = await fileRepo.find({ where: { challengeId: created.id } });
expect(files).toHaveLength(0);
const target = path.join(uploadDir, 'challenges', storedName!);
// Either the file is gone or never landed on disk if commit failed;
// service.remove tolerates unlink failures, so we accept both states.
expect([true, false]).toContain(fs.existsSync(target));
});
it('search filter matches case-insensitive substring on name', async () => {
const list = await service.list({ q: 'alpha' });
expect(list.length).toBeGreaterThanOrEqual(1);
expect(list.every((r) => r.name.toLowerCase().includes('alpha'))).toBe(true);
});
});
+55 -3
View File
@@ -5,6 +5,7 @@ import { SeedSystemData1700000000100 } from '../../backend/src/database/migratio
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
import { UpdateSystemCategoryKeys1700000000300 } from '../../backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys';
import { RepairCategorySchemaAndSystemCategories1700000000400 } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from '../../backend/src/database/migrations/1700000000500-UpgradeChallengeAdminSchema';
import { DataSource } from 'typeorm';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
@@ -29,6 +30,7 @@ describe('Migrations', () => {
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
],
migrationsRun: true,
synchronize: false,
@@ -77,10 +79,11 @@ describe('Migrations', () => {
expect(uq).toBeDefined();
});
it('enforces ON DELETE RESTRICT FK on solve', async () => {
it('keeps ON DELETE RESTRICT on solve.user_id (Job 861)', async () => {
const fks = await dataSource.query("PRAGMA foreign_key_list('solve')");
const restrict = fks.filter((f: any) => f.on_delete === 'RESTRICT');
expect(restrict.length).toBeGreaterThanOrEqual(2);
const userFk = fks.find((f: any) => f.table === 'user');
expect(userFk).toBeDefined();
expect(userFk.on_delete).toBe('RESTRICT');
});
it('creates the category table with created_at and updated_at columns (Job 889)', async () => {
@@ -100,4 +103,53 @@ describe('Migrations', () => {
expect(Array.isArray(rows)).toBe(true);
expect(rows.length).toBeGreaterThanOrEqual(6);
});
it('upgrades challenge to canonical enums and adds enabled/updated_at columns (Job 861)', async () => {
const cols: any[] = await dataSource.query(`PRAGMA table_info("challenge")`);
const names = new Set(cols.map((c) => c.name));
expect(names.has('enabled')).toBe(true);
expect(names.has('updated_at')).toBe(true);
expect(names.has('description_md')).toBe(true);
});
it('enforces unique lower-cased challenge names (Job 861)', async () => {
const indexes = await dataSource.query(`SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='challenge'`);
const uq = indexes.find((i: any) => /UNIQUE.*LOWER\(\s*(?:"name")\s*\)/i.test(i.sql || ''));
expect(uq).toBeDefined();
});
it('cascade-deletes challenge_file rows when challenge is deleted (Job 861)', async () => {
const fks = await dataSource.query(`PRAGMA foreign_key_list('challenge_file')`);
const cascade = fks.find((f: any) => f.on_delete === 'CASCADE');
expect(cascade).toBeDefined();
});
it('cascade-deletes solve rows when challenge is deleted (Job 861)', async () => {
const fks = await dataSource.query(`PRAGMA foreign_key_list('solve')`);
const cascade = fks.find((f: any) => f.on_delete === 'CASCADE');
expect(cascade).toBeDefined();
});
it('still enforces UNIQUE(challenge_id,user_id) on solve (Job 861)', async () => {
const indexes = await dataSource.query("SELECT name, sql FROM sqlite_master WHERE type='index' AND tbl_name='solve'");
const uq = indexes.find((i: any) => /UNIQUE.*challenge_id.*user_id/i.test(i.sql || ''));
expect(uq).toBeDefined();
});
it('adds is_first/is_second/is_third columns to solve (Job 861)', async () => {
const cols: any[] = await dataSource.query(`PRAGMA table_info("solve")`);
const names = new Set(cols.map((c) => c.name));
expect(names.has('is_first')).toBe(true);
expect(names.has('is_second')).toBe(true);
expect(names.has('is_third')).toBe(true);
});
it('adds stored_filename/mime_type/size_bytes/created_at to challenge_file (Job 861)', async () => {
const cols: any[] = await dataSource.query(`PRAGMA table_info("challenge_file")`);
const names = new Set(cols.map((c) => c.name));
expect(names.has('stored_filename')).toBe(true);
expect(names.has('mime_type')).toBe(true);
expect(names.has('size_bytes')).toBe(true);
expect(names.has('created_at')).toBe(true);
});
});
+2 -3
View File
@@ -213,14 +213,13 @@ describe('Uploads endpoint integration', () => {
expect(icons.some((f) => f === 'big.bin')).toBe(false);
});
it('stores files under per-route subdirectories', async () => {
it('does not expose a direct /uploads/challenge-file route anymore (challenge files are staged)', async () => {
const { agent, csrf } = await primeCsrf();
const res = await agent
.post('/api/v1/uploads/challenge-file')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.attach('file', Buffer.from('attachment'), 'readme.txt');
expect(res.status).toBe(201);
expect(res.body.publicUrl).toMatch(/^\/uploads\/challenges\//);
expect([404, 405]).toContain(res.status);
});
});
@@ -0,0 +1,200 @@
import '@angular/compiler';
import { FormBuilder } from '@angular/forms';
import {
buildChallengeFormGroup,
defaultMinimumPoints,
syncChallengeForm,
} from '../../frontend/src/app/features/admin/challenges/challenge-form-modal.component';
import {
ChallengeFormDefaults,
ChallengeFormFileItem,
ChallengeStagedFileUi,
buildInitialFiles,
defaultMinimumPoints as pureDefault,
deriveMinimumPoints,
isValidIpOrHostname,
mapServerFieldErrors,
prefillChallengeFormDefaults,
toCreateBody,
validateChallengeForm,
} from '../../frontend/src/app/features/admin/challenges/challenge-form.pure';
import { AdminChallengeDetail } from '../../frontend/src/app/core/services/admin.service';
function makeDetail(overrides: Partial<AdminChallengeDetail> = {}): AdminChallengeDetail {
return {
id: 'd1',
name: 'Sample',
descriptionMd: '# hi',
categoryId: 'cat-1',
categoryAbbreviation: 'CRY',
categoryIconPath: '/uploads/icons/CRY.png',
difficulty: 'MEDIUM',
initialPoints: 200,
minimumPoints: 100,
decaySolves: 5,
flag: 'flag{X}',
protocol: 'NC',
port: 1337,
ipAddress: '127.0.0.1',
enabled: true,
createdAt: '2026-07-22T00:00:00.000Z',
updatedAt: '2026-07-22T00:00:00.000Z',
files: [
{
id: 'f1',
originalFilename: 'manual.txt',
mimeType: 'text/plain',
sizeBytes: 42,
createdAt: '2026-07-22T00:00:00.000Z',
},
],
...overrides,
};
}
function makeDefaults(overrides: Partial<ChallengeFormDefaults> = {}): ChallengeFormDefaults {
return {
name: 'X',
descriptionMd: 'd',
categoryId: 'cat-1',
difficulty: 'MEDIUM',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'f',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
...overrides,
};
}
describe('pure: challenge form helpers', () => {
it('defaultMinimumPoints returns half of the initial points, floored, with a minimum of 1', () => {
expect(defaultMinimumPoints(100)).toBe(50);
expect(defaultMinimumPoints(1)).toBe(1);
expect(defaultMinimumPoints(3)).toBe(1);
expect(pureDefault(99)).toBe(49);
});
it('deriveMinimumPoints follows the auto-derived rule until the minimum is touched, then preserves the value', () => {
expect(deriveMinimumPoints(200, 100, 100, false)).toBe(100); // touched: kept
expect(deriveMinimumPoints(200, 50, 100, false)).toBe(100); // not touched, previous matched default
expect(deriveMinimumPoints(50, 50, 100, false)).toBe(25); // auto-derives because new initial
});
it('isValidIpOrHostname accepts IPv4, IPv6, hostnames and rejects garbage', () => {
expect(isValidIpOrHostname('127.0.0.1')).toBe(true);
expect(isValidIpOrHostname('::1')).toBe(true);
expect(isValidIpOrHostname('2001:db8::1')).toBe(true);
expect(isValidIpOrHostname('example.com')).toBe(true);
expect(isValidIpOrHostname('')).toBe(true);
expect(isValidIpOrHostname('not a host')).toBe(false);
expect(isValidIpOrHostname('-bad.com')).toBe(false);
});
it('validateChallengeForm flags missing name, min > initial, NC without port, invalid IP', () => {
const errs = validateChallengeForm(
makeDefaults({
name: '',
descriptionMd: '',
categoryId: '',
flag: '',
protocol: 'NC',
port: null,
initialPoints: 100,
minimumPoints: 200,
decaySolves: 0,
ipAddress: 'bad value',
}),
);
expect(errs.name).toBeDefined();
expect(errs.descriptionMd).toBeDefined();
expect(errs.categoryId).toBeDefined();
expect(errs.minimumPoints).toBeDefined();
expect(errs.flag).toBeDefined();
expect(errs.port).toBeDefined();
expect(errs.decaySolves).toBeDefined();
expect(errs.ipAddress).toBeDefined();
});
it('validateChallengeForm accepts a well-formed WEB challenge with optional port', () => {
const errs = validateChallengeForm(makeDefaults());
expect(errs).toEqual({});
});
it('mapServerFieldErrors flattens a Zod details array into per-field messages', () => {
const errs = mapServerFieldErrors([
{ path: 'name', message: 'taken' },
{ path: 'port', message: 'required' },
]);
expect(errs.name).toBe('taken');
expect(errs.port).toBe('required');
});
it('prefillChallengeFormDefaults hydrates every field from the detail', () => {
const d = makeDetail({ minimumPoints: 75, protocol: 'WEB', port: 443 });
const out = prefillChallengeFormDefaults(d);
expect(out.name).toBe('Sample');
expect(out.minimumPoints).toBe(75);
expect(out.protocol).toBe('WEB');
expect(out.port).toBe(443);
});
it('toCreateBody omits WEB port when null and keeps NC port', () => {
const web = toCreateBody(makeDefaults({ protocol: 'WEB', port: null }), []);
expect(web.port).toBeNull();
const nc = toCreateBody(makeDefaults({ protocol: 'NC', port: 1337 }), []);
expect(nc.port).toBe(1337);
});
it('toCreateBody maps staged files into the manifest, including removal markers', () => {
const files: ChallengeFormFileItem[] = [
{ id: 'f1', originalFilename: '', mimeType: '', sizeBytes: 0, remove: true },
{
stagedToken: 'tok',
originalFilename: 'a.txt',
mimeType: 'text/plain',
sizeBytes: 5,
},
];
const out = toCreateBody(makeDefaults(), files);
expect(out.files?.length).toBe(2);
expect(out.files?.[0].remove).toBe(true);
expect(out.files?.[1].stagedToken).toBe('tok');
});
it('buildInitialFiles hydrates a UI list from detail.files', () => {
const list: ChallengeStagedFileUi[] = buildInitialFiles(makeDetail());
expect(list.length).toBe(1);
expect(list[0].kind).toBe('existing');
expect(list[0].id).toBe('f1');
});
});
describe('syncChallengeForm', () => {
it('populates every control from the detail and marks them pristine', () => {
const form = buildChallengeFormGroup(new FormBuilder());
const out = syncChallengeForm(form, 'edit', makeDetail());
expect(form.controls.name.value).toBe('Sample');
expect(form.controls.initialPoints.value).toBe(200);
expect(form.controls.minimumPoints.value).toBe(100);
expect(form.controls.flag.value).toBe('flag{X}');
expect(form.controls.protocol.value).toBe('NC');
expect(form.controls.port.value).toBe(1337);
expect(form.controls.ipAddress.value).toBe('127.0.0.1');
expect(form.controls.name.pristine).toBe(true);
expect(out.minimumPoints).toBe(100);
});
it('resets every control when switching to create mode with null detail', () => {
const form = buildChallengeFormGroup(new FormBuilder());
syncChallengeForm(form, 'edit', makeDetail());
const out = syncChallengeForm(form, 'create', null);
expect(form.controls.name.value).toBe('');
expect(form.controls.initialPoints.value).toBe(100);
expect(form.controls.minimumPoints.value).toBe(50);
expect(out.protocol).toBe('WEB');
});
});
@@ -0,0 +1,101 @@
import {
AdminChallengeListItem,
ImportPreviewResponse,
ImportResultResponse,
} from '../../frontend/src/app/core/services/admin.service';
import {
buildExportFilename,
categoryIconSrc,
deriveEmptyState,
difficultyClass,
formatFileSize,
summarizeImportPreview,
summarizeImportResult,
} from '../../frontend/src/app/features/admin/challenges/challenges.pure';
function makeRow(overrides: Partial<AdminChallengeListItem> = {}): AdminChallengeListItem {
return {
id: 'c1',
name: 'Alpha',
categoryAbbreviation: 'CRY',
categoryIconPath: '/uploads/icons/CRY.png',
difficulty: 'LOW',
protocol: 'WEB',
enabled: true,
createdAt: '2026-07-22T00:00:00.000Z',
updatedAt: '2026-07-22T00:00:00.000Z',
fileCount: 0,
solveCount: 0,
...overrides,
};
}
describe('challenges.pure helpers', () => {
it('formatFileSize formats bytes / KB / MB', () => {
expect(formatFileSize(0)).toBe('0 B');
expect(formatFileSize(512)).toBe('512 B');
expect(formatFileSize(2048)).toMatch(/KB$/);
expect(formatFileSize(5 * 1024 * 1024)).toMatch(/MB$/);
});
it('buildExportFilename derives an ISO-date filename', () => {
const fixed = new Date('2026-07-22T12:00:00.000Z');
expect(buildExportFilename(fixed)).toBe('challenges-export-2026-07-22.json');
});
it('deriveEmptyState distinguishes empty-list, no-results-search, and loaded states', () => {
expect(deriveEmptyState([], '', false)).toEqual({ kind: 'empty-list' });
expect(deriveEmptyState([], 'crypto', false)).toEqual({
kind: 'no-results-search',
search: 'crypto',
});
expect(deriveEmptyState([makeRow()], '', false)).toBeNull();
expect(deriveEmptyState([], '', true)).toBeNull();
});
it('summarizeImportPreview builds a single-line preview summary', () => {
const preview: ImportPreviewResponse = {
total: 3,
conflicts: ['Alpha', 'Beta'],
unknownCategories: ['UNKNOWN'],
warnings: [],
};
const out = summarizeImportPreview(preview);
expect(out).toContain('Import 3 challenges');
expect(out).toContain('overwritten');
expect(out).toContain('missing categories');
});
it('summarizeImportPreview handles empty document', () => {
const preview: ImportPreviewResponse = {
total: 0,
conflicts: [],
unknownCategories: [],
warnings: [],
};
expect(summarizeImportPreview(preview)).toContain('no challenges');
});
it('summarizeImportResult reports imported/skipped/warning counts', () => {
const r: ImportResultResponse = {
imported: 2,
skipped: [{ name: 'X', reason: 'Unknown category: Y' }],
warnings: ['1 skipped.'],
};
const out = summarizeImportResult(r);
expect(out).toContain('Imported 2');
expect(out).toContain('Skipped 1');
expect(out).toContain('1 warning');
});
it('difficultyClass returns the appropriate badge class', () => {
expect(difficultyClass('LOW')).toContain('difficulty-low');
expect(difficultyClass('MEDIUM')).toContain('difficulty-medium');
expect(difficultyClass('HIGH')).toContain('difficulty-high');
});
it('categoryIconSrc returns the supplied icon path or an empty string', () => {
expect(categoryIconSrc('/uploads/icons/X.png', 'X')).toBe('/uploads/icons/X.png');
expect(categoryIconSrc('', 'X')).toBe('');
});
});