--- 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-22T20:30:00Z --- # Tables ## `category` | Column | Type | Description | |----------------|---------|------------------------------------------------------------------------------| | `id` | TEXT PK | UUID v4. | | `system_key` | TEXT | Non-null for system categories (e.g. `CRY`, `MSC`, `PWN`, `REV`, `WEB`, `HW`); unique where NOT NULL; `NULL` for user-created categories. | | `name` | TEXT | Display name (e.g. `Cryptography`). | | `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. | | `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). The forward-only `RepairCategorySchemaAndSystemCategories1700000000400` migration repairs legacy schemas on existing databases. ## `challenge` | Column | Type | Description | |-------------------|----------|-----------------------------------------------------------------------------------| | `id` | TEXT PK | UUID v4. | | `name` | TEXT | Display name. **Case-insensitive uniqueness** enforced by `uq_challenge_name_lower`. | | `description_md` | TEXT | Markdown description. | | `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. The `SeedSampleChallenges1700000000600` migration inserts a small representative set of sample challenges (one or more per canonical system category) on a fresh database so the `/challenges` board has cards immediately after bootstrap. It is idempotent: if the `challenge` table already contains rows (e.g. an admin imported a custom challenge set), it does nothing. Sample flags follow the `flag{}` convention and are exposed only inside admin views. ## `challenge_file` | Column | Type | Description | |---------------------|---------|--------------------------------------| | `id` | TEXT PK | UUID v4. | | `challenge_id` | TEXT | FK → `challenge.id` (`ON DELETE CASCADE`). | | `original_filename` | TEXT | Filename from upload. | | `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` (`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. | `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 `points_awarded = clamp(initial_points - decay, minimum_points, initial_points)` where `decay = max(0, base_points - minimum_points) * solvesBefore / decay_solves`, and additional `rank_bonus` may be added for early solves. # 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)