Files
HIPCTF2/docs/guides/admin-challenges.md
T
2026-07-22 22:18:07 +00:00

141 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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-22T22:17:11Z
---
# 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 | Digits-only text input with numeric keyboard hint; integer 165535. Switching to WEB clears it. |
| IP address | `cf-ip` | no | Free text — falls back to `setting.defaultChallengeIp` at solve time. |
| File picker / drop zone | `cf-upload`, `cf-drop-zone` | no | Accepts multiple files of any type. Users may choose files or drag them onto the dashed drop zone; oversize files are rejected before upload. |
| Upload error | `cf-upload-error` | n/a | Reports the rejected filename and global size limit, or a staging request failure. |
| 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. For NC challenges, Port starts blank and must contain digits representing an integer from 1 through 65535. Invalid or missing values display inline, and Save automatically opens the Connection tab so the error is visible. General-field errors similarly open the General tab.
4. Files can be selected through the picker or dropped onto the Files tab drop zone. Files within the global limit immediately appear as uploading placeholders and are staged through `POST /api/v1/admin/challenges/files/stage`; oversize files never trigger that request and show `cf-upload-error`.
5. Save calls `POST` (or `PUT`) with the manifest of staged file tokens and existing-file removal ids.
6. 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.
7. 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 **wipes every existing challenge (cascade-deleting their `challenge_file` and `solve` rows and best-effort unlinking their disk files) and inserts the archive's challenges** inside one transaction.
4. The modal auto-closes on success and the list refreshes automatically; the page-level status banner shows imported/skipped/warning counts.
See [Admin — Challenges Full-Replace Import](/guides/admin-challenges-import.md) for the full transactional semantics, side effects, and the auto-close contract.
# 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, first-invalid-tab selection, file-size partitioning, 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, full-replace 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 existing challenge (matching or not) is replaced by the archive contents.