AI Implementation feature(820): Project Scaffold and Platform Foundations #1
+13
@@ -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/
|
||||
@@ -0,0 +1,101 @@
|
||||
# Implementation Plan: WAL journal mode + OpenAPI 3.1 — Status check
|
||||
|
||||
## 0. Scope vs. Current Branch State
|
||||
|
||||
This reviewer request asks for three changes that were **already implemented
|
||||
and committed** in `de27a8b` on the `feature-820-1784637300954` branch. The
|
||||
code currently in the working tree (and in HEAD) matches the request verbatim:
|
||||
|
||||
1. ✅ `InitSchema` migration (`backend/src/database/migrations/1700000000000-InitSchema.ts`)
|
||||
already contains the WAL PRAGMA at line 8 — immediately after the existing
|
||||
`PRAGMA foreign_keys = ON` (line 7).
|
||||
2. ✅ `DatabaseInitService` (`backend/src/database/database-init.service.ts`)
|
||||
already enforces WAL on every startup via a private `ensureWalMode()`
|
||||
method that runs after `dataSource.initialize()`. It checks the current
|
||||
`journal_mode` and applies `PRAGMA journal_mode = WAL` if not already
|
||||
`wal`; idempotent and safe to call repeatedly.
|
||||
3. ✅ `main.ts` (`backend/src/main.ts` lines 83–94) now pipes the
|
||||
NestJS-generated OpenAPI document through a custom `toOpenApi31()`
|
||||
converter (`backend/src/common/utils/openapi31.ts`) that emits
|
||||
`openapi: '3.1.0'` and rewrites every legacy `nullable: true` marker
|
||||
into the JSON Schema 2020-12 type union.
|
||||
|
||||
Per the system instruction "If the feature is ALREADY fully implemented, you
|
||||
MUST include the exact marker text **[ALREADY_IMPLEMENTED]** at the top or in
|
||||
the status section of your plan file. Explain in the plan exactly which
|
||||
files/methods already contain the implementation", this plan documents the
|
||||
existing implementation rather than adding duplicate code.
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
|
||||
- **Migration** (`backend/src/database/migrations/1700000000000-InitSchema.ts:7-8`):
|
||||
```ts
|
||||
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
|
||||
await queryRunner.query(`PRAGMA journal_mode = WAL;`);
|
||||
```
|
||||
- **Warm-DB enforcement** (`backend/src/database/database-init.service.ts:34-78`):
|
||||
- `init()` calls `ensureWalMode()` after `dataSource.initialize()`.
|
||||
- `ensureWalMode()` queries `PRAGMA journal_mode`, switches to WAL if not
|
||||
already set, logs the transition.
|
||||
- **OpenAPI 3.1 pipeline** (`backend/src/main.ts:83-94`):
|
||||
```ts
|
||||
const swaggerConfig = new DocumentBuilder()...
|
||||
const rawDocument = SwaggerModule.createDocument(app, swaggerConfig) as Record<string, any>;
|
||||
const document = toOpenApi31(rawDocument);
|
||||
SwaggerModule.setup('api/docs', app, document, { jsonDocumentUrl: 'api/docs-json' });
|
||||
```
|
||||
- `backend/src/common/utils/openapi31.ts` exports `toOpenApi31(doc)`.
|
||||
- Rewrites `nullable: true` → `type: ['<orig>', 'null']` recursively in
|
||||
`components.schemas`, `paths`, parameters, request/response bodies,
|
||||
`properties`, `items`, `oneOf/anyOf/allOf`, and nested `schema` keys.
|
||||
- Preserves `$ref` nodes.
|
||||
- **Tests** (`tests/backend/openapi31.spec.ts`, 7 tests): all pass.
|
||||
- **Full suite**: 83/83 tests across 18 suites.
|
||||
|
||||
## 2. Impacted Files
|
||||
|
||||
None — all changes are already in place at HEAD.
|
||||
|
||||
| File | Status |
|
||||
| --- | --- |
|
||||
| `backend/src/database/migrations/1700000000000-InitSchema.ts` | ✅ line 8 |
|
||||
| `backend/src/database/database-init.service.ts` | ✅ lines 34-78 |
|
||||
| `backend/src/common/utils/openapi31.ts` | ✅ exists (created in `de27a8b`) |
|
||||
| `backend/src/main.ts` | ✅ lines 83-94 |
|
||||
| `tests/backend/openapi31.spec.ts` | ✅ exists, 7 tests passing |
|
||||
|
||||
## 3. Proposed Changes
|
||||
|
||||
No code changes required. If a follow-up task wants to extend the existing
|
||||
implementation (e.g., add an `info.license` field, support `webhooks`, or
|
||||
add a Swagger UI bundle switch), that would be a separate plan.
|
||||
|
||||
## 4. Test Strategy
|
||||
|
||||
- The existing `tests/backend/openapi31.spec.ts` (7 tests) locks the
|
||||
conversion behaviour.
|
||||
- The full suite `npm test` passes 83/83 across 18 suites.
|
||||
- A live-server smoke (documented in the previous turn's commit message)
|
||||
confirms `openapi: '3.1.0'`, 13 paths, zero `nullable: true` occurrences,
|
||||
WAL applied on cold start AND confirmed on warm restart.
|
||||
|
||||
## 5. Persistent `/data` Usage
|
||||
|
||||
No change. WAL persists to the file-backed SQLite DB; the SQLite WAL mode is
|
||||
persisted in the database header, so subsequent connections inherit it.
|
||||
|
||||
## 6. Definition of Done (already met)
|
||||
|
||||
- `git log --oneline -3` shows commit `de27a8b` "WAL journal mode + OpenAPI 3.1"
|
||||
- `npm test` → 83/83 passing
|
||||
- `npm run build` → backend + frontend succeed
|
||||
- `GET /api/docs-json` returns `openapi: "3.1.0"`
|
||||
- Cold start log: `PRAGMA journal_mode set (was='delete', now='wal')`
|
||||
- Warm start log: `PRAGMA journal_mode=wal (already set)`
|
||||
- Zero `nullable: true` occurrences in the served JSON document
|
||||
|
||||
**[ALREADY_IMPLEMENTED]** — All three requested changes exist on the branch
|
||||
in commits `de27a8b` (WAL + OpenAPI 3.1). No further code changes are needed
|
||||
unless the reviewer wants to extend the implementation with additional
|
||||
OpenAPI 3.1 features (webhooks, license, contact, etc.) or additional SQLite
|
||||
PRAGMAs.
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "HIPCTF NestJS backend",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "node dist/main.js",
|
||||
"start:dev": "ts-node -r tsconfig-paths/register src/main.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.3.10",
|
||||
"@nestjs/config": "^3.2.3",
|
||||
"@nestjs/core": "^10.3.10",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.10",
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"argon2": "^0.40.1",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"helmet": "^7.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.20",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^10.4.22",
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
|
||||
import { validateEnv } from './config/env.schema';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { CommonModule } from './common/common.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { SystemModule } from './modules/system/system.module';
|
||||
import { SettingsModule } from './modules/settings/settings.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
import { UploadsModule } from './modules/uploads/uploads.module';
|
||||
import { FrontendModule } from './frontend/frontend.module';
|
||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
cache: true,
|
||||
validate: validateEnv,
|
||||
}),
|
||||
DatabaseModule,
|
||||
CommonModule,
|
||||
SettingsModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
SystemModule,
|
||||
AdminModule,
|
||||
UploadsModule,
|
||||
FrontendModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { ThemeLoaderService } from './utils/theme-loader.service';
|
||||
import { EventStatusService } from './services/event-status.service';
|
||||
import { SseHubService } from './services/sse-hub.service';
|
||||
import { LoginBackoffService } from './services/login-backoff.service';
|
||||
import { RegistrationRateLimitService } from './services/registration-rate-limit.service';
|
||||
import { CsrfMiddleware } from './middleware/csrf.middleware';
|
||||
import { SettingsModule } from '../modules/settings/settings.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [SettingsModule],
|
||||
providers: [
|
||||
ThemeLoaderService,
|
||||
EventStatusService,
|
||||
SseHubService,
|
||||
LoginBackoffService,
|
||||
RegistrationRateLimitService,
|
||||
CsrfMiddleware,
|
||||
],
|
||||
exports: [
|
||||
ThemeLoaderService,
|
||||
EventStatusService,
|
||||
SseHubService,
|
||||
LoginBackoffService,
|
||||
RegistrationRateLimitService,
|
||||
CsrfMiddleware,
|
||||
],
|
||||
})
|
||||
export class CommonModule {}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '../../database/entities/user.entity';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const SKIP_CSRF_KEY = 'skipCsrf';
|
||||
export const SkipCsrf = () => SetMetadata(SKIP_CSRF_KEY, true);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ErrorCode, ERROR_CODES } from './error-codes';
|
||||
|
||||
export class ApiError extends HttpException {
|
||||
constructor(
|
||||
public readonly code: ErrorCode,
|
||||
message: string,
|
||||
status: HttpStatus,
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super({ code, message, details }, status);
|
||||
}
|
||||
|
||||
static validation(message = 'Validation failed', details?: unknown): ApiError {
|
||||
return new ApiError(ERROR_CODES.VALIDATION_FAILED, message, HttpStatus.BAD_REQUEST, details);
|
||||
}
|
||||
|
||||
static unauthorized(message = 'Unauthorized'): ApiError {
|
||||
return new ApiError(ERROR_CODES.UNAUTHORIZED, message, HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
static forbidden(message = 'Forbidden'): ApiError {
|
||||
return new ApiError(ERROR_CODES.FORBIDDEN, message, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
static notFound(message = 'Not found'): ApiError {
|
||||
return new ApiError(ERROR_CODES.NOT_FOUND, message, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
static conflict(code: ErrorCode, message: string): ApiError {
|
||||
return new ApiError(code, message, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
static internal(message = 'Internal server error'): ApiError {
|
||||
return new ApiError(ERROR_CODES.INTERNAL, message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
const req = context.switchToHttp().getRequest();
|
||||
if (!req.user) throw new ForbiddenException('Authentication required');
|
||||
if (!required || required.length === 0) return true;
|
||||
if (!required.includes(req.user.role)) {
|
||||
throw new ForbiddenException('Admin role required');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
import { UserRole } from '../../database/entities/user.entity';
|
||||
import { ApiError } from '../errors/api-error';
|
||||
import { ERROR_CODES } from '../errors/error-codes';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!required || required.length === 0) return true;
|
||||
const req = context.switchToHttp().getRequest();
|
||||
if (!req.user) throw ApiError.unauthorized();
|
||||
if (!required.includes(req.user.role)) {
|
||||
throw new ForbiddenException('Insufficient role');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
return next.handle().pipe(map((data) => ({ data })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const THEME_IDS = [
|
||||
'classic',
|
||||
'midnight',
|
||||
'sunset',
|
||||
'forest',
|
||||
'cyber',
|
||||
'paper',
|
||||
'crimson',
|
||||
'ocean',
|
||||
'neon',
|
||||
'monochrome',
|
||||
] as const;
|
||||
|
||||
export type ThemeId = (typeof THEME_IDS)[number];
|
||||
@@ -0,0 +1,19 @@
|
||||
export type ThemeTokens = {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
surface: string;
|
||||
text: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
danger: string;
|
||||
fontFamily: string;
|
||||
radii: { sm: string; md: string; lg: string };
|
||||
spacingScale: number[];
|
||||
};
|
||||
|
||||
export type Theme = {
|
||||
id: string;
|
||||
name: string;
|
||||
tokens: ThemeTokens;
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { OpenAPIObject } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Convert a NestJS-generated OpenAPI 3.0 document to a JSON Schema 2020-12-
|
||||
* compatible OpenAPI 3.1 document.
|
||||
*
|
||||
* The two specs differ mainly in:
|
||||
* - `openapi` version string.
|
||||
* - JSON Schema dialect: 3.0 used a custom subset of JSON Schema Draft 5
|
||||
* (with `nullable: true`); 3.1 uses JSON Schema 2020-12 natively
|
||||
* (nullable via `type: ['T', 'null']`).
|
||||
* - `info` no longer requires `license` / `contact`.
|
||||
*
|
||||
* The `paths`/`components`/`security` structures are otherwise identical,
|
||||
* and we keep all NestJS-generated content intact.
|
||||
*/
|
||||
export function toOpenApi31(doc: Record<string, any>): OpenAPIObject {
|
||||
const next: Record<string, any> = { ...doc, openapi: '3.1.0' };
|
||||
|
||||
// Normalize info so it has every required 3.1 field.
|
||||
if (!next.info || typeof next.info !== 'object') {
|
||||
next.info = { title: 'HIPCTF API', version: '1.0.0' };
|
||||
} else {
|
||||
next.info = {
|
||||
title: next.info.title ?? 'HIPCTF API',
|
||||
version: next.info.version ?? '1.0.0',
|
||||
...(next.info.description ? { description: next.info.description } : {}),
|
||||
...(next.info.contact ? { contact: next.info.contact } : {}),
|
||||
...(next.info.license ? { license: next.info.license } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Rewrite component schemas: replace `nullable: true` with the JSON
|
||||
// Schema 2020-12 `type: ['<orig>', 'null']` union. Leave everything else
|
||||
// untouched so existing component definitions remain valid.
|
||||
const components = next.components ?? {};
|
||||
const schemas = components.schemas ?? {};
|
||||
for (const [name, schema] of Object.entries<any>(schemas)) {
|
||||
schemas[name] = rewriteNullable(schema);
|
||||
}
|
||||
components.schemas = schemas;
|
||||
next.components = components;
|
||||
|
||||
// Walk paths and parameters and rewrite any remaining `nullable: true`.
|
||||
if (next.paths && typeof next.paths === 'object') {
|
||||
for (const [pathKey, methods] of Object.entries<any>(next.paths)) {
|
||||
for (const [method, op] of Object.entries<any>(methods ?? {})) {
|
||||
if (!op || typeof op !== 'object') continue;
|
||||
if (Array.isArray(op.parameters)) {
|
||||
op.parameters = op.parameters.map((p: any) => rewriteNullable(p));
|
||||
}
|
||||
if (op.requestBody) op.requestBody = rewriteNullable(op.requestBody);
|
||||
const body = op.requestBody?.content;
|
||||
if (body && typeof body === 'object') {
|
||||
for (const media of Object.values<any>(body)) {
|
||||
if (media?.schema) media.schema = rewriteNullable(media.schema);
|
||||
}
|
||||
}
|
||||
if (op.responses && typeof op.responses === 'object') {
|
||||
for (const [code, resp] of Object.entries<any>(op.responses)) {
|
||||
op.responses[code] = rewriteNullable(resp);
|
||||
const respBody = op.responses[code]?.content;
|
||||
if (respBody && typeof respBody === 'object') {
|
||||
for (const media of Object.values<any>(respBody)) {
|
||||
if (media?.schema) media.schema = rewriteNullable(media.schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next as OpenAPIObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk a schema and convert legacy `nullable: true` markers
|
||||
* into JSON Schema 2020-12 `type: ['<orig>', 'null']` unions. Preserve any
|
||||
* other keyword (`$ref`, `format`, `enum`, etc.).
|
||||
*/
|
||||
function rewriteNullable(node: any): any {
|
||||
if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
|
||||
|
||||
// If this is a $ref, leave it alone.
|
||||
if (typeof node.$ref === 'string') return node;
|
||||
|
||||
const out: Record<string, any> = { ...node };
|
||||
|
||||
if (out.nullable === true) {
|
||||
delete out.nullable;
|
||||
if (typeof out.type === 'string') {
|
||||
out.type = [out.type, 'null'];
|
||||
} else if (Array.isArray(out.type)) {
|
||||
if (!out.type.includes('null')) out.type = [...out.type, 'null'];
|
||||
} else if (!out.oneOf && !out.anyOf && !out.allOf) {
|
||||
out.type = ['null'];
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of Object.keys(out)) {
|
||||
if (k === 'properties' && out.properties && typeof out.properties === 'object') {
|
||||
for (const p of Object.keys(out.properties)) {
|
||||
out.properties[p] = rewriteNullable(out.properties[p]);
|
||||
}
|
||||
} else if (k === 'items') {
|
||||
out.items = rewriteNullable(out.items);
|
||||
} else if (k === 'schema') {
|
||||
out.schema = rewriteNullable(out.schema);
|
||||
} else if ((k === 'oneOf' || k === 'anyOf' || k === 'allOf') && Array.isArray(out[k])) {
|
||||
out[k] = out[k].map((v: any) => rewriteNullable(v));
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ApiError } from '../errors/api-error';
|
||||
import { ERROR_CODES } from '../errors/error-codes';
|
||||
|
||||
export function validatePassword(password: string, config: ConfigService): void {
|
||||
const minLen = config.get<number>('PASSWORD_MIN_LENGTH', 12);
|
||||
if (typeof password !== 'string' || password.length < minLen) {
|
||||
throw new ApiError(ERROR_CODES.WEAK_PASSWORD, `Password must be at least ${minLen} characters`, 400);
|
||||
}
|
||||
const requireMixed = config.get<boolean>('PASSWORD_REQUIRE_MIXED', true);
|
||||
if (requireMixed) {
|
||||
const hasUpper = /[A-Z]/.test(password);
|
||||
const hasLower = /[a-z]/.test(password);
|
||||
const hasDigit = /[0-9]/.test(password);
|
||||
const hasSymbol = /[^A-Za-z0-9]/.test(password);
|
||||
if (!(hasUpper && hasLower && hasDigit && hasSymbol)) {
|
||||
throw new ApiError(ERROR_CODES.WEAK_PASSWORD, 'Password must include upper, lower, digit, and symbol', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { THEME_IDS, ThemeId } from '../types/theme-ids';
|
||||
import { Theme, ThemeTokens } from '../types/theme';
|
||||
import { SettingsService } from '../../modules/settings/settings.module';
|
||||
import { SETTINGS_KEYS } from '../../config/env.schema';
|
||||
|
||||
@Injectable()
|
||||
export class ThemeLoaderService implements OnModuleInit {
|
||||
private readonly logger = new Logger(ThemeLoaderService.name);
|
||||
private themes: Map<string, Theme> = new Map();
|
||||
private defaultThemeId: ThemeId = 'classic';
|
||||
private themesDir = './themes';
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
this.themesDir = this.config.get<string>('THEMES_DIR', './themes');
|
||||
this.loadFromDisk();
|
||||
this.backfillMissing();
|
||||
await this.validateConfiguredKey();
|
||||
this.logger.log(`Loaded ${this.themes.size} themes; default='${this.defaultThemeId}'`);
|
||||
}
|
||||
|
||||
private loadFromDisk(): void {
|
||||
const absDir = path.resolve(this.themesDir);
|
||||
if (!fs.existsSync(absDir)) {
|
||||
this.logger.warn(`Themes directory not found at ${absDir}; will backfill from built-ins`);
|
||||
return;
|
||||
}
|
||||
const files = fs.readdirSync(absDir).filter((f) => f.endsWith('.json'));
|
||||
const validIds = new Set<string>(THEME_IDS);
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(absDir, file), 'utf-8');
|
||||
const parsed = JSON.parse(raw) as Theme;
|
||||
if (!parsed.id || !validIds.has(parsed.id)) {
|
||||
this.logger.warn(`Skipping theme file ${file}: invalid or unknown id '${parsed.id}'`);
|
||||
continue;
|
||||
}
|
||||
if (!parsed.tokens || !this.validateTokens(parsed.tokens)) {
|
||||
this.logger.warn(`Skipping theme file ${file}: invalid tokens`);
|
||||
continue;
|
||||
}
|
||||
this.themes.set(parsed.id, parsed);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to parse theme file ${file}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After disk-loading, backfill any of the 10 canonical themes that are
|
||||
* missing with the matching built-in. This guarantees the service
|
||||
* ALWAYS exposes all 10 theme ids regardless of disk state.
|
||||
*/
|
||||
private backfillMissing(): void {
|
||||
for (const t of BUILTIN_THEMES) {
|
||||
if (!this.themes.has(t.id)) {
|
||||
this.logger.warn(`Theme '${t.id}' missing from disk; using built-in default`);
|
||||
this.themes.set(t.id, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the configured theme key (from settings) against the 10
|
||||
* canonical ids. If it's missing or invalid, fall back to 'classic' with
|
||||
* a warning. Persists the canonical value back to settings so future
|
||||
* reads are consistent.
|
||||
*/
|
||||
private async validateConfiguredKey(): Promise<void> {
|
||||
let configured = 'classic';
|
||||
try {
|
||||
configured = await this.settings.get(SETTINGS_KEYS.THEME_KEY, 'classic');
|
||||
} catch (e) {
|
||||
// The setting table may not exist yet (early boot, migrations not
|
||||
// run). Fall back to the safe default and try again later.
|
||||
this.logger.warn(`Could not read configured themeKey (${(e as Error).message}); falling back to 'classic'`);
|
||||
this.defaultThemeId = 'classic';
|
||||
return;
|
||||
}
|
||||
const validIds = new Set<string>(THEME_IDS);
|
||||
if (!configured || !validIds.has(configured)) {
|
||||
this.logger.warn(`Configured themeKey '${configured}' is not in [${THEME_IDS.join(',')}]; falling back to 'classic'`);
|
||||
this.defaultThemeId = 'classic';
|
||||
try {
|
||||
await this.settings.set(SETTINGS_KEYS.THEME_KEY, 'classic');
|
||||
} catch {
|
||||
// Setting table may still not exist; ignore.
|
||||
}
|
||||
} else {
|
||||
this.defaultThemeId = configured as ThemeId;
|
||||
}
|
||||
}
|
||||
|
||||
private validateTokens(t: ThemeTokens): boolean {
|
||||
const required = ['primary', 'secondary', 'accent', 'surface', 'text', 'success', 'warning', 'danger', 'fontFamily', 'radii', 'spacingScale'];
|
||||
for (const k of required) {
|
||||
if (!(k in t)) return false;
|
||||
}
|
||||
if (!t.radii.sm || !t.radii.md || !t.radii.lg) return false;
|
||||
if (!Array.isArray(t.spacingScale) || t.spacingScale.length === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
getTheme(id: string | null | undefined): Theme {
|
||||
if (!id) return this.themes.get(this.defaultThemeId)!;
|
||||
const t = this.themes.get(id);
|
||||
if (t) return t;
|
||||
this.logger.warn(`Unknown theme id '${id}'; falling back to '${this.defaultThemeId}'`);
|
||||
return this.themes.get(this.defaultThemeId)!;
|
||||
}
|
||||
|
||||
getDefaultTheme(): Theme {
|
||||
return this.themes.get(this.defaultThemeId)!;
|
||||
}
|
||||
|
||||
listThemes(): Theme[] {
|
||||
return Array.from(this.themes.values());
|
||||
}
|
||||
|
||||
/** Test/internal accessor for the resolved default id. */
|
||||
getDefaultId(): ThemeId {
|
||||
return this.defaultThemeId;
|
||||
}
|
||||
}
|
||||
|
||||
export const BUILTIN_THEMES: Theme[] = [
|
||||
{
|
||||
id: 'classic',
|
||||
name: 'Classic',
|
||||
tokens: {
|
||||
primary: '#3b82f6', secondary: '#64748b', accent: '#f59e0b',
|
||||
surface: '#ffffff', text: '#0f172a',
|
||||
success: '#10b981', warning: '#f59e0b', danger: '#ef4444',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
radii: { sm: '4px', md: '8px', lg: '16px' },
|
||||
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'midnight',
|
||||
name: 'Midnight',
|
||||
tokens: {
|
||||
primary: '#6366f1', secondary: '#1e293b', accent: '#22d3ee',
|
||||
surface: '#0f172a', text: '#e2e8f0',
|
||||
success: '#34d399', warning: '#fbbf24', danger: '#f87171',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
radii: { sm: '4px', md: '8px', lg: '16px' },
|
||||
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
name: 'Sunset',
|
||||
tokens: {
|
||||
primary: '#f97316', secondary: '#fb7185', accent: '#facc15',
|
||||
surface: '#fff7ed', text: '#431407',
|
||||
success: '#84cc16', warning: '#f59e0b', danger: '#dc2626',
|
||||
fontFamily: 'Georgia, serif',
|
||||
radii: { sm: '6px', md: '12px', lg: '24px' },
|
||||
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
name: 'Forest',
|
||||
tokens: {
|
||||
primary: '#16a34a', secondary: '#65a30d', accent: '#ca8a04',
|
||||
surface: '#f0fdf4', text: '#14532d',
|
||||
success: '#15803d', warning: '#ca8a04', danger: '#b91c1c',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
radii: { sm: '4px', md: '8px', lg: '12px' },
|
||||
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cyber',
|
||||
name: 'Cyber',
|
||||
tokens: {
|
||||
primary: '#00ffd5', secondary: '#ff00aa', accent: '#ffee00',
|
||||
surface: '#000000', text: '#00ffd5',
|
||||
success: '#00ff88', warning: '#ffee00', danger: '#ff0066',
|
||||
fontFamily: 'Courier New, monospace',
|
||||
radii: { sm: '0', md: '0', lg: '0' },
|
||||
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'paper',
|
||||
name: 'Paper',
|
||||
tokens: {
|
||||
primary: '#1f2937', secondary: '#6b7280', accent: '#0ea5e9',
|
||||
surface: '#fafaf9', text: '#1c1917',
|
||||
success: '#059669', warning: '#d97706', danger: '#b91c1c',
|
||||
fontFamily: 'Georgia, serif',
|
||||
radii: { sm: '2px', md: '4px', lg: '8px' },
|
||||
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'crimson',
|
||||
name: 'Crimson',
|
||||
tokens: {
|
||||
primary: '#dc2626', secondary: '#991b1b', accent: '#fbbf24',
|
||||
surface: '#1f1f1f', text: '#fef2f2',
|
||||
success: '#22c55e', warning: '#fbbf24', danger: '#ef4444',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
radii: { sm: '4px', md: '8px', lg: '12px' },
|
||||
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ocean',
|
||||
name: 'Ocean',
|
||||
tokens: {
|
||||
primary: '#0ea5e9', secondary: '#06b6d4', accent: '#14b8a6',
|
||||
surface: '#f0f9ff', text: '#0c4a6e',
|
||||
success: '#10b981', warning: '#f59e0b', danger: '#ef4444',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
radii: { sm: '8px', md: '16px', lg: '24px' },
|
||||
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'neon',
|
||||
name: 'Neon',
|
||||
tokens: {
|
||||
primary: '#a855f7', secondary: '#ec4899', accent: '#22d3ee',
|
||||
surface: '#0a0a0a', text: '#f5f3ff',
|
||||
success: '#4ade80', warning: '#fbbf24', danger: '#fb7185',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
radii: { sm: '4px', md: '12px', lg: '20px' },
|
||||
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'monochrome',
|
||||
name: 'Monochrome',
|
||||
tokens: {
|
||||
primary: '#000000', secondary: '#525252', accent: '#a3a3a3',
|
||||
surface: '#ffffff', text: '#0a0a0a',
|
||||
success: '#404040', warning: '#737373', danger: '#171717',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
radii: { sm: '0', md: '0', lg: '0' },
|
||||
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as crypto from 'crypto';
|
||||
import multer from 'multer';
|
||||
|
||||
export interface UploadLimits {
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a multer-style `limits` object from the UPLOAD_SIZE_LIMIT env value.
|
||||
* Examples: '50mb', '500kb', '2gb'. Defaults to '50mb'.
|
||||
*/
|
||||
export function parseUploadSizeLimit(value: string | undefined): number {
|
||||
const v = (value ?? '50mb').trim().toLowerCase();
|
||||
const m = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)$/.exec(v);
|
||||
if (!m) return 50 * 1024 * 1024;
|
||||
const n = parseFloat(m[1]);
|
||||
const unit = m[2];
|
||||
const mult = unit === 'b' ? 1 : unit === 'kb' ? 1024 : unit === 'mb' ? 1024 * 1024 : 1024 * 1024 * 1024;
|
||||
return Math.floor(n * mult);
|
||||
}
|
||||
|
||||
export function buildUploadLimits(config: ConfigService): UploadLimits {
|
||||
const v = config.get<string>('UPLOAD_SIZE_LIMIT', '50mb');
|
||||
return { fileSize: parseUploadSizeLimit(v) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a safe, collision-resistant filename from the original upload
|
||||
* filename. Strips directory traversal, lowercases, restricts to a safe
|
||||
* charset, caps the stem length, and appends a short random suffix so
|
||||
* identical names from different uploads never overwrite each other.
|
||||
*/
|
||||
export function safeFilename(original: string): string {
|
||||
const base = path.basename(original || 'file').toLowerCase();
|
||||
const cleaned = base
|
||||
.replace(/[^a-z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 120);
|
||||
const stem = cleaned || 'file';
|
||||
const dot = stem.lastIndexOf('.');
|
||||
const head = dot > 0 ? stem.slice(0, dot) : stem;
|
||||
const ext = dot > 0 ? stem.slice(dot) : '';
|
||||
const suffix = crypto.randomBytes(4).toString('hex');
|
||||
const safeHead = (head || 'file').slice(0, 100);
|
||||
return `${safeHead}-${suffix}${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a configured Multer instance with a per-route `fileSize` limit
|
||||
* (taken from `UPLOAD_SIZE_LIMIT`) and safe-filename storage at the
|
||||
* supplied destination directory.
|
||||
*/
|
||||
export interface MulterOptions {
|
||||
destination?: string;
|
||||
fieldName?: string;
|
||||
fileSize?: number;
|
||||
}
|
||||
|
||||
export function buildMulter(config: ConfigService, opts: MulterOptions = {}): multer.Multer {
|
||||
const limits = opts.fileSize !== undefined
|
||||
? { fileSize: opts.fileSize }
|
||||
: buildUploadLimits(config);
|
||||
const dest = opts.destination ?? path.resolve(config.get<string>('UPLOAD_DIR', '/data/hipctf/uploads'));
|
||||
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
||||
return multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, dest),
|
||||
filename: (_req, file, cb) => cb(null, safeFilename(file.originalname)),
|
||||
}),
|
||||
limits,
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseInitService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(DatabaseInitService.name);
|
||||
private initialized = false;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Explicit, awaitable database initialization.
|
||||
*
|
||||
* - Initializes the DataSource (idempotent — TypeORM returns the existing
|
||||
* connection if already initialized).
|
||||
* - Runs all pending migrations in order.
|
||||
* - Verifies that the expected schema and seeded data are present.
|
||||
*
|
||||
* `main.ts` calls this BEFORE `app.listen()` so the HTTP server never
|
||||
* accepts traffic until the DB is fully migrated and seeded.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
if (!this.dataSource.isInitialized) {
|
||||
await this.dataSource.initialize();
|
||||
}
|
||||
|
||||
// Enforce WAL journal mode on every startup. The InitSchema migration
|
||||
// also sets it for fresh DBs, but warm DBs (where migrations are
|
||||
// skipped) need it applied here so PRAGMA journal_mode is always 'wal'.
|
||||
await this.ensureWalMode();
|
||||
|
||||
const pending = await this.dataSource.showMigrations();
|
||||
const pendingList: string[] = Array.isArray(pending) ? pending : [];
|
||||
this.logger.log(`Pending migrations: ${pendingList.length}`);
|
||||
if (pendingList.length > 0 || !(await this.hasCategoryTable())) {
|
||||
const ran = await this.dataSource.runMigrations({ transaction: 'each' });
|
||||
this.logger.log(`Ran ${ran.length} migration(s): ${ran.map((m) => m.name).join(', ')}`);
|
||||
} else {
|
||||
this.logger.log('Database schema is up to date');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.verifySeed();
|
||||
} catch (e) {
|
||||
this.logger.warn(`verifySeed skipped: ${(e as Error).message}`);
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read PRAGMA journal_mode and switch to WAL if not already enabled.
|
||||
* Persisted for file-backed databases; idempotent and safe on every
|
||||
* startup.
|
||||
*/
|
||||
private async ensureWalMode(): Promise<void> {
|
||||
try {
|
||||
const rows: any[] = await this.dataSource.query('PRAGMA journal_mode');
|
||||
const current = String(rows?.[0]?.journal_mode ?? '').toLowerCase();
|
||||
if (current === 'wal') {
|
||||
this.logger.log('PRAGMA journal_mode=wal (already set)');
|
||||
return;
|
||||
}
|
||||
const setRows: any[] = await this.dataSource.query('PRAGMA journal_mode = WAL');
|
||||
const actual = String(setRows?.[0]?.journal_mode ?? '').toLowerCase();
|
||||
this.logger.log(`PRAGMA journal_mode set (was='${current}', now='${actual}')`);
|
||||
} catch (e) {
|
||||
this.logger.warn(`ensureWalMode skipped: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async verifySeed(): Promise<void> {
|
||||
const categoryCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "category"').then((rows: any[]) => Number(rows[0]?.c ?? 0));
|
||||
const settingsCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "setting"').then((rows: any[]) => Number(rows[0]?.c ?? 0));
|
||||
this.logger.log(`Seed verified: ${categoryCount} categories, ${settingsCount} settings`);
|
||||
if (categoryCount < 6) {
|
||||
this.logger.warn(`Expected at least 6 seeded categories, found ${categoryCount}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async hasCategoryTable(): Promise<boolean> {
|
||||
try {
|
||||
const rows: any[] = await this.dataSource.query("SELECT name FROM sqlite_master WHERE type='table' AND name='category'");
|
||||
return rows.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getDbPath(): string {
|
||||
return this.config.get<string>('DATABASE_PATH', '/data/hipctf/db.sqlite');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Module, Global, Logger } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { UserEntity } from './entities/user.entity';
|
||||
import { SettingEntity } from './entities/setting.entity';
|
||||
import { CategoryEntity } from './entities/category.entity';
|
||||
import { ChallengeEntity } from './entities/challenge.entity';
|
||||
import { ChallengeFileEntity } from './entities/challenge-file.entity';
|
||||
import { SolveEntity } from './entities/solve.entity';
|
||||
import { RefreshTokenEntity } from './entities/refresh-token.entity';
|
||||
import { BlogPostEntity } from './entities/blog-post.entity';
|
||||
import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema';
|
||||
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
|
||||
import { DatabaseInitService } from './database-init.service';
|
||||
|
||||
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: false,
|
||||
synchronize: false,
|
||||
logging: false,
|
||||
};
|
||||
},
|
||||
}),
|
||||
TypeOrmModule.forFeature(ENTITIES),
|
||||
],
|
||||
providers: [DatabaseInitService],
|
||||
exports: [TypeOrmModule, DatabaseInitService],
|
||||
})
|
||||
export class DatabaseModule {
|
||||
private readonly logger = new Logger(DatabaseModule.name);
|
||||
}
|
||||
@@ -0,0 +1,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,124 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class InitSchema1700000000000 implements MigrationInterface {
|
||||
name = 'InitSchema1700000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
|
||||
await queryRunner.query(`PRAGMA journal_mode = WAL;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "user" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"username" TEXT NOT NULL UNIQUE,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'player',
|
||||
"status" TEXT NOT NULL DEFAULT 'enabled',
|
||||
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "setting" (
|
||||
"key" TEXT PRIMARY KEY,
|
||||
"value" TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "category" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"system_key" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"abbreviation" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"icon_path" TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "challenge" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description_md" TEXT NOT NULL DEFAULT '',
|
||||
"category_id" TEXT NOT NULL,
|
||||
"difficulty" TEXT NOT NULL DEFAULT 'low',
|
||||
"initial_points" INTEGER NOT NULL DEFAULT 0,
|
||||
"minimum_points" INTEGER NOT NULL DEFAULT 0,
|
||||
"decay_solves" INTEGER NOT NULL DEFAULT 0,
|
||||
"flag" TEXT NOT NULL DEFAULT '',
|
||||
"protocol" TEXT NOT NULL DEFAULT 'nc',
|
||||
"port" INTEGER,
|
||||
"ip_address" TEXT NOT NULL DEFAULT '',
|
||||
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
CONSTRAINT "fk_challenge_category" FOREIGN KEY ("category_id") REFERENCES "category"("id") ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_challenge_category" ON "challenge"("category_id");`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "challenge_file" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"challenge_id" TEXT NOT NULL,
|
||||
"original_filename" TEXT NOT NULL,
|
||||
"stored_path" TEXT NOT NULL,
|
||||
CONSTRAINT "fk_challenge_file_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_challenge_file_challenge" ON "challenge_file"("challenge_id");`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "solve" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"challenge_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"solved_at" TEXT NOT NULL,
|
||||
"points_awarded" INTEGER NOT NULL DEFAULT 0,
|
||||
"base_points" INTEGER NOT NULL DEFAULT 0,
|
||||
"rank_bonus" INTEGER NOT NULL DEFAULT 0,
|
||||
CONSTRAINT "fk_solve_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "fk_solve_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE RESTRICT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_solve_challenge_user" ON "solve"("challenge_id","user_id");`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_user" ON "solve"("user_id");`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_challenge" ON "solve"("challenge_id");`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "refresh_token" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL UNIQUE,
|
||||
"issued_at" TEXT NOT NULL,
|
||||
"expires_at" TEXT NOT NULL,
|
||||
"revoked_at" TEXT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_refresh_token_user" ON "refresh_token"("user_id");`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "blog_post" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"title" TEXT NOT NULL,
|
||||
"body_md" TEXT NOT NULL DEFAULT '',
|
||||
"status" TEXT NOT NULL DEFAULT 'draft',
|
||||
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
"updated_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
"published_at" TEXT
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_blog_status_published" ON "blog_post"("status","published_at");`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "blog_post";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "refresh_token";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "solve";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "challenge_file";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "challenge";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "category";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "setting";`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "user";`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { SYSTEM_CATEGORY_KEYS, SYSTEM_CATEGORY_META, DEFAULT_SETTINGS, SETTINGS_KEYS } from '../../config/env.schema';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export class SeedSystemData1700000000100 implements MigrationInterface {
|
||||
name = 'SeedSystemData1700000000100';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const key of SYSTEM_CATEGORY_KEYS) {
|
||||
const meta = SYSTEM_CATEGORY_META[key];
|
||||
const existing = await queryRunner.query(`SELECT id FROM "category" WHERE "system_key" = ?`, [key]);
|
||||
if (existing.length > 0) continue;
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path") VALUES (?,?,?,?,?,?)`,
|
||||
[uuid(), key, meta.name, meta.abbreviation, meta.description, meta.iconPath],
|
||||
);
|
||||
}
|
||||
|
||||
for (const key of Object.values(SETTINGS_KEYS)) {
|
||||
const existing = await queryRunner.query(`SELECT "key" FROM "setting" WHERE "key" = ?`, [key]);
|
||||
if (existing.length > 0) continue;
|
||||
const value = DEFAULT_SETTINGS[key] ?? '';
|
||||
await queryRunner.query(`INSERT INTO "setting" ("key","value") VALUES (?,?)`, [key, value]);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DELETE FROM "category" WHERE "system_key" IS NOT NULL;`);
|
||||
await queryRunner.query(`DELETE FROM "setting";`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
})
|
||||
export class FrontendModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class SpaFallbackMiddleware implements NestMiddleware {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction): void {
|
||||
const url = req.path || req.url;
|
||||
if (url.startsWith('/api') || url.startsWith('/uploads')) return next();
|
||||
if (path.extname(url)) return next();
|
||||
if (req.method !== 'GET') return next();
|
||||
|
||||
const distDir = path.resolve(this.config.get<string>('FRONTEND_DIST', '../frontend/dist'));
|
||||
const candidates = [distDir, path.join(distDir, 'browser')];
|
||||
for (const dir of candidates) {
|
||||
const p = path.join(dir, 'index.html');
|
||||
if (fs.existsSync(p)) {
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
fs.createReadStream(p).pipe(res);
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.status(200).send('<!doctype html><html><body><h1>HIPCTF</h1><p>Frontend not built.</p></body></html>');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class UploadsStaticMiddleware implements NestMiddleware {
|
||||
private rootDir = '/tmp';
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
const dir = path.resolve(config.get<string>('UPLOAD_DIR', '/data/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { toOpenApi31 } from './common/utils/openapi31';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as express from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import { SpaFallbackMiddleware } from './frontend/spa.controller';
|
||||
import { DatabaseInitService } from './database/database-init.service';
|
||||
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
bufferLogs: false,
|
||||
});
|
||||
|
||||
const config = app.get(ConfigService);
|
||||
const nodeEnv = config.get<string>('NODE_ENV', 'development');
|
||||
const port = config.get<number>('PORT', 3000);
|
||||
const corsOrigins = (config.get<string>('CORS_ORIGINS', 'http://localhost:4200,http://localhost:3000'))
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const bodyLimit = config.get<string>('BODY_SIZE_LIMIT', '1mb');
|
||||
const uploadDir = path.resolve(config.get<string>('UPLOAD_DIR', '/data/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 });
|
||||
|
||||
await app.get(DatabaseInitService).init();
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
objectSrc: ["'none'"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
},
|
||||
hsts: tlsEnabled ? { maxAge: 31536000, includeSubDomains: true, preload: true } : false,
|
||||
frameguard: { action: 'deny' },
|
||||
noSniff: true,
|
||||
referrerPolicy: { policy: 'no-referrer' },
|
||||
}),
|
||||
);
|
||||
|
||||
app.enableCors({
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
});
|
||||
|
||||
// Body parsers MUST run before the CSRF middleware so that route handlers
|
||||
// can read `req.body` when the CsrfMiddleware short-circuits to next().
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: bodyLimit }));
|
||||
app.use(express.urlencoded({ extended: true, limit: bodyLimit }));
|
||||
|
||||
// CSRF: register globally via express so we have explicit ordering after
|
||||
// the body parsers. (Nest's configure()-time middleware is registered
|
||||
// before app.use middlewares, which would consume the body before the
|
||||
// CsrfMiddleware short-circuits to next().)
|
||||
const csrf = new CsrfMiddleware(config);
|
||||
app.use((req, res, next) => csrf.use(req as any, res as any, next));
|
||||
|
||||
app.use('/uploads', express.static(uploadDir));
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('HIPCTF API')
|
||||
.setDescription('HIPCTF REST API')
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth()
|
||||
.addCookieAuth('rt')
|
||||
.build();
|
||||
const rawDocument = SwaggerModule.createDocument(app, swaggerConfig) as Record<string, any>;
|
||||
const document = toOpenApi31(rawDocument);
|
||||
SwaggerModule.setup('api/docs', app, document, {
|
||||
jsonDocumentUrl: 'api/docs-json',
|
||||
});
|
||||
|
||||
if (fs.existsSync(frontendDist)) {
|
||||
app.use(express.static(frontendDist, { index: false, fallthrough: true }));
|
||||
const browserDir = path.join(frontendDist, 'browser');
|
||||
if (fs.existsSync(browserDir)) {
|
||||
app.use(express.static(browserDir, { index: false, fallthrough: true }));
|
||||
}
|
||||
}
|
||||
|
||||
const spa = new SpaFallbackMiddleware(config);
|
||||
app.use((req, res, next) => spa.use(req as any, res as any, next));
|
||||
|
||||
await app.listen(port, '0.0.0.0');
|
||||
Logger.log(`HIPCTF API listening on http://0.0.0.0:${port} (env=${nodeEnv})`, 'Bootstrap');
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error('Fatal startup error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { UserRole } from '../../database/entities/user.entity';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import {
|
||||
CreateUserDtoSchema,
|
||||
ListUsersQueryDtoSchema,
|
||||
UpdateUserRoleDtoSchema,
|
||||
UserIdParamDtoSchema,
|
||||
} from './dto/admin.dto';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
/**
|
||||
* All admin handlers use Zod validation via `ZodValidationPipe` and an
|
||||
* INLINE body/param/query type. Using inline types (rather than class
|
||||
* metatypes) avoids the Nest class-transformer pass which would otherwise
|
||||
* overwrite the raw body before our pipe sees it.
|
||||
*/
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1/admin')
|
||||
@UseGuards(AdminGuard)
|
||||
@Roles('admin')
|
||||
export class AdminController {
|
||||
constructor(private readonly admin: AdminService) {}
|
||||
|
||||
@Get('users')
|
||||
@ApiOperation({ summary: 'List all users (admin only)' })
|
||||
list(
|
||||
@Query(new ZodValidationPipe(ListUsersQueryDtoSchema))
|
||||
query: { limit?: number; cursor?: string; role?: UserRole },
|
||||
) {
|
||||
return this.admin.listUsers(query);
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
@ApiOperation({ summary: 'Update a user role (admin only)' })
|
||||
update(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
@Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole },
|
||||
) {
|
||||
return this.admin.updateUserRole(params.id, body.role);
|
||||
}
|
||||
|
||||
@Delete('users/:id')
|
||||
@ApiOperation({ summary: 'Delete a user (admin only)' })
|
||||
async remove(
|
||||
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
|
||||
): Promise<void> {
|
||||
await this.admin.deleteUser(params.id);
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
@ApiOperation({ summary: 'Create a user (admin only)' })
|
||||
create(
|
||||
@Body(new ZodValidationPipe(CreateUserDtoSchema)) body: { username: string; password: string; role: UserRole },
|
||||
) {
|
||||
return this.admin.createUser(body.username, body.password, body.role);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,79 @@
|
||||
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';
|
||||
import { validatePassword } from '../../common/utils/password-policy';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface ListUsersQuery {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
role?: UserRole;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async listUsers(query: ListUsersQuery = {}): Promise<UserEntity[]> {
|
||||
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
|
||||
const repo = this.dataSource.getRepository(UserEntity);
|
||||
const qb = repo
|
||||
.createQueryBuilder('u')
|
||||
.orderBy('u.createdAt', 'ASC')
|
||||
.addOrderBy('u.id', 'ASC')
|
||||
.limit(limit + 1);
|
||||
if (query.role) qb.andWhere('u.role = :role', { role: query.role });
|
||||
if (query.cursor) qb.andWhere('u.id > :cursor', { cursor: query.cursor });
|
||||
const rows = await qb.getMany();
|
||||
return rows.slice(0, limit);
|
||||
}
|
||||
|
||||
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 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 res = await manager.delete(UserEntity, { id: targetId });
|
||||
if (!res.affected) throw ApiError.notFound('User not found');
|
||||
});
|
||||
}
|
||||
|
||||
async createUser(username: string, password: string, role: UserRole): Promise<UserEntity> {
|
||||
validatePassword(password, this.config);
|
||||
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,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateUserDtoSchema = z.object({
|
||||
username: z.string().trim().min(3).max(64).regex(/^[a-zA-Z0-9._-]+$/, 'Username may contain letters, digits, dot, underscore, dash'),
|
||||
password: z.string().min(1).max(256),
|
||||
role: z.enum(['admin', 'player']),
|
||||
});
|
||||
export type CreateUserDto = z.infer<typeof CreateUserDtoSchema>;
|
||||
|
||||
export const UpdateUserRoleDtoSchema = z.object({
|
||||
role: z.enum(['admin', 'player']),
|
||||
});
|
||||
export type UpdateUserRoleDto = z.infer<typeof UpdateUserRoleDtoSchema>;
|
||||
|
||||
export const UserIdParamDtoSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
});
|
||||
export type UserIdParamDto = z.infer<typeof UserIdParamDtoSchema>;
|
||||
|
||||
export const ListUsersQueryDtoSchema = z.object({
|
||||
limit: z.coerce.number().int().positive().max(200).optional().default(50),
|
||||
cursor: z.string().uuid().optional(),
|
||||
role: z.enum(['admin', 'player']).optional(),
|
||||
});
|
||||
export type ListUsersQueryDto = z.infer<typeof ListUsersQueryDtoSchema>;
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export const REFRESH_COOKIE_NAME = 'rt';
|
||||
|
||||
export function setRefreshCookie(res: Response, token: string, config: ConfigService): void {
|
||||
const tls = config.get<boolean>('TLS_ENABLED', false);
|
||||
res.cookie(REFRESH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: tls ? 'strict' : 'lax',
|
||||
secure: tls,
|
||||
path: '/api/v1/auth',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearRefreshCookie(res: Response): void {
|
||||
res.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/v1/auth' });
|
||||
}
|
||||
@@ -0,0 +1,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,65 @@
|
||||
import { Controller, Post, UseGuards, UseInterceptors, Req, HttpCode, HttpStatus, BadRequestException } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { parseUploadSizeLimit, safeFilename } from '../../common/utils/upload';
|
||||
|
||||
@ApiTags('uploads')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1/uploads')
|
||||
@UseGuards(AdminGuard)
|
||||
@Roles('admin')
|
||||
export class UploadsController {
|
||||
private readonly uploadDir: string;
|
||||
private readonly globalLimit: number;
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.uploadDir = path.resolve(this.config.get<string>('UPLOAD_DIR', '/data/hipctf/uploads'));
|
||||
this.globalLimit = parseUploadSizeLimit(this.config.get<string>('UPLOAD_SIZE_LIMIT', '50mb'));
|
||||
}
|
||||
|
||||
@Post('category-icon')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Upload a category icon (admin only)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadCategoryIcon(@Req() req: any) {
|
||||
return this.handleUpload(req, 'icons');
|
||||
}
|
||||
|
||||
@Post('challenge-file')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Upload a challenge attachment (admin only)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadChallengeFile(@Req() req: any) {
|
||||
return this.handleUpload(req, 'challenges');
|
||||
}
|
||||
|
||||
private handleUpload(req: any, subdir: string) {
|
||||
const file = req.file as any;
|
||||
if (!file) throw new BadRequestException('No file uploaded under field "file"');
|
||||
if (file.size > this.globalLimit) {
|
||||
throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`);
|
||||
}
|
||||
const safeName = safeFilename(file.originalname || 'file');
|
||||
const finalDir = path.join(this.uploadDir, subdir);
|
||||
if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true });
|
||||
const finalPath = path.join(finalDir, safeName);
|
||||
// FileInterceptor uses memoryStorage; the bytes are in file.buffer.
|
||||
fs.writeFileSync(finalPath, file.buffer);
|
||||
const publicUrl = `/uploads/${subdir}/${safeName}`;
|
||||
return {
|
||||
id: safeName,
|
||||
originalFilename: file.originalname,
|
||||
storedPath: finalPath,
|
||||
publicUrl,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UploadsController } from './uploads.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [UploadsController],
|
||||
})
|
||||
export class UploadsModule {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
type: architecture
|
||||
title: Backend Module Map
|
||||
description: NestJS modules, controllers, services, and how they are wired together.
|
||||
tags: [architecture, backend, nestjs, modules]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Module Map
|
||||
|
||||
All modules are imported in `backend/src/app.module.ts`. The root module
|
||||
also registers two global providers:
|
||||
|
||||
| Global provider | Purpose |
|
||||
|---------------------------|--------------------------------------------------------|
|
||||
| `APP_GUARD = JwtAuthGuard` | Enforces JWT auth unless the handler is `@Public()` |
|
||||
| `APP_FILTER = GlobalExceptionFilter` | Normalizes errors into the standard envelope |
|
||||
|
||||
# Modules
|
||||
|
||||
| Module | Path | Responsibility |
|
||||
|------------------|-----------------------------------------------|-------------------------------------------------------------------------------------------------|
|
||||
| `DatabaseModule` | `backend/src/database/database.module.ts` | Configures TypeORM with `better-sqlite3`, registers entities, runs migrations, enforces WAL. |
|
||||
| `CommonModule` | `backend/src/common/common.module.ts` | Provides shared services (CSRF middleware class, backoff, registration rate limit, SSE hub, theme loader, etc.). |
|
||||
| `SettingsModule` | `backend/src/modules/settings/settings.module.ts` | Exposes `SettingsService` (get/set/getAll over `setting` table). |
|
||||
| `AuthModule` | `backend/src/modules/auth/auth.module.ts` | `AuthController` (`/api/v1/auth/*`) + `AuthService` (login/refresh/logout/register-first-admin).|
|
||||
| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant). |
|
||||
| `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. |
|
||||
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | `AdminController` (`/api/v1/admin/users*`) + `AdminService`. Gated by `AdminGuard` + `@Roles('admin')`. |
|
||||
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
|
||||
| `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. |
|
||||
|
||||
# Controllers
|
||||
|
||||
| Controller | Path prefix | Auth | Source |
|
||||
|------------------------|-------------------------|--------------|--------------------------------------------------------------|
|
||||
| `AuthController` | `/api/v1/auth` | Mostly public (login, refresh, logout, csrf) | `backend/src/modules/auth/auth.controller.ts` |
|
||||
| `UsersController` | `/api/v1/auth/register-first-admin` | Public (bootstrap-only) | `backend/src/modules/users/users.controller.ts` |
|
||||
| `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` |
|
||||
| `SystemController` | `/api/v1` | Public | `backend/src/modules/system/system.controller.ts` |
|
||||
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
|
||||
|
||||
# Common services
|
||||
|
||||
| Service | Path | Purpose |
|
||||
|----------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
|
||||
| `CsrfMiddleware` | `backend/src/common/middleware/csrf.middleware.ts` | Mints/validates CSRF tokens via cookie + header. |
|
||||
| `JwtAuthGuard` | `backend/src/common/guards/jwt-auth.guard.ts` | Global guard; honors `@Public()` decorator. |
|
||||
| `AdminGuard` | `backend/src/common/guards/admin.guard.ts` | Requires authenticated user with `role === 'admin'`. |
|
||||
| `RolesGuard` | `backend/src/common/guards/roles.guard.ts` | Enforces `@Roles(...)` metadata. |
|
||||
| `GlobalExceptionFilter` | `backend/src/common/filters/global-exception.filter.ts` | Converts exceptions to standard envelope. |
|
||||
| `EventStatusService` | `backend/src/common/services/event-status.service.ts` | Computes `Stopped`/`Running` and `countdownMs`. |
|
||||
| `LoginBackoffService` | `backend/src/common/services/login-backoff.service.ts` | Per-IP+username brute-force throttle. |
|
||||
| `RegistrationRateLimitService` | `backend/src/common/services/registration-rate-limit.service.ts` | Per-IP rate limit on first-admin registration. |
|
||||
| `SseHubService` | `backend/src/common/services/sse-hub.service.ts` | In-process pub/sub for SSE streams. |
|
||||
| `ThemeLoaderService` | `backend/src/common/utils/theme-loader.service.ts` | Loads + validates the 10 canonical themes. |
|
||||
| `ZodValidationPipe` | `backend/src/common/pipes/zod-validation.pipe.ts` | Replaces `ValidationPipe` for zod-validated DTOs. |
|
||||
| `TransformInterceptor` | `backend/src/common/interceptors/transform.interceptor.ts` | Optional response transformer. |
|
||||
|
||||
# Decorators
|
||||
|
||||
| Decorator | Path | Purpose |
|
||||
|-----------------|------------------------------------------------------------|----------------------------------|
|
||||
| `@Public()` | `backend/src/common/decorators/public.decorator.ts` | Mark a route as unauthenticated. |
|
||||
| `@Roles(...)` | `backend/src/common/decorators/roles.decorator.ts` | Required role(s). |
|
||||
| `@SkipCsrf()` | `backend/src/common/decorators/skip-csrf.decorator.ts` | Exempt a route from CSRF check. |
|
||||
|
||||
# Startup chain
|
||||
|
||||
`main.ts` → `AppModule` (loads `ConfigModule`, then all feature modules)
|
||||
→ `DatabaseInitService.init()` (runs migrations + WAL)
|
||||
→ `app.use(...)` middlewares (helmet, parsers, CSRF, static)
|
||||
→ `SwaggerModule.setup(...)` (OpenAPI 3.1)
|
||||
→ `SpaFallbackMiddleware` → `app.listen()`.
|
||||
|
||||
# See also
|
||||
|
||||
- [System Overview](/architecture/overview.md)
|
||||
- [Key Files Index](/architecture/key-files.md)
|
||||
- [REST API Overview](/api/rest-overview.md)
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
type: architecture
|
||||
title: Frontend Structure
|
||||
description: Angular routes, components, services, guards, and interceptors.
|
||||
tags: [architecture, frontend, angular]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Routes
|
||||
|
||||
Routes live in `frontend/src/app/app.routes.ts`:
|
||||
|
||||
| Path | Component | Guard | Notes |
|
||||
|--------------|----------------------------------|---------------|----------------------------------------|
|
||||
| `/bootstrap` | `CreateAdminComponent` | — | Rendered only when `initialized === false`. |
|
||||
| `/login` | `LoginComponent` | — | Standard login form. |
|
||||
| `/` | `HomeComponent` | `authGuard` | Requires auth + initialized. |
|
||||
| `**` | Redirect to `/` | — | Wildcard fallback. |
|
||||
|
||||
# Components
|
||||
|
||||
All components are standalone (no NgModules). Each component imports
|
||||
`FormsModule` directly and uses Angular signals for state.
|
||||
|
||||
| Component | Path | Purpose |
|
||||
|------------------------|-----------------------------------------------------------------|-------------------------------------------------|
|
||||
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>` and calls `BootstrapService.load()`. |
|
||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Landing page shown after auth. |
|
||||
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
|
||||
| `CreateAdminComponent` | `frontend/src/app/features/bootstrap/create-admin.component.ts`| First-admin bootstrap form. |
|
||||
|
||||
# Services
|
||||
|
||||
| Service | Path | Purpose |
|
||||
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
||||
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user. |
|
||||
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS. |
|
||||
|
||||
# Guards and interceptors
|
||||
|
||||
| Symbol | Path | Purpose |
|
||||
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
||||
| `authGuard` | `frontend/src/app/core/guards/auth.guard.ts` | Redirects to `/bootstrap` when uninitialized, `/login` when not authenticated. |
|
||||
| `authInterceptor` | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches `Authorization: Bearer <accessToken>` header. |
|
||||
| `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. |
|
||||
|
||||
Both interceptors are wired in `frontend/src/main.ts` via
|
||||
`provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor]))`
|
||||
(notably CSRF runs first so the token is attached before the auth header).
|
||||
|
||||
# Bootstrap flow
|
||||
|
||||
1. `AppComponent.ngOnInit()` → `BootstrapService.load()`.
|
||||
2. `BootstrapService` calls `GET /api/v1/bootstrap` (public, with
|
||||
`credentials: 'include'`).
|
||||
3. The payload sets `initialized` (true iff any admin user exists),
|
||||
`pageTitle`, `logo`, `welcomeMarkdown`, the active `theme`, and the
|
||||
default challenge IP.
|
||||
4. Theme tokens are written to CSS custom properties on
|
||||
`document.documentElement` (`--color-primary`, `--font-family`,
|
||||
`--radius-*`, etc.) consumed by `frontend/src/styles.css`.
|
||||
5. The `authGuard` reads `BootstrapService.initialized()` and either
|
||||
allows navigation or redirects to `/bootstrap`.
|
||||
|
||||
# See also
|
||||
|
||||
- [System Overview](/architecture/overview.md)
|
||||
- [Backend Module Map](/architecture/backend-modules.md)
|
||||
- [First-Run Bootstrap](/guides/bootstrap.md)
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
type: architecture
|
||||
title: Key Files Index
|
||||
description: One-line responsibility for every important source file in the repository.
|
||||
tags: [architecture, index, key-files]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Backend
|
||||
|
||||
## Entrypoint & config
|
||||
|
||||
| File | Responsibility |
|
||||
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|
||||
| `backend/src/main.ts` | Bootstraps Nest, registers middleware, builds OpenAPI 3.1, mounts SPA fallback. |
|
||||
| `backend/src/app.module.ts` | Root module; wires every feature module + global guards/filters. |
|
||||
| `backend/src/config/env.schema.ts` | Zod-validated env schema, `SETTINGS_KEYS`, system category metadata. |
|
||||
|
||||
## Database
|
||||
|
||||
| File | Responsibility |
|
||||
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|
||||
| `backend/src/database/database.module.ts` | TypeORM `better-sqlite3` config, entity + migration registration. |
|
||||
| `backend/src/database/database-init.service.ts` | Initializes the DataSource, enforces WAL PRAGMA, runs migrations, verifies seed. |
|
||||
| `backend/src/database/migrations/1700000000000-InitSchema.ts` | Creates all tables and sets WAL + foreign keys. |
|
||||
| `backend/src/database/migrations/1700000000100-SeedSystemData.ts` | Seeds the 6 system categories and default settings. |
|
||||
| `backend/src/database/entities/user.entity.ts` | `user` table (admin/player role, enabled/disabled status). |
|
||||
| `backend/src/database/entities/setting.entity.ts` | `setting` key/value table. |
|
||||
| `backend/src/database/entities/category.entity.ts` | `category` table with `system_key` unique index. |
|
||||
| `backend/src/database/entities/challenge.entity.ts` | `challenge` table (difficulty, decay, protocol, flag, port). |
|
||||
| `backend/src/database/entities/challenge-file.entity.ts` | `challenge_file` table for attachments. |
|
||||
| `backend/src/database/entities/solve.entity.ts` | `solve` table with unique `(challenge_id, user_id)`. |
|
||||
| `backend/src/database/entities/refresh-token.entity.ts` | `refresh_token` table (token_hash + revocation). |
|
||||
| `backend/src/database/entities/blog-post.entity.ts` | `blog_post` table (draft/published + publishedAt index). |
|
||||
|
||||
## Common
|
||||
|
||||
| File | Responsibility |
|
||||
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|
||||
| `backend/src/common/common.module.ts` | Aggregates common services, middleware, and providers. |
|
||||
| `backend/src/common/middleware/csrf.middleware.ts` | Double-submit CSRF check; mints/reads cookie, compares header. |
|
||||
| `backend/src/common/guards/jwt-auth.guard.ts` | Global JWT guard; honors `@Public()`. |
|
||||
| `backend/src/common/guards/admin.guard.ts` | Requires `req.user.role === 'admin'`. |
|
||||
| `backend/src/common/guards/roles.guard.ts` | Enforces `@Roles(...)` metadata. |
|
||||
| `backend/src/common/decorators/public.decorator.ts` | `@Public()` marker. |
|
||||
| `backend/src/common/decorators/roles.decorator.ts` | `@Roles(...)` metadata. |
|
||||
| `backend/src/common/decorators/skip-csrf.decorator.ts` | `@SkipCsrf()` marker. |
|
||||
| `backend/src/common/filters/global-exception.filter.ts` | Converts all errors to the standard envelope. |
|
||||
| `backend/src/common/pipes/zod-validation.pipe.ts` | Validates body/param/query with a zod schema. |
|
||||
| `backend/src/common/interceptors/transform.interceptor.ts` | Optional response transformer. |
|
||||
| `backend/src/common/services/event-status.service.ts` | Computes event `Stopped`/`Running` and `countdownMs`. |
|
||||
| `backend/src/common/services/login-backoff.service.ts` | Per-IP+username failed-login throttle. |
|
||||
| `backend/src/common/services/registration-rate-limit.service.ts` | Per-IP first-admin registration throttle. |
|
||||
| `backend/src/common/services/sse-hub.service.ts` | In-process pub/sub for event status and scoreboard streams. |
|
||||
| `backend/src/common/utils/openapi31.ts` | Converts the Nest-generated OpenAPI 3.0 doc to OpenAPI 3.1. |
|
||||
| `backend/src/common/utils/password-policy.ts` | Argon2 password validator (length, mixed-case policy). |
|
||||
| `backend/src/common/utils/theme-loader.service.ts` | Loads themes from disk, backfills built-ins, validates tokens. |
|
||||
| `backend/src/common/utils/upload.ts` | Upload size-limit parser + filename sanitizer. |
|
||||
| `backend/src/common/types/theme.ts`, `theme-ids.ts` | `Theme` interface and the 10 canonical `ThemeId`s. |
|
||||
| `backend/src/common/errors/api-error.ts` | `ApiError` class (status + machine code + message + details). |
|
||||
| `backend/src/common/errors/error-codes.ts` | Canonical `ERROR_CODES` enum. |
|
||||
|
||||
## Modules
|
||||
|
||||
| File | Responsibility |
|
||||
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|
||||
| `backend/src/modules/auth/auth.module.ts` | Wires `AuthController`, `AuthService`, `JwtStrategy`. |
|
||||
| `backend/src/modules/auth/auth.controller.ts` | `/api/v1/auth/{login,refresh,logout,csrf}`. |
|
||||
| `backend/src/modules/auth/auth.service.ts` | Login, refresh, logout, register-first-admin, mint JWT + refresh tokens. |
|
||||
| `backend/src/modules/auth/cookie.ts` | `setRefreshCookie` / `clearRefreshCookie` helpers. |
|
||||
| `backend/src/modules/auth/dto/auth.dto.ts` | Zod schemas for login + refresh. |
|
||||
| `backend/src/modules/auth/strategies/jwt.strategy.ts` | Passport JWT strategy. |
|
||||
| `backend/src/modules/users/users.module.ts` | Wires `UsersController` + `UsersService`. |
|
||||
| `backend/src/modules/users/users.controller.ts` | `/api/v1/auth/register-first-admin`. |
|
||||
| `backend/src/modules/users/users.service.ts` | `enforceLastAdminInvariant` helper. |
|
||||
| `backend/src/modules/users/dto/create-first-admin.dto.ts` | Zod schema for first-admin registration. |
|
||||
| `backend/src/modules/admin/admin.module.ts` | Wires `AdminController` + `AdminService`. |
|
||||
| `backend/src/modules/admin/admin.controller.ts` | `/api/v1/admin/users[/{id}]`. |
|
||||
| `backend/src/modules/admin/admin.service.ts` | list / create / update role / delete user with last-admin guard. |
|
||||
| `backend/src/modules/admin/dto/admin.dto.ts` | Zod schemas for admin endpoints. |
|
||||
| `backend/src/modules/system/system.module.ts` | Wires `SystemController` + `SystemService` + status + hub services. |
|
||||
| `backend/src/modules/system/system.controller.ts` | `/api/v1/{bootstrap,event/status}` + SSE `/event/stream`, `/scoreboard/stream`. |
|
||||
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload from settings + theme + admin count. |
|
||||
| `backend/src/modules/uploads/uploads.module.ts` | Wires `UploadsController`. |
|
||||
| `backend/src/modules/uploads/uploads.controller.ts` | `/api/v1/uploads/{category-icon,challenge-file}`. |
|
||||
| `backend/src/modules/settings/settings.module.ts` | `SettingsService` (get/set/getAll over the `setting` table). |
|
||||
| `backend/src/frontend/frontend.module.ts` | Wires SPA fallback + uploads static middleware. |
|
||||
| `backend/src/frontend/spa.controller.ts` | `SpaFallbackMiddleware` (returns `index.html` for client routes). |
|
||||
| `backend/src/frontend/uploads-static.middleware.ts` | Mounts `/uploads` static helper. |
|
||||
|
||||
## Themes
|
||||
|
||||
| File | Responsibility |
|
||||
|----------------------------------------------|-------------------------------------------------------------|
|
||||
| `backend/themes/01-classic.json` | `classic` theme tokens. |
|
||||
| `backend/themes/02-midnight.json` … `10-monochrome.json` | The other 9 canonical themes. |
|
||||
|
||||
# Frontend
|
||||
|
||||
| File | Responsibility |
|
||||
|-----------------------------------------------------|--------------------------------------------------------------------------------|
|
||||
| `frontend/src/main.ts` | Bootstraps the standalone Angular app + registers interceptors. |
|
||||
| `frontend/src/index.html` | Root HTML shell. |
|
||||
| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. |
|
||||
| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap on init. |
|
||||
| `frontend/src/app/app.routes.ts` | Angular route table. |
|
||||
| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user. |
|
||||
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap` and applies theme tokens to CSS. |
|
||||
| `frontend/src/app/core/guards/auth.guard.ts` | `CanActivateFn` checking init + auth. |
|
||||
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. |
|
||||
| `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. |
|
||||
| `frontend/src/app/features/auth/login.component.ts` | Login form. |
|
||||
| `frontend/src/app/features/bootstrap/create-admin.component.ts` | First-admin creation form. |
|
||||
| `frontend/src/app/features/home/home.component.ts` | Landing page after auth. |
|
||||
|
||||
# Tests
|
||||
|
||||
| File | Responsibility |
|
||||
|------------------------------------------------|--------------------------------------------------------------------------------|
|
||||
| `tests/backend/*.spec.ts` (18 suites) | Jest tests for backend modules, CSRF, OpenAPI 3.1, migrations, rate limits. |
|
||||
| `tests/frontend/theme.spec.ts` | Jest test for theme token application. |
|
||||
| `tests/backend/csrf-client.ts` | Test helper for CSRF flow. |
|
||||
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
|
||||
|
||||
# See also
|
||||
|
||||
- [System Overview](/architecture/overview.md)
|
||||
- [Backend Module Map](/architecture/backend-modules.md)
|
||||
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
type: architecture
|
||||
title: System Overview
|
||||
description: High-level layout of the HIPCTF monorepo (NestJS API + Angular SPA) and how they communicate.
|
||||
tags: [architecture, backend, frontend, overview]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
HIPCTF is delivered as a Node.js npm workspace containing two packages:
|
||||
|
||||
| Package | Path | Stack |
|
||||
|-----------|-------------|----------------------------------|
|
||||
| Backend | `backend/` | NestJS 10 + TypeORM + better-sqlite3 |
|
||||
| Frontend | `frontend/` | Angular 17 standalone components |
|
||||
|
||||
The backend also serves the built Angular SPA as static files and falls
|
||||
back to `index.html` for client-side routes, so a single Node process
|
||||
hosts the whole application.
|
||||
|
||||
# Topology
|
||||
|
||||
```
|
||||
Browser ──HTTPS──▶ NestJS process (PORT, default 3000)
|
||||
│
|
||||
├── /api/v1/* ── controllers → services → TypeORM
|
||||
│ │
|
||||
│ ▼
|
||||
│ better-sqlite3
|
||||
│ (DATABASE_PATH)
|
||||
│
|
||||
├── /api/docs, /api/docs-json ── Swagger UI (OpenAPI 3.1)
|
||||
│
|
||||
├── /uploads/* ── static file server (UPLOAD_DIR)
|
||||
│
|
||||
└── /* ── SPA fallback (FRONTEND_DIST/index.html)
|
||||
```
|
||||
|
||||
# Backend entrypoint flow
|
||||
|
||||
`backend/src/main.ts` orchestrates startup in this order:
|
||||
|
||||
1. Create the Nest application.
|
||||
2. Read configuration via `ConfigService` (validated against
|
||||
`backend/src/config/env.schema.ts`).
|
||||
3. Call `DatabaseInitService.init()` so migrations + WAL PRAGMA run
|
||||
before HTTP traffic is accepted.
|
||||
4. Register global middleware: `helmet`, `cookieParser`, JSON / URL-encoded
|
||||
parsers, then `CsrfMiddleware` (registered explicitly so it can read
|
||||
the parsed body).
|
||||
5. Mount `/uploads` static and Angular static (`FRONTEND_DIST`).
|
||||
6. Build the OpenAPI 3.1 document via `toOpenApi31()` and expose Swagger
|
||||
UI at `/api/docs` and JSON at `/api/docs-json`.
|
||||
7. Install `SpaFallbackMiddleware` so non-`/api`, non-`/uploads`,
|
||||
extensionless `GET` requests return `index.html`.
|
||||
8. `app.listen(port)`.
|
||||
|
||||
# Frontend entrypoint flow
|
||||
|
||||
`frontend/src/main.ts` bootstraps a standalone Angular app:
|
||||
|
||||
1. `provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor]))`
|
||||
attaches the CSRF header on unsafe methods and the Bearer access
|
||||
token on every request.
|
||||
2. `provideRouter(APP_ROUTES, withComponentInputBinding())`.
|
||||
3. `AppComponent.ngOnInit()` calls `BootstrapService.load()` which
|
||||
fetches `GET /api/v1/bootstrap` and applies the returned theme tokens
|
||||
to CSS custom properties on `document.documentElement`.
|
||||
|
||||
# Cross-cutting concepts
|
||||
|
||||
| Concern | Backend | Frontend |
|
||||
|----------------|------------------------------------------------------|---------------------------------------------|
|
||||
| Authentication | `JwtAuthGuard` (global) + `AuthGuard('jwt')` strategy | `AuthService` signal + `authInterceptor` |
|
||||
| Authorization | `AdminGuard` + `@Roles('admin')` decorator | `authGuard` route guard |
|
||||
| CSRF | `CsrfMiddleware` (skips `/api/v1/auth/login` and `/api/v1/auth/register-first-admin`) | `csrfInterceptor` reads `csrf` cookie |
|
||||
| Errors | `ApiError` → `GlobalExceptionFilter` returns `{code, message, details, path, timestamp}` | Components display `error?.error?.message` |
|
||||
| Config | `envSchema` (zod) + `validateEnv` | None (consumed via bootstrap) |
|
||||
|
||||
# See also
|
||||
|
||||
- [Backend Module Map](/architecture/backend-modules.md)
|
||||
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||
- [REST API Overview](/api/rest-overview.md)
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
type: database
|
||||
title: Auth and Settings Tables
|
||||
description: refresh_token and setting tables — rotating refresh tokens and site configuration.
|
||||
tags: [database, refresh-token, settings, config]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Tables
|
||||
|
||||
## `refresh_token`
|
||||
|
||||
| Column | Type | Description |
|
||||
|---------------|---------|------------------------------------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4. |
|
||||
| `user_id` | TEXT | FK → `user.id` (indexed, but no DB-level FK cascade is configured). |
|
||||
| `token_hash` | TEXT | SHA-256 of the raw refresh token (unique). |
|
||||
| `issued_at` | TEXT | ISO 8601 timestamp. |
|
||||
| `expires_at` | TEXT | ISO 8601 timestamp; checked on refresh. |
|
||||
| `revoked_at` | TEXT | ISO 8601 timestamp or `NULL` if active. |
|
||||
|
||||
# Refresh token lifecycle
|
||||
|
||||
1. **Issue** — `AuthService.login` and `AuthService.registerFirstAdmin`
|
||||
mint a new token via `crypto.randomBytes(32).toString('base64url')`
|
||||
and store its SHA-256 hash with `expires_at = now + JWT_REFRESH_TTL`.
|
||||
The raw token is set as an HttpOnly `rt` cookie and also returned in
|
||||
the login response body.
|
||||
2. **Rotate** — `AuthService.refresh` looks up the row by `token_hash`,
|
||||
asserts it isn't revoked/expired, marks `revoked_at = now`, and mints
|
||||
a new token in the same transaction. Each token is single-use.
|
||||
3. **Logout** — `AuthService.logout` looks up by `token_hash` (from the
|
||||
`rt` cookie) and sets `revoked_at`. Already-revoked or unknown tokens
|
||||
are silently ignored.
|
||||
|
||||
## `setting`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|---------|-----------------------------------------------------|
|
||||
| `key` | TEXT PK | Settings key (see `SETTINGS_KEYS` below). |
|
||||
| `value`| TEXT | String value (defaults to empty string). |
|
||||
|
||||
Read/write access goes through `SettingsService`
|
||||
(`backend/src/modules/settings/settings.module.ts`), which exposes
|
||||
`get(key, fallback?)`, `set(key, value)`, and `getAll()`.
|
||||
|
||||
# Settings keys
|
||||
|
||||
Defined in `backend/src/config/env.schema.ts` (`SETTINGS_KEYS`) and
|
||||
seeded by `SeedSystemData1700000000100`:
|
||||
|
||||
| Key | Default | Consumed by |
|
||||
|-------------------------|------------------------------------------|-------------------------------------------------------------------|
|
||||
| `pageTitle` | `HIPCTF` | `SystemService.bootstrap` → `pageTitle` |
|
||||
| `logo` | `""` | `SystemService.bootstrap` → `logo` |
|
||||
| `welcomeMarkdown` | `"# Welcome\n\nCreate the first admin..."` | `SystemService.bootstrap` → `welcomeMarkdown` |
|
||||
| `themeKey` | `classic` | `SystemService.bootstrap` → `theme` (overridden via `ThemeLoaderService`) |
|
||||
| `defaultChallengeIp` | `127.0.0.1` | `SystemService.bootstrap` → `defaultChallengeIp` |
|
||||
| `registrationsEnabled` | `false` | `SystemService.bootstrap` → `registrationsEnabled` |
|
||||
| `eventStartUtc` | `now + 60s` (ISO) | `EventStatusService.getStatus` |
|
||||
| `eventEndUtc` | `now + 7d` (ISO) | `EventStatusService.getStatus` |
|
||||
|
||||
# See also
|
||||
|
||||
- [Database Schema Overview](/database/schema.md)
|
||||
- [User Table](/database/users.md)
|
||||
- [System Endpoints](/api/system.md)
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
type: database
|
||||
title: Challenge Tables
|
||||
description: category, challenge, challenge_file, and solve tables — how CTF challenges and scoring are stored.
|
||||
tags: [database, challenge, category, solve]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Tables
|
||||
|
||||
## `category`
|
||||
|
||||
| Column | Type | Description |
|
||||
|----------------|---------|------------------------------------------------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4. |
|
||||
| `system_key` | TEXT | One of `crypto`, `forensics`, `pwn`, `web`, `misc`, `osint` (unique where NOT NULL) or `NULL` for user-created categories. |
|
||||
| `name` | TEXT | Display name (e.g. `Cryptography`). |
|
||||
| `abbreviation` | TEXT | Short label (e.g. `CRY`). |
|
||||
| `description` | TEXT | Optional description. |
|
||||
| `icon_path` | TEXT | Default icon URL (e.g. `/uploads/icons/crypto.svg`). |
|
||||
|
||||
The seed migration inserts one row per `SYSTEM_CATEGORY_KEYS` value from
|
||||
`backend/src/config/env.schema.ts`.
|
||||
|
||||
## `challenge`
|
||||
|
||||
| Column | Type | Description |
|
||||
|-------------------|----------|-----------------------------------------------------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4. |
|
||||
| `name` | TEXT | Display name. |
|
||||
| `description_md` | TEXT | Markdown description. |
|
||||
| `category_id` | TEXT | FK → `category.id`. |
|
||||
| `difficulty` | TEXT | `'low'` / `'med'` / `'high'`. |
|
||||
| `initial_points` | INTEGER | Starting point value. |
|
||||
| `minimum_points` | INTEGER | Floor after decay. |
|
||||
| `decay_solves` | INTEGER | Number of solves at which decay reaches the minimum. |
|
||||
| `flag` | TEXT | Submission string expected from players. |
|
||||
| `protocol` | TEXT | `'nc'` (netcat-style connection) or `'web'` (browser-based). |
|
||||
| `port` | INTEGER | TCP port when relevant (nullable). |
|
||||
| `ip_address` | TEXT | IP the challenge listens on (defaulted from `setting.defaultChallengeIp`). |
|
||||
| `created_at` | TEXT | ISO 8601 timestamp. |
|
||||
|
||||
## `challenge_file`
|
||||
|
||||
| Column | Type | Description |
|
||||
|---------------------|---------|--------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4. |
|
||||
| `challenge_id` | TEXT | FK → `challenge.id`. |
|
||||
| `original_filename` | TEXT | Filename from upload. |
|
||||
| `stored_path` | TEXT | Path on disk under `UPLOAD_DIR/challenges/`. |
|
||||
|
||||
## `solve`
|
||||
|
||||
| Column | Type | Description |
|
||||
|------------------|---------|----------------------------------------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4. |
|
||||
| `challenge_id` | TEXT | FK → `challenge.id`. |
|
||||
| `user_id` | TEXT | FK → `user.id`. |
|
||||
| `solved_at` | TEXT | ISO 8601 timestamp the solve was recorded. |
|
||||
| `points_awarded` | INTEGER | Points actually awarded (after decay). |
|
||||
| `base_points` | INTEGER | Snapshot of `challenge.initial_points` at solve time. |
|
||||
| `rank_bonus` | INTEGER | Extra points from rank (e.g. first-blood). |
|
||||
|
||||
Uniqueness is enforced by `uq_solve_challenge_user` on
|
||||
`(challenge_id, user_id)` — a user can solve a given challenge only once.
|
||||
|
||||
# Scoring formula
|
||||
|
||||
`points_awarded = clamp(initial_points - decay, minimum_points, initial_points)`
|
||||
where `decay = max(0, base_points - minimum_points) * solvesBefore / decay_solves`,
|
||||
and additional `rank_bonus` may be added for early solves.
|
||||
|
||||
(The exact scoring algorithm lives in the future solve-recording service
|
||||
and is consumed by `SseHubService` whenever a new solve is published.)
|
||||
|
||||
# See also
|
||||
|
||||
- [Database Schema Overview](/database/schema.md)
|
||||
- [System Endpoints](/api/system.md) (SSE scoreboard stream)
|
||||
- [Scoreboard Stream](/guides/scoreboard-stream.md)
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
type: database
|
||||
title: Database Schema Overview
|
||||
description: SQLite (better-sqlite3) schema for HIPCTF: tables, relationships, indexes, and WAL journal mode.
|
||||
tags: [database, sqlite, typeorm, schema]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
HIPCTF uses **SQLite via better-sqlite3** with TypeORM migrations. The
|
||||
database is a single file (`DATABASE_PATH`, default
|
||||
`/data/hipctf/db.sqlite`) running in **WAL journal mode** for better
|
||||
concurrent read/write performance.
|
||||
|
||||
The schema is created by a single migration
|
||||
(`backend/src/database/migrations/1700000000000-InitSchema.ts`) and seeded
|
||||
by a second migration
|
||||
(`backend/src/database/migrations/1700000000100-SeedSystemData.ts`).
|
||||
|
||||
# Tables
|
||||
|
||||
| Table | Purpose |
|
||||
|-------------------|-------------------------------------------------------------|
|
||||
| `user` | Authenticated users with role + status. |
|
||||
| `setting` | Key/value site configuration. |
|
||||
| `category` | Challenge categories (system + user-defined). |
|
||||
| `challenge` | CTF challenges with scoring + protocol metadata. |
|
||||
| `challenge_file` | Files attached to challenges. |
|
||||
| `solve` | User-solved challenges (scoring ledger). |
|
||||
| `refresh_token` | Rotating refresh tokens (hash + revocation). |
|
||||
| `blog_post` | Markdown blog posts in draft/published states. |
|
||||
|
||||
# Relationships
|
||||
|
||||
```
|
||||
user ───< solve >─── challenge
|
||||
│
|
||||
└─── challenge_file >── challenge ──> category
|
||||
|
||||
user ───< refresh_token
|
||||
```
|
||||
|
||||
* `challenge.category_id` → `category.id` (`ON DELETE RESTRICT`).
|
||||
* `challenge_file.challenge_id` → `challenge.id` (`ON DELETE RESTRICT`).
|
||||
* `solve.challenge_id` → `challenge.id` (`ON DELETE RESTRICT`).
|
||||
* `solve.user_id` → `user.id` (`ON DELETE RESTRICT`).
|
||||
|
||||
# Indexes
|
||||
|
||||
| Table | Index |
|
||||
|------------------|----------------------------------------------------|
|
||||
| `category` | `uq_category_system_key` (unique on `system_key` where NOT NULL) |
|
||||
| `challenge` | `idx_challenge_category` |
|
||||
| `challenge_file` | `idx_challenge_file_challenge` |
|
||||
| `solve` | `uq_solve_challenge_user` (unique), `idx_solve_user`, `idx_solve_challenge` |
|
||||
| `refresh_token` | `idx_refresh_token_user`, unique on `token_hash` |
|
||||
| `blog_post` | `idx_blog_status_published` (`status`, `published_at`) |
|
||||
|
||||
# PRAGMAs
|
||||
|
||||
| PRAGMA | Where set | Effect |
|
||||
|--------------------|----------------------------------------------------|---------------------------------|
|
||||
| `foreign_keys = ON`| Migration `InitSchema1700000000000` | Enforce FK constraints. |
|
||||
| `journal_mode = WAL`| Migration + `DatabaseInitService.ensureWalMode()` on every startup | Persisted in DB header; allows concurrent readers. |
|
||||
|
||||
# Bootstrap behavior
|
||||
|
||||
On every process start, `DatabaseInitService.init()`:
|
||||
|
||||
1. Initializes the TypeORM `DataSource` (idempotent).
|
||||
2. Calls `ensureWalMode()` — idempotent; if WAL is already set it only logs.
|
||||
3. Runs pending migrations if any (or if the `category` table is missing).
|
||||
4. Logs the seed row counts (categories, settings) via `verifySeed()`.
|
||||
|
||||
# See also
|
||||
|
||||
- [User Table](/database/users.md)
|
||||
- [Challenge Tables](/database/challenges.md)
|
||||
- [Auth and Settings Tables](/database/auth-settings.md)
|
||||
- [Blog Post Table](/database/blog-posts.md)
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
type: database
|
||||
title: User Table
|
||||
description: The `user` table — accounts, roles, and statuses.
|
||||
tags: [database, user, auth]
|
||||
timestamp: 2026-07-21T14:18:00Z
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
| Column | Type | Description |
|
||||
|----------------|----------|------------------------------------------------------------------|
|
||||
| `id` | TEXT PK | UUID v4 generated on creation. |
|
||||
| `username` | TEXT | Unique login name. |
|
||||
| `password_hash`| TEXT | Argon2id hash (memory/time/parallelism from env config). |
|
||||
| `role` | TEXT | `'admin'` or `'player'`. Default `'player'`. |
|
||||
| `status` | TEXT | `'enabled'` or `'disabled'`. Default `'enabled'`. |
|
||||
| `created_at` | TEXT | ISO 8601 timestamp (`strftime('%Y-%m-%dT%H:%M:%fZ','now')`). |
|
||||
|
||||
# Constraints
|
||||
|
||||
- Unique on `username`.
|
||||
- `role` is enforced application-side (see `AdminService.updateUserRole`).
|
||||
- `status` is checked by `AuthService.login` and `AuthService.refresh`
|
||||
— disabled users cannot log in or refresh.
|
||||
|
||||
# Invariants
|
||||
|
||||
- The application enforces the **last-admin invariant**: at least one
|
||||
enabled user with `role = 'admin'` must always exist. Attempts to
|
||||
demote or delete the last admin are rejected by
|
||||
`UsersService.enforceLastAdminInvariant` (used by
|
||||
`AdminService.updateUserRole` and `AdminService.deleteUser`).
|
||||
|
||||
# First-admin bootstrap
|
||||
|
||||
When the `user` table is empty of admins, `POST /api/v1/auth/register-first-admin`
|
||||
is the only endpoint that can create one. After that, the endpoint
|
||||
returns `409 SYSTEM_INITIALIZED`.
|
||||
|
||||
# See also
|
||||
|
||||
- [Auth and Settings Tables](/database/auth-settings.md)
|
||||
- [REST API Overview](/api/rest-overview.md)
|
||||
- [Admin Endpoints](/api/admin.md)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
okf_version: "0.1"
|
||||
---
|
||||
|
||||
# HIPCTF Documentation
|
||||
|
||||
HIPCTF is a single-tenant CTF (Capture-The-Flag) platform built as a Node.js
|
||||
monorepo containing a NestJS REST API and an Angular single-page application.
|
||||
It supports user registration, authentication, challenge management, a live
|
||||
scoreboard, an event window with a public countdown, theming, and admin
|
||||
controls.
|
||||
|
||||
The docs below are organized by purpose so agents can pull just the slice
|
||||
they need.
|
||||
|
||||
# Architecture
|
||||
|
||||
* [System Overview](/architecture/overview.md) - High-level layout of the
|
||||
backend, frontend, and how they communicate.
|
||||
* [Backend Module Map](/architecture/backend-modules.md) - NestJS modules,
|
||||
controllers, services, and their wiring.
|
||||
* [Frontend Structure](/architecture/frontend-structure.md) - Angular routes,
|
||||
components, services, and guards.
|
||||
* [Key Files Index](/architecture/key-files.md) - One-line summary of every
|
||||
important source file in the repository.
|
||||
|
||||
# Database
|
||||
|
||||
* [Schema Overview](/database/schema.md) - Tables, relationships, and indexes.
|
||||
* [User Table](/database/users.md) - `user` table schema and seed behavior.
|
||||
* [Challenge Tables](/database/challenges.md) - `category`, `challenge`,
|
||||
`challenge_file`, `solve` tables.
|
||||
* [Auth and Settings Tables](/database/auth-settings.md) - `refresh_token`
|
||||
and `setting` tables.
|
||||
* [Blog Post Table](/database/blog-posts.md) - `blog_post` table.
|
||||
|
||||
# API
|
||||
|
||||
* [REST API Overview](/api/rest-overview.md) - Base URL, versioning, auth,
|
||||
CSRF, and error envelope.
|
||||
* [Auth Endpoints](/api/auth.md) - Login, refresh, logout, CSRF, and
|
||||
first-admin registration.
|
||||
* [Admin Endpoints](/api/admin.md) - User management endpoints
|
||||
(admin role required).
|
||||
* [System Endpoints](/api/system.md) - Bootstrap, event status, and SSE
|
||||
streams.
|
||||
* [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads.
|
||||
|
||||
# Guides
|
||||
|
||||
* [First-Run Bootstrap](/guides/bootstrap.md) - How a fresh instance is
|
||||
initialized by the very first admin.
|
||||
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
||||
* [Event Window](/guides/event-window.md) - How the event window and live
|
||||
countdown work.
|
||||
* [Scoreboard Stream](/guides/scoreboard-stream.md) - How live solves are
|
||||
broadcast to clients.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user