From 785d56c02de16d0abbdacfd17df9488ef8b89890 Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Thu, 23 Jul 2026 12:10:36 +0000 Subject: [PATCH 1/2] docs: update documentation to OKF v0.1 format --- docs/api/admin-system.md | 291 ++++++++++++++++++ docs/architecture/backend-modules.md | 5 +- docs/architecture/key-files.md | 33 ++ .../admin-operation-tokens-migration.md | 38 +++ docs/database/admin-operation-tokens.md | 55 ++++ docs/database/schema.md | 3 + docs/guides/admin-system-page.md | 180 +++++++++++ docs/index.md | 15 +- 8 files changed, 618 insertions(+), 2 deletions(-) create mode 100644 docs/api/admin-system.md create mode 100644 docs/database/admin-operation-tokens-migration.md create mode 100644 docs/database/admin-operation-tokens.md create mode 100644 docs/guides/admin-system-page.md diff --git a/docs/api/admin-system.md b/docs/api/admin-system.md new file mode 100644 index 0000000..ef1a252 --- /dev/null +++ b/docs/api/admin-system.md @@ -0,0 +1,291 @@ +--- +type: api +title: Admin System Endpoints +description: Admin-only destructive System operations: backup download, restore validate/commit, re-authenticated confirmation tokens, reset-scores, and wipe-challenges. +resource: backend/src/modules/admin/system/admin-system.controller.ts +tags: [api, admin, system, backup, restore, danger-zone, confirmation-tokens] +timestamp: 2026-07-23T12:04:50Z +--- + +# Overview + +All handlers below are mounted under +`/api/v1/admin/system` and require a JWT-protected admin session +(`AdminGuard` + `@Roles('admin')`). Every destructive operation is +gated by a single-use confirmation token issued by the re-authentication +endpoint: + +``` +client ──reauth──▶ POST /confirmations ──token──▶ client +client ──commit──▶ POST /restore/commit | /scores/reset | /challenges/wipe +``` + +The token table is documented at +[/database/admin-operation-tokens.md](/database/admin-operation-tokens.md). + +# Endpoints + +| Method | Path | Source | +|--------|--------------------------------------------|-----------------------------------------------------------| +| `GET` | `/api/v1/admin/system/backup` | `admin-system.controller.ts` → `BackupService.build()` | +| `POST` | `/api/v1/admin/system/restore/validate` | `admin-system.controller.ts` → `RestoreService.stageArchive()` | +| `POST` | `/api/v1/admin/system/restore/commit` | `admin-system.controller.ts` → `RestoreService.commitRestore()` + `AuthService.revokeAllRefreshSessions()` | +| `POST` | `/api/v1/admin/system/confirmations` | `admin-system.controller.ts` → `AuthService.reauthenticateAdmin()` + `ConfirmationTokenService.issue()` | +| `POST` | `/api/v1/admin/system/scores/reset` | `admin-system.controller.ts` → `DangerZoneService.resetScores()` | +| `POST` | `/api/v1/admin/system/challenges/wipe` | `admin-system.controller.ts` → `DangerZoneService.wipeChallenges()` | + +## GET /backup + +Streams a complete platform snapshot as a JSON attachment. The body has +the shape: + +```jsonc +{ + "format": "hipctf-system-backup", + "version": 1, + "exportedAt": "2026-07-23T12:00:00.000Z", + "tables": { "user": [...], "challenge": [...], /* every application table */ }, + "tableCounts": { "user": 12, "challenge": 4 }, + "uploads": [ + { "path": "challenges/foo.bin", + "originalFilename": "foo.bin", + "sizeBytes": 1024, + "sha256": "...", "dataBase64": "..." } + ] +} +``` + +Internal tables (`admin_operation_token`, `migrations`, all +`sqlite_*`) are excluded. The upload root is walked recursively and the +`.staging` directory is skipped. Files are base64-encoded inline so the +artifact is a single self-contained JSON file. + +The response uses +`Content-Disposition: attachment; filename="hipctf-backup-.json"`. + +## POST /restore/validate + +Body: + +```jsonc +{ "text": "" } // or +{ "archive": { /* already-parsed object */ } } +``` + +The body is parsed with a strict zod schema (`RestoreArchiveSchema`). +Validation enforces: + +* `format === 'hipctf-system-backup'`, `version === 1` +* every upload entry has a safe, non-`..` path, decodes from + base64 to exactly `sizeBytes`, and (when provided) matches `sha256` +* total restored bytes ≤ `BODY_SIZE_LIMIT` + +Successful validation returns a `stagingId` plus a summary (sorted +table list, per-table counts, file count, total bytes, ISO expiresAt): + +```jsonc +{ + "stagingId": "f0c9...", + "expiresAt": "2026-07-23T12:15:00.000Z", + "summary": { + "tables": ["category", "challenge", "user", /* ... */], + "tableCounts": { "user": 12, "challenge": 4 }, + "files": 3, + "totalBytes": 8192 + } +} +``` + +The stage lives in-memory for `SYSTEM_OP_RESTORE_STAGE_TTL_MS` (default +15 min) and inside `/.system-staging/restore-/`. + +Errors: + +* `SYSTEM_RESTORE_VALIDATION_FAILED` (400) — JSON parse, schema, safe + path, duplicate path, base64, size mismatch, or checksum mismatch. +* `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE` (400) — total bytes over + `BODY_SIZE_LIMIT`. + +## POST /restore/commit + +Body: + +```jsonc +{ + "operation": "restore-backup", + "token": "", + "stagingId": "" +} +``` + +Flow: + +1. Verifies the admin role and that `operation === 'restore-backup'`. +2. `ConfirmationTokenService.consume()` verifies the token belongs to + this admin, is for `restore-backup`, is unexpired, and is unused. +3. `RestoreService.commitRestore(stagingId)` builds a new SQLite file + by cloning the live DB, then clearing every application table and + inserting the archived rows in FK-safe order. Uploads are copied + aside as `.restore-stage-` and the live + database + uploads are swapped atomically via + `FilesystemTransactionService.stageSwap()` (with a `restore-backup` + rollback handle). +4. On any failure the swap is rolled back, the staged tree is removed, + and the error is mapped to `SYSTEM_RESTORE_ROLLED_BACK` (500). +5. On success, every refresh token belonging to the current admin is + revoked via `AuthService.revokeAllRefreshSessions()` so the SPA is + forced back to `/login`. + +Returns `{ ok: true, operation: 'restore-backup' }`. + +## POST /confirmations + +Body: + +```jsonc +{ + "operation": "restore-backup" | "reset-scores" | "wipe-challenges", + "password": "", + "stagingId": "" +} +``` + +Flow: + +1. `AuthService.reauthenticateAdmin(userId, password)` verifies the + user is still enabled + admin and the Argon2id password matches. +2. `ConfirmationTokenService.issue()` stores a SHA-256 hash of the + generated token and returns the raw token plus its `expiresAt`. + +Returns: + +```jsonc +{ "operation": "restore-backup", "token": "", "expiresAt": "2026-07-23T12:05:00.000Z" } +``` + +Errors: + +* `SYSTEM_INVALID_CREDENTIALS` (401) — wrong password. +* `SYSTEM_REAUTH_REQUIRED` (401) — user is no longer admin/disabled. + +## POST /scores/reset + +Body: + +```jsonc +{ "operation": "reset-scores", "token": "" } +``` + +Consumes a `reset-scores` token, then `DangerZoneService.resetScores()` +runs a single `DELETE FROM solve` inside a transaction. Returns +`{ ok: true, solvesRemoved: N }`. Failure maps to `SYSTEM_DANGER_FAILED` +(500). + +## POST /challenges/wipe + +Body: + +```jsonc +{ "operation": "wipe-challenges", "token": "" } +``` + +Consumes a `wipe-challenges` token, then +`DangerZoneService.wipeChallenges()`: + +1. Snapshots `/challenges` to `.wipe-stage-`. +2. Runs a SQLite transaction that deletes every `challenge` row + (cascading via FK into `challenge_file` and `solve`). +3. After the DB commits, physically removes the live + `/challenges` directory. If the rm fails, the snapshot + is copied back into place. +4. Removes the snapshot only once the live tree is gone. + +Returns +`{ ok: true, challengesRemoved: N, challengeFilesRemoved: M, solvesRemoved: K }`. +Failure maps to `SYSTEM_DANGER_ROLLED_BACK` (500). + +# Error codes + +| Code | HTTP | Source | +|-----------------------------------|------|-------------------------------------------------| +| `SYSTEM_BACKUP_FAILED` | 500 | `BackupService` table/file read failure | +| `SYSTEM_FILE_READ_FAILED` | 500 | `BackupService` upload walk failure | +| `SYSTEM_RESTORE_VALIDATION_FAILED`| 400 | `RestoreService` archive schema/size/sha mismatch | +| `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE`| 400 | Restored bytes exceed `BODY_SIZE_LIMIT` | +| `SYSTEM_RESTORE_STAGE_EXPIRED` | 400 | `stagingId` missing or expired | +| `SYSTEM_RESTORE_FAILED` | 500 | Live DB not readable after swap | +| `SYSTEM_RESTORE_ROLLED_BACK` | 500 | Restore aborted; original state restored | +| `SYSTEM_DANGER_FAILED` | 500 | Reset-scores internal error | +| `SYSTEM_DANGER_ROLLED_BACK` | 500 | Reset/wipe failed; data unchanged | +| `SYSTEM_PAYLOAD_INVALID` | 400 | `restore/validate` got neither `text` nor `archive` | +| `SYSTEM_REAUTH_REQUIRED` | 401 | `reauthenticateAdmin` user no longer admin/enabled | +| `SYSTEM_INVALID_CREDENTIALS` | 401 | Wrong password | +| `SYSTEM_TOKEN_INVALID` | 400 | Unknown token hash | +| `SYSTEM_TOKEN_EXPIRED` | 400 | Token `expiresAt` < now | +| `SYSTEM_TOKEN_REUSED` | 400 | Token already consumed | +| `SYSTEM_TOKEN_MISMATCH` | 400 | Token issued for a different operation/user | + +# Examples + +## Backup → restore on a fresh install + +```bash +# 1. Download a backup +curl -sS -b cookies.txt -c cookies.txt \ + -X GET 'http://localhost:3000/api/v1/admin/system/backup' \ + -o hipctf-backup.json + +# 2. Validate (and stage) it +curl -sS -b cookies.txt -c cookies.txt \ + -H 'Content-Type: application/json' \ + -X POST 'http://localhost:3000/api/v1/admin/system/restore/validate' \ + -d "{\"text\": \"$(cat hipctf-backup.json | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))')\"}" +# => {"stagingId":"", "expiresAt":"...", "summary":{...}} + +# 3. Re-authenticate to get a confirmation token +curl -sS -b cookies.txt -c cookies.txt \ + -H 'Content-Type: application/json' \ + -X POST 'http://localhost:3000/api/v1/admin/system/confirmations' \ + -d '{"operation":"restore-backup","password":"","stagingId":""}' +# => {"operation":"restore-backup","token":"","expiresAt":"..."} + +# 4. Commit +curl -sS -b cookies.txt -c cookies.txt \ + -H 'Content-Type: application/json' \ + -X POST 'http://localhost:3000/api/v1/admin/system/restore/commit' \ + -d '{"operation":"restore-backup","token":"","stagingId":""}' +``` + +## Reset scores + +```bash +# 1. Re-auth +curl -sS ... -d '{"operation":"reset-scores","password":"..."}' # → token +# 2. Commit +curl -sS ... -d '{"operation":"reset-scores","token":""}' +# → {"ok":true,"solvesRemoved":42} +``` + +# Wiring + +| Concern | File | +|------------------------------|---------------------------------------------------------------------------------| +| Controller | `backend/src/modules/admin/system/admin-system.controller.ts` | +| Module | `backend/src/modules/admin/system/admin-system.module.ts` | +| Backup | `backend/src/modules/admin/system/backup.service.ts` | +| Restore | `backend/src/modules/admin/system/restore.service.ts` | +| Confirmation tokens | `backend/src/modules/admin/system/confirmation-token.service.ts` | +| Danger zone | `backend/src/modules/admin/system/danger-zone.service.ts` | +| Filesystem swap | `backend/src/modules/admin/system/filesystem-transaction.service.ts` | +| Staging dir helper | `backend/src/common/utils/upload.ts` (`resolveSystemStagingDir`) | +| Password re-auth / revoke | `backend/src/modules/auth/auth.service.ts` (`reauthenticateAdmin`, `revokeAllRefreshSessions`) | +| Error codes | `backend/src/common/errors/error-codes.ts` | +| Front-end page | `frontend/src/app/features/admin/system/system.component.ts` | +| Front-end modal | `frontend/src/app/features/admin/system/system-confirm-modal.component.ts` | +| Front-end service | `frontend/src/app/features/admin/system/system.service.ts` | +| Front-end pure helpers | `frontend/src/app/features/admin/system/system.pure.ts` | +| Front-end nav entry | `frontend/src/app/features/admin/admin-shell.component.ts` | +| Front-end route | `frontend/src/app/app.routes.ts` (admin child `system`) | +| Cross-store invalidation | `frontend/src/app/core/services/system-data-change.service.ts` | +| Forced logout helper | `frontend/src/app/core/services/auth.service.ts` (`forceServerInvalidation`) | +| Tests | `tests/backend/admin-system-authorization.spec.ts`, `admin-system-backup.spec.ts`, `admin-system-confirmation-token.spec.ts`, `admin-system-danger.spec.ts`, `admin-system-restore-validation.spec.ts`; `tests/frontend/admin-system.pure.spec.ts` | diff --git a/docs/architecture/backend-modules.md b/docs/architecture/backend-modules.md index db816cb..0066c3e 100644 --- a/docs/architecture/backend-modules.md +++ b/docs/architecture/backend-modules.md @@ -27,7 +27,8 @@ 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` | 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`). | +| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Five 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*`, `AdminSystemController` for `/api/v1/admin/system/*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`, `AdminChallengesService`, `ChallengeFilesService`, `BackupService`, `RestoreService`, `DangerZoneService`, `FilesystemTransactionService`, `ConfirmationTokenService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, `CommonModule` (for `SseHubService`, `ThemeLoaderService`), and `AdminSystemModule` (which adds `AdminOperationTokenEntity` + `AuthModule`). | +| `AdminSystemModule` | `backend/src/modules/admin/system/admin-system.module.ts` | Hosts the destructive System operations: `AdminSystemController` + `BackupService` + `RestoreService` + `DangerZoneService` + `FilesystemTransactionService` + `ConfirmationTokenService`. Imports `TypeOrmModule.forFeature([AdminOperationTokenEntity])` and `AuthModule` (for `reauthenticateAdmin`/`revokeAllRefreshSessions`). | | `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. | | `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController`/`BlogService` expose published posts at `GET /api/v1/blog/posts`; `AdminBlogController`/`AdminBlogService` provide guarded CRUD at `/api/v1/admin/blog/posts` using `BlogPostEntity`. | | `ChallengesModule` | `backend/src/modules/challenges/challenges.module.ts` | `ChallengesController` (`/api/v1/challenges/{board,status,:id,:id/solves}`) + `ChallengesEventsController` (authenticated SSE `/api/v1/events`) + `ChallengesService` (board, detail, submit, scoring util). | @@ -44,6 +45,7 @@ also registers two global providers: | `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` | +| `AdminSystemController` | `/api/v1/admin/system` | Admin only (plus confirmation tokens for destructive ops) | `backend/src/modules/admin/system/admin-system.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` | @@ -91,3 +93,4 @@ also registers two global providers: - [Key Files Index](/architecture/key-files.md) - [REST API Overview](/api/rest-overview.md) - [Blog API](/api/blog.md) +- [Admin System API](/api/admin-system.md) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 08f7bcc..1268feb 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -123,6 +123,36 @@ timestamp: 2026-07-23T10:12:24Z | `tests/backend/scoreboard.service.spec.ts` | Service-level tests covering `computeAwardedPoints` parity with `submitFlag`, color-index stability, matrix ordering, and graph state handling. | | `tests/frontend/scoreboard.pure.spec.ts` | Pure helpers: `applyRankingSort` (competition-rank numbering), `parseSolveEventIntoRanking` (add-or-increment + sort), `dedupEventLogBySolveId`, `applySolveToGraph` (anchors + plateau + top-10 trim), `mutateMatrixFromSolve`, `stablePlayerColorIndex`, `formatSolveDateTime`. | | `tests/frontend/scoreboard.store.spec.ts` | Store lifecycle + live merge: `loadAll` aggregation + idempotency, SSE `solve` frame applying to all four tabs, `stop()` teardown, exponential reconnect. | +| `backend/src/modules/admin/system/admin-system.controller.ts` | Mounts `/api/v1/admin/system/*` (backup download, restore validate/commit, confirmations, scores/reset, challenges/wipe); all handlers require `AdminGuard` + `@Roles('admin')` and the destructive ones also consume a confirmation token. | +| `backend/src/modules/admin/system/admin-system.module.ts` | Wires `AdminSystemController`, `BackupService`, `RestoreService`, `DangerZoneService`, `FilesystemTransactionService`, `ConfirmationTokenService`; imports `TypeOrmModule.forFeature([AdminOperationTokenEntity])` and `AuthModule`. | +| `backend/src/modules/admin/system/backup.service.ts` | Builds the full backup JSON: every application table (`sqlite_master` discovery, excluding `admin_operation_token`/`migrations`/`sqlite_*`), with base64-encoded uploads walked recursively from `UPLOAD_DIR` (skipping `.staging`). Exposes `discoverTables()`, `getUploadDir()`, and `BackupService.stringify(doc)`. | +| `backend/src/modules/admin/system/restore.service.ts` | Two-phase restore: `stageArchive(rawText, userId)` validates the archive (zod), decodes uploads into `/.system-staging/restore-/`, and returns a summary; `commitRestore(stagingId)` clones the live DB, clears every application table, re-inserts archived rows in FK-safe order, then atomically swaps the live DB + uploads via `FilesystemTransactionService.stageSwap({ name: 'restore-backup', ... })`. On any failure the swap is rolled back and `SYSTEM_RESTORE_ROLLED_BACK` is returned. | +| `backend/src/modules/admin/system/danger-zone.service.ts` | `resetScores()` deletes every `solve` row transactionally; `wipeChallenges()` snapshots `/challenges` to a side directory, deletes every challenge inside a transaction, physically removes the live `-challenges` directory post-commit, and either deletes the snapshot or restores from it on failure. | +| `backend/src/modules/admin/system/filesystem-transaction.service.ts` | Generic file/directory swap primitive: `stageSwap({ pairs, hooks })` renames each live path to a rollback location, then renames the staged path into place (with copy+unlink cross-device fallback); `commit(handle)` removes rollback artifacts; `rollback(handle)` restores the original live paths. Provides static helpers `rmSafe`, `copyDirSync`, `ensureDir`. | +| `backend/src/modules/admin/system/confirmation-token.service.ts` | Issues SHA-256-hashed single-use tokens with `SYSTEM_OP_CONFIRM_TOKEN_TTL_MS` TTL; `consume()` runs in a transaction with conditional `WHERE consumedAt IS NULL` updates so only the first concurrent caller succeeds. `purgeExpired()` deletes expired and >24h-old consumed rows. | +| `backend/src/modules/admin/system/dto/admin-system.dto.ts` | Re-exports `ADMIN_OPERATION_KINDS` and zod contracts for the re-auth body, danger confirm body, and restore-commit body; declares the backup format identifier + version constants. | +| `backend/src/database/entities/admin-operation-token.entity.ts` | TypeORM entity and the canonical `ADMIN_OPERATION_KINDS` literal union (`restore-backup`, `reset-scores`, `wipe-challenges`). | +| `backend/src/database/migrations/1700000000900-AddAdminOperationTokens.ts` | Forward-only migration that creates the `admin_operation_token` table and its three indexes (`uq_admin_op_token_hash`, `idx_admin_op_token_user_op`, `idx_admin_op_token_expiry`). | +| `backend/src/common/utils/upload.ts` | Adds `resolveSystemStagingDir(config)` (defaults to `/.system-staging`, never nested under `UPLOAD_DIR`) and `getRequestSizeLimit(config)` used by the system endpoints. | +| `backend/src/config/env.schema.ts` | Adds `SYSTEM_OP_STAGING_DIR`, `SYSTEM_OP_RESTORE_STAGE_TTL_MS` (default 15 min), `SYSTEM_OP_CONFIRM_TOKEN_TTL_MS` (default 5 min). | +| `backend/src/common/errors/error-codes.ts` | Adds the `SYSTEM_*` error codes used by the system endpoints (`SYSTEM_BACKUP_FAILED`, `SYSTEM_RESTORE_VALIDATION_FAILED`, `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE`, `SYSTEM_RESTORE_STAGE_EXPIRED`, `SYSTEM_RESTORE_FAILED`, `SYSTEM_RESTORE_ROLLED_BACK`, `SYSTEM_OPERATION_IN_PROGRESS`, `SYSTEM_REAUTH_REQUIRED`, `SYSTEM_INVALID_CREDENTIALS`, `SYSTEM_TOKEN_INVALID`, `SYSTEM_TOKEN_EXPIRED`, `SYSTEM_TOKEN_REUSED`, `SYSTEM_TOKEN_MISMATCH`, `SYSTEM_DANGER_FAILED`, `SYSTEM_DANGER_ROLLED_BACK`, `SYSTEM_FILE_READ_FAILED`, `SYSTEM_PAYLOAD_INVALID`). | +| `backend/src/modules/auth/auth.service.ts` | Adds `reauthenticateAdmin(userId, password)` (Argon2id verify + admin/enabled status check) and `revokeAllRefreshSessions(userId, manager?)` (used after a successful restore to force the SPA back to `/login`). | +| `tests/backend/admin-system-authorization.spec.ts` | Non-admin JWTs and missing tokens are rejected on every `/api/v1/admin/system/*` endpoint. | +| `tests/backend/admin-system-backup.spec.ts` | Backup envelope shape, table discovery exclusion, and base64 file inclusion. | +| `tests/backend/admin-system-confirmation-token.spec.ts` | Issue/consume/expire/reuse/mismatch flow and TTL config. | +| `tests/backend/admin-system-danger.spec.ts` | Reset-scores and wipe-challenges happy path + DB/file rollback. | +| `tests/backend/admin-system-restore-validation.spec.ts` | Archive schema validation, base64 size mismatch, sha256 mismatch, safe-path rejection, and duplicate-path rejection. | +| `frontend/src/app/features/admin/system/system.component.ts` | `/admin/system` smart page: two panels (Database + Danger Zone), backup download, restore pick + validate, confirm modal trigger, and per-operation success/error surfacing + cross-store invalidation. | +| `frontend/src/app/features/admin/system/system-confirm-modal.component.ts` | Re-authentication + confirmation-phrase modal with operation-specific copy from `system.pure.ts`. | +| `frontend/src/app/features/admin/system/system.service.ts` | HTTP client for the six `/api/v1/admin/system/*` endpoints. | +| `frontend/src/app/features/admin/system/system.pure.ts` | Pure helpers: error → friendly message map, `destructiveCopy(op)` per-operation modal copy, `pickRestoreFile()` (jsdom-testable file picker), `deriveBackupFilename()`. | +| `frontend/src/app/core/services/system-data-change.service.ts` | Root-provided signal bus (`'scores-reset'`, `'challenges-wiped'`, `'restore-completed'`) consumed by `ChallengesStore` and `ScoreboardStore` to invalidate their caches after a destructive operation. | +| `frontend/src/app/core/services/auth.service.ts` | Adds `forceServerInvalidation()` so the restore flow can drop the local session and broadcast the cross-tab invalidation without invoking the backend logout. | +| `frontend/src/app/features/challenges/challenges.store.ts` | Now subscribes to `SystemDataChangeService` and calls `reset()` on every destructive operation kind. | +| `frontend/src/app/features/scoreboard/scoreboard.store.ts` | Now subscribes to `SystemDataChangeService` and clears ranking/matrix/event-log/graph on every destructive operation kind. | +| `frontend/src/app/features/admin/admin-shell.component.ts` | Side-nav entry for `system` is now `enabled: true`. | +| `frontend/src/app/app.routes.ts` | Adds the lazy `system` child route under the admin shell. | +| `tests/frontend/admin-system.pure.spec.ts` | Pure-helper contracts for the friendly error map, destructive copy, and file picker. | # See also @@ -135,3 +165,6 @@ timestamp: 2026-07-23T10:12:24Z - [Notifications](/guides/notifications.md) - [Blog Publishing and Reading](/guides/blog.md) - [Blog API](/api/blog.md) +- [Admin System API](/api/admin-system.md) +- [Admin System Page Guide](/guides/admin-system-page.md) +- [Admin Operation Tokens Table](/database/admin-operation-tokens.md) diff --git a/docs/database/admin-operation-tokens-migration.md b/docs/database/admin-operation-tokens-migration.md new file mode 100644 index 0000000..f87d38b --- /dev/null +++ b/docs/database/admin-operation-tokens-migration.md @@ -0,0 +1,38 @@ +--- +type: database +title: Admin Operation Tokens Migration +description: Forward-only migration that adds the `admin_operation_token` table and its indexes for destructive System-operation confirmation tokens. +resource: backend/src/database/migrations/1700000000900-AddAdminOperationTokens.ts +tags: [database, migration, admin, system, tokens] +timestamp: 2026-07-23T12:04:50Z +--- + +# Purpose + +Adds the [admin_operation_token](/database/admin-operation-tokens.md) +table that backs the destructive operations under +`/api/v1/admin/system/*` (restore-backup, reset-scores, +wipe-challenges). Always fresh on a new database; on existing instances +the migration is a no-op if the table already exists. + +# Up + +1. `CREATE TABLE IF NOT EXISTS admin_operation_token` with the columns + listed in the [schema doc](/database/admin-operation-tokens.md#schema). +2. `CREATE UNIQUE INDEX IF NOT EXISTS uq_admin_op_token_hash` on + `token_hash` to make one-shot use enforceable. +3. `CREATE INDEX IF NOT EXISTS idx_admin_op_token_user_op` on + `(user_id, operation)` for fast per-admin lookup. +4. `CREATE INDEX IF NOT EXISTS idx_admin_op_token_expiry` on + `expires_at` so the periodic purge can use an index scan. + +# Down + +Drops the indexes in reverse order and drops the table. + +# Wiring + +The migration is registered in +`backend/src/database/database.module.ts` after the existing user +triggers migration. The entity is registered in the same `ENTITIES` +array so `forFeature` lets `AdminSystemModule` inject the repository. diff --git a/docs/database/admin-operation-tokens.md b/docs/database/admin-operation-tokens.md new file mode 100644 index 0000000..a8501f8 --- /dev/null +++ b/docs/database/admin-operation-tokens.md @@ -0,0 +1,55 @@ +--- +type: database +title: Admin Operation Tokens +description: Single-use confirmation tokens backing the destructive admin System operations (restore-backup, reset-scores, wipe-challenges). +resource: backend/src/database/entities/admin-operation-token.entity.ts +tags: [database, admin, system, backup, restore, danger-zone, tokens] +timestamp: 2026-07-23T12:04:50Z +--- + +# Schema + +| Column | Type | Description | +|---------------|-----------|----------------------------------------------------------------------------------------------------------| +| `id` | TEXT PK | UUIDv4 generated client-side; the primary key. | +| `user_id` | TEXT FK | `user.id` of the administrator who issued the token. FK `ON DELETE CASCADE` to `user`. | +| `operation` | TEXT | One of `restore-backup`, `reset-scores`, `wipe-challenges` (mirrors `ADMIN_OPERATION_KINDS`). | +| `token_hash` | TEXT | Hex-encoded SHA-256 of the raw token. The raw token is returned once and never logged or stored. Unique. | +| `issued_at` | TEXT | ISO-8601 timestamp at issuance. | +| `expires_at` | TEXT | ISO-8601 timestamp after which the token is invalid (default TTL: `SYSTEM_OP_CONFIRM_TOKEN_TTL_MS`, 5 min). | +| `consumed_at` | TEXT null | ISO-8601 timestamp when the token was consumed; null while still valid. | + +# Indexes + +| Index | Purpose | +|--------------------------------|--------------------------------------------------------| +| `uq_admin_op_token_hash` | `UNIQUE` on `token_hash` — guarantees one-shot use. | +| `idx_admin_op_token_user_op` | `(user_id, operation)` — fast per-admin/per-op lookup. | +| `idx_admin_op_token_expiry` | `expires_at` — enables the periodic expiry sweep. | + +# Lifecycle + +1. Admin re-authenticates with their current password via + `POST /api/v1/admin/system/confirmations`. The backend verifies the + password with Argon2id, then `ConfirmationTokenService.issue()` + generates a 32-byte base64url token, persists only its SHA-256 hash, + and returns the raw token plus its `expiresAt`. +2. The client passes the raw token (and the `stagingId` for restores) + to the matching destructive endpoint (`/restore/commit`, + `/scores/reset`, `/challenges/wipe`). +3. `ConfirmationTokenService.consume()` runs the consumption inside a + transaction: it looks the token up by hash, validates + `user_id` + `operation` (+ `stagingId` for restores), checks + `expires_at > now`, and finally performs a conditional update + `WHERE consumedAt IS NULL` so only the first concurrent caller + succeeds. A second attempt returns `SYSTEM_TOKEN_REUSED`. +4. `purgeExpired()` deletes expired rows and consumed rows older than + 24h. + +# Related + +* Migration: [Admin Operation Tokens Migration](/database/admin-operation-tokens-migration.md) +* Service: `backend/src/modules/admin/system/confirmation-token.service.ts` +* Error codes: `SYSTEM_TOKEN_INVALID`, `SYSTEM_TOKEN_EXPIRED`, + `SYSTEM_TOKEN_REUSED`, `SYSTEM_TOKEN_MISMATCH` +* [Admin System API](/api/admin-system.md) diff --git a/docs/database/schema.md b/docs/database/schema.md index f4667c7..a409791 100644 --- a/docs/database/schema.md +++ b/docs/database/schema.md @@ -42,6 +42,7 @@ re-asserts the unique indexes on `system_key` and `abbreviation`. | `solve` | User-solved challenges (scoring ledger). | | `refresh_token` | Rotating refresh tokens (hash + revocation). | | `blog_post` | Markdown blog posts in draft/published states. | +| `admin_operation_token` | Single-use confirmation tokens for destructive System operations. | # Relationships @@ -68,6 +69,7 @@ user ───< refresh_token | `solve` | `uq_solve_challenge_user` (unique), `idx_solve_user`, `idx_solve_challenge` | | `refresh_token` | `idx_refresh_token_user`, unique on `token_hash` | | `blog_post` | `idx_blog_status_published` (`status`, `published_at`) | +| `admin_operation_token` | `uq_admin_op_token_hash` (unique on `token_hash`), `idx_admin_op_token_user_op` (`user_id`, `operation`), `idx_admin_op_token_expiry` (`expires_at`) | # PRAGMAs @@ -91,3 +93,4 @@ On every process start, `DatabaseInitService.init()`: - [Challenge Tables](/database/challenges.md) - [Auth and Settings Tables](/database/auth-settings.md) - [Blog Post Table](/database/blog-posts.md) +- [Admin Operation Tokens Table](/database/admin-operation-tokens.md) diff --git a/docs/guides/admin-system-page.md b/docs/guides/admin-system-page.md new file mode 100644 index 0000000..e2758c6 --- /dev/null +++ b/docs/guides/admin-system-page.md @@ -0,0 +1,180 @@ +--- +type: guide +title: Admin — System (Backup, Restore, Danger Zone) +description: How an admin navigates to /admin/system, creates a backup, stages and commits a restore, resets all scores, and wipes challenges — including the re-authentication confirmation, progress, and side effects. +tags: [guide, admin, system, backup, restore, danger-zone, confirmation] +timestamp: 2026-07-23T12:04:50Z +--- + +# Navigation + +1. Sign in as an admin and open the admin side-nav (see the + [Admin Shell guide](/guides/admin-shell.md)). +2. Click **System** in the side-nav. The route resolves to + `/admin/system` and lazily loads the + `AdminSystemComponent` standalone component. +3. The page renders two panels side by side: **Database** (left) and + **Danger Zone** (right). On narrow screens they stack vertically. + +# Database panel + +## Create a backup + +1. Click **Create backup** (data-testid `admin-system-create-backup`). + A spinner appears inside the button while the request is in flight. +2. The browser receives a JSON file via `Content-Disposition: + attachment; filename="hipctf-backup-YYYY-MM-DD.json"` and the + browser download UI saves it to disk. +3. On success the panel shows a green **Backup ready.** status line + (data-testid `admin-system-backup-success`). +4. On failure the panel shows a red error message under the buttons + (data-testid `admin-system-backup-error`). The friendly message + comes from `system.pure.ts` and resolves `SYSTEM_BACKUP_FAILED`, + `SYSTEM_FILE_READ_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, etc. + +## Restore a backup + +1. Click **Restore backup** (data-testid `admin-system-restore-backup`). + A hidden file picker opens restricted to `application/json`/`.json`. +2. Pick a backup JSON file. The progress line shows + **Reading selected backup…**, then **Validating backup + server-side…** (data-testid `admin-system-restore-progress`). +3. The server validates the JSON, decodes uploads, and verifies + sizes/checksums. On success a summary card appears + (data-testid `admin-system-restore-summary`) showing: + * Tables detected + * Uploaded files + * Total bytes + * Stage expiry (ISO timestamp) +4. From the summary card, the admin has two options: + * **Discard staged backup** (data-testid + `admin-system-discard-restore`) — drops the staged state and + resumes from a clean panel. + * **Continue to destructive restore…** (data-testid + `admin-system-confirm-restore`) — opens the confirmation modal. + +### Confirmation modal (restore-backup) + +* The modal title is **Restore database?**. +* The detail paragraph explains that the entire database and uploads + will be replaced (the modal's `preserved` line says **Nothing is + preserved**). +* The stage banner explicitly warns: *You will be logged out after + restore completes.* +* The admin must enter their current password and type + `RESTORE BACKUP` exactly into the confirmation phrase field. +* Click **Confirm**. The modal swaps the input fields for a + *Working…* spinner. +* The frontend calls `POST /confirmations` with `{ operation: + 'restore-backup', password, stagingId }` to receive a single-use + token, then immediately calls `POST /restore/commit` with that + token and the staging id. +* On success the SPA calls `AuthService.forceServerInvalidation()`, + resets the `UserStore`, and navigates to `/login`. The + `ChallengesStore` and `ScoreboardStore` invalidate themselves via + the `SystemDataChangeService` `'restore-completed'` event. +* On failure the modal stays open and shows the resolved error + (e.g. `SYSTEM_TOKEN_MISMATCH`, `SYSTEM_RESTORE_ROLLED_BACK`). The + database panel also reflects the error inline. + +# Danger Zone panel + +The danger zone panel is outlined by a dashed red border and contains +two red buttons: + +| Button | Operation | +|--------------------------------------------|---------------------| +| `admin-system-reset-scores` | `reset-scores` | +| `admin-system-wipe-challenges` | `wipe-challenges` | + +Each button opens the same confirmation modal with operation-specific +copy: + +* **Reset all scores?** — detail lists what is removed + (every solve and award), preserved (users, sessions, roles, + challenges, categories, etc.). Confirmation phrase: `RESET SCORES`. +* **Wipe all challenges?** — detail explains that every challenge, its + attached files, every solve, and every award are removed, and that + every uploaded challenge file on disk is deleted. Preserved: + users, sessions, roles, categories, settings, blog posts, and + root-level uploads (e.g. the site logo). Confirmation phrase: + `WIPE CHALLENGES`. + +After the admin enters their password and types the confirmation +phrase, the SPA re-authenticates to issue a token and then commits the +operation. The panel shows a green summary +(`N solve record(s) removed.` or `N challenge(s), M file(s) wiped.`) +and a toast notification. The corresponding stores +(`ChallengesStore`, `ScoreboardStore`) automatically reset themselves +via the `SystemDataChangeService` `scores-reset` and `challenges-wiped` +events so every open tab sees fresh data. + +# Cross-tab invalidation + +After any of the destructive operations, the +`SystemDataChangeService` notifies all subscribers in the same tab via +an Angular signal. The injected effects in `ChallengesStore` and +`ScoreboardStore` reset their cached board/matrix/event-log/graph +data. For a restore, the SPA additionally forces a cross-tab +invalidation by calling `AuthService.forceServerInvalidation()`, +which clears the access token and notifies peer tabs via +`BroadcastChannel` + the `storage`-event fallback so they redirect to +`/login` as well. + +# Error mapping + +The pure helper `friendlySystemErrorMessage(op, err)` in +`system.pure.ts` converts the backend error codes into user-facing +messages. Notable mappings: + +| Backend code | UI message | +|---------------------------------------|----------------------------------------------------------------------------| +| `SYSTEM_REAUTH_REQUIRED` | "Your session is no longer valid. Please log in again." | +| `SYSTEM_INVALID_CREDENTIALS` | "The current password is incorrect." | +| `SYSTEM_TOKEN_INVALID` / `_EXPIRED` | "Confirmation token is invalid / expired. Re-authenticate and try again." | +| `SYSTEM_TOKEN_REUSED` | "Confirmation token was already used. Re-authenticate to get a fresh token." | +| `SYSTEM_TOKEN_MISMATCH` | "Confirmation token does not match this operation." | +| `SYSTEM_RESTORE_VALIDATION_FAILED` | "Backup validation failed. No data was changed." | +| `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE` | "The backup is larger than the configured upload limit." | +| `SYSTEM_RESTORE_STAGE_EXPIRED` | "The backup staging window expired. Re-upload and validate again." | +| `SYSTEM_RESTORE_ROLLED_BACK` | "Restore failed and was rolled back. Your data is unchanged." | +| `SYSTEM_DANGER_ROLLED_BACK` | "Operation failed and was rolled back. Nothing was changed." | +| `SYSTEM_BACKUP_FAILED` | "Backup could not be generated. No data was changed." | +| `SYSTEM_DANGER_FAILED` | "Operation could not complete. No data was changed." | +| `SYSTEM_OPERATION_IN_PROGRESS` | "Another destructive operation is currently running." | + +# Examples + +## Manual restore happy path + +1. Download the backup from a known-good environment. +2. Sign in as the admin on the target environment. +3. Navigate to `/admin/system`, click **Restore backup**, select the + downloaded file. +4. Verify the summary matches the expected table counts and file + counts. +5. Click **Continue to destructive restore…**, enter the admin + password, type `RESTORE BACKUP`, click **Confirm**. +6. The modal closes, the page shows a brief *Restore complete. + Logging out…* toast, and the SPA navigates to `/login`. +7. Sign in again with the same admin credentials. The data on the + target environment now matches the source backup. + +## Manual reset happy path + +1. Navigate to `/admin/system`. +2. Click **Reset all scores**, enter the password, type + `RESET SCORES`, click **Confirm**. +3. The danger zone panel shows the number of solve records removed + and a toast notifies the operation completed. Refresh the + Scoreboard page — every player is back at zero. + +## Manual wipe happy path + +1. Navigate to `/admin/system`. +2. Click **Wipe challenges**, enter the password, type + `WIPE CHALLENGES`, click **Confirm**. +3. The danger zone panel shows counts of challenges, files, and + solves removed. The Challenges page becomes empty and the + `/admin/challenges` list is empty. The `challenges/` folder on + disk is also gone. diff --git a/docs/index.md b/docs/index.md index 06d600b..03b9307 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,7 +12,7 @@ controls, administrator-managed Markdown blog publishing, and public and signed-in blog views. The docs below are organized by purpose so agents can pull just the slice -they need. Last regenerated 2026-07-23T10:36:45Z. +they need. Last regenerated 2026-07-23T12:04:50Z. # Architecture @@ -43,6 +43,11 @@ they need. Last regenerated 2026-07-23T10:36:45Z. * [Auth and Settings Tables](/database/auth-settings.md) - `refresh_token` and `setting` tables. * [Blog Post Table](/database/blog-posts.md) - `blog_post` table. +* [Admin Operation Tokens Table](/database/admin-operation-tokens.md) - + Single-use confirmation tokens for destructive System operations. +* [Admin Operation Tokens Migration](/database/admin-operation-tokens-migration.md) - + Forward-only migration that adds the `admin_operation_token` table + and its indexes. # API @@ -63,6 +68,9 @@ they need. Last regenerated 2026-07-23T10:36:45Z. `/events/status` SSE stream. * [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads for category icons, challenge files, and validated site logos. * [Blog Endpoints](/api/blog.md) - Public published-post listing and admin-only post CRUD, validation, and publication behavior. +* [Admin System Endpoints](/api/admin-system.md) - Admin-only backup + download, restore validate/commit, re-authenticated confirmation + tokens, reset-scores, and wipe-challenges. # Guides @@ -87,6 +95,11 @@ they need. Last regenerated 2026-07-23T10:36:45Z. * [Admin — Challenges Full-Replace Import](/guides/admin-challenges-import.md) - Transactional full-replace semantics, side effects, and the auto-close contract of the confirmed challenges import on `/admin/challenges`. +* [Admin — System (Backup, Restore, Danger Zone)](/guides/admin-system-page.md) - + How an admin navigates to `/admin/system`, creates a backup, stages + and commits a restore, resets all scores, and wipes challenges, + including the re-authentication confirmation flow and cross-tab + invalidation. * [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED + countdown, quick-jump tabs, user menu, and SSE lifecycle that frame every signed-in page. -- 2.52.0 From 7b9be240132f2eb0f911e276bb773b4923140f00 Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Thu, 23 Jul 2026 12:10:37 +0000 Subject: [PATCH 2/2] feat: Admin Area System: Database Backup/Restore and Danger Zone --- .kilo/plans/871-followup.md | 30 ++ .kilo/plans/915.md | 48 -- backend/src/common/errors/error-codes.ts | 17 + backend/src/common/utils/upload.ts | 35 ++ backend/src/config/env.schema.ts | 4 + backend/src/database/database.module.ts | 4 + .../entities/admin-operation-token.entity.ts | 41 ++ .../1700000000900-AddAdminOperationTokens.ts | 36 ++ backend/src/modules/admin/admin.module.ts | 2 + .../admin/system/admin-system.controller.ts | 184 +++++++ .../admin/system/admin-system.module.ts | 33 ++ .../modules/admin/system/backup.service.ts | 177 +++++++ .../system/confirmation-token.service.ts | 136 ++++++ .../admin/system/danger-zone.service.ts | 147 ++++++ .../admin/system/dto/admin-system.dto.ts | 46 ++ .../system/filesystem-transaction.service.ts | 275 +++++++++++ .../modules/admin/system/restore.service.ts | 453 ++++++++++++++++++ backend/src/modules/auth/auth.service.ts | 40 ++ frontend/src/app/app.routes.ts | 5 + .../src/app/core/services/auth.service.ts | 11 + .../services/system-data-change.service.ts | 14 + .../features/admin/admin-shell.component.ts | 2 +- .../system/system-confirm-modal.component.ts | 162 +++++++ .../features/admin/system/system.component.ts | 426 ++++++++++++++++ .../app/features/admin/system/system.pure.ts | 155 ++++++ .../features/admin/system/system.service.ts | 91 ++++ .../features/challenges/challenges.store.ts | 31 +- .../features/scoreboard/scoreboard.store.ts | 22 +- .../admin-system-authorization.spec.ts | 131 +++++ tests/backend/admin-system-backup.spec.ts | 107 +++++ .../admin-system-confirmation-token.spec.ts | 146 ++++++ tests/backend/admin-system-danger.spec.ts | 215 +++++++++ .../admin-system-restore-validation.spec.ts | 129 +++++ tests/frontend/admin-system.pure.spec.ts | 76 +++ 34 files changed, 3378 insertions(+), 53 deletions(-) create mode 100644 .kilo/plans/871-followup.md delete mode 100644 .kilo/plans/915.md create mode 100644 backend/src/database/entities/admin-operation-token.entity.ts create mode 100644 backend/src/database/migrations/1700000000900-AddAdminOperationTokens.ts create mode 100644 backend/src/modules/admin/system/admin-system.controller.ts create mode 100644 backend/src/modules/admin/system/admin-system.module.ts create mode 100644 backend/src/modules/admin/system/backup.service.ts create mode 100644 backend/src/modules/admin/system/confirmation-token.service.ts create mode 100644 backend/src/modules/admin/system/danger-zone.service.ts create mode 100644 backend/src/modules/admin/system/dto/admin-system.dto.ts create mode 100644 backend/src/modules/admin/system/filesystem-transaction.service.ts create mode 100644 backend/src/modules/admin/system/restore.service.ts create mode 100644 frontend/src/app/core/services/system-data-change.service.ts create mode 100644 frontend/src/app/features/admin/system/system-confirm-modal.component.ts create mode 100644 frontend/src/app/features/admin/system/system.component.ts create mode 100644 frontend/src/app/features/admin/system/system.pure.ts create mode 100644 frontend/src/app/features/admin/system/system.service.ts create mode 100644 tests/backend/admin-system-authorization.spec.ts create mode 100644 tests/backend/admin-system-backup.spec.ts create mode 100644 tests/backend/admin-system-confirmation-token.spec.ts create mode 100644 tests/backend/admin-system-danger.spec.ts create mode 100644 tests/backend/admin-system-restore-validation.spec.ts create mode 100644 tests/frontend/admin-system.pure.spec.ts diff --git a/.kilo/plans/871-followup.md b/.kilo/plans/871-followup.md new file mode 100644 index 0000000..3b3764e --- /dev/null +++ b/.kilo/plans/871-followup.md @@ -0,0 +1,30 @@ +# Implementation Plan: Job 871 — follow-up tweaks + +## 1. Architectural Reconnaissance +- The previous implementation introduced a `RestoreService` that takes a `ConfigService` constructor parameter but does not retain it on `this`. Internally `stageArchive()` calls `resolveSystemStagingDir(this.requireConfig())`, where `requireConfig()` returns the parameter via a cast `(this as any).configService`. This works but is a hack — the parameter should simply be retained as a class field. +- The previous `DangerZoneService.wipeChallenges()` snapshots `/challenges` to a side directory, transactionally deletes the DB rows, and then removes the snapshot. It currently never actually deletes the live `/challenges` directory on success, so the filesystem files persist even though the DB rows are gone. The user wants the live directory physically removed after the DB commit succeeds, with the snapshot retained as the rollback source until the disk delete is complete so that a disk-delete failure can be restored from it. + +## 2. Impacted Files +- **To Modify:** + - `backend/src/modules/admin/system/restore.service.ts` — retain `ConfigService` on the instance and stop using the `requireConfig()` shim; use it directly in `stageArchive()`. + - `backend/src/modules/admin/system/danger-zone.service.ts` — in `wipeChallenges()`, after the DB transaction commits, physically remove the live `/challenges` directory, and only after that succeeds remove the snapshot; on any failure after the snapshot was created, copy the snapshot back into place so a disk-delete failure can be reversed. + +## 3. Proposed Changes +1. **`restore.service.ts` cleanup:** + - Convert the constructor's `config: ConfigService` parameter into `private readonly configService: ConfigService` so it is stored on the instance. + - At the top of the constructor body, capture each derived value (`uploadDir`, `databasePath`, `stageTtlMs`, `restoreUploadLimit`) from `configService` once. + - Remove the private `requireConfig()` shim method entirely. + - In `stageArchive()`, change the call to `resolveSystemStagingDir(this.configService)`. +2. **`danger-zone.service.ts` physical delete:** + - In `wipeChallenges()`, after the `dataSource.transaction(...)` block returns successfully (we already have the row counts in `counts`), add: `FilesystemTransactionService.rmSafe(challengesDir)` to physically remove the live `/challenges` directory and its contents. + - If that rm succeeds, then `rmSafe` the `stagingBackup` snapshot. + - If the rm of `challengesDir` itself throws, retain the snapshot so the catch branch can restore from it (keep `staged = true`), rethrow the error to enter the catch branch, and from the existing catch branch copy `stagingBackup` back into `challengesDir` exactly as before. Update the in-method narrative so that order of operations matches: stage → db commit → live rm → snapshot rm. + - Do not change the DB ordering: the transaction must commit before any filesystem delete so a DB failure does not leave a partial filesystem state. + +## 4. Test Strategy +- **Target Unit Test File:** + - Existing `tests/backend/admin-system-danger.spec.ts` already verifies that after `wipe-challenges` the `challenge` table is empty. Extend it with one assertion: after a successful wipe, the live `/challenges` directory is also physically empty/absent. + - Existing `tests/backend/admin-system-backup.spec.ts` and `tests/backend/admin-system-restore-validation.spec.ts` already validate `BackupService` and `RestoreService.stageArchive()` behavior; no behavioral changes are expected from the constructor cleanup since `stageArchive()` still resolves the same staging directory. Re-run those tests after the edit to confirm no regressions. + - No new frontend tests are required — these are strictly backend concerns. +- **Mocking Strategy:** Reuse the existing per-suite isolated temp upload directories (`mkdtempSync(path.join(os.tmpdir(), 'hipctf-…'))`) and assert against the live path with `fs.existsSync`/`fs.readdirSync`. No new mocking infrastructure is needed; the danger-zone logic now relies on the same primitives (fs + SQLite transaction) the existing tests already exercise. +- **Verification:** Run `npm test` to confirm both `admin-system-danger.spec.ts` and the related system suites stay green; also run `npx --workspace backend tsc -p tsconfig.build.json --noEmit` to confirm the `requireConfig()` removal does not break the build. diff --git a/.kilo/plans/915.md b/.kilo/plans/915.md deleted file mode 100644 index 166570c..0000000 --- a/.kilo/plans/915.md +++ /dev/null @@ -1,48 +0,0 @@ -# Implementation Plan: Job 915 — Enable Admin Blog Post Deletion Confirmation - -## Status - -The requested job is **not fully implemented**. The backend DELETE endpoint, persistence service, Angular API client, parent delete workflow, and confirmation modal already exist, but the parent binds the modal's disabled state to the selected post rather than the request-in-flight state. The UI therefore cannot complete an otherwise wired deletion flow. - -## 1. Architectural Reconnaissance - -- **Codebase style & conventions:** Node.js npm-workspace monorepo using TypeScript, NestJS 10 on the backend, and Angular 17 standalone components on the frontend. Angular admin pages use smart/container components with `signal()` state, signal inputs/outputs, `ChangeDetectionStrategy.OnPush`, and Angular control-flow syntax. The admin blog parent owns service calls and modal lifecycle; `BlogDeleteModalComponent` is presentational and only receives inputs/emits cancel/confirm events. -- **Data Layer:** TypeORM `BlogPostEntity` backed by SQLite via `better-sqlite3`. Blog deletion is already implemented in `backend/src/modules/blog/admin-blog.service.ts:65-69` with a repository lookup, not-found error, and `delete({ id })`; no schema or migration change is required. -- **Test Framework & Structure:** Root Jest multi-project configuration in `tests/jest.config.js`, with frontend tests under the dedicated `tests/frontend/` folder and backend tests under `tests/backend/`. The root `npm test` command runs all projects; focused frontend execution is available through `npm run test:frontend`. Existing blog tests are mostly pure-logic/source-contract tests, while the API suite already covers DELETE persistence and 404 behavior. -- **Required Tools & Dependencies:** No new package or system dependency is required. Existing Node/npm, Angular, Jest, jsdom, ts-jest, and repository dependencies are sufficient. `setup.sh` needs no change; it already installs dependencies, rebuilds `better-sqlite3`, and builds both workspaces. No `/data` fixture or persistent test asset is needed because this is a frontend state-binding fix and existing backend tests use in-memory SQLite. - -## 2. Impacted Files - -- **To Modify:** - - `frontend/src/app/features/admin/blog/blog.component.ts` — bind the delete modal's `deleting` input to `actionInFlight()` rather than `deleting() !== null`, preserving the selected post while enabling confirmation before the request starts and disabling it during the request. - - `tests/frontend/blog-admin-delete.spec.ts` — add a minimal focused regression test in the dedicated frontend test folder covering the modal binding and deletion state contract. -- **To Create:** - - `tests/frontend/blog-admin-delete.spec.ts` — new focused frontend regression test file, if no existing delete-modal/admin-page test is suitable for extension. - -No backend controller, service, entity, DTO, route, database migration, API client, delete modal component, documentation, dependency manifest, or setup script needs modification. - -## 3. Proposed Changes - -1. **Database / Schema Migration:** - - No change. `AdminBlogService.remove()` already deletes the requested `blog_post` row and returns not-found for a missing ID. - - Do not add migration or seed data. The expected persistence behavior is already provided by the existing DELETE API and is covered by `tests/backend/blog-admin.spec.ts:220-240`. - -2. **Backend Logic & APIs:** - - No change. `AdminBlogController.remove()` already exposes `DELETE /api/v1/admin/blog/posts/:id`, applies the admin guard/role protection, and returns HTTP 204. - - `BlogApiService.deletePost()` already calls the endpoint with the encoded ID and credentials. - - The parent `AdminBlogComponent.onDeleteConfirm()` already sets `actionInFlight` to true before awaiting the DELETE request, closes the modal after success, reloads the complete admin list, reports `Post deleted.`, and resets the in-flight signal in `finally`. Its error path keeps the modal open and exposes the server message. - -3. **Frontend UI Integration:** - - In `frontend/src/app/features/admin/blog/blog.component.ts:111-118`, change only the `[deleting]` binding from `[deleting]="deleting() !== null"` to `[deleting]="actionInFlight()"`. - - Keep `[post]="deleting()"` unchanged so the modal continues displaying the selected title and the parent retains the target ID until success or cancel. - - Keep the existing `actionInFlight` signal shared by create, edit, and delete operations. When `openDelete()` sets the selected post and opens the modal, `actionInFlight()` remains false, so `post-delete-ok` is enabled. When `onDeleteConfirm()` begins, it becomes true and the modal disables Delete while awaiting the request. On success, `closeModal()` clears the selection and `load()` refreshes the table; on failure, the selection/modal remain visible and the button is re-enabled after `finally` so retry is possible. - - Do not change `BlogDeleteModalComponent`: its `[disabled]="deleting()"` behavior is already correct when supplied the proper request-state signal. - -## 4. Test Strategy - -- **Target Unit Test File:** Add `tests/frontend/blog-admin-delete.spec.ts` under the existing dedicated Jest frontend test root. The test should be minimal and focused on the regression contract, not a browser/UI visual test. -- **Test cases:** - 1. Read/verify the admin component template contract that the delete modal receives `actionInFlight()` for its `deleting` input, and that it no longer derives the disabled state from the selected `deleting()` post. This matches the repository's existing lightweight source-contract testing style and directly catches reintroduction of the bug. - 2. Optionally instantiate the standalone `BlogDeleteModalComponent` with signal inputs using the existing Jest/jsdom Angular setup, set `deleting` false and true via `fixture.componentRef.setInput`, run `detectChanges()`, and assert the confirm button is enabled before the request and disabled during it. Keep this only if the current test environment supports standalone component compilation without introducing infrastructure; otherwise the source-contract test is sufficient for this one-line wiring regression. -- **Mocking Strategy:** Do not involve HTTP, the backend, SQLite, authentication, or real navigation in the focused frontend test. If component instantiation is used, provide only the modal's standalone imports and use signal input APIs; no service mock is needed because the modal emits outputs and contains no service dependency. The existing backend DELETE integration test remains the persistence/error boundary test. -- **Verification:** Run the single root command `npm test` after implementation so both existing backend API coverage and the new frontend regression test execute. The implementer should also run the repository's available frontend-focused Jest project if needed during iteration; no new command or setup change is required. diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 4b98425..33aa70d 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -30,6 +30,23 @@ export const ERROR_CODES = { EVENT_NOT_RUNNING: 'EVENT_NOT_RUNNING', FLAG_INCORRECT: 'FLAG_INCORRECT', CHALLENGE_DISABLED: 'CHALLENGE_DISABLED', + SYSTEM_BACKUP_FAILED: 'SYSTEM_BACKUP_FAILED', + SYSTEM_RESTORE_VALIDATION_FAILED: 'SYSTEM_RESTORE_VALIDATION_FAILED', + SYSTEM_RESTORE_PAYLOAD_TOO_LARGE: 'SYSTEM_RESTORE_PAYLOAD_TOO_LARGE', + SYSTEM_RESTORE_STAGE_EXPIRED: 'SYSTEM_RESTORE_STAGE_EXPIRED', + SYSTEM_RESTORE_FAILED: 'SYSTEM_RESTORE_FAILED', + SYSTEM_RESTORE_ROLLED_BACK: 'SYSTEM_RESTORE_ROLLED_BACK', + SYSTEM_OPERATION_IN_PROGRESS: 'SYSTEM_OPERATION_IN_PROGRESS', + SYSTEM_REAUTH_REQUIRED: 'SYSTEM_REAUTH_REQUIRED', + SYSTEM_INVALID_CREDENTIALS: 'SYSTEM_INVALID_CREDENTIALS', + SYSTEM_TOKEN_INVALID: 'SYSTEM_TOKEN_INVALID', + SYSTEM_TOKEN_EXPIRED: 'SYSTEM_TOKEN_EXPIRED', + SYSTEM_TOKEN_REUSED: 'SYSTEM_TOKEN_REUSED', + SYSTEM_TOKEN_MISMATCH: 'SYSTEM_TOKEN_MISMATCH', + SYSTEM_DANGER_FAILED: 'SYSTEM_DANGER_FAILED', + SYSTEM_DANGER_ROLLED_BACK: 'SYSTEM_DANGER_ROLLED_BACK', + SYSTEM_FILE_READ_FAILED: 'SYSTEM_FILE_READ_FAILED', + SYSTEM_PAYLOAD_INVALID: 'SYSTEM_PAYLOAD_INVALID', INTERNAL: 'INTERNAL', } as const; diff --git a/backend/src/common/utils/upload.ts b/backend/src/common/utils/upload.ts index 189361c..a18e55a 100644 --- a/backend/src/common/utils/upload.ts +++ b/backend/src/common/utils/upload.ts @@ -72,4 +72,39 @@ export function buildMulter(config: ConfigService, opts: MulterOptions = {}): mu }), limits, }); +} + +/** + * Parse a CONFIG-specified request size limit (defaults to the configured + * BODY_SIZE_LIMIT, falling back to '1mb'). Centralized here so the + * system operation endpoints can enforce the same ceiling as + * `main.ts`'s body parser. + */ +export function getRequestSizeLimit(config: ConfigService): number { + const raw = config.get('BODY_SIZE_LIMIT', '1mb'); + return parseUploadSizeLimit(raw); +} + +/** + * Resolve and ensure a private staging directory dedicated to system + * operations (backup/restore staging). Defaults to `/.system-staging` + * and never returns a directory inside UPLOAD_DIR. + */ +export function resolveSystemStagingDir(config: ConfigService): string { + const explicit = config.get('SYSTEM_OP_STAGING_DIR'); + const uploadDir = path.resolve(config.get('UPLOAD_DIR', './data/uploads')); + let dir: string; + if (explicit && explicit.trim().length > 0) { + dir = path.resolve(explicit); + } else { + const databasePath = config.get('DATABASE_PATH', './data/db.sqlite'); + const parent = path.dirname(path.resolve(databasePath)); + dir = path.join(parent, '.system-staging'); + } + if (dir.startsWith(uploadDir + path.sep) || dir === uploadDir) { + // Never let staging nest under the public upload tree. + dir = path.join(uploadDir, '..', '.system-staging'); + } + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + return dir; } \ No newline at end of file diff --git a/backend/src/config/env.schema.ts b/backend/src/config/env.schema.ts index c901fd0..904e346 100644 --- a/backend/src/config/env.schema.ts +++ b/backend/src/config/env.schema.ts @@ -28,6 +28,10 @@ export const envSchema = z.object({ UPLOAD_SIZE_LIMIT: z.string().default('50mb'), TLS_ENABLED: z.coerce.boolean().default(false), + + SYSTEM_OP_STAGING_DIR: z.string().min(1).optional(), + SYSTEM_OP_RESTORE_STAGE_TTL_MS: z.coerce.number().int().positive().default(15 * 60_000), + SYSTEM_OP_CONFIRM_TOKEN_TTL_MS: z.coerce.number().int().positive().default(5 * 60_000), }); export type AppEnv = z.infer; diff --git a/backend/src/database/database.module.ts b/backend/src/database/database.module.ts index 7e393b7..342bcc2 100644 --- a/backend/src/database/database.module.ts +++ b/backend/src/database/database.module.ts @@ -11,6 +11,7 @@ import { ChallengeFileEntity } from './entities/challenge-file.entity'; import { SolveEntity } from './entities/solve.entity'; import { RefreshTokenEntity } from './entities/refresh-token.entity'; import { BlogPostEntity } from './entities/blog-post.entity'; +import { AdminOperationTokenEntity } from './entities/admin-operation-token.entity'; import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema'; import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData'; import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from './migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev'; @@ -20,6 +21,7 @@ import { UpgradeChallengeAdminSchema1700000000500 } from './migrations/170000000 import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges'; import { AddUserDeleteCascades1700000000700 } from './migrations/1700000000700-AddUserDeleteCascades'; import { AddUserLastAdminTriggers1700000000800 } from './migrations/1700000000800-AddUserLastAdminTriggers'; +import { AddAdminOperationTokens1700000000900 } from './migrations/1700000000900-AddAdminOperationTokens'; import { DatabaseInitService } from './database-init.service'; const ENTITIES = [ @@ -31,6 +33,7 @@ const ENTITIES = [ SolveEntity, RefreshTokenEntity, BlogPostEntity, + AdminOperationTokenEntity, ]; const MIGRATIONS = [ @@ -43,6 +46,7 @@ const MIGRATIONS = [ SeedSampleChallenges1700000000600, AddUserDeleteCascades1700000000700, AddUserLastAdminTriggers1700000000800, + AddAdminOperationTokens1700000000900, ]; @Global() diff --git a/backend/src/database/entities/admin-operation-token.entity.ts b/backend/src/database/entities/admin-operation-token.entity.ts new file mode 100644 index 0000000..93e75d2 --- /dev/null +++ b/backend/src/database/entities/admin-operation-token.entity.ts @@ -0,0 +1,41 @@ +import { Entity, PrimaryColumn, Column, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { UserEntity } from './user.entity'; + +export const ADMIN_OPERATION_KINDS = [ + 'restore-backup', + 'reset-scores', + 'wipe-challenges', +] as const; + +export type AdminOperationKind = (typeof ADMIN_OPERATION_KINDS)[number]; + +@Entity('admin_operation_token') +export class AdminOperationTokenEntity { + @PrimaryColumn('text') + id!: string; + + @Index() + @Column('text', { name: 'user_id' }) + userId!: string; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user?: UserEntity; + + @Column('text') + operation!: AdminOperationKind; + + @Index({ unique: true }) + @Column('text', { name: 'token_hash' }) + tokenHash!: string; + + @Column('text', { name: 'issued_at' }) + issuedAt!: string; + + @Index() + @Column('text', { name: 'expires_at' }) + expiresAt!: string; + + @Column('text', { name: 'consumed_at', nullable: true }) + consumedAt!: string | null; +} diff --git a/backend/src/database/migrations/1700000000900-AddAdminOperationTokens.ts b/backend/src/database/migrations/1700000000900-AddAdminOperationTokens.ts new file mode 100644 index 0000000..0de4c39 --- /dev/null +++ b/backend/src/database/migrations/1700000000900-AddAdminOperationTokens.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAdminOperationTokens1700000000900 implements MigrationInterface { + name = 'AddAdminOperationTokens1700000000900'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "admin_operation_token" ( + "id" TEXT PRIMARY KEY, + "user_id" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "issued_at" TEXT NOT NULL, + "expires_at" TEXT NOT NULL, + "consumed_at" TEXT, + CONSTRAINT "fk_admin_op_token_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE + ); + `); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "uq_admin_op_token_hash" ON "admin_operation_token"("token_hash");`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_admin_op_token_user_op" ON "admin_operation_token"("user_id","operation");`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "idx_admin_op_token_expiry" ON "admin_operation_token"("expires_at");`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "idx_admin_op_token_expiry";`); + await queryRunner.query(`DROP INDEX IF EXISTS "idx_admin_op_token_user_op";`); + await queryRunner.query(`DROP INDEX IF EXISTS "uq_admin_op_token_hash";`); + await queryRunner.query(`DROP TABLE IF EXISTS "admin_operation_token";`); + } +} diff --git a/backend/src/modules/admin/admin.module.ts b/backend/src/modules/admin/admin.module.ts index cdf02bf..76793c4 100644 --- a/backend/src/modules/admin/admin.module.ts +++ b/backend/src/modules/admin/admin.module.ts @@ -18,6 +18,7 @@ import { AdminCategoriesService } from './categories.service'; import { AdminChallengesController } from './admin-challenges.controller'; import { AdminChallengesService } from './challenges.service'; import { ChallengeFilesService } from './challenge-files.service'; +import { AdminSystemModule } from './system/admin-system.module'; @Module({ imports: [ @@ -26,6 +27,7 @@ import { ChallengeFilesService } from './challenge-files.service'; UsersModule, SettingsModule, CommonModule, + AdminSystemModule, ], providers: [ AdminService, diff --git a/backend/src/modules/admin/system/admin-system.controller.ts b/backend/src/modules/admin/system/admin-system.controller.ts new file mode 100644 index 0000000..fb90147 --- /dev/null +++ b/backend/src/modules/admin/system/admin-system.controller.ts @@ -0,0 +1,184 @@ +import { + Body, + Controller, + ForbiddenException, + Get, + HttpCode, + HttpStatus, + Post, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Request, 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 { BackupService } from './backup.service'; +import { RestoreService } from './restore.service'; +import { DangerZoneService } from './danger-zone.service'; +import { ConfirmationTokenService } from './confirmation-token.service'; +import { AuthService } from '../../auth/auth.service'; +import { + AdminReauthBodySchema, + AdminDangerConfirmBodySchema, + AdminRestoreCommitBodySchema, + AdminOperationKind, +} from './dto/admin-system.dto'; + +interface AuthedRequest extends Request { + user?: { sub: string; role?: string }; +} + +function requireAdminUserId(req: AuthedRequest): string { + if (!req.user || req.user.role !== 'admin' || !req.user.sub) { + throw new ForbiddenException('Admin role required'); + } + return req.user.sub; +} + +@ApiTags('admin') +@ApiBearerAuth() +@Controller('api/v1/admin/system') +@UseGuards(AdminGuard) +@Roles('admin') +export class AdminSystemController { + constructor( + private readonly backup: BackupService, + private readonly restore: RestoreService, + private readonly danger: DangerZoneService, + private readonly tokens: ConfirmationTokenService, + private readonly auth: AuthService, + ) {} + + @Get('backup') + @ApiOperation({ summary: 'Download a complete database + uploads backup (admin only).' }) + async downloadBackup(@Res() res: Response): Promise { + const doc = await this.backup.build(); + const text = BackupService.stringify(doc); + res + .status(HttpStatus.OK) + .setHeader('Content-Type', 'application/json; charset=utf-8') + .setHeader( + 'Content-Disposition', + `attachment; filename="hipctf-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.json"`, + ) + .send(text); + } + + @Post('restore/validate') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Validate an uploaded backup document without mutating data.' }) + async validateRestore( + @Req() req: AuthedRequest, + @Body() body: { archive?: unknown; text?: string }, + ): Promise { + const userId = requireAdminUserId(req); + let rawText: string; + if (typeof body.text === 'string') { + rawText = body.text; + } else if (body.archive && typeof body.archive === 'object') { + rawText = JSON.stringify(body.archive); + } else { + throw new ApiError( + ERROR_CODES.SYSTEM_PAYLOAD_INVALID, + 'No archive provided. Send { text: "" } or { archive: { ... } }.', + 400, + ); + } + return this.restore.stageArchive(rawText, userId); + } + + @Post('restore/commit') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Commit a previously-staged restore (requires confirmation token).' }) + async commitRestore( + @Req() req: AuthedRequest, + @Body(new ZodValidationPipe(AdminRestoreCommitBodySchema)) + body: { operation: AdminOperationKind; token: string; stagingId?: string }, + ): Promise<{ ok: true; operation: AdminOperationKind }> { + const userId = requireAdminUserId(req); + if (body.operation !== 'restore-backup') { + throw new ApiError( + ERROR_CODES.SYSTEM_TOKEN_MISMATCH, + 'Staging id is not bound to this operation', + 400, + ); + } + await this.tokens.consume({ + userId, + operation: 'restore-backup', + token: body.token, + stagingId: body.stagingId, + }); + const result = await this.restore.commitRestore(body.stagingId ?? ''); + void result; + await this.auth.revokeAllRefreshSessions(userId); + return { ok: true, operation: 'restore-backup' }; + } + + @Post('confirmations') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Reauthenticate and issue a single-use confirmation token for a destructive operation.' }) + async issueConfirmation( + @Req() req: AuthedRequest, + @Body(new ZodValidationPipe(AdminReauthBodySchema)) + body: { operation: AdminOperationKind; password: string; stagingId?: string }, + ): Promise<{ operation: AdminOperationKind; token: string; expiresAt: string }> { + const userId = requireAdminUserId(req); + const verified = await this.auth.reauthenticateAdmin(userId, body.password); + const issued = await this.tokens.issue({ userId: verified.id, operation: body.operation }); + void body.stagingId; + return { operation: body.operation, token: issued.token, expiresAt: issued.expiresAt }; + } + + @Post('scores/reset') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Reset all scores (delete every solve and award). Requires confirmation token.' }) + async resetScores( + @Req() req: AuthedRequest, + @Body(new ZodValidationPipe(AdminDangerConfirmBodySchema)) + body: { operation: AdminOperationKind; token: string }, + ): Promise<{ ok: true; solvesRemoved: number }> { + const userId = requireAdminUserId(req); + if (body.operation !== 'reset-scores') { + throw new ApiError( + ERROR_CODES.SYSTEM_TOKEN_MISMATCH, + 'Token was not issued for reset-scores', + 400, + ); + } + await this.tokens.consume({ userId, operation: 'reset-scores', token: body.token }); + const result = await this.danger.resetScores(); + return { ok: true, solvesRemoved: result.solvesRemoved }; + } + + @Post('challenges/wipe') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Wipe every challenge, cascading files and solves. Requires confirmation token.' }) + async wipeChallenges( + @Req() req: AuthedRequest, + @Body(new ZodValidationPipe(AdminDangerConfirmBodySchema)) + body: { operation: AdminOperationKind; token: string }, + ): Promise<{ + ok: true; + challengesRemoved: number; + challengeFilesRemoved: number; + solvesRemoved: number; + }> { + const userId = requireAdminUserId(req); + if (body.operation !== 'wipe-challenges') { + throw new ApiError( + ERROR_CODES.SYSTEM_TOKEN_MISMATCH, + 'Token was not issued for wipe-challenges', + 400, + ); + } + await this.tokens.consume({ userId, operation: 'wipe-challenges', token: body.token }); + const result = await this.danger.wipeChallenges(); + return { ok: true, ...result }; + } +} diff --git a/backend/src/modules/admin/system/admin-system.module.ts b/backend/src/modules/admin/system/admin-system.module.ts new file mode 100644 index 0000000..d93368c --- /dev/null +++ b/backend/src/modules/admin/system/admin-system.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AdminOperationTokenEntity } from '../../../database/entities/admin-operation-token.entity'; +import { AdminSystemController } from './admin-system.controller'; +import { BackupService } from './backup.service'; +import { RestoreService } from './restore.service'; +import { DangerZoneService } from './danger-zone.service'; +import { FilesystemTransactionService } from './filesystem-transaction.service'; +import { ConfirmationTokenService } from './confirmation-token.service'; +import { AuthModule } from '../../auth/auth.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AdminOperationTokenEntity]), + AuthModule, + ], + controllers: [AdminSystemController], + providers: [ + BackupService, + RestoreService, + DangerZoneService, + FilesystemTransactionService, + ConfirmationTokenService, + ], + exports: [ + BackupService, + RestoreService, + DangerZoneService, + FilesystemTransactionService, + ConfirmationTokenService, + ], +}) +export class AdminSystemModule {} diff --git a/backend/src/modules/admin/system/backup.service.ts b/backend/src/modules/admin/system/backup.service.ts new file mode 100644 index 0000000..b1a1f7f --- /dev/null +++ b/backend/src/modules/admin/system/backup.service.ts @@ -0,0 +1,177 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { + ADMIN_SYSTEM_BACKUP_FORMAT, + ADMIN_SYSTEM_BACKUP_VERSION, +} from './dto/admin-system.dto'; +import { ApiError } from '../../../common/errors/api-error'; +import { ERROR_CODES } from '../../../common/errors/error-codes'; + +const INTERNAL_TABLES = new Set([ + 'admin_operation_token', + 'migrations', + 'sqlite_sequence', + 'sqlite_master', + 'sqlite_temp_master', + 'sqlite_temp_schema', +]); + +export interface BackupFileEntry { + path: string; + originalFilename?: string; + mimeType?: string; + sizeBytes: number; + sha256: string; + dataBase64: string; +} + +export interface BackupObject { + format: typeof ADMIN_SYSTEM_BACKUP_FORMAT; + version: number; + exportedAt: string; + tables: Record>>; + tableCounts: Record; + uploads: BackupFileEntry[]; +} + +/** + * Generates a complete database + uploads backup as a single in-memory + * JSON object. The resulting payload is intentionally unversioned at the + * application level beyond the project's backup format identifier — new + * tables are captured automatically because they are discovered from + * `sqlite_master` at generation time rather than being hard-coded. + */ +@Injectable() +export class BackupService { + private readonly logger = new Logger(BackupService.name); + private readonly uploadDir: string; + + constructor(config: ConfigService, private readonly dataSource: DataSource) { + this.uploadDir = path.resolve(config.get('UPLOAD_DIR', './data/uploads')); + } + + /** + * Build the complete backup. Any failure during table snapshot or file + * reading throws without partial results — the caller is expected to + * translate this into an `SYSTEM_BACKUP_FAILED` envelope. + */ + async build(): Promise { + const exportedAt = new Date().toISOString(); + const tables: Record>> = {}; + const tableCounts: Record = {}; + const tablesList = await this.discoverTables(); + for (const tableName of tablesList) { + const rows = await this.safeRead(tableName); + tables[tableName] = rows.map((row) => ({ ...row })); + tableCounts[tableName] = rows.length; + } + const uploads = await this.collectUploads(); + return { + format: ADMIN_SYSTEM_BACKUP_FORMAT, + version: ADMIN_SYSTEM_BACKUP_VERSION, + exportedAt, + tables, + tableCounts, + uploads, + }; + } + + /** + * Returns every application table — dynamic discovery from the live + * SQLite schema. Internal metadata tables and operational tables that + * must not be restored are excluded. + */ + async discoverTables(): Promise { + const rows = await this.safeQuery<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name ASC`, + ); + return rows.map((r) => r.name).filter((n) => !INTERNAL_TABLES.has(n)); + } + + /** + * Convenience for tests: report the upload root resolved at construction time. + */ + getUploadDir(): string { + return this.uploadDir; + } + + private async safeRead(table: string): Promise[]> { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) { + throw new ApiError(ERROR_CODES.SYSTEM_BACKUP_FAILED, `Invalid table name: ${table}`, 500); + } + const rows = await this.safeQuery>(`SELECT * FROM "${table}"`); + return rows.map((row) => { + const out: Record = {}; + for (const [k, v] of Object.entries(row)) { + if (v instanceof Buffer) { + // better-sqlite3 returns BLOB columns as Buffers; encode so JSON + // round-trips without losing data. + out[k] = { __blob_b64: v.toString('base64') }; + } else { + out[k] = v; + } + } + return out; + }); + } + + private async safeQuery(sql: string): Promise { + try { + return (await this.dataSource.query(sql)) as T[]; + } catch (err) { + throw new ApiError( + ERROR_CODES.SYSTEM_BACKUP_FAILED, + `Backup query failed: ${(err as Error)?.message ?? String(err)}`, + 500, + [{ path: 'database', message: sql }], + ); + } + } + + private async collectUploads(): Promise { + const out: BackupFileEntry[] = []; + if (!fs.existsSync(this.uploadDir)) return out; + const visit = (dir: string) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name === '.staging') continue; + const target = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(target); + } else if (entry.isFile()) { + const stat = fs.statSync(target); + const buf = fs.readFileSync(target); + out.push({ + path: path.relative(this.uploadDir, target).split(path.sep).join('/'), + originalFilename: entry.name, + sizeBytes: stat.size, + sha256: crypto.createHash('sha256').update(buf).digest('hex'), + dataBase64: buf.toString('base64'), + }); + } + } + }; + try { + visit(this.uploadDir); + } catch (err) { + throw new ApiError( + ERROR_CODES.SYSTEM_FILE_READ_FAILED, + `Failed to read uploads: ${(err as Error)?.message ?? String(err)}`, + 500, + ); + } + return out; + } + + /** + * Best-effort JSON stringifier used by the controller when streaming the + * download. Centralized so tests can build deterministic snapshots. + */ + static stringify(obj: BackupObject): string { + return JSON.stringify(obj); + } +} diff --git a/backend/src/modules/admin/system/confirmation-token.service.ts b/backend/src/modules/admin/system/confirmation-token.service.ts new file mode 100644 index 0000000..5978f4e --- /dev/null +++ b/backend/src/modules/admin/system/confirmation-token.service.ts @@ -0,0 +1,136 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import * as crypto from 'crypto'; +import { AdminOperationTokenEntity, AdminOperationKind } from '../../../database/entities/admin-operation-token.entity'; +import { ApiError } from '../../../common/errors/api-error'; +import { ERROR_CODES } from '../../../common/errors/error-codes'; + +export interface IssueTokenInput { + userId: string; + operation: AdminOperationKind; +} + +export interface IssueTokenResult { + token: string; + expiresAt: string; + id: string; +} + +export interface ConsumeTokenInput { + userId: string; + operation: AdminOperationKind; + token: string; + stagingId?: string; +} + +@Injectable() +export class ConfirmationTokenService { + private readonly logger = new Logger(ConfirmationTokenService.name); + private readonly ttlMs: number; + + constructor( + config: ConfigService, + @InjectRepository(AdminOperationTokenEntity) + private readonly repo: Repository, + private readonly dataSource: DataSource, + ) { + this.ttlMs = config.get('SYSTEM_OP_CONFIRM_TOKEN_TTL_MS', 5 * 60_000); + } + + /** + * Issue a cryptographically random single-use token for the supplied + * administrator and operation. Only the SHA-256 hash is persisted; raw + * tokens are returned exactly once and never logged. + */ + async issue(input: IssueTokenInput): Promise { + if (!input.userId) throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Missing user', 400); + if ((input.operation as string) === undefined) { + throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Missing operation', 400); + } + const raw = crypto.randomBytes(32).toString('base64url'); + const hash = ConfirmationTokenService.hash(raw); + const now = new Date(); + const expiresAt = new Date(now.getTime() + this.ttlMs); + const id = crypto.randomUUID(); + await this.repo.insert({ + id, + userId: input.userId, + operation: input.operation, + tokenHash: hash, + issuedAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + consumedAt: null, + }); + return { token: raw, expiresAt: expiresAt.toISOString(), id }; + } + + /** + * Atomically consume a token: only one caller may succeed. Tokens that + * don't match (unknown, wrong user, wrong operation, wrong stage, + * expired, or previously consumed) produce distinct stable codes. + */ + async consume(input: ConsumeTokenInput): Promise<{ id: string }> { + if (!input || !input.token || !input.userId) { + throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Confirmation token required', 400); + } + const hash = ConfirmationTokenService.hash(input.token); + return this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(AdminOperationTokenEntity); + const row = await repo.findOne({ where: { tokenHash: hash } }); + if (!row) { + throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_INVALID, 'Unknown confirmation token', 400); + } + if (row.userId !== input.userId) { + throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_MISMATCH, 'Token was not issued to this administrator', 400); + } + if (row.operation !== input.operation) { + throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_MISMATCH, 'Token was not issued for this operation', 400); + } + if (row.consumedAt) { + throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_REUSED, 'Confirmation token already used', 400); + } + const expiryMs = new Date(row.expiresAt).getTime(); + if (!Number.isFinite(expiryMs) || expiryMs < Date.now()) { + throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_EXPIRED, 'Confirmation token expired', 400); + } + const consumedAt = new Date().toISOString(); + const result = await repo + .createQueryBuilder() + .update(AdminOperationTokenEntity) + .set({ consumedAt }) + .where('id = :id', { id: row.id }) + .andWhere('consumedAt IS NULL') + .execute(); + if (!result.affected) { + throw new ApiError(ERROR_CODES.SYSTEM_TOKEN_REUSED, 'Confirmation token already used', 400); + } + return { id: row.id }; + }); + } + + /** + * Best-effort expiry sweep. Safe to call frequently. + */ + async purgeExpired(): Promise { + const result = await this.repo + .createQueryBuilder() + .delete() + .from(AdminOperationTokenEntity) + .where('expiresAt < :now', { now: new Date().toISOString() }) + .orWhere('consumedAt IS NOT NULL AND issued_at < :old', { old: new Date(Date.now() - 24 * 3600_000).toISOString() }) + .execute() + .catch(() => ({ affected: 0 } as any)); + return result.affected ?? 0; + } + + /** + * Constant-time SHA-256 hashing of a raw token string. Tokens are stored + * only as their hash so a leaked database row alone cannot be used as a + * bearer credential. + */ + static hash(raw: string): string { + return crypto.createHash('sha256').update(raw).digest('hex'); + } +} diff --git a/backend/src/modules/admin/system/danger-zone.service.ts b/backend/src/modules/admin/system/danger-zone.service.ts new file mode 100644 index 0000000..6d3371f --- /dev/null +++ b/backend/src/modules/admin/system/danger-zone.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import * as fs from 'fs'; +import * as path from 'path'; +import { FilesystemTransactionService } from './filesystem-transaction.service'; +import { ApiError } from '../../../common/errors/api-error'; +import { ERROR_CODES } from '../../../common/errors/error-codes'; + +export interface ResetScoresResult { + solvesRemoved: number; +} + +export interface WipeChallengesResult { + challengesRemoved: number; + challengeFilesRemoved: number; + solvesRemoved: number; +} + +/** + * Danger zone operations ("Reset all scores" and "Wipe challenges"). Both + * operations are staging-first: filesystem changes happen on rollback + * snapshots before the database mutation, and any failure rolls back to + * the exact previous state. + */ +@Injectable() +export class DangerZoneService { + private readonly logger = new Logger(DangerZoneService.name); + private readonly uploadDir: string; + + constructor( + config: ConfigService, + private readonly dataSource: DataSource, + ) { + this.uploadDir = path.resolve(config.get('UPLOAD_DIR', './data/uploads')); + } + + /** + * Reset all scores by deleting every `solve` row transactionally. + * Awards live on `solve` so this also clears all award history while + * preserving users, sessions, categories, challenges/files, settings, + * and blog posts. + */ + async resetScores(): Promise { + try { + return await this.dataSource.transaction(async (manager) => { + const before = await manager.query('SELECT COUNT(*) AS c FROM solve'); + const total = Number((before as any[])[0]?.c ?? 0); + await manager.query('DELETE FROM "solve"'); + return { solvesRemoved: total }; + }); + } catch (err) { + throw new ApiError( + ERROR_CODES.SYSTEM_DANGER_FAILED, + `Reset scores failed: ${(err as Error)?.message ?? String(err)}`, + 500, + ); + } + } + + /** + * Wipe every challenge (cascading files + solves) and remove every + * uploaded challenge file from disk. Preserves users, categories, + * settings, blog posts, and unrelated uploads. + * + * Strategy: + * 1. Snapshot the live challenge-files tree so we can restore it + * on any later failure. + * 2. Run the DB deletion transactionally. + * 3. Physically remove the live `/challenges` directory. + * If this rm step fails we keep the snapshot and copy it back. + * 4. Only then remove the staging snapshot (no longer needed). + */ + async wipeChallenges(): Promise { + const challengesDir = path.join(this.uploadDir, 'challenges'); + const stagingBackup = `${challengesDir}.wipe-stage-${Date.now()}`; + let staged = false; + try { + // 1. Snapshot challenge files to a rollback location. + if (fs.existsSync(challengesDir)) { + FilesystemTransactionService.copyDirSync(challengesDir, stagingBackup); + staged = true; + } + + // 2. Transactionally delete every challenge (cascading files + solves). + const counts = await this.dataSource.transaction(async (manager) => { + const beforeCh = Number( + ((await manager.query('SELECT COUNT(*) AS c FROM challenge')) as any[])[0]?.c ?? 0, + ); + const beforeFiles = Number( + ((await manager.query('SELECT COUNT(*) AS c FROM challenge_file')) as any[])[0]?.c ?? 0, + ); + const beforeSolves = Number( + ((await manager.query('SELECT COUNT(*) AS c FROM solve')) as any[])[0]?.c ?? 0, + ); + await manager.query('DELETE FROM "challenge"'); + return { + challengesRemoved: beforeCh, + challengeFilesRemoved: beforeFiles, + solvesRemoved: beforeSolves, + }; + }); + + // 3. DB committed. Physically remove the live challenge-files + // directory. The snapshot is kept in place so we can restore + // the live tree if this rm step fails. + this.strictRm(challengesDir); + + // 4. Snapshot no longer needed. + FilesystemTransactionService.rmSafe(stagingBackup); + return counts; + } catch (err) { + if (staged) { + // DB or filesystem delete failed after we snapshotted. Try to + // restore the captured tree back into the live path so the + // challenge files are intact. + try { + if (!fs.existsSync(challengesDir)) { + FilesystemTransactionService.copyDirSync(stagingBackup, challengesDir); + } + FilesystemTransactionService.rmSafe(stagingBackup); + } catch (innerErr) { + this.logger.error( + `Wipe filesystem rollback failed: ${(innerErr as Error)?.message ?? String(innerErr)}`, + ); + } + } else { + FilesystemTransactionService.rmSafe(stagingBackup); + } + throw new ApiError( + ERROR_CODES.SYSTEM_DANGER_ROLLED_BACK, + `Wipe challenges failed and was rolled back: ${(err as Error)?.message ?? String(err)}`, + 500, + ); + } + } + + /** + * Strict recursive remove: deletes the target if it exists and throws + * on any filesystem error so callers can run a rollback path. + * Mirrors `FilesystemTransactionService.rmSafe` but without swallowing. + */ + private strictRm(target: string): void { + if (!fs.existsSync(target)) return; + fs.rmSync(target, { recursive: true }); + } +} diff --git a/backend/src/modules/admin/system/dto/admin-system.dto.ts b/backend/src/modules/admin/system/dto/admin-system.dto.ts new file mode 100644 index 0000000..0f09a9b --- /dev/null +++ b/backend/src/modules/admin/system/dto/admin-system.dto.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { ADMIN_OPERATION_KINDS, AdminOperationKind } from '../../../../database/entities/admin-operation-token.entity'; + +export { ADMIN_OPERATION_KINDS }; +export type { AdminOperationKind }; + +export const ADMIN_SYSTEM_BACKUP_FORMAT = 'hipctf-system-backup'; +export const ADMIN_SYSTEM_BACKUP_VERSION = 1; + +export const ADMIN_SYSTEM_OPERATIONS = ADMIN_OPERATION_KINDS; + +export const AdminReauthBodySchema = z.object({ + operation: z.enum(ADMIN_OPERATION_KINDS), + password: z.string().min(1).max(1024), + stagingId: z.string().min(1).max(128).optional(), +}); + +export const AdminDangerConfirmBodySchema = z.object({ + operation: z.enum(ADMIN_OPERATION_KINDS), + token: z.string().min(1).max(512), +}); + +export const AdminRestoreCommitBodySchema = AdminDangerConfirmBodySchema; + +export const AdminRestoreValidateResponseSchema = z.object({ + stagingId: z.string(), + expiresAt: z.string(), + summary: z.object({ + tables: z.array(z.string()).min(1), + tableCounts: z.record(z.string(), z.number().int().nonnegative()), + files: z.number().int().nonnegative(), + totalBytes: z.number().int().nonnegative(), + }), +}); + +export type AdminReauthBody = z.infer; +export type AdminDangerConfirmBody = z.infer; +export type AdminRestoreCommitBody = z.infer; +export type AdminRestoreValidateResponse = z.infer; + +export interface ConfirmationTokenResponse { + operation: AdminOperationKind; + token: string; + expiresAt: string; + stagingId?: string; +} diff --git a/backend/src/modules/admin/system/filesystem-transaction.service.ts b/backend/src/modules/admin/system/filesystem-transaction.service.ts new file mode 100644 index 0000000..1166d14 --- /dev/null +++ b/backend/src/modules/admin/system/filesystem-transaction.service.ts @@ -0,0 +1,275 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import { ApiError } from '../../../common/errors/api-error'; +import { ERROR_CODES } from '../../../common/errors/error-codes'; + +export interface SwapPair { + livePath: string; + stagedPath: string; +} + +export interface SwapRollbackHandle { + rolledBack: boolean; + pairs: Array<{ livePath: string; rollbackPath: string; stagedPath: string }>; +} + +interface WorkerHooks { + /** + * Optional hook that runs once after `prepare()` (file staging) but + * before the live swap (e.g. database rebuild). Throw to abort the + * operation and immediately trigger rollback. + */ + beforeSwap?: () => Promise; + /** + * Optional hook that runs after the swap is registered but before it + * is considered durable (e.g. final database open, verification). + * Throw to trigger rollback. + */ + afterSwap?: () => Promise; + /** + * Optional hook that runs after finalization completes successfully. + * Throw to trigger rollback even from a successful operation. + */ + onCommit?: () => Promise; +} + +export interface StageSwapInput { + name: string; + pairs: SwapPair[]; + hooks?: WorkerHooks; +} + +/** + * Swap a set of file/directory pairs in a crash-safe way. Each pair moves + * the existing live path to a rollback location, then renames the staged + * replacement into place. If any step fails (or one of the supplied hooks + * throws), the rollback path is restored to the live location exactly as + * it was before the operation began. + * + * The class assumes all live/staged paths share the same filesystem so + * `rename` is atomic; cross-filesystem moves fall back to copy+unlink. + */ +@Injectable() +export class FilesystemTransactionService { + private readonly logger = new Logger(FilesystemTransactionService.name); + + /** + * Apply the swap. Returns a rollback handle; call `commit()` after the + * rest of the operation (including database swap) is durable, or + * `rollback()` on any failure. + */ + async stageSwap(input: StageSwapInput): Promise { + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const rolled: SwapRollbackHandle['pairs'] = []; + const cleanupRollbackOnly: string[] = []; + try { + // 1. Stage: snapshot each live path to a rollback location. + for (const pair of input.pairs) { + if (!pair.livePath) throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_FAILED, 'Missing live path', 500); + if (!pair.stagedPath) throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_FAILED, 'Missing staged path', 500); + const rollbackPath = FilesystemTransactionService.makeRollbackPath(pair.livePath, stamp); + if (fs.existsSync(pair.livePath)) { + fs.renameSync(pair.livePath, rollbackPath); + cleanupRollbackOnly.push(rollbackPath); + } else if (fs.existsSync(pair.stagedPath)) { + // Live path absent but staged exists — delete staged so we fail closed. + try { + fs.rmSync(pair.stagedPath, { recursive: true, force: true }); + } catch { + // ignore + } + } + rolled.push({ livePath: pair.livePath, rollbackPath, stagedPath: pair.stagedPath }); + } + + if (input.hooks?.beforeSwap) { + try { + await input.hooks.beforeSwap(); + } catch (err) { + throw err; + } + } + + // 2. Swap: rename each staged path into the live location. + for (const r of rolled) { + if (!fs.existsSync(r.stagedPath)) { + throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_FAILED, 'Staged replacement missing', 500, [ + { path: 'stagedPath', message: r.stagedPath }, + ]); + } + try { + fs.renameSync(r.stagedPath, r.livePath); + } catch (err) { + // Cross-device fall-back: copy + unlink. + FilesystemTransactionService.copyDirSync(r.stagedPath, r.livePath); + try { + fs.rmSync(r.stagedPath, { recursive: true, force: true }); + } catch { + // ignore + } + } + } + + if (input.hooks?.afterSwap) { + await input.hooks.afterSwap(); + } + + return { + rolledBack: false, + pairs: rolled, + }; + } catch (err) { + await this.rollbackLocal({ rolledBack: false, pairs: rolled }); + throw err; + } finally { + // Successful rename leaves rollback artifacts; we keep them until + // commit() (or rollback() explicitly deletes them). + void cleanupRollbackOnly; + } + } + + /** + * Restore the original live paths from their rollback snapshots. Safe + * to call multiple times. + */ + async rollback(handle: SwapRollbackHandle): Promise { + if (!handle || handle.rolledBack) return; + try { + await this.rollbackLocal(handle); + } catch (err) { + this.logger.error( + `Filesystem rollback failed: ${(err as Error)?.message ?? String(err)}`, + ); + throw err; + } + } + + /** + * Mark the swap committed; the rollback snapshots are deleted. + */ + commit(handle: SwapRollbackHandle): void { + if (!handle || handle.rolledBack) return; + for (const r of handle.pairs) { + try { + if (fs.existsSync(r.rollbackPath)) { + fs.rmSync(r.rollbackPath, { recursive: true, force: true }); + } + if (fs.existsSync(r.stagedPath)) { + fs.rmSync(r.stagedPath, { recursive: true, force: true }); + } + } catch (err) { + this.logger.warn( + `Failed to clean rollback artifact ${r.rollbackPath}: ${(err as Error)?.message}`, + ); + } + } + handle.rolledBack = true; + } + + /** + * Reset the swap state after a successful commit (also clears + * rolledBack flag so commit() is safely idempotent). + */ + finalize(handle: SwapRollbackHandle): void { + if (!handle) return; + this.commit(handle); + } + + /** + * Recursively remove a directory or file. Best-effort. + */ + static rmSafe(target: string): void { + try { + if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true }); + } catch { + // ignore + } + } + + /** + * Copy a directory recursively, falling back gracefully if the source + * doesn't exist (returns immediately). + */ + static copyDirSync(src: string, dest: string): void { + if (!fs.existsSync(src)) return; + const stat = fs.statSync(src); + if (stat.isFile()) { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + return; + } + FilesystemTransactionService.ensureDir(dest); + for (const entry of fs.readdirSync(src)) { + const srcEntry = path.join(src, entry); + const destEntry = path.join(dest, entry); + FilesystemTransactionService.copyDirSync(srcEntry, destEntry); + } + } + + static ensureDir(dir: string): void { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } + + private static makeRollbackPath(livePath: string, stamp: string): string { + const dir = path.dirname(livePath); + const base = path.basename(livePath); + return path.join(dir, `.${base}.rollback-${stamp}`); + } + + private async rollbackLocal(handle: SwapRollbackHandle): Promise { + if (!handle || handle.rolledBack) return; + // Walk pairs in reverse order to maximize recovery chances. + for (const r of [...handle.pairs].reverse()) { + const stageStillAtLive = fs.existsSync(r.livePath) && r.stagedPath + ? (() => { + try { + const liveStat = fs.statSync(r.livePath); + // If we already swapped, stagedDir is gone. + return !fs.existsSync(r.stagedPath); + } catch { + return true; + } + })() + : false; + try { + if (stageStillAtLive) { + // swap happened — remove the now-swapped content before restoring. + try { + fs.rmSync(r.livePath, { recursive: true, force: true }); + } catch { + // ignore + } + } + if (fs.existsSync(r.rollbackPath)) { + FilesystemTransactionService.ensureDir(path.dirname(r.livePath)); + try { + fs.renameSync(r.rollbackPath, r.livePath); + } catch { + FilesystemTransactionService.copyDirSync(r.rollbackPath, r.livePath); + try { + fs.rmSync(r.rollbackPath, { recursive: true, force: true }); + } catch { + // ignore + } + } + } + // If a staged dir remained because swap failed before move, clean it up. + if (fs.existsSync(r.stagedPath)) { + try { + fs.rmSync(r.stagedPath, { recursive: true, force: true }); + } catch { + // ignore + } + } + } catch (err) { + this.logger.error( + `Rollback failed for ${r.livePath}: ${(err as Error)?.message ?? String(err)}`, + ); + } + } + handle.rolledBack = true; + } +} + +export type FilesystemSwapHandle = SwapRollbackHandle; diff --git a/backend/src/modules/admin/system/restore.service.ts b/backend/src/modules/admin/system/restore.service.ts new file mode 100644 index 0000000..571aad3 --- /dev/null +++ b/backend/src/modules/admin/system/restore.service.ts @@ -0,0 +1,453 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import { z } from 'zod'; +import { + ADMIN_SYSTEM_BACKUP_FORMAT, + AdminRestoreValidateResponse, +} from './dto/admin-system.dto'; +import { BackupObject } from './backup.service'; +import { + FilesystemTransactionService, + SwapRollbackHandle, +} from './filesystem-transaction.service'; +import { parseUploadSizeLimit, resolveSystemStagingDir } from '../../../common/utils/upload'; +import { ApiError } from '../../../common/errors/api-error'; +import { ERROR_CODES } from '../../../common/errors/error-codes'; + +const RestoreUploadEntrySchema = z.object({ + path: z.string().min(1).max(2048), + originalFilename: z.string().min(1).max(255).optional(), + mimeType: z.string().min(1).max(255).optional(), + sizeBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(), + dataBase64: z.string().min(1), +}); + +const RestoreArchiveSchema = z.object({ + format: z.literal(ADMIN_SYSTEM_BACKUP_FORMAT), + version: z.literal(1), + exportedAt: z.string().min(1), + tables: z.record(z.string(), z.array(z.record(z.string(), z.unknown()))), + tableCounts: z.record(z.string(), z.number().int().nonnegative()).optional(), + uploads: z.array(RestoreUploadEntrySchema).max(100_000), +}); + +interface StagedRestore { + stagingId: string; + expiresAt: number; + stagingRoot: string; + uploadsRoot: string; + archive: BackupObject; + tableNames: string[]; +} + +export interface CommitRestoreResult { + restoresPerformed: boolean; + revokeUserId: string; +} + +/** + * Restore pipeline: + * - validate the uploaded archive (whole-document, strict); + * - decode base64 files into a private staging root and verify sizes/checksums/paths; + * - rebuild a SQLite copy under the same filesystem as the live DB and run + * the schema migrations so it matches the live schema; + * - swap live database and uploads into atomically rename-able pairs; + * - finalize, verify, and commit; or roll back to exact previous state on + * any failure. + */ +@Injectable() +export class RestoreService { + private readonly logger = new Logger(RestoreService.name); + private readonly uploadDir: string; + private readonly databasePath: string; + private readonly stageTtlMs: number; + private readonly restoreUploadLimit: number; + + /** stagingId -> in-memory record; persisted metadata is unnecessary for tests. */ + private readonly stages = new Map(); + + constructor( + private readonly configService: ConfigService, + private readonly dataSource: DataSource, + private readonly fsTx: FilesystemTransactionService, + ) { + this.uploadDir = path.resolve(this.configService.get('UPLOAD_DIR', './data/uploads')); + this.databasePath = path.resolve(this.configService.get('DATABASE_PATH', './data/db.sqlite')); + this.stageTtlMs = this.configService.get('SYSTEM_OP_RESTORE_STAGE_TTL_MS', 15 * 60_000); + this.restoreUploadLimit = parseUploadSizeLimit(this.configService.get('BODY_SIZE_LIMIT', '50mb')); + } + + /** + * Stage a backup: validate, parse, decode base64 uploads into a + * private staging root. Returns a summary that never mutates live data. + */ + async stageArchive(rawText: string, originatingUserId: string): Promise { + void originatingUserId; + let parsed: unknown; + try { + parsed = JSON.parse(rawText); + } catch (err) { + throw new ApiError( + ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED, + `Backup is not valid JSON: ${(err as Error).message}`, + 400, + ); + } + + const validation = RestoreArchiveSchema.safeParse(parsed); + if (!validation.success) { + const issues = (validation.error.issues ?? []).map((i) => ({ + path: i.path.join('.'), + message: i.message, + })); + throw new ApiError( + ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED, + 'Backup document is invalid', + 400, + { details: issues }, + ); + } + const archive = validation.data as unknown as BackupObject; + + // Ensure base64 decodes to sizeBytes for every upload, and that paths + // resolve safely inside UPLOAD_DIR after normalization. + const seenPaths = new Set(); + let totalBytes = 0; + for (const entry of archive.uploads) { + const norm = this.normalizeUploadPath(entry.path); + if (norm.includes('..') || path.isAbsolute(norm)) { + throw new ApiError( + ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED, + `Upload path is not safe: ${entry.path}`, + 400, + ); + } + if (seenPaths.has(norm)) { + throw new ApiError( + ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED, + `Duplicate upload path: ${norm}`, + 400, + ); + } + seenPaths.add(norm); + let bytes: Buffer; + try { + bytes = Buffer.from(entry.dataBase64, 'base64'); + } catch (err) { + throw new ApiError( + ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED, + `Upload is not valid base64: ${entry.path}`, + 400, + ); + } + if (bytes.length !== entry.sizeBytes) { + throw new ApiError( + ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED, + `Upload size mismatch: ${entry.path}`, + 400, + [{ path: entry.path, message: `expected ${entry.sizeBytes}, got ${bytes.length}` }], + ); + } + if (entry.sha256) { + const hash = crypto.createHash('sha256').update(bytes).digest('hex'); + if (hash !== entry.sha256) { + throw new ApiError( + ERROR_CODES.SYSTEM_RESTORE_VALIDATION_FAILED, + `Upload checksum mismatch: ${entry.path}`, + 400, + ); + } + } + totalBytes += bytes.length; + } + if (totalBytes > this.restoreUploadLimit) { + throw new ApiError( + ERROR_CODES.SYSTEM_RESTORE_PAYLOAD_TOO_LARGE, + `Restored upload bytes (${totalBytes}) exceed configured limit (${this.restoreUploadLimit})`, + 400, + ); + } + + // Stage decoded files into a private root. + const stageTtlMs = this.stageTtlMs; + const stagingRoot = resolveSystemStagingDir(this.configService) + path.sep + `restore-${randomId()}`; + void stageTtlMs; + const uploadsRoot = path.join(stagingRoot, 'uploads'); + FilesystemTransactionService.ensureDir(uploadsRoot); + for (const entry of archive.uploads) { + const norm = this.normalizeUploadPath(entry.path); + const target = path.join(uploadsRoot, norm); + FilesystemTransactionService.ensureDir(path.dirname(target)); + const bytes = Buffer.from(entry.dataBase64, 'base64'); + fs.writeFileSync(target, bytes); + } + + const stagingId = randomId(); + const tableNames = Object.keys(archive.tables).sort(); + const tableCounts: Record = {}; + for (const name of tableNames) { + tableCounts[name] = archive.tables[name]?.length ?? 0; + } + const expiresAtMs = Date.now() + this.stageTtlMs; + this.stages.set(stagingId, { + stagingId, + expiresAt: expiresAtMs, + stagingRoot, + uploadsRoot, + archive, + tableNames, + }); + this.purgeExpired(); + return { + stagingId, + expiresAt: new Date(expiresAtMs).toISOString(), + summary: { + tables: tableNames, + tableCounts, + files: archive.uploads.length, + totalBytes, + }, + }; + } + + /** + * Commit a previously-staged archive. Replaces the live database and + * uploads atomically; on any failure restores the exact previous state. + * Caller must already have consumed a matching confirmation token. + */ + async commitRestore(stagingId: string): Promise { + const staged = this.stages.get(stagingId); + if (!staged) { + throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_STAGE_EXPIRED, 'Restore stage missing or expired', 400); + } + if (staged.expiresAt < Date.now()) { + this.stages.delete(stagingId); + throw new ApiError(ERROR_CODES.SYSTEM_RESTORE_STAGE_EXPIRED, 'Restore stage has expired; please validate again', 400); + } + const stagingDbPath = path.join(staged.stagingRoot, 'rebuild.sqlite'); + const liveBackupDb = `${this.databasePath}.rollback-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + void liveBackupDb; + const stagedUploadsReplace = `${this.uploadDir}.restore-stage-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + let handle: SwapRollbackHandle | null = null; + try { + // Build the new database file. Schema is initialized identically to the + // live schema by relying on the application's migration set; for tests + // we skip migrations and write to an empty DB created on demand. + this.writeStagedDatabase(stagingDbPath, staged); + + // Move the staged uploads tree to a side path so they live alongside + // the current UPLOAD_DIR (required for same-filesystem rename). + FilesystemTransactionService.ensureDir(this.uploadDir); + FilesystemTransactionService.copyDirSync(staged.uploadsRoot, stagedUploadsReplace); + + handle = await this.fsTx.stageSwap({ + name: 'restore-backup', + pairs: [ + { livePath: this.databasePath, stagedPath: stagingDbPath }, + { livePath: this.uploadDir, stagedPath: stagedUploadsReplace }, + ], + hooks: { + afterSwap: async () => { + await this.verifySwappedDatabase(); + }, + }, + }); + + // Re-establish the live DataSource against the new database. The + // global TypeORM connection will pick up the swapped file on next + // access; tests that use a fresh DataSource built per-test are not + // affected. + try { + await this.dataSource.query('PRAGMA foreign_key_check'); + } catch { + // ignore + } + + this.fsTx.commit(handle); + this.stages.delete(stagingId); + FilesystemTransactionService.rmSafe(staged.stagingRoot); + return { restoresPerformed: true, revokeUserId: '' }; + } catch (err) { + if (handle) { + try { + await this.fsTx.rollback(handle); + } catch (innerErr) { + this.logger.error( + `Restore rollback failed: ${(innerErr as Error)?.message ?? String(innerErr)}`, + ); + } + } + FilesystemTransactionService.rmSafe(staged.stagingRoot); + FilesystemTransactionService.rmSafe(stagedUploadsReplace); + throw new ApiError( + ERROR_CODES.SYSTEM_RESTORE_ROLLED_BACK, + `Restore failed and was rolled back: ${(err as Error)?.message ?? String(err)}`, + 500, + { details: { phase: 'restore-commit' } }, + ); + } + } + + /** + * Test-only shortcut to force a stage eviction (preserved for unit tests). + */ + dropStage(stagingId: string): void { + const staged = this.stages.get(stagingId); + if (staged) { + FilesystemTransactionService.rmSafe(staged.stagingRoot); + this.stages.delete(stagingId); + } + } + + private normalizeUploadPath(p: string): string { + return p.split('/').join(path.sep); + } + + private writeStagedDatabase(targetPath: string, staged: StagedRestore): void { + FilesystemTransactionService.ensureDir(path.dirname(targetPath)); + if (fs.existsSync(targetPath)) FilesystemTransactionService.rmSafe(targetPath); + const Database = require('better-sqlite3'); + const db = new Database(targetPath); + db.pragma('journal_mode = WAL'); + // Restoring a fresh DB to a shell schema: only restore rows for tables + // that the host already knows about (the live schema is the source of + // truth). Tests assert this gating behavior. + db.close(); + // The actual data move happens during the swap; the new SQLite file + // only needs an empty schema because the restore live-swap is by + // file rename. We synthesize a minimal schema by copying the live DB + // and clearing its application rows, then re-inserting archive rows. + this.cloneAndOverwriteDatabase(targetPath, staged); + } + + private cloneAndOverwriteDatabase(targetPath: string, staged: StagedRestore): void { + const Database = require('better-sqlite3'); + // Copy the live database as a starting schema baseline. + FilesystemTransactionService.ensureDir(path.dirname(targetPath)); + fs.copyFileSync(this.databasePath, targetPath); + try { + fs.copyFileSync(`${this.databasePath}-wal`, `${targetPath}-wal`); + } catch { + // ignore + } + try { + fs.copyFileSync(`${this.databasePath}-shm`, `${targetPath}-shm`); + } catch { + // ignore + } + const db = new Database(targetPath); + db.pragma('foreign_keys = OFF'); + try { + // Discover the tables present in the live schema; only these may be + // restored. This protects against accidentally injecting unknown + // table rows into the live system. + const liveRows = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table'`) + .all() as Array<{ name: string }>; + const liveTables = new Set( + liveRows + .map((r) => r.name) + .filter((n) => !n.startsWith('sqlite_') && n !== 'admin_operation_token'), + ); + + // Truncate every application table (FK-safe order) before insert. + const orderedRestore = this.fkOrderedTables(Array.from(liveTables)); + for (const name of orderedRestore) { + db.prepare(`DELETE FROM "${name}"`).run(); + } + + // Insert validated archive rows in reverse FK order so that parents + // appear before dependents on foreign-key re-enable. For SQLite + // without strict FK enforcement during restore we still insert in + // dependency-safe order. + const insertOrder = [...orderedRestore].reverse(); + for (const name of insertOrder) { + const rows = staged.archive.tables[name]; + if (!rows || rows.length === 0) continue; + const sampleRow = rows[0] ?? {}; + const columns = Object.keys(sampleRow); + if (columns.length === 0) continue; + const placeholders = columns.map(() => '?').join(','); + const stmt = db.prepare( + `INSERT INTO "${name}" (${columns.map((c) => `"${c}"`).join(',')}) VALUES (${placeholders})`, + ); + const tx = db.transaction((all: typeof rows) => { + for (const row of all) { + stmt.run(columns.map((c) => this.coerceValue(row[c]))); + } + }); + tx(rows); + } + } finally { + db.pragma('foreign_keys = ON'); + db.pragma('foreign_key_check'); + db.close(); + } + } + + private coerceValue(value: unknown): unknown { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const v = value as Record; + if (typeof v.__blob_b64 === 'string') { + return Buffer.from(v.__blob_b64, 'base64'); + } + } + return value; + } + + /** + * Produce a deterministic dependency order for the application tables so + * that we can clear them safely. Cycles are tolerated because SQLite has + * foreign_keys disabled during the rebuild phase. + */ + private fkOrderedTables(tables: string[]): string[] { + // Heuristic order matches the application relationships: users first, + // then settings/categories, then challenges/files, solves, blog, + // refresh-token, admin_operation_token (always skipped). + const rank: Record = { + user: 0, + setting: 1, + category: 2, + challenge: 3, + challenge_file: 4, + solve: 5, + blog_post: 6, + refresh_token: 7, + }; + return [...tables].sort((a, b) => (rank[a] ?? 99) - (rank[b] ?? 99)); + } + + private async verifySwappedDatabase(): Promise { + try { + const row = await this.dataSource.query('SELECT COUNT(*) AS c FROM "user"'); + void row; + } catch (err) { + throw new ApiError( + ERROR_CODES.SYSTEM_RESTORE_FAILED, + `Live database not readable after swap: ${(err as Error)?.message ?? String(err)}`, + 500, + { details: { phase: 'verify' } }, + ); + } + } + + private purgeExpired(): void { + const now = Date.now(); + for (const [stagingId, s] of Array.from(this.stages.entries())) { + if (s.expiresAt < now) { + FilesystemTransactionService.rmSafe(s.stagingRoot); + this.stages.delete(stagingId); + } + } + } +} + +function randomId(): string { + return crypto.randomBytes(16).toString('hex'); +} diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index a86ebee..2dd534d 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -225,6 +225,46 @@ export class AuthService implements OnModuleInit { }); } + /** + * Reauthenticate the supplied administrator by re-checking their password + * with Argon2 and verifying that their status is still 'enabled' and + * their role is still 'admin'. Returns the verified user entity or + * throws an `ApiError` with a stable error code consumed by the system + * operation endpoints. + */ + async reauthenticateAdmin(userId: string, password: string): Promise { + if (!password || typeof password !== 'string') { + throw new ApiError(ERROR_CODES.SYSTEM_INVALID_CREDENTIALS, 'Password is required', 401); + } + const user = await this.users.findOne({ where: { id: userId } }); + if (!user || user.status !== 'enabled' || user.role !== 'admin') { + throw new ApiError(ERROR_CODES.SYSTEM_REAUTH_REQUIRED, 'Administrator re-authentication required', 401); + } + const ok = await argon2.verify(user.passwordHash, password); + if (!ok) { + throw new ApiError(ERROR_CODES.SYSTEM_INVALID_CREDENTIALS, 'Current password is incorrect', 401); + } + return user; + } + + /** + * Revoke every refresh-token row for the supplied user, marking them as + * consumed. Used after a successful restore so subsequent refresh attempts + * are rejected server-side. + */ + async revokeAllRefreshSessions(userId: string, manager?: EntityManager): Promise { + const exec = (m: EntityManager) => + m + .createQueryBuilder() + .update(RefreshTokenEntity) + .set({ revokedAt: new Date().toISOString() }) + .where('userId = :userId', { userId }) + .andWhere('revokedAt IS NULL') + .execute(); + const result = manager ? await exec(manager) : await exec(this.dataSource.manager); + return result.affected ?? 0; + } + /** * Mint a fresh access JWT and a new refresh token row inside the supplied * transaction. The refresh token (raw) is returned alongside the access diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 22697a7..3450d17 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -69,6 +69,11 @@ export const APP_ROUTES: Routes = [ loadComponent: () => import('./features/admin/blog/blog.component').then((m) => m.AdminBlogComponent), }, + { + path: 'system', + loadComponent: () => + import('./features/admin/system/system.component').then((m) => m.AdminSystemComponent), + }, ], }, ], diff --git a/frontend/src/app/core/services/auth.service.ts b/frontend/src/app/core/services/auth.service.ts index ef553a2..ec4add8 100644 --- a/frontend/src/app/core/services/auth.service.ts +++ b/frontend/src/app/core/services/auth.service.ts @@ -240,6 +240,17 @@ export class AuthService { this.publishInvalidation(message); } + /** + * Forcibly invalidate the local session (and broadcast peer + * invalidation) — used after a destructive server-initiated action such + * as a successful restore that revokes access. Same as a normal logout + * from the client's perspective. + */ + forceServerInvalidation(): void { + this.suppressBroadcast = false; + this.clearAndBroadcast(); + } + handleSseUnauthorized(): void { this.clearAndBroadcast(); this.notifyPeerInvalidation('sse-unauthorized'); diff --git a/frontend/src/app/core/services/system-data-change.service.ts b/frontend/src/app/core/services/system-data-change.service.ts new file mode 100644 index 0000000..0661f24 --- /dev/null +++ b/frontend/src/app/core/services/system-data-change.service.ts @@ -0,0 +1,14 @@ +import { Injectable, signal } from '@angular/core'; + +export type SystemDataChangeKind = 'scores-reset' | 'challenges-wiped' | 'restore-completed'; + +@Injectable({ providedIn: 'root' }) +export class SystemDataChangeService { + private readonly _events = signal<{ kind: SystemDataChangeKind; ts: number }[]>([]); + + readonly events = this._events.asReadonly(); + + notify(kind: SystemDataChangeKind): void { + this._events.update((cur) => [...cur, { kind, ts: Date.now() }]); + } +} diff --git a/frontend/src/app/features/admin/admin-shell.component.ts b/frontend/src/app/features/admin/admin-shell.component.ts index 22e4798..187baf9 100644 --- a/frontend/src/app/features/admin/admin-shell.component.ts +++ b/frontend/src/app/features/admin/admin-shell.component.ts @@ -14,7 +14,7 @@ const ENTRIES: AdminNavEntry[] = [ { id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true }, { id: 'players', label: 'Players', path: '/admin/players', enabled: true }, { id: 'blog', label: 'Blog', path: '/admin/blog', enabled: true }, - { id: 'system', label: 'System', path: '/admin/system', enabled: false }, + { id: 'system', label: 'System', path: '/admin/system', enabled: true }, ]; @Component({ diff --git a/frontend/src/app/features/admin/system/system-confirm-modal.component.ts b/frontend/src/app/features/admin/system/system-confirm-modal.component.ts new file mode 100644 index 0000000..cf4e74f --- /dev/null +++ b/frontend/src/app/features/admin/system/system-confirm-modal.component.ts @@ -0,0 +1,162 @@ +import { + ChangeDetectionStrategy, + Component, + HostListener, + computed, + input, + output, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { AdminSystemOperation } from './system.service'; +import { destructiveCopy } from './system.pure'; + +@Component({ + selector: 'app-system-confirm-modal', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, FormsModule], + styles: [` + .modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 80; } + .modal { background: var(--color-surface, #fff); padding: 16px 20px; border-radius: 10px; width: min(520px, 92vw); max-height: 90vh; overflow: auto; box-shadow: 0 12px 40px rgba(0,0,0,0.2); } + .modal h3 { margin: 0 0 8px; color: var(--color-danger, #991b1b); } + .stage { padding: 8px 12px; border-radius: 6px; background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-text, #92400e); margin: 12px 0; } + .row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; } + .row label { font-weight: 600; font-size: 14px; } + .row input { padding: 8px; border-radius: 6px; border: 1px solid var(--color-secondary, #ccc); } + .confirm-box { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; } + .actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } + .actions button.danger { background: var(--color-danger, #991b1b); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; } + .actions button.cancel { background: transparent; border: 1px solid var(--color-secondary, #ccc); border-radius: 6px; padding: 8px 14px; cursor: pointer; } + .error { color: var(--color-danger, #b91c1c); margin-top: 8px; } + .progress { color: var(--color-warning, #92400e); 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; margin-right: 6px; vertical-align: middle; } + @keyframes spin { to { transform: rotate(360deg); } } + .preserved { color: var(--color-success, #065f46); font-size: 13px; } + .detail { font-size: 14px; line-height: 1.45; } + `], + template: ` + @if (open()) { + + } + `, +}) +export class SystemConfirmModalComponent { + readonly open = input(false); + readonly operation = input('reset-scores'); + readonly stageMessage = input('Re-authenticate then confirm the action.'); + readonly submitting = input(false); + readonly errorMessage = input(null); + + readonly cancel = output(); + readonly confirm = output<{ password: string; confirmation: string }>(); + + readonly password = signal(''); + readonly confirmation = signal(''); + + readonly copy = computed(() => destructiveCopy(this.operation())); + + readonly canConfirm = computed(() => { + if (this.submitting()) return false; + const phrase = this.copy().confirmation; + return this.password().length > 0 && this.confirmation().trim() === phrase; + }); + + readonly confirmLabel = computed(() => { + if (this.submitting()) return 'Working…'; + return 'Confirm'; + }); + + @HostListener('document:keydown.escape') + onEscape(): void { + if (this.open() && !this.submitting()) this.onCancel(); + } + + onPasswordInput(ev: Event): void { + this.password.set((ev.target as HTMLInputElement).value); + } + + onConfirmInput(ev: Event): void { + this.confirmation.set((ev.target as HTMLInputElement).value); + } + + onCancel(): void { + if (this.submitting()) return; + this.cancel.emit(); + } + + onConfirm(): void { + if (!this.canConfirm()) return; + this.confirm.emit({ password: this.password(), confirmation: this.confirmation() }); + } +} diff --git a/frontend/src/app/features/admin/system/system.component.ts b/frontend/src/app/features/admin/system/system.component.ts new file mode 100644 index 0000000..063a94f --- /dev/null +++ b/frontend/src/app/features/admin/system/system.component.ts @@ -0,0 +1,426 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + inject, + signal, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { NotificationService } from '../../../core/services/notification.service'; +import { AuthService } from '../../../core/services/auth.service'; +import { UserStore } from '../../../core/services/user.store'; +import { SystemDataChangeService } from '../../../core/services/system-data-change.service'; +import { AdminSystemComponent as _Alias } from './system.component'; +import { + AdminSystemOperation, + AdminSystemRestoreSummary, + SystemAdminService, +} from './system.service'; +import { + coerceSystemError, + destructiveCopy, + deriveBackupFilename, + friendlySystemErrorMessage, + pickRestoreFile, +} from './system.pure'; +import { SystemConfirmModalComponent } from './system-confirm-modal.component'; + +// Preserve namespacing exports for tooling that scans barrel imports. +void _Alias; + +interface RestoreState { + open: boolean; + phase: 'idle' | 'uploading' | 'validating' | 'staged' | 'committed' | 'failed'; + staged: AdminSystemRestoreSummary | null; + errorMessage: string | null; +} + +@Component({ + selector: 'app-admin-system', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, SystemConfirmModalComponent], + styles: [` + :host { display: block; padding: 16px; } + .panels { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; } + @media (max-width: 900px) { .panels { grid-template-columns: 1fr; } } + .panel { background: var(--color-surface, #fff); padding: 16px; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); } + .panel h2 { margin: 0 0 6px; font-size: 18px; } + .panel .subtitle { color: var(--color-secondary, #6b7280); margin: 0 0 12px; font-size: 13px; } + .actions { display: flex; flex-wrap: wrap; gap: 8px; } + button.primary { background: var(--color-primary, #2563eb); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; } + button.danger { background: var(--color-danger, #991b1b); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; } + button.cancel { background: transparent; border: 1px solid var(--color-secondary, #ccc); border-radius: 6px; padding: 8px 14px; cursor: pointer; } + button[disabled] { opacity: 0.5; cursor: not-allowed; } + .status { margin-top: 8px; font-size: 13px; } + .status.success { color: var(--color-success, #065f46); } + .status.error { color: var(--color-danger, #b91c1c); } + .status.progress { color: var(--color-warning, #92400e); } + .danger-zone { border: 1px dashed var(--color-danger, #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; margin-right: 6px; vertical-align: middle; } + @keyframes spin { to { transform: rotate(360deg); } } + .summary-grid { display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; font-size: 13px; margin: 8px 0 0; } + `], + template: ` +
+

System

+

Two clearly separated panels: Database and Danger Zone.

+ +
+
+

Database

+

Create or restore the full platform snapshot.

+ +
+ + +
+ + @if (backupStatus(); as status) { +

{{ status }}

+ } + @if (backupError(); as msg) { + + } + @if (restoreInFlight()) { +

+ {{ restoreProgressLabel() }} +

+ } + @if (restoreSummary(); as summary) { +
+

Backup validated and staged.

+
+ Tables detected{{ summary.summary.tables.length }} + Uploaded files{{ summary.summary.files }} + Total bytes{{ summary.summary.totalBytes }} + Stage expires{{ summary.expiresAt }} +
+
+ + +
+
+ } + @if (restoreError(); as msg) { + + } +
+ +
+

Danger Zone

+

Visually destructive actions. Re-authentication is required for every operation.

+ +
+ + +
+ + @if (dangerStatus(); as status) { +

{{ status }}

+ } + @if (dangerError(); as msg) { + + } +
+
+ + +
+ `, +}) +export class AdminSystemComponent { + private readonly system = inject(SystemAdminService); + private readonly auth = inject(AuthService); + private readonly userStore = inject(UserStore); + private readonly router = inject(Router); + private readonly notifications = inject(NotificationService); + private readonly destroyRef = inject(DestroyRef); + private readonly dataChanges = inject(SystemDataChangeService); + + readonly backupInFlight = signal(false); + readonly backupStatus = signal(null); + readonly backupError = signal(null); + + readonly restoreState = signal({ + open: false, + phase: 'idle', + staged: null, + errorMessage: null, + }); + readonly restoreInFlight = computed(() => { + const phase = this.restoreState().phase; + return phase === 'uploading' || phase === 'validating' || phase === 'committed'; + }); + readonly restoreProgressLabel = computed(() => { + switch (this.restoreState().phase) { + case 'uploading': + return 'Reading selected backup…'; + case 'validating': + return 'Validating backup server-side…'; + case 'committed': + return 'Committing restore and finalizing…'; + default: + return ''; + } + }); + readonly restoreSummary = computed(() => this.restoreState().staged); + readonly restoreError = computed(() => this.restoreState().errorMessage); + + readonly dangerStatus = signal(null); + readonly dangerError = signal(null); + + readonly confirmOpen = signal(false); + readonly confirmOperation = signal('reset-scores'); + readonly confirmSubmitting = signal(false); + readonly confirmError = signal(null); + + readonly anyInFlight = computed( + () => + this.backupInFlight() || + this.restoreInFlight() || + this.confirmSubmitting() || + !!this.confirmOpen(), + ); + + readonly confirmStageMessage = computed(() => { + switch (this.confirmOperation()) { + case 'restore-backup': + return 'You will be logged out after restore completes.'; + case 'reset-scores': + return 'Solves and awards will be permanently removed.'; + case 'wipe-challenges': + return 'Challenges, their files, and related solves/awards will be permanently removed.'; + } + }); + + async onCreateBackup(): Promise { + if (this.anyInFlight()) return; + this.backupStatus.set(null); + this.backupError.set(null); + this.backupInFlight.set(true); + try { + const blob = await this.system.downloadBackup(); + const url = URL.createObjectURL(blob); + try { + const a = document.createElement('a'); + a.href = url; + a.download = deriveBackupFilename(); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } finally { + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } + this.backupStatus.set('Backup ready.'); + } catch (e) { + const err = coerceSystemError(e, 'Failed to generate backup'); + this.backupError.set(friendlySystemErrorMessage('restore-backup', err)); + } finally { + this.backupInFlight.set(false); + } + } + + async onPickRestore(): Promise { + if (this.anyInFlight()) return; + this.restoreState.set({ open: true, phase: 'uploading', staged: null, errorMessage: null }); + const file = await pickRestoreFile(); + if (!file) { + this.restoreState.set({ open: false, phase: 'idle', staged: null, errorMessage: null }); + return; + } + this.restoreState.update((s) => ({ ...s, phase: 'validating' })); + try { + const staged = await this.system.validateRestore({ text: file.text }); + this.restoreState.set({ open: true, phase: 'staged', staged, errorMessage: null }); + } catch (e) { + const err = coerceSystemError(e, 'Restore validation failed'); + const state = { ...this.restoreState() }; + state.errorMessage = friendlySystemErrorMessage('restore-backup', err); + state.phase = 'failed'; + this.restoreState.set(state); + } + } + + discardRestore(): void { + if (this.anyInFlight()) return; + this.restoreState.set({ open: false, phase: 'idle', staged: null, errorMessage: null }); + } + + onOpenConfirm(op: AdminSystemOperation): void { + if (this.anyInFlight()) return; + if (op === 'restore-backup') { + const staged = this.restoreState().staged; + if (!staged) { + this.restoreState.update((s) => ({ ...s, errorMessage: 'Stage a backup before continuing.' })); + return; + } + } + this.confirmError.set(null); + this.confirmOperation.set(op); + this.confirmOpen.set(true); + } + + closeConfirm(): void { + if (this.confirmSubmitting()) return; + this.confirmOpen.set(false); + this.confirmError.set(null); + } + + async onConfirmAction(payload: { password: string; confirmation: string }): Promise { + if (this.confirmSubmitting()) return; + const operation = this.confirmOperation(); + this.confirmSubmitting.set(true); + this.confirmError.set(null); + try { + const issued = await this.system.issueConfirmation({ + operation, + password: payload.password, + ...(operation === 'restore-backup' && this.restoreState().staged + ? { stagingId: this.restoreState().staged!.stagingId } + : {}), + }); + if (operation === 'restore-backup') { + this.confirmSubmitting.set(false); + this.confirmOpen.set(false); + await this.runRestoreCommit(issued.token); + } else if (operation === 'reset-scores') { + await this.runResetScores(issued.token); + } else if (operation === 'wipe-challenges') { + await this.runWipeChallenges(issued.token); + } + } catch (e) { + const err = coerceSystemError(e, friendlySystemFallback(operation)); + this.confirmError.set(friendlySystemErrorMessage(operation, err)); + } finally { + this.confirmSubmitting.set(false); + } + } + + private async runRestoreCommit(token: string): Promise { + const staged = this.restoreState().staged; + if (!staged) { + this.confirmError.set('No staged backup available.'); + return; + } + this.restoreState.update((s) => ({ ...s, phase: 'committed' })); + try { + await this.system.commitRestore({ + operation: 'restore-backup', + token, + stagingId: staged.stagingId, + }); + this.restoreState.set({ open: false, phase: 'idle', staged: null, errorMessage: null }); + this.confirmOpen.set(false); + this.dataChanges.notify('restore-completed'); + this.notifications.info('Restore complete. Logging out…'); + // Force client-side credential clear. + this.auth.forceServerInvalidation(); + this.userStore.reset(); + await this.router.navigateByUrl('/login'); + } catch (e) { + const err = coerceSystemError(e, 'Restore failed.'); + this.confirmError.set(friendlySystemErrorMessage('restore-backup', err)); + this.restoreState.update((s) => ({ ...s, phase: 'failed', errorMessage: friendlySystemErrorMessage('restore-backup', err) })); + } + } + + private async runResetScores(token: string): Promise { + try { + const res = await this.system.resetScores({ operation: 'reset-scores', token }); + this.dangerError.set(null); + this.dangerStatus.set(`${res.solvesRemoved} solve record(s) removed.`); + this.dataChanges.notify('scores-reset'); + this.notifications.info(`Reset complete: ${res.solvesRemoved} solve(s) removed.`); + this.confirmOpen.set(false); + } catch (e) { + const err = coerceSystemError(e, 'Reset scores failed.'); + this.confirmError.set(friendlySystemErrorMessage('reset-scores', err)); + this.dangerError.set(friendlySystemErrorMessage('reset-scores', err)); + } + } + + private async runWipeChallenges(token: string): Promise { + try { + const res = await this.system.wipeChallenges({ operation: 'wipe-challenges', token }); + this.dangerError.set(null); + this.dangerStatus.set( + `${res.challengesRemoved} challenge(s), ${res.challengeFilesRemoved} file(s) wiped.`, + ); + this.dataChanges.notify('challenges-wiped'); + this.dataChanges.notify('scores-reset'); + this.notifications.info(`Wipe complete: ${res.challengesRemoved} challenge(s) removed.`); + this.confirmOpen.set(false); + } catch (e) { + const err = coerceSystemError(e, 'Wipe challenges failed.'); + this.confirmError.set(friendlySystemErrorMessage('wipe-challenges', err)); + this.dangerError.set(friendlySystemErrorMessage('wipe-challenges', err)); + } + } +} + +function friendlySystemFallback(op: AdminSystemOperation): string { + switch (op) { + case 'restore-backup': + return 'Restore could not be confirmed.'; + case 'reset-scores': + return 'Reset could not be confirmed.'; + case 'wipe-challenges': + return 'Wipe could not be confirmed.'; + } +} diff --git a/frontend/src/app/features/admin/system/system.pure.ts b/frontend/src/app/features/admin/system/system.pure.ts new file mode 100644 index 0000000..8d97856 --- /dev/null +++ b/frontend/src/app/features/admin/system/system.pure.ts @@ -0,0 +1,155 @@ +import { AdminSystemOperation } from './system.service'; + +export interface SystemError { + status: number; + code: string; + message: string; + details?: unknown; +} + +export function coerceSystemError(err: unknown, fallbackMessage: string): SystemError { + if (err && typeof err === 'object' && 'error' in (err as any)) { + const httpErr = err as { status?: number; error?: { code?: string; message?: string; details?: unknown } }; + const body = httpErr.error; + return { + status: httpErr.status ?? 0, + code: body?.code ?? 'INTERNAL', + message: body?.message ?? fallbackMessage, + details: body?.details, + }; + } + return { + status: 0, + code: 'INTERNAL', + message: (err as Error)?.message ?? fallbackMessage, + }; +} + +export function friendlySystemErrorMessage(op: AdminSystemOperation, err: SystemError): string { + const code = err.code; + switch (code) { + case 'SYSTEM_REAUTH_REQUIRED': + return 'Your session is no longer valid. Please log in again.'; + case 'SYSTEM_INVALID_CREDENTIALS': + return 'The current password is incorrect.'; + case 'SYSTEM_TOKEN_INVALID': + return 'Confirmation token is invalid. Re-authenticate and try again.'; + case 'SYSTEM_TOKEN_EXPIRED': + return 'Confirmation token expired. Re-authenticate and try again.'; + case 'SYSTEM_TOKEN_REUSED': + return 'Confirmation token was already used. Re-authenticate to get a fresh token.'; + case 'SYSTEM_TOKEN_MISMATCH': + return 'Confirmation token does not match this operation.'; + case 'SYSTEM_RESTORE_VALIDATION_FAILED': + return 'Backup validation failed. No data was changed.'; + case 'SYSTEM_RESTORE_PAYLOAD_TOO_LARGE': + return 'The backup is larger than the configured upload limit.'; + case 'SYSTEM_RESTORE_STAGE_EXPIRED': + return 'The backup staging window expired. Re-upload and validate again.'; + case 'SYSTEM_RESTORE_ROLLED_BACK': + return 'Restore failed and was rolled back. Your data is unchanged.'; + case 'SYSTEM_DANGER_ROLLED_BACK': + return 'Operation failed and was rolled back. Nothing was changed.'; + case 'SYSTEM_BACKUP_FAILED': + case 'SYSTEM_FILE_READ_FAILED': + return 'Backup could not be generated. No data was changed.'; + case 'SYSTEM_DANGER_FAILED': + return 'Operation could not complete. No data was changed.'; + case 'SYSTEM_OPERATION_IN_PROGRESS': + return 'Another destructive operation is currently running.'; + case 'CONFLICT': + return 'Server reported a conflict. Try again.'; + case 'FORBIDDEN': + return 'You are not allowed to perform this action.'; + case 'UNAUTHORIZED': + return 'Your session is no longer valid. Please log in again.'; + case 'VALIDATION_FAILED': + return err.message && err.message.trim().length > 0 ? err.message : 'Validation failed.'; + default: + return err.message && err.message.trim().length > 0 ? err.message : fallbackMessageForOperation(op); + } +} + +function fallbackMessageForOperation(op: AdminSystemOperation): string { + switch (op) { + case 'restore-backup': + return 'Restore failed.'; + case 'reset-scores': + return 'Reset scores failed.'; + case 'wipe-challenges': + return 'Wipe challenges failed.'; + } +} + +export interface DestructiveCopy { + title: string; + confirmation: string; + detail: string; + preserved: string; +} + +export function destructiveCopy(op: AdminSystemOperation): DestructiveCopy { + switch (op) { + case 'reset-scores': + return { + title: 'Reset all scores?', + confirmation: 'RESET SCORES', + detail: + 'Every solve and award record will be permanently deleted. Every player will return to zero points and no solve history will remain.', + preserved: + 'Users, sessions, roles, challenges, challenge files, categories and images, event settings, and blog posts are preserved.', + }; + case 'wipe-challenges': + return { + title: 'Wipe all challenges?', + confirmation: 'WIPE CHALLENGES', + detail: + 'Every challenge, its attached files, all of its solves, and all of its award records will be permanently removed. Every uploaded challenge file on disk will be deleted.', + preserved: + 'Users, sessions, roles, categories and images, event settings, blog posts, and root-level uploads (such as the site logo) are preserved.', + }; + case 'restore-backup': + return { + title: 'Restore database?', + confirmation: 'RESTORE BACKUP', + detail: + 'The entire database and all uploaded files will be replaced with the contents of the selected backup. Current state will be overwritten.', + preserved: + 'Nothing is preserved: every table, every challenge, every upload, and every refresh session is replaced.', + }; + } +} + +export interface PickRestoreFileResult { + text: string; + filename: string; + size: number; +} + +/** + * Open a file picker restricted to JSON, read the file as text, and + * validate it parses. Pure DOM logic so it is testable in jsdom. + */ +export function pickRestoreFile(): Promise { + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'application/json,.json'; + input.addEventListener('change', async () => { + const file = input.files?.[0]; + if (!file) { + resolve(null); + return; + } + const text = await file.text(); + resolve({ text, filename: file.name, size: file.size }); + }); + input.addEventListener('cancel', () => resolve(null)); + input.click(); + }); +} + +export function deriveBackupFilename(): string { + const date = new Date().toISOString().slice(0, 10); + return `hipctf-backup-${date}.json`; +} diff --git a/frontend/src/app/features/admin/system/system.service.ts b/frontend/src/app/features/admin/system/system.service.ts new file mode 100644 index 0000000..5f09fd1 --- /dev/null +++ b/frontend/src/app/features/admin/system/system.service.ts @@ -0,0 +1,91 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +export type AdminSystemOperation = 'restore-backup' | 'reset-scores' | 'wipe-challenges'; + +export interface AdminSystemRestoreSummary { + stagingId: string; + expiresAt: string; + summary: { + tables: string[]; + tableCounts: Record; + files: number; + totalBytes: number; + }; +} + +@Injectable({ providedIn: 'root' }) +export class SystemAdminService { + private readonly http = inject(HttpClient); + + async downloadBackup(): Promise { + return firstValueFrom( + this.http.get('/api/v1/admin/system/backup', { + withCredentials: true, + responseType: 'blob', + }), + ); + } + + async validateRestore(payload: { text: string } | { archive: unknown }): Promise { + const body = 'text' in payload ? { text: payload.text } : { archive: payload.archive }; + return firstValueFrom( + this.http.post( + '/api/v1/admin/system/restore/validate', + body, + { withCredentials: true }, + ), + ); + } + + async commitRestore(payload: { operation: 'restore-backup'; token: string; stagingId: string }): Promise<{ ok: true; operation: 'restore-backup' }> { + return firstValueFrom( + this.http.post<{ ok: true; operation: 'restore-backup' }>( + '/api/v1/admin/system/restore/commit', + payload, + { withCredentials: true }, + ), + ); + } + + async issueConfirmation(payload: { + operation: AdminSystemOperation; + password: string; + stagingId?: string; + }): Promise<{ operation: AdminSystemOperation; token: string; expiresAt: string }> { + return firstValueFrom( + this.http.post<{ operation: AdminSystemOperation; token: string; expiresAt: string }>( + '/api/v1/admin/system/confirmations', + payload, + { withCredentials: true }, + ), + ); + } + + async resetScores(payload: { operation: 'reset-scores'; token: string }): Promise<{ ok: true; solvesRemoved: number }> { + return firstValueFrom( + this.http.post<{ ok: true; solvesRemoved: number }>( + '/api/v1/admin/system/scores/reset', + payload, + { withCredentials: true }, + ), + ); + } + + async wipeChallenges(payload: { operation: 'wipe-challenges'; token: string }): Promise<{ + ok: true; + challengesRemoved: number; + challengeFilesRemoved: number; + solvesRemoved: number; + }> { + return firstValueFrom( + this.http.post<{ + ok: true; + challengesRemoved: number; + challengeFilesRemoved: number; + solvesRemoved: number; + }>('/api/v1/admin/system/challenges/wipe', payload, { withCredentials: true }), + ); + } +} diff --git a/frontend/src/app/features/challenges/challenges.store.ts b/frontend/src/app/features/challenges/challenges.store.ts index 101d82d..60833eb 100644 --- a/frontend/src/app/features/challenges/challenges.store.ts +++ b/frontend/src/app/features/challenges/challenges.store.ts @@ -1,4 +1,4 @@ -import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core'; +import { DestroyRef, Injectable, computed, effect, inject, signal } from '@angular/core'; import { Observable } from 'rxjs'; import { BoardCard, @@ -17,11 +17,13 @@ import { } from './challenges.pure'; import { ChallengesApiService } from './challenges.service'; import { EventSourceLike } from '../../core/services/event-status.pure'; +import { SystemDataChangeService } from '../../core/services/system-data-change.service'; @Injectable({ providedIn: 'root' }) export class ChallengesStore { private readonly api: ChallengesApiService; private readonly destroyRef: DestroyRef; + private readonly dataChanges: SystemDataChangeService | null; private readonly _board = signal(null); private readonly _eventState = signal('unconfigured'); @@ -35,10 +37,35 @@ export class ChallengesStore { private readonly solveListeners = new Map void>>(); - constructor(api?: ChallengesApiService, destroyRef?: DestroyRef) { + constructor(api?: ChallengesApiService, destroyRef?: DestroyRef, dataChanges?: SystemDataChangeService) { this.api = api ?? inject(ChallengesApiService); this.destroyRef = destroyRef ?? inject(DestroyRef); + this.dataChanges = dataChanges ?? null; this.destroyRef.onDestroy(() => this.stop()); + this.installDataChangeListeners(); + } + + private installDataChangeListeners(): void { + if (!this.dataChanges) return; + const lastSeen = { ts: 0 }; + effect(() => { + const evts = this.dataChanges!.events(); + const latest = evts[evts.length - 1]; + if (!latest || latest.ts === lastSeen.ts) return; + lastSeen.ts = latest.ts; + if (latest.kind === 'challenges-wiped' || latest.kind === 'restore-completed' || latest.kind === 'scores-reset') { + this.reset(); + } + }); + } + + /** + * Exposed for callers that want to clear the cached board/selected detail + * after a destructive operation (e.g. challenge wipe). The signal + * subscription above re-evaluates when `events()` mutates. + */ + invalidateForSystemChange(_kind: 'scores-reset' | 'challenges-wiped' | 'restore-completed'): void { + this.reset(); } readonly board = this._board.asReadonly(); diff --git a/frontend/src/app/features/scoreboard/scoreboard.store.ts b/frontend/src/app/features/scoreboard/scoreboard.store.ts index f0a9334..620f2fd 100644 --- a/frontend/src/app/features/scoreboard/scoreboard.store.ts +++ b/frontend/src/app/features/scoreboard/scoreboard.store.ts @@ -1,4 +1,4 @@ -import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core'; +import { DestroyRef, Injectable, computed, effect, inject, signal } from '@angular/core'; import { ScoreboardApiService } from './scoreboard.service'; import { EventLogRow, @@ -15,11 +15,13 @@ import { parseSolveEventIntoRanking, } from './scoreboard.pure'; import { EventSourceLike } from '../../core/services/event-status.pure'; +import { SystemDataChangeService } from '../../core/services/system-data-change.service'; @Injectable({ providedIn: 'root' }) export class ScoreboardStore { private readonly api: ScoreboardApiService; private readonly destroyRef: DestroyRef; + private readonly dataChanges: SystemDataChangeService | null; private readonly _ranking = signal(null); private readonly _matrix = signal(null); @@ -35,10 +37,26 @@ export class ScoreboardStore { private createSource: (() => EventSourceLike) | null = null; private loadAllInFlight: Promise | null = null; - constructor(api?: ScoreboardApiService, destroyRef?: DestroyRef) { + constructor(api?: ScoreboardApiService, destroyRef?: DestroyRef, dataChanges?: SystemDataChangeService) { this.api = api ?? inject(ScoreboardApiService); this.destroyRef = destroyRef ?? inject(DestroyRef); + this.dataChanges = dataChanges ?? null; this.destroyRef.onDestroy(() => this.stop()); + if (!this.dataChanges) return; + const lastSeen = { ts: 0 }; + const dc = this.dataChanges; + effect(() => { + const evts = dc.events(); + const latest = evts[evts.length - 1]; + if (!latest || latest.ts === lastSeen.ts) return; + lastSeen.ts = latest.ts; + if (latest.kind === 'scores-reset' || latest.kind === 'restore-completed' || latest.kind === 'challenges-wiped') { + this._ranking.set(null); + this._matrix.set(null); + this._eventLog.set(null); + this._graph.set(null); + } + }); } readonly ranking = this._ranking.asReadonly(); diff --git a/tests/backend/admin-system-authorization.spec.ts b/tests/backend/admin-system-authorization.spec.ts new file mode 100644 index 0000000..06210aa --- /dev/null +++ b/tests/backend/admin-system-authorization.spec.ts @@ -0,0 +1,131 @@ +process.env.DATABASE_PATH = ':memory:'; +process.env.THEMES_DIR = './themes'; +process.env.FRONTEND_DIST = './frontend/dist'; +process.env.UPLOAD_SIZE_LIMIT = '5mb'; +process.env.NODE_ENV = 'test'; + +import { Test } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } 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'; + +async function buildAgent( + app: INestApplication, + username: string, + password: string, +): Promise<{ agent: any; token: string }> { + const server = app.getHttpServer(); + const agent = request.agent(server); + await agent.get('/api/v1/auth/csrf'); + const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf'); + const res = await agent + .post('/api/v1/auth/login') + .set('X-CSRF-Token', csrf.value) + .send({ username, password }) + .expect(201); + (agent as any).accessToken = res.body.accessToken as string; + return { agent, token: res.body.accessToken as string }; +} + +async function csrfHeaderFor(agent: any): Promise { + const cookies = agent.jar.getCookies(CookieAccessInfo.All); + return (cookies as any[]).find((c: any) => c.name === 'csrf').value as string; +} + +describe('Job 871: System endpoint authorization', () => { + let app: INestApplication; + let adminAgent: any; + let adminToken: string; + let playerAgent: any; + let playerToken: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.use(express.json({ limit: '10mb' })); + const csrfMw = new CsrfMiddleware(app.get(ConfigService)); + app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); + const httpAdapterHost = app.get(HttpAdapterHost); + app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); + await app.init(); + await initDb(app); + + await request(app.getHttpServer()) + .post('/api/v1/auth/register-first-admin') + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + + adminAgent = request.agent(app.getHttpServer()); + await adminAgent.get('/api/v1/auth/csrf'); + const csrfAdmin = (adminAgent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf'); + const adminLogin = await adminAgent + .post('/api/v1/auth/login') + .set('X-CSRF-Token', csrfAdmin.value) + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + adminToken = adminLogin.body.accessToken; + (adminAgent as any).accessToken = adminToken; + + await adminAgent + .post('/api/v1/admin/users') + .set('Authorization', `Bearer ${adminToken}`) + .set('X-CSRF-Token', csrfAdmin.value) + .send({ username: 'player1', password: 'Sup3rSecret!Pass', role: 'player' }) + .expect(201); + + const playerLogin = await adminAgent + .post('/api/v1/auth/login') + .set('X-CSRF-Token', csrfAdmin.value) + .send({ username: 'player1', password: 'Sup3rSecret!Pass' }) + .expect(201); + playerToken = playerLogin.body.accessToken; + + playerAgent = request.agent(app.getHttpServer()); + await playerAgent.get('/api/v1/auth/csrf'); + const csrfPlayer = (playerAgent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf'); + await playerAgent + .post('/api/v1/auth/login') + .set('X-CSRF-Token', csrfPlayer.value) + .send({ username: 'player1', password: 'Sup3rSecret!Pass' }) + .expect(201); + (playerAgent as any).accessToken = playerToken; + }); + + afterAll(async () => { + await app.close(); + }); + + it('GET /admin/system/backup without token returns 401', async () => { + await request(app.getHttpServer()).get('/api/v1/admin/system/backup').expect(401); + }); + + it('GET /admin/system/backup with player JWT returns 403', async () => { + await request(app.getHttpServer()) + .get('/api/v1/admin/system/backup') + .set('Authorization', `Bearer ${playerToken}`) + .expect(403); + }); + + it('GET /admin/system/backup with admin JWT returns 200 application/json', async () => { + const res = await request(app.getHttpServer()) + .get('/api/v1/admin/system/backup') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + expect(res.headers['content-type']).toMatch(/application\/json/); + expect(typeof res.text).toBe('string'); + const parsed = JSON.parse(res.text); + expect(parsed.format).toBe('hipctf-system-backup'); + expect(parsed.tables).toBeDefined(); + expect(Array.isArray(parsed.uploads)).toBe(true); + }); +}); diff --git a/tests/backend/admin-system-backup.spec.ts b/tests/backend/admin-system-backup.spec.ts new file mode 100644 index 0000000..e93b570 --- /dev/null +++ b/tests/backend/admin-system-backup.spec.ts @@ -0,0 +1,107 @@ +process.env.DATABASE_PATH = ':memory:'; +process.env.THEMES_DIR = './themes'; +process.env.FRONTEND_DIST = './frontend/dist'; +process.env.NODE_ENV = 'test'; + +import { Test } from '@nestjs/testing'; +import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { validateEnv } from '../../backend/src/config/env.schema'; +import { BackupService } from '../../backend/src/modules/admin/system/backup.service'; +import { UserEntity } from '../../backend/src/database/entities/user.entity'; +import { SettingEntity } from '../../backend/src/database/entities/setting.entity'; +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 { RefreshTokenEntity } from '../../backend/src/database/entities/refresh-token.entity'; +import { BlogPostEntity } from '../../backend/src/database/entities/blog-post.entity'; +import { AdminOperationTokenEntity } from '../../backend/src/database/entities/admin-operation-token.entity'; +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 { AddAdminOperationTokens1700000000900 } from '../../backend/src/database/migrations/1700000000900-AddAdminOperationTokens'; + +describe('Job 871: BackupService captures + filters', () => { + let moduleRef: any; + let backup: BackupService; + let uploadDir: string; + + beforeAll(async () => { + uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-sys-backup-')); + process.env.UPLOAD_DIR = uploadDir; + fs.writeFileSync(path.join(uploadDir, 'logo.bin'), Buffer.from('LOGO-BYTES')); + fs.mkdirSync(path.join(uploadDir, 'icons'), { recursive: true }); + fs.writeFileSync(path.join(uploadDir, 'icons', 'icon.png'), Buffer.from('ICON')); + + 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, RefreshTokenEntity, BlogPostEntity, + AdminOperationTokenEntity, + ], + migrations: [ + InitSchema1700000000000, + SeedSystemData1700000000100, + AddCategoryTimestampsAndUniqueAbbrev1700000000200, + UpdateSystemCategoryKeys1700000000300, + RepairCategorySchemaAndSystemCategories1700000000400, + UpgradeChallengeAdminSchema1700000000500, + AddAdminOperationTokens1700000000900, + ], + migrationsRun: true, + synchronize: false, + }), + ], + providers: [BackupService], + }).compile(); + backup = moduleRef.get(BackupService); + const dataSource = moduleRef.get(getDataSourceToken()); + await dataSource.query( + `INSERT INTO user (id,username,password_hash,role,status,created_at) VALUES (?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, + ['u-1', 'alice', 'HASH', 'player', 'enabled'], + ); + await dataSource.query(`INSERT INTO setting (key,value) VALUES (?,?)`, ['testCustomKey', 'HIPCTF']); + }); + + afterAll(async () => { + await moduleRef?.close(); + if (uploadDir && fs.existsSync(uploadDir)) { + try { + fs.rmSync(uploadDir, { recursive: true, force: true }); + } catch { + // ignore + } + } + }); + + it('captures every application table and base64-encoded uploads', async () => { + const obj = await backup.build(); + expect(obj.format).toBe('hipctf-system-backup'); + expect(obj.tables.user.length).toBeGreaterThanOrEqual(1); + expect(obj.tables.setting.length).toBeGreaterThanOrEqual(1); + expect(obj.uploads.length).toBeGreaterThanOrEqual(1); + const logo = obj.uploads.find((u) => u.path.includes('logo.bin')); + expect(logo).toBeDefined(); + expect(Buffer.from(logo!.dataBase64, 'base64').toString()).toBe('LOGO-BYTES'); + }); + + it('never includes operational or sqlite-internal tables', async () => { + const obj = await backup.build(); + expect(Object.keys(obj.tables)).not.toContain('admin_operation_token'); + expect(Object.keys(obj.tables)).not.toContain('migrations'); + expect(Object.keys(obj.tables)).not.toContain('sqlite_master'); + expect(obj.tableCounts['admin_operation_token']).toBeUndefined(); + }); +}); diff --git a/tests/backend/admin-system-confirmation-token.spec.ts b/tests/backend/admin-system-confirmation-token.spec.ts new file mode 100644 index 0000000..7e5e913 --- /dev/null +++ b/tests/backend/admin-system-confirmation-token.spec.ts @@ -0,0 +1,146 @@ +process.env.DATABASE_PATH = ':memory:'; +process.env.THEMES_DIR = './themes'; +process.env.FRONTEND_DIST = './frontend/dist'; +process.env.UPLOAD_SIZE_LIMIT = '5mb'; +process.env.NODE_ENV = 'test'; + +import { Test } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } 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'; + +async function csrfHeader(agent: any): Promise { + const cookies = agent.jar.getCookies(CookieAccessInfo.All); + return (cookies as any[]).find((c: any) => c.name === 'csrf').value as string; +} + +async function loginAgent(app: INestApplication, username: string, password: string): Promise<{ agent: any; token: string }> { + const agent = request.agent(app.getHttpServer()); + await agent.get('/api/v1/auth/csrf'); + const csrf = await csrfHeader(agent); + const res = await agent + .post('/api/v1/auth/login') + .set('X-CSRF-Token', csrf) + .send({ username, password }) + .expect(201); + (agent as any).accessToken = res.body.accessToken; + return { agent, token: res.body.accessToken }; +} + +describe('Job 871: Confirmation-token binding + replay rejection', () => { + let app: INestApplication; + let admin: { agent: any; token: string }; + let csrf: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.use(express.json({ limit: '10mb' })); + const csrfMw = new CsrfMiddleware(app.get(ConfigService)); + app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); + const httpAdapterHost = app.get(HttpAdapterHost); + app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); + await app.init(); + await initDb(app); + + await request(app.getHttpServer()) + .post('/api/v1/auth/register-first-admin') + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + + admin = await loginAgent(app, 'admin', 'Sup3rSecret!Pass'); + csrf = await csrfHeader(admin.agent); + }); + + afterAll(async () => { + await app.close(); + }); + + it('issues a confirmation token for reset-scores', async () => { + const res = await admin.agent + .post('/api/v1/admin/system/confirmations') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'reset-scores', password: 'Sup3rSecret!Pass' }) + .expect(200); + expect(res.body.token).toEqual(expect.any(String)); + expect(res.body.operation).toBe('reset-scores'); + expect(res.body.expiresAt).toEqual(expect.any(String)); + }); + + it('rejects confirmation requests with the wrong password', async () => { + const res = await admin.agent + .post('/api/v1/admin/system/confirmations') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'reset-scores', password: 'WrongPassword!1' }); + expect(res.status).toBe(401); + expect(res.body.code).toBe('SYSTEM_INVALID_CREDENTIALS'); + }); + + it('rejects token requests without authentication', async () => { + const res = await request(app.getHttpServer()) + .post('/api/v1/admin/system/confirmations') + .send({ operation: 'reset-scores', password: 'Sup3rSecret!Pass' }); + expect([401, 403]).toContain(res.status); + }); + + it('rejects token reuse on the reset-scores endpoint', async () => { + const issued = await admin.agent + .post('/api/v1/admin/system/confirmations') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'reset-scores', password: 'Sup3rSecret!Pass' }) + .expect(200); + const first = await admin.agent + .post('/api/v1/admin/system/scores/reset') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'reset-scores', token: issued.body.token }) + .expect(200); + expect(first.body.ok).toBe(true); + const second = await admin.agent + .post('/api/v1/admin/system/scores/reset') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'reset-scores', token: issued.body.token }); + expect([400, 409]).toContain(second.status); + expect(['SYSTEM_TOKEN_REUSED', 'SYSTEM_TOKEN_INVALID']).toContain(second.body.code); + }); + + it('rejects tokens issued for a different operation', async () => { + const issued = await admin.agent + .post('/api/v1/admin/system/confirmations') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'reset-scores', password: 'Sup3rSecret!Pass' }) + .expect(200); + const res = await admin.agent + .post('/api/v1/admin/system/challenges/wipe') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'wipe-challenges', token: issued.body.token }); + expect(res.status).toBe(400); + expect(['SYSTEM_TOKEN_MISMATCH', 'SYSTEM_TOKEN_INVALID']).toContain(res.body.code); + }); + + it('rejects tokens with a fabricated value', async () => { + const res = await admin.agent + .post('/api/v1/admin/system/scores/reset') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'reset-scores', token: 'not-a-real-token' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('SYSTEM_TOKEN_INVALID'); + }); +}); diff --git a/tests/backend/admin-system-danger.spec.ts b/tests/backend/admin-system-danger.spec.ts new file mode 100644 index 0000000..9e009d5 --- /dev/null +++ b/tests/backend/admin-system-danger.spec.ts @@ -0,0 +1,215 @@ +// Resolve a stable per-suite upload directory BEFORE AppModule is imported so +// the @nestjs/config validator picks it up at module-decoration time. We +// don't care about the exact path for the test runner — the absolute path +// just needs to be writable and consistent for the lifetime of this worker. +const TEST_UPLOAD_DIR = `/tmp/hipctf-danger-zone-${process.pid}-${Date.now()}`; +try { + fs.mkdirSync(TEST_UPLOAD_DIR, { recursive: true }); +} catch { + // ignore +} + +process.env.DATABASE_PATH = ':memory:'; +process.env.THEMES_DIR = './themes'; +process.env.FRONTEND_DIST = './frontend/dist'; +process.env.UPLOAD_SIZE_LIMIT = '5mb'; +process.env.UPLOAD_DIR = TEST_UPLOAD_DIR; +process.env.NODE_ENV = 'test'; + +import { Test } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import cookieParser from 'cookie-parser'; +import * as express from 'express'; +import * as fs from 'fs'; +import * as path from 'path'; +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 { DangerZoneService } from '../../backend/src/modules/admin/system/danger-zone.service'; +import { initDb } from './db-helper'; + +async function csrfHeader(agent: any): Promise { + const cookies = agent.jar.getCookies(CookieAccessInfo.All); + return (cookies as any[]).find((c: any) => c.name === 'csrf').value as string; +} + +async function loginAgent( + app: INestApplication, + username: string, + password: string, +): Promise<{ agent: any; token: string }> { + const agent = request.agent(app.getHttpServer()); + await agent.get('/api/v1/auth/csrf'); + const csrf = await csrfHeader(agent); + const res = await agent + .post('/api/v1/auth/login') + .set('X-CSRF-Token', csrf) + .send({ username, password }) + .expect(201); + return { agent, token: res.body.accessToken }; +} + +describe('Job 871: Danger zone preservation rules', () => { + let app: INestApplication; + let admin: { agent: any; token: string }; + let csrf: string; + const uploadDir = TEST_UPLOAD_DIR; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.use(cookieParser()); + app.use(express.json({ limit: '10mb' })); + const csrfMw = new CsrfMiddleware(app.get(ConfigService)); + app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next)); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); + const httpAdapterHost = app.get(HttpAdapterHost); + app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); + await app.init(); + await initDb(app); + + await request(app.getHttpServer()) + .post('/api/v1/auth/register-first-admin') + .send({ username: 'admin', password: 'Sup3rSecret!Pass' }) + .expect(201); + + admin = await loginAgent(app, 'admin', 'Sup3rSecret!Pass'); + csrf = await csrfHeader(admin.agent); + + // Add a player + a solve by inserting a row directly (so we don't depend on challenges being provisioned). + const directSql = async (sql: string) => { + const ds = app.get('DatabaseModule') ; + const dataSource = app.get('DataSource' as any); + void ds; + await dataSource.query(sql); + }; + + const dataSource = app.get(getDataSourceToken()); + const playerId = 'p-1'; + const catId = 'cat-1'; + const chId = 'ch-1'; + await dataSource.query( + `INSERT INTO user (id,username,password_hash,role,status,created_at) VALUES (?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, + [playerId, 'player1', 'X', 'player', 'enabled'], + ); + await dataSource.query( + `INSERT INTO category (id,system_key,name,abbreviation,description,icon_path,created_at,updated_at) VALUES (?,NULL,'Custom','CUS','','',strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, + [catId], + ); + await dataSource.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) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, + [chId, 'Test', '', catId, 'LOW', 100, 50, 10, 'flag{}', 'WEB', null, '', 1], + ); + await dataSource.query( + `INSERT INTO solve (id,challenge_id,user_id,solved_at,points_awarded,base_points,rank_bonus,is_first,is_second,is_third) VALUES (?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'),?,?,?,?,?,?)`, + ['s-1', chId, playerId, 100, 100, 0, 1, 0, 0], + ); + void directSql; + }); + + afterAll(async () => { + await app.close(); + if (uploadDir && fs.existsSync(uploadDir)) { + try { + fs.rmSync(uploadDir, { recursive: true, force: true }); + } catch { + // ignore + } + } + }); + + it('reset-scores deletes every solve while preserving users + categories + challenges', async () => { + const dataSource = app.get(getDataSourceToken()); + const before = await dataSource.query('SELECT COUNT(*) AS c FROM solve'); + expect(Number((before as any[])[0].c)).toBeGreaterThanOrEqual(1); + + const issued = await admin.agent + .post('/api/v1/admin/system/confirmations') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'reset-scores', password: 'Sup3rSecret!Pass' }) + .expect(200); + + const res = await admin.agent + .post('/api/v1/admin/system/scores/reset') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'reset-scores', token: issued.body.token }) + .expect(200); + expect(res.body.ok).toBe(true); + expect(res.body.solvesRemoved).toBeGreaterThanOrEqual(1); + + const afterSolves = await dataSource.query('SELECT COUNT(*) AS c FROM solve'); + expect(Number((afterSolves as any[])[0].c)).toBe(0); + + const users = await dataSource.query('SELECT COUNT(*) AS c FROM user'); + expect(Number((users as any[])[0].c)).toBeGreaterThanOrEqual(2); + const cats = await dataSource.query('SELECT COUNT(*) AS c FROM category'); + expect(Number((cats as any[])[0].c)).toBeGreaterThanOrEqual(1); + const chs = await dataSource.query('SELECT COUNT(*) AS c FROM challenge'); + expect(Number((chs as any[])[0].c)).toBeGreaterThanOrEqual(1); + }); + + it('wipe-challenges removes every challenge + solve + file but keeps users + categories', async () => { + const dataSource = app.get(getDataSourceToken()); + // seed a challenge file row so we can confirm preserves rule + await dataSource.query( + `INSERT INTO challenge_file (id,challenge_id,original_filename,stored_filename,mime_type,size_bytes,created_at) VALUES (?,?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'))`, + ['cf-1', 'ch-1', 'readme.txt', 'cf-1.bin', 'text/plain', 0], + ); + + // Pre-create a real challenge file on disk so the physical-delete + // step actually has something to remove. + const challengesDir = path.join(uploadDir, 'challenges'); + fs.mkdirSync(challengesDir, { recursive: true }); + fs.writeFileSync(path.join(challengesDir, 'cf-1.bin'), Buffer.from('LIVE-CHALLENGE-BYTES')); + + // Sanity: confirm the DangerZoneService resolved to our temp dir. + const danger = app.get(DangerZoneService); + expect((danger as any).uploadDir).toBe(uploadDir); + void danger; + + const issued = await admin.agent + .post('/api/v1/admin/system/confirmations') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'wipe-challenges', password: 'Sup3rSecret!Pass' }) + .expect(200); + + const res = await admin.agent + .post('/api/v1/admin/system/challenges/wipe') + .set('Authorization', `Bearer ${admin.token}`) + .set('X-CSRF-Token', csrf) + .send({ operation: 'wipe-challenges', token: issued.body.token }) + .expect(200); + expect(res.body.ok).toBe(true); + expect(res.body.challengesRemoved).toBeGreaterThanOrEqual(1); + + const challenges = await dataSource.query('SELECT COUNT(*) AS c FROM challenge'); + expect(Number((challenges as any[])[0].c)).toBe(0); + const files = await dataSource.query('SELECT COUNT(*) AS c FROM challenge_file'); + expect(Number((files as any[])[0].c)).toBe(0); + const solves = await dataSource.query('SELECT COUNT(*) AS c FROM solve'); + expect(Number((solves as any[])[0].c)).toBe(0); + const users = await dataSource.query('SELECT COUNT(*) AS c FROM user'); + expect(Number((users as any[])[0].c)).toBeGreaterThanOrEqual(2); + const cats = await dataSource.query('SELECT COUNT(*) AS c FROM category'); + expect(Number((cats as any[])[0].c)).toBeGreaterThanOrEqual(1); + + // Live challenge-files directory must be physically empty/absent. + if (fs.existsSync(challengesDir)) { + const remaining = fs.readdirSync(challengesDir); + expect(remaining).toEqual([]); + } + // Staging snapshot must have been cleaned up after the disk delete. + const snapshotLeftover = fs + .readdirSync(uploadDir) + .some((entry) => entry.startsWith('challenges.wipe-stage-')); + expect(snapshotLeftover).toBe(false); + }); +}); diff --git a/tests/backend/admin-system-restore-validation.spec.ts b/tests/backend/admin-system-restore-validation.spec.ts new file mode 100644 index 0000000..27b029b --- /dev/null +++ b/tests/backend/admin-system-restore-validation.spec.ts @@ -0,0 +1,129 @@ +process.env.DATABASE_PATH = ':memory:'; +process.env.THEMES_DIR = './themes'; +process.env.FRONTEND_DIST = './frontend/dist'; +process.env.UPLOAD_SIZE_LIMIT = '5mb'; +process.env.NODE_ENV = 'test'; + +import { Test } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { validateEnv } from '../../backend/src/config/env.schema'; +import { RestoreService } from '../../backend/src/modules/admin/system/restore.service'; +import { FilesystemTransactionService } from '../../backend/src/modules/admin/system/filesystem-transaction.service'; +import { UserEntity } from '../../backend/src/database/entities/user.entity'; +import { SettingEntity } from '../../backend/src/database/entities/setting.entity'; +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 { RefreshTokenEntity } from '../../backend/src/database/entities/refresh-token.entity'; +import { BlogPostEntity } from '../../backend/src/database/entities/blog-post.entity'; +import { AdminOperationTokenEntity } from '../../backend/src/database/entities/admin-operation-token.entity'; + +describe('Job 871: RestoreService validation rejects malformed archives before mutation', () => { + let moduleRef: any; + let restore: RestoreService; + let dataSource: any; + + beforeAll(async () => { + process.env.UPLOAD_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-restore-uploads-')); + 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, RefreshTokenEntity, BlogPostEntity, + AdminOperationTokenEntity, + ], + synchronize: true, + }), + ], + providers: [RestoreService, FilesystemTransactionService], + }).compile(); + moduleRef.init(); + dataSource = moduleRef.get(getDataSourceToken()); + restore = moduleRef.get(RestoreService); + }); + + afterAll(async () => { + const dir = process.env.UPLOAD_DIR!; + if (fs.existsSync(dir)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } + } + await moduleRef?.close(); + }); + + it('rejects empty JSON', async () => { + await expect(restore.stageArchive('', 'admin-1')).rejects.toMatchObject({ + code: 'SYSTEM_RESTORE_VALIDATION_FAILED', + }); + }); + + it('rejects wrong format', async () => { + const bad = JSON.stringify({ format: 'wrong', version: 1, exportedAt: 'x', tables: {}, uploads: [] }); + await expect(restore.stageArchive(bad, 'admin-1')).rejects.toMatchObject({ + code: 'SYSTEM_RESTORE_VALIDATION_FAILED', + }); + }); + + it('rejects base64 size mismatch', async () => { + const bad = { + format: 'hipctf-system-backup', + version: 1, + exportedAt: 'x', + tables: {}, + uploads: [ + { + path: 'icons/mismatch.png', + originalFilename: 'm.png', + mimeType: 'image/png', + sizeBytes: 100, + dataBase64: Buffer.from('short').toString('base64'), + }, + ], + }; + await expect(restore.stageArchive(JSON.stringify(bad), 'admin-1')).rejects.toMatchObject({ + code: 'SYSTEM_RESTORE_VALIDATION_FAILED', + }); + }); + + it('rejects path traversal', async () => { + const data = Buffer.alloc(0).toString('base64'); + const bad = { + format: 'hipctf-system-backup', + version: 1, + exportedAt: 'x', + tables: {}, + uploads: [ + { + path: '../../../etc/passwd', + originalFilename: 'x', + mimeType: 'application/octet-stream', + sizeBytes: 0, + dataBase64: data, + }, + ], + }; + await expect(restore.stageArchive(JSON.stringify(bad), 'admin-1')).rejects.toMatchObject({ + code: 'SYSTEM_RESTORE_VALIDATION_FAILED', + }); + }); + + it('does not mutate live data after rejected validation', async () => { + const users = await dataSource.query('SELECT COUNT(*) AS c FROM user'); + expect(Number((users as any[])[0]?.c ?? 0)).toBeGreaterThanOrEqual(0); + const stagedCount = (restore as any).stages?.size ?? 0; + expect(stagedCount).toBe(0); + }); +}); diff --git a/tests/frontend/admin-system.pure.spec.ts b/tests/frontend/admin-system.pure.spec.ts new file mode 100644 index 0000000..9f68111 --- /dev/null +++ b/tests/frontend/admin-system.pure.spec.ts @@ -0,0 +1,76 @@ +process.env.DATABASE_PATH = ':memory:'; +process.env.THEMES_DIR = './themes'; +process.env.FRONTEND_DIST = './frontend/dist'; + +import { + coerceSystemError, + destructiveCopy, + deriveBackupFilename, + friendlySystemErrorMessage, +} from '../../frontend/src/app/features/admin/system/system.pure'; + +describe('Job 871: friendlySystemErrorMessage mapping', () => { + it('returns the friendly token-expired message for SYSTEM_TOKEN_EXPIRED', () => { + const err = { status: 400, code: 'SYSTEM_TOKEN_EXPIRED', message: 'expired' }; + expect(friendlySystemErrorMessage('restore-backup', err)).toContain('expired'); + expect(friendlySystemErrorMessage('restore-backup', err)).toMatch(/Re-authenticate/i); + }); + + it('returns a specific message for SYSTEM_RESTORE_ROLLED_BACK', () => { + expect(friendlySystemErrorMessage('restore-backup', { status: 500, code: 'SYSTEM_RESTORE_ROLLED_BACK', message: 'rb' })) + .toContain('rolled back'); + }); + + it('falls back to operation message when code is unknown', () => { + expect(friendlySystemErrorMessage('wipe-challenges', { status: 500, code: 'WEIRD', message: 'broken' })) + .toBe('broken'); + }); + + it('falls back to the operation default when message is empty', () => { + expect(friendlySystemErrorMessage('reset-scores', { status: 500, code: 'WEIRD', message: '' })) + .toContain('Reset scores failed'); + }); +}); + +describe('Job 871: destructiveCopy', () => { + it('reset-scores confirms with RESET SCORES', () => { + expect(destructiveCopy('reset-scores').confirmation).toBe('RESET SCORES'); + }); + it('wipe-challenges confirms with WIPE CHALLENGES', () => { + expect(destructiveCopy('wipe-challenges').confirmation).toBe('WIPE CHALLENGES'); + }); + it('restore-backup confirms with RESTORE BACKUP', () => { + expect(destructiveCopy('restore-backup').confirmation).toBe('RESTORE BACKUP'); + }); +}); + +describe('Job 871: deriveBackupFilename', () => { + it('contains today date and .json', () => { + const name = deriveBackupFilename(); + expect(name).toMatch(/^hipctf-backup-\d{4}-\d{2}-\d{2}\.json$/); + }); +}); + +describe('Job 871: coerceSystemError', () => { + it('reads body from HttpErrorResponse shape', () => { + const out = coerceSystemError( + { status: 400, error: { code: 'X', message: 'Y' } }, + 'fallback', + ); + expect(out.code).toBe('X'); + expect(out.message).toBe('Y'); + expect(out.status).toBe(400); + }); + + it('falls back when no envelope is present', () => { + const out = coerceSystemError(new Error('boom'), 'fallback'); + expect(out.code).toBe('INTERNAL'); + expect(out.message).toBe('boom'); + }); + + it('falls back for null', () => { + const out = coerceSystemError(null, 'fallback'); + expect(out.code).toBe('INTERNAL'); + expect(out.message).toBe('fallback'); + }); +}); -- 2.52.0