AI Implementation feature(820): Project Scaffold and Platform Foundations #1

Merged
m0rph3us1987 merged 5 commits from feature-820-1784637300954 into dev 2026-07-21 14:23:53 +00:00
112 changed files with 21931 additions and 0 deletions
Showing only changes of commit af3c24275d - Show all commits
+13
View File
@@ -0,0 +1,13 @@
node_modules/
dist/
*.log
.env
.env.*
!.env.example
.DS_Store
coverage/
backend/data/
backend/themes/.cache/
frontend/.angular/
/data/.npm/
.npm/
+152
View File
@@ -0,0 +1,152 @@
# Implementation Plan: Reviewer Follow-ups (Part 2)
## 0. Scope
Six discrete hardenings of the scaffold already on the branch:
1. Commit the actual scaffold to the branch (the working-tree only had README.md deleted in the previous commit).
2. Make `main.ts` explicitly await DB initialization (migrations + seeds) before `app.listen()`.
3. The Angular `authInterceptor` should call `auth.getAccessToken()` (it already does — confirm wiring & ensure clones set `Authorization: Bearer`; also make sure it's registered BEFORE `csrfInterceptor` in the providers list).
4. Add Zod + class-validator DTOs for every admin body/path/query input, with `@Body(new ZodValidationPipe(...))` etc.
5. Implement real upload endpoints using a configured per-route/global Multer `fileSize` limit and a safe filename strategy.
6. `ThemeLoaderService` must REQUIRE and validate the ten theme files at startup; validate the configured theme key; fall back with a warning if missing/invalid.
## 1. Architectural Reconnaissance
- **Current state:** all scaffold files are present in the working tree and staged; only `README.md` was deleted in the prior commit. `git status` shows the planned commit is already a no-op on tracked paths except the working-tree edits.
- **Stack:** NestJS 10 + TypeORM/better-sqlite3 backend, Angular 17 standalone frontend, Jest + supertest tests in `/repo/tests/`, single command `npm test`.
- **Key existing modules:** `DatabaseModule` (TypeORM async + `migrationsRun: true`), `ThemeLoaderService` (currently warn-and-fallback on missing themes), `CommonModule` (exports CsrfMiddleware), `AuthModule`, `UsersModule`, `AdminModule`, `SystemModule`, `FrontendModule`.
- **Tests live in:** `/repo/tests/backend/` and `/repo/tests/frontend/`, runnable with `npm test`.
- **Persistent data:** `/data/hipctf/db.sqlite` and `/data/hipctf/uploads/`.
## 2. Impacted Files
### To Create
- `backend/src/modules/admin/dto/create-user.dto.ts` (Zod schema)
- `backend/src/modules/admin/dto/update-user-role.dto.ts`
- `backend/src/modules/admin/dto/list-users.query.dto.ts`
- `backend/src/modules/admin/dto/user-id.param.dto.ts`
- `backend/src/modules/uploads/uploads.module.ts`
- `backend/src/modules/uploads/uploads.controller.ts`
- `backend/src/modules/uploads/uploads.service.ts`
- `backend/src/modules/uploads/dto/upload-category-image.dto.ts`
- `tests/backend/admin-validation.spec.ts`
- `tests/backend/uploads.spec.ts`
- `tests/backend/theme-required.spec.ts`
### To Modify
- `.gitignore` — keep `/data/` and `node_modules/` ignored; keep the prior diff already staged.
- `backend/src/main.ts` — await `dataSource.initialize()` (or a dedicated `DatabaseInitService.init()`) BEFORE `app.listen()`; ensure SPA static and helmet/CORS ordering unchanged.
- `backend/src/database/database.module.ts` — implement `OnApplicationBootstrap` that awaits `DataSource.initialize()` and logs; export `DatabaseInitService` so `main.ts` can await it.
- `backend/src/common/utils/theme-loader.service.ts` — make the service REQUIRE all 10 themes; at the end of `loadAll()` compare loaded ids vs `THEME_IDS`; if any are missing OR if the configured `themeKey` setting does not match a loaded id, log a clear warning and fall back to default.
- `backend/src/modules/admin/admin.controller.ts` — replace untyped `@Body()`/`@Param()` with `@Body(new ZodValidationPipe(...))`/`@Param(new ZodValidationPipe(...))`/query DTOs; add class-validator metadata via `@ApiProperty` for OpenAPI.
- `backend/src/common/utils/upload.ts` — add a `createMulterMiddleware(config, opts)` factory returning a configured `multer` instance with `limits.fileSize`; add a `safeFilename(original)` helper that strips path traversal, lowercases, restricts charset, and deduplicates.
- `frontend/src/main.ts` — verify the providers order: `[csrfInterceptor, authInterceptor]`; ensure `AuthService.getAccessToken()` is wired and the clone sets `Authorization: Bearer`.
## 3. Proposed Changes
### 3.1 Commit the scaffold
The working tree is already correctly staged. Steps:
1. `git add -A` to capture any untracked files (`.gitignore` already modified).
2. `git commit -m "Scaffold HIPCTF platform (NestJS + Angular)"` on the current branch `feature-820-1784637300954`. (No amend; no force-push.)
3. Verify the new commit includes all scaffold files: `git show --stat HEAD` lists 50+ files across `backend/`, `frontend/`, `tests/`.
### 3.2 Await DB init in `main.ts`
1. Add `DatabaseInitService` exporting `init(): Promise<void>` that:
- Resolves the `DataSource` token from `app.get(DataSource)`.
- Calls `dataSource.initialize()` (idempotent — TypeORM returns the existing connection if already initialized).
- Awaits `dataSource.runMigrations({ transaction: 'each' })` (TypeORM runs them in order; both our migrations are idempotent).
- Logs the migration count and seed result.
2. Expose via `DatabaseModule` `exports` so `main.ts` can `app.get(DatabaseInitService).init()`.
3. `main.ts` flow:
```ts
const app = await NestFactory.create(AppModule);
await app.get(DatabaseInitService).init();
// ... existing helmet/cors/static/validation/swagger setup unchanged ...
await app.listen(port, '0.0.0.0');
```
4. The existing `migrationsRun: true` flag in `TypeOrmModule.forRootAsync` already runs migrations automatically on `DataSource.initialize()`; the explicit `init()` step is for *visibility*, ordering guarantees, and logging. This satisfies "startup awaits database initialization/migrations/seeds before app.listen".
5. Test: add `database-init.spec.ts` that calls `DatabaseInitService.init()` against `:memory:` and asserts the schema + seeded categories/settings are present.
### 3.3 Angular auth interceptor (confirm and finalize)
1. `frontend/src/main.ts` registers providers in this order:
```ts
provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor]))
```
2. `frontend/src/app/core/interceptors/auth.interceptor.ts` already calls `auth.getAccessToken()` and clones with `Authorization: Bearer ${token}`. No code change needed — verify by a unit test:
- `tests/frontend/auth-interceptor.spec.ts` uses `provideHttpClientTesting()` to capture outbound requests; sets a token on `AuthService.setSession(...)` and asserts the cloned request has the `Authorization` header.
### 3.4 Admin DTOs (Zod + class-validator)
1. Create `backend/src/modules/admin/dto/` with:
- `create-user.dto.ts` — Zod: `{ username: string.min(3).max(64), password: string.min(12), role: enum(['admin','player']) }`.
- `update-user-role.dto.ts` — Zod: `{ role: enum(['admin','player']) }`.
- `user-id.param.dto.ts` — Zod: `{ id: string.uuid() }`.
- `list-users.query.dto.ts` — Zod: `{ limit?: number.int().positive().max(200).default(50), cursor?: string.uuid().optional() }`.
2. Apply via `ZodValidationPipe` on every handler. Also add `@ApiProperty` for Swagger metadata (the project already mixes Zod + Swagger; the Swagger annotations live alongside the DTOs).
3. Admin service: enforce `password` policy (delegate to `validatePassword` from `common/utils/password-policy`).
4. Tests: `admin-validation.spec.ts` — invalid body returns 400; missing path param returns 400; bad role returns 400.
### 3.5 Upload endpoints with safe filenames
1. `backend/src/common/utils/upload.ts` additions:
```ts
export function safeFilename(original: string): string {
// strip directory, lowercase, replace [^a-z0-9._-], collapse, dedupe
const base = path.basename(original || 'file');
const cleaned = base.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 120) || 'file';
const ext = path.extname(cleaned);
const stem = ext ? cleaned.slice(0, -ext.length) : cleaned;
return `${stem}-${randomUUID().slice(0, 8)}${ext}`;
}
export function buildMulter(config: ConfigService) {
const limits = buildUploadLimits(config);
return multer({ storage: multer.diskStorage({
destination: path.resolve(config.get('UPLOAD_DIR', '/data/hipctf/uploads')),
filename: (_req, file, cb) => cb(null, safeFilename(file.originalname)),
}), limits });
}
```
2. New `uploads.module/controller/service`:
- `POST /api/v1/uploads/category-icon` (admin-only via `AdminGuard`) — `multer.single('file')`, global `fileSize` limit applied via `buildMulter(config)`. Returns `{ id, storedPath, originalFilename, size }`.
- `POST /api/v1/uploads/challenge-file` (admin-only) — same, but stored under `<UPLOAD_DIR>/challenges/<id>/`.
3. Serve via existing `app.use('/uploads', express.static(uploadDir))` — already in `main.ts`. The static handler uses the SAME `uploadDir`, so files saved by multer are publicly served.
4. Tests: `uploads.spec.ts` — admin can upload a small file (200), rejects oversize with 413 (or 500 via Multer's `LIMIT_FILE_SIZE` error), rejects path-traversal filenames via `safeFilename` (unit-test the helper directly).
### 3.6 ThemeLoaderService — require + validate the 10 themes
1. After `loadAll()` populates `this.themes`:
- Compute `missing = THEME_IDS.filter(id => !this.themes.has(id))`.
- If `missing.length > 0`:
- Log a `warn` listing missing ids.
- For each missing id, copy the matching entry from `BUILTIN_THEMES` into `this.themes` so `THEME_IDS.length === this.themes.size` after init.
2. Read the configured theme key: `await this.settings.get('themeKey', 'classic')` (SettingsService is `@Global()` already).
3. If `configuredKey` is not in `THEME_IDS` OR not in `this.themes`, log a warning (`Configured theme 'X' is unknown; falling back to 'classic'`) and set `defaultThemeId = 'classic'`.
4. Otherwise set `defaultThemeId = configuredKey`.
5. `getTheme(id)` behaviour unchanged but now guaranteed to find a theme (since all 10 are loaded).
6. Test: `theme-required.spec.ts` — `theme-loader.spec.ts` already covers warn-and-fallback; this new spec asserts that even with an empty THEMES_DIR, all 10 themes are available (because builtins are backfilled).
### 3.7 Tests added
- `database-init.spec.ts` — boots `DatabaseInitService` against `:memory:`, asserts schema present + 6 system categories + 8 settings rows.
- `admin-validation.spec.ts` — covers all four admin DTOs (create, update, param, query).
- `uploads.spec.ts` — admin upload happy-path + safeFilename unit + oversize rejection.
- `theme-required.spec.ts` — all 10 ids available even when THEMES_DIR points at empty dir; configured theme key fallback logs warn.
- `auth-interceptor.spec.ts` (frontend) — verifies `Authorization: Bearer` header is attached when `AuthService.getAccessToken()` returns a token.
## 4. Test Strategy
- New unit/integration specs listed above.
- No new heavyweight suites; all are pure-Jest with no extra containers.
- One small frontend test for the auth interceptor (jsdom + `provideHttpClientTesting`).
- All tests runnable with `npm test` from repo root.
## 5. Persistent `/data` Usage
No change to existing `/data` layout. Upload tests use `UPLOAD_DIR=/tmp/...` via env, mirroring the existing convention.
## 6. Definition of Done
- `git log -1 --stat` shows the new commit containing the full scaffold diff on the `feature-820-1784637300954` branch.
- `npm test` passes (46 existing + new suites green).
- `npm run build` succeeds.
- Startup logging explicitly mentions migrations + seeds having completed before `app.listen` resolves.
- `GET /api/v1/admin/users` requires valid DTO inputs and returns 400 on bad UUIDs, bad roles, etc.
- `POST /api/v1/uploads/category-icon` saves files into `/data/hipctf/uploads/` with safe filenames, rejecting oversize uploads.
- `ThemeLoaderService.listThemes().length === 10` regardless of disk state; configured theme key validated.
- Manual E2E smoke: register admin → upload a 1kb icon → `GET /uploads/<safe-name>` returns the file.
+7
View File
@@ -0,0 +1,7 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+47
View File
@@ -0,0 +1,47 @@
{
"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",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"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/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"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { MiddlewareConsumer, Module, NestModule } 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 { SystemModule } from './modules/system/system.module';
import { SettingsModule } from './modules/settings/settings.module';
import { AdminModule } from './modules/admin/admin.module';
import { FrontendModule } from './frontend/frontend.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
validate: validateEnv,
}),
DatabaseModule,
CommonModule,
SettingsModule,
AuthModule,
UsersModule,
SystemModule,
AdminModule,
FrontendModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(CsrfMiddleware).forRoutes('*');
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Module, Global } from '@nestjs/common';
import { ThemeLoaderService } from './utils/theme-loader.service';
import { EventStatusService } from './services/event-status.service';
import { SseHubService } from './services/sse-hub.service';
import { LoginBackoffService } from './services/login-backoff.service';
import { RegistrationRateLimitService } from './services/registration-rate-limit.service';
import { CsrfMiddleware } from './middleware/csrf.middleware';
import { SettingsModule } from '../modules/settings/settings.module';
@Global()
@Module({
imports: [SettingsModule],
providers: [
ThemeLoaderService,
EventStatusService,
SseHubService,
LoginBackoffService,
RegistrationRateLimitService,
CsrfMiddleware,
],
exports: [
ThemeLoaderService,
EventStatusService,
SseHubService,
LoginBackoffService,
RegistrationRateLimitService,
CsrfMiddleware,
],
})
export class CommonModule {}
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../database/entities/user.entity';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const SKIP_CSRF_KEY = 'skipCsrf';
export const SkipCsrf = () => SetMetadata(SKIP_CSRF_KEY, true);
+37
View File
@@ -0,0 +1,37 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { ErrorCode, ERROR_CODES } from './error-codes';
export class ApiError extends HttpException {
constructor(
public readonly code: ErrorCode,
message: string,
status: HttpStatus,
public readonly details?: unknown,
) {
super({ code, message, details }, status);
}
static validation(message = 'Validation failed', details?: unknown): ApiError {
return new ApiError(ERROR_CODES.VALIDATION_FAILED, message, HttpStatus.BAD_REQUEST, details);
}
static unauthorized(message = 'Unauthorized'): ApiError {
return new ApiError(ERROR_CODES.UNAUTHORIZED, message, HttpStatus.UNAUTHORIZED);
}
static forbidden(message = 'Forbidden'): ApiError {
return new ApiError(ERROR_CODES.FORBIDDEN, message, HttpStatus.FORBIDDEN);
}
static notFound(message = 'Not found'): ApiError {
return new ApiError(ERROR_CODES.NOT_FOUND, message, HttpStatus.NOT_FOUND);
}
static conflict(code: ErrorCode, message: string): ApiError {
return new ApiError(code, message, HttpStatus.CONFLICT);
}
static internal(message = 'Internal server error'): ApiError {
return new ApiError(ERROR_CODES.INTERNAL, message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
+18
View File
@@ -0,0 +1,18 @@
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',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
TOKEN_REVOKED: 'TOKEN_REVOKED',
THEME_INVALID: 'THEME_INVALID',
INTERNAL: 'INTERNAL',
} as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
@@ -0,0 +1,81 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { ApiError } from '../errors/api-error';
import { ERROR_CODES } from '../errors/error-codes';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const req = ctx.getRequest();
const path = req?.url ?? '';
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let body: { code: string; message: string; details?: unknown; path: string; timestamp: string };
if (exception instanceof ApiError) {
status = exception.getStatus();
const resp = exception.getResponse() as any;
body = {
code: resp.code,
message: resp.message,
details: resp.details,
path,
timestamp: new Date().toISOString(),
};
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const resp = exception.getResponse() as any;
const message = typeof resp === 'string' ? resp : resp?.message ?? exception.message;
const code = typeof resp === 'object' && resp?.code ? resp.code : this.codeFromStatus(status);
body = {
code,
message: Array.isArray(message) ? message.join('; ') : message,
details: typeof resp === 'object' ? resp?.details : undefined,
path,
timestamp: new Date().toISOString(),
};
} else {
this.logger.error(`Unhandled error: ${(exception as Error)?.message}`, (exception as Error)?.stack);
const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : (exception as Error)?.message ?? 'Internal server error';
body = {
code: ERROR_CODES.INTERNAL,
message,
path,
timestamp: new Date().toISOString(),
};
}
if (status >= 500) {
this.logger.error(`${status} ${body.code} ${path}: ${body.message}`);
} else if (status >= 400) {
this.logger.warn(`${status} ${body.code} ${path}: ${body.message}`);
}
httpAdapter.reply(ctx.getResponse(), body, status);
}
private codeFromStatus(status: number): string {
switch (status) {
case 400: return ERROR_CODES.VALIDATION_FAILED;
case 401: return ERROR_CODES.UNAUTHORIZED;
case 403: return ERROR_CODES.FORBIDDEN;
case 404: return ERROR_CODES.NOT_FOUND;
case 409: return ERROR_CODES.CONFLICT;
case 429: return ERROR_CODES.RATE_LIMITED;
default: return ERROR_CODES.INTERNAL;
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class AdminGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
const req = context.switchToHttp().getRequest();
if (!req.user) throw new ForbiddenException('Authentication required');
if (!required || required.length === 0) return true;
if (!required.includes(req.user.role)) {
throw new ForbiddenException('Admin role required');
}
return true;
}
}
@@ -0,0 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { UserRole } from '../../database/entities/user.entity';
import { ApiError } from '../errors/api-error';
import { ERROR_CODES } from '../errors/error-codes';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) return true;
const req = context.switchToHttp().getRequest();
if (!req.user) throw ApiError.unauthorized();
if (!required.includes(req.user.role)) {
throw new ForbiddenException('Insufficient role');
}
return true;
}
}
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(map((data) => ({ data })));
}
}
@@ -0,0 +1,98 @@
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/auth/login'];
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,48 @@
import { Injectable } from '@nestjs/common';
import { SettingsService } from '../../modules/settings/settings.module';
export type EventStatusName = 'Stopped' | 'Running';
export interface EventStatus {
status: EventStatusName;
startUtc: string;
endUtc: string;
serverNowUtc: string;
countdownMs: number;
}
@Injectable()
export class EventStatusService {
constructor(private readonly settings: SettingsService) {}
async getStatus(now: Date = new Date()): Promise<EventStatus> {
const startStr = await this.settings.get('eventStartUtc');
const endStr = await this.settings.get('eventEndUtc');
const start = new Date(startStr);
const end = new Date(endStr);
const nowMs = now.getTime();
const startMs = start.getTime();
const endMs = end.getTime();
let status: EventStatusName = 'Stopped';
let countdownMs = 0;
if (nowMs < startMs) {
status = 'Stopped';
countdownMs = startMs - nowMs;
} else if (nowMs <= endMs) {
status = 'Running';
countdownMs = endMs - nowMs;
} else {
status = 'Stopped';
countdownMs = 0;
}
return {
status,
startUtc: start.toISOString(),
endUtc: end.toISOString(),
serverNowUtc: now.toISOString(),
countdownMs,
};
}
}
@@ -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,21 @@
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);
}
}
@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { Subject, Observable } from 'rxjs';
@Injectable()
export class SseHubService {
private readonly eventSubject = new Subject<any>();
private readonly scoreboardSubject = new Subject<any>();
emitEvent(payload: any): void {
this.eventSubject.next(payload);
}
emitScoreboard(payload: any): void {
this.scoreboardSubject.next(payload);
}
event$(): Observable<any> {
return this.eventSubject.asObservable();
}
scoreboard$(): Observable<any> {
return this.scoreboardSubject.asObservable();
}
}
+14
View File
@@ -0,0 +1,14 @@
export const THEME_IDS = [
'classic',
'midnight',
'sunset',
'forest',
'cyber',
'paper',
'crimson',
'ocean',
'neon',
'monochrome',
] as const;
export type ThemeId = (typeof THEME_IDS)[number];
+19
View File
@@ -0,0 +1,19 @@
export type ThemeTokens = {
primary: string;
secondary: string;
accent: string;
surface: string;
text: string;
success: string;
warning: string;
danger: string;
fontFamily: string;
radii: { sm: string; md: string; lg: string };
spacingScale: number[];
};
export type Theme = {
id: string;
name: string;
tokens: ThemeTokens;
};
@@ -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,215 @@
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';
@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) {}
async onModuleInit(): Promise<void> {
this.themesDir = this.config.get<string>('THEMES_DIR', './themes');
this.loadAll();
}
private loadAll(): void {
const absDir = path.resolve(this.themesDir);
if (!fs.existsSync(absDir)) {
this.logger.warn(`Themes directory not found at ${absDir}; using built-in defaults`);
this.installBuiltins();
return;
}
const files = fs.readdirSync(absDir).filter((f) => f.endsWith('.json'));
if (files.length === 0) {
this.logger.warn(`No theme files found in ${absDir}; using built-in defaults`);
this.installBuiltins();
return;
}
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}`);
}
}
if (this.themes.size === 0) {
this.logger.warn('No valid themes loaded; using built-in defaults');
this.installBuiltins();
}
}
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;
}
private installBuiltins(): void {
const builtins = BUILTIN_THEMES;
for (const t of builtins) this.themes.set(t.id, t);
}
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());
}
}
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],
},
},
];
+24
View File
@@ -0,0 +1,24 @@
import { ConfigService } from '@nestjs/config';
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) };
}
+76
View File
@@ -0,0 +1,76 @@
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/hipctf/db.sqlite'),
UPLOAD_DIR: z.string().min(1).default('/data/hipctf/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),
});
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' },
};
+68
View File
@@ -0,0 +1,68 @@
import { Module, Global, OnModuleInit, 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 { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema';
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
const ENTITIES = [
UserEntity,
SettingEntity,
CategoryEntity,
ChallengeEntity,
ChallengeFileEntity,
SolveEntity,
RefreshTokenEntity,
BlogPostEntity,
];
const MIGRATIONS = [InitSchema1700000000000, SeedSystemData1700000000100];
@Global()
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const dbPath = config.get<string>('DATABASE_PATH', '/data/hipctf/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: true,
synchronize: false,
logging: false,
};
},
}),
TypeOrmModule.forFeature(ENTITIES),
],
exports: [TypeOrmModule],
})
export class DatabaseModule implements OnModuleInit {
private readonly logger = new Logger(DatabaseModule.name);
constructor(private readonly config: ConfigService) {}
onModuleInit(): void {
const dbPath = this.config.get<string>('DATABASE_PATH', '/data/hipctf/db.sqlite');
if (dbPath === ':memory:' || dbPath.startsWith('file:')) return;
this.logger.log(`Database ready at ${dbPath}`);
}
}
@@ -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,23 @@
import { Entity, PrimaryColumn, Column, Index } from 'typeorm';
@Entity('category')
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;
}
@@ -0,0 +1,22 @@
import { Entity, PrimaryColumn, Column, 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)
@JoinColumn({ name: 'challenge_id' })
challenge?: ChallengeEntity;
@Column('text', { name: 'original_filename' })
originalFilename!: string;
@Column('text', { name: 'stored_path' })
storedPath!: string;
}
@@ -0,0 +1,52 @@
import { Entity, PrimaryColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
import { CategoryEntity } from './category.entity';
export type Difficulty = 'low' | 'med' | '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: 'low' })
difficulty!: Difficulty;
@Column('integer', { name: 'initial_points', default: 0 })
initialPoints!: number;
@Column('integer', { name: 'minimum_points', default: 0 })
minimumPoints!: number;
@Column('integer', { name: 'decay_solves', default: 0 })
decaySolves!: number;
@Column('text', { default: '' })
flag!: string;
@Column('text', { default: 'nc' })
protocol!: Protocol;
@Column('integer', { nullable: true })
port!: number | null;
@Column('text', { name: 'ip_address', default: '' })
ipAddress!: string;
@CreateDateColumn({ type: 'text', name: 'created_at' })
createdAt!: string;
}
@@ -0,0 +1,23 @@
import { Entity, PrimaryColumn, Column, Index } from 'typeorm';
@Entity('refresh_token')
export class RefreshTokenEntity {
@PrimaryColumn('text')
id!: string;
@Index()
@Column('text', { name: 'user_id' })
userId!: string;
@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,28 @@
import { Entity, PrimaryColumn, Column, Index, Unique } from 'typeorm';
@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;
@Column('text', { name: 'user_id' })
userId!: string;
@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;
}
@@ -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,123 @@
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(`
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 ''
);
`);
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";`);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [ConfigModule],
})
export class FrontendModule {}
+29
View File
@@ -0,0 +1,29 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SpaFallbackMiddleware implements NestMiddleware {
constructor(private readonly config: ConfigService) {}
use(req: Request, res: Response, next: NextFunction): void {
const url = req.path || req.url;
if (url.startsWith('/api') || url.startsWith('/uploads')) return next();
if (path.extname(url)) return next();
if (req.method !== 'GET') return next();
const distDir = path.resolve(this.config.get<string>('FRONTEND_DIST', '../frontend/dist'));
const candidates = [distDir, path.join(distDir, 'browser')];
for (const dir of candidates) {
const p = path.join(dir, 'index.html');
if (fs.existsSync(p)) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
fs.createReadStream(p).pipe(res);
return;
}
}
res.status(200).send('<!doctype html><html><body><h1>HIPCTF</h1><p>Frontend not built.</p></body></html>');
}
}
@@ -0,0 +1,22 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class UploadsStaticMiddleware implements NestMiddleware {
private rootDir = '/tmp';
constructor(config: ConfigService) {
const dir = path.resolve(config.get<string>('UPLOAD_DIR', '/data/hipctf/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);
}
}
+100
View File
@@ -0,0 +1,100 @@
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 { 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';
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/hipctf/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 });
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'],
});
app.use(cookieParser());
app.use(express.json({ limit: bodyLimit }));
app.use(express.urlencoded({ extended: true, limit: bodyLimit }));
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 document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document, {
jsonDocumentUrl: 'api/docs-json',
});
// Serve built SPA assets (chunks, css, js, images) before the SPA fallback so /api and /uploads still win.
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 }));
}
}
// SPA fallback as plain express middleware registered LAST so /api, /uploads, and asset static all win first.
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,48 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
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 { AdminService } from './admin.service';
@ApiTags('admin')
@ApiBearerAuth()
@Controller('api/v1/admin')
@UseGuards(AdminGuard)
@Roles('admin')
export class AdminController {
constructor(private readonly admin: AdminService) {}
@Get('users')
@ApiOperation({ summary: 'List all users (admin only)' })
list() {
return this.admin.listUsers();
}
@Patch('users/:id')
@ApiOperation({ summary: 'Update a user role (admin only)' })
update(@Param('id') id: string, @Body() body: { role: UserRole }) {
return this.admin.updateUserRole(id, body.role);
}
@Delete('users/:id')
@ApiOperation({ summary: 'Delete a user (admin only)' })
async remove(@Param('id') id: string): Promise<void> {
await this.admin.deleteUser(id);
}
@Post('users')
@ApiOperation({ summary: 'Create a user (admin only)' })
create(@Body() body: { username: string; password: string; role: UserRole }) {
return this.admin.createUser(body.username, body.password, body.role);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../../database/entities/user.entity';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity]), AuthModule, UsersModule],
providers: [AdminService],
controllers: [AdminController],
})
export class AdminModule {}
@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { v4 as uuid } from 'uuid';
import * as argon2 from 'argon2';
import { UserEntity, UserRole } from '../../database/entities/user.entity';
import { UsersService } from '../users/users.service';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
@Injectable()
export class AdminService {
constructor(
private readonly usersService: UsersService,
private readonly dataSource: DataSource,
) {}
listUsers(): Promise<UserEntity[]> {
return this.usersService.listAll();
}
async updateUserRole(targetId: string, role: UserRole): Promise<UserEntity> {
if (role !== 'admin' && role !== 'player') {
throw ApiError.validation('Invalid role');
}
return this.dataSource.transaction(async (manager) => {
await this.usersService.enforceLastAdminInvariant(manager, targetId, role);
const repo = manager.getRepository(UserEntity);
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
if (!user) throw ApiError.notFound('User not found');
user.role = role;
await manager.save(user);
return user;
});
}
async deleteUser(targetId: string): Promise<void> {
return this.dataSource.transaction(async (manager) => {
await this.usersService.enforceLastAdminInvariant(manager, targetId);
const repo = manager.getRepository(UserEntity);
const res = await repo.delete({ id: targetId });
if (!res.affected) throw ApiError.notFound('User not found');
});
}
async createUser(username: string, password: string, role: UserRole): Promise<UserEntity> {
return 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 argon2.hash(password, { type: argon2.argon2id });
const user = manager.create(UserEntity, {
id: uuid(),
username,
passwordHash: hash,
role,
status: 'enabled',
});
await manager.save(user);
return user;
});
}
}
@@ -0,0 +1,89 @@
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 { LoginDtoSchema, RefreshDtoSchema } 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('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);
}
@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 };
}
}
+30
View File
@@ -0,0 +1,30 @@
import { 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';
@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') },
}),
}),
],
providers: [AuthService, JwtStrategy, AdminGuard],
controllers: [AuthController],
exports: [AuthService, JwtModule, AdminGuard],
})
export class AuthModule {}
+184
View File
@@ -0,0 +1,184 @@
import {
Injectable,
OnModuleInit,
Logger,
} 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 { LoginDto, LoginResponse } from './dto/auth.dto';
import { validatePassword } from '../../common/utils/password-policy';
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,
) {}
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.isAllowed(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 argon2.hash(password, {
type: argon2.argon2id,
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
});
const user = manager.create(UserEntity, {
id: uuid(),
username,
passwordHash,
role: 'admin',
status: 'enabled',
});
await manager.save(user);
this.registrationRateLimit.record(ip);
return this.mintSession(manager, user);
});
}
/**
* 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> {
const accessToken = await this.signAccess(user);
const refreshToken = this.generateRefreshToken();
const tokenHash = this.hashToken(refreshToken);
const now = new Date();
const expires = new Date(now.getTime() + this.refreshTtlSeconds * 1000);
await manager.insert(RefreshTokenEntity, {
id: uuid(),
userId: user.id,
tokenHash,
issuedAt: now.toISOString(),
expiresAt: expires.toISOString(),
revokedAt: null,
});
return {
accessToken,
refreshToken,
expiresIn: this.accessTtlSeconds(),
user: { id: user.id, username: user.username, role: user.role },
};
}
private async signAccess(user: UserEntity): Promise<string> {
return this.jwt.signAsync({ sub: user.id, username: user.username, role: user.role });
}
private generateRefreshToken(): string {
return crypto.randomBytes(32).toString('base64url');
}
private hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
private accessTtlSeconds(): number {
return this.parseTtl(this.config.get<string>('JWT_ACCESS_TTL', '15m'));
}
private parseTtl(ttl: string): number {
const m = /^(\d+)([smhd])$/.exec(ttl.trim());
if (!m) return 900;
const n = parseInt(m[1], 10);
const unit = m[2];
const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400;
return n * mult;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Response } from 'express';
import { ConfigService } from '@nestjs/config';
export const REFRESH_COOKIE_NAME = 'rt';
export function setRefreshCookie(res: Response, token: string, config: ConfigService): void {
const tls = config.get<boolean>('TLS_ENABLED', false);
res.cookie(REFRESH_COOKIE_NAME, token, {
httpOnly: true,
sameSite: tls ? 'strict' : 'lax',
secure: tls,
path: '/api/v1/auth',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
}
export function clearRefreshCookie(res: Response): void {
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/v1/auth' });
}
+24
View File
@@ -0,0 +1,24 @@
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 interface LoginResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: { id: string; username: string; role: 'admin' | 'player' };
}
@@ -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,33 @@
import { Injectable, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SettingEntity } from '../../database/entities/setting.entity';
@Injectable()
export class SettingsService {
constructor(@InjectRepository(SettingEntity) private readonly repo: Repository<SettingEntity>) {}
async get(key: string, fallback: string = ''): Promise<string> {
const row = await this.repo.findOne({ where: { key } });
return row?.value ?? fallback;
}
async set(key: string, value: string): Promise<void> {
await this.repo.upsert({ key, value }, ['key']);
}
async getAll(): Promise<Record<string, string>> {
const rows = await this.repo.find();
const out: Record<string, string> = {};
for (const r of rows) out[r.key] = r.value;
return out;
}
}
@Module({
imports: [TypeOrmModule.forFeature([SettingEntity])],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}
@@ -0,0 +1,83 @@
import { Controller, Get, Sse, MessageEvent } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { interval, map, merge, mergeMap, Observable, startWith, defer } from 'rxjs';
import { Public } from '../../common/decorators/public.decorator';
import { SystemService } from './system.service';
import { EventStatusService } from '../../common/services/event-status.service';
import { SseHubService } from '../../common/services/sse-hub.service';
@ApiTags('system')
@Controller('api/v1')
export class SystemController {
constructor(
private readonly system: SystemService,
private readonly statusSvc: EventStatusService,
private readonly hub: SseHubService,
) {}
@Public()
@Get('bootstrap')
@ApiOperation({ summary: 'Public bootstrap payload used by the SPA on startup' })
bootstrap() {
return this.system.bootstrap();
}
@Public()
@Get('event/status')
@ApiOperation({ summary: 'Event status derived from UTC timestamps' })
eventStatus() {
return this.statusSvc.getStatus();
}
/**
* Global event status + countdown stream.
* Emits a FLATTENED object every second (and whenever the hub pushes).
*/
@Public()
@Sse('event/stream')
eventStream(): Observable<MessageEvent> {
const tick$ = interval(1000).pipe(
startWith(0),
mergeMap(() =>
defer(() =>
this.statusSvc.getStatus().then((s) => ({
status: s.status,
countdownMs: s.countdownMs,
serverNowUtc: s.serverNowUtc,
startUtc: s.startUtc,
endUtc: s.endUtc,
})),
),
),
);
const hub$ = this.hub.event$().pipe(
map((payload: any) => ({
status: payload?.status,
countdownMs: payload?.countdownMs,
serverNowUtc: payload?.serverNowUtc,
startUtc: payload?.startUtc,
endUtc: payload?.endUtc,
})),
);
return merge(tick$, hub$).pipe(map((src) => ({ data: src }) as MessageEvent));
}
/**
* Live solves stream.
* Emits a FLATTENED solve payload whenever a new solve is published.
*/
@Public()
@Sse('scoreboard/stream')
scoreboardStream(): Observable<MessageEvent> {
return this.hub.scoreboard$().pipe(
map((s: any) => ({
challengeId: s?.challengeId,
userId: s?.userId,
pointsAwarded: s?.pointsAwarded,
rankBonus: s?.rankBonus,
solvedAt: s?.solvedAt,
})),
map((src) => ({ data: src }) as MessageEvent),
);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../../database/entities/user.entity';
import { SettingEntity } from '../../database/entities/setting.entity';
import { SystemController } from './system.controller';
import { SystemService } from './system.service';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity, SettingEntity])],
controllers: [SystemController],
providers: [SystemService],
})
export class SystemModule {}
@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from '../../database/entities/user.entity';
import { SettingEntity } from '../../database/entities/setting.entity';
import { ThemeLoaderService } from '../../common/utils/theme-loader.service';
import { SETTINGS_KEYS } from '../../config/env.schema';
export interface BootstrapPayload {
initialized: boolean;
pageTitle: string;
logo: string;
welcomeMarkdown: string;
theme: any;
defaultChallengeIp: string;
registrationsEnabled: boolean;
}
@Injectable()
export class SystemService {
constructor(
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
@InjectRepository(SettingEntity) private readonly settings: Repository<SettingEntity>,
private readonly themes: ThemeLoaderService,
) {}
async bootstrap(): Promise<BootstrapPayload> {
const adminCount = await this.users.count({ where: { role: 'admin' } });
const themeKey = await this.getSetting(SETTINGS_KEYS.THEME_KEY, 'classic');
return {
initialized: adminCount > 0,
pageTitle: await this.getSetting(SETTINGS_KEYS.PAGE_TITLE, 'HIPCTF'),
logo: await this.getSetting(SETTINGS_KEYS.LOGO, ''),
welcomeMarkdown: await this.getSetting(SETTINGS_KEYS.WELCOME_MARKDOWN, ''),
theme: this.themes.getTheme(themeKey),
defaultChallengeIp: await this.getSetting(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, '127.0.0.1'),
registrationsEnabled: (await this.getSetting(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')) === 'true',
};
}
private async getSetting(key: string, fallback: string): Promise<string> {
const row = await this.settings.findOne({ where: { key } });
return row?.value ?? fallback;
}
}
@@ -0,0 +1,7 @@
import { z } from 'zod';
export const CreateFirstAdminDtoSchema = z.object({
username: z.string().min(3).max(64),
password: z.string().min(1).max(256),
});
export type CreateFirstAdminDto = z.infer<typeof CreateFirstAdminDtoSchema>;
@@ -0,0 +1,36 @@
import { Body, Controller, Post, Req, Res } from '@nestjs/common';
import { ApiTags, ApiOperation } 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 { AuthService } from '../auth/auth.service';
import { CreateFirstAdminDtoSchema } from './dto/create-first-admin.dto';
import { setRefreshCookie } from '../auth/cookie';
@ApiTags('users')
@Controller('api/v1/auth')
export class UsersController {
constructor(
private readonly auth: AuthService,
private readonly config: ConfigService,
) {}
@Public()
@Post('register-first-admin')
@ApiOperation({ summary: 'Create the very first admin (only allowed when no admins exist)' })
async registerFirstAdmin(
@Body(new ZodValidationPipe(CreateFirstAdminDtoSchema)) 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.registerFirstAdmin(body.username, body.password, ip);
setRefreshCookie(res, session.refreshToken, this.config);
return {
accessToken: session.accessToken,
expiresIn: session.expiresIn,
user: session.user,
};
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserEntity } from '../../database/entities/user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [TypeOrmModule.forFeature([UserEntity]), AuthModule],
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService],
})
export class UsersModule {}
@@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { UserEntity, UserRole } from '../../database/entities/user.entity';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
type UserRepoLike = Repository<UserEntity> | EntityManager;
@Injectable()
export class UsersService {
constructor(@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>) {}
countAdmins(manager?: EntityManager): Promise<number> {
return this.getRepo(manager).count({ where: { role: 'admin' } });
}
findById(id: string, manager?: EntityManager): Promise<UserEntity | null> {
return this.getRepo(manager).findOne({ where: { id } });
}
listAll(): Promise<UserEntity[]> {
return this.repo.find({ order: { createdAt: 'ASC' } });
}
/**
* Verify, inside an open transaction, that demoting/deleting the given user
* would not leave the system with zero admins. Throws ApiError(LAST_ADMIN)
* if so. Must be invoked from a `dataSource.transaction(...)` callback.
*/
async enforceLastAdminInvariant(manager: EntityManager, targetId: string, nextRole?: UserRole): Promise<void> {
const repo = manager.getRepository(UserEntity);
const target = await repo.findOne({ where: { id: targetId } });
if (!target) throw ApiError.notFound('User not found');
const wouldDemoteOrDelete =
target.role === 'admin' && (nextRole === undefined || nextRole !== 'admin');
if (!wouldDemoteOrDelete) return;
const adminCount = await repo.count({ where: { role: 'admin' } });
if (adminCount <= 1) {
throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, 'Cannot delete or demote the last admin');
}
}
private getRepo(manager?: EntityManager): Repository<UserEntity> {
return manager ? manager.getRepository(UserEntity) : this.repo;
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"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]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"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]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"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]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"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]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"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]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"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]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"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]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"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]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"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]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"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]
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"removeComments": true,
"sourceMap": true,
"incremental": true,
"tsBuildInfoFile": "./dist/.tsbuildinfo"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}
+51
View File
@@ -0,0 +1,51 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"hipctf": {
"projectType": "application",
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [],
"styles": ["src/styles.css"],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{ "type": "initial", "maximumWarning": "1mb", "maximumError": "5mb" },
{ "type": "anyComponentStyle", "maximumWarning": "10kb", "maximumError": "20kb" }
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": { "buildTarget": "hipctf:build:production" },
"development": { "buildTarget": "hipctf:build:development" }
},
"defaultConfiguration": "development"
}
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "ng build --configuration production",
"start": "ng serve --host 0.0.0.0"
},
"dependencies": {
"@angular/animations": "^17.3.0",
"@angular/common": "^17.3.0",
"@angular/compiler": "^17.3.0",
"@angular/core": "^17.3.0",
"@angular/forms": "^17.3.0",
"@angular/platform-browser": "^17.3.0",
"@angular/platform-browser-dynamic": "^17.3.0",
"@angular/router": "^17.3.0",
"rxjs": "^7.8.1",
"tslib": "^2.6.2",
"zone.js": "~0.14.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^17.3.0",
"@angular/cli": "^17.3.0",
"@angular/compiler-cli": "^17.3.0",
"typescript": "~5.4.5"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Component, OnInit, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { BootstrapService } from './core/services/bootstrap.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet></router-outlet>`,
})
export class AppComponent implements OnInit {
private bootstrap = inject(BootstrapService);
async ngOnInit() {
await this.bootstrap.load();
}
}
+19
View File
@@ -0,0 +1,19 @@
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
export const APP_ROUTES: Routes = [
{
path: 'bootstrap',
loadComponent: () => import('./features/bootstrap/create-admin.component').then((m) => m.CreateAdminComponent),
},
{
path: 'login',
loadComponent: () => import('./features/auth/login.component').then((m) => m.LoginComponent),
},
{
path: '',
loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent),
canActivate: [authGuard],
},
{ path: '**', redirectTo: '' },
];
@@ -0,0 +1,18 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
import { BootstrapService } from '../services/bootstrap.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const bootstrap = inject(BootstrapService);
const router = inject(Router);
if (!bootstrap.initialized()) {
return router.createUrlTree(['/bootstrap']);
}
if (!auth.isAuthenticated()) {
return router.createUrlTree(['/login']);
}
return true;
};
@@ -0,0 +1,12 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const token = auth.getAccessToken();
if (token) {
req = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(req);
};
@@ -0,0 +1,23 @@
import { HttpInterceptorFn } from '@angular/common/http';
function readCookie(name: string): string | null {
const header = document.cookie;
if (!header) return null;
for (const part of header.split(';')) {
const [k, ...v] = part.trim().split('=');
if (k === name) return v.join('=');
}
return null;
}
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export const csrfInterceptor: HttpInterceptorFn = (req, next) => {
if (UNSAFE.has(req.method)) {
const token = readCookie('csrf');
if (token) {
req = req.clone({ setHeaders: { 'X-CSRF-Token': token } });
}
}
return next(req);
};
@@ -0,0 +1,30 @@
import { Injectable, signal, computed } from '@angular/core';
export interface CurrentUser {
id: string;
username: string;
role: 'admin' | 'player';
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private accessToken = signal<string | null>(null);
private user = signal<CurrentUser | null>(null);
readonly currentUser = computed(() => this.user());
readonly isAuthenticated = computed(() => !!this.user());
setSession(token: string, user: CurrentUser): void {
this.accessToken.set(token);
this.user.set(user);
}
clear(): void {
this.accessToken.set(null);
this.user.set(null);
}
getAccessToken(): string | null {
return this.accessToken();
}
}
@@ -0,0 +1,49 @@
import { Injectable, signal } from '@angular/core';
export interface BootstrapPayload {
initialized: boolean;
pageTitle: string;
logo: string;
welcomeMarkdown: string;
theme: any;
defaultChallengeIp: string;
registrationsEnabled: boolean;
}
@Injectable({ providedIn: 'root' })
export class BootstrapService {
readonly payload = signal<BootstrapPayload | null>(null);
readonly initialized = signal<boolean>(false);
async load(): Promise<BootstrapPayload | null> {
try {
const res = await fetch('/api/v1/bootstrap', { credentials: 'include' });
const data = (await res.json()) as BootstrapPayload;
this.payload.set(data);
this.initialized.set(data.initialized);
this.applyTheme(data.theme);
return data;
} catch (e) {
console.error('Bootstrap load failed', e);
return null;
}
}
private applyTheme(theme: any): void {
if (!theme?.tokens) return;
const root = document.documentElement.style;
const t = theme.tokens;
root.setProperty('--color-primary', t.primary);
root.setProperty('--color-secondary', t.secondary);
root.setProperty('--color-accent', t.accent);
root.setProperty('--color-surface', t.surface);
root.setProperty('--color-text', t.text);
root.setProperty('--color-success', t.success);
root.setProperty('--color-warning', t.warning);
root.setProperty('--color-danger', t.danger);
root.setProperty('--font-family', t.fontFamily);
root.setProperty('--radius-sm', t.radii.sm);
root.setProperty('--radius-md', t.radii.md);
root.setProperty('--radius-lg', t.radii.lg);
}
}
@@ -0,0 +1,49 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../core/services/auth.service';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule],
template: `
<div class="container">
<div class="card">
<h1>Sign in</h1>
<label>Username<input [(ngModel)]="username" name="u" /></label>
<label>Password<input [(ngModel)]="password" name="p" type="password" /></label>
<p style="color:var(--color-danger)">{{ error }}</p>
<button (click)="submit()" [disabled]="loading">{{ loading ? 'Signing in...' : 'Sign in' }}</button>
</div>
</div>
`,
})
export class LoginComponent {
private http = inject(HttpClient);
private auth = inject(AuthService);
private router = inject(Router);
username = '';
password = '';
loading = false;
error = '';
async submit(): Promise<void> {
this.loading = true;
this.error = '';
try {
await fetch('/api/v1/auth/csrf', { credentials: 'include' });
const res: any = await this.http
.post('/api/v1/auth/login', { username: this.username, password: this.password }, { withCredentials: true })
.toPromise();
this.auth.setSession(res.accessToken, res.user);
await this.router.navigateByUrl('/');
} catch (e: any) {
this.error = e?.error?.message ?? 'Failed';
} finally {
this.loading = false;
}
}
}
@@ -0,0 +1,50 @@
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../core/services/auth.service';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-create-admin',
standalone: true,
imports: [FormsModule],
template: `
<div class="container">
<div class="card">
<h1>Create the first admin</h1>
<p>This HIPCTF instance has not been initialized.</p>
<label>Username<input [(ngModel)]="username" name="username" /></label>
<label>Password<input [(ngModel)]="password" name="password" type="password" /></label>
<p style="color:var(--color-danger)">{{ error }}</p>
<button (click)="submit()" [disabled]="loading">{{ loading ? 'Creating...' : 'Create admin' }}</button>
</div>
</div>
`,
})
export class CreateAdminComponent {
private http = inject(HttpClient);
private auth = inject(AuthService);
private router = inject(Router);
username = '';
password = '';
loading = false;
error = '';
async submit(): Promise<void> {
this.loading = true;
this.error = '';
try {
await fetch('/api/v1/auth/csrf', { credentials: 'include' });
const res: any = await this.http
.post('/api/v1/auth/register-first-admin', { username: this.username, password: this.password }, { withCredentials: true })
.toPromise();
this.auth.setSession(res.accessToken, res.user);
await this.router.navigateByUrl('/');
} catch (e: any) {
this.error = e?.error?.message ?? 'Failed';
} finally {
this.loading = false;
}
}
}
@@ -0,0 +1,26 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BootstrapService } from '../../core/services/bootstrap.service';
import { AuthService } from '../../core/services/auth.service';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule],
template: `
<div class="container">
<h1>{{ bootstrap.payload()?.pageTitle ?? 'HIPCTF' }}</h1>
<div *ngIf="!auth.isAuthenticated()">
<p>{{ bootstrap.payload()?.welcomeMarkdown }}</p>
<a href="/login">Sign in</a>
</div>
<div *ngIf="auth.isAuthenticated()">
<p>Signed in as <b>{{ auth.currentUser()?.username }}</b> ({{ auth.currentUser()?.role }})</p>
</div>
</div>
`,
})
export class HomeComponent {
bootstrap = inject(BootstrapService);
auth = inject(AuthService);
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>HIPCTF</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="/" />
</head>
<body>
<app-root></app-root>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
import 'zone.js';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideHttpClient, withInterceptors, HttpInterceptorFn } from '@angular/common/http';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { AppComponent } from './app/app.component';
import { APP_ROUTES } from './app/app.routes';
import { authInterceptor } from './app/core/interceptors/auth.interceptor';
import { csrfInterceptor } from './app/core/interceptors/csrf.interceptor';
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor])),
provideRouter(APP_ROUTES, withComponentInputBinding()),
],
}).catch((err) => console.error(err));
+22
View File
@@ -0,0 +1,22 @@
:root {
--color-primary: #3b82f6;
--color-secondary: #64748b;
--color-accent: #f59e0b;
--color-surface: #ffffff;
--color-text: #0f172a;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--font-family: system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; font-family: var(--font-family); background: var(--color-surface); color: var(--color-text); }
button { font: inherit; padding: 8px 16px; border: 0; border-radius: var(--radius-md); background: var(--color-primary); color: #fff; cursor: pointer; }
button[disabled] { opacity: .5; cursor: not-allowed; }
input { font: inherit; padding: 8px 12px; border: 1px solid var(--color-secondary); border-radius: var(--radius-sm); width: 100%; }
.container { max-width: 960px; margin: 0 auto; padding: 24px; }
.card { padding: 24px; border-radius: var(--radius-lg); box-shadow: 0 1px 4px rgba(0,0,0,.1); }
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": false,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": ["ES2022", "dom"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": false
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://kilo.ai/config.json",
"permission": {
"*": "allow",
"ask_user": "deny",
"question": "deny",
"task": "deny",
"external_directory": {
"/tmp": "allow",
"/tmp/**": "allow",
"/tmp\\**": "allow",
"/SKILLS": "allow",
"/SKILLS/**": "allow",
"/SKILLS\\**": "allow"
}
}
}
+17837
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "hipctf",
"version": "0.1.0",
"private": true,
"description": "HIPCTF platform monorepo (NestJS API + Angular SPA)",
"workspaces": [
"backend",
"frontend"
],
"scripts": {
"build": "npm --workspace frontend run build && npm --workspace backend run build",
"start": "node backend/dist/main.js",
"start:dev": "npm --workspace backend run start:dev",
"test": "jest --config tests/jest.config.js",
"test:backend": "jest --config tests/jest.config.js --selectProjects backend",
"test:frontend": "jest --config tests/jest.config.js --selectProjects frontend",
"setup": "bash setup.sh"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/jsdom": "^28.0.3",
"@types/node": "^20.11.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "^29.1.1",
"ts-jest": "^29.1.2",
"typescript": "^5.4.5"
}
}
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[setup] Ensuring /data/hipctf exists..."
mkdir -p /data/hipctf/uploads
echo "[setup] Installing root dependencies..."
npm ci --no-audit --no-fund || npm install --no-audit --no-fund
echo "[setup] Building Angular frontend..."
npm --workspace frontend run build
echo "[setup] Building NestJS backend..."
npm --workspace backend run build
echo "[setup] Done. Start with: npm start"
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"esModuleInterop": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strict": false,
"resolveJsonModule": true,
"moduleResolution": "node",
"baseUrl": "..",
"paths": {
"*": ["*"]
}
}
}
+102
View File
@@ -0,0 +1,102 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import request from 'supertest';
import { CookieAccessInfo } from 'cookiejar';
import cookieParser from 'cookie-parser';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
describe('Admin route protection', () => {
let app: INestApplication;
let adminToken: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
const server = app.getHttpServer();
await request(server).post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const login = await request(server).post('/api/v1/auth/login')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
adminToken = login.body.accessToken;
});
afterAll(async () => {
await app.close();
});
it('GET /api/v1/admin/users without a token returns 401', async () => {
await request(app.getHttpServer()).get('/api/v1/admin/users').expect(401);
});
it('GET /api/v1/admin/users with a player JWT returns 403', async () => {
const server = app.getHttpServer();
const agent = request.agent(server);
// Prime CSRF cookie first.
await agent.get('/api/v1/auth/csrf');
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
const csrf = cookies.find((c: any) => c.name === 'csrf');
// Create a player via admin endpoint
await agent.post('/api/v1/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf.value)
.send({ username: 'player1', password: 'Sup3rSecret!Pass', role: 'player' })
.expect(201);
const playerLogin = await agent.post('/api/v1/auth/login')
.set('X-CSRF-Token', csrf.value)
.send({ username: 'player1', password: 'Sup3rSecret!Pass' })
.expect(201);
const playerToken = playerLogin.body.accessToken;
await request(app.getHttpServer())
.get('/api/v1/admin/users')
.set('Authorization', `Bearer ${playerToken}`)
.expect(403);
});
it('GET /api/v1/admin/users with admin JWT returns 200 and lists users', async () => {
const res = await request(app.getHttpServer())
.get('/api/v1/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBeGreaterThanOrEqual(2);
});
it('DELETE /api/v1/admin/users/:id on the only admin returns 409 LAST_ADMIN', async () => {
const list = await request(app.getHttpServer())
.get('/api/v1/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200);
const adminRow = list.body.find((u: any) => u.role === 'admin');
expect(adminRow).toBeDefined();
const server = app.getHttpServer();
const agent = request.agent(server);
await agent.get('/api/v1/auth/csrf');
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
const csrf = cookies.find((c: any) => c.name === 'csrf');
await agent.delete(`/api/v1/admin/users/${adminRow.id}`)
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf.value)
.expect(409);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { ApiError } from '../../backend/src/common/errors/api-error';
import { ERROR_CODES } from '../../backend/src/common/errors/error-codes';
describe('ApiError', () => {
it('formats validation errors with details', () => {
const e = ApiError.validation('Bad input', { field: 'x' });
expect(e.getStatus()).toBe(400);
const body = e.getResponse() as any;
expect(body.code).toBe(ERROR_CODES.VALIDATION_FAILED);
expect(body.message).toBe('Bad input');
expect(body.details).toEqual({ field: 'x' });
});
it('formats last-admin conflict', () => {
const e = ApiError.conflict(ERROR_CODES.LAST_ADMIN, 'Cannot delete the last admin');
expect(e.getStatus()).toBe(409);
const body = e.getResponse() as any;
expect(body.code).toBe(ERROR_CODES.LAST_ADMIN);
});
it('formats internal error as 500', () => {
const e = ApiError.internal();
expect(e.getStatus()).toBe(500);
});
});
+108
View File
@@ -0,0 +1,108 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import { CookieAccessInfo } from 'cookiejar';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
describe('Auth refresh rotation', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
const server = app.getHttpServer();
await request(server).post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
});
afterAll(async () => {
await app.close();
});
it('GET /api/v1/auth/csrf always sets the cookie and returns a non-empty token', async () => {
const server = app.getHttpServer();
const agent = request.agent(server);
const res = await agent.get('/api/v1/auth/csrf').expect(200);
expect(res.body.csrfToken).toBeDefined();
expect(res.body.csrfToken.length).toBeGreaterThan(0);
const cookies = res.headers['set-cookie'] as unknown as string[];
expect(cookies?.some((c) => c.startsWith('csrf='))).toBe(true);
});
it('login returns an access token and sets the rt refresh cookie', async () => {
const server = app.getHttpServer();
const agent = request.agent(server);
const res = await agent.post('/api/v1/auth/login')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
expect(res.body.accessToken).toBeDefined();
const cookies = res.headers['set-cookie'] as unknown as string[];
const rt = cookies.find((c) => c.startsWith('rt='));
expect(rt).toBeDefined();
expect(rt).toMatch(/HttpOnly/);
});
it('refresh rotates the refresh cookie and returns a new access token', async () => {
const server = app.getHttpServer();
const agent = request.agent(server);
await agent.post('/api/v1/auth/login')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
const rt = cookies.find((c: any) => c.name === 'rt');
const csrf = cookies.find((c: any) => c.name === 'csrf');
expect(rt).toBeDefined();
expect(csrf).toBeDefined();
const refresh = await agent.post('/api/v1/auth/refresh')
.set('X-CSRF-Token', csrf.value)
.set('Cookie', `rt=${rt.value}`)
.send({})
.expect(200);
expect(refresh.body.accessToken).toBeDefined();
expect(refresh.body.user.username).toBe('admin');
});
it('logout invalidates the refresh token', async () => {
const server = app.getHttpServer();
const agent = request.agent(server);
await agent.post('/api/v1/auth/login')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
const rt = cookies.find((c: any) => c.name === 'rt');
const csrf = cookies.find((c: any) => c.name === 'csrf');
await agent.post('/api/v1/auth/logout')
.set('X-CSRF-Token', csrf.value)
.set('Cookie', `rt=${rt.value}`)
.send({})
.expect(204);
// Reusing the same refresh cookie must fail
await agent.post('/api/v1/auth/refresh')
.set('X-CSRF-Token', csrf.value)
.set('Cookie', `rt=${rt.value}`)
.send({})
.expect(401);
});
});
@@ -0,0 +1,71 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
import { csrfClient, primeCsrf } from './csrf-client';
describe('Bootstrap integration', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
const httpAdapterHost = app.get(HttpAdapterHost);
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
await app.init();
});
afterAll(async () => {
await app.close();
});
it('a: bootstrap before any admin', async () => {
const c = csrfClient(app);
const res = await c.get('/api/v1/bootstrap').expect(200);
expect(res.body.initialized).toBe(false);
expect(res.body.theme).toBeDefined();
expect(res.body.theme.tokens.primary).toMatch(/^#/);
});
it('b: event status is Stopped or Running', async () => {
const c = csrfClient(app);
const res = await c.get('/api/v1/event/status').expect(200);
expect(['Stopped', 'Running']).toContain(res.body.status);
expect(typeof res.body.countdownMs).toBe('number');
});
it('c: register-first-admin rejects weak password', async () => {
const c = csrfClient(app);
await c.post('/api/v1/auth/register-first-admin')
.send({ username: 'weak', password: '123' })
.expect(400);
});
it('d: register-first-admin with valid creds creates admin', async () => {
const c = csrfClient(app);
const res = await c.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
expect(res.body.accessToken).toBeDefined();
expect(res.body.user.role).toBe('admin');
});
it('e: bootstrap now reports initialized=true', async () => {
const c = csrfClient(app);
const res = await c.get('/api/v1/bootstrap').expect(200);
expect(res.body.initialized).toBe(true);
});
it('f: register-first-admin now refuses (system initialized)', async () => {
const c = csrfClient(app);
await c.post('/api/v1/auth/register-first-admin')
.send({ username: 'second', password: 'Sup3rSecret!Pass' })
.expect(409);
});
});
+46
View File
@@ -0,0 +1,46 @@
import request from 'supertest';
import type { INestApplication } from '@nestjs/common';
import { CookieAccessInfo } from 'cookiejar';
const csrfSkipPaths = new Set<string>(['/api/v1/auth/login', '/api/v1/auth/register-first-admin']);
function attachCsrfHeader(test: request.Test, method: string, path: string, agent: any): request.Test {
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase())) return test;
if (csrfSkipPaths.has(path)) return test;
const jar = agent.jar;
if (!jar || typeof jar.getCookies !== 'function') return test;
const cookies: any[] = jar.getCookies(CookieAccessInfo.All);
if (process.env.CSRF_DEBUG) console.log('[csrf-client] all cookies in jar:', cookies.map((c: any) => ({ name: c.name, path: c.path })), 'for path', path);
const csrfCookie = cookies.find((c: any) => c.name === 'csrf');
if (!csrfCookie) return test;
const csrf = decodeURIComponent(csrfCookie.value ?? '');
if (csrf) test.set('X-CSRF-Token', csrf);
return test;
}
export function csrfClient(app: INestApplication): {
get: (path: string) => request.Test;
post: (path: string) => request.Test;
put: (path: string) => request.Test;
patch: (path: string) => request.Test;
delete: (path: string) => request.Test;
} {
const server = app.getHttpServer();
const agent = request.agent(server);
return {
get: (path: string) => agent.get(path),
post: (path: string) => attachCsrfHeader(agent.post(path), 'POST', path, agent),
put: (path: string) => attachCsrfHeader(agent.put(path), 'PUT', path, agent),
patch: (path: string) => attachCsrfHeader(agent.patch(path), 'PATCH', path, agent),
delete: (path: string) => attachCsrfHeader(agent.delete(path), 'DELETE', path, agent),
};
}
export async function primeCsrf(app: INestApplication): Promise<string> {
const server = app.getHttpServer();
const agent = request.agent(server);
const res = await agent.get('/api/v1/auth/csrf');
const cookieHeader = (res.headers['set-cookie'] as unknown as string[] | undefined)?.find((c) => c.startsWith('csrf='));
const token = cookieHeader ? cookieHeader.split(';')[0].split('=')[1] : '';
return decodeURIComponent(token);
}
+23
View File
@@ -0,0 +1,23 @@
import { envSchema, SYSTEM_CATEGORY_KEYS, SETTINGS_KEYS } from '../../backend/src/config/env.schema';
describe('envSchema', () => {
it('accepts minimal valid env', () => {
const r = envSchema.safeParse({});
expect(r.success).toBe(true);
});
it('rejects invalid PORT', () => {
const r = envSchema.safeParse({ PORT: 'not-a-number' });
expect(r.success).toBe(false);
});
it('rejects short JWT secrets', () => {
const r = envSchema.safeParse({ JWT_ACCESS_SECRET: 'short' });
expect(r.success).toBe(false);
});
it('exposes the canonical system category and settings keys', () => {
expect(SYSTEM_CATEGORY_KEYS).toEqual(['crypto', 'forensics', 'pwn', 'web', 'misc', 'osint']);
expect(SETTINGS_KEYS.THEME_KEY).toBe('themeKey');
});
});

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