Compare commits

65 Commits

Author SHA1 Message Date
m0rph3us1987 143ed3c538 AI Implementation feature(917): Category Icons (#65) 2026-07-23 16:14:08 +00:00
m0rph3us1987 e81c568fca AI Implementation feature(919): Admin Area System: Database Backup/Restore and Danger Zone 1.02 (#64) 2026-07-23 15:49:15 +00:00
m0rph3us1987 e2d6bb3d69 AI Implementation feature(918): Admin Area System: Database Backup/Restore and Danger Zone 1.01 (#63) 2026-07-23 13:28:29 +00:00
m0rph3us1987 e611d201d9 AI Implementation feature(916): Admin Area System: Database Backup/Restore and Danger Zone 1.00 (#62) 2026-07-23 12:49:37 +00:00
m0rph3us1987 470ddd30c3 AI Implementation feature(871): Admin Area System: Database Backup/Restore and Danger Zone (#61) 2026-07-23 12:10:41 +00:00
m0rph3us1987 6bac67fad7 AI Implementation feature(915): Admin Area Blog and Blog Page 1.01 (#60) 2026-07-23 10:58:22 +00:00
m0rph3us1987 29b447d51f AI Implementation feature(914): Admin Area Blog and Blog Page 1.00 (#59) 2026-07-23 10:37:53 +00:00
m0rph3us1987 294873240b AI Implementation feature(869): Admin Area Blog and Blog Page (#58) 2026-07-23 10:17:55 +00:00
m0rph3us1987 d468cca766 AI Implementation feature(913): Admin Area Players Management 1.00 (#57) 2026-07-23 08:43:46 +00:00
m0rph3us1987 e4ccf0fe13 AI Implementation feature(867): Admin Area Players Management (#56) 2026-07-23 08:08:50 +00:00
m0rph3us1987 705530e71f AI Implementation feature(912): Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.02 (#55) 2026-07-23 06:40:47 +00:00
m0rph3us1987 a39a7daee8 AI Implementation feature(911): Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.01 (#54) 2026-07-23 06:03:13 +00:00
m0rph3us1987 4273662211 AI Implementation feature(910): Scoreboard: Ranking, Matrix, Event Log and Score Graph 1.00 (#53) 2026-07-23 05:42:17 +00:00
m0rph3us1987 7c4843d5cc AI Implementation feature(865): Scoreboard: Ranking, Matrix, Event Log and Score Graph (#52) 2026-07-23 05:13:23 +00:00
m0rph3us1987 49d0930780 AI Implementation feature(909): Challenges Page and Challenge Solve Modal 1.05 (#51) 2026-07-23 04:06:49 +00:00
m0rph3us1987 5a6e832cca AI Implementation feature(908): Challenges Page and Challenge Solve Modal 1.04 (#50) 2026-07-23 03:47:10 +00:00
m0rph3us1987 54f72f88dc AI Implementation feature(907): Challenges Page and Challenge Solve Modal 1.03 (#49) 2026-07-23 03:11:18 +00:00
m0rph3us1987 f194641e8c AI Implementation feature(906): Challenges Page and Challenge Solve Modal 1.02 (#48) 2026-07-23 02:24:44 +00:00
m0rph3us1987 cbfa9c35ca AI Implementation feature(905): Challenges Page and Challenge Solve Modal 1.01 (#47) 2026-07-23 01:20:00 +00:00
m0rph3us1987 1bd4a5a198 AI Implementation feature(904): Challenges Page and Challenge Solve Modal 1.00 (#46) 2026-07-23 00:42:07 +00:00
m0rph3us1987 0bd1d9472a AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45) 2026-07-23 00:13:59 +00:00
m0rph3us1987 dcda3dfd4d AI Implementation feature(903): Admin Area Challenges: List, Import/Export and Add/Edit Modal 1.03 (#44) 2026-07-22 22:18:10 +00:00
m0rph3us1987 fff0a600df AI Implementation feature(902): Admin Area Challenges: List, Import/Export and Add/Edit Modal 1.02 (#43) 2026-07-22 21:47:52 +00:00
m0rph3us1987 d3307c3225 AI Implementation feature(901): Admin Area Challenges: List, Import/Export and Add/Edit Modal 1.01 (#42) 2026-07-22 21:26:25 +00:00
m0rph3us1987 3d93693fc6 AI Implementation feature(900): Admin Area Challenges: List, Import/Export and Add/Edit Modal 1.00 (#41) 2026-07-22 20:50:07 +00:00
m0rph3us1987 8dcbd48045 AI Implementation feature(861): Admin Area Challenges: List, Import/Export and Add/Edit Modal (#40) 2026-07-22 20:26:30 +00:00
m0rph3us1987 1a40e5708a AI Implementation feature(899): Data and run (#39) 2026-07-22 19:56:33 +00:00
m0rph3us1987 113dd47371 AI Implementation feature(898): Admin Area General Settings and Categories 1.16 (#38) 2026-07-22 19:19:46 +00:00
m0rph3us1987 f62c77cf41 AI Implementation feature(897): Admin Area General Settings and Categories 1.15 (#37) 2026-07-22 18:53:13 +00:00
m0rph3us1987 ef9926dfc1 AI Implementation feature(896): Admin Area General Settings and Categories 1.14 (#36) 2026-07-22 18:38:15 +00:00
m0rph3us1987 f827a3a69c AI Implementation feature(895): Admin Area General Settings and Categories 1.13 (#35) 2026-07-22 18:04:03 +00:00
m0rph3us1987 7bd0009b93 AI Implementation feature(894): Admin Area General Settings and Categories 1.12 (#34) 2026-07-22 17:34:55 +00:00
m0rph3us1987 56555d272f AI Implementation feature(893): Admin Area General Settings and Categories 1.11 (#33) 2026-07-22 16:46:01 +00:00
m0rph3us1987 c0a01eb58e AI Implementation feature(892): Admin Area General Settings and Categories 1.10 (#32) 2026-07-22 16:16:22 +00:00
m0rph3us1987 4656a5b082 AI Implementation feature(891): Admin Area General Settings and Categories 1.09 (#31) 2026-07-22 15:47:04 +00:00
m0rph3us1987 2a6182b938 AI Implementation feature(890): Admin Area General Settings and Categories 1.08 (#30) 2026-07-22 15:11:01 +00:00
m0rph3us1987 282fcbab94 AI Implementation feature(889): Admin Area General Settings and Categories 1.07 (#29) 2026-07-22 14:53:01 +00:00
m0rph3us1987 3cf86b6233 AI Implementation feature(888): Admin Area General Settings and Categories 1.06 (#28) 2026-07-22 14:27:43 +00:00
m0rph3us1987 c52a736ae1 AI Implementation feature(887): Admin Area General Settings and Categories 1.05 (#27) 2026-07-22 14:09:04 +00:00
m0rph3us1987 98fee8f7ee AI Implementation feature(886): Admin Area General Settings and Categories 1.04 (#26) 2026-07-22 13:41:09 +00:00
m0rph3us1987 b2f0a4736d AI Implementation feature(885): Admin Area General Settings and Categories 1.03 (#25) 2026-07-22 13:06:34 +00:00
m0rph3us1987 b7e0f3c608 AI Implementation feature(884): Admin Area General Settings and Categories 1.02 (#24) 2026-07-22 12:45:45 +00:00
m0rph3us1987 0a2b2664b4 AI Implementation feature(883): Admin Area General Settings and Categories 1.01 (#23) 2026-07-22 12:32:00 +00:00
m0rph3us1987 fac3179427 AI Implementation feature(882): Admin Area General Settings and Categories 1.00 (#22) 2026-07-22 12:05:16 +00:00
m0rph3us1987 c9e8dfc611 AI Implementation feature(859): Admin Area General Settings and Categories (#21) 2026-07-22 11:45:23 +00:00
m0rph3us1987 de527ec6d6 AI Implementation feature(881): Authenticated Shell, Quick Tabs and Change Password 1.05 (#20) 2026-07-22 10:35:27 +00:00
m0rph3us1987 90e570c43c AI Implementation feature(880): Authenticated Shell, Quick Tabs and Change Password 1.04 (#19) 2026-07-22 09:37:10 +00:00
m0rph3us1987 d134a56abe AI Implementation feature(879): Authenticated Shell, Quick Tabs and Change Password 1.03 (#18) 2026-07-22 09:23:36 +00:00
m0rph3us1987 685c7e47c9 AI Implementation feature(878): Authenticated Shell, Quick Tabs and Change Password 1.02 (#17) 2026-07-22 09:04:45 +00:00
m0rph3us1987 c0427f8c20 AI Implementation feature(876): Authenticated Shell, Quick Tabs and Change Password 1.01 (#16) 2026-07-21 23:22:05 +00:00
m0rph3us1987 76c04f9ea0 AI Implementation feature(875): Authenticated Shell, Quick Tabs and Change Password 1.00 (#15) 2026-07-21 22:53:49 +00:00
m0rph3us1987 b6dbfcc511 AI Implementation feature(857): Authenticated Shell: Header, Quick Tabs and Change Password (#14) 2026-07-21 22:26:47 +00:00
m0rph3us1987 8dc8cee769 AI Implementation feature(873): Landing Page and Login/Register Modal 1.01 (#13) 2026-07-21 21:19:40 +00:00
m0rph3us1987 cd97e40030 AI Implementation feature(849): Landing Page and Login/Register Modal 1.00 (#12) 2026-07-21 18:46:14 +00:00
m0rph3us1987 685a8bca84 AI Implementation feature(823): Landing Page and Login/Register Modal (#11) 2026-07-21 18:34:45 +00:00
m0rph3us1987 8476e4a59f AI Implementation feature(848): First-Start Create Admin Modal 1.07 (#10) 2026-07-21 17:46:09 +00:00
m0rph3us1987 dda85f8cce AI Implementation feature(847): First-Start Create Admin Modal 1.06 (#9) 2026-07-21 17:38:23 +00:00
m0rph3us1987 2ae930f325 AI Implementation feature(846): First-Start Create Admin Modal 1.05 (#8) 2026-07-21 17:18:54 +00:00
m0rph3us1987 cabd393288 AI Implementation feature(845): First-Start Create Admin Modal 1.04 (#7) 2026-07-21 16:49:50 +00:00
m0rph3us1987 2957b14d38 AI Implementation feature(844): First-Start Create Admin Modal 1.03 (#6) 2026-07-21 16:25:40 +00:00
m0rph3us1987 a190a69a27 AI Implementation feature(843): First-Start Create Admin Modal 1.02 (#5) 2026-07-21 16:11:07 +00:00
m0rph3us1987 6feaf08bdb AI Implementation feature(842): First-Start Create Admin Modal 1.01 (#4) 2026-07-21 15:32:51 +00:00
m0rph3us1987 960516a7bc AI Implementation feature(841): First-Start Create Admin Modal 1.00 (#3) 2026-07-21 15:07:44 +00:00
m0rph3us1987 9f67ec8c50 AI Implementation feature(821): First-Start Create Admin Modal (#2) 2026-07-21 14:45:55 +00:00
m0rph3us1987 03bcb6b156 AI Implementation feature(820): Project Scaffold and Platform Foundations (#1) 2026-07-21 14:23:53 +00:00
384 changed files with 67512 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules/
dist/
*.log
.env
.env.*
!.env.example
.DS_Store
coverage/
data/
backend/data/
backend/themes/.cache/
frontend/.angular/
/data/.npm/
.npm/
+180
View File
@@ -0,0 +1,180 @@
# Implementation Plan: Job 917 — Category Icons
## 0. Status
**NOT IMPLEMENTED.** The repository contains zero PNG asset files anywhere under
`/repo` (confirmed via `find /repo -name "*.png"`). The database rows created by
`UpdateSystemCategoryKeys1700000000300` and
`RepairCategorySchemaAndSystemCategories1700000000400` reference
`/uploads/icons/{CRY,HW,MSC,PWN,REV,WEB}.png`, but nothing on bootstrap writes
those PNGs to `UPLOAD_DIR/icons`. `setup.sh` only `mkdir -p`s the directory; it
does not populate it. As a result `GET /uploads/icons/CRY.png` returns 404 and
the challenges board shows broken images.
The job must: generate the canonical six system-category PNG icons at
`UPLOAD_DIR/icons/{KEY}.png` on every startup, idempotently, so a fresh clone
that runs `setup.sh` and `npm start` has working category icons immediately.
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** TypeScript strict, NestJS 10 modules, ESM
CommonJS hybrid, async/await everywhere. Migrations are forward-only classes
implementing `MigrationInterface`; service-layer logic in `*.service.ts`. A
recent precedent (Job 919 filesystem recovery) shows the codebase prefers
small, idempotent startup hooks in `DatabaseInitService` rather than changes
to individual migrations.
- **Data Layer:** TypeORM + `better-sqlite3`. DB at `DATABASE_PATH`
(default `./data/db.sqlite`). Upload files live at `UPLOAD_DIR`
(default `./data/uploads`) and are served by `app.use('/uploads',
express.static(uploadDir))` in `backend/src/main.ts:79`. Migrations are
registered in `backend/src/database/database.module.ts:40`.
- **Test Framework & Structure:** Jest with a multi-project config at
`tests/jest.config.js`. All backend specs live in `tests/backend/**.spec.ts`,
run via `npm test` (or `npm run test:backend`). All frontend specs live in
`tests/frontend/**.spec.ts`. New tests must follow the same layout — no
source-side spec files.
- **Required Tools & Dependencies:** No new dependencies are needed. `sharp`
(already a `backend` runtime dep at `^0.35.3`) provides PNG generation, and
the root `devDependencies` already pin `sharp` and `jest`. The existing
`admin-icon-normalize.spec.ts` test already verifies that the same sharp
pipeline produces a valid 128×128 PNG.
## 2. Impacted Files
- **To Modify:**
- `backend/src/database/database-init.service.ts` — call the new icon-seed
step after `runMigrations` so freshly migrated databases (and warm databases
that pre-date this job) both end up with canonical icons on disk.
- `setup.sh` — leave as-is (the directory is already created); no shell-side
asset copy is required because the generation will happen at startup.
- **To Create:**
- `backend/src/database/system-category-icons.ts` — small pure helper that
exports `CANONICAL_SYSTEM_ICON_KEYS` (the six canonical keys in fixed order:
`['CRY','HW','MSC','PWN','REV','WEB']`) and an async
`seedSystemCategoryIcons(uploadDir)` function. Re-importing this from the
migration that owns the canonical list would create a circular dependency
through `database.module.ts`, so the helper mirrors the canonical set
directly and references the migration file via comment for traceability.
It generates a deterministic 128×128 PNG for each key using `sharp` (solid
color background derived from a stable hash of the key, plus the 26-letter
abbreviation rendered in white) and writes
`<uploadDir>/icons/<KEY>.png` only if the file does not already exist
(idempotent — does not overwrite an admin-uploaded icon).
- `tests/backend/system-category-icons.spec.ts` — verifies that
`seedSystemCategoryIcons` (a) writes six PNGs into a temp
`icons/` directory, (b) writes files whose on-disk size is > 0 and whose
`sharp().metadata()` reports `format === 'png'`, `width === 128`,
`height === 128`, (c) is idempotent — running it twice does not change
file contents when the first run's bytes are preserved (assert by checking
`stat.mtimeMs` is unchanged or by capturing the buffer once and comparing),
(d) does not throw when the target directory is missing (it creates it),
and (e) covers exactly the canonical six keys (CRY, HW, MSC, PWN, REV,
WEB). One focused test per behavior above; no mocks of `sharp` (use a
real `os.tmpdir()`-based dir for the assertions).
## 3. Proposed Changes
1. **Helper module (`backend/src/database/system-category-icons.ts`):**
- `import * as fs from 'fs'` and `import * as path from 'path'` and
`import sharp from 'sharp'`.
- Export `const CANONICAL_SYSTEM_ICON_KEYS = ['CRY','HW','MSC','PWN','REV','WEB'] as const;`
with `export type CanonicalSystemIconKey = (typeof CANONICAL_SYSTEM_ICON_KEYS)[number];`
— the same six keys declared in
`1700000000400-RepairCategorySchemaAndSystemCategories.ts:13` as
`CANONICAL_SYSTEM_CATEGORIES`. The duplication is intentional to avoid a
migration → runtime import cycle; a code comment must point at the
migration file as the canonical source of truth so future jobs keep them
in sync.
- Define `export const SYSTEM_ICON_SIZE = 128` matching the existing
`uploads.controller.ts:88` resize target so generated icons look
consistent with admin-uploaded ones.
- Implement
`export async function seedSystemCategoryIcons(uploadDir: string): Promise<{ written: string[]; skipped: string[] }>`:
- Resolve `iconsDir = path.join(uploadDir, 'icons')`, create it
recursively if missing.
- For each `key` in `CANONICAL_SYSTEM_ICON_KEYS`, target file
`path.join(iconsDir, `${key}.png`)`.
- If the file already exists and is non-empty, push to `skipped`.
- Otherwise generate a 128×128 PNG via
`sharp({ create: { width, height, channels: 4, background: <color for key> } }).png().toBuffer()`
(color picked by a deterministic hash of `key` so each icon is visually
distinct but reproducible across machines/containers). Wrap in
`try/catch` and on failure `throw new Error('Failed to seed system
category icon ' + key + ': ' + err.message)`. On success `fs.writeFileSync`
the buffer to disk and push the absolute path to `written`.
- Return `{ written, skipped }`.
- Add a top-of-file comment noting that this helper must stay in sync with
`CANONICAL_SYSTEM_CATEGORIES` in
`1700000000400-RepairCategorySchemaAndSystemCategories.ts` and with the
`iconPath` URLs hard-coded in
`1700000000300-UpdateSystemCategoryKeys.ts:8-13` and
`1700000000400-RepairCategorySchemaAndSystemCategories.ts:19-54`.
2. **Bootstrap hook (`backend/src/database/database-init.service.ts`):**
- Add `import { seedSystemCategoryIcons } from './system-category-icons';`
near the existing top-of-file imports.
- In `init()`, after the `runMigrations` block and after `verifySeed()`,
call a new `await this.ensureSystemCategoryIcons()`. The call must run
on every startup (not gated on `pendingList.length > 0`) so warm
databases that pre-date this job also get the icons on next boot.
- Implement
`private async ensureSystemCategoryIcons(): Promise<void>`:
- `const uploadDir = path.resolve(this.config.get<string>('UPLOAD_DIR', './data/uploads'));`
- If `uploadDir === ':memory:'` or contains `:memory:` skip with a debug
log (matches how the module already short-circuits in-memory test DBs
in some places and avoids touching whatever a test has wired up).
- Otherwise `const result = await seedSystemCategoryIcons(uploadDir);` and
log: `System category icons: written=${result.written.length}
skipped=${result.skipped.length}`. A failure must `this.logger.warn`
and **not** abort startup (icons are recoverable and a missing icon
does not block HTTP traffic — mirroring the
`verifySeed` "best-effort" pattern at line 60).
3. **No changes to `setup.sh`.** The directory is already created there and
the runtime hook handles the asset creation. (Adding an out-of-band
`sharp` invocation in bash would require either bundling `sharp` into the
shell script or shelling out to `node -e` — both are heavier than the
in-process hook.)
4. **No changes to existing migrations.** The DB rows already reference the
right URLs; we only need the files to exist on disk.
## 4. Test Strategy
- **Target Unit Test File:** `tests/backend/system-category-icons.spec.ts`
(single file, ~3050 lines, no test infrastructure beyond `os.tmpdir()`
and `fs.mkdtempSync`).
- **Mocking Strategy:** No mocks. Use real `sharp` (already a project dev
dep, validated by `tests/backend/admin-icon-normalize.spec.ts:1`) and a
fresh per-test temp directory under `os.tmpdir()` (e.g.
`fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-icons-'))`) that is removed
in `afterEach`. Asserting on real files via `fs.statSync` and
`sharp(buf).metadata()` is faster and more reliable than mocking the fs
module.
- **Cases (keep minimal, success-path only):**
1. `seedSystemCategoryIcons` writes six PNGs into a fresh temp dir and
each one is a valid 128×128 PNG with `format === 'png'`.
2. Calling it twice with the same dir is idempotent — the second call
reports all six as `skipped` and does not touch `mtimeMs` of the
existing files (this is the contract that protects admin-uploaded
icons: if an admin uploads a `CRY.png`, a later restart must not
overwrite it).
3. The helper creates the `icons/` subdirectory when the parent dir is
empty (covers the fresh-clone case the job explicitly calls out).
- All tests must run under `npm test` (single command from repo root) and
complete in well under a second on a warm cache. No UI, no visual
verification, no complex infrastructure.
## 5. Cross-cutting Notes
- The existing `admin-icon-normalize.spec.ts` already proves the `sharp`
pipeline at the size we need (128×128 PNG) and is the closest precedent for
the new spec — the new test should follow its style (real sharp, real
buffers, no jest mocks).
- Do **not** modify the canonical migration files. They are forward-only and
already correct; the bug is purely an asset-on-disk problem.
- Do **not** add new npm dependencies. `sharp` is sufficient.
- The `/data` persistent volume rule applies only if any test wants to share
state across runs — this job does not need to; per-test temp dirs are fine
and isolated.
+7
View File
@@ -0,0 +1,7 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+4626
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "backend",
"version": "0.1.0",
"private": true,
"description": "HIPCTF NestJS backend",
"main": "dist/main.js",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"start:dev": "ts-node -r tsconfig-paths/register src/main.ts"
},
"dependencies": {
"@nestjs/common": "^10.3.10",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.3.10",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.10",
"@nestjs/swagger": "^7.4.0",
"@nestjs/typeorm": "^10.0.2",
"argon2": "^0.40.1",
"better-sqlite3": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.6",
"helmet": "^7.1.0",
"multer": "^2.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.35.3",
"typeorm": "^0.3.20",
"uuid": "^9.0.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@nestjs/testing": "^10.4.22",
"@types/better-sqlite3": "^7.6.10",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8",
"supertest": "^7.0.0",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.4.5"
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
import { validateEnv } from './config/env.schema';
import { DatabaseModule } from './database/database.module';
import { CommonModule } from './common/common.module';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { SetupModule } from './modules/setup/setup.module';
import { SystemModule } from './modules/system/system.module';
import { SettingsModule } from './modules/settings/settings.module';
import { AdminModule } from './modules/admin/admin.module';
import { UploadsModule } from './modules/uploads/uploads.module';
import { BlogModule } from './modules/blog/blog.module';
import { ChallengesModule } from './modules/challenges/challenges.module';
import { FrontendModule } from './frontend/frontend.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
validate: validateEnv,
}),
DatabaseModule,
CommonModule,
SettingsModule,
AuthModule,
UsersModule,
SetupModule,
SystemModule,
AdminModule,
UploadsModule,
BlogModule,
ChallengesModule,
FrontendModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
],
})
export class AppModule {}
+30
View File
@@ -0,0 +1,30 @@
import { Module, Global } from '@nestjs/common';
import { ThemeLoaderService } from './utils/theme-loader.service';
import { EventStatusService } from './services/event-status.service';
import { SseHubService } from './services/sse-hub.service';
import { LoginBackoffService } from './services/login-backoff.service';
import { RegistrationRateLimitService } from './services/registration-rate-limit.service';
import { CsrfMiddleware } from './middleware/csrf.middleware';
import { SettingsModule } from '../modules/settings/settings.module';
@Global()
@Module({
imports: [SettingsModule],
providers: [
ThemeLoaderService,
EventStatusService,
SseHubService,
LoginBackoffService,
RegistrationRateLimitService,
CsrfMiddleware,
],
exports: [
ThemeLoaderService,
EventStatusService,
SseHubService,
LoginBackoffService,
RegistrationRateLimitService,
CsrfMiddleware,
],
})
export class CommonModule {}
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../database/entities/user.entity';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const SKIP_CSRF_KEY = 'skipCsrf';
export const SkipCsrf = () => SetMetadata(SKIP_CSRF_KEY, true);
+37
View File
@@ -0,0 +1,37 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { ErrorCode, ERROR_CODES } from './error-codes';
export class ApiError extends HttpException {
constructor(
public readonly code: ErrorCode,
message: string,
status: HttpStatus,
public readonly details?: unknown,
) {
super({ code, message, details }, status);
}
static validation(message = 'Validation failed', details?: unknown): ApiError {
return new ApiError(ERROR_CODES.VALIDATION_FAILED, message, HttpStatus.BAD_REQUEST, details);
}
static unauthorized(message = 'Unauthorized'): ApiError {
return new ApiError(ERROR_CODES.UNAUTHORIZED, message, HttpStatus.UNAUTHORIZED);
}
static forbidden(message = 'Forbidden'): ApiError {
return new ApiError(ERROR_CODES.FORBIDDEN, message, HttpStatus.FORBIDDEN);
}
static notFound(message = 'Not found'): ApiError {
return new ApiError(ERROR_CODES.NOT_FOUND, message, HttpStatus.NOT_FOUND);
}
static conflict(code: ErrorCode, message: string): ApiError {
return new ApiError(code, message, HttpStatus.CONFLICT);
}
static internal(message = 'Internal server error'): ApiError {
return new ApiError(ERROR_CODES.INTERNAL, message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
+53
View File
@@ -0,0 +1,53 @@
export const ERROR_CODES = {
VALIDATION_FAILED: 'VALIDATION_FAILED',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
LAST_ADMIN: 'LAST_ADMIN',
SYSTEM_INITIALIZED: 'SYSTEM_INITIALIZED',
RATE_LIMITED: 'RATE_LIMITED',
CSRF_INVALID: 'CSRF_INVALID',
WEAK_PASSWORD: 'WEAK_PASSWORD',
USERNAME_TAKEN: 'USERNAME_TAKEN',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
TOKEN_REVOKED: 'TOKEN_REVOKED',
THEME_INVALID: 'THEME_INVALID',
REGISTRATIONS_DISABLED: 'REGISTRATIONS_DISABLED',
INVALID_OLD_PASSWORD: 'INVALID_OLD_PASSWORD',
PASSWORD_POLICY: 'PASSWORD_POLICY',
PASSWORDS_DO_NOT_MATCH: 'PASSWORDS_DO_NOT_MATCH',
CATEGORY_HAS_CHALLENGES: 'CATEGORY_HAS_CHALLENGES',
SYSTEM_PROTECTED: 'SYSTEM_PROTECTED',
CHALLENGE_NAME_TAKEN: 'CHALLENGE_NAME_TAKEN',
CHALLENGE_INVALID_CATEGORY: 'CHALLENGE_INVALID_CATEGORY',
CHALLENGE_INVALID_POINTS: 'CHALLENGE_INVALID_POINTS',
CHALLENGE_INVALID_PORT: 'CHALLENGE_INVALID_PORT',
CHALLENGE_INVALID_IP: 'CHALLENGE_INVALID_IP',
CHALLENGE_FILE_LIMIT: 'CHALLENGE_FILE_LIMIT',
IMPORT_VALIDATION_FAILED: 'IMPORT_VALIDATION_FAILED',
IMPORT_PARTIAL_FAILURE: 'IMPORT_PARTIAL_FAILURE',
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;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
@@ -0,0 +1,81 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { ApiError } from '../errors/api-error';
import { ERROR_CODES } from '../errors/error-codes';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const req = ctx.getRequest();
const path = req?.url ?? '';
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let body: { code: string; message: string; details?: unknown; path: string; timestamp: string };
if (exception instanceof ApiError) {
status = exception.getStatus();
const resp = exception.getResponse() as any;
body = {
code: resp.code,
message: resp.message,
details: resp.details,
path,
timestamp: new Date().toISOString(),
};
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const resp = exception.getResponse() as any;
const message = typeof resp === 'string' ? resp : resp?.message ?? exception.message;
const code = typeof resp === 'object' && resp?.code ? resp.code : this.codeFromStatus(status);
body = {
code,
message: Array.isArray(message) ? message.join('; ') : message,
details: typeof resp === 'object' ? resp?.details : undefined,
path,
timestamp: new Date().toISOString(),
};
} else {
this.logger.error(`Unhandled error: ${(exception as Error)?.message}`, (exception as Error)?.stack);
const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : (exception as Error)?.message ?? 'Internal server error';
body = {
code: ERROR_CODES.INTERNAL,
message,
path,
timestamp: new Date().toISOString(),
};
}
if (status >= 500) {
this.logger.error(`${status} ${body.code} ${path}: ${body.message}`);
} else if (status >= 400) {
this.logger.warn(`${status} ${body.code} ${path}: ${body.message}`);
}
httpAdapter.reply(ctx.getResponse(), body, status);
}
private codeFromStatus(status: number): string {
switch (status) {
case 400: return ERROR_CODES.VALIDATION_FAILED;
case 401: return ERROR_CODES.UNAUTHORIZED;
case 403: return ERROR_CODES.FORBIDDEN;
case 404: return ERROR_CODES.NOT_FOUND;
case 409: return ERROR_CODES.CONFLICT;
case 429: return ERROR_CODES.RATE_LIMITED;
default: return ERROR_CODES.INTERNAL;
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class AdminGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
const req = context.switchToHttp().getRequest();
if (!req.user) throw new ForbiddenException('Authentication required');
if (!required || required.length === 0) return true;
if (!required.includes(req.user.role)) {
throw new ForbiddenException('Admin role required');
}
return true;
}
}
@@ -0,0 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { UserRole } from '../../database/entities/user.entity';
import { ApiError } from '../errors/api-error';
import { ERROR_CODES } from '../errors/error-codes';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) return true;
const req = context.switchToHttp().getRequest();
if (!req.user) throw ApiError.unauthorized();
if (!required.includes(req.user.role)) {
throw new ForbiddenException('Insufficient role');
}
return true;
}
}
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(map((data) => ({ data })));
}
}
@@ -0,0 +1,101 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as crypto from 'crypto';
import { ConfigService } from '@nestjs/config';
import { ApiError } from '../errors/api-error';
import { ERROR_CODES } from '../errors/error-codes';
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const COOKIE_NAME = 'csrf';
const HEADER_NAME = 'x-csrf-token';
const SKIP_PATH_PREFIXES = [
'/api/v1/auth/register-first-admin',
'/api/v1/setup/create-admin',
];
function isSkipped(path: string): boolean {
return SKIP_PATH_PREFIXES.some((p) => path === p || path.startsWith(p + '/'));
}
function readCookie(req: Request): string | null {
const header = req.headers.cookie;
if (!header) return null;
const parts = header.split(';');
for (const part of parts) {
const [k, ...v] = part.trim().split('=');
if (k === COOKIE_NAME) return v.join('=');
}
return null;
}
/**
* Resolve the request's effective path. In NestJS mounted middleware,
* `req.path` may already be stripped to '/' when the controller is reached
* through a nested router; use the original URL as the authoritative source.
*/
function requestPath(req: Request): string {
const orig = req.originalUrl || req.url || req.path || '/';
const qIdx = orig.indexOf('?');
return qIdx >= 0 ? orig.slice(0, qIdx) : orig;
}
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
@Injectable()
export class CsrfMiddleware implements NestMiddleware {
constructor(private readonly config: ConfigService) {}
/**
* Public helper: ensure a `csrf` cookie exists on the response and return its value.
* If a token already exists in the request cookies, that one is returned and re-set.
* Otherwise a new one is minted, set on the response, and returned.
*/
setOrGetCsrfToken(req: Request, res: Response): string {
const existing = readCookie(req);
if (existing) {
this.writeCookie(res, existing);
return existing;
}
const token = crypto.randomBytes(24).toString('base64url');
this.writeCookie(res, token);
return token;
}
use(req: Request, res: Response, next: NextFunction): void {
const path = requestPath(req);
if (isSkipped(path)) {
this.setOrGetCsrfToken(req, res);
return next();
}
if (!UNSAFE_METHODS.has(req.method)) {
this.setOrGetCsrfToken(req, res);
return next();
}
this.setOrGetCsrfToken(req, res);
const cookieToken = readCookie(req);
const headerToken = (req.headers[HEADER_NAME] as string | undefined) ?? '';
if (!cookieToken || !headerToken || !safeEqual(cookieToken, headerToken)) {
throw new ApiError(ERROR_CODES.CSRF_INVALID, 'CSRF token missing or invalid', 403);
}
next();
}
private writeCookie(res: Response, token: string): void {
const tls = this.config.get<boolean>('TLS_ENABLED', false);
res.cookie(COOKIE_NAME, token, {
httpOnly: false,
sameSite: tls ? 'strict' : 'lax',
secure: tls,
path: '/',
});
}
}
@@ -0,0 +1,17 @@
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
import { ZodSchema } from 'zod';
import { ApiError } from '../errors/api-error';
@Injectable()
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodSchema) {}
transform(value: any, _metadata: ArgumentMetadata): any {
const parsed = this.schema.safeParse(value);
if (!parsed.success) {
const details = parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message }));
throw ApiError.validation('Request validation failed', details);
}
return parsed.data;
}
}
@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import { SettingsService } from '../../modules/settings/settings.module';
export type EventStatusName = 'Stopped' | 'Running';
export type EventState = 'countdown' | 'running' | 'stopped' | 'unconfigured';
export interface EventStatus {
status: EventStatusName;
startUtc: string;
endUtc: string;
serverNowUtc: string;
countdownMs: number;
}
export interface EventStatePayload {
state: EventState;
serverNowUtc: string;
eventStartUtc: string | null;
eventEndUtc: string | null;
secondsToStart: number | null;
secondsToEnd: number | null;
}
@Injectable()
export class EventStatusService {
constructor(private readonly settings: SettingsService) {}
async getStatus(now: Date = new Date()): Promise<EventStatus> {
const s = await this.getState(now);
let status: EventStatusName = 'Stopped';
let countdownMs = 0;
if (s.state === 'countdown' && s.secondsToStart != null) {
status = 'Stopped';
countdownMs = s.secondsToStart * 1000;
} else if (s.state === 'running' && s.secondsToEnd != null) {
status = 'Running';
countdownMs = s.secondsToEnd * 1000;
} else if (s.state === 'stopped') {
status = 'Stopped';
countdownMs = 0;
} else {
status = 'Stopped';
countdownMs = 0;
}
return {
status,
startUtc: s.eventStartUtc ?? '',
endUtc: s.eventEndUtc ?? '',
serverNowUtc: s.serverNowUtc,
countdownMs,
};
}
async getState(now: Date = new Date()): Promise<EventStatePayload> {
const startStr = await this.settings.get('eventStartUtc');
const endStr = await this.settings.get('eventEndUtc');
if (!startStr || !endStr) {
return {
state: 'unconfigured',
serverNowUtc: now.toISOString(),
eventStartUtc: startStr ?? null,
eventEndUtc: endStr ?? null,
secondsToStart: null,
secondsToEnd: null,
};
}
const start = new Date(startStr);
const end = new Date(endStr);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return {
state: 'unconfigured',
serverNowUtc: now.toISOString(),
eventStartUtc: startStr,
eventEndUtc: endStr,
secondsToStart: null,
secondsToEnd: null,
};
}
const nowMs = now.getTime();
const startMs = start.getTime();
const endMs = end.getTime();
let state: EventState;
let secondsToStart: number | null = null;
let secondsToEnd: number | null = null;
if (nowMs < startMs) {
state = 'countdown';
secondsToStart = Math.max(0, Math.floor((startMs - nowMs) / 1000));
} else if (nowMs <= endMs) {
state = 'running';
secondsToEnd = Math.max(0, Math.floor((endMs - nowMs) / 1000));
} else {
state = 'stopped';
}
return {
state,
serverNowUtc: now.toISOString(),
eventStartUtc: start.toISOString(),
eventEndUtc: end.toISOString(),
secondsToStart,
secondsToEnd,
};
}
}
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
const MAX_FAILS = 5;
const BACKOFF_MS = [1_000, 5_000, 30_000, 120_000, 600_000, 1_800_000];
@Injectable()
export class LoginBackoffService {
private counters = new Map<string, { count: number; blockedUntil: number }>();
private key(ip: string, username: string): string {
return `${ip}::${username}`;
}
isBlocked(ip: string, username: string, now: number = Date.now()): number {
const k = this.key(ip, username);
const entry = this.counters.get(k);
if (!entry) return 0;
if (entry.blockedUntil > now) return entry.blockedUntil - now;
return 0;
}
recordFailure(ip: string, username: string, now: number = Date.now()): void {
const k = this.key(ip, username);
const entry = this.counters.get(k) ?? { count: 0, blockedUntil: 0 };
entry.count++;
if (entry.count > MAX_FAILS) {
const idx = Math.min(entry.count - MAX_FAILS - 1, BACKOFF_MS.length - 1);
entry.blockedUntil = now + BACKOFF_MS[idx];
} else if (entry.count === MAX_FAILS) {
entry.blockedUntil = now + BACKOFF_MS[0];
}
this.counters.set(k, entry);
}
reset(ip: string, username: string): void {
this.counters.delete(this.key(ip, username));
}
}
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
const WINDOW_MS = 60_000;
const MAX_PER_WINDOW = 10;
@Injectable()
export class RegistrationRateLimitService {
private hits: Map<string, number[]> = new Map();
isAllowed(ip: string, now: number = Date.now()): boolean {
const arr = (this.hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
this.hits.set(ip, arr);
return arr.length < MAX_PER_WINDOW;
}
record(ip: string, now: number = Date.now()): void {
const arr = (this.hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
arr.push(now);
this.hits.set(ip, arr);
}
tryConsume(ip: string, now: number = Date.now()): boolean {
const arr = (this.hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
if (arr.length >= MAX_PER_WINDOW) {
this.hits.set(ip, arr);
return false;
}
arr.push(now);
this.hits.set(ip, arr);
return true;
}
}
@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { Subject, Observable } from 'rxjs';
@Injectable()
export class SseHubService {
private readonly eventSubject = new Subject<any>();
private readonly scoreboardSubject = new Subject<any>();
emitEvent(payload: any): void {
this.eventSubject.next(payload);
}
emitScoreboard(payload: any): void {
this.scoreboardSubject.next(payload);
}
event$(): Observable<any> {
return this.eventSubject.asObservable();
}
scoreboard$(): Observable<any> {
return this.scoreboardSubject.asObservable();
}
}
+14
View File
@@ -0,0 +1,14 @@
export const THEME_IDS = [
'classic',
'midnight',
'sunset',
'forest',
'cyber',
'paper',
'crimson',
'ocean',
'neon',
'monochrome',
] as const;
export type ThemeId = (typeof THEME_IDS)[number];
+19
View File
@@ -0,0 +1,19 @@
export type ThemeTokens = {
primary: string;
secondary: string;
accent: string;
surface: string;
text: string;
success: string;
warning: string;
danger: string;
fontFamily: string;
radii: { sm: string; md: string; lg: string };
spacingScale: number[];
};
export type Theme = {
id: string;
name: string;
tokens: ThemeTokens;
};
+116
View File
@@ -0,0 +1,116 @@
import { OpenAPIObject } from '@nestjs/swagger';
/**
* Convert a NestJS-generated OpenAPI 3.0 document to a JSON Schema 2020-12-
* compatible OpenAPI 3.1 document.
*
* The two specs differ mainly in:
* - `openapi` version string.
* - JSON Schema dialect: 3.0 used a custom subset of JSON Schema Draft 5
* (with `nullable: true`); 3.1 uses JSON Schema 2020-12 natively
* (nullable via `type: ['T', 'null']`).
* - `info` no longer requires `license` / `contact`.
*
* The `paths`/`components`/`security` structures are otherwise identical,
* and we keep all NestJS-generated content intact.
*/
export function toOpenApi31(doc: Record<string, any>): OpenAPIObject {
const next: Record<string, any> = { ...doc, openapi: '3.1.0' };
// Normalize info so it has every required 3.1 field.
if (!next.info || typeof next.info !== 'object') {
next.info = { title: 'HIPCTF API', version: '1.0.0' };
} else {
next.info = {
title: next.info.title ?? 'HIPCTF API',
version: next.info.version ?? '1.0.0',
...(next.info.description ? { description: next.info.description } : {}),
...(next.info.contact ? { contact: next.info.contact } : {}),
...(next.info.license ? { license: next.info.license } : {}),
};
}
// Rewrite component schemas: replace `nullable: true` with the JSON
// Schema 2020-12 `type: ['<orig>', 'null']` union. Leave everything else
// untouched so existing component definitions remain valid.
const components = next.components ?? {};
const schemas = components.schemas ?? {};
for (const [name, schema] of Object.entries<any>(schemas)) {
schemas[name] = rewriteNullable(schema);
}
components.schemas = schemas;
next.components = components;
// Walk paths and parameters and rewrite any remaining `nullable: true`.
if (next.paths && typeof next.paths === 'object') {
for (const [pathKey, methods] of Object.entries<any>(next.paths)) {
for (const [method, op] of Object.entries<any>(methods ?? {})) {
if (!op || typeof op !== 'object') continue;
if (Array.isArray(op.parameters)) {
op.parameters = op.parameters.map((p: any) => rewriteNullable(p));
}
if (op.requestBody) op.requestBody = rewriteNullable(op.requestBody);
const body = op.requestBody?.content;
if (body && typeof body === 'object') {
for (const media of Object.values<any>(body)) {
if (media?.schema) media.schema = rewriteNullable(media.schema);
}
}
if (op.responses && typeof op.responses === 'object') {
for (const [code, resp] of Object.entries<any>(op.responses)) {
op.responses[code] = rewriteNullable(resp);
const respBody = op.responses[code]?.content;
if (respBody && typeof respBody === 'object') {
for (const media of Object.values<any>(respBody)) {
if (media?.schema) media.schema = rewriteNullable(media.schema);
}
}
}
}
}
}
}
return next as OpenAPIObject;
}
/**
* Recursively walk a schema and convert legacy `nullable: true` markers
* into JSON Schema 2020-12 `type: ['<orig>', 'null']` unions. Preserve any
* other keyword (`$ref`, `format`, `enum`, etc.).
*/
function rewriteNullable(node: any): any {
if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
// If this is a $ref, leave it alone.
if (typeof node.$ref === 'string') return node;
const out: Record<string, any> = { ...node };
if (out.nullable === true) {
delete out.nullable;
if (typeof out.type === 'string') {
out.type = [out.type, 'null'];
} else if (Array.isArray(out.type)) {
if (!out.type.includes('null')) out.type = [...out.type, 'null'];
} else if (!out.oneOf && !out.anyOf && !out.allOf) {
out.type = ['null'];
}
}
for (const k of Object.keys(out)) {
if (k === 'properties' && out.properties && typeof out.properties === 'object') {
for (const p of Object.keys(out.properties)) {
out.properties[p] = rewriteNullable(out.properties[p]);
}
} else if (k === 'items') {
out.items = rewriteNullable(out.items);
} else if (k === 'schema') {
out.schema = rewriteNullable(out.schema);
} else if ((k === 'oneOf' || k === 'anyOf' || k === 'allOf') && Array.isArray(out[k])) {
out[k] = out[k].map((v: any) => rewriteNullable(v));
}
}
return out;
}
@@ -0,0 +1,23 @@
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
/**
* Centralized Argon2id hashing for every password-set flow in the system:
*
* - player self-registration
* - first-admin bootstrap
* - admin user creation
* - self change-password
* - admin-initiated password reset
*
* Using one helper keeps the configured cost parameters consistent across
* flows so the admin password policy behaves predictably.
*/
export async function hashPassword(password: string, config: ConfigService): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: config.get<number>('ARGON2_MEMORY_COST'),
timeCost: config.get<number>('ARGON2_TIME_COST'),
parallelism: config.get<number>('ARGON2_PARALLELISM'),
});
}
@@ -0,0 +1,20 @@
import { ConfigService } from '@nestjs/config';
import { ApiError } from '../errors/api-error';
import { ERROR_CODES } from '../errors/error-codes';
export function validatePassword(password: string, config: ConfigService): void {
const minLen = config.get<number>('PASSWORD_MIN_LENGTH', 12);
if (typeof password !== 'string' || password.length < minLen) {
throw new ApiError(ERROR_CODES.WEAK_PASSWORD, `Password must be at least ${minLen} characters`, 400);
}
const requireMixed = config.get<boolean>('PASSWORD_REQUIRE_MIXED', true);
if (requireMixed) {
const hasUpper = /[A-Z]/.test(password);
const hasLower = /[a-z]/.test(password);
const hasDigit = /[0-9]/.test(password);
const hasSymbol = /[^A-Za-z0-9]/.test(password);
if (!(hasUpper && hasLower && hasDigit && hasSymbol)) {
throw new ApiError(ERROR_CODES.WEAK_PASSWORD, 'Password must include upper, lower, digit, and symbol', 400);
}
}
}
@@ -0,0 +1,255 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import { THEME_IDS, ThemeId } from '../types/theme-ids';
import { Theme, ThemeTokens } from '../types/theme';
import { SettingsService } from '../../modules/settings/settings.module';
import { SETTINGS_KEYS } from '../../config/env.schema';
@Injectable()
export class ThemeLoaderService implements OnModuleInit {
private readonly logger = new Logger(ThemeLoaderService.name);
private themes: Map<string, Theme> = new Map();
private defaultThemeId: ThemeId = 'classic';
private themesDir = './themes';
constructor(
private readonly config: ConfigService,
private readonly settings: SettingsService,
) {}
async onModuleInit(): Promise<void> {
this.themesDir = this.config.get<string>('THEMES_DIR', './themes');
this.loadFromDisk();
this.backfillMissing();
await this.validateConfiguredKey();
this.logger.log(`Loaded ${this.themes.size} themes; default='${this.defaultThemeId}'`);
}
private loadFromDisk(): void {
const absDir = path.resolve(this.themesDir);
if (!fs.existsSync(absDir)) {
this.logger.warn(`Themes directory not found at ${absDir}; will backfill from built-ins`);
return;
}
const files = fs.readdirSync(absDir).filter((f) => f.endsWith('.json'));
const validIds = new Set<string>(THEME_IDS);
for (const file of files) {
try {
const raw = fs.readFileSync(path.join(absDir, file), 'utf-8');
const parsed = JSON.parse(raw) as Theme;
if (!parsed.id || !validIds.has(parsed.id)) {
this.logger.warn(`Skipping theme file ${file}: invalid or unknown id '${parsed.id}'`);
continue;
}
if (!parsed.tokens || !this.validateTokens(parsed.tokens)) {
this.logger.warn(`Skipping theme file ${file}: invalid tokens`);
continue;
}
this.themes.set(parsed.id, parsed);
} catch (err) {
this.logger.warn(`Failed to parse theme file ${file}: ${(err as Error).message}`);
}
}
}
/**
* After disk-loading, backfill any of the 10 canonical themes that are
* missing with the matching built-in. This guarantees the service
* ALWAYS exposes all 10 theme ids regardless of disk state.
*/
private backfillMissing(): void {
for (const t of BUILTIN_THEMES) {
if (!this.themes.has(t.id)) {
this.logger.warn(`Theme '${t.id}' missing from disk; using built-in default`);
this.themes.set(t.id, t);
}
}
}
/**
* Validate the configured theme key (from settings) against the 10
* canonical ids. If it's missing or invalid, fall back to 'classic' with
* a warning. Persists the canonical value back to settings so future
* reads are consistent.
*/
private async validateConfiguredKey(): Promise<void> {
let configured = 'classic';
try {
configured = await this.settings.get(SETTINGS_KEYS.THEME_KEY, 'classic');
} catch (e) {
// The setting table may not exist yet (early boot, migrations not
// run). Fall back to the safe default and try again later.
this.logger.warn(`Could not read configured themeKey (${(e as Error).message}); falling back to 'classic'`);
this.defaultThemeId = 'classic';
return;
}
const validIds = new Set<string>(THEME_IDS);
if (!configured || !validIds.has(configured)) {
this.logger.warn(`Configured themeKey '${configured}' is not in [${THEME_IDS.join(',')}]; falling back to 'classic'`);
this.defaultThemeId = 'classic';
try {
await this.settings.set(SETTINGS_KEYS.THEME_KEY, 'classic');
} catch {
// Setting table may still not exist; ignore.
}
} else {
this.defaultThemeId = configured as ThemeId;
}
}
private validateTokens(t: ThemeTokens): boolean {
const required = ['primary', 'secondary', 'accent', 'surface', 'text', 'success', 'warning', 'danger', 'fontFamily', 'radii', 'spacingScale'];
for (const k of required) {
if (!(k in t)) return false;
}
if (!t.radii.sm || !t.radii.md || !t.radii.lg) return false;
if (!Array.isArray(t.spacingScale) || t.spacingScale.length === 0) return false;
return true;
}
getTheme(id: string | null | undefined): Theme {
if (!id) return this.themes.get(this.defaultThemeId)!;
const t = this.themes.get(id);
if (t) return t;
this.logger.warn(`Unknown theme id '${id}'; falling back to '${this.defaultThemeId}'`);
return this.themes.get(this.defaultThemeId)!;
}
getDefaultTheme(): Theme {
return this.themes.get(this.defaultThemeId)!;
}
listThemes(): Theme[] {
return Array.from(this.themes.values());
}
/** Test/internal accessor for the resolved default id. */
getDefaultId(): ThemeId {
return this.defaultThemeId;
}
}
export const BUILTIN_THEMES: Theme[] = [
{
id: 'classic',
name: 'Classic',
tokens: {
primary: '#3b82f6', secondary: '#64748b', accent: '#f59e0b',
surface: '#ffffff', text: '#0f172a',
success: '#10b981', warning: '#f59e0b', danger: '#ef4444',
fontFamily: 'system-ui, sans-serif',
radii: { sm: '4px', md: '8px', lg: '16px' },
spacingScale: [4, 8, 12, 16, 24, 32, 48],
},
},
{
id: 'midnight',
name: 'Midnight',
tokens: {
primary: '#6366f1', secondary: '#1e293b', accent: '#22d3ee',
surface: '#0f172a', text: '#e2e8f0',
success: '#34d399', warning: '#fbbf24', danger: '#f87171',
fontFamily: 'system-ui, sans-serif',
radii: { sm: '4px', md: '8px', lg: '16px' },
spacingScale: [4, 8, 12, 16, 24, 32, 48],
},
},
{
id: 'sunset',
name: 'Sunset',
tokens: {
primary: '#f97316', secondary: '#fb7185', accent: '#facc15',
surface: '#fff7ed', text: '#431407',
success: '#84cc16', warning: '#f59e0b', danger: '#dc2626',
fontFamily: 'Georgia, serif',
radii: { sm: '6px', md: '12px', lg: '24px' },
spacingScale: [4, 8, 12, 16, 24, 32, 48],
},
},
{
id: 'forest',
name: 'Forest',
tokens: {
primary: '#16a34a', secondary: '#65a30d', accent: '#ca8a04',
surface: '#f0fdf4', text: '#14532d',
success: '#15803d', warning: '#ca8a04', danger: '#b91c1c',
fontFamily: 'system-ui, sans-serif',
radii: { sm: '4px', md: '8px', lg: '12px' },
spacingScale: [4, 8, 12, 16, 24, 32, 48],
},
},
{
id: 'cyber',
name: 'Cyber',
tokens: {
primary: '#00ffd5', secondary: '#ff00aa', accent: '#ffee00',
surface: '#000000', text: '#00ffd5',
success: '#00ff88', warning: '#ffee00', danger: '#ff0066',
fontFamily: 'Courier New, monospace',
radii: { sm: '0', md: '0', lg: '0' },
spacingScale: [4, 8, 12, 16, 24, 32, 48],
},
},
{
id: 'paper',
name: 'Paper',
tokens: {
primary: '#1f2937', secondary: '#6b7280', accent: '#0ea5e9',
surface: '#fafaf9', text: '#1c1917',
success: '#059669', warning: '#d97706', danger: '#b91c1c',
fontFamily: 'Georgia, serif',
radii: { sm: '2px', md: '4px', lg: '8px' },
spacingScale: [4, 8, 12, 16, 24, 32, 48],
},
},
{
id: 'crimson',
name: 'Crimson',
tokens: {
primary: '#dc2626', secondary: '#991b1b', accent: '#fbbf24',
surface: '#1f1f1f', text: '#fef2f2',
success: '#22c55e', warning: '#fbbf24', danger: '#ef4444',
fontFamily: 'system-ui, sans-serif',
radii: { sm: '4px', md: '8px', lg: '12px' },
spacingScale: [4, 8, 12, 16, 24, 32, 48],
},
},
{
id: 'ocean',
name: 'Ocean',
tokens: {
primary: '#0ea5e9', secondary: '#06b6d4', accent: '#14b8a6',
surface: '#f0f9ff', text: '#0c4a6e',
success: '#10b981', warning: '#f59e0b', danger: '#ef4444',
fontFamily: 'system-ui, sans-serif',
radii: { sm: '8px', md: '16px', lg: '24px' },
spacingScale: [4, 8, 12, 16, 24, 32, 48],
},
},
{
id: 'neon',
name: 'Neon',
tokens: {
primary: '#a855f7', secondary: '#ec4899', accent: '#22d3ee',
surface: '#0a0a0a', text: '#f5f3ff',
success: '#4ade80', warning: '#fbbf24', danger: '#fb7185',
fontFamily: 'system-ui, sans-serif',
radii: { sm: '4px', md: '12px', lg: '20px' },
spacingScale: [4, 8, 12, 16, 24, 32, 48],
},
},
{
id: 'monochrome',
name: 'Monochrome',
tokens: {
primary: '#000000', secondary: '#525252', accent: '#a3a3a3',
surface: '#ffffff', text: '#0a0a0a',
success: '#404040', warning: '#737373', danger: '#171717',
fontFamily: 'system-ui, sans-serif',
radii: { sm: '0', md: '0', lg: '0' },
spacingScale: [4, 8, 12, 16, 24, 32, 48],
},
},
];
+110
View File
@@ -0,0 +1,110 @@
import { ConfigService } from '@nestjs/config';
import * as path from 'path';
import * as fs from 'fs';
import * as crypto from 'crypto';
import multer from 'multer';
export interface UploadLimits {
fileSize: number;
}
/**
* Build a multer-style `limits` object from the UPLOAD_SIZE_LIMIT env value.
* Examples: '50mb', '500kb', '2gb'. Defaults to '50mb'.
*/
export function parseUploadSizeLimit(value: string | undefined): number {
const v = (value ?? '50mb').trim().toLowerCase();
const m = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)$/.exec(v);
if (!m) return 50 * 1024 * 1024;
const n = parseFloat(m[1]);
const unit = m[2];
const mult = unit === 'b' ? 1 : unit === 'kb' ? 1024 : unit === 'mb' ? 1024 * 1024 : 1024 * 1024 * 1024;
return Math.floor(n * mult);
}
export function buildUploadLimits(config: ConfigService): UploadLimits {
const v = config.get<string>('UPLOAD_SIZE_LIMIT', '50mb');
return { fileSize: parseUploadSizeLimit(v) };
}
/**
* Produce a safe, collision-resistant filename from the original upload
* filename. Strips directory traversal, lowercases, restricts to a safe
* charset, caps the stem length, and appends a short random suffix so
* identical names from different uploads never overwrite each other.
*/
export function safeFilename(original: string): string {
const base = path.basename(original || 'file').toLowerCase();
const cleaned = base
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 120);
const stem = cleaned || 'file';
const dot = stem.lastIndexOf('.');
const head = dot > 0 ? stem.slice(0, dot) : stem;
const ext = dot > 0 ? stem.slice(dot) : '';
const suffix = crypto.randomBytes(4).toString('hex');
const safeHead = (head || 'file').slice(0, 100);
return `${safeHead}-${suffix}${ext}`;
}
/**
* Build a configured Multer instance with a per-route `fileSize` limit
* (taken from `UPLOAD_SIZE_LIMIT`) and safe-filename storage at the
* supplied destination directory.
*/
export interface MulterOptions {
destination?: string;
fieldName?: string;
fileSize?: number;
}
export function buildMulter(config: ConfigService, opts: MulterOptions = {}): multer.Multer {
const limits = opts.fileSize !== undefined
? { fileSize: opts.fileSize }
: buildUploadLimits(config);
const dest = opts.destination ?? path.resolve(config.get<string>('UPLOAD_DIR', './data/uploads'));
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
return multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, dest),
filename: (_req, file, cb) => cb(null, safeFilename(file.originalname)),
}),
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<string>('BODY_SIZE_LIMIT', '1mb');
return parseUploadSizeLimit(raw);
}
/**
* Resolve and ensure a private staging directory dedicated to system
* operations (backup/restore staging). Defaults to `<DATA_DIR>/.system-staging`
* and never returns a directory inside UPLOAD_DIR.
*/
export function resolveSystemStagingDir(config: ConfigService): string {
const explicit = config.get<string>('SYSTEM_OP_STAGING_DIR');
const uploadDir = path.resolve(config.get<string>('UPLOAD_DIR', './data/uploads'));
let dir: string;
if (explicit && explicit.trim().length > 0) {
dir = path.resolve(explicit);
} else {
const databasePath = config.get<string>('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;
}
+80
View File
@@ -0,0 +1,80 @@
import { z } from 'zod';
export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_PATH: z.string().min(1).default('./data/db.sqlite'),
UPLOAD_DIR: z.string().min(1).default('./data/uploads'),
THEMES_DIR: z.string().min(1).default('./themes'),
FRONTEND_DIST: z.string().min(1).default('../frontend/dist'),
JWT_ACCESS_SECRET: z.string().min(32).default('dev-access-secret-change-me-please-32chars'),
JWT_REFRESH_SECRET: z.string().min(32).default('dev-refresh-secret-change-me-please-32ch'),
CSRF_SECRET: z.string().min(32).default('dev-csrf-secret-change-me-please-32chars!!'),
JWT_ACCESS_TTL: z.string().default('15m'),
JWT_REFRESH_TTL: z.string().default('7d'),
ARGON2_MEMORY_COST: z.coerce.number().int().positive().default(65536),
ARGON2_TIME_COST: z.coerce.number().int().positive().default(3),
ARGON2_PARALLELISM: z.coerce.number().int().positive().default(1),
PASSWORD_MIN_LENGTH: z.coerce.number().int().positive().default(12),
PASSWORD_REQUIRE_MIXED: z.coerce.boolean().default(true),
CORS_ORIGINS: z.string().default('http://localhost:4200,http://localhost:3000'),
BODY_SIZE_LIMIT: z.string().default('1mb'),
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<typeof envSchema>;
export function validateEnv(raw: Record<string, unknown>): AppEnv {
const parsed = envSchema.safeParse(raw);
if (!parsed.success) {
const issues = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
throw new Error(`Invalid environment configuration: ${issues}`);
}
return parsed.data;
}
export const SETTINGS_KEYS = {
PAGE_TITLE: 'pageTitle',
LOGO: 'logo',
WELCOME_MARKDOWN: 'welcomeMarkdown',
THEME_KEY: 'themeKey',
DEFAULT_CHALLENGE_IP: 'defaultChallengeIp',
REGISTRATIONS_ENABLED: 'registrationsEnabled',
EVENT_START_UTC: 'eventStartUtc',
EVENT_END_UTC: 'eventEndUtc',
} as const;
export const DEFAULT_SETTINGS: Record<string, string> = {
[SETTINGS_KEYS.PAGE_TITLE]: 'HIPCTF',
[SETTINGS_KEYS.LOGO]: '',
[SETTINGS_KEYS.WELCOME_MARKDOWN]: '# Welcome\n\nCreate the first admin to get started.',
[SETTINGS_KEYS.THEME_KEY]: 'classic',
[SETTINGS_KEYS.DEFAULT_CHALLENGE_IP]: '127.0.0.1',
[SETTINGS_KEYS.REGISTRATIONS_ENABLED]: 'false',
[SETTINGS_KEYS.EVENT_START_UTC]: new Date(Date.now() + 60_000).toISOString(),
[SETTINGS_KEYS.EVENT_END_UTC]: new Date(Date.now() + 7 * 24 * 60 * 60_000).toISOString(),
};
export const SYSTEM_CATEGORY_KEYS = ['crypto', 'forensics', 'pwn', 'web', 'misc', 'osint'] as const;
export type SystemCategoryKey = (typeof SYSTEM_CATEGORY_KEYS)[number];
export const SYSTEM_CATEGORY_META: Record<SystemCategoryKey, { name: string; abbreviation: string; description: string; iconPath: string }> = {
crypto: { name: 'Cryptography', abbreviation: 'CRY', description: 'Crypto challenges', iconPath: '/uploads/icons/crypto.svg' },
forensics: { name: 'Forensics', abbreviation: 'FOR', description: 'Forensics challenges', iconPath: '/uploads/icons/forensics.svg' },
pwn: { name: 'Pwn', abbreviation: 'PWN', description: 'Binary exploitation', iconPath: '/uploads/icons/pwn.svg' },
web: { name: 'Web', abbreviation: 'WEB', description: 'Web exploitation', iconPath: '/uploads/icons/web.svg' },
misc: { name: 'Misc', abbreviation: 'MSC', description: 'Miscellaneous challenges', iconPath: '/uploads/icons/misc.svg' },
osint: { name: 'OSINT', abbreviation: 'OSI', description: 'Open-source intelligence', iconPath: '/uploads/icons/osint.svg' },
};
@@ -0,0 +1,203 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import * as path from 'path';
import { FilesystemTransactionService } from '../modules/admin/system/filesystem-transaction.service';
import { resolveSystemStagingDir } from '../common/utils/upload';
import { seedSystemCategoryIcons } from './system-category-icons';
@Injectable()
export class DatabaseInitService implements OnApplicationBootstrap {
private readonly logger = new Logger(DatabaseInitService.name);
private initialized = false;
constructor(
private readonly dataSource: DataSource,
private readonly config: ConfigService,
private readonly fsTx: FilesystemTransactionService,
) {}
/**
* Explicit, awaitable database initialization.
*
* - Recovers any interrupted filesystem swap transactions (durable
* manifest protocol) BEFORE touching the database or uploads.
* - Initializes the DataSource (idempotent — TypeORM returns the existing
* connection if already initialized).
* - Runs all pending migrations in order.
* - Verifies that the expected schema and seeded data are present.
*
* `main.ts` calls this BEFORE `app.listen()` so the HTTP server never
* accepts traffic until the DB is fully migrated and seeded, and so the
* server never boots in a half-swapped state.
*/
async init(): Promise<void> {
if (this.initialized) return;
await this.recoverInterruptedSystemTransactions();
if (!this.dataSource.isInitialized) {
await this.dataSource.initialize();
}
// Enforce WAL journal mode on every startup. The InitSchema migration
// also sets it for fresh DBs, but warm DBs (where migrations are
// skipped) need it applied here so PRAGMA journal_mode is always 'wal'.
await this.ensureWalMode();
const pending = await this.dataSource.showMigrations();
const pendingList: string[] = Array.isArray(pending) ? pending : [];
this.logger.log(`Pending migrations: ${pendingList.length}`);
if (pendingList.length > 0 || !(await this.hasCategoryTable())) {
const ran = await this.dataSource.runMigrations({ transaction: 'each' });
this.logger.log(`Ran ${ran.length} migration(s): ${ran.map((m) => m.name).join(', ')}`);
} else {
this.logger.log('Database schema is up to date');
}
try {
await this.verifySeed();
} catch (e) {
this.logger.warn(`verifySeed skipped: ${(e as Error).message}`);
}
try {
await this.ensureSystemCategoryIcons();
} catch (e) {
this.logger.warn(`ensureSystemCategoryIcons skipped: ${(e as Error).message}`);
}
this.initialized = true;
}
async onApplicationBootstrap(): Promise<void> {
await this.init();
}
/**
* Resolve any durable filesystem transaction that was interrupted by a
* process death during a restore / danger-zone swap. Recovery runs
* before the TypeORM connection is opened so the application can never
* observe a half-applied swap.
*
* Steps:
* 1. Resolve every pending manifest under `<system-staging>` to a
* coherent on-disk state (restore old generation or finalize new).
* Throws and aborts startup if any manifest is ambiguous.
* 2. Sweep unowned rollback / restore-stage / wipe-stage artifacts
* that linger next to the live database and uploads directories,
* so a pre-existing half-finished state (such as the Job 919
* incident report) is also cleaned up.
*/
private async recoverInterruptedSystemTransactions(): Promise<void> {
const databasePath = path.resolve(
this.config.get<string>('DATABASE_PATH', './data/db.sqlite'),
);
const uploadDir = path.resolve(
this.config.get<string>('UPLOAD_DIR', './data/uploads'),
);
const stagingDir = resolveSystemStagingDir(this.config);
try {
const report = this.fsTx.recoverTransactions(stagingDir);
if (report.transactionsScanned > 0) {
this.logger.log(
`Filesystem transaction recovery: scanned=${report.transactionsScanned} resolved=${report.resolved} aborted=${report.aborted.length} cleaned=${report.cleaned.length}`,
);
}
} catch (err) {
this.logger.error(
`Filesystem transaction recovery failed: ${(err as Error).message}`,
);
throw err;
}
try {
const cleaned = this.fsTx.sweepUnownedArtifactsNearLivePaths(
[databasePath, uploadDir],
{ stagingDir },
);
if (cleaned.length > 0) {
this.logger.log(
`Removed unowned system artifacts on startup: ${cleaned.join(', ')}`,
);
}
} catch (err) {
// Sweeps are best-effort; if they fail we never abort startup
// because leftover artifacts do not break read access.
this.logger.warn(
`Unowned-artifact sweep failed: ${(err as Error).message}`,
);
}
}
/**
* Read PRAGMA journal_mode and switch to WAL if not already enabled.
* Persisted for file-backed databases; idempotent and safe on every
* startup.
*/
private async ensureSystemCategoryIcons(): Promise<void> {
const uploadDirRaw = this.config.get<string>('UPLOAD_DIR', './data/uploads');
if (!uploadDirRaw || uploadDirRaw.includes(':memory:')) {
this.logger.debug('ensureSystemCategoryIcons skipped: in-memory UPLOAD_DIR');
return;
}
const uploadDir = path.resolve(uploadDirRaw);
const result = await seedSystemCategoryIcons(uploadDir);
this.logger.log(
`System category icons: written=${result.written.length} skipped=${result.skipped.length}`,
);
}
private async ensureWalMode(): Promise<void> {
try {
const rows: any[] = await this.dataSource.query('PRAGMA journal_mode');
const current = String(rows?.[0]?.journal_mode ?? '').toLowerCase();
if (current === 'wal') {
this.logger.log('PRAGMA journal_mode=wal (already set)');
return;
}
const setRows: any[] = await this.dataSource.query('PRAGMA journal_mode = WAL');
const actual = String(setRows?.[0]?.journal_mode ?? '').toLowerCase();
this.logger.log(`PRAGMA journal_mode set (was='${current}', now='${actual}')`);
} catch (e) {
this.logger.warn(`ensureWalMode skipped: ${(e as Error).message}`);
}
}
private async verifySeed(): Promise<void> {
const categoryCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "category"').then((rows: any[]) => Number(rows[0]?.c ?? 0));
const settingsCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "setting"').then((rows: any[]) => Number(rows[0]?.c ?? 0));
this.logger.log(`Seed verified: ${categoryCount} categories, ${settingsCount} settings`);
if (categoryCount < 6) {
this.logger.warn(`Expected at least 6 seeded categories, found ${categoryCount}`);
}
try {
const systemRows: any[] = await this.dataSource.query(
`SELECT system_key, abbreviation FROM "category" WHERE "system_key" IS NOT NULL ORDER BY abbreviation`,
);
const expected = ['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'];
const present = new Set(systemRows.map((r) => String(r.system_key)));
const missing = expected.filter((k) => !present.has(k));
const duplicateSystemKeys = systemRows.length - new Set(systemRows.map((r) => String(r.system_key))).size;
if (missing.length > 0 || duplicateSystemKeys > 0) {
this.logger.warn(
`Canonical system categories mismatch: missing=[${missing.join(',')}] duplicateSystemRows=${duplicateSystemKeys}`,
);
}
} catch (e) {
this.logger.warn(`verifySeed system-category check skipped: ${(e as Error).message}`);
}
}
private async hasCategoryTable(): Promise<boolean> {
try {
const rows: any[] = await this.dataSource.query("SELECT name FROM sqlite_master WHERE type='table' AND name='category'");
return rows.length > 0;
} catch {
return false;
}
}
getDbPath(): string {
return this.config.get<string>('DATABASE_PATH', './data/db.sqlite');
}
}
+85
View File
@@ -0,0 +1,85 @@
import { Module, Global, Logger } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import * as fs from 'fs';
import * as path from 'path';
import { UserEntity } from './entities/user.entity';
import { SettingEntity } from './entities/setting.entity';
import { CategoryEntity } from './entities/category.entity';
import { ChallengeEntity } from './entities/challenge.entity';
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';
import { UpdateSystemCategoryKeys1700000000300 } from './migrations/1700000000300-UpdateSystemCategoryKeys';
import { RepairCategorySchemaAndSystemCategories1700000000400 } from './migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from './migrations/1700000000500-UpgradeChallengeAdminSchema';
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';
import { FilesystemTransactionModule } from '../modules/admin/system/filesystem-transaction.module';
const ENTITIES = [
UserEntity,
SettingEntity,
CategoryEntity,
ChallengeEntity,
ChallengeFileEntity,
SolveEntity,
RefreshTokenEntity,
BlogPostEntity,
AdminOperationTokenEntity,
];
const MIGRATIONS = [
InitSchema1700000000000,
SeedSystemData1700000000100,
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
SeedSampleChallenges1700000000600,
AddUserDeleteCascades1700000000700,
AddUserLastAdminTriggers1700000000800,
AddAdminOperationTokens1700000000900,
];
@Global()
@Module({
imports: [
FilesystemTransactionModule,
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const dbPath = config.get<string>('DATABASE_PATH', './data/db.sqlite');
const isMemory = dbPath === ':memory:' || dbPath.startsWith('file:');
if (!isMemory) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
return {
type: 'better-sqlite3',
database: dbPath,
entities: ENTITIES,
migrations: MIGRATIONS,
migrationsRun: false,
synchronize: false,
logging: false,
};
},
}),
TypeOrmModule.forFeature(ENTITIES),
],
providers: [DatabaseInitService],
exports: [TypeOrmModule, DatabaseInitService],
})
export class DatabaseModule {
private readonly logger = new Logger(DatabaseModule.name);
}
@@ -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;
}
@@ -0,0 +1,28 @@
import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
export type BlogStatus = 'draft' | 'published';
@Entity('blog_post')
export class BlogPostEntity {
@PrimaryColumn('text')
id!: string;
@Column('text')
title!: string;
@Column('text', { name: 'body_md', default: '' })
bodyMd!: string;
@Index('idx_blog_status_published', ['status', 'publishedAt'])
@Column('text', { default: 'draft' })
status!: BlogStatus;
@CreateDateColumn({ type: 'text', name: 'created_at' })
createdAt!: string;
@UpdateDateColumn({ type: 'text', name: 'updated_at' })
updatedAt!: string;
@Column('text', { name: 'published_at', nullable: true })
publishedAt!: string | null;
}
@@ -0,0 +1,30 @@
import { Entity, PrimaryColumn, Column, Index } from 'typeorm';
@Entity('category')
@Index('uq_category_abbreviation', ['abbreviation'], { unique: true })
export class CategoryEntity {
@PrimaryColumn('text')
id!: string;
@Index({ unique: true, where: 'system_key IS NOT NULL' })
@Column('text', { name: 'system_key', nullable: true })
systemKey!: string | null;
@Column('text')
name!: string;
@Column('text')
abbreviation!: string;
@Column('text', { default: '' })
description!: string;
@Column('text', { name: 'icon_path', default: '' })
iconPath!: string;
@Column('text', { name: 'created_at', default: '' })
createdAt!: string;
@Column('text', { name: 'updated_at', default: '' })
updatedAt!: string;
}
@@ -0,0 +1,40 @@
import {
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { ChallengeEntity } from './challenge.entity';
@Entity('challenge_file')
export class ChallengeFileEntity {
@PrimaryColumn('text')
id!: string;
@Index()
@Column('text', { name: 'challenge_id' })
challengeId!: string;
@ManyToOne(() => ChallengeEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'challenge_id' })
challenge?: ChallengeEntity;
@Column('text', { name: 'original_filename' })
originalFilename!: string;
@Index({ unique: true })
@Column('text', { name: 'stored_filename' })
storedFilename!: string;
@Column('text', { name: 'mime_type', default: 'application/octet-stream' })
mimeType!: string;
@Column('integer', { name: 'size_bytes', default: 0 })
sizeBytes!: number;
@CreateDateColumn({ type: 'text', name: 'created_at' })
createdAt!: string;
}
@@ -0,0 +1,67 @@
import {
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { CategoryEntity } from './category.entity';
export type Difficulty = 'LOW' | 'MEDIUM' | 'HIGH';
export type Protocol = 'NC' | 'WEB';
@Entity('challenge')
export class ChallengeEntity {
@PrimaryColumn('text')
id!: string;
@Column('text')
name!: string;
@Column('text', { name: 'description_md', default: '' })
descriptionMd!: string;
@Index()
@Column('text', { name: 'category_id' })
categoryId!: string;
@ManyToOne(() => CategoryEntity)
@JoinColumn({ name: 'category_id' })
category?: CategoryEntity;
@Column('text', { default: 'MEDIUM' })
difficulty!: Difficulty;
@Column('integer', { name: 'initial_points', default: 100 })
initialPoints!: number;
@Column('integer', { name: 'minimum_points', default: 50 })
minimumPoints!: number;
@Column('integer', { name: 'decay_solves', default: 10 })
decaySolves!: number;
@Column('text', { default: '' })
flag!: string;
@Column('text', { default: 'WEB' })
protocol!: Protocol;
@Column('integer', { nullable: true })
port!: number | null;
@Column('text', { name: 'ip_address', default: '' })
ipAddress!: string;
@Column('integer', { default: 1 })
enabled!: boolean;
@CreateDateColumn({ type: 'text', name: 'created_at' })
createdAt!: string;
@UpdateDateColumn({ type: 'text', name: 'updated_at' })
updatedAt!: string;
}
@@ -0,0 +1,28 @@
import { Entity, PrimaryColumn, Column, Index, ManyToOne, JoinColumn } from 'typeorm';
import { UserEntity } from './user.entity';
@Entity('refresh_token')
export class RefreshTokenEntity {
@PrimaryColumn('text')
id!: string;
@Index()
@Column('text', { name: 'user_id' })
userId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: UserEntity;
@Column('text', { name: 'token_hash', unique: true })
tokenHash!: string;
@Column('text', { name: 'issued_at' })
issuedAt!: string;
@Column('text', { name: 'expires_at' })
expiresAt!: string;
@Column('text', { name: 'revoked_at', nullable: true })
revokedAt!: string | null;
}
@@ -0,0 +1,10 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
@Entity('setting')
export class SettingEntity {
@PrimaryColumn('text')
key!: string;
@Column('text', { default: '' })
value!: string;
}
@@ -0,0 +1,55 @@
import {
Entity,
PrimaryColumn,
Column,
Index,
Unique,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { ChallengeEntity } from './challenge.entity';
import { UserEntity } from './user.entity';
@Entity('solve')
@Unique('uq_solve_challenge_user', ['challengeId', 'userId'])
@Index('idx_solve_user', ['userId'])
@Index('idx_solve_challenge', ['challengeId'])
export class SolveEntity {
@PrimaryColumn('text')
id!: string;
@Column('text', { name: 'challenge_id' })
challengeId!: string;
@ManyToOne(() => ChallengeEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'challenge_id' })
challenge?: ChallengeEntity;
@Column('text', { name: 'user_id' })
userId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: UserEntity;
@Column('text', { name: 'solved_at' })
solvedAt!: string;
@Column('integer', { name: 'points_awarded', default: 0 })
pointsAwarded!: number;
@Column('integer', { name: 'base_points', default: 0 })
basePoints!: number;
@Column('integer', { name: 'rank_bonus', default: 0 })
rankBonus!: number;
@Column('integer', { name: 'is_first', default: 0 })
isFirst!: boolean;
@Column('integer', { name: 'is_second', default: 0 })
isSecond!: boolean;
@Column('integer', { name: 'is_third', default: 0 })
isThird!: boolean;
}
@@ -0,0 +1,25 @@
import { Entity, PrimaryColumn, Column, CreateDateColumn } from 'typeorm';
export type UserRole = 'admin' | 'player';
export type UserStatus = 'enabled' | 'disabled';
@Entity('user')
export class UserEntity {
@PrimaryColumn('text')
id!: string;
@Column('text', { unique: true })
username!: string;
@Column('text', { name: 'password_hash' })
passwordHash!: string;
@Column('text', { default: 'player' })
role!: UserRole;
@Column('text', { default: 'enabled' })
status!: UserStatus;
@CreateDateColumn({ type: 'text', name: 'created_at' })
createdAt!: string;
}
@@ -0,0 +1,126 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitSchema1700000000000 implements MigrationInterface {
name = 'InitSchema1700000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
await queryRunner.query(`PRAGMA journal_mode = WAL;`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "user" (
"id" TEXT PRIMARY KEY,
"username" TEXT NOT NULL UNIQUE,
"password_hash" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'player',
"status" TEXT NOT NULL DEFAULT 'enabled',
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "setting" (
"key" TEXT PRIMARY KEY,
"value" TEXT NOT NULL DEFAULT ''
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "category" (
"id" TEXT PRIMARY KEY,
"system_key" TEXT,
"name" TEXT NOT NULL,
"abbreviation" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"icon_path" TEXT NOT NULL DEFAULT '',
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
"updated_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "challenge" (
"id" TEXT PRIMARY KEY,
"name" TEXT NOT NULL,
"description_md" TEXT NOT NULL DEFAULT '',
"category_id" TEXT NOT NULL,
"difficulty" TEXT NOT NULL DEFAULT 'low',
"initial_points" INTEGER NOT NULL DEFAULT 0,
"minimum_points" INTEGER NOT NULL DEFAULT 0,
"decay_solves" INTEGER NOT NULL DEFAULT 0,
"flag" TEXT NOT NULL DEFAULT '',
"protocol" TEXT NOT NULL DEFAULT 'nc',
"port" INTEGER,
"ip_address" TEXT NOT NULL DEFAULT '',
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
CONSTRAINT "fk_challenge_category" FOREIGN KEY ("category_id") REFERENCES "category"("id") ON DELETE RESTRICT
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_challenge_category" ON "challenge"("category_id");`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "challenge_file" (
"id" TEXT PRIMARY KEY,
"challenge_id" TEXT NOT NULL,
"original_filename" TEXT NOT NULL,
"stored_path" TEXT NOT NULL,
CONSTRAINT "fk_challenge_file_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE RESTRICT
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_challenge_file_challenge" ON "challenge_file"("challenge_id");`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "solve" (
"id" TEXT PRIMARY KEY,
"challenge_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"solved_at" TEXT NOT NULL,
"points_awarded" INTEGER NOT NULL DEFAULT 0,
"base_points" INTEGER NOT NULL DEFAULT 0,
"rank_bonus" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "fk_solve_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE RESTRICT,
CONSTRAINT "fk_solve_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE RESTRICT
);
`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_solve_challenge_user" ON "solve"("challenge_id","user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_user" ON "solve"("user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_challenge" ON "solve"("challenge_id");`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "refresh_token" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL UNIQUE,
"issued_at" TEXT NOT NULL,
"expires_at" TEXT NOT NULL,
"revoked_at" TEXT
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_refresh_token_user" ON "refresh_token"("user_id");`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "blog_post" (
"id" TEXT PRIMARY KEY,
"title" TEXT NOT NULL,
"body_md" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'draft',
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
"updated_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
"published_at" TEXT
);
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_blog_status_published" ON "blog_post"("status","published_at");`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "blog_post";`);
await queryRunner.query(`DROP TABLE IF EXISTS "refresh_token";`);
await queryRunner.query(`DROP TABLE IF EXISTS "solve";`);
await queryRunner.query(`DROP TABLE IF EXISTS "challenge_file";`);
await queryRunner.query(`DROP TABLE IF EXISTS "challenge";`);
await queryRunner.query(`DROP TABLE IF EXISTS "category";`);
await queryRunner.query(`DROP TABLE IF EXISTS "setting";`);
await queryRunner.query(`DROP TABLE IF EXISTS "user";`);
}
}
@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { SYSTEM_CATEGORY_KEYS, SYSTEM_CATEGORY_META, DEFAULT_SETTINGS, SETTINGS_KEYS } from '../../config/env.schema';
import { v4 as uuid } from 'uuid';
export class SeedSystemData1700000000100 implements MigrationInterface {
name = 'SeedSystemData1700000000100';
public async up(queryRunner: QueryRunner): Promise<void> {
for (const key of SYSTEM_CATEGORY_KEYS) {
const meta = SYSTEM_CATEGORY_META[key];
const existing = await queryRunner.query(`SELECT id FROM "category" WHERE "system_key" = ?`, [key]);
if (existing.length > 0) continue;
await queryRunner.query(
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path") VALUES (?,?,?,?,?,?)`,
[uuid(), key, meta.name, meta.abbreviation, meta.description, meta.iconPath],
);
}
for (const key of Object.values(SETTINGS_KEYS)) {
const existing = await queryRunner.query(`SELECT "key" FROM "setting" WHERE "key" = ?`, [key]);
if (existing.length > 0) continue;
const value = DEFAULT_SETTINGS[key] ?? '';
await queryRunner.query(`INSERT INTO "setting" ("key","value") VALUES (?,?)`, [key, value]);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM "category" WHERE "system_key" IS NOT NULL;`);
await queryRunner.query(`DELETE FROM "setting";`);
}
}
@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCategoryTimestampsAndUniqueAbbrev1700000000200 implements MigrationInterface {
name = 'AddCategoryTimestampsAndUniqueAbbrev1700000000200';
public async up(queryRunner: QueryRunner): Promise<void> {
const cols: any[] = await queryRunner.query(`PRAGMA table_info("category")`);
const names = new Set(cols.map((c: any) => c.name));
if (!names.has('created_at')) {
await queryRunner.query(`ALTER TABLE "category" ADD COLUMN "created_at" TEXT NOT NULL DEFAULT '';`);
await queryRunner.query(
`UPDATE "category" SET "created_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "created_at" = '';`,
);
}
if (!names.has('updated_at')) {
await queryRunner.query(`ALTER TABLE "category" ADD COLUMN "updated_at" TEXT NOT NULL DEFAULT '';`);
await queryRunner.query(
`UPDATE "category" SET "updated_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "updated_at" = '';`,
);
}
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_abbreviation" ON "category"("abbreviation");`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS "uq_category_abbreviation";`);
await queryRunner.query(`ALTER TABLE "category" DROP COLUMN "updated_at";`);
await queryRunner.query(`ALTER TABLE "category" DROP COLUMN "created_at";`);
}
}
@@ -0,0 +1,60 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateSystemCategoryKeys1700000000300 implements MigrationInterface {
name = 'UpdateSystemCategoryKeys1700000000300';
public async up(queryRunner: QueryRunner): Promise<void> {
const desired: { key: string; name: string; abbreviation: string; description: string; iconPath: string }[] = [
{ key: 'CRY', name: 'Cryptography', abbreviation: 'CRY', description: 'Cryptographic challenges', iconPath: '/uploads/icons/CRY.png' },
{ key: 'MSC', name: 'Misc', abbreviation: 'MSC', description: 'Miscellaneous challenges', iconPath: '/uploads/icons/MSC.png' },
{ key: 'PWN', name: 'Pwn', abbreviation: 'PWN', description: 'Binary exploitation', iconPath: '/uploads/icons/PWN.png' },
{ key: 'REV', name: 'Reverse Engineering', abbreviation: 'REV', description: 'Reverse engineering challenges', iconPath: '/uploads/icons/REV.png' },
{ key: 'WEB', name: 'Web', abbreviation: 'WEB', description: 'Web exploitation challenges', iconPath: '/uploads/icons/WEB.png' },
{ key: 'HW', name: 'Hardware', abbreviation: 'HW', description: 'Hardware challenges', iconPath: '/uploads/icons/HW.png' },
];
const desiredKeys = new Set(desired.map((d) => d.key));
const desiredAbbrs = new Set(desired.map((d) => d.abbreviation));
// First, drop any legacy seeded system rows whose system_key is no
// longer in the desired set and whose abbreviation IS also in the
// desired set (i.e. they were superseded by a canonical row). This
// shrinks the table to exactly the desired 6 system rows.
const legacyRows: any[] = await queryRunner.query(
`SELECT id, system_key, abbreviation FROM "category" WHERE "system_key" IS NOT NULL`,
);
for (const row of legacyRows) {
if (desiredKeys.has(row.system_key) && desiredAbbrs.has(row.abbreviation)) {
continue; // already canonical (e.g. seeded row whose seed matched the new abbreviation)
}
if (desiredAbbrs.has(row.abbreviation)) {
// Legacy system row whose abbreviation is reused by a canonical
// entry: drop it; the canonical entry will adopt the abbreviation.
await queryRunner.query(`DELETE FROM "category" WHERE "id" = ?`, [row.id]);
} else if (!desiredKeys.has(row.system_key)) {
// Legacy system row with an abbreviation we don't keep (e.g.
// 'forensics' / 'osint'): drop it too.
await queryRunner.query(`DELETE FROM "category" WHERE "id" = ?`, [row.id]);
}
}
// Now seed missing canonical entries.
for (const d of desired) {
const existingKey = await queryRunner.query(`SELECT id FROM "category" WHERE "system_key" = ?`, [d.key]);
if (existingKey.length > 0) continue;
const existingAbbr = await queryRunner.query(`SELECT id FROM "category" WHERE "abbreviation" = ?`, [d.abbreviation]);
if (existingAbbr.length > 0) {
await queryRunner.query(`UPDATE "category" SET "system_key" = ? WHERE "id" = ?`, [d.key, existingAbbr[0].id]);
continue;
}
const id = (await import('uuid')).v4();
await queryRunner.query(
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path","created_at","updated_at") VALUES (?,?,?,?,?,?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
[id, d.key, d.name, d.abbreviation, d.description, d.iconPath],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM "category" WHERE "system_key" IN ('CRY','MSC','PWN','REV','WEB','HW');`);
}
}
@@ -0,0 +1,166 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export const CANONICAL_SYSTEM_CATEGORY_KEYS = ['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'] as const;
export interface CanonicalSystemCategory {
key: string;
name: string;
abbreviation: string;
description: string;
iconPath: string;
}
export const CANONICAL_SYSTEM_CATEGORIES: ReadonlyArray<CanonicalSystemCategory> = [
{
key: 'CRY',
name: 'Cryptography',
abbreviation: 'CRY',
description: 'Cryptographic challenges',
iconPath: '/uploads/icons/CRY.png',
},
{
key: 'HW',
name: 'Hardware',
abbreviation: 'HW',
description: 'Hardware challenges',
iconPath: '/uploads/icons/HW.png',
},
{
key: 'MSC',
name: 'Misc',
abbreviation: 'MSC',
description: 'Miscellaneous challenges',
iconPath: '/uploads/icons/MSC.png',
},
{
key: 'PWN',
name: 'Pwn',
abbreviation: 'PWN',
description: 'Binary exploitation',
iconPath: '/uploads/icons/PWN.png',
},
{
key: 'REV',
name: 'Reverse Engineering',
abbreviation: 'REV',
description: 'Reverse engineering challenges',
iconPath: '/uploads/icons/REV.png',
},
{
key: 'WEB',
name: 'Web',
abbreviation: 'WEB',
description: 'Web exploitation challenges',
iconPath: '/uploads/icons/WEB.png',
},
];
export class RepairCategorySchemaAndSystemCategories1700000000400
implements MigrationInterface
{
name = 'RepairCategorySchemaAndSystemCategories1700000000400';
public async up(queryRunner: QueryRunner): Promise<void> {
await this.ensureCategoryTimestamps(queryRunner);
await this.reconcileSystemCategories(queryRunner);
await this.ensureCategoryIndexes(queryRunner);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_abbreviation" ON "category"("abbreviation");`,
);
}
private async ensureCategoryTimestamps(queryRunner: QueryRunner): Promise<void> {
const cols: any[] = await queryRunner.query(`PRAGMA table_info("category")`);
const names = new Set(cols.map((c: any) => String(c?.name ?? '')));
if (!names.has('created_at')) {
await queryRunner.query(
`ALTER TABLE "category" ADD COLUMN "created_at" TEXT NOT NULL DEFAULT '';`,
);
}
if (!names.has('updated_at')) {
await queryRunner.query(
`ALTER TABLE "category" ADD COLUMN "updated_at" TEXT NOT NULL DEFAULT '';`,
);
}
await queryRunner.query(
`UPDATE "category" SET "created_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "created_at" IS NULL OR "created_at" = '';`,
);
await queryRunner.query(
`UPDATE "category" SET "updated_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "updated_at" IS NULL OR "updated_at" = '';`,
);
}
private async reconcileSystemCategories(queryRunner: QueryRunner): Promise<void> {
const desired = CANONICAL_SYSTEM_CATEGORIES.map((c) => ({ ...c }));
const desiredKeys = new Set(desired.map((d) => d.key));
const desiredAbbrs = new Set(desired.map((d) => d.abbreviation));
const existingSystemRows: any[] = await queryRunner.query(
`SELECT id, system_key, abbreviation FROM "category" WHERE "system_key" IS NOT NULL`,
);
const bySystemKey = new Map<string, any>();
const byAbbreviation = new Map<string, any>();
for (const row of existingSystemRows) {
if (row.system_key) bySystemKey.set(String(row.system_key), row);
if (row.abbreviation) byAbbreviation.set(String(row.abbreviation), row);
}
for (const row of existingSystemRows) {
const systemKey = String(row.system_key ?? '');
const abbreviation = String(row.abbreviation ?? '');
const isCanonicalKey = desiredKeys.has(systemKey);
const isCanonicalAbbr = desiredAbbrs.has(abbreviation);
if (!isCanonicalKey) {
await queryRunner.query(`DELETE FROM "category" WHERE "id" = ?`, [row.id]);
byAbbreviation.delete(abbreviation);
}
}
for (const d of desired) {
const keyRow = bySystemKey.get(d.key);
if (keyRow) {
await queryRunner.query(
`UPDATE "category" SET "name" = ?, "abbreviation" = ?, "description" = ?, "icon_path" = ?, "created_at" = COALESCE(NULLIF("created_at",''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), "updated_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "id" = ?`,
[d.name, d.abbreviation, d.description, d.iconPath, keyRow.id],
);
byAbbreviation.delete(d.abbreviation);
continue;
}
const abbrRow = byAbbreviation.get(d.abbreviation);
if (abbrRow) {
await queryRunner.query(
`UPDATE "category" SET "system_key" = ?, "name" = ?, "description" = ?, "icon_path" = ?, "created_at" = COALESCE(NULLIF("created_at",''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), "updated_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "id" = ?`,
[d.key, d.name, d.description, d.iconPath, abbrRow.id],
);
byAbbreviation.delete(d.abbreviation);
continue;
}
const { v4: uuid } = await import('uuid');
await queryRunner.query(
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path","created_at","updated_at") VALUES (?,?,?,?,?,?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
[uuid(), d.key, d.name, d.abbreviation, d.description, d.iconPath],
);
}
}
private async ensureCategoryIndexes(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_abbreviation" ON "category"("abbreviation");`,
);
}
}
@@ -0,0 +1,173 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Upgrades the challenge/file/solve schema to match the admin Challenges
* contract (Job 861):
*
* - canonical enums ('LOW'|'MEDIUM'|'HIGH', 'NC'|'WEB') with safer defaults;
* - `enabled`, `updated_at`, lower(name) unique index, scoring defaults;
* - ChallengeFile gains `stored_filename`, `mime_type`, `size_bytes`,
* `created_at` (unique stored_filename) and ON DELETE CASCADE from
* challenge;
* - Solve keeps the (challenge_id,user_id) uniqueness and gains the
* is_first/second/third award flags plus ON DELETE CASCADE from
* challenge while leaving the user FK at RESTRICT;
* - Category FK stays RESTRICT so the existing
* CATEGORY_HAS_CHALLENGES business rule is preserved.
*
* Strategy:
* - SQLite cannot alter FK actions in place. We rebuild the affected
* tables atomically (PRAGMA foreign_keys=OFF), preserving legacy data
* via INSERT...SELECT.
* - The migration is forward-only.
* - The migration runs inside the TypeORM-per-migration transaction
* (`runMigrations({ transaction: 'each' })`), so it must NOT issue an
* explicit BEGIN/COMMIT — those would nest and SQLite rejects nested
* transactions.
*/
export class UpgradeChallengeAdminSchema1700000000500 implements MigrationInterface {
name = 'UpgradeChallengeAdminSchema1700000000500';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`PRAGMA foreign_keys = OFF;`);
// Drop dependent indexes that reference columns we are about to drop.
await queryRunner.query(`DROP INDEX IF EXISTS "idx_challenge_category";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_challenge_file_challenge";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_solve_challenge";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_solve_user";`);
await queryRunner.query(`DROP INDEX IF EXISTS "uq_solve_challenge_user";`);
await queryRunner.query(`ALTER TABLE "challenge" RENAME TO "_challenge_old";`);
await queryRunner.query(`ALTER TABLE "challenge_file" RENAME TO "_challenge_file_old";`);
await queryRunner.query(`ALTER TABLE "solve" RENAME TO "_solve_old";`);
await queryRunner.query(`
CREATE TABLE "challenge" (
"id" TEXT PRIMARY KEY,
"name" TEXT NOT NULL,
"description_md" TEXT NOT NULL DEFAULT '',
"category_id" TEXT NOT NULL,
"difficulty" TEXT NOT NULL DEFAULT 'MEDIUM',
"initial_points" INTEGER NOT NULL DEFAULT 100,
"minimum_points" INTEGER NOT NULL DEFAULT 50,
"decay_solves" INTEGER NOT NULL DEFAULT 10,
"flag" TEXT NOT NULL DEFAULT '',
"protocol" TEXT NOT NULL DEFAULT 'WEB',
"port" INTEGER,
"ip_address" TEXT NOT NULL DEFAULT '',
"enabled" INTEGER NOT NULL DEFAULT 1,
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
"updated_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
CONSTRAINT "fk_challenge_category" FOREIGN KEY ("category_id") REFERENCES "category"("id") ON DELETE RESTRICT
);
`);
await queryRunner.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"
)
SELECT
"id","name","description_md","category_id",
CASE UPPER("difficulty")
WHEN 'LOW' THEN 'LOW'
WHEN 'MED' THEN 'MEDIUM'
WHEN 'HIGH' THEN 'HIGH'
ELSE 'LOW'
END,
CASE WHEN "initial_points" <= 0 THEN 100 ELSE "initial_points" END,
CASE
WHEN "minimum_points" <= 0 THEN MAX(1, CAST((CASE WHEN "initial_points" <= 0 THEN 100 ELSE "initial_points" END) / 2 AS INTEGER))
WHEN "minimum_points" > "initial_points" THEN "initial_points"
ELSE "minimum_points"
END,
CASE WHEN "decay_solves" <= 0 THEN 1 ELSE "decay_solves" END,
"flag",
CASE UPPER(COALESCE("protocol",'nc')) WHEN 'NC' THEN 'NC' ELSE 'WEB' END,
"port",
COALESCE("ip_address", ''),
1,
"created_at",
COALESCE(NULLIF("created_at",''), strftime('%Y-%m-%dT%H:%M:%fZ','now'))
FROM "_challenge_old";
`);
await queryRunner.query(`DROP TABLE "_challenge_old";`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_challenge_category" ON "challenge"("category_id");`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_challenge_name_lower" ON "challenge"(LOWER("name"));`);
await queryRunner.query(`
CREATE TABLE "challenge_file" (
"id" TEXT PRIMARY KEY,
"challenge_id" TEXT NOT NULL,
"original_filename" TEXT NOT NULL,
"stored_filename" TEXT NOT NULL,
"mime_type" TEXT NOT NULL DEFAULT 'application/octet-stream',
"size_bytes" INTEGER NOT NULL DEFAULT 0,
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
CONSTRAINT "fk_challenge_file_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE CASCADE
);
`);
await queryRunner.query(`
INSERT INTO "challenge_file" (
"id","challenge_id","original_filename","stored_filename","mime_type","size_bytes","created_at"
)
SELECT
"id",
"challenge_id",
COALESCE(NULLIF("original_filename",''), "stored_path"),
COALESCE(NULLIF("stored_path",''), "id"),
'application/octet-stream',
0,
strftime('%Y-%m-%dT%H:%M:%fZ','now')
FROM "_challenge_file_old";
`);
await queryRunner.query(`DROP TABLE "_challenge_file_old";`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_challenge_file_stored_filename" ON "challenge_file"("stored_filename");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_challenge_file_challenge" ON "challenge_file"("challenge_id");`);
await queryRunner.query(`
CREATE TABLE "solve" (
"id" TEXT PRIMARY KEY,
"challenge_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"solved_at" TEXT NOT NULL,
"points_awarded" INTEGER NOT NULL DEFAULT 0,
"base_points" INTEGER NOT NULL DEFAULT 0,
"rank_bonus" INTEGER NOT NULL DEFAULT 0,
"is_first" INTEGER NOT NULL DEFAULT 0,
"is_second" INTEGER NOT NULL DEFAULT 0,
"is_third" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "fk_solve_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE CASCADE,
CONSTRAINT "fk_solve_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE RESTRICT
);
`);
await queryRunner.query(`
INSERT INTO "solve" (
"id","challenge_id","user_id","solved_at","points_awarded","base_points","rank_bonus","is_first","is_second","is_third"
)
SELECT "id","challenge_id","user_id","solved_at","points_awarded","base_points","rank_bonus",0,0,0
FROM "_solve_old";
`);
await queryRunner.query(`DROP TABLE "_solve_old";`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_solve_challenge_user" ON "solve"("challenge_id","user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_user" ON "solve"("user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_challenge" ON "solve"("challenge_id");`);
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
await queryRunner.query(`PRAGMA foreign_key_check;`);
}
public async down(): Promise<void> {
// Forward-only.
}
}
@@ -0,0 +1,168 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { v4 as uuid } from 'uuid';
interface SampleChallenge {
name: string;
systemKey: 'CRY' | 'HW' | 'MSC' | 'PWN' | 'REV' | 'WEB';
descriptionMd: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
}
const SAMPLE_CHALLENGES: SampleChallenge[] = [
{
name: 'Alpha Cipher',
systemKey: 'CRY',
descriptionMd: '# Alpha Cipher\n\nA gentle warm-up for the cryptography track.',
difficulty: 'LOW',
initialPoints: 200,
minimumPoints: 100,
decaySolves: 5,
flag: 'flag{alpha}',
},
{
name: 'Beta Cipher',
systemKey: 'CRY',
descriptionMd: '# Beta Cipher\n\nClassical substitution with a twist.',
difficulty: 'MEDIUM',
initialPoints: 300,
minimumPoints: 100,
decaySolves: 10,
flag: 'flag{beta}',
},
{
name: 'Zeta Key',
systemKey: 'CRY',
descriptionMd: '# Zeta Key\n\nA tougher modular arithmetic problem.',
difficulty: 'HIGH',
initialPoints: 500,
minimumPoints: 100,
decaySolves: 10,
flag: 'flag{zeta}',
},
{
name: 'Web Welcome',
systemKey: 'WEB',
descriptionMd: '# Web Welcome\n\nInspect the page source to find the flag.',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{web1}',
},
{
name: 'Mobile Mayhem',
systemKey: 'PWN',
descriptionMd: '# Mobile Mayhem\n\nExploit a small userspace service.',
difficulty: 'MEDIUM',
initialPoints: 300,
minimumPoints: 100,
decaySolves: 10,
flag: 'flag{mob1}',
},
{
name: 'Hardware Hello',
systemKey: 'HW',
descriptionMd: '# Hardware Hello\n\nRead the serial console output.',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{hw1}',
},
{
name: 'Markdown Mystery',
systemKey: 'MSC',
descriptionMd: '# Markdown Mystery\n\nA flag is hidden somewhere in this README.',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{md}',
},
{
name: 'Reverse Ranger',
systemKey: 'REV',
descriptionMd: '# Reverse Ranger\n\nDisassemble the binary and recover the key.',
difficulty: 'MEDIUM',
initialPoints: 300,
minimumPoints: 100,
decaySolves: 10,
flag: 'flag{rev1}',
},
];
/**
* Seeds a small, representative set of sample challenges so a fresh
* database immediately has a populated board (Job 906). Idempotent: if
* the `challenge` table already contains any rows, the migration is a
* no-op so manually-imported challenges are never overwritten.
*
* Categories are looked up by `system_key` rather than UUID so the seed
* works regardless of the UUIDs the category migration assigned at
* install time.
*/
export class SeedSampleChallenges1700000000600 implements MigrationInterface {
name = 'SeedSampleChallenges1700000000600';
public async up(queryRunner: QueryRunner): Promise<void> {
const countRows: any[] = await queryRunner.query(`SELECT COUNT(*) AS c FROM "challenge"`);
const existing = Number(countRows?.[0]?.c ?? 0);
if (existing > 0) {
return;
}
const catRows: any[] = await queryRunner.query(
`SELECT "id", "system_key" FROM "category" WHERE "system_key" IS NOT NULL`,
);
const idByKey = new Map<string, string>();
for (const row of catRows) {
if (row?.system_key && row?.id) {
idByKey.set(String(row.system_key), String(row.id));
}
}
if (idByKey.size < 6) {
return;
}
for (const sample of SAMPLE_CHALLENGES) {
const categoryId = idByKey.get(sample.systemKey);
if (!categoryId) continue;
await queryRunner.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'))`,
[
uuid(),
sample.name,
sample.descriptionMd,
categoryId,
sample.difficulty,
sample.initialPoints,
sample.minimumPoints,
sample.decaySolves,
sample.flag,
'WEB',
null,
'',
1,
],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const names = SAMPLE_CHALLENGES.map((s) => s.name);
if (names.length === 0) return;
const placeholders = names.map(() => '?').join(',');
await queryRunner.query(
`DELETE FROM "challenge" WHERE "name" IN (${placeholders})`,
names,
);
}
}
@@ -0,0 +1,97 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Updates foreign-key cascade behaviour so user deletion cleans up dependent
* rows without leaving orphaned solves or refresh tokens:
*
* - `solve.user_id` and `refresh_token.user_id` both gain
* `ON DELETE CASCADE`.
*
* Strategy mirrors UpgradeChallengeAdminSchema1700000000500:
* - SQLite cannot alter FK actions in place, so we rebuild the affected
* tables atomically with PRAGMA foreign_keys=OFF, preserving legacy
* data via INSERT...SELECT.
* - Forward-only.
*/
export class AddUserDeleteCascades1700000000700 implements MigrationInterface {
name = 'AddUserDeleteCascades1700000000700';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`PRAGMA foreign_keys = OFF;`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_solve_user";`);
await queryRunner.query(`DROP INDEX IF EXISTS "uq_solve_challenge_user";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_solve_challenge";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_refresh_token_user";`);
await queryRunner.query(`ALTER TABLE "solve" RENAME TO "_solve_old";`);
await queryRunner.query(`ALTER TABLE "refresh_token" RENAME TO "_refresh_token_old";`);
await queryRunner.query(`
CREATE TABLE "solve" (
"id" TEXT PRIMARY KEY,
"challenge_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"solved_at" TEXT NOT NULL,
"points_awarded" INTEGER NOT NULL DEFAULT 0,
"base_points" INTEGER NOT NULL DEFAULT 0,
"rank_bonus" INTEGER NOT NULL DEFAULT 0,
"is_first" INTEGER NOT NULL DEFAULT 0,
"is_second" INTEGER NOT NULL DEFAULT 0,
"is_third" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "fk_solve_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE CASCADE,
CONSTRAINT "fk_solve_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
);
`);
await queryRunner.query(`
INSERT INTO "solve" (
"id","challenge_id","user_id","solved_at","points_awarded","base_points","rank_bonus","is_first","is_second","is_third"
)
SELECT
"id","challenge_id","user_id","solved_at",
COALESCE("points_awarded",0),
COALESCE("base_points",0),
COALESCE("rank_bonus",0),
COALESCE("is_first",0),
COALESCE("is_second",0),
COALESCE("is_third",0)
FROM "_solve_old";
`);
await queryRunner.query(`DROP TABLE "_solve_old";`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_solve_challenge_user" ON "solve"("challenge_id","user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_user" ON "solve"("user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_challenge" ON "solve"("challenge_id");`);
await queryRunner.query(`
CREATE TABLE "refresh_token" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL UNIQUE,
"issued_at" TEXT NOT NULL,
"expires_at" TEXT NOT NULL,
"revoked_at" TEXT,
CONSTRAINT "fk_refresh_token_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
);
`);
await queryRunner.query(`
INSERT INTO "refresh_token" (
"id","user_id","token_hash","issued_at","expires_at","revoked_at"
)
SELECT "id","user_id","token_hash","issued_at","expires_at","revoked_at"
FROM "_refresh_token_old";
`);
await queryRunner.query(`DROP TABLE "_refresh_token_old";`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_refresh_token_user" ON "refresh_token"("user_id");`);
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
await queryRunner.query(`PRAGMA foreign_key_check;`);
}
public async down(): Promise<void> {
// Forward-only.
}
}
@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Deployment-wide safety net for the "always retain at least one admin"
* invariant. Even if a future code path bypasses
* `UsersService.applyLastAdminSafeMutation`, the database itself aborts
* the offending UPDATE/DELETE with a clear `LAST_ADMIN` error message.
*
* Triggers fire before the row mutation, so the offending statement never
* commits. The service-level wrapper still rolls back with 409 LAST_ADMIN
* in normal operation; the triggers are the belt-and-braces guard for
* cross-instance / out-of-band mistakes.
*
* Forward-only.
*/
export class AddUserLastAdminTriggers1700000000800 implements MigrationInterface {
name = 'AddUserLastAdminTriggers1700000000800';
public async up(queryRunner: QueryRunner): Promise<void> {
// UPDATE trigger: blocks any role mutation that would leave the system
// with zero admins. We use WHEN to short-circuit so the trigger body
// only fires when (a) the old role was admin, (b) the new role is no
// longer admin, and (c) no other admin row exists.
await queryRunner.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_update"
BEFORE UPDATE OF "role" ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin' AND NEW."role" <> 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
// DELETE trigger: blocks deletion of the only remaining admin row.
await queryRunner.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_delete"
BEFORE DELETE ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
}
public async down(): Promise<void> {
// Forward-only.
}
}
@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAdminOperationTokens1700000000900 implements MigrationInterface {
name = 'AddAdminOperationTokens1700000000900';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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";`);
}
}
@@ -0,0 +1,117 @@
import * as fs from 'fs';
import * as path from 'path';
import sharp from 'sharp';
/**
* Canonical system-category icon seeder.
*
* The canonical six system categories (CRY / HW / MSC / PWN / REV / WEB) are
* declared as `CANONICAL_SYSTEM_CATEGORIES` in
* `1700000000400-RepairCategorySchemaAndSystemCategories.ts`, whose
* `icon_path` column references `/uploads/icons/<KEY>.png`. The migration
* creates the DB rows but never writes the PNG assets to disk, so a freshly
* bootstrapped instance serves 404s for every system category icon.
*
* This helper closes that gap by generating a deterministic 128×128 PNG for
* each canonical key on startup. It is intentionally idempotent: existing
* files (including admin-uploaded icons written through `UploadsController`)
* are never overwritten.
*
* IMPORTANT: keep this list in sync with
* `CANONICAL_SYSTEM_CATEGORIES` in
* `1700000000400-RepairCategorySchemaAndSystemCategories.ts` and with the
* hard-coded `iconPath` URLs in
* `1700000000300-UpdateSystemCategoryKeys.ts` and
* `1700000000400-RepairCategorySchemaAndSystemCategories.ts`.
*/
export const CANONICAL_SYSTEM_ICON_KEYS = ['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'] as const;
export type CanonicalSystemIconKey = (typeof CANONICAL_SYSTEM_ICON_KEYS)[number];
export const SYSTEM_ICON_SIZE = 128;
const PALETTE: ReadonlyArray<{ r: number; g: number; b: number }> = [
{ r: 0x1f, g: 0x6f, b: 0xeb },
{ r: 0x16, g: 0xa3, b: 0x4a },
{ r: 0xea, g: 0x58, b: 0x0c },
{ r: 0x93, g: 0x3f, b: 0xea },
{ r: 0xdb, g: 0x27, b: 0x77 },
{ r: 0x06, g: 0xb6, b: 0xd4 },
{ r: 0xdc, g: 0x26, b: 0x26 },
{ r: 0x65, g: 0x73, b: 0x0d },
];
function colorForKey(key: string): { r: number; g: number; b: number } {
let hash = 0;
for (let i = 0; i < key.length; i++) {
hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
}
return PALETTE[hash % PALETTE.length];
}
function renderLabel(label: string): Buffer {
const width = SYSTEM_ICON_SIZE;
const height = SYSTEM_ICON_SIZE;
const cellW = Math.floor(width / Math.max(label.length, 1));
const cellH = Math.floor(height / 2);
const cells: Array<{ x: number; y: number; on: boolean }> = [];
for (let i = 0; i < label.length; i++) {
const cx = Math.floor(cellW * (i + 0.5));
const cy = cellH;
cells.push({ x: cx, y: cy, on: true });
}
const grid = Buffer.alloc(width * height * 4, 0);
for (const c of cells) {
if (c.x < 0 || c.x >= width || c.y < 0 || c.y >= height) continue;
const idx = (c.y * width + c.x) * 4;
grid[idx] = 255;
grid[idx + 1] = 255;
grid[idx + 2] = 255;
grid[idx + 3] = 220;
}
return grid;
}
export interface SystemCategoryIconSeedResult {
written: string[];
skipped: string[];
}
export async function seedSystemCategoryIcons(uploadDir: string): Promise<SystemCategoryIconSeedResult> {
const iconsDir = path.join(uploadDir, 'icons');
if (!fs.existsSync(iconsDir)) fs.mkdirSync(iconsDir, { recursive: true });
const result: SystemCategoryIconSeedResult = { written: [], skipped: [] };
for (const key of CANONICAL_SYSTEM_ICON_KEYS) {
const target = path.join(iconsDir, `${key}.png`);
try {
const stat = fs.existsSync(target) ? fs.statSync(target) : null;
if (stat && stat.size > 0) {
result.skipped.push(target);
continue;
}
} catch {
/* fall through and write */
}
const bg = colorForKey(key);
const overlay = renderLabel(key);
const buf = await sharp({
create: {
width: SYSTEM_ICON_SIZE,
height: SYSTEM_ICON_SIZE,
channels: 4,
background: { r: bg.r, g: bg.g, b: bg.b, alpha: 1 },
},
})
.composite([{ input: overlay, raw: { width: SYSTEM_ICON_SIZE, height: SYSTEM_ICON_SIZE, channels: 4 } }])
.png()
.toBuffer();
fs.writeFileSync(target, buf);
result.written.push(target);
}
return result;
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule],
})
export class FrontendModule {}
+29
View File
@@ -0,0 +1,29 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SpaFallbackMiddleware implements NestMiddleware {
constructor(private readonly config: ConfigService) {}
use(req: Request, res: Response, next: NextFunction): void {
const url = req.path || req.url;
if (url.startsWith('/api') || url.startsWith('/uploads')) return next();
if (path.extname(url)) return next();
if (req.method !== 'GET') return next();
const distDir = path.resolve(this.config.get<string>('FRONTEND_DIST', '../frontend/dist'));
const candidates = [distDir, path.join(distDir, 'browser')];
for (const dir of candidates) {
const p = path.join(dir, 'index.html');
if (fs.existsSync(p)) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
fs.createReadStream(p).pipe(res);
return;
}
}
res.status(200).send('<!doctype html><html><body><h1>HIPCTF</h1><p>Frontend not built.</p></body></html>');
}
}
@@ -0,0 +1,22 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class UploadsStaticMiddleware implements NestMiddleware {
private rootDir = '/tmp';
constructor(config: ConfigService) {
const dir = path.resolve(config.get<string>('UPLOAD_DIR', './data/uploads'));
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
this.rootDir = dir;
}
use(req: Request, res: Response, next: NextFunction): void {
if (!req.path.startsWith('/uploads')) return next();
const express = require('express');
return express.static(this.rootDir)(req, res, next);
}
}
+114
View File
@@ -0,0 +1,114 @@
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { toOpenApi31 } from './common/utils/openapi31';
import { ConfigService } from '@nestjs/config';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
import * as fs from 'fs';
import * as path from 'path';
import * as express from 'express';
import { AppModule } from './app.module';
import { SpaFallbackMiddleware } from './frontend/spa.controller';
import { DatabaseInitService } from './database/database-init.service';
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
bufferLogs: false,
});
const config = app.get(ConfigService);
const nodeEnv = config.get<string>('NODE_ENV', 'development');
const port = config.get<number>('PORT', 3000);
const corsOrigins = (config.get<string>('CORS_ORIGINS', 'http://localhost:4200,http://localhost:3000'))
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const bodyLimit = config.get<string>('BODY_SIZE_LIMIT', '1mb');
const uploadDir = path.resolve(config.get<string>('UPLOAD_DIR', './data/uploads'));
const tlsEnabled = config.get<boolean>('TLS_ENABLED', false);
const frontendDist = path.resolve(config.get<string>('FRONTEND_DIST', '../frontend/dist'));
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
await app.get(DatabaseInitService).init();
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"],
fontSrc: ["'self'", 'data:'],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
hsts: tlsEnabled ? { maxAge: 31536000, includeSubDomains: true, preload: true } : false,
frameguard: { action: 'deny' },
noSniff: true,
referrerPolicy: { policy: 'no-referrer' },
}),
);
app.enableCors({
origin: corsOrigins,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});
// Body parsers MUST run before the CSRF middleware so that route handlers
// can read `req.body` when the CsrfMiddleware short-circuits to next().
app.use(cookieParser());
app.use(express.json({ limit: bodyLimit }));
app.use(express.urlencoded({ extended: true, limit: bodyLimit }));
// CSRF: register globally via express so we have explicit ordering after
// the body parsers. (Nest's configure()-time middleware is registered
// before app.use middlewares, which would consume the body before the
// CsrfMiddleware short-circuits to next().)
const csrf = new CsrfMiddleware(config);
app.use((req, res, next) => csrf.use(req as any, res as any, next));
app.use('/uploads', express.static(uploadDir));
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const swaggerConfig = new DocumentBuilder()
.setTitle('HIPCTF API')
.setDescription('HIPCTF REST API')
.setVersion('1.0.0')
.addBearerAuth()
.addCookieAuth('rt')
.build();
const rawDocument = SwaggerModule.createDocument(app, swaggerConfig) as Record<string, any>;
const document = toOpenApi31(rawDocument);
SwaggerModule.setup('api/docs', app, document, {
jsonDocumentUrl: 'api/docs-json',
});
if (fs.existsSync(frontendDist)) {
app.use(express.static(frontendDist, { index: false, fallthrough: true }));
const browserDir = path.join(frontendDist, 'browser');
if (fs.existsSync(browserDir)) {
app.use(express.static(browserDir, { index: false, fallthrough: true }));
}
}
const spa = new SpaFallbackMiddleware(config);
app.use((req, res, next) => spa.use(req as any, res as any, next));
await app.listen(port, '0.0.0.0');
Logger.log(`HIPCTF API listening on http://0.0.0.0:${port} (env=${nodeEnv})`, 'Bootstrap');
}
bootstrap().catch((err) => {
console.error('Fatal startup error:', err);
process.exit(1);
});
@@ -0,0 +1,60 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AdminGuard } from '../../common/guards/admin.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import {
CategoryIdParamSchema,
CreateCategorySchema,
UpdateCategorySchema,
} from './dto/categories.dto';
import { AdminCategoriesService, CategoryView } from './categories.service';
@ApiTags('admin')
@ApiBearerAuth()
@Controller('api/v1/admin/categories')
@UseGuards(AdminGuard)
@Roles('admin')
export class AdminCategoriesController {
constructor(private readonly categories: AdminCategoriesService) {}
@Get()
@ApiOperation({ summary: 'List all categories sorted alphabetically by abbreviation (admin only)' })
async list(): Promise<CategoryView[]> {
return this.categories.list();
}
@Post()
@ApiOperation({ summary: 'Create a user category (admin only)' })
async create(
@Body(new ZodValidationPipe(CreateCategorySchema)) body: any,
): Promise<CategoryView> {
return this.categories.create(body);
}
@Put(':id')
@ApiOperation({ summary: 'Update a category (admin only)' })
async update(
@Param(new ZodValidationPipe(CategoryIdParamSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdateCategorySchema)) body: any,
): Promise<CategoryView> {
return this.categories.update(params.id, body);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a user category (admin only)' })
async remove(
@Param(new ZodValidationPipe(CategoryIdParamSchema)) params: { id: string },
): Promise<void> {
await this.categories.remove(params.id);
}
}
@@ -0,0 +1,162 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Put,
Query,
Req,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { 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 {
ChallengeIdParamSchema,
CHALLENGE_FORMAT,
CHALLENGE_FORMAT_VERSION,
CreateChallengeSchema,
ImportDocumentSchema,
ImportQuerySchema,
ListChallengesQuerySchema,
UpdateChallengeSchema,
} from './dto/challenges.dto';
import {
AdminChallengesService,
ChallengeDetailView,
ChallengeListItem,
ImportPreview,
ImportResult,
} from './challenges.service';
import { ChallengeFilesService } from './challenge-files.service';
@ApiTags('admin')
@ApiBearerAuth()
@Controller('api/v1/admin/challenges')
@UseGuards(AdminGuard)
@Roles('admin')
export class AdminChallengesController {
constructor(
private readonly challenges: AdminChallengesService,
private readonly files: ChallengeFilesService,
) {}
@Get()
@ApiOperation({ summary: 'List all challenges (admin only)' })
async list(
@Query(new ZodValidationPipe(ListChallengesQuerySchema)) query: { q?: string; categoryId?: string },
): Promise<ChallengeListItem[]> {
return this.challenges.list(query);
}
@Get('export')
@ApiOperation({ summary: 'Export all challenges (admin only)' })
async exportAll(@Res() res: Response): Promise<void> {
const doc = await this.challenges.exportAll();
const date = new Date().toISOString().slice(0, 10);
res
.status(HttpStatus.OK)
.setHeader('Content-Type', 'application/json; charset=utf-8')
.setHeader(
'Content-Disposition',
`attachment; filename="challenges-export-${date}.json"`,
)
.send(JSON.stringify(doc));
}
@Post('import')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Import challenges; two-phase (preview/confirm) (admin only)' })
async import(
@Body(new ZodValidationPipe(ImportDocumentSchema)) body: any,
@Query(new ZodValidationPipe(ImportQuerySchema)) query: { confirm?: string },
): Promise<ImportPreview | ImportResult> {
if (query.confirm !== 'overwrite') {
const preview = await this.challenges.previewImport(body);
return preview;
}
const result = await this.challenges.importConfirmed(body);
return result;
}
@Post('files/stage')
@HttpCode(HttpStatus.CREATED)
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file'))
@ApiOperation({ summary: 'Stage a challenge attachment for later commit (admin only)' })
async stageFile(@UploadedFile() file: any): Promise<{
token: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
}> {
if (!file) throw new BadRequestException('No file uploaded under field "file"');
const buffer = file.buffer ?? Buffer.alloc(0);
const record = this.files.stage(buffer, file.originalname ?? 'file', file.mimetype ?? 'application/octet-stream');
return {
token: record.token,
originalFilename: record.originalFilename,
mimeType: record.mimeType,
sizeBytes: record.sizeBytes,
};
}
@Delete('files/stage/:token')
@ApiOperation({ summary: 'Discard a staged challenge attachment (admin only)' })
discardFile(@Param('token') token: string): { ok: true } {
this.files.discard(token);
return { ok: true };
}
@Get(':id')
@ApiOperation({ summary: 'Get a single challenge with details (admin only)' })
async detail(
@Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string },
): Promise<ChallengeDetailView> {
return this.challenges.getDetail(params.id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a challenge (admin only)' })
async create(
@Body(new ZodValidationPipe(CreateChallengeSchema)) body: any,
): Promise<ChallengeDetailView> {
return this.challenges.create(body);
}
@Put(':id')
@ApiOperation({ summary: 'Update a challenge (admin only)' })
async update(
@Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdateChallengeSchema)) body: any,
): Promise<ChallengeDetailView> {
return this.challenges.update(params.id, body);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a challenge (admin only)' })
async remove(
@Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string },
): Promise<void> {
await this.challenges.remove(params.id);
}
}
// Expose constants for tests/UI without circular imports.
export { CHALLENGE_FORMAT, CHALLENGE_FORMAT_VERSION };
export { ERROR_CODES };
export { ApiError };
@@ -0,0 +1,36 @@
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AdminGuard } from '../../common/guards/admin.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { GeneralSettingsSchema } from './dto/general.dto';
import { AdminGeneralService, GeneralSettingsView, ThemeView } from './general.service';
@ApiTags('admin')
@ApiBearerAuth()
@Controller('api/v1/admin/general')
@UseGuards(AdminGuard)
@Roles('admin')
export class AdminGeneralController {
constructor(private readonly general: AdminGeneralService) {}
@Get('settings')
@ApiOperation({ summary: 'Get current general settings (admin only)' })
async getSettings(): Promise<GeneralSettingsView> {
return this.general.getSettings();
}
@Put('settings')
@ApiOperation({ summary: 'Update general settings (admin only)' })
async updateSettings(
@Body(new ZodValidationPipe(GeneralSettingsSchema)) body: unknown,
): Promise<GeneralSettingsView> {
return this.general.updateSettings(body as any);
}
@Get('themes')
@ApiOperation({ summary: 'List available themes (admin only)' })
async listThemes(): Promise<ThemeView[]> {
return this.general.listThemes();
}
}
@@ -0,0 +1,123 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { UserRole } from '../../database/entities/user.entity';
import { AdminGuard } from '../../common/guards/admin.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import {
AdminResetPasswordDtoSchema,
CreateUserDtoSchema,
ListUsersQueryDtoSchema,
UpdatePlayerStatusDtoSchema,
UpdateUserRoleDtoSchema,
UserIdParamDtoSchema,
} from './dto/admin.dto';
import { AdminService } from './admin.service';
/**
* All admin handlers use Zod validation via `ZodValidationPipe` and an
* INLINE body/param/query type. Using inline types (rather than class
* metatypes) avoids the Nest class-transformer pass which would otherwise
* overwrite the raw body before our pipe sees it.
*/
@ApiTags('admin')
@ApiBearerAuth()
@Controller('api/v1/admin')
@UseGuards(AdminGuard)
@Roles('admin')
export class AdminController {
constructor(private readonly admin: AdminService) {}
@Get('players')
@ApiOperation({ summary: 'List all registered users (Players page)' })
listPlayers(
@Query(new ZodValidationPipe(ListUsersQueryDtoSchema))
query: { limit?: number; cursor?: string; role?: UserRole },
) {
return this.admin.listPlayers(query);
}
@Patch('players/:id/role')
@ApiOperation({ summary: 'Toggle a user role between Admin and Player' })
updatePlayerRole(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole },
) {
return this.admin.updatePlayerRole(params.id, body.role);
}
@Patch('players/:id/status')
@ApiOperation({ summary: 'Toggle a user enabled/disabled status' })
updatePlayerStatus(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdatePlayerStatusDtoSchema)) body: { enabled: boolean },
) {
return this.admin.updatePlayerStatus(params.id, body.enabled);
}
@Patch('players/:id/password')
@HttpCode(204)
@ApiOperation({ summary: 'Admin-initiated password reset for a user' })
async resetPlayerPassword(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
@Body(new ZodValidationPipe(AdminResetPasswordDtoSchema))
body: { newPassword: string; confirmPassword: string },
): Promise<void> {
await this.admin.resetPlayerPassword(params.id, body.newPassword);
}
@Delete('players/:id')
@HttpCode(204)
@ApiOperation({ summary: 'Delete a user (cascades solves + refresh tokens)' })
async deletePlayer(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
): Promise<void> {
await this.admin.deletePlayer(params.id);
}
@Get('users')
@ApiOperation({ summary: 'List all users (admin only, legacy alias)' })
list(
@Query(new ZodValidationPipe(ListUsersQueryDtoSchema))
query: { limit?: number; cursor?: string; role?: UserRole },
) {
return this.admin.listUsers(query);
}
@Patch('users/:id')
@ApiOperation({ summary: 'Update a user role (admin only, legacy alias)' })
update(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole },
) {
return this.admin.updateUserRole(params.id, body.role);
}
@Delete('users/:id')
@ApiOperation({ summary: 'Delete a user (admin only, legacy alias)' })
async remove(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
): Promise<void> {
await this.admin.deleteUser(params.id);
}
@Post('users')
@ApiOperation({ summary: 'Create a user (admin only)' })
create(
@Body(new ZodValidationPipe(CreateUserDtoSchema)) body: { username: string; password: string; role: UserRole },
) {
return this.admin.createUser(body.username, body.password, body.role);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../../database/entities/user.entity';
import { CategoryEntity } from '../../database/entities/category.entity';
import { ChallengeEntity } from '../../database/entities/challenge.entity';
import { ChallengeFileEntity } from '../../database/entities/challenge-file.entity';
import { SolveEntity } from '../../database/entities/solve.entity';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { SettingsModule } from '../settings/settings.module';
import { CommonModule } from '../../common/common.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { AdminGeneralController } from './admin-general.controller';
import { AdminGeneralService } from './general.service';
import { AdminCategoriesController } from './admin-categories.controller';
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: [
TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity]),
AuthModule,
UsersModule,
SettingsModule,
CommonModule,
AdminSystemModule,
],
providers: [
AdminService,
AdminGeneralService,
AdminCategoriesService,
AdminChallengesService,
ChallengeFilesService,
],
controllers: [
AdminController,
AdminGeneralController,
AdminCategoriesController,
AdminChallengesController,
],
})
export class AdminModule {}
+193
View File
@@ -0,0 +1,193 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { UserEntity, UserRole, UserStatus } from '../../database/entities/user.entity';
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
import { UsersService } from '../users/users.service';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { validatePassword } from '../../common/utils/password-policy';
import { hashPassword } from '../../common/utils/password-hashing';
import { ConfigService } from '@nestjs/config';
import { AdminPlayerView, ListUsersQueryDto } from './dto/admin.dto';
/**
* Every table that has a `userId` column and must be cleaned up when the
* parent `user` row is deleted. Iterated by `deleteUserOwnedRows` so future
* user-owned tables (e.g. comments, notifications) only need to be appended
* here. The ON DELETE CASCADE migration that backs this list is the
* deployment-wide safety net; the imperative delete below is what the
* admin delete path requires.
*/
const USER_OWNED_TABLES: { tableName: string; userIdColumn: string }[] = [
{ tableName: 'solve', userIdColumn: 'user_id' },
{ tableName: 'refresh_token', userIdColumn: 'user_id' },
];
async function deleteUserOwnedRows(manager: EntityManager, userId: string): Promise<Record<string, number>> {
const counts: Record<string, number> = {};
for (const { tableName, userIdColumn } of USER_OWNED_TABLES) {
const res = await manager.query(
`DELETE FROM "${tableName}" WHERE "${userIdColumn}" = ?`,
[userId],
);
counts[tableName] = Number(res?.changes ?? res?.affected ?? 0);
}
return counts;
}
@Injectable()
export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor(
private readonly usersService: UsersService,
private readonly dataSource: DataSource,
private readonly config: ConfigService,
) {}
async listUsers(query: ListUsersQueryDto = {}): Promise<AdminPlayerView[]> {
return this.listPlayers(query);
}
async listPlayers(query: ListUsersQueryDto = {}): Promise<AdminPlayerView[]> {
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
const repo = this.dataSource.getRepository(UserEntity);
const qb = repo
.createQueryBuilder('u')
.orderBy('u.createdAt', 'ASC')
.addOrderBy('u.id', 'ASC')
.limit(limit + 1);
if (query.role) qb.andWhere('u.role = :role', { role: query.role });
if (query.cursor) qb.andWhere('u.id > :cursor', { cursor: query.cursor });
const rows = await qb.getMany();
return rows.slice(0, limit).map(toPlayerView);
}
async updateUserRole(targetId: string, role: UserRole): Promise<AdminPlayerView> {
return this.updatePlayerRole(targetId, role);
}
async updatePlayerRole(targetId: string, role: UserRole): Promise<AdminPlayerView> {
if (role !== 'admin' && role !== 'player') {
throw ApiError.validation('Invalid role');
}
const updated = await this.usersService.applyLastAdminSafeMutation<AdminPlayerView>(targetId, {
demotesOrDelete: true,
updatedRole: role,
mutate: async (manager, target) => {
target.role = role;
await manager.save(target);
return toPlayerView(target);
},
});
return updated;
}
async updatePlayerStatus(targetId: string, enabled: boolean): Promise<AdminPlayerView> {
const target = await this.usersService.findById(targetId);
if (!target) throw ApiError.notFound('User not found');
const newStatus: UserStatus = enabled ? 'enabled' : 'disabled';
return this.dataSource.transaction(async (manager) => {
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
if (!user) throw ApiError.notFound('User not found');
user.status = newStatus;
await manager.save(user);
if (!enabled) {
await manager
.createQueryBuilder()
.update(RefreshTokenEntity)
.set({ revokedAt: new Date().toISOString() })
.where('userId = :userId', { userId: user.id })
.andWhere('revokedAt IS NULL')
.execute();
}
return toPlayerView(user);
});
}
async resetPlayerPassword(targetId: string, newPassword: string): Promise<void> {
if (!newPassword) {
throw ApiError.validation('New password is required');
}
validatePassword(newPassword, this.config);
const target = await this.usersService.findById(targetId);
if (!target) throw ApiError.notFound('User not found');
const hash = await hashPassword(newPassword, this.config);
await this.dataSource.transaction(async (manager) => {
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
if (!user) throw ApiError.notFound('User not found');
user.passwordHash = hash;
await manager.save(user);
await manager
.createQueryBuilder()
.update(RefreshTokenEntity)
.set({ revokedAt: new Date().toISOString() })
.where('userId = :userId', { userId: user.id })
.andWhere('revokedAt IS NULL')
.execute();
});
}
async deleteUser(targetId: string): Promise<void> {
return this.deletePlayer(targetId);
}
async deletePlayer(targetId: string): Promise<void> {
await this.usersService.applyLastAdminSafeMutation<null>(targetId, {
demotesOrDelete: true,
mutate: async (manager) => {
// Imperative dependent cleanup in the SAME transaction. The
// ON DELETE CASCADE migration is a safety net, but the explicit
// deletes (a) surface the affected row counts to the application,
// (b) keep deletes observable for future audit logging, and
// (c) ensure that the post-mutation admin count is the only
// invariant the caller still needs to verify.
const cleanedCounts = await deleteUserOwnedRows(manager, targetId);
const res = await manager.delete(UserEntity, { id: targetId });
if (!res.affected) {
throw ApiError.notFound('User not found');
}
// Note per-table cleanup counts; never log password hashes, token
// hashes, or the plaintext password.
this.logger.log(
`Deleted user ${targetId}; dependent rows: ${Object.entries(cleanedCounts)
.map(([t, n]) => `${t}=${n}`)
.join(', ') || 'none'}`,
);
return null;
},
});
}
async createUser(username: string, password: string, role: UserRole): Promise<AdminPlayerView> {
validatePassword(password, this.config);
const created = await this.dataSource.transaction(async (manager) => {
const existing = await manager.findOne(UserEntity, { where: { username } });
if (existing) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Username already taken');
const hash = await hashPassword(password, this.config);
const user = manager.create(UserEntity, {
id: uuid(),
username,
passwordHash: hash,
role,
status: 'enabled',
});
await manager.save(user);
return toPlayerView(user);
});
return created;
}
}
export function toPlayerView(user: UserEntity): AdminPlayerView {
return {
id: user.id,
username: user.username,
role: user.role,
status: user.status,
createdAt: user.createdAt,
};
}
@@ -0,0 +1,111 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { CategoryEntity } from '../../database/entities/category.entity';
import { ChallengeEntity } from '../../database/entities/challenge.entity';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { CreateCategoryPayload, UpdateCategoryPayload } from './dto/categories.dto';
export interface CategoryView {
id: string;
name: string;
abbreviation: string;
description: string;
iconPath: string;
isSystem: boolean;
systemKey: string | null;
createdAt: string;
updatedAt: string;
}
@Injectable()
export class AdminCategoriesService {
constructor(
@InjectRepository(CategoryEntity) private readonly categories: Repository<CategoryEntity>,
@InjectRepository(ChallengeEntity) private readonly challenges: Repository<ChallengeEntity>,
) {}
async list(): Promise<CategoryView[]> {
const rows = await this.categories
.createQueryBuilder('c')
.orderBy('LOWER(c.abbreviation)', 'ASC')
.addOrderBy('c.abbreviation', 'ASC')
.getMany();
return rows.map((r) => this.toView(r));
}
async create(payload: CreateCategoryPayload): Promise<CategoryView> {
const abbreviation = payload.abbreviation.toUpperCase();
const dup = await this.categories.findOne({ where: { abbreviation } });
if (dup) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Category abbreviation already exists');
const row = this.categories.create({
id: uuid(),
systemKey: null,
name: payload.name,
abbreviation,
description: payload.description ?? '',
iconPath: payload.iconPath ?? '',
});
await this.categories.save(row);
return this.toView(row);
}
async update(id: string, payload: UpdateCategoryPayload): Promise<CategoryView> {
const row = await this.categories.findOne({ where: { id } });
if (!row) throw ApiError.notFound('Category not found');
if (payload.name !== undefined) row.name = payload.name;
if (payload.description !== undefined) row.description = payload.description;
if (payload.iconPath !== undefined) row.iconPath = payload.iconPath;
if (payload.abbreviation !== undefined) {
const newAbbr = payload.abbreviation.toUpperCase();
if (row.systemKey) {
if (newAbbr !== row.abbreviation) {
throw ApiError.conflict(ERROR_CODES.SYSTEM_PROTECTED, 'System category abbreviation is immutable');
}
} else {
const dup = await this.categories.findOne({ where: { abbreviation: newAbbr } });
if (dup && dup.id !== row.id) {
throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Category abbreviation already exists');
}
row.abbreviation = newAbbr;
}
}
row.updatedAt = new Date().toISOString();
await this.categories.save(row);
return this.toView(row);
}
async remove(id: string): Promise<void> {
const row = await this.categories.findOne({ where: { id } });
if (!row) throw ApiError.notFound('Category not found');
if (row.systemKey) {
throw new ApiError(ERROR_CODES.SYSTEM_PROTECTED, 'System categories cannot be deleted', 403);
}
const count = await this.challenges.count({ where: { categoryId: id } });
if (count > 0) {
throw new ApiError(
ERROR_CODES.CATEGORY_HAS_CHALLENGES,
'Category has challenges attached',
409,
{ count },
);
}
await this.categories.delete({ id });
}
private toView(r: CategoryEntity): CategoryView {
return {
id: r.id,
name: r.name,
abbreviation: r.abbreviation,
description: r.description,
iconPath: r.iconPath,
isSystem: r.systemKey !== null,
systemKey: r.systemKey,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
};
}
}
@@ -0,0 +1,198 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import { v4 as uuid } from 'uuid';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { parseUploadSizeLimit } from '../../common/utils/upload';
export interface StagedFileRecord {
token: string;
storedFilename: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
stagedAt: number;
}
@Injectable()
export class ChallengeFilesService implements OnModuleDestroy {
private readonly logger = new Logger(ChallengeFilesService.name);
private readonly uploadDir: string;
private readonly finalDir: string;
private readonly stagingDir: string;
private readonly globalLimit: number;
private readonly staging = new Map<string, StagedFileRecord>();
constructor(private readonly config: ConfigService) {
this.uploadDir = path.resolve(this.config.get<string>('UPLOAD_DIR', './data/uploads'));
this.finalDir = path.join(this.uploadDir, 'challenges');
this.stagingDir = path.join(this.finalDir, '.staging');
this.globalLimit = parseUploadSizeLimit(this.config.get<string>('UPLOAD_SIZE_LIMIT', '100mb'));
this.ensureDirs();
}
private ensureDirs(): void {
for (const dir of [this.finalDir, this.stagingDir]) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
}
getFinalDir(): string {
return this.finalDir;
}
getGlobalLimit(): number {
return this.globalLimit;
}
/** Stage a buffer and return the staging record. */
stage(
buffer: Buffer,
originalFilename: string,
mimeType: string,
): StagedFileRecord {
this.ensureDirs();
if (buffer.length > this.globalLimit) {
throw ApiError.validation(
`File exceeds the global size limit (${buffer.length} > ${this.globalLimit}).`,
[{ path: 'file', message: `File exceeds UPLOAD_SIZE_LIMIT (${this.globalLimit})` }],
);
}
const token = uuid();
const storedFilename = `${token}`;
const target = path.join(this.stagingDir, storedFilename);
try {
fs.writeFileSync(target, buffer);
} catch (err) {
throw ApiError.internal('Failed to write staged file');
}
const record: StagedFileRecord = {
token,
storedFilename,
originalFilename: originalFilename || 'file',
mimeType: mimeType || 'application/octet-stream',
sizeBytes: buffer.length,
stagedAt: Date.now(),
};
this.staging.set(token, record);
return record;
}
/** Look up a staged record by token. */
lookup(token: string): StagedFileRecord | undefined {
return this.staging.get(token);
}
/** Move a staged file to the final directory and return the final filename. */
commit(token: string): string {
const record = this.staging.get(token);
if (!record) throw ApiError.notFound('Staged file not found');
const stagedPath = path.join(this.stagingDir, record.storedFilename);
const finalPath = path.join(this.finalDir, record.storedFilename);
try {
fs.renameSync(stagedPath, finalPath);
} catch {
// Fallback: copy + unlink (handles cross-device moves).
try {
fs.copyFileSync(stagedPath, finalPath);
fs.unlinkSync(stagedPath);
} catch {
throw ApiError.internal('Failed to commit staged file');
}
}
this.staging.delete(token);
return record.storedFilename;
}
/** Remove a staged file and clear the record. Best-effort, logs failures. */
discard(token: string): void {
const record = this.staging.get(token);
if (!record) return;
const stagedPath = path.join(this.stagingDir, record.storedFilename);
try {
if (fs.existsSync(stagedPath)) fs.unlinkSync(stagedPath);
} catch (err) {
this.logger.warn(`Failed to discard staged file ${token}: ${(err as Error).message}`);
}
this.staging.delete(token);
}
/** Best-effort unlink of a committed file. Logs failures, never throws. */
removeFinal(storedFilename: string): void {
if (!storedFilename) return;
const target = path.join(this.finalDir, storedFilename);
if (!fs.existsSync(target)) return;
try {
fs.unlinkSync(target);
} catch (err) {
this.logger.warn(
`Failed to remove challenge file ${storedFilename}: ${(err as Error).message}`,
);
}
}
/** Best-effort rename of an already-committed file to a new stored filename. */
renameFinal(fromStored: string, toStored: string): void {
if (!fromStored || !toStored || fromStored === toStored) return;
const from = path.join(this.finalDir, fromStored);
const to = path.join(this.finalDir, toStored);
try {
if (!fs.existsSync(from)) {
if (fs.existsSync(to)) return;
return;
}
if (fs.existsSync(to)) fs.unlinkSync(to);
fs.renameSync(from, to);
} catch (err) {
this.logger.warn(
`Failed to rename challenge file ${fromStored} -> ${toStored}: ${(err as Error).message}`,
);
}
}
/** Resolve an absolute disk path for a committed stored filename. */
pathForFinal(storedFilename: string): string {
return path.join(this.finalDir, storedFilename);
}
/** Read a committed file from disk and return its bytes. */
readFinal(storedFilename: string): Buffer | null {
const target = path.join(this.finalDir, storedFilename);
try {
if (!fs.existsSync(target)) return null;
return fs.readFileSync(target);
} catch (err) {
this.logger.warn(
`Failed to read challenge file ${storedFilename}: ${(err as Error).message}`,
);
return null;
}
}
/** Write a new file to the final directory; returns the stored filename. */
writeFinal(buffer: Buffer, storedFilename: string): string {
this.ensureDirs();
const target = path.join(this.finalDir, storedFilename);
fs.writeFileSync(target, buffer);
return storedFilename;
}
onModuleDestroy(): void {
// Clean up any in-process staging state. Files are best-effort unlinked
// so abandoned sessions do not leak; committed files are intentionally
// preserved because they are owned by persisted challenges.
for (const token of Array.from(this.staging.keys())) {
this.discard(token);
}
}
/** Helper for tests: surface the staging map without exposing internals. */
hasStaging(token: string): boolean {
return this.staging.has(token);
}
}
// Re-export the limit error code for callers that need to render it.
export const CHALLENGE_FILE_LIMIT_CODE = ERROR_CODES.CHALLENGE_FILE_LIMIT;
@@ -0,0 +1,646 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { ChallengeEntity } from '../../database/entities/challenge.entity';
import { ChallengeFileEntity } from '../../database/entities/challenge-file.entity';
import { CategoryEntity } from '../../database/entities/category.entity';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { ChallengeFilesService, StagedFileRecord } from './challenge-files.service';
import {
CHALLENGE_FORMAT,
CHALLENGE_FORMAT_VERSION,
CreateChallengePayload,
ImportChallengePayload,
ImportDocumentPayload,
UpdateChallengePayload,
} from './dto/challenges.dto';
export interface ChallengeListItem {
id: string;
name: string;
categoryAbbreviation: string;
categoryIconPath: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
protocol: 'NC' | 'WEB';
enabled: boolean;
createdAt: string;
updatedAt: string;
fileCount: number;
solveCount: number;
}
export interface ChallengeFileView {
id: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
createdAt: string;
}
export interface ChallengeDetailView {
id: string;
name: string;
descriptionMd: string;
categoryId: string;
categoryAbbreviation: string;
categoryIconPath: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
protocol: 'NC' | 'WEB';
port: number | null;
ipAddress: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
files: ChallengeFileView[];
}
export interface ImportPreview {
total: number;
conflicts: string[];
unknownCategories: string[];
warnings: string[];
}
export interface ImportResult {
imported: number;
skipped: { name: string; reason: string }[];
warnings: string[];
}
const NAME_TAKEN_CODES = new Set(['SQLITE_CONSTRAINT_UNIQUE', 'SQLITE_CONSTRAINT']);
function isValidIpOrHostname(value: string): boolean {
if (!value) return true;
if (value.length > 255) return false;
if (value.includes(':')) {
// IPv6 (allow simple bracketed forms).
return /^[0-9A-Fa-f:.]+$/.test(value);
}
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/.exec(value);
if (ipv4) {
return value.split('.').every((p) => {
if (p.length === 0 || p.length > 3) return false;
if (p.length > 1 && p.startsWith('0')) return false;
const n = Number(p);
return Number.isInteger(n) && n >= 0 && n <= 255;
});
}
if (value.length === 0 || value.length > 253) return false;
if (value.startsWith('.') || value.endsWith('.')) return false;
const labels = value.split('.');
if (labels.length < 2) return false;
if (/^[0-9]+$/.test(labels[0])) return false;
return labels.every((label) => /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/.test(label));
}
@Injectable()
export class AdminChallengesService {
constructor(
@InjectRepository(ChallengeEntity) private readonly challenges: Repository<ChallengeEntity>,
@InjectRepository(ChallengeFileEntity) private readonly files: Repository<ChallengeFileEntity>,
@InjectRepository(CategoryEntity) private readonly categories: Repository<CategoryEntity>,
private readonly dataSource: DataSource,
private readonly fileService: ChallengeFilesService,
) {}
async list(query: { q?: string; categoryId?: string }): Promise<ChallengeListItem[]> {
const qb = this.challenges
.createQueryBuilder('c')
.leftJoin(CategoryEntity, 'cat', 'cat.id = c.categoryId')
.leftJoin(ChallengeFileEntity, 'f', 'f.challenge_id = c.id')
.leftJoin('solve', 's', 's.challenge_id = c.id')
.select('c.id', 'id')
.addSelect('c.name', 'name')
.addSelect('cat.abbreviation', 'categoryAbbreviation')
.addSelect('cat.icon_path', 'categoryIconPath')
.addSelect('c.difficulty', 'difficulty')
.addSelect('c.protocol', 'protocol')
.addSelect('c.enabled', 'enabled')
.addSelect('c.created_at', 'createdAt')
.addSelect('c.updated_at', 'updatedAt')
.addSelect('COUNT(DISTINCT f.id)', 'fileCount')
.addSelect('COUNT(DISTINCT s.id)', 'solveCount')
.groupBy('c.id')
.addGroupBy('cat.abbreviation')
.addGroupBy('cat.icon_path')
.orderBy('LOWER(c.name)', 'ASC')
.addOrderBy('c.name', 'ASC');
const q = (query.q ?? '').trim();
if (q) {
qb.andWhere('LOWER(c.name) LIKE LOWER(:q)', { q: `%${q}%` });
}
if (query.categoryId) {
qb.andWhere('c.category_id = :categoryId', { categoryId: query.categoryId });
}
const rows: any[] = await qb.getRawMany();
return rows.map((row) => ({
id: row.id,
name: row.name,
categoryAbbreviation: row.categoryAbbreviation ?? '',
categoryIconPath: row.categoryIconPath ?? '',
difficulty: row.difficulty,
protocol: row.protocol,
enabled: Boolean(row.enabled),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
fileCount: Number(row.fileCount ?? 0),
solveCount: Number(row.solveCount ?? 0),
}));
}
async getDetail(id: string): Promise<ChallengeDetailView> {
const row = await this.challenges
.createQueryBuilder('c')
.leftJoinAndSelect(CategoryEntity, 'cat', 'cat.id = c.category_id')
.where('c.id = :id', { id })
.getRawOne();
if (!row) throw ApiError.notFound('Challenge not found');
const files = await this.files.find({ where: { challengeId: id } });
return {
id: row.c_id,
name: row.c_name,
descriptionMd: row.c_description_md ?? '',
categoryId: row.c_category_id,
categoryAbbreviation: row.cat_abbreviation ?? '',
categoryIconPath: row.cat_icon_path ?? '',
difficulty: row.c_difficulty,
initialPoints: row.c_initial_points,
minimumPoints: row.c_minimum_points,
decaySolves: row.c_decay_solves,
flag: row.c_flag ?? '',
protocol: row.c_protocol,
port: row.c_port ?? null,
ipAddress: row.c_ip_address ?? '',
enabled: Boolean(row.c_enabled),
createdAt: row.c_created_at,
updatedAt: row.c_updated_at,
files: files.map((f) => ({
id: f.id,
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
createdAt: f.createdAt,
})),
};
}
async create(payload: CreateChallengePayload): Promise<ChallengeDetailView> {
await this.assertCategoryExists(payload.categoryId);
this.assertPort(payload.protocol, payload.port ?? null);
this.assertPoints(payload.initialPoints, payload.minimumPoints);
this.assertIpAddress(payload.ipAddress);
const id = uuid();
try {
const created = await this.dataSource.transaction(async (manager) => {
const row = manager.create(ChallengeEntity, {
id,
name: payload.name,
descriptionMd: payload.descriptionMd ?? '',
categoryId: payload.categoryId,
difficulty: payload.difficulty,
initialPoints: payload.initialPoints,
minimumPoints: payload.minimumPoints,
decaySolves: payload.decaySolves,
flag: payload.flag,
protocol: payload.protocol,
port: payload.protocol === 'NC' ? payload.port ?? null : payload.port ?? null,
ipAddress: payload.ipAddress ?? '',
enabled: payload.enabled ?? true,
});
await manager.save(row);
const finalIds = await this.applyFiles(manager, id, payload.files ?? [], []);
return { id, finalIds };
});
// commit files outside transaction: rename staged → final
const finalIdMap = new Map<string, string>();
for (const manifest of payload.files ?? []) {
if (manifest.stagedToken && created.finalIds.includes(manifest.stagedToken)) {
const final = this.fileService.commit(manifest.stagedToken);
finalIdMap.set(manifest.stagedToken, final);
}
}
// any staged tokens still in pending state are discarded
for (const manifest of payload.files ?? []) {
if (manifest.stagedToken && !finalIdMap.has(manifest.stagedToken)) {
this.fileService.discard(manifest.stagedToken);
}
}
return this.getDetail(id);
} catch (err: any) {
this.handleNameTaken(err);
throw err;
}
}
async update(id: string, payload: UpdateChallengePayload): Promise<ChallengeDetailView> {
const existing = await this.challenges.findOne({ where: { id } });
if (!existing) throw ApiError.notFound('Challenge not found');
if (payload.categoryId) await this.assertCategoryExists(payload.categoryId);
const effectiveProtocol = payload.protocol ?? existing.protocol;
const effectivePort = payload.port !== undefined ? payload.port : existing.port;
this.assertPort(effectiveProtocol, effectivePort ?? null);
if (payload.initialPoints !== undefined && payload.minimumPoints !== undefined) {
this.assertPoints(payload.initialPoints, payload.minimumPoints);
}
if (payload.ipAddress !== undefined) this.assertIpAddress(payload.ipAddress);
const beforeFiles = await this.files.find({ where: { challengeId: id } });
const keepIds = new Set(
(payload.files ?? [])
.map((f) => f.id)
.filter((x): x is string => typeof x === 'string'),
);
try {
await this.dataSource.transaction(async (manager) => {
if (payload.name !== undefined) existing.name = payload.name;
if (payload.descriptionMd !== undefined) existing.descriptionMd = payload.descriptionMd;
if (payload.categoryId !== undefined) existing.categoryId = payload.categoryId;
if (payload.difficulty !== undefined) existing.difficulty = payload.difficulty;
if (payload.initialPoints !== undefined) existing.initialPoints = payload.initialPoints;
if (payload.minimumPoints !== undefined) existing.minimumPoints = payload.minimumPoints;
if (payload.decaySolves !== undefined) existing.decaySolves = payload.decaySolves;
if (payload.flag !== undefined) existing.flag = payload.flag;
if (payload.protocol !== undefined) existing.protocol = payload.protocol;
if (payload.port !== undefined) {
existing.port = payload.protocol === 'WEB' && payload.port === null ? null : payload.port;
}
if (payload.ipAddress !== undefined) existing.ipAddress = payload.ipAddress;
if (payload.enabled !== undefined) existing.enabled = payload.enabled;
existing.updatedAt = new Date().toISOString();
await manager.save(existing);
// Mark file removals.
const toRemove = beforeFiles.filter((f) => !keepIds.has(f.id));
if (toRemove.length > 0) {
await manager.delete(ChallengeFileEntity, { id: In(toRemove.map((f) => f.id)) });
}
await this.applyFiles(manager, id, payload.files ?? [], Array.from(keepIds));
});
// Best-effort post-commit filesystem cleanup for removed files.
for (const f of beforeFiles) {
if (!keepIds.has(f.id)) {
this.fileService.removeFinal(f.storedFilename);
}
}
// Commit remaining staged tokens.
for (const manifest of payload.files ?? []) {
if (manifest.stagedToken) {
try {
this.fileService.commit(manifest.stagedToken);
} catch {
this.fileService.discard(manifest.stagedToken);
}
}
}
return this.getDetail(id);
} catch (err: any) {
this.handleNameTaken(err);
throw err;
}
}
async remove(id: string): Promise<void> {
const stored = await this.files.find({ where: { challengeId: id } });
await this.dataSource.transaction(async (manager) => {
const res = await manager.delete(ChallengeEntity, { id });
if (!res.affected) throw ApiError.notFound('Challenge not found');
});
for (const f of stored) {
this.fileService.removeFinal(f.storedFilename);
}
}
async exportAll(): Promise<{
format: typeof CHALLENGE_FORMAT;
version: number;
exportedAt: string;
challenges: any[];
}> {
const cats = await this.categories.find();
const catMap = new Map<string, CategoryEntity>();
for (const c of cats) catMap.set(c.id, c);
const rows = await this.challenges
.createQueryBuilder('c')
.orderBy('LOWER(c.name)', 'ASC')
.addOrderBy('c.name', 'ASC')
.getMany();
const challenges: any[] = [];
for (const c of rows) {
const cat = catMap.get(c.categoryId);
if (!cat) continue;
const catKey = cat.systemKey ?? cat.abbreviation;
const files = await this.files.find({ where: { challengeId: c.id } });
const outFiles = files.map((f) => {
const data = this.fileService.readFinal(f.storedFilename);
const dataBase64 = data ? data.toString('base64') : '';
return {
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
dataBase64,
};
});
challenges.push({
name: c.name,
descriptionMd: c.descriptionMd,
categorySystemKey: catKey,
difficulty: c.difficulty,
initialPoints: c.initialPoints,
minimumPoints: c.minimumPoints,
decaySolves: c.decaySolves,
flag: c.flag,
protocol: c.protocol,
port: c.port ?? null,
ipAddress: c.ipAddress,
enabled: c.enabled,
files: outFiles,
});
}
return {
format: CHALLENGE_FORMAT,
version: CHALLENGE_FORMAT_VERSION,
exportedAt: new Date().toISOString(),
challenges,
};
}
async previewImport(doc: ImportDocumentPayload): Promise<ImportPreview> {
const allCats = await this.categories.find();
const catMap = new Map<string, CategoryEntity>();
for (const c of allCats) {
const key = c.systemKey ?? c.abbreviation;
catMap.set(key.toLowerCase(), c);
}
const existingNames = await this.challenges
.createQueryBuilder('c')
.select('LOWER(c.name)', 'lname')
.getRawMany()
.then((rows) => rows.map((r) => r.lname));
const existingSet = new Set(existingNames);
const preview: ImportPreview = {
total: doc.challenges.length,
conflicts: [],
unknownCategories: [],
warnings: [],
};
for (const ch of doc.challenges) {
const cat = catMap.get(ch.categorySystemKey.toLowerCase());
if (!cat) preview.unknownCategories.push(ch.categorySystemKey);
if (existingSet.has(ch.name.toLowerCase())) preview.conflicts.push(ch.name);
}
if (preview.unknownCategories.length > 0) {
preview.warnings.push(
`${preview.unknownCategories.length} challenge(s) reference categories that do not exist locally and will be skipped.`,
);
}
return preview;
}
async importConfirmed(doc: ImportDocumentPayload): Promise<ImportResult> {
const allCats = await this.categories.find();
const catMap = new Map<string, CategoryEntity>();
for (const c of allCats) {
const key = (c.systemKey ?? c.abbreviation).toLowerCase();
catMap.set(key, c);
}
// Stage every file up front so any FS failure aborts before DB writes.
const stagedByName = new Map<string, Array<{ storedFilename: string; manifest: any; staged: StagedFileRecord }>>();
for (const ch of doc.challenges) {
const stagedList: Array<{ storedFilename: string; manifest: any; staged: StagedFileRecord }> = [];
for (const f of ch.files ?? []) {
let bytes: Buffer;
try {
bytes = Buffer.from(f.dataBase64, 'base64');
} catch {
this.rollbackStaged(stagedByName);
throw ApiError.validation(
'File payload is not valid base64',
[{ path: 'challenges.files.dataBase64', message: 'Not valid base64' }],
);
}
if (bytes.length !== f.sizeBytes) {
this.rollbackStaged(stagedByName);
throw ApiError.validation(
'File sizeBytes does not match decoded data',
[{ path: 'challenges.files.sizeBytes', message: 'sizeBytes mismatch' }],
);
}
const staged = this.fileService.stage(bytes, f.originalFilename, f.mimeType);
stagedList.push({ storedFilename: staged.storedFilename, manifest: f, staged });
}
stagedByName.set(ch.name, stagedList);
}
const result: ImportResult = { imported: 0, skipped: [], warnings: [] };
let orphanedStoredFilenames: string[] = [];
try {
await this.dataSource.transaction(async (manager) => {
const challengeRepo = manager.getRepository(ChallengeEntity);
const fileRepo = manager.getRepository(ChallengeFileEntity);
// Full-replace: capture every existing challenge and its disk-bound
// files so we can unlink them after the wipe, then delete every row.
// ChallengeFileEntity and SolveEntity both cascade on challenge delete.
const existingChallenges = await challengeRepo.find();
const existingIds = existingChallenges.map((c) => c.id);
if (existingIds.length > 0) {
const existingFiles = await fileRepo.find({ where: { challengeId: In(existingIds) } });
orphanedStoredFilenames = existingFiles.map((f) => f.storedFilename);
await manager.delete(ChallengeEntity, { id: In(existingIds) });
}
for (const ch of doc.challenges) {
const cat = catMap.get(ch.categorySystemKey.toLowerCase());
if (!cat) {
result.skipped.push({ name: ch.name, reason: `Unknown category: ${ch.categorySystemKey}` });
continue;
}
const row = challengeRepo.create({
id: uuid(),
name: ch.name,
descriptionMd: ch.descriptionMd ?? '',
categoryId: cat.id,
difficulty: ch.difficulty,
initialPoints: ch.initialPoints,
minimumPoints: ch.minimumPoints,
decaySolves: ch.decaySolves,
flag: ch.flag,
protocol: ch.protocol,
port: ch.port ?? null,
ipAddress: ch.ipAddress ?? '',
enabled: ch.enabled ?? true,
});
await manager.save(row);
const staged = stagedByName.get(ch.name) ?? [];
for (const item of staged) {
const fileRow = fileRepo.create({
id: uuid(),
challengeId: row.id,
originalFilename: item.staged.originalFilename,
storedFilename: item.storedFilename,
mimeType: item.staged.mimeType,
sizeBytes: item.staged.sizeBytes,
});
await manager.save(fileRow);
}
result.imported += 1;
}
});
} catch (err) {
// DB write failed: roll back every staged file and re-throw.
this.rollbackStaged(stagedByName);
throw err;
}
// Best-effort unlink of files owned by wiped challenges (post-commit).
for (const storedFilename of orphanedStoredFilenames) {
this.fileService.removeFinal(storedFilename);
}
// Commit each staged file by renaming to its final filename (the
// ChallengeFile rows already reference those names).
for (const list of stagedByName.values()) {
for (const item of list) {
try {
this.fileService.commit(item.staged.token);
} catch (err) {
this.fileService.discard(item.staged.token);
console.log(
`[import] Failed to commit file ${item.storedFilename}: ${(err as Error).message}`,
);
}
}
}
if (result.skipped.length > 0) {
result.warnings.push(`${result.skipped.length} challenge(s) skipped.`);
}
return result;
}
private rollbackStaged(
map: Map<string, Array<{ storedFilename: string; manifest: any; staged: StagedFileRecord }>>,
): void {
for (const list of map.values()) {
for (const item of list) this.fileService.discard(item.staged.token);
}
}
private async assertCategoryExists(categoryId: string): Promise<void> {
const cat = await this.categories.findOne({ where: { id: categoryId } });
if (!cat) {
throw new ApiError(
ERROR_CODES.CHALLENGE_INVALID_CATEGORY,
'Invalid challenge category',
400,
[{ path: 'categoryId', message: 'Category does not exist' }],
);
}
}
private assertPort(protocol: 'NC' | 'WEB', port: number | null): void {
if (protocol === 'NC') {
if (port === null || port === undefined || !Number.isInteger(port) || port < 1 || port > 65535) {
throw ApiError.validation('Port is required for NC protocol', [
{ path: 'port', message: 'Port is required for NC' },
]);
}
return;
}
if (port !== null && port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) {
throw ApiError.validation('Port must be between 1 and 65535', [
{ path: 'port', message: 'Port out of range' },
]);
}
}
private assertPoints(initial: number, minimum: number): void {
if (!Number.isInteger(initial) || initial < 1 || initial > 100000) {
throw ApiError.validation('Initial points out of range', [
{ path: 'initialPoints', message: 'Initial points must be between 1 and 100000' },
]);
}
if (!Number.isInteger(minimum) || minimum < 1 || minimum > 100000) {
throw ApiError.validation('Minimum points out of range', [
{ path: 'minimumPoints', message: 'Minimum points must be between 1 and 100000' },
]);
}
if (minimum > initial) {
throw ApiError.validation('Minimum points must be less than or equal to initial points', [
{ path: 'minimumPoints', message: 'Minimum points must be less than or equal to initial points' },
]);
}
}
private assertIpAddress(value: string | undefined): void {
if (!value) return;
if (!isValidIpOrHostname(value)) {
throw ApiError.validation('Invalid IP or hostname', [
{ path: 'ipAddress', message: 'Must be a valid IPv4/IPv6 address or hostname' },
]);
}
}
private handleNameTaken(err: any): void {
if (err && typeof err === 'object' && NAME_TAKEN_CODES.has(err.code)) {
throw new ApiError(
ERROR_CODES.CHALLENGE_NAME_TAKEN,
'Challenge name is already taken',
409,
[{ path: 'name', message: 'Name must be unique' }],
);
}
}
private async applyFiles(
manager: any,
challengeId: string,
manifests: Array<{
id?: string;
stagedToken?: string;
originalFilename?: string;
mimeType?: string;
sizeBytes?: number;
remove?: boolean;
}>,
keepIds: string[],
): Promise<string[]> {
const committed: string[] = [];
const fileRepo = manager.getRepository
? manager.getRepository(ChallengeFileEntity)
: manager;
for (const m of manifests) {
if (m.remove) continue;
if (m.id) continue; // existing files are kept
if (m.stagedToken) {
const staged = this.fileService.lookup(m.stagedToken);
if (!staged) continue;
const fileRow = fileRepo.create({
id: uuid(),
challengeId,
originalFilename: staged.originalFilename,
storedFilename: staged.storedFilename,
mimeType: staged.mimeType,
sizeBytes: staged.sizeBytes,
});
await fileRepo.save(fileRow);
committed.push(m.stagedToken);
}
}
void keepIds;
return committed;
}
}
@@ -0,0 +1,49 @@
import { z } from 'zod';
export const CreateUserDtoSchema = z.object({
username: z.string().trim().min(3).max(64).regex(/^[a-zA-Z0-9._-]+$/, 'Username may contain letters, digits, dot, underscore, dash'),
password: z.string().min(1).max(256),
role: z.enum(['admin', 'player']),
});
export type CreateUserDto = z.infer<typeof CreateUserDtoSchema>;
export const UpdateUserRoleDtoSchema = z.object({
role: z.enum(['admin', 'player']),
});
export type UpdateUserRoleDto = z.infer<typeof UpdateUserRoleDtoSchema>;
export const UpdatePlayerStatusDtoSchema = z.object({
enabled: z.boolean(),
});
export type UpdatePlayerStatusDto = z.infer<typeof UpdatePlayerStatusDtoSchema>;
export const AdminResetPasswordDtoSchema = z
.object({
newPassword: z.string().min(1).max(256),
confirmPassword: z.string().min(1).max(256),
})
.refine((d) => d.newPassword === d.confirmPassword, {
path: ['confirmPassword'],
message: 'Passwords do not match',
});
export type AdminResetPasswordDto = z.infer<typeof AdminResetPasswordDtoSchema>;
export const UserIdParamDtoSchema = z.object({
id: z.string().uuid(),
});
export type UserIdParamDto = z.infer<typeof UserIdParamDtoSchema>;
export const ListUsersQueryDtoSchema = z.object({
limit: z.coerce.number().int().positive().max(200).optional().default(50),
cursor: z.string().uuid().optional(),
role: z.enum(['admin', 'player']).optional(),
});
export type ListUsersQueryDto = z.infer<typeof ListUsersQueryDtoSchema>;
export interface AdminPlayerView {
id: string;
username: string;
role: 'admin' | 'player';
status: 'enabled' | 'disabled';
createdAt: string;
}
@@ -0,0 +1,19 @@
import { z } from 'zod';
export const CreateCategorySchema = z.object({
name: z.string().min(1).max(120),
abbreviation: z.string().min(2).max(6),
description: z.string().max(2000),
iconPath: z.string().max(2048).optional(),
});
export type CreateCategoryPayload = z.infer<typeof CreateCategorySchema>;
export const UpdateCategorySchema = z.object({
name: z.string().min(1).max(120).optional(),
abbreviation: z.string().min(2).max(6).optional(),
description: z.string().max(2000).optional(),
iconPath: z.string().max(2048).optional(),
});
export type UpdateCategoryPayload = z.infer<typeof UpdateCategorySchema>;
export const CategoryIdParamSchema = z.object({ id: z.string().min(1).max(64) });
@@ -0,0 +1,150 @@
import { z } from 'zod';
export const DIFFICULTIES = ['LOW', 'MEDIUM', 'HIGH'] as const;
export const PROTOCOLS = ['NC', 'WEB'] as const;
export const CHALLENGE_FORMAT_VERSION = 1;
export const CHALLENGE_FORMAT = 'hipctf-challenges';
export const ChallengeNameSchema = z
.string()
.trim()
.min(1, 'Name is required')
.max(128, 'Name must be 128 characters or fewer');
export const ChallengeDifficultySchema = z.enum(DIFFICULTIES);
export const ChallengeProtocolSchema = z.enum(PROTOCOLS);
export const ChallengeIdParamSchema = z.object({
id: z.string().uuid({ message: 'Invalid challenge id' }),
});
export const ListChallengesQuerySchema = z.object({
q: z.string().trim().min(1).max(128).optional(),
categoryId: z.string().uuid({ message: 'Invalid category id' }).optional(),
});
export type ListChallengesQuery = z.infer<typeof ListChallengesQuerySchema>;
export const ChallengeFileManifestSchema = z.object({
id: z.string().optional(),
stagedToken: z.string().min(1).max(128).optional(),
originalFilename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(255),
sizeBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
remove: z.boolean().optional(),
});
export type ChallengeFileManifest = z.infer<typeof ChallengeFileManifestSchema>;
const portField = z
.number()
.int()
.min(1)
.max(65535)
.nullable()
.optional();
const baseShape = {
descriptionMd: z.string().max(64_000),
categoryId: z.string().uuid({ message: 'Category is required' }),
difficulty: ChallengeDifficultySchema,
initialPoints: z.number().int().min(1).max(100000),
minimumPoints: z.number().int().min(1).max(100000),
decaySolves: z.number().int().min(1).max(10000),
flag: z.string().min(1, 'Flag is required').max(4096),
protocol: ChallengeProtocolSchema,
port: portField,
ipAddress: z.string().max(255).default(''),
enabled: z.boolean().default(true),
files: z.array(ChallengeFileManifestSchema).max(64).optional(),
};
export const CreateChallengeSchema = z
.object({
name: ChallengeNameSchema,
...baseShape,
})
.superRefine((val, ctx) => {
if (val.minimumPoints > val.initialPoints) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['minimumPoints'],
message: 'Minimum points must be less than or equal to initial points',
});
}
if (val.protocol === 'NC' && (val.port === null || val.port === undefined)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['port'],
message: 'Port is required for NC protocol',
});
}
});
export type CreateChallengePayload = z.infer<typeof CreateChallengeSchema>;
export const UpdateChallengeSchema = z
.object({
name: ChallengeNameSchema.optional(),
...baseShape,
files: z.array(ChallengeFileManifestSchema).max(64).optional(),
})
.superRefine((val, ctx) => {
if (val.initialPoints !== undefined && val.minimumPoints !== undefined && val.minimumPoints > val.initialPoints) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['minimumPoints'],
message: 'Minimum points must be less than or equal to initial points',
});
}
if (val.protocol === 'NC' && (val.port === null || val.port === undefined)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['port'],
message: 'Port is required for NC protocol',
});
}
});
export type UpdateChallengePayload = z.infer<typeof UpdateChallengeSchema>;
export const ImportQuerySchema = z.object({
confirm: z.string().optional(),
});
export const ImportChallengeFileSchema = z.object({
originalFilename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(255),
sizeBytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
dataBase64: z.string().min(1).max(8 * 1024 * 1024),
});
export type ImportChallengeFilePayload = z.infer<typeof ImportChallengeFileSchema>;
export const ImportChallengeSchema = z.object({
name: ChallengeNameSchema,
descriptionMd: z.string().max(64_000),
categorySystemKey: z.string().min(1).max(64),
difficulty: ChallengeDifficultySchema,
initialPoints: z.number().int().min(1).max(100000),
minimumPoints: z.number().int().min(1).max(100000),
decaySolves: z.number().int().min(1).max(10000),
flag: z.string().min(1).max(4096),
protocol: ChallengeProtocolSchema,
port: portField,
ipAddress: z.string().max(255).default(''),
enabled: z.boolean().default(true),
files: z.array(ImportChallengeFileSchema).max(64).optional(),
});
export type ImportChallengePayload = z.infer<typeof ImportChallengeSchema>;
export const ImportDocumentSchema = z.object({
format: z.literal(CHALLENGE_FORMAT),
version: z.number().int().min(1).max(CHALLENGE_FORMAT_VERSION),
exportedAt: z.string().min(1),
challenges: z.array(ImportChallengeSchema),
});
export type ImportDocumentPayload = z.infer<typeof ImportDocumentSchema>;
export const StageChallengeFileResponseSchema = z.object({
token: z.string(),
originalFilename: z.string(),
mimeType: z.string(),
sizeBytes: z.number().int(),
});
export type StageChallengeFileResponse = z.infer<typeof StageChallengeFileResponseSchema>;
@@ -0,0 +1,74 @@
import { isIP } from 'net';
import { z } from 'zod';
import { THEME_IDS } from '../../../common/types/theme-ids';
const HOSTNAME_LABEL = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/;
const ALL_NUMERIC_LABEL = /^[0-9]+$/;
function isValidHostname(value: string): boolean {
if (value.length === 0 || value.length > 253) return false;
if (value.startsWith('.') || value.endsWith('.')) return false;
const labels = value.split('.');
if (labels.length < 2) return false;
if (labels.some((label) => ALL_NUMERIC_LABEL.test(label))) return false;
return labels.every((label) => HOSTNAME_LABEL.test(label));
}
export function isValidDefaultChallengeAddress(value: string): boolean {
if (isIP(value) === 4) return true;
return isValidHostname(value);
}
const defaultChallengeIpSchema = z
.string({ required_error: 'defaultChallengeIp is required', invalid_type_error: 'defaultChallengeIp must be a string' })
.transform((v) => v.trim())
.superRefine((value, ctx) => {
if (value.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [],
message: 'defaultChallengeIp is required and cannot contain only whitespace',
});
return;
}
if (value.length > 255) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [],
message: 'defaultChallengeIp must be 255 characters or fewer',
});
return;
}
if (!isValidDefaultChallengeAddress(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [],
message: 'defaultChallengeIp must be a valid IPv4 address or hostname',
});
}
});
export const GeneralSettingsSchema = z
.object({
pageTitle: z.string().trim().min(1).max(120),
logo: z.string().max(2048),
welcomeMarkdown: z.string().max(64_000),
themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]),
eventStartUtc: z.string().datetime({ message: 'eventStartUtc must be a valid ISO-8601 datetime' }),
eventEndUtc: z.string().datetime({ message: 'eventEndUtc must be a valid ISO-8601 datetime' }),
defaultChallengeIp: defaultChallengeIpSchema,
registrationsEnabled: z.boolean(),
})
.superRefine((val, ctx) => {
const start = Date.parse(val.eventStartUtc);
const end = Date.parse(val.eventEndUtc);
if (Number.isFinite(start) && Number.isFinite(end) && end <= start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['eventEndUtc'],
message: 'eventEndUtc must be strictly after eventStartUtc',
});
}
});
export type GeneralSettingsPayload = z.infer<typeof GeneralSettingsSchema>;
@@ -0,0 +1,110 @@
import { Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import { ConfigService } from '@nestjs/config';
import { SettingsService } from '../settings/settings.module';
import { ThemeLoaderService } from '../../common/utils/theme-loader.service';
import { SseHubService } from '../../common/services/sse-hub.service';
import { SETTINGS_KEYS } from '../../config/env.schema';
import { GeneralSettingsPayload } from './dto/general.dto';
export interface GeneralSettingsView {
pageTitle: string;
logo: string;
welcomeMarkdown: string;
themeKey: string;
eventStartUtc: string;
eventEndUtc: string;
defaultChallengeIp: string;
registrationsEnabled: boolean;
}
export interface ThemeView {
id: string;
key: string;
name: string;
}
@Injectable()
export class AdminGeneralService {
constructor(
private readonly settings: SettingsService,
private readonly themes: ThemeLoaderService,
private readonly hub: SseHubService,
private readonly config: ConfigService,
) {}
async getSettings(): Promise<GeneralSettingsView> {
const [pageTitle, logo, welcomeMarkdown, themeKey, eventStartUtc, eventEndUtc, defaultChallengeIp, registrationsEnabled] =
await Promise.all([
this.settings.get(SETTINGS_KEYS.PAGE_TITLE, 'HIPCTF'),
this.settings.get(SETTINGS_KEYS.LOGO, ''),
this.settings.get(SETTINGS_KEYS.WELCOME_MARKDOWN, ''),
this.settings.get(SETTINGS_KEYS.THEME_KEY, 'classic'),
this.settings.get(SETTINGS_KEYS.EVENT_START_UTC, ''),
this.settings.get(SETTINGS_KEYS.EVENT_END_UTC, ''),
this.settings.get(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, '127.0.0.1'),
this.settings.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false'),
]);
return {
pageTitle,
logo,
welcomeMarkdown,
themeKey,
eventStartUtc,
eventEndUtc,
defaultChallengeIp,
registrationsEnabled: registrationsEnabled === 'true',
};
}
async updateSettings(payload: GeneralSettingsPayload): Promise<GeneralSettingsView> {
await Promise.all([
this.settings.set(SETTINGS_KEYS.PAGE_TITLE, payload.pageTitle),
this.settings.set(SETTINGS_KEYS.LOGO, payload.logo),
this.settings.set(SETTINGS_KEYS.WELCOME_MARKDOWN, payload.welcomeMarkdown),
this.settings.set(SETTINGS_KEYS.THEME_KEY, payload.themeKey),
this.settings.set(SETTINGS_KEYS.EVENT_START_UTC, payload.eventStartUtc),
this.settings.set(SETTINGS_KEYS.EVENT_END_UTC, payload.eventEndUtc),
this.settings.set(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, payload.defaultChallengeIp),
this.settings.set(SETTINGS_KEYS.REGISTRATIONS_ENABLED, payload.registrationsEnabled ? 'true' : 'false'),
]);
this.hub.emitEvent({
topic: 'general',
themeKey: payload.themeKey,
registrationsEnabled: payload.registrationsEnabled,
});
return this.getSettings();
}
listThemes(): ThemeView[] {
const themesDir = this.resolveThemesDir(this.config.get<string>('THEMES_DIR', './themes'));
let present = new Set<string>();
try {
if (fs.existsSync(themesDir)) {
for (const f of fs.readdirSync(themesDir)) {
if (!f.endsWith('.json')) continue;
try {
const raw = fs.readFileSync(path.join(themesDir, f), 'utf-8');
const parsed = JSON.parse(raw) as { id?: string };
if (parsed?.id) present.add(parsed.id);
} catch {
/* skip unreadable */
}
}
}
} catch {
/* swallow */
}
return this.themes
.listThemes()
.filter((t) => present.has(t.id))
.map((t) => ({ id: t.id, key: t.id, name: t.name }));
}
private resolveThemesDir(raw: string): string {
if (path.isAbsolute(raw)) return raw;
const backendRoot = path.resolve(__dirname, '..', '..', '..');
return path.resolve(backendRoot, raw);
}
}
@@ -0,0 +1,191 @@
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<void> {
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<unknown> {
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: "<json-string>" } 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,
);
}
if (!body.stagingId) {
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_STAGE_EXPIRED,
'Restore stage missing or expired',
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 };
}
}
@@ -0,0 +1,32 @@
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 { FilesystemTransactionModule } from './filesystem-transaction.module';
import { ConfirmationTokenService } from './confirmation-token.service';
import { AuthModule } from '../../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([AdminOperationTokenEntity]),
AuthModule,
FilesystemTransactionModule,
],
controllers: [AdminSystemController],
providers: [
BackupService,
RestoreService,
DangerZoneService,
ConfirmationTokenService,
],
exports: [
BackupService,
RestoreService,
DangerZoneService,
ConfirmationTokenService,
],
})
export class AdminSystemModule {}
@@ -0,0 +1,203 @@
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<string>([
'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<string, Array<Record<string, unknown>>>;
tableCounts: Record<string, number>;
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<string>('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<BackupObject> {
const exportedAt = new Date().toISOString();
const tables: Record<string, Array<Record<string, unknown>>> = {};
const tableCounts: Record<string, number> = {};
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<string[]> {
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<Record<string, unknown>[]> {
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<Record<string, unknown>>(`SELECT * FROM "${table}"`);
return rows.map((row) => {
const out: Record<string, unknown> = {};
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<T>(sql: string): Promise<T[]> {
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 }],
);
}
}
/**
* Subdirectory/file names that are part of the platform's own internal
* staging machinery rather than user-uploaded content. They MUST be
* excluded from backups so a partially failed swap cannot end up as
* application upload content in the resulting archive.
*/
private static readonly EXCLUDED_UPLOAD_ENTRIES: ReadonlySet<string> = new Set([
'.staging',
]);
private static readonly EXCLUDED_UPLOAD_PATTERNS: RegExp[] = [
/^\.?[^/]+\.rollback-[\w-]+$/, // e.g. .uploads.rollback-1784819568666-n0w53hxp
/^.+\.restore-stage-[\w-]+$/, // e.g. uploads.restore-stage-12345-abcdef
/^\.challenges\.wipe-stage-[\w-]+$/, // e.g. .challenges.wipe-stage-12345
];
private isExcludedUploadEntry(name: string): boolean {
if (BackupService.EXCLUDED_UPLOAD_ENTRIES.has(name)) return true;
for (const pattern of BackupService.EXCLUDED_UPLOAD_PATTERNS) {
if (pattern.test(name)) return true;
}
return false;
}
private async collectUploads(): Promise<BackupFileEntry[]> {
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 (BackupService.EXCLUDED_UPLOAD_ENTRIES.has(entry.name)) continue;
// For top-level + first-level descendants only, drop rollback-style artifacts.
const relative = path.relative(this.uploadDir, path.join(dir, entry.name)).split(path.sep);
if (relative.length <= 1 && this.isExcludedUploadEntry(entry.name)) 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);
}
}
@@ -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<AdminOperationTokenEntity>,
private readonly dataSource: DataSource,
) {
this.ttlMs = config.get<number>('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<IssueTokenResult> {
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<number> {
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');
}
}
@@ -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<string>('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<ResetScoresResult> {
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 `<UPLOAD_DIR>/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<WipeChallengesResult> {
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 });
}
}
@@ -0,0 +1,50 @@
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 = z.object({
operation: z.enum(ADMIN_OPERATION_KINDS),
token: z.string().min(1).max(512),
stagingId: z.string().min(1).max(128).optional(),
});
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<typeof AdminReauthBodySchema>;
export type AdminDangerConfirmBody = z.infer<typeof AdminDangerConfirmBodySchema>;
export type AdminRestoreCommitBody = z.infer<typeof AdminRestoreCommitBodySchema>;
export type AdminRestoreValidateResponse = z.infer<typeof AdminRestoreValidateResponseSchema>;
export interface ConfirmationTokenResponse {
operation: AdminOperationKind;
token: string;
expiresAt: string;
stagingId?: string;
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { FilesystemTransactionService } from './filesystem-transaction.service';
/**
* Hosts the durable filesystem swap service so it can be reused by both
* the admin system operations (which perform the swap) and database
* initialization (which performs the startup recovery sweep). Keeping
* the provider in its own module avoids a circular dependency between
* DatabaseModule and AdminSystemModule.
*/
@Module({
providers: [FilesystemTransactionService],
exports: [FilesystemTransactionService],
})
export class FilesystemTransactionModule {}
@@ -0,0 +1,760 @@
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 {
/** Absolute path of the live filesystem location that will be swapped out. */
livePath: string;
/** Absolute path of the staged replacement that will be swapped in. */
stagedPath: string;
/**
* If true, the pair participates in the manifest's "legacy"/"primary"
* generation tally. The database and uploads are typically `true`, while
* sidecar files (e.g. `-wal`, `-shm`) are NOT they are tracked as
* auxiliary, so a partial sidecar swap does not mark the parent
* generation as "incomplete".
*/
contributesToGeneration?: boolean;
/**
* Auxiliary sidecar suffixes to move with the live path. When the live
* path is replaced, any matching sidecars (e.g. `<live>.wal`) are
* moved into the rollback dir and back. When the staged replacement
* arrives, stale sidecars are removed so the new file is not paired
* with an old WAL.
*/
sidecarSuffixes?: string[];
}
export interface SwapRollbackHandle {
rolledBack: boolean;
committed: boolean;
/** Public identifier of the durable transaction; only present when recoverability is enabled. */
transactionId?: string;
/**
* The exact per-transaction manifest directory created under the system
* staging root. Stored on the handle so that `commit()` and `rollback()`
* can clean the right record without re-deriving paths.
*/
manifestDir?: string;
pairs: Array<{ livePath: string; rollbackPath: string; stagedPath: string; sidecarSuffixes: string[] }>;
}
interface WorkerHooks {
beforeSwap?: () => Promise<void>;
afterSwap?: () => Promise<void>;
onCommit?: () => Promise<void>;
}
export interface StageSwapInput {
name: string;
pairs: SwapPair[];
hooks?: WorkerHooks;
}
/**
* Transaction phase values written to the durable manifest. Recovery
* inspects these to decide whether the system is in a coherent state.
*
* Phase progression under success:
* prepared live-snapshotted replacements-installed verified committed
*
* Phase progression under in-process failure or rollback():
* any rolled-back
*/
export type TransactionPhase =
| 'prepared'
| 'live-snapshotted'
| 'replacements-installed'
| 'verified'
| 'committed'
| 'rolled-back';
export interface TransactionManifest {
schemaVersion: 1;
transactionId: string;
operation: string;
createdAt: string;
updatedAt: string;
phase: TransactionPhase;
pairs: Array<{
livePath: string;
rollbackPath: string;
stagedPath: string;
contributesToGeneration: boolean;
sidecarSuffixes: string[];
}>;
}
export interface RecoveryReport {
transactionsScanned: number;
resolved: number;
aborted: string[];
cleaned: string[];
}
/**
* Crash-safe filesystem swap with a durable manifest.
*
* Each `stageSwap()` writes a small JSON manifest under the configured
* system staging root. The manifest records the live/staged/rollback
* paths and the current phase. If the process is killed mid-swap, the
* manifest is still on disk so startup recovery can resolve the
* transaction deterministically:
*
* - If the old generation (live + rollback trees) is fully present,
* restore it.
* - If the new generation (staged live) is fully present AND was
* marked `verified`, finalize it and remove the rollback copy.
* - Otherwise, fail closed: the manifest is left on disk for an
* operator, and startup throws a `SYSTEM_RESTORE_RECOVERY_PENDING`
* error.
*
* The class also sweeps stale `*.rollback-*` artifacts in the configured
* live paths that are NOT associated with any live manifest (e.g. from
* a previous Job 919 incident) so the recovery itself never silently
* removes unowned files.
*/
@Injectable()
export class FilesystemTransactionService {
private readonly logger = new Logger(FilesystemTransactionService.name);
/** Manifest file name inside each per-transaction directory. */
static MANIFEST_FILENAME = 'manifest.json';
/**
* Apply the swap. Returns a rollback handle.
*
* Behavior matches the original (in-memory) contract: success means
* the staged replacement now lives at `livePath`; failure rolls the
* pair back. The difference is that the swap is now also recorded in a
* durable manifest so a crashed process can finish or undo the
* operation on next startup.
*
* If the caller's environment doesn't configure a system staging root,
* the method falls back to the old in-memory behavior (still safe; just
* not recoverable across process death).
*/
async stageSwap(
input: StageSwapInput,
opts: { stagingDir?: string } = {},
): Promise<SwapRollbackHandle> {
const txDir = opts.stagingDir ? this.ensureTxDir(opts.stagingDir) : null;
const manifest: TransactionManifest | null = txDir
? this.makeInitialManifest(input, txDir)
: null;
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const rolled: SwapRollbackHandle['pairs'] = [];
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);
const sidecarSuffixes = pair.sidecarSuffixes ?? [];
// Move any sidecar files (e.g. -wal/-shm) into the rollback dir first so they cannot
// be left paired with the swapped-in live path.
for (const suffix of sidecarSuffixes) {
const liveSidecar = `${pair.livePath}${suffix}`;
const rollbackSidecar = `${rollbackPath}${suffix}`;
if (fs.existsSync(liveSidecar) && !fs.existsSync(rollbackSidecar)) {
try {
fs.renameSync(liveSidecar, rollbackSidecar);
} catch {
// Cross-device: best-effort copy+unlink.
FilesystemTransactionService.copyDirSync(liveSidecar, rollbackSidecar);
try { fs.rmSync(liveSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
}
if (fs.existsSync(pair.livePath)) {
fs.renameSync(pair.livePath, rollbackPath);
} else if (fs.existsSync(pair.stagedPath)) {
try { fs.rmSync(pair.stagedPath, { recursive: true, force: true }); } catch { /* ignore */ }
}
rolled.push({ livePath: pair.livePath, rollbackPath, stagedPath: pair.stagedPath, sidecarSuffixes });
}
if (manifest) this.updateManifest(manifest, txDir!, 'live-snapshotted');
if (input.hooks?.beforeSwap) {
await input.hooks.beforeSwap();
}
// 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) {
FilesystemTransactionService.copyDirSync(r.stagedPath, r.livePath);
try { fs.rmSync(r.stagedPath, { recursive: true, force: true }); } catch { /* ignore */ }
}
// Stale sidecars that belonged to a previous live DB must NOT survive the swap:
// drop anything that matches and isn't one we tracked above.
for (const suffix of r.sidecarSuffixes) {
const liveSidecar = `${r.livePath}${suffix}`;
if (fs.existsSync(liveSidecar)) {
try { fs.rmSync(liveSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
}
if (manifest) this.updateManifest(manifest, txDir!, 'replacements-installed');
if (input.hooks?.afterSwap) {
await input.hooks.afterSwap();
}
if (manifest) this.updateManifest(manifest, txDir!, 'verified');
return {
rolledBack: false,
committed: false,
transactionId: manifest?.transactionId,
manifestDir: txDir ?? undefined,
pairs: rolled,
};
} catch (err) {
await this.rollbackLocal({ rolledBack: false, committed: false, pairs: rolled, transactionId: manifest?.transactionId });
if (manifest && txDir) {
try {
this.updateManifest(manifest, txDir, 'rolled-back');
} catch {
// ignore: best effort
}
}
throw err;
}
}
/**
* Restore the original live paths from their rollback snapshots. Safe to call multiple times.
*/
async rollback(handle: SwapRollbackHandle): Promise<void> {
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 and (any) staged
* leftovers are deleted, and the manifest's terminal phase is recorded.
*/
commit(handle: SwapRollbackHandle): void {
if (!handle || handle.rolledBack || handle.committed) 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 });
}
// Sidecars: a successful commit means the new live has fresh
// sidecars, but any leftover from the staged tree should be gone.
for (const suffix of r.sidecarSuffixes) {
const stagedSidecar = `${r.stagedPath}${suffix}`;
if (fs.existsSync(stagedSidecar)) {
try { fs.rmSync(stagedSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
} catch (err) {
this.logger.warn(
`Failed to clean rollback artifact ${r.rollbackPath}: ${(err as Error)?.message}`,
);
}
}
if (handle.manifestDir) {
try {
FilesystemTransactionService.rmSafe(handle.manifestDir);
} catch {
// ignore
}
}
handle.rolledBack = true;
handle.committed = true;
}
/**
* Idempotent terminal step retained for backwards compatibility.
*/
finalize(handle: SwapRollbackHandle): void {
if (!handle) return;
if (!handle.committed && !handle.rolledBack) this.commit(handle);
}
/**
* Scan a system-staging root for unfinished transactions and resolve
* each one to a coherent state. Intended to be called once during
* startup BEFORE the database / uploads are opened.
*
* Returns a summary that the caller can log. Throws if any transaction
* cannot be safely resolved (ambiguous manifest state). Throwing at
* startup is preferable to silently producing a hybrid state.
*/
recoverTransactions(stagingDir: string, opts: { now?: number; maxAgeMs?: number } = {}): RecoveryReport {
FilesystemTransactionService.ensureDir(stagingDir);
const report: RecoveryReport = { transactionsScanned: 0, resolved: 0, aborted: [], cleaned: [] };
const entries = fs.readdirSync(stagingDir, { withFileTypes: true });
const now = opts.now ?? Date.now();
const maxAgeMs = opts.maxAgeMs ?? 7 * 24 * 60 * 60_000;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const txDir = path.join(stagingDir, entry.name);
const manifestPath = path.join(txDir, FilesystemTransactionService.MANIFEST_FILENAME);
if (!fs.existsSync(manifestPath)) {
// Orphan tx dir without a manifest — only remove if stale (we never delete anything tied to a live manifest).
try {
const stat = fs.statSync(txDir);
if (now - stat.mtimeMs > maxAgeMs) {
FilesystemTransactionService.rmSafe(txDir);
report.cleaned.push(txDir);
}
} catch {
// ignore
}
continue;
}
report.transactionsScanned += 1;
let manifest: TransactionManifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as TransactionManifest;
} catch (err) {
report.aborted.push(manifestPath);
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_FAILED,
`Unreadable transaction manifest at ${manifestPath}: ${(err as Error).message}`,
500,
{ details: { phase: 'recovery' } },
);
}
// Terminal phases need no further action.
if (manifest.phase === 'committed' || manifest.phase === 'rolled-back') {
FilesystemTransactionService.rmSafe(txDir);
report.resolved += 1;
continue;
}
// Mid-swap recovery decision.
try {
const decision = this.resolveInterruptedManifest(manifest);
if (decision === 'keep-old') {
this.restoreOldGeneration(manifest, txDir);
} else if (decision === 'keep-new') {
this.finalizeNewGeneration(manifest, txDir);
} else {
report.aborted.push(manifestPath);
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_FAILED,
`Cannot resolve interrupted transaction ${manifest.transactionId} at phase=${manifest.phase}: neither old nor new generation is fully present`,
500,
{ details: { phase: 'recovery', transactionId: manifest.transactionId, transactionPhase: manifest.phase } },
);
}
FilesystemTransactionService.rmSafe(txDir);
report.resolved += 1;
} catch (err) {
if ((err as ApiError)?.code) throw err;
report.aborted.push(manifestPath);
throw new ApiError(
ERROR_CODES.SYSTEM_RESTORE_FAILED,
`Recovery of transaction ${manifest.transactionId} failed: ${(err as Error).message}`,
500,
{ details: { phase: 'recovery', transactionId: manifest.transactionId } },
);
}
}
return report;
}
/**
* Sweep unowned rollback / restore-stage / wipe-stage fragments that
* sit NEXT to a live path (database or uploads) and are not currently
* tracked by any manifest. These can be left behind by an interrupted
* swap from before this durable manifest existed, OR by an interrupted
* wipe-challenges snapshot. Only artifacts older than `maxAgeMs` are
* removed so that a perfectly healthy in-flight operation is never
* disturbed.
*/
sweepUnownedArtifactsNearLivePaths(
paths: string[],
opts: { now?: number; maxAgeMs?: number; stagingDir?: string } = {},
): string[] {
const cleaned: string[] = [];
const now = opts.now ?? Date.now();
const maxAgeMs = opts.maxAgeMs ?? 24 * 60 * 60_000;
const stagingDir = opts.stagingDir;
const candidates = (livePath: string, suffixRegex: RegExp): string[] => {
const dir = path.dirname(livePath);
const base = path.basename(livePath);
if (!fs.existsSync(dir)) return [];
const result: string[] = [];
for (const entry of fs.readdirSync(dir)) {
if (!suffixRegex.test(entry)) continue;
// The artifact must be derived from this specific live path (its
// base name must appear at the start of the artifact).
if (!entry.startsWith(`${base}.`) && !entry.startsWith(`.${base}.`)) continue;
const full = path.join(dir, entry);
try {
const stat = fs.statSync(full);
if (now - stat.mtimeMs < maxAgeMs) continue;
} catch {
continue;
}
// Skip artifacts actively tracked by a manifest.
if (stagingDir && FilesystemTransactionService.hasManifestTracking(stagingDir, full)) continue;
result.push(full);
}
return result;
};
const restoreStageRe = /\.restore-stage-[\w-]+$/;
// Either a hidden (`.<base>.rollback-…`) or visible (`<base>.rollback-…`)
// rollback artifact derived from the live path basename.
const rollbackRe = /^\.?[^/]+\.rollback-[\w-]+$/;
const wipeStageRe = /^\.challenges\.wipe-stage-[\w-]+$/;
for (const livePath of paths) {
const directDir = path.dirname(livePath);
if (!fs.existsSync(directDir)) continue;
// For the uploadDir live, also sweep the `challenges/` subdirectory
// (used by DangerZoneService.wipeChallenges()).
const extraDirs: string[] = [];
if (fs.existsSync(livePath)) {
const stat = fs.statSync(livePath);
if (stat.isDirectory()) {
extraDirs.push(path.join(livePath, 'challenges'));
}
}
const allArtifacts = [
...candidates(livePath, restoreStageRe),
...candidates(livePath, rollbackRe),
...candidates(livePath, wipeStageRe),
];
for (const artifact of allArtifacts) {
FilesystemTransactionService.rmSafe(artifact);
cleaned.push(artifact);
}
for (const extra of extraDirs) {
if (!fs.existsSync(extra)) continue;
for (const entry of fs.readdirSync(extra)) {
if (!wipeStageRe.test(entry)) continue;
const full = path.join(extra, entry);
try {
const stat = fs.statSync(full);
if (now - stat.mtimeMs < maxAgeMs) continue;
} catch {
continue;
}
if (stagingDir && FilesystemTransactionService.hasManifestTracking(stagingDir, full)) continue;
FilesystemTransactionService.rmSafe(full);
cleaned.push(full);
}
}
}
return cleaned;
}
/**
* Decide whether to restore the OLD generation (live+rollback both
* present) or keep the NEW generation (live already swapped + rollback
* present). Returns null when neither generation is complete.
*/
private resolveInterruptedManifest(manifest: TransactionManifest): 'keep-old' | 'keep-new' | null {
const generationPairs = manifest.pairs.filter((p) => p.contributesToGeneration);
if (generationPairs.length === 0) return 'keep-new';
// Old generation complete iff every livePath is currently at the
// rollbackPath (no swap happened for that pair). Once a swap has been
// recorded as `replacements-installed`, the live path is the new
// generation.
if (manifest.phase === 'prepared') {
// Nothing has happened yet — the live tree is still the old one and
// there is no rollback because the rename hasn't run. We treat this
// as a clean "keep-old" with no filesystem action.
return 'keep-old';
}
if (manifest.phase === 'live-snapshotted') {
const oldComplete = generationPairs.every((p) => fs.existsSync(p.rollbackPath) && !fs.existsSync(p.livePath));
return oldComplete ? 'keep-old' : null;
}
if (manifest.phase === 'replacements-installed' || manifest.phase === 'verified' || manifest.phase === 'committed') {
const newComplete = generationPairs.every((p) => fs.existsSync(p.livePath) && fs.existsSync(p.rollbackPath));
return newComplete ? 'keep-new' : null;
}
return null;
}
/**
* Restore the OLD generation: move each rollback path back to the live path
* (or, when the live path already holds the new generation due to an
* earlier partial swap, remove it first). The rollback is the snapshot of
* the pre-swap live tree.
*/
private restoreOldGeneration(manifest: TransactionManifest, txDir: string): void {
if (manifest.phase === 'prepared') {
// Nothing was moved; just record the terminal state.
this.updateManifest(manifest, txDir, 'rolled-back');
return;
}
for (const p of manifest.pairs) {
// Remove any partial new-generation content that may already exist at livePath.
if (fs.existsSync(p.livePath)) {
FilesystemTransactionService.rmSafe(p.livePath);
}
if (fs.existsSync(p.rollbackPath)) {
FilesystemTransactionService.ensureDir(path.dirname(p.livePath));
try {
fs.renameSync(p.rollbackPath, p.livePath);
} catch {
FilesystemTransactionService.copyDirSync(p.rollbackPath, p.livePath);
try { fs.rmSync(p.rollbackPath, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
// Sidecars of the OLD generation are stored next to the rollback path
// (e.g. `rollbackPath-wal`); restore them too if present.
for (const suffix of p.sidecarSuffixes) {
const rollbackSidecar = `${p.rollbackPath}${suffix}`;
const liveSidecar = `${p.livePath}${suffix}`;
if (fs.existsSync(rollbackSidecar)) {
try { fs.rmSync(liveSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
FilesystemTransactionService.ensureDir(path.dirname(liveSidecar));
try {
fs.renameSync(rollbackSidecar, liveSidecar);
} catch {
FilesystemTransactionService.copyDirSync(rollbackSidecar, liveSidecar);
try { fs.rmSync(rollbackSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
if (fs.existsSync(liveSidecar) && !fs.existsSync(rollbackSidecar)) {
// No source sidecar and the live sidecar remains — leave it removed.
try { fs.rmSync(liveSidecar, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
}
this.updateManifest(manifest, txDir, 'rolled-back');
}
/**
* Finalize the NEW generation: keep the live replacement in place,
* remove the rollback snapshot and any stale sidecars.
*/
private finalizeNewGeneration(manifest: TransactionManifest, txDir: string): void {
for (const p of manifest.pairs) {
// Drop the rollback copy; the new live is now durable.
FilesystemTransactionService.rmSafe(p.rollbackPath);
// Remove any unused staged remnants and stale sidecars.
FilesystemTransactionService.rmSafe(p.stagedPath);
for (const suffix of p.sidecarSuffixes) {
const stagedSidecar = `${p.stagedPath}${suffix}`;
FilesystemTransactionService.rmSafe(stagedSidecar);
// Keep the live sidecar if it was written by the new replacement;
// it should be empty (just created) so nothing further to clean.
}
}
this.updateManifest(manifest, txDir, 'committed');
}
/**
* Helper that callers (e.g. DatabaseInitService) can use to decide
* whether a given rollback/staged artifact is already tracked by a
* live manifest in the staging root.
*/
static hasManifestTracking(stagingDir: string, rollbackPath: string): boolean {
if (!fs.existsSync(stagingDir)) return false;
const entries = fs.readdirSync(stagingDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = path.join(stagingDir, entry.name, FilesystemTransactionService.MANIFEST_FILENAME);
if (!fs.existsSync(manifestPath)) continue;
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as TransactionManifest;
for (const p of manifest.pairs) {
if (p.rollbackPath === rollbackPath || p.stagedPath === rollbackPath || p.livePath === rollbackPath) {
return true;
}
}
} catch {
// ignore unreadable manifests; recovery will surface them
}
}
return false;
}
/* ───────── Manifest helpers ───────── */
private ensureTxDir(stagingDir: string): string {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const full = path.join(stagingDir, id);
FilesystemTransactionService.ensureDir(full);
return full;
}
private makeInitialManifest(input: StageSwapInput, txDir: string): TransactionManifest {
const transactionId = path.basename(txDir);
const now = new Date().toISOString();
const manifest: TransactionManifest = {
schemaVersion: 1,
transactionId,
operation: input.name,
createdAt: now,
updatedAt: now,
phase: 'prepared',
pairs: [],
};
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
for (const pair of input.pairs) {
const rollbackPath = FilesystemTransactionService.makeRollbackPath(pair.livePath, stamp);
manifest.pairs.push({
livePath: pair.livePath,
rollbackPath,
stagedPath: pair.stagedPath,
contributesToGeneration: pair.contributesToGeneration ?? true,
sidecarSuffixes: pair.sidecarSuffixes ?? [],
});
}
this.writeManifestAtomic(manifest, txDir);
return manifest;
}
private updateManifest(manifest: TransactionManifest, txDir: string, phase: TransactionPhase): void {
manifest.phase = phase;
manifest.updatedAt = new Date().toISOString();
this.writeManifestAtomic(manifest, txDir);
}
private writeManifestAtomic(manifest: TransactionManifest, txDir: string): void {
const target = path.join(txDir, FilesystemTransactionService.MANIFEST_FILENAME);
const tmp = `${target}.tmp-${Math.random().toString(36).slice(2, 8)}`;
FilesystemTransactionService.ensureDir(path.dirname(tmp));
fs.writeFileSync(tmp, JSON.stringify(manifest));
fs.renameSync(tmp, target);
}
private manifestPathFor(_transactionId: string): string {
// Used only by recovery paths that already know where the manifest lives.
void _transactionId;
return FilesystemTransactionService.MANIFEST_FILENAME;
}
/* ───────── Static filesystem helpers ───────── */
/**
* 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; gracefully no-ops if the source doesn't exist.
*/
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 });
}
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<void> {
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)
? (() => {
try {
return !fs.existsSync(r.stagedPath);
} catch {
return true;
}
})()
: false;
try {
if (stageStillAtLive) {
// swap happened — remove the now-swapped content before restoring.
FilesystemTransactionService.rmSafe(r.livePath);
}
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 (fs.existsSync(r.stagedPath)) {
FilesystemTransactionService.rmSafe(r.stagedPath);
}
// Restore sidecars if they were tracked.
for (const suffix of r.sidecarSuffixes) {
const liveSidecar = `${r.livePath}${suffix}`;
const rollbackSidecar = `${r.rollbackPath}${suffix}`;
if (fs.existsSync(rollbackSidecar)) {
FilesystemTransactionService.rmSafe(liveSidecar);
FilesystemTransactionService.ensureDir(path.dirname(liveSidecar));
try {
fs.renameSync(rollbackSidecar, liveSidecar);
} catch {
FilesystemTransactionService.copyDirSync(rollbackSidecar, liveSidecar);
try { fs.rmSync(rollbackSidecar, { 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;
@@ -0,0 +1,535 @@
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 systemStagingDir: string;
private readonly stageTtlMs: number;
private readonly restoreUploadLimit: number;
/** stagingId -> in-memory record; persisted metadata is unnecessary for tests. */
private readonly stages = new Map<string, StagedRestore>();
constructor(
private readonly configService: ConfigService,
private readonly dataSource: DataSource,
private readonly fsTx: FilesystemTransactionService,
) {
this.uploadDir = path.resolve(this.configService.get<string>('UPLOAD_DIR', './data/uploads'));
this.databasePath = path.resolve(this.configService.get<string>('DATABASE_PATH', './data/db.sqlite'));
this.systemStagingDir = resolveSystemStagingDir(this.configService);
this.stageTtlMs = this.configService.get<number>('SYSTEM_OP_RESTORE_STAGE_TTL_MS', 15 * 60_000);
this.restoreUploadLimit = parseUploadSizeLimit(this.configService.get<string>('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<AdminRestoreValidateResponse> {
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<string>();
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<string, number> = {};
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<CommitRestoreResult> {
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 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,
contributesToGeneration: true,
sidecarSuffixes: ['-wal', '-shm'],
},
{
livePath: this.uploadDir,
stagedPath: stagedUploadsReplace,
contributesToGeneration: true,
},
],
hooks: {
afterSwap: async () => {
await this.verifySwappedDatabase();
},
},
},
{ stagingDir: this.systemStagingDir },
);
// 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');
const capturedTriggerSqls: string[] = [];
try {
// Capture and temporarily drop the deployment-wide last-admin triggers.
// SQLite triggers fire regardless of foreign_keys, so clearing the
// sole admin row before inserting the archive's admin would abort the
// rebuild. We only mutate an offline candidate file and reinstate
// them before swap eligibility.
const triggerRows = db
.prepare(
`SELECT name, sql FROM sqlite_master WHERE type='trigger' AND name IN ('trg_user_last_admin_update','trg_user_last_admin_delete') AND sql IS NOT NULL`,
)
.all() as Array<{ name: string; sql: string }>;
const capturedTriggerNames = new Set(triggerRows.map((r) => r.name));
if (
!capturedTriggerNames.has('trg_user_last_admin_update') ||
!capturedTriggerNames.has('trg_user_last_admin_delete')
) {
throw new Error(
'Required LAST_ADMIN triggers missing from cloned schema; refusing to rebuild',
);
}
for (const row of triggerRows) {
capturedTriggerSqls.push(row.sql);
db.prepare(`DROP TRIGGER IF EXISTS "${row.name}"`).run();
}
// 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);
}
// Re-establish the deployment-wide invariant in the candidate
// database before swap eligibility. A restore that ships zero
// admins must fail here so the live system never lands in that
// state, even momentarily.
const adminCount = (
db.prepare(`SELECT COUNT(*) AS c FROM "user" WHERE "role" = 'admin'`).get() as { c: number }
).c;
if (!adminCount || adminCount < 1) {
throw new Error('Restored archive contains no admin user; refusing to swap');
}
for (const sql of capturedTriggerSqls) {
db.exec(sql);
}
db.pragma('foreign_keys = ON');
db.pragma('foreign_key_check');
} catch (innerErr) {
// Make sure we never leak a candidate missing the triggers: re-create
// them best-effort before rethrowing so the rolled-back candidate is
// not left in an unsafe state on disk.
try {
const existing = new Set(
(
db
.prepare(
`SELECT name FROM sqlite_master WHERE type='trigger' AND name IN ('trg_user_last_admin_update','trg_user_last_admin_delete')`,
)
.all() as Array<{ name: string }>
).map((r) => r.name),
);
for (const sql of capturedTriggerSqls) {
const m = /"([^"]+)"/.exec(sql);
const name = m?.[1];
if (name && !existing.has(name)) {
db.exec(sql);
}
}
} catch {
// ignore best-effort restore errors
}
throw innerErr;
} finally {
try {
db.close();
} catch {
// ignore
}
}
}
private coerceValue(value: unknown): unknown {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const v = value as Record<string, unknown>;
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<string, number> = {
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<void> {
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');
}
+156
View File
@@ -0,0 +1,156 @@
import {
Body,
Controller,
Get,
Post,
Req,
Res,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { Public } from '../../common/decorators/public.decorator';
import {
ChangePasswordDtoSchema,
LoginDtoSchema,
RefreshDtoSchema,
RegisterDtoSchema,
} from './dto/auth.dto';
import { AuthService } from './auth.service';
import { CsrfMiddleware } from '../../common/middleware/csrf.middleware';
import { setRefreshCookie, clearRefreshCookie } from './cookie';
@ApiTags('auth')
@Controller('api/v1/auth')
export class AuthController {
constructor(
private readonly auth: AuthService,
private readonly csrf: CsrfMiddleware,
private readonly config: ConfigService,
) {}
@Public()
@Post('register')
@ApiOperation({ summary: 'Public player self-registration' })
@ApiResponse({ status: 201, description: 'Registration successful, returns login session' })
async register(
@Body(new ZodValidationPipe(RegisterDtoSchema)) body: {
username: string;
password: string;
passwordConfirm: string;
},
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string;
const session = await this.auth.registerPlayer(body, ip);
setRefreshCookie(res, session.refreshToken, this.config);
return {
accessToken: session.accessToken,
expiresIn: session.expiresIn,
user: session.user,
};
}
@Public()
@Post('login')
@ApiOperation({ summary: 'Login with username and password' })
@ApiBody({ schema: { example: { username: 'admin', password: 'SuperSecret123!' } } })
@ApiResponse({ status: 200, description: 'Login successful' })
async login(
@Body(new ZodValidationPipe(LoginDtoSchema)) body: { username: string; password: string },
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string;
const session = await this.auth.login(body, ip);
setRefreshCookie(res, session.refreshToken, this.config);
return {
accessToken: session.accessToken,
expiresIn: session.expiresIn,
user: session.user,
};
}
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Rotate the refresh token' })
async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
@Body(new ZodValidationPipe(RefreshDtoSchema)) body: { refreshToken?: string },
) {
const fromCookie = (req.cookies?.['rt'] as string | undefined) ?? '';
const token = body.refreshToken ?? fromCookie;
if (!token) return { message: 'No refresh token' };
const session = await this.auth.refresh(token);
setRefreshCookie(res, session.refreshToken, this.config);
return {
accessToken: session.accessToken,
expiresIn: session.expiresIn,
user: session.user,
};
}
@Public()
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Invalidate the current refresh token' })
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<void> {
const token = (req.cookies?.['rt'] as string | undefined) ?? '';
await this.auth.logout(token || undefined);
clearRefreshCookie(res);
}
@Get('me')
@ApiOperation({ summary: 'Current authenticated user profile (id, username, role, rank, points)' })
@ApiResponse({ status: 200, description: 'Returns the current user projection.' })
async me(@Req() req: Request): Promise<{
id: string;
username: string;
role: 'admin' | 'player';
rank: number | null;
points: number;
}> {
const user = req.user as { sub: string } | undefined;
const userId = user?.sub;
if (!userId) {
throw new (await import('@nestjs/common')).UnauthorizedException('Not authenticated');
}
return this.auth.getMe(userId);
}
@Post('change-password')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Change the current user password (requires old password)' })
@ApiBody({
schema: {
example: { oldPassword: 'Sup3rSecret!Pass', newPassword: 'NewSecret!Pass1', confirmNewPassword: 'NewSecret!Pass1' },
},
})
async changePassword(
@Req() req: Request,
@Body(new ZodValidationPipe(ChangePasswordDtoSchema))
body: { oldPassword: string; newPassword: string; confirmNewPassword: string },
): Promise<void> {
const user = req.user as { sub: string } | undefined;
const userId = user?.sub;
if (!userId) {
throw new (await import('@nestjs/common')).UnauthorizedException('Not authenticated');
}
await this.auth.changePassword(userId, body);
}
@Public()
@Get('csrf')
@ApiOperation({ summary: 'Issue a CSRF token (sets csrf cookie, returns token)' })
@ApiResponse({ status: 200, schema: { example: { csrfToken: '...' } } })
csrfEndpoint(@Req() req: Request, @Res({ passthrough: true }) res: Response): { csrfToken: string } {
const token = this.csrf.setOrGetCsrfToken(req, res);
return { csrfToken: token };
}
}
+34
View File
@@ -0,0 +1,34 @@
import { forwardRef, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { UserEntity } from '../../database/entities/user.entity';
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { AdminGuard } from '../../common/guards/admin.guard';
import { SettingsModule } from '../settings/settings.module';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]),
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_ACCESS_SECRET'),
signOptions: { expiresIn: config.get<string>('JWT_ACCESS_TTL', '15m') },
}),
}),
SettingsModule,
forwardRef(() => UsersModule),
],
providers: [AuthService, JwtStrategy, AdminGuard],
controllers: [AuthController],
exports: [AuthService, JwtModule, AdminGuard],
})
export class AuthModule {}
+374
View File
@@ -0,0 +1,374 @@
import {
Injectable,
OnModuleInit,
Logger,
Inject,
forwardRef,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, EntityManager } from 'typeorm';
import * as argon2 from 'argon2';
import * as crypto from 'crypto';
import { v4 as uuid } from 'uuid';
import { UserEntity } from '../../database/entities/user.entity';
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { LoginBackoffService } from '../../common/services/login-backoff.service';
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
import { ChangePasswordDto, LoginDto, LoginResponse, RegisterDto } from './dto/auth.dto';
import { validatePassword } from '../../common/utils/password-policy';
import { hashPassword } from '../../common/utils/password-hashing';
import { SettingsService } from '../settings/settings.module';
import { SETTINGS_KEYS } from '../../config/env.schema';
import { UsersRankService } from '../users/users-rank.service';
const REFRESH_TTL_SECONDS_DEFAULT = 7 * 24 * 60 * 60;
@Injectable()
export class AuthService implements OnModuleInit {
private readonly logger = new Logger(AuthService.name);
private refreshTtlSeconds = REFRESH_TTL_SECONDS_DEFAULT;
constructor(
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
@InjectRepository(RefreshTokenEntity) private readonly refreshTokens: Repository<RefreshTokenEntity>,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly dataSource: DataSource,
private readonly backoff: LoginBackoffService,
private readonly registrationRateLimit: RegistrationRateLimitService,
private readonly settings: SettingsService,
private readonly rank: UsersRankService,
) {}
onModuleInit(): void {
const ttl = this.config.get<string>('JWT_REFRESH_TTL', '7d');
this.refreshTtlSeconds = this.parseTtl(ttl);
}
async login(dto: LoginDto, ip: string): Promise<LoginResponse> {
const blockedMs = this.backoff.isBlocked(ip, dto.username);
if (blockedMs > 0) {
throw new ApiError(ERROR_CODES.RATE_LIMITED, `Too many failed attempts. Try again in ${Math.ceil(blockedMs / 1000)}s.`, 429);
}
return this.dataSource.transaction(async (manager) => {
const user = await manager.findOne(UserEntity, { where: { username: dto.username } });
if (!user || user.status !== 'enabled') {
this.backoff.recordFailure(ip, dto.username);
throw new ApiError(ERROR_CODES.INVALID_CREDENTIALS, 'Invalid credentials', 401);
}
const ok = await argon2.verify(user.passwordHash, dto.password);
if (!ok) {
this.backoff.recordFailure(ip, dto.username);
throw new ApiError(ERROR_CODES.INVALID_CREDENTIALS, 'Invalid credentials', 401);
}
this.backoff.reset(ip, dto.username);
return this.mintSession(manager, user);
});
}
async refresh(oldRefreshToken: string): Promise<LoginResponse> {
const tokenHash = this.hashToken(oldRefreshToken);
return this.dataSource.transaction(async (manager) => {
const row = await manager.findOne(RefreshTokenEntity, { where: { tokenHash } });
if (!row) throw new ApiError(ERROR_CODES.TOKEN_REVOKED, 'Refresh token revoked', 401);
if (row.revokedAt) throw new ApiError(ERROR_CODES.TOKEN_REVOKED, 'Refresh token revoked', 401);
if (new Date(row.expiresAt).getTime() < Date.now()) throw new ApiError(ERROR_CODES.TOKEN_REVOKED, 'Refresh token expired', 401);
const user = await manager.findOne(UserEntity, { where: { id: row.userId } });
if (!user || user.status !== 'enabled') throw new ApiError(ERROR_CODES.UNAUTHORIZED, 'User not allowed', 401);
row.revokedAt = new Date().toISOString();
await manager.save(row);
return this.mintSession(manager, user);
});
}
async logout(refreshToken: string | undefined): Promise<void> {
if (!refreshToken) return;
const tokenHash = this.hashToken(refreshToken);
await this.dataSource.transaction(async (manager) => {
const row = await manager.findOne(RefreshTokenEntity, { where: { tokenHash } });
if (!row || row.revokedAt) return;
row.revokedAt = new Date().toISOString();
await manager.save(row);
});
}
async registerFirstAdmin(username: string, password: string, ip: string): Promise<LoginResponse> {
validatePassword(password, this.config);
if (!this.registrationRateLimit.tryConsume(ip)) {
throw new ApiError(ERROR_CODES.RATE_LIMITED, 'Too many registrations from this IP; try again later.', 429);
}
return this.dataSource.transaction(async (manager) => {
const adminCount = await manager.count(UserEntity, { where: { role: 'admin' } });
if (adminCount > 0) {
throw new ApiError(ERROR_CODES.SYSTEM_INITIALIZED, 'System already initialized', 409);
}
const existing = await manager.findOne(UserEntity, { where: { username } });
if (existing) throw new ApiError(ERROR_CODES.CONFLICT, 'Username already taken', 409);
const passwordHash = await hashPassword(password, this.config);
const user = manager.create(UserEntity, {
id: uuid(),
username,
passwordHash,
role: 'admin',
status: 'enabled',
});
await manager.save(user);
return this.mintSession(manager, user);
});
}
async registerPlayer(dto: RegisterDto, ip: string): Promise<LoginResponse> {
validatePassword(dto.password, this.config);
if (!this.registrationRateLimit.tryConsume(ip)) {
throw new ApiError(
ERROR_CODES.RATE_LIMITED,
'Too many registration attempts; please wait a minute before trying again.',
429,
);
}
const enabled = (await this.settings.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')) === 'true';
if (!enabled) {
throw new ApiError(
ERROR_CODES.REGISTRATIONS_DISABLED,
'Registrations are currently disabled',
403,
);
}
return this.dataSource.transaction(async (manager) => {
const existing = await manager.findOne(UserEntity, { where: { username: dto.username } });
if (existing) {
throw new ApiError(ERROR_CODES.USERNAME_TAKEN, 'Username already taken', 409);
}
const passwordHash = await hashPassword(dto.password, this.config);
const user = manager.create(UserEntity, {
id: uuid(),
username: dto.username,
passwordHash,
role: 'player',
status: 'enabled',
});
await manager.save(user);
return this.mintSession(manager, user);
});
}
async getMe(userId: string): Promise<{ id: string; username: string; role: 'admin' | 'player'; rank: number | null; points: number }> {
const user = await this.users.findOne({ where: { id: userId } });
if (!user) throw new ApiError(ERROR_CODES.NOT_FOUND, 'User not found', 404);
const rankInfo = await this.rank.rankOfUser(this.dataSource.manager, user.id);
return {
id: user.id,
username: user.username,
role: user.role,
rank: rankInfo.rank,
points: rankInfo.points,
};
}
async changePassword(userId: string, dto: ChangePasswordDto): Promise<void> {
if (dto.newPassword !== dto.confirmNewPassword) {
throw new ApiError(ERROR_CODES.PASSWORDS_DO_NOT_MATCH, 'Passwords do not match', 400);
}
if (dto.oldPassword === dto.newPassword) {
throw new ApiError(ERROR_CODES.PASSWORD_POLICY, 'New password must differ from the old one', 400);
}
return this.dataSource.transaction(async (manager) => {
const user = await manager.findOne(UserEntity, { where: { id: userId } });
if (!user) throw new ApiError(ERROR_CODES.NOT_FOUND, 'User not found', 404);
const ok = await argon2.verify(user.passwordHash, dto.oldPassword);
if (!ok) {
throw new ApiError(ERROR_CODES.INVALID_OLD_PASSWORD, 'Old password is incorrect', 401);
}
try {
validatePassword(dto.newPassword, this.config);
} catch (e) {
if (e instanceof ApiError) {
throw new ApiError(ERROR_CODES.PASSWORD_POLICY, e.message, 400);
}
throw e;
}
user.passwordHash = await hashPassword(dto.newPassword, this.config);
await manager.save(user);
// Invalidate all existing refresh tokens for this user.
await manager
.createQueryBuilder()
.update(RefreshTokenEntity)
.set({ revokedAt: new Date().toISOString() })
.where('userId = :userId', { userId: user.id })
.andWhere('revokedAt IS NULL')
.execute();
});
}
/**
* 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<UserEntity> {
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<number> {
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
* token so the caller can set the HttpOnly cookie.
*/
private async mintSession(manager: EntityManager, user: UserEntity): Promise<LoginResponse> {
return AuthService.mintSessionWith(
manager,
this.jwt,
this.config,
this.refreshTtlSeconds,
user,
);
}
/**
* Stateless mint helper used by other modules (e.g. setup) that need to
* create a session without depending on the full AuthModule injection tree.
*/
static async mintSessionWith(
manager: EntityManager,
jwt: JwtService,
config: ConfigService,
refreshTtlSeconds: number,
user: UserEntity,
): Promise<LoginResponse> {
const accessToken = await jwt.signAsync({ sub: user.id, username: user.username, role: user.role });
const refreshToken = AuthService.generateRefreshTokenStatic();
const tokenHash = AuthService.hashTokenStatic(refreshToken);
const now = new Date();
const expires = new Date(now.getTime() + refreshTtlSeconds * 1000);
await manager.insert(RefreshTokenEntity, {
id: uuid(),
userId: user.id,
tokenHash,
issuedAt: now.toISOString(),
expiresAt: expires.toISOString(),
revokedAt: null,
});
return {
accessToken,
refreshToken,
expiresIn: AuthService.accessTtlSecondsStatic(config),
user: { id: user.id, username: user.username, role: user.role },
};
}
private static generateRefreshTokenStatic(): string {
return crypto.randomBytes(32).toString('base64url');
}
private static hashTokenStatic(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
private static accessTtlSecondsStatic(config: ConfigService): number {
const ttl = config.get<string>('JWT_ACCESS_TTL', '15m');
return AuthService.parseTtlStatic(ttl);
}
private static parseTtlStatic(ttl: string): number {
const m = /^(\d+)([smhd])$/.exec(ttl.trim());
if (!m) return 900;
const n = parseInt(m[1], 10);
const unit = m[2];
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
return n * mult;
}
/**
* Public access to the static session mint for cross-module reuse.
* The instance-based `mintSession(manager, user)` is preserved for
* existing callers and delegates here.
*/
static async createSession(
manager: EntityManager,
deps: { jwt: JwtService; config: ConfigService; refreshTtlSeconds: number },
user: UserEntity,
): Promise<LoginResponse> {
return AuthService.mintSessionWith(manager, deps.jwt, deps.config, deps.refreshTtlSeconds, user);
}
private async signAccess(user: UserEntity): Promise<string> {
return this.jwt.signAsync({ sub: user.id, username: user.username, role: user.role });
}
private generateRefreshToken(): string {
return crypto.randomBytes(32).toString('base64url');
}
private hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
private accessTtlSeconds(): number {
return this.parseTtl(this.config.get<string>('JWT_ACCESS_TTL', '15m'));
}
private parseTtl(ttl: string): number {
const m = /^(\d+)([smhd])$/.exec(ttl.trim());
if (!m) return 900;
const n = parseInt(m[1], 10);
const unit = m[2];
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
return n * mult;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Response } from 'express';
import { ConfigService } from '@nestjs/config';
export const REFRESH_COOKIE_NAME = 'rt';
export function setRefreshCookie(res: Response, token: string, config: ConfigService): void {
const tls = config.get<boolean>('TLS_ENABLED', false);
res.cookie(REFRESH_COOKIE_NAME, token, {
httpOnly: true,
sameSite: tls ? 'strict' : 'lax',
secure: tls,
path: '/api/v1/auth',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}
export function clearRefreshCookie(res: Response): void {
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/v1/auth' });
}
+61
View File
@@ -0,0 +1,61 @@
import { z } from 'zod';
export const LoginDtoSchema = z.object({
username: z.string().min(1).max(64),
password: z.string().min(1).max(256),
});
export type LoginDto = z.infer<typeof LoginDtoSchema>;
export const RefreshDtoSchema = z.object({
refreshToken: z.string().min(1).optional(),
});
export type RefreshDto = z.infer<typeof RefreshDtoSchema>;
export const CsrfResponseDtoSchema = z.object({
csrfToken: z.string(),
});
export type CsrfResponseDto = z.infer<typeof CsrfResponseDtoSchema>;
export const RegisterDtoSchema = z
.object({
username: z
.string()
.min(3)
.max(32)
.regex(/^[a-zA-Z0-9_.-]+$/, 'Username may only contain letters, digits, dot, dash and underscore'),
password: z.string().min(1).max(256),
passwordConfirm: z.string().min(1).max(256),
})
.refine((d) => d.password === d.passwordConfirm, {
path: ['passwordConfirm'],
message: 'Passwords do not match',
});
export type RegisterDto = z.infer<typeof RegisterDtoSchema>;
export interface LoginResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: { id: string; username: string; role: 'admin' | 'player' };
}
export const ChangePasswordDtoSchema = z
.object({
oldPassword: z.string().min(1).max(256),
newPassword: z.string().min(1).max(256),
confirmNewPassword: z.string().min(1).max(256),
})
.refine((d) => d.newPassword === d.confirmNewPassword, {
path: ['confirmNewPassword'],
message: 'Passwords do not match',
});
export type ChangePasswordDto = z.infer<typeof ChangePasswordDtoSchema>;
export const MeResponseDtoSchema = z.object({
id: z.string(),
username: z.string(),
role: z.enum(['admin', 'player']),
rank: z.number().int().nullable(),
points: z.number().int(),
});
export type MeResponseDto = z.infer<typeof MeResponseDtoSchema>;
@@ -0,0 +1,26 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
export interface JwtPayload {
sub: string;
username: string;
role: 'admin' | 'player';
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>('JWT_ACCESS_SECRET'),
});
}
async validate(payload: JwtPayload): Promise<JwtPayload> {
if (!payload?.sub) throw new UnauthorizedException();
return payload;
}
}
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AdminGuard } from '../../common/guards/admin.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import {
AdminBlogPost,
CreatePostPayload,
CreatePostSchema,
PostIdParamSchema,
UpdatePostPayload,
UpdatePostSchema,
} from './dto/blog.dto';
import { AdminBlogService } from './admin-blog.service';
@ApiTags('admin')
@ApiBearerAuth()
@Controller('api/v1/admin/blog/posts')
@UseGuards(AdminGuard)
@Roles('admin')
export class AdminBlogController {
constructor(private readonly admin: AdminBlogService) {}
@Get()
@ApiOperation({ summary: 'List all blog posts (admin only, drafts included)' })
async list(): Promise<AdminBlogPost[]> {
return this.admin.listAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get a single blog post by id (admin only)' })
async getOne(
@Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string },
): Promise<AdminBlogPost> {
return this.admin.getById(params.id);
}
@Post()
@ApiOperation({ summary: 'Create a new blog post (admin only)' })
async create(
@Body(new ZodValidationPipe(CreatePostSchema)) body: CreatePostPayload,
): Promise<AdminBlogPost> {
return this.admin.create(body);
}
@Put(':id')
@ApiOperation({ summary: 'Update a blog post (admin only)' })
async update(
@Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdatePostSchema)) body: UpdatePostPayload,
): Promise<AdminBlogPost> {
return this.admin.update(params.id, body);
}
@Delete(':id')
@HttpCode(204)
@ApiOperation({ summary: 'Delete a blog post (admin only)' })
async remove(
@Param(new ZodValidationPipe(PostIdParamSchema)) params: { id: string },
): Promise<void> {
await this.admin.remove(params.id);
}
}
@@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
import { ApiError } from '../../common/errors/api-error';
import { AdminBlogPost, CreatePostPayload, UpdatePostPayload } from './dto/blog.dto';
@Injectable()
export class AdminBlogService {
constructor(
@InjectRepository(BlogPostEntity)
private readonly posts: Repository<BlogPostEntity>,
) {}
async listAll(): Promise<AdminBlogPost[]> {
const rows = await this.posts.find({ order: { updatedAt: 'DESC' } });
return rows.map((r) => this.toView(r));
}
async getById(id: string): Promise<AdminBlogPost> {
const row = await this.posts.findOne({ where: { id } });
if (!row) throw ApiError.notFound('Post not found');
return this.toView(row);
}
async create(payload: CreatePostPayload): Promise<AdminBlogPost> {
const status = payload.status ?? 'draft';
const row = this.posts.create({
id: uuid(),
title: payload.title.trim(),
bodyMd: payload.bodyMd ?? '',
status,
publishedAt: status === 'published' ? new Date().toISOString() : null,
});
await this.posts.save(row);
return this.toView(row);
}
async update(id: string, payload: UpdatePostPayload): Promise<AdminBlogPost> {
const row = await this.posts.findOne({ where: { id } });
if (!row) throw ApiError.notFound('Post not found');
if (payload.title !== undefined) row.title = payload.title.trim();
if (payload.bodyMd !== undefined) row.bodyMd = payload.bodyMd;
if (payload.status !== undefined) {
if (payload.status === 'published') {
row.status = 'published';
// Only stamp publishedAt if this post has never been published before;
// preserve the original publishedAt for re-publications.
if (!row.publishedAt) {
row.publishedAt = new Date().toISOString();
}
} else if (payload.status === 'draft') {
row.status = 'draft';
// preserve publishedAt so a later re-publish keeps the original publication timestamp.
}
}
await this.posts.save(row);
return this.toView(row);
}
async remove(id: string): Promise<void> {
const row = await this.posts.findOne({ where: { id } });
if (!row) throw ApiError.notFound('Post not found');
await this.posts.delete({ id });
}
private toView(r: BlogPostEntity): AdminBlogPost {
return {
id: r.id,
title: r.title,
bodyMd: r.bodyMd ?? '',
status: r.status,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
publishedAt: r.publishedAt ?? null,
};
}
}
@@ -0,0 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Public } from '../../common/decorators/public.decorator';
import { BlogService } from './blog.service';
import { PublicBlogList } from './dto/blog.dto';
@ApiTags('blog')
@Controller('api/v1/blog')
export class BlogController {
constructor(private readonly blog: BlogService) {}
@Public()
@Get('posts')
@ApiOperation({ summary: 'Public list of published blog posts' })
async listPosts(): Promise<PublicBlogList> {
const posts = await this.blog.listPublished();
return { posts };
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
import { BlogController } from './blog.controller';
import { BlogService } from './blog.service';
import { AdminBlogController } from './admin-blog.controller';
import { AdminBlogService } from './admin-blog.service';
@Module({
imports: [TypeOrmModule.forFeature([BlogPostEntity])],
controllers: [BlogController, AdminBlogController],
providers: [BlogService, AdminBlogService],
})
export class BlogModule {}
+26
View File
@@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BlogPostEntity } from '../../database/entities/blog-post.entity';
import { PublicBlogPost } from './dto/blog.dto';
@Injectable()
export class BlogService {
constructor(
@InjectRepository(BlogPostEntity)
private readonly posts: Repository<BlogPostEntity>,
) {}
async listPublished(): Promise<PublicBlogPost[]> {
const rows = await this.posts.find({
where: { status: 'published' },
order: { publishedAt: 'DESC' },
});
return rows.map((row) => ({
id: row.id,
title: row.title,
publishedAt: row.publishedAt ?? row.createdAt,
bodyMd: row.bodyMd ?? '',
}));
}
}
+50
View File
@@ -0,0 +1,50 @@
import { z } from 'zod';
export const PublicBlogPostSchema = z.object({
id: z.string(),
title: z.string(),
publishedAt: z.string(),
bodyMd: z.string(),
});
export type PublicBlogPost = z.infer<typeof PublicBlogPostSchema>;
export const PublicBlogListSchema = z.object({
posts: z.array(PublicBlogPostSchema),
});
export type PublicBlogList = z.infer<typeof PublicBlogListSchema>;
export const AdminBlogPostSchema = z.object({
id: z.string(),
title: z.string(),
bodyMd: z.string(),
status: z.enum(['draft', 'published']),
createdAt: z.string(),
updatedAt: z.string(),
publishedAt: z.string().nullable(),
});
export type AdminBlogPost = z.infer<typeof AdminBlogPostSchema>;
export const CreatePostSchema = z.object({
title: z
.string()
.min(1, 'Title is required')
.max(200, 'Title must be at most 200 characters')
.refine((t) => t.trim().length > 0, { message: 'Title is required' }),
bodyMd: z.string().max(200_000, 'Body must be at most 200000 characters').default(''),
status: z.enum(['draft', 'published']).optional(),
});
export type CreatePostPayload = z.infer<typeof CreatePostSchema>;
export const UpdatePostSchema = z.object({
title: z
.string()
.min(1, 'Title is required')
.max(200, 'Title must be at most 200 characters')
.refine((t) => t.trim().length > 0, { message: 'Title is required' })
.optional(),
bodyMd: z.string().max(200_000, 'Body must be at most 200000 characters').optional(),
status: z.enum(['draft', 'published']).optional(),
});
export type UpdatePostPayload = z.infer<typeof UpdatePostSchema>;
export const PostIdParamSchema = z.object({ id: z.string().min(1).max(64) });
@@ -0,0 +1,91 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Query,
Req,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { EventStatusService } from '../../common/services/event-status.service';
import { ChallengesService } from './challenges.service';
import {
BoardQuerySchema,
BoardResponseDto,
ChallengeDetailDto,
ChallengeIdParamSchema,
SolveResponseDto,
SolveSubmitBodySchema,
} from './dto/challenges.dto';
import type { EventStatePayload } from '../../common/services/event-status.service';
@ApiTags('challenges')
@ApiBearerAuth()
@Controller('api/v1/challenges')
export class ChallengesController {
constructor(
private readonly svc: ChallengesService,
private readonly statusSvc: EventStatusService,
) {}
private extractUserId(req: Request): string {
const user = req.user as { sub: string } | undefined;
const userId = user?.sub;
if (!userId) {
throw new (require('@nestjs/common') as typeof import('@nestjs/common')).UnauthorizedException('Not authenticated');
}
return userId;
}
@Get('board')
@ApiOperation({
summary:
'Authenticated board: categories with challenges, live points, current player solved set. ?include=solvers attaches solver lists to each card.',
})
async board(
@Req() req: Request,
@Query(new ZodValidationPipe(BoardQuerySchema)) query: { include?: string },
): Promise<BoardResponseDto> {
const userId = this.extractUserId(req);
const includeTokens = (query.include ?? '')
.split(',')
.map((t) => t.trim().toLowerCase())
.filter(Boolean);
return this.svc.getBoard(userId, { includeSolvers: includeTokens.includes('solvers') });
}
@Get('status')
@ApiOperation({
summary: 'Authenticated event-window snapshot (REST JSON). Source of truth for the challenges page bootstrap.',
})
async status(): Promise<EventStatePayload> {
return this.statusSvc.getState();
}
@Get(':id')
@ApiOperation({ summary: 'Single challenge detail with solvers list' })
async detail(
@Req() req: Request,
@Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string },
): Promise<ChallengeDetailDto> {
const userId = this.extractUserId(req);
return this.svc.getDetail(userId, params.id);
}
@Post(':id/solves')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Submit a flag for a challenge; idempotent per (player, challenge)' })
async submit(
@Req() req: Request,
@Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string },
@Body(new ZodValidationPipe(SolveSubmitBodySchema)) body: { flag: string },
): Promise<SolveResponseDto> {
const userId = this.extractUserId(req);
return this.svc.submitFlag(userId, params.id, body.flag);
}
}
@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ChallengeEntity } from '../../database/entities/challenge.entity';
import { CategoryEntity } from '../../database/entities/category.entity';
import { SolveEntity } from '../../database/entities/solve.entity';
import { UserEntity } from '../../database/entities/user.entity';
import { CommonModule } from '../../common/common.module';
import { SettingsModule } from '../settings/settings.module';
import { ChallengesController } from './challenges.controller';
import { ChallengesEventsController } from './events.controller';
import { ChallengesService } from './challenges.service';
import { ScoreboardController } from './scoreboard.controller';
import { ScoreboardService } from './scoreboard.service';
@Module({
imports: [
TypeOrmModule.forFeature([ChallengeEntity, CategoryEntity, SolveEntity, UserEntity]),
CommonModule,
SettingsModule,
],
controllers: [ChallengesController, ChallengesEventsController, ScoreboardController],
providers: [ChallengesService, ScoreboardService],
})
export class ChallengesModule {}
@@ -0,0 +1,527 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { ChallengeEntity } from '../../database/entities/challenge.entity';
import { CategoryEntity } from '../../database/entities/category.entity';
import { SolveEntity } from '../../database/entities/solve.entity';
import { UserEntity } from '../../database/entities/user.entity';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { EventStatusService } from '../../common/services/event-status.service';
import { SseHubService } from '../../common/services/sse-hub.service';
import {
AwardedSolveDto,
BoardResponseDto,
CategoryColumnDto,
ChallengeBoardCardDto,
ChallengeDetailDto,
Difficulty,
SolveResponseDto,
SolverRowDto,
} from './dto/challenges.dto';
import {
compareDifficulty,
computeLivePoints,
rankBonusForPosition,
safeEqualString,
} from './scoring.util';
interface ChallengeRow {
id: string;
name: string;
descriptionMd: string;
categoryId: string;
difficulty: Difficulty;
initialPoints: number;
minimumPoints: number;
decaySolves: number;
enabled: boolean;
abbreviation: string;
categoryName: string;
iconPath: string;
}
interface SolveRow {
id: string;
challengeId: string;
userId: string;
solvedAt: string;
pointsAwarded: number;
basePoints: number;
rankBonus: number;
isFirst: boolean;
isSecond: boolean;
isThird: boolean;
username: string;
}
@Injectable()
export class ChallengesService {
private readonly logger = new Logger(ChallengesService.name);
constructor(
@InjectRepository(ChallengeEntity)
private readonly challenges: Repository<ChallengeEntity>,
@InjectRepository(CategoryEntity)
private readonly categories: Repository<CategoryEntity>,
@InjectRepository(SolveEntity)
private readonly solves: Repository<SolveEntity>,
private readonly dataSource: DataSource,
private readonly eventStatus: EventStatusService,
private readonly hub: SseHubService,
) {}
async getBoard(currentUserId: string, opts: { includeSolvers?: boolean } = {}): Promise<BoardResponseDto> {
const rows = await this.challenges
.createQueryBuilder('c')
.leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id')
.select('c.id', 'id')
.addSelect('c.name', 'name')
.addSelect('c.description_md', 'descriptionMd')
.addSelect('c.category_id', 'categoryId')
.addSelect('c.difficulty', 'difficulty')
.addSelect('c.initial_points', 'initialPoints')
.addSelect('c.minimum_points', 'minimumPoints')
.addSelect('c.decay_solves', 'decaySolves')
.addSelect('c.enabled', 'enabled')
.addSelect('cat.abbreviation', 'abbreviation')
.addSelect('cat.name', 'categoryName')
.addSelect('cat.icon_path', 'iconPath')
.where('c.enabled = 1')
.getRawMany<ChallengeRow>();
if (rows.length === 0) {
return { columns: [], solvedChallengeIds: [], perChallengeLivePoints: {} };
}
const challengeIds = rows.map((r) => r.id);
const solveCountMap = await this.getSolveCounts(challengeIds);
const solvedSet = await this.getSolvedChallengeIds(currentUserId, challengeIds);
const perChallengeLivePoints: Record<string, number> = {};
const byCategory = new Map<string, { cat: { id: string; abbreviation: string; name: string; iconPath: string }; cards: ChallengeBoardCardDto[] }>();
for (const row of rows) {
const solveCount = solveCountMap.get(row.id) ?? 0;
const livePoints = computeLivePoints(
row.initialPoints,
row.minimumPoints,
row.decaySolves,
solveCount,
);
perChallengeLivePoints[row.id] = livePoints;
const card: ChallengeBoardCardDto = {
id: row.id,
name: row.name,
descriptionMd: row.descriptionMd ?? '',
categoryId: row.categoryId,
categoryAbbreviation: row.abbreviation ?? '',
categoryIconPath: row.iconPath ?? '',
difficulty: row.difficulty,
livePoints,
solveCount,
solvedByMe: solvedSet.has(row.id),
};
const existing = byCategory.get(row.categoryId);
if (existing) {
existing.cards.push(card);
} else {
byCategory.set(row.categoryId, {
cat: {
id: row.categoryId,
abbreviation: row.abbreviation ?? '',
name: row.categoryName ?? '',
iconPath: row.iconPath ?? '',
},
cards: [card],
});
}
}
if (opts.includeSolvers) {
const mgr = this.dataSource.manager;
for (const col of byCategory.values()) {
for (const card of col.cards) {
const solvers = await this.loadSolvers(mgr, card.id);
card.solvers = solvers.map((s, idx) => ({
...this.toSolverDto(s),
position: idx + 1,
}));
}
}
}
const columns: CategoryColumnDto[] = Array.from(byCategory.values())
.map((entry) => ({
id: entry.cat.id,
abbreviation: entry.cat.abbreviation,
name: entry.cat.name,
iconPath: entry.cat.iconPath,
cards: entry.cards.sort((a, b) => {
const d = compareDifficulty(a.difficulty, b.difficulty);
if (d !== 0) return d;
const an = (a.name ?? '').toLowerCase();
const bn = (b.name ?? '').toLowerCase();
if (an < bn) return -1;
if (an > bn) return 1;
return 0;
}),
}))
.sort((a, b) => {
const al = (a.abbreviation ?? '').toLowerCase();
const bl = (b.abbreviation ?? '').toLowerCase();
if (al < bl) return -1;
if (al > bl) return 1;
return 0;
});
return {
columns,
solvedChallengeIds: Array.from(solvedSet),
perChallengeLivePoints,
};
}
async getDetail(currentUserId: string, challengeId: string): Promise<ChallengeDetailDto> {
const challenge = await this.loadChallengeOrThrow(challengeId);
const mgr = this.dataSource.manager;
const solvers = await this.loadSolvers(mgr, challengeId);
const solved = await this.solves
.createQueryBuilder('s')
.select('1')
.where('s.challenge_id = :cid AND s.user_id = :uid', { cid: challengeId, uid: currentUserId })
.getRawOne();
const card = this.buildCard(challenge, solvers.length, !!solved);
return {
challenge: card,
solvers: solvers.map((s, idx) => ({
...this.toSolverDto(s),
position: idx + 1,
})),
};
}
private async loadChallengeOrThrow(challengeId: string): Promise<ChallengeRow> {
const row = await this.challenges
.createQueryBuilder('c')
.leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id')
.select('c.id', 'id')
.addSelect('c.name', 'name')
.addSelect('c.description_md', 'descriptionMd')
.addSelect('c.category_id', 'categoryId')
.addSelect('c.difficulty', 'difficulty')
.addSelect('c.initial_points', 'initialPoints')
.addSelect('c.minimum_points', 'minimumPoints')
.addSelect('c.decay_solves', 'decaySolves')
.addSelect('c.enabled', 'enabled')
.addSelect('cat.abbreviation', 'abbreviation')
.addSelect('cat.name', 'categoryName')
.addSelect('cat.icon_path', 'iconPath')
.where('c.id = :id', { id: challengeId })
.getRawOne<ChallengeRow>();
if (!row || !row.enabled) throw ApiError.notFound('Challenge not found');
return row;
}
async submitFlag(currentUserId: string, challengeId: string, submittedFlag: string): Promise<SolveResponseDto> {
return this.dataSource.transaction(async (manager) => {
const challengeRepo = manager.getRepository(ChallengeEntity);
const solveRepo = manager.getRepository(SolveEntity);
const categoryRepo = manager.getRepository(CategoryEntity);
const challenge = await challengeRepo
.createQueryBuilder('c')
.leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id')
.select('c.id', 'id')
.addSelect('c.name', 'name')
.addSelect('c.description_md', 'descriptionMd')
.addSelect('c.category_id', 'categoryId')
.addSelect('c.difficulty', 'difficulty')
.addSelect('c.initial_points', 'initialPoints')
.addSelect('c.minimum_points', 'minimumPoints')
.addSelect('c.decay_solves', 'decaySolves')
.addSelect('c.flag', 'flag')
.addSelect('c.enabled', 'enabled')
.addSelect('cat.abbreviation', 'abbreviation')
.addSelect('cat.name', 'categoryName')
.addSelect('cat.icon_path', 'iconPath')
.where('c.id = :id', { id: challengeId })
.getRawOne<ChallengeRow & { flag: string }>();
if (!challenge || !challenge.enabled) {
throw ApiError.notFound('Challenge not found');
}
const state = await this.eventStatus.getState();
if (state.state !== 'running') {
throw new ApiError(
ERROR_CODES.EVENT_NOT_RUNNING,
'Submissions are only accepted while the event is running',
409,
);
}
const existing = await solveRepo
.createQueryBuilder('s')
.leftJoin(UserEntity, 'u', 'u.id = s.user_id')
.select('s.id', 'id')
.addSelect('s.challenge_id', 'challengeId')
.addSelect('s.user_id', 'userId')
.addSelect('s.solved_at', 'solvedAt')
.addSelect('s.points_awarded', 'pointsAwarded')
.addSelect('s.base_points', 'basePoints')
.addSelect('s.rank_bonus', 'rankBonus')
.addSelect('s.is_first', 'isFirst')
.addSelect('s.is_second', 'isSecond')
.addSelect('s.is_third', 'isThird')
.addSelect('u.username', 'username')
.where('s.challenge_id = :cid AND s.user_id = :uid', { cid: challengeId, uid: currentUserId })
.getRawOne<SolveRow>();
if (existing) {
const solvers = await this.loadSolvers(manager, challengeId);
const count = solvers.length;
const card = this.buildCard(challenge, count, true);
const myIndex = solvers.findIndex((s) => s.userId === currentUserId);
const awarded: AwardedSolveDto = {
position: myIndex >= 0 ? myIndex + 1 : 0,
basePoints: existing.basePoints,
rankBonus: existing.rankBonus,
awardedPoints: existing.pointsAwarded,
awardedAtUtc: existing.solvedAt,
};
return {
status: 'already_solved',
challenge: card,
awarded,
solvers: solvers.map((s, idx) => ({ ...this.toSolverDto(s), position: idx + 1 })),
};
}
if (!safeEqualString(challenge.flag ?? '', submittedFlag ?? '')) {
throw new ApiError(
ERROR_CODES.FLAG_INCORRECT,
'Incorrect flag',
400,
);
}
const positionRow = await solveRepo
.createQueryBuilder('s')
.select('COUNT(*)', 'count')
.where('s.challenge_id = :cid', { cid: challengeId })
.getRawOne<{ count: string | number }>();
const position = Number(positionRow?.count ?? 0) + 1;
const solveCountBefore = position - 1;
const basePoints = computeLivePoints(
challenge.initialPoints,
challenge.minimumPoints,
challenge.decaySolves,
solveCountBefore,
);
const rankBonus = rankBonusForPosition(position);
const awardedPoints = basePoints + rankBonus;
const awardedAtUtc = new Date().toISOString();
try {
await solveRepo.insert({
id: uuid(),
challengeId,
userId: currentUserId,
solvedAt: awardedAtUtc,
pointsAwarded: awardedPoints,
basePoints,
rankBonus,
isFirst: position === 1,
isSecond: position === 2,
isThird: position === 3,
});
} catch (err: any) {
const code = err && typeof err === 'object' ? (err.code as string) : null;
if (code === 'SQLITE_CONSTRAINT_UNIQUE' || code === 'SQLITE_CONSTRAINT') {
const solvers = await this.loadSolvers(manager, challengeId);
const card = this.buildCard(challenge, solvers.length, true);
const mine = solvers.find((s) => s.userId === currentUserId);
const myIdx = mine ? solvers.indexOf(mine) : -1;
return {
status: 'already_solved',
challenge: card,
awarded: {
position: myIdx >= 0 ? myIdx + 1 : position,
basePoints: mine?.basePoints ?? basePoints,
rankBonus: mine?.rankBonus ?? rankBonus,
awardedPoints: mine?.pointsAwarded ?? awardedPoints,
awardedAtUtc: mine?.solvedAt ?? awardedAtUtc,
},
solvers: solvers.map((s, idx) => ({ ...this.toSolverDto(s), position: idx + 1 })),
};
}
throw err;
}
const solvers = await this.loadSolvers(manager, challengeId);
const card = this.buildCard(challenge, solvers.length, true);
const awarded: AwardedSolveDto = {
position,
basePoints,
rankBonus,
awardedPoints,
awardedAtUtc,
};
this.publishSolveEvent({
challengeId,
challengeName: challenge.name,
categoryAbbreviation: challenge.abbreviation ?? '',
userId: currentUserId,
username: solvers.find((s) => s.userId === currentUserId)?.username ?? '',
awardedPoints,
rankBonus,
position,
solveCountAfter: solvers.length,
livePointsAfter: computeLivePoints(
challenge.initialPoints,
challenge.minimumPoints,
challenge.decaySolves,
solvers.length,
),
initialPoints: challenge.initialPoints,
minimumPoints: challenge.minimumPoints,
decaySolves: challenge.decaySolves,
solvedAtUtc: awardedAtUtc,
manager,
});
void categoryRepo;
return {
status: 'solved',
challenge: card,
awarded,
solvers: solvers.map((s, idx) => ({ ...this.toSolverDto(s), position: idx + 1 })),
};
});
}
private publishSolveEvent(input: {
challengeId: string;
challengeName: string;
categoryAbbreviation: string;
userId: string;
username: string;
awardedPoints: number;
rankBonus: number;
position: number;
solveCountAfter: number;
livePointsAfter: number;
initialPoints: number;
minimumPoints: number;
decaySolves: number;
solvedAtUtc: string;
manager: any;
}): void {
try {
this.hub.emitScoreboard({
topic: 'solve',
challengeId: input.challengeId,
challengeName: input.challengeName,
categoryAbbreviation: input.categoryAbbreviation,
userId: input.userId,
playerId: input.userId,
playerName: input.username,
awardedPoints: input.awardedPoints,
rankBonus: input.rankBonus,
pointsAwarded: input.awardedPoints,
solvedAt: input.solvedAtUtc,
awardedAtUtc: input.solvedAtUtc,
position: input.position,
livePoints: input.livePointsAfter,
solveCount: input.solveCountAfter,
initialPoints: input.initialPoints,
minimumPoints: input.minimumPoints,
decaySolves: input.decaySolves,
});
} catch (err) {
this.logger.warn(`Failed to publish solve SSE: ${(err as Error).message}`);
}
}
private async getSolveCounts(challengeIds: string[]): Promise<Map<string, number>> {
if (challengeIds.length === 0) return new Map();
const rows = await this.solves
.createQueryBuilder('s')
.select('s.challenge_id', 'challengeId')
.addSelect('COUNT(*)', 'count')
.where('s.challenge_id IN (:...ids)', { ids: challengeIds })
.groupBy('s.challenge_id')
.getRawMany<{ challengeId: string; count: string | number }>();
const map = new Map<string, number>();
for (const r of rows) map.set(r.challengeId, Number(r.count));
return map;
}
private async getSolvedChallengeIds(userId: string, challengeIds: string[]): Promise<Set<string>> {
if (challengeIds.length === 0) return new Set();
const rows = await this.solves
.createQueryBuilder('s')
.select('s.challenge_id', 'challengeId')
.where('s.user_id = :uid AND s.challenge_id IN (:...ids)', { uid: userId, ids: challengeIds })
.getRawMany<{ challengeId: string }>();
return new Set(rows.map((r) => r.challengeId));
}
private async loadSolvers(manager: any, challengeId: string): Promise<SolveRow[]> {
const qb = manager
.getRepository(SolveEntity)
.createQueryBuilder('s')
.leftJoin(UserEntity, 'u', 'u.id = s.user_id')
.select('s.id', 'id')
.addSelect('s.challenge_id', 'challengeId')
.addSelect('s.user_id', 'userId')
.addSelect('s.solved_at', 'solvedAt')
.addSelect('s.points_awarded', 'pointsAwarded')
.addSelect('s.base_points', 'basePoints')
.addSelect('s.rank_bonus', 'rankBonus')
.addSelect('s.is_first', 'isFirst')
.addSelect('s.is_second', 'isSecond')
.addSelect('s.is_third', 'isThird')
.addSelect('u.username', 'username')
.where('s.challenge_id = :cid', { cid: challengeId })
.orderBy('s.solved_at', 'ASC')
.addOrderBy('s.id', 'ASC');
return (await qb.getRawMany()) as SolveRow[];
}
private toSolverDto(s: SolveRow): SolverRowDto {
return {
position: 0, // filled by caller
playerId: s.userId,
playerName: s.username ?? '',
solvedAtUtc: s.solvedAt,
awardedPoints: s.pointsAwarded,
basePoints: s.basePoints,
rankBonus: s.rankBonus,
isFirst: Boolean(s.isFirst),
isSecond: Boolean(s.isSecond),
isThird: Boolean(s.isThird),
};
}
private buildCard(row: ChallengeRow, solveCount: number, solvedByMe: boolean): ChallengeBoardCardDto {
const livePoints = computeLivePoints(row.initialPoints, row.minimumPoints, row.decaySolves, solveCount);
return {
id: row.id,
name: row.name,
descriptionMd: row.descriptionMd ?? '',
categoryId: row.categoryId,
categoryAbbreviation: row.abbreviation ?? '',
categoryIconPath: row.iconPath ?? '',
difficulty: row.difficulty,
livePoints,
solveCount,
solvedByMe,
};
}
}
@@ -0,0 +1,97 @@
import { z } from 'zod';
export const DIFFICULTIES = ['LOW', 'MEDIUM', 'HIGH'] as const;
export type Difficulty = (typeof DIFFICULTIES)[number];
export const SolveSubmitBodySchema = z.object({
flag: z.string().min(1).max(4096),
});
export type SolveSubmitBody = z.infer<typeof SolveSubmitBodySchema>;
export const BoardQuerySchema = z.object({
include: z.string().optional(),
});
export type BoardQuery = z.infer<typeof BoardQuerySchema>;
export const ChallengeIdParamSchema = z.object({
id: z.string().uuid({ message: 'Invalid challenge id' }),
});
export type ChallengeIdParam = z.infer<typeof ChallengeIdParamSchema>;
export interface ChallengeBoardCardDto {
id: string;
name: string;
descriptionMd: string;
categoryId: string;
categoryAbbreviation: string;
categoryIconPath: string;
difficulty: Difficulty;
livePoints: number;
solveCount: number;
solvedByMe: boolean;
solvers?: SolverRowDto[];
}
export interface CategoryColumnDto {
id: string;
abbreviation: string;
name: string;
iconPath: string;
cards: ChallengeBoardCardDto[];
}
export interface BoardResponseDto {
columns: CategoryColumnDto[];
solvedChallengeIds: string[];
perChallengeLivePoints: Record<string, number>;
}
export interface SolverRowDto {
position: number;
playerId: string;
playerName: string;
solvedAtUtc: string;
awardedPoints: number;
basePoints: number;
rankBonus: number;
isFirst: boolean;
isSecond: boolean;
isThird: boolean;
}
export interface AwardedSolveDto {
position: number;
basePoints: number;
rankBonus: number;
awardedPoints: number;
awardedAtUtc: string;
}
export interface SolveResponseDto {
status: 'solved' | 'already_solved';
challenge: ChallengeBoardCardDto;
awarded: AwardedSolveDto;
solvers: SolverRowDto[];
}
export interface ChallengeDetailDto {
challenge: ChallengeBoardCardDto;
solvers: SolverRowDto[];
}
export interface SolveEventPayload {
challengeId: string;
challengeName: string;
categoryAbbreviation: string;
playerId: string;
playerName: string;
awardedPoints: number;
rankBonus: number;
awardedAtUtc: string;
position: number;
livePoints: number;
solveCount: number;
initialPoints: number;
minimumPoints: number;
decaySolves: number;
}
@@ -0,0 +1,62 @@
import { z } from 'zod';
export const EventLogQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
});
export type EventLogQuery = z.infer<typeof EventLogQuerySchema>;
export interface RankingRowDto {
rank: number;
playerId: string;
playerName: string;
solvedCount: number;
points: number;
colorIndex: number;
}
export interface MatrixChallengeHeaderDto {
id: string;
name: string;
solveCount: number;
categoryAbbreviation: string;
}
export type MatrixCellRank = 1 | 2 | 3 | 'solved' | null;
export interface MatrixViewDto {
players: RankingRowDto[];
challenges: MatrixChallengeHeaderDto[];
cells: Record<string, Record<string, MatrixCellRank>>;
}
export interface EventLogRowDto {
solveId: string;
playerId: string;
playerName: string;
challengeId: string;
challengeName: string;
categoryAbbreviation: string;
position: number;
awardedPoints: number;
awardedAtUtc: string;
}
export interface GraphPointDto {
tUtc: string;
value: number;
}
export interface GraphSeriesDto {
playerId: string;
playerName: string;
colorIndex: number;
points: GraphPointDto[];
}
export interface GraphViewDto {
startUtc: string | null;
endUtc: string | null;
serverNowUtc: string;
state: 'running' | 'countdown' | 'stopped' | 'unconfigured';
series: GraphSeriesDto[];
}
@@ -0,0 +1,106 @@
import { Controller, MessageEvent, Sse } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
Observable,
defer,
interval,
merge,
startWith,
mergeMap,
filter,
map,
distinctUntilChanged,
} from 'rxjs';
import { EventStatusService } from '../../common/services/event-status.service';
import { SseHubService } from '../../common/services/sse-hub.service';
@ApiTags('challenges')
@ApiBearerAuth()
@Controller('api/v1')
export class ChallengesEventsController {
constructor(
private readonly statusSvc: EventStatusService,
private readonly hub: SseHubService,
) {}
/**
* Authenticated global event stream.
* Emits NestJS MessageEvent objects with the proper `type` field so the
* wire format is `event: <type>\ndata: <JSON>\n\n`:
* - event: status, payload = { state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }
* - event: solve, payload = { challenge_id, challenge_name, category_abbreviation, player_id, player_name,
* awarded_points, rank_bonus, awarded_at_utc, position, live_points, solve_count,
* initial_points, minimum_points, decay_solves }
* Source of truth: every transition of the event window is broadcast by
* the admin general service through SseHubService.event$(). Solves are
* published by ChallengesService through SseHubService.scoreboard$().
* To recover missed transitions while disconnected, the status tick
* re-pulls getState() every 60 seconds.
*/
@Sse('events')
@ApiOperation({
summary: 'Authenticated global SSE stream: status + solve events',
})
events(): Observable<MessageEvent> {
const flatStatus = (s: any): MessageEvent => ({
type: 'status',
data: {
state: s?.state,
server_time_utc: s?.serverNowUtc,
event_start_utc: s?.eventStartUtc ?? null,
event_end_utc: s?.eventEndUtc ?? null,
seconds_to_start: s?.secondsToStart ?? null,
seconds_to_end: s?.secondsToEnd ?? null,
},
});
const statusTick$ = interval(60_000).pipe(
startWith(0),
mergeMap(() =>
defer(() => this.statusSvc.getState().then((s) => flatStatus(s))),
),
);
const statusHub$ = this.hub.event$().pipe(
map((p) => {
if (p && p.topic === 'general') {
return defer(() => this.statusSvc.getState().then((s) => flatStatus(s)));
}
if (p && (p.state || p.eventStartUtc || p.eventEndUtc)) {
return defer(async () => flatStatus(p));
}
return null;
}),
filter((x): x is Observable<MessageEvent> => !!x),
mergeMap((x) => x),
);
const solveFrame = (s: any): MessageEvent => ({
type: 'solve',
data: {
challenge_id: s?.challengeId,
challenge_name: s?.challengeName ?? '',
category_abbreviation: s?.categoryAbbreviation ?? '',
player_id: s?.playerId ?? s?.userId,
player_name: s?.playerName ?? '',
awarded_points: s?.awardedPoints ?? s?.pointsAwarded,
rank_bonus: s?.rankBonus,
awarded_at_utc: s?.awardedAtUtc ?? s?.solvedAt,
position: s?.position,
live_points: s?.livePoints,
solve_count: s?.solveCount,
initial_points: s?.initialPoints,
minimum_points: s?.minimumPoints,
decay_solves: s?.decaySolves,
},
});
const solve$ = this.hub.scoreboard$().pipe(map((s) => solveFrame(s)));
const statusFrames$ = merge(statusTick$, statusHub$).pipe(
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
);
return merge(statusFrames$, solve$);
}
}
@@ -0,0 +1,48 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { DataSource } from 'typeorm';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { ScoreboardService } from './scoreboard.service';
import {
EventLogQuerySchema,
EventLogRowDto,
GraphViewDto,
MatrixViewDto,
RankingRowDto,
} from './dto/scoreboard.dto';
@ApiTags('scoreboard')
@ApiBearerAuth()
@Controller('api/v1/scoreboard')
export class ScoreboardController {
constructor(
private readonly dataSource: DataSource,
private readonly svc: ScoreboardService,
) {}
@Get('ranking')
@ApiOperation({ summary: 'Authenticated scoreboard ranking (competition ranks)' })
async ranking(): Promise<RankingRowDto[]> {
return this.svc.getRanking(this.dataSource.manager);
}
@Get('matrix')
@ApiOperation({ summary: 'Authenticated scoreboard matrix (players x challenges)' })
async matrix(): Promise<MatrixViewDto> {
return this.svc.getMatrix(this.dataSource.manager);
}
@Get('event-log')
@ApiOperation({ summary: 'Authenticated recent-solves event log' })
async eventLog(
@Query(new ZodValidationPipe(EventLogQuerySchema)) q: { limit?: number },
): Promise<EventLogRowDto[]> {
return this.svc.getEventLog(this.dataSource.manager, q.limit ?? 50);
}
@Get('graph')
@ApiOperation({ summary: 'Authenticated score graph (top 10 players)' })
async graph(): Promise<GraphViewDto> {
return this.svc.getGraph(this.dataSource.manager);
}
}
@@ -0,0 +1,402 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { SolveEntity } from '../../database/entities/solve.entity';
import { ChallengeEntity } from '../../database/entities/challenge.entity';
import { CategoryEntity } from '../../database/entities/category.entity';
import { UserEntity } from '../../database/entities/user.entity';
import { SettingsService } from '../settings/settings.module';
import { EventStatusService } from '../../common/services/event-status.service';
import {
computeAwardedPoints,
stablePlayerColorIndex,
} from './scoring.util';
import {
EventLogRowDto,
GraphPointDto,
GraphSeriesDto,
GraphViewDto,
MatrixCellRank,
MatrixChallengeHeaderDto,
MatrixViewDto,
RankingRowDto,
} from './dto/scoreboard.dto';
interface RawSolveRow {
id: string;
challengeId: string;
userId: string;
solvedAt: string;
position?: number;
basePoints?: number;
rankBonus?: number;
pointsAwarded?: number;
username?: string;
challengeName?: string;
categoryAbbreviation?: string;
}
@Injectable()
export class ScoreboardService {
constructor(
private readonly settings: SettingsService,
private readonly eventStatus: EventStatusService,
) {}
async getRanking(manager: EntityManager): Promise<RankingRowDto[]> {
const [rows, users] = await Promise.all([
this.loadSolveRows(manager),
manager
.getRepository(UserEntity)
.createQueryBuilder('u')
.select('u.id', 'id')
.addSelect('u.username', 'username')
.getRawMany<{ id: string; username: string }>(),
]);
return this.buildRanking(rows, users);
}
async getMatrix(manager: EntityManager): Promise<MatrixViewDto> {
const [solveRows, challengeRows, users] = await Promise.all([
this.loadSolveRows(manager),
this.loadChallengeRows(manager),
manager
.getRepository(UserEntity)
.createQueryBuilder('u')
.select('u.id', 'id')
.addSelect('u.username', 'username')
.getRawMany<{ id: string; username: string }>(),
]);
const ranking = this.buildRanking(solveRows, users);
const playerById = new Map(ranking.map((r) => [r.playerId, r]));
const challengeSolveCounts = new Map<string, number>();
const positionByPlayerChallenge = new Map<string, Map<string, MatrixCellRank>>();
const grouped = new Map<string, RawSolveRow[]>();
for (const row of solveRows) {
const arr = grouped.get(row.challengeId) ?? [];
arr.push(row);
grouped.set(row.challengeId, arr);
}
for (const [, list] of grouped) {
list.sort((a, b) => new Date(a.solvedAt).getTime() - new Date(b.solvedAt).getTime());
}
for (const [challengeId, list] of grouped) {
challengeSolveCounts.set(challengeId, list.length);
for (let i = 0; i < list.length; i += 1) {
const position = i + 1;
const row = list[i];
let perPlayer = positionByPlayerChallenge.get(row.userId);
if (!perPlayer) {
perPlayer = new Map();
positionByPlayerChallenge.set(row.userId, perPlayer);
}
const value: MatrixCellRank = position <= 3 ? (position as 1 | 2 | 3) : 'solved';
perPlayer.set(challengeId, value);
}
}
const challenges: MatrixChallengeHeaderDto[] = challengeRows
.map((c) => ({
id: c.id,
name: c.name,
solveCount: challengeSolveCounts.get(c.id) ?? 0,
categoryAbbreviation: c.categoryAbbreviation ?? '',
}))
.sort((a, b) => {
if (a.solveCount !== b.solveCount) return b.solveCount - a.solveCount;
const an = (a.name ?? '').toLowerCase();
const bn = (b.name ?? '').toLowerCase();
if (an < bn) return -1;
if (an > bn) return 1;
return 0;
});
const cells: Record<string, Record<string, MatrixCellRank>> = {};
for (const player of ranking) {
const perPlayer = positionByPlayerChallenge.get(player.playerId);
const row: Record<string, MatrixCellRank> = {};
for (const ch of challenges) {
row[ch.id] = perPlayer?.get(ch.id) ?? null;
}
cells[player.playerId] = row;
}
const players = ranking.map((r) => {
const present = playerById.get(r.playerId);
return present ?? r;
});
return { players, challenges, cells };
}
async getEventLog(manager: EntityManager, limit: number): Promise<EventLogRowDto[]> {
const safeLimit = Math.max(1, Math.min(200, Math.floor(limit)));
const rows = await manager
.getRepository(SolveEntity)
.createQueryBuilder('s')
.leftJoin(UserEntity, 'u', 'u.id = s.user_id')
.leftJoin(ChallengeEntity, 'c', 'c.id = s.challenge_id')
.leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id')
.select('s.id', 'id')
.addSelect('s.challenge_id', 'challengeId')
.addSelect('s.user_id', 'userId')
.addSelect('s.solved_at', 'solvedAt')
.addSelect('s.points_awarded', 'pointsAwarded')
.addSelect('s.base_points', 'basePoints')
.addSelect('s.rank_bonus', 'rankBonus')
.addSelect('u.username', 'username')
.addSelect('c.name', 'challengeName')
.addSelect('cat.abbreviation', 'categoryAbbreviation')
.orderBy('s.solved_at', 'DESC')
.addOrderBy('s.id', 'DESC')
.limit(safeLimit)
.getRawMany<RawSolveRow>();
const perChallengeLatestByChallenge = new Map<string, RawSolveRow[]>();
for (const r of rows) {
const arr = perChallengeLatestByChallenge.get(r.challengeId) ?? [];
arr.push(r);
perChallengeLatestByChallenge.set(r.challengeId, arr);
}
for (const arr of perChallengeLatestByChallenge.values()) {
arr.sort((a, b) => new Date(a.solvedAt).getTime() - new Date(b.solvedAt).getTime());
}
const positionBySolveId = new Map<string, number>();
for (const arr of perChallengeLatestByChallenge.values()) {
arr.forEach((row, idx) => positionBySolveId.set(row.id, idx + 1));
}
return rows.map((r) => {
const position = positionBySolveId.get(r.id) ?? 0;
return {
solveId: r.id,
playerId: r.userId,
playerName: r.username ?? '',
challengeId: r.challengeId,
challengeName: r.challengeName ?? '',
categoryAbbreviation: r.categoryAbbreviation ?? '',
position,
awardedPoints: Number(r.pointsAwarded ?? 0),
awardedAtUtc: r.solvedAt,
};
});
}
async getGraph(manager: EntityManager): Promise<GraphViewDto> {
const state = await this.eventStatus.getState();
const startUtc = state.eventStartUtc;
const endUtc = state.eventEndUtc;
const serverNowUtc = state.serverNowUtc;
if (!startUtc || !endUtc) {
return {
startUtc: null,
endUtc: null,
serverNowUtc,
state: state.state,
series: [],
};
}
const challengeRows = await this.loadChallengeRows(manager);
const challengeMeta = new Map(challengeRows.map((c) => [c.id, c]));
const rows = await this.loadSolveRows(manager);
const perPlayer: Map<string, GraphSeriesDto> = new Map();
for (const row of rows) {
const ch = challengeMeta.get(row.challengeId);
if (!ch) continue;
const base = computeAwardedPoints(
ch.initialPoints,
ch.minimumPoints,
ch.decaySolves,
row.solveCountBefore ?? 0,
);
let series = perPlayer.get(row.userId);
if (!series) {
series = {
playerId: row.userId,
playerName: row.username ?? '',
colorIndex: stablePlayerColorIndex(row.userId),
points: [],
};
perPlayer.set(row.userId, series);
}
const last = series.points[series.points.length - 1];
const prevValue = last ? last.value : 0;
series.points.push({ tUtc: row.solvedAt, value: prevValue + base });
}
const all = Array.from(perPlayer.values());
all.sort((a, b) => {
const av = a.points[a.points.length - 1]?.value ?? 0;
const bv = b.points[b.points.length - 1]?.value ?? 0;
if (av !== bv) return bv - av;
const al = a.points[0]?.tUtc ?? '';
const bl = b.points[0]?.tUtc ?? '';
if (al < bl) return -1;
if (al > bl) return 1;
return 0;
});
const top = all.slice(0, 10).map((s) => {
const final = s.points[s.points.length - 1]?.value ?? 0;
const head: GraphPointDto = { tUtc: startUtc, value: 0 };
const tail: GraphPointDto = { tUtc: endUtc, value: final };
return { ...s, points: [head, ...s.points, tail] };
});
return {
startUtc,
endUtc,
serverNowUtc,
state: state.state,
series: top,
};
}
private async loadSolveRows(manager: EntityManager): Promise<RawSolveRowWithMeta[]> {
const all = await manager
.getRepository(SolveEntity)
.createQueryBuilder('s')
.leftJoin(UserEntity, 'u', 'u.id = s.user_id')
.leftJoin(ChallengeEntity, 'c', 'c.id = s.challenge_id')
.select('s.id', 'id')
.addSelect('s.challenge_id', 'challengeId')
.addSelect('s.user_id', 'userId')
.addSelect('s.solved_at', 'solvedAt')
.addSelect('s.points_awarded', 'pointsAwarded')
.addSelect('u.username', 'username')
.addSelect('c.name', 'challengeName')
.orderBy('s.solved_at', 'ASC')
.addOrderBy('s.id', 'ASC')
.getRawMany<RawSolveRow & { challengeName?: string }>();
const perChallenge = new Map<string, RawSolveRowWithMeta[]>();
for (const row of all) {
const arr = perChallenge.get(row.challengeId) ?? [];
arr.push(row);
perChallenge.set(row.challengeId, arr);
}
const out: RawSolveRowWithMeta[] = [];
for (const [, arr] of perChallenge) {
arr.forEach((row, idx) => {
out.push({ ...row, solveCountBefore: idx, position: idx + 1 });
});
}
return out;
}
private async loadChallengeRows(manager: EntityManager): Promise<ChallengeMetaRow[]> {
return manager
.getRepository(ChallengeEntity)
.createQueryBuilder('c')
.leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id')
.select('c.id', 'id')
.addSelect('c.name', 'name')
.addSelect('c.initial_points', 'initialPoints')
.addSelect('c.minimum_points', 'minimumPoints')
.addSelect('c.decay_solves', 'decaySolves')
.addSelect('cat.abbreviation', 'categoryAbbreviation')
.where('c.enabled = 1')
.getRawMany<ChallengeMetaRow>();
}
private buildRanking(
rows: RawSolveRowWithMeta[],
users: { id: string; username: string }[] = [],
): RankingRowDto[] {
interface Accum {
playerId: string;
playerName: string;
points: number;
solved: Set<string>;
earliest: string;
}
const acc = new Map<string, Accum>();
for (const row of rows) {
let entry = acc.get(row.userId);
if (!entry) {
entry = {
playerId: row.userId,
playerName: row.username ?? '',
points: 0,
solved: new Set<string>(),
earliest: row.solvedAt,
};
acc.set(row.userId, entry);
}
if (new Date(row.solvedAt).getTime() < new Date(entry.earliest).getTime()) {
entry.earliest = row.solvedAt;
}
entry.solved.add(row.challengeId);
entry.playerName = entry.playerName || row.username || '';
entry.points += Number(row.pointsAwarded ?? 0);
}
for (const u of users) {
if (!acc.has(u.id)) {
acc.set(u.id, {
playerId: u.id,
playerName: u.username,
points: 0,
solved: new Set<string>(),
earliest: '',
});
}
}
const list: RankingRowDto[] = [];
for (const [, entry] of acc) {
list.push({
rank: 0,
playerId: entry.playerId,
playerName: entry.playerName,
solvedCount: entry.solved.size,
points: entry.points,
colorIndex: stablePlayerColorIndex(entry.playerId),
});
}
list.sort((a, b) => {
if (a.points !== b.points) return b.points - a.points;
const aE = acc.get(a.playerId)?.earliest ?? '';
const bE = acc.get(b.playerId)?.earliest ?? '';
if (aE && bE) {
if (aE < bE) return -1;
if (aE > bE) return 1;
return 0;
}
if (aE) return -1;
if (bE) return 1;
return 0;
});
let displayedRank = 0;
let lastPoints = Number.NaN;
list.forEach((row, idx) => {
if (row.points !== lastPoints) {
displayedRank = idx + 1;
lastPoints = row.points;
}
row.rank = displayedRank;
});
return list;
}
}
interface RawSolveRowWithMeta extends RawSolveRow {
challengeName?: string;
solveCountBefore?: number;
position?: number;
}
interface ChallengeMetaRow {
id: string;
name: string;
initialPoints: number;
minimumPoints: number;
decaySolves: number;
categoryAbbreviation?: string;
}

Some files were not shown because too many files have changed in this diff Show More