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

12 KiB
Raw Blame History

type, title, description, tags, timestamp
type title description tags timestamp
guide Admin — Challenges How an admin lists, searches, creates, edits, deletes, imports, and exports challenges from the /admin/challenges page.
guide
admin
challenges
import
export
files
tester
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 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 areaChallenges 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 ''".
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

  • 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 challengesGET /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.