diff --git a/docs/api/admin.md b/docs/api/admin.md index 851f67c..71db21d 100644 --- a/docs/api/admin.md +++ b/docs/api/admin.md @@ -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-.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. diff --git a/docs/api/uploads.md b/docs/api/uploads.md index f8a604b..45a22de 100644 --- a/docs/api/uploads.md +++ b/docs/api/uploads.md @@ -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 diff --git a/docs/architecture/backend-modules.md b/docs/architecture/backend-modules.md index a09ca23..981a010 100644 --- a/docs/architecture/backend-modules.md +++ b/docs/architecture/backend-modules.md @@ -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` | diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 6bb2cce..785fe0e 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -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 diff --git a/docs/database/challenges.md b/docs/database/challenges.md index d273204..4bd5342 100644 --- a/docs/database/challenges.md +++ b/docs/database/challenges.md @@ -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`), 2–6 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 (1–100000). | +| `minimum_points` | INTEGER | Floor after decay (1–100000, must be ≤ `initial_points`). | +| `decay_solves` | INTEGER | Solves at which decay reaches the minimum (1–10000). | +| `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 1–65535 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) \ No newline at end of file diff --git a/docs/guides/admin-challenges.md b/docs/guides/admin-challenges.md new file mode 100644 index 0000000..d85747d --- /dev/null +++ b/docs/guides/admin-challenges.md @@ -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-.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 ''". | +| Table | `[data-testid="challenges-table"]` | One row per challenge. | +| Row | `[data-testid="challenge-row-"]` | 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-"`). + +| Label | `data-testid` | Required | Notes | +|-------------------------------|------------------|----------|-------| +| Name (1–128 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 1–100000 (default 100). | +| Minimum points | `cf-minimum` | yes | Integer 1–100000; default = `floor(initialPoints / 2)`. Auto-updates while untouched; once edited, the value sticks. | +| Decay solves | `cf-decay` | yes | Integer 1–10000. | +| 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 1–65535; 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 ``; 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-`) 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-.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. \ No newline at end of file diff --git a/docs/guides/admin-shell.md b/docs/guides/admin-shell.md index b8bed19..f032bcb 100644 --- a/docs/guides/admin-shell.md +++ b/docs/guides/admin-shell.md @@ -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. | diff --git a/docs/index.md b/docs/index.md index 130a4ae..fd64ad6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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.