Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 143ed3c538 | |||
| e81c568fca | |||
| e2d6bb3d69 | |||
| e611d201d9 | |||
| 470ddd30c3 | |||
| 6bac67fad7 | |||
| 29b447d51f | |||
| 294873240b | |||
| d468cca766 | |||
| e4ccf0fe13 | |||
| 705530e71f | |||
| a39a7daee8 | |||
| 4273662211 | |||
| 7c4843d5cc | |||
| 49d0930780 | |||
| 5a6e832cca | |||
| 54f72f88dc | |||
| f194641e8c | |||
| cbfa9c35ca | |||
| 1bd4a5a198 | |||
| 0bd1d9472a | |||
| dcda3dfd4d | |||
| fff0a600df | |||
| d3307c3225 | |||
| 3d93693fc6 | |||
| 8dcbd48045 | |||
| 1a40e5708a | |||
| 113dd47371 | |||
| f62c77cf41 | |||
| ef9926dfc1 | |||
| f827a3a69c | |||
| 7bd0009b93 | |||
| 56555d272f | |||
| c0a01eb58e | |||
| 4656a5b082 | |||
| 2a6182b938 | |||
| 282fcbab94 | |||
| 3cf86b6233 | |||
| c52a736ae1 | |||
| 98fee8f7ee | |||
| b2f0a4736d | |||
| b7e0f3c608 | |||
| 0a2b2664b4 | |||
| fac3179427 | |||
| c9e8dfc611 | |||
| de527ec6d6 | |||
| 90e570c43c | |||
| d134a56abe | |||
| 685c7e47c9 | |||
| c0427f8c20 | |||
| 76c04f9ea0 | |||
| b6dbfcc511 | |||
| 8dc8cee769 | |||
| cd97e40030 | |||
| 685a8bca84 | |||
| 8476e4a59f | |||
| dda85f8cce | |||
| 2ae930f325 | |||
| cabd393288 | |||
| 2957b14d38 | |||
| a190a69a27 | |||
| 6feaf08bdb | |||
| 960516a7bc | |||
| 9f67ec8c50 | |||
| 03bcb6b156 |
+14
@@ -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/
|
||||||
@@ -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 2–6-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, ~30–50 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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4626
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+166
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
})
|
||||||
|
export class FrontendModule {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user