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

This commit was merged in pull request #1.
This commit is contained in:
2026-07-21 14:23:53 +00:00
parent e55c9cda56
commit 03bcb6b156
131 changed files with 23657 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
import { Module, Global } from '@nestjs/common';
import { ThemeLoaderService } from './utils/theme-loader.service';
import { EventStatusService } from './services/event-status.service';
import { SseHubService } from './services/sse-hub.service';
import { LoginBackoffService } from './services/login-backoff.service';
import { RegistrationRateLimitService } from './services/registration-rate-limit.service';
import { CsrfMiddleware } from './middleware/csrf.middleware';
import { SettingsModule } from '../modules/settings/settings.module';
@Global()
@Module({
imports: [SettingsModule],
providers: [
ThemeLoaderService,
EventStatusService,
SseHubService,
LoginBackoffService,
RegistrationRateLimitService,
CsrfMiddleware,
],
exports: [
ThemeLoaderService,
EventStatusService,
SseHubService,
LoginBackoffService,
RegistrationRateLimitService,
CsrfMiddleware,
],
})
export class CommonModule {}
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../database/entities/user.entity';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
@@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const SKIP_CSRF_KEY = 'skipCsrf';
export const SkipCsrf = () => SetMetadata(SKIP_CSRF_KEY, true);
+37
View File
@@ -0,0 +1,37 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { ErrorCode, ERROR_CODES } from './error-codes';
export class ApiError extends HttpException {
constructor(
public readonly code: ErrorCode,
message: string,
status: HttpStatus,
public readonly details?: unknown,
) {
super({ code, message, details }, status);
}
static validation(message = 'Validation failed', details?: unknown): ApiError {
return new ApiError(ERROR_CODES.VALIDATION_FAILED, message, HttpStatus.BAD_REQUEST, details);
}
static unauthorized(message = 'Unauthorized'): ApiError {
return new ApiError(ERROR_CODES.UNAUTHORIZED, message, HttpStatus.UNAUTHORIZED);
}
static forbidden(message = 'Forbidden'): ApiError {
return new ApiError(ERROR_CODES.FORBIDDEN, message, HttpStatus.FORBIDDEN);
}
static notFound(message = 'Not found'): ApiError {
return new ApiError(ERROR_CODES.NOT_FOUND, message, HttpStatus.NOT_FOUND);
}
static conflict(code: ErrorCode, message: string): ApiError {
return new ApiError(code, message, HttpStatus.CONFLICT);
}
static internal(message = 'Internal server error'): ApiError {
return new ApiError(ERROR_CODES.INTERNAL, message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
+18
View File
@@ -0,0 +1,18 @@
export const ERROR_CODES = {
VALIDATION_FAILED: 'VALIDATION_FAILED',
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
LAST_ADMIN: 'LAST_ADMIN',
SYSTEM_INITIALIZED: 'SYSTEM_INITIALIZED',
RATE_LIMITED: 'RATE_LIMITED',
CSRF_INVALID: 'CSRF_INVALID',
WEAK_PASSWORD: 'WEAK_PASSWORD',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
TOKEN_REVOKED: 'TOKEN_REVOKED',
THEME_INVALID: 'THEME_INVALID',
INTERNAL: 'INTERNAL',
} as const;
export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
@@ -0,0 +1,81 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { ApiError } from '../errors/api-error';
import { ERROR_CODES } from '../errors/error-codes';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const req = ctx.getRequest();
const path = req?.url ?? '';
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let body: { code: string; message: string; details?: unknown; path: string; timestamp: string };
if (exception instanceof ApiError) {
status = exception.getStatus();
const resp = exception.getResponse() as any;
body = {
code: resp.code,
message: resp.message,
details: resp.details,
path,
timestamp: new Date().toISOString(),
};
} else if (exception instanceof HttpException) {
status = exception.getStatus();
const resp = exception.getResponse() as any;
const message = typeof resp === 'string' ? resp : resp?.message ?? exception.message;
const code = typeof resp === 'object' && resp?.code ? resp.code : this.codeFromStatus(status);
body = {
code,
message: Array.isArray(message) ? message.join('; ') : message,
details: typeof resp === 'object' ? resp?.details : undefined,
path,
timestamp: new Date().toISOString(),
};
} else {
this.logger.error(`Unhandled error: ${(exception as Error)?.message}`, (exception as Error)?.stack);
const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : (exception as Error)?.message ?? 'Internal server error';
body = {
code: ERROR_CODES.INTERNAL,
message,
path,
timestamp: new Date().toISOString(),
};
}
if (status >= 500) {
this.logger.error(`${status} ${body.code} ${path}: ${body.message}`);
} else if (status >= 400) {
this.logger.warn(`${status} ${body.code} ${path}: ${body.message}`);
}
httpAdapter.reply(ctx.getResponse(), body, status);
}
private codeFromStatus(status: number): string {
switch (status) {
case 400: return ERROR_CODES.VALIDATION_FAILED;
case 401: return ERROR_CODES.UNAUTHORIZED;
case 403: return ERROR_CODES.FORBIDDEN;
case 404: return ERROR_CODES.NOT_FOUND;
case 409: return ERROR_CODES.CONFLICT;
case 429: return ERROR_CODES.RATE_LIMITED;
default: return ERROR_CODES.INTERNAL;
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class AdminGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
const req = context.switchToHttp().getRequest();
if (!req.user) throw new ForbiddenException('Authentication required');
if (!required || required.length === 0) return true;
if (!required.includes(req.user.role)) {
throw new ForbiddenException('Admin role required');
}
return true;
}
}
@@ -0,0 +1,20 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { UserRole } from '../../database/entities/user.entity';
import { ApiError } from '../errors/api-error';
import { ERROR_CODES } from '../errors/error-codes';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) return true;
const req = context.switchToHttp().getRequest();
if (!req.user) throw ApiError.unauthorized();
if (!required.includes(req.user.role)) {
throw new ForbiddenException('Insufficient role');
}
return true;
}
}
@@ -0,0 +1,10 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(map((data) => ({ data })));
}
}
@@ -0,0 +1,98 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import * as crypto from 'crypto';
import { ConfigService } from '@nestjs/config';
import { ApiError } from '../errors/api-error';
import { ERROR_CODES } from '../errors/error-codes';
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const COOKIE_NAME = 'csrf';
const HEADER_NAME = 'x-csrf-token';
const SKIP_PATH_PREFIXES = ['/api/v1/auth/register-first-admin', '/api/v1/auth/login'];
function isSkipped(path: string): boolean {
return SKIP_PATH_PREFIXES.some((p) => path === p || path.startsWith(p + '/'));
}
function readCookie(req: Request): string | null {
const header = req.headers.cookie;
if (!header) return null;
const parts = header.split(';');
for (const part of parts) {
const [k, ...v] = part.trim().split('=');
if (k === COOKIE_NAME) return v.join('=');
}
return null;
}
/**
* Resolve the request's effective path. In NestJS mounted middleware,
* `req.path` may already be stripped to '/' when the controller is reached
* through a nested router; use the original URL as the authoritative source.
*/
function requestPath(req: Request): string {
const orig = req.originalUrl || req.url || req.path || '/';
const qIdx = orig.indexOf('?');
return qIdx >= 0 ? orig.slice(0, qIdx) : orig;
}
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return crypto.timingSafeEqual(bufA, bufB);
}
@Injectable()
export class CsrfMiddleware implements NestMiddleware {
constructor(private readonly config: ConfigService) {}
/**
* Public helper: ensure a `csrf` cookie exists on the response and return its value.
* If a token already exists in the request cookies, that one is returned and re-set.
* Otherwise a new one is minted, set on the response, and returned.
*/
setOrGetCsrfToken(req: Request, res: Response): string {
const existing = readCookie(req);
if (existing) {
this.writeCookie(res, existing);
return existing;
}
const token = crypto.randomBytes(24).toString('base64url');
this.writeCookie(res, token);
return token;
}
use(req: Request, res: Response, next: NextFunction): void {
const path = requestPath(req);
if (isSkipped(path)) {
this.setOrGetCsrfToken(req, res);
return next();
}
if (!UNSAFE_METHODS.has(req.method)) {
this.setOrGetCsrfToken(req, res);
return next();
}
this.setOrGetCsrfToken(req, res);
const cookieToken = readCookie(req);
const headerToken = (req.headers[HEADER_NAME] as string | undefined) ?? '';
if (!cookieToken || !headerToken || !safeEqual(cookieToken, headerToken)) {
throw new ApiError(ERROR_CODES.CSRF_INVALID, 'CSRF token missing or invalid', 403);
}
next();
}
private writeCookie(res: Response, token: string): void {
const tls = this.config.get<boolean>('TLS_ENABLED', false);
res.cookie(COOKIE_NAME, token, {
httpOnly: false,
sameSite: tls ? 'strict' : 'lax',
secure: tls,
path: '/',
});
}
}
@@ -0,0 +1,17 @@
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
import { ZodSchema } from 'zod';
import { ApiError } from '../errors/api-error';
@Injectable()
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodSchema) {}
transform(value: any, _metadata: ArgumentMetadata): any {
const parsed = this.schema.safeParse(value);
if (!parsed.success) {
const details = parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message }));
throw ApiError.validation('Request validation failed', details);
}
return parsed.data;
}
}
@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { SettingsService } from '../../modules/settings/settings.module';
export type EventStatusName = 'Stopped' | 'Running';
export interface EventStatus {
status: EventStatusName;
startUtc: string;
endUtc: string;
serverNowUtc: string;
countdownMs: number;
}
@Injectable()
export class EventStatusService {
constructor(private readonly settings: SettingsService) {}
async getStatus(now: Date = new Date()): Promise<EventStatus> {
const startStr = await this.settings.get('eventStartUtc');
const endStr = await this.settings.get('eventEndUtc');
const start = new Date(startStr);
const end = new Date(endStr);
const nowMs = now.getTime();
const startMs = start.getTime();
const endMs = end.getTime();
let status: EventStatusName = 'Stopped';
let countdownMs = 0;
if (nowMs < startMs) {
status = 'Stopped';
countdownMs = startMs - nowMs;
} else if (nowMs <= endMs) {
status = 'Running';
countdownMs = endMs - nowMs;
} else {
status = 'Stopped';
countdownMs = 0;
}
return {
status,
startUtc: start.toISOString(),
endUtc: end.toISOString(),
serverNowUtc: now.toISOString(),
countdownMs,
};
}
}
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
const MAX_FAILS = 5;
const BACKOFF_MS = [1_000, 5_000, 30_000, 120_000, 600_000, 1_800_000];
@Injectable()
export class LoginBackoffService {
private counters = new Map<string, { count: number; blockedUntil: number }>();
private key(ip: string, username: string): string {
return `${ip}::${username}`;
}
isBlocked(ip: string, username: string, now: number = Date.now()): number {
const k = this.key(ip, username);
const entry = this.counters.get(k);
if (!entry) return 0;
if (entry.blockedUntil > now) return entry.blockedUntil - now;
return 0;
}
recordFailure(ip: string, username: string, now: number = Date.now()): void {
const k = this.key(ip, username);
const entry = this.counters.get(k) ?? { count: 0, blockedUntil: 0 };
entry.count++;
if (entry.count > MAX_FAILS) {
const idx = Math.min(entry.count - MAX_FAILS - 1, BACKOFF_MS.length - 1);
entry.blockedUntil = now + BACKOFF_MS[idx];
} else if (entry.count === MAX_FAILS) {
entry.blockedUntil = now + BACKOFF_MS[0];
}
this.counters.set(k, entry);
}
reset(ip: string, username: string): void {
this.counters.delete(this.key(ip, username));
}
}
@@ -0,0 +1,21 @@
import { Injectable } from '@nestjs/common';
const WINDOW_MS = 60_000;
const MAX_PER_WINDOW = 10;
@Injectable()
export class RegistrationRateLimitService {
private hits: Map<string, number[]> = new Map();
isAllowed(ip: string, now: number = Date.now()): boolean {
const arr = (this.hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
this.hits.set(ip, arr);
return arr.length < MAX_PER_WINDOW;
}
record(ip: string, now: number = Date.now()): void {
const arr = (this.hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
arr.push(now);
this.hits.set(ip, arr);
}
}
@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { Subject, Observable } from 'rxjs';
@Injectable()
export class SseHubService {
private readonly eventSubject = new Subject<any>();
private readonly scoreboardSubject = new Subject<any>();
emitEvent(payload: any): void {
this.eventSubject.next(payload);
}
emitScoreboard(payload: any): void {
this.scoreboardSubject.next(payload);
}
event$(): Observable<any> {
return this.eventSubject.asObservable();
}
scoreboard$(): Observable<any> {
return this.scoreboardSubject.asObservable();
}
}
+14
View File
@@ -0,0 +1,14 @@
export const THEME_IDS = [
'classic',
'midnight',
'sunset',
'forest',
'cyber',
'paper',
'crimson',
'ocean',
'neon',
'monochrome',
] as const;
export type ThemeId = (typeof THEME_IDS)[number];
+19
View File
@@ -0,0 +1,19 @@
export type ThemeTokens = {
primary: string;
secondary: string;
accent: string;
surface: string;
text: string;
success: string;
warning: string;
danger: string;
fontFamily: string;
radii: { sm: string; md: string; lg: string };
spacingScale: number[];
};
export type Theme = {
id: string;
name: string;
tokens: ThemeTokens;
};
+116
View File
@@ -0,0 +1,116 @@
import { OpenAPIObject } from '@nestjs/swagger';
/**
* Convert a NestJS-generated OpenAPI 3.0 document to a JSON Schema 2020-12-
* compatible OpenAPI 3.1 document.
*
* The two specs differ mainly in:
* - `openapi` version string.
* - JSON Schema dialect: 3.0 used a custom subset of JSON Schema Draft 5
* (with `nullable: true`); 3.1 uses JSON Schema 2020-12 natively
* (nullable via `type: ['T', 'null']`).
* - `info` no longer requires `license` / `contact`.
*
* The `paths`/`components`/`security` structures are otherwise identical,
* and we keep all NestJS-generated content intact.
*/
export function toOpenApi31(doc: Record<string, any>): OpenAPIObject {
const next: Record<string, any> = { ...doc, openapi: '3.1.0' };
// Normalize info so it has every required 3.1 field.
if (!next.info || typeof next.info !== 'object') {
next.info = { title: 'HIPCTF API', version: '1.0.0' };
} else {
next.info = {
title: next.info.title ?? 'HIPCTF API',
version: next.info.version ?? '1.0.0',
...(next.info.description ? { description: next.info.description } : {}),
...(next.info.contact ? { contact: next.info.contact } : {}),
...(next.info.license ? { license: next.info.license } : {}),
};
}
// Rewrite component schemas: replace `nullable: true` with the JSON
// Schema 2020-12 `type: ['<orig>', 'null']` union. Leave everything else
// untouched so existing component definitions remain valid.
const components = next.components ?? {};
const schemas = components.schemas ?? {};
for (const [name, schema] of Object.entries<any>(schemas)) {
schemas[name] = rewriteNullable(schema);
}
components.schemas = schemas;
next.components = components;
// Walk paths and parameters and rewrite any remaining `nullable: true`.
if (next.paths && typeof next.paths === 'object') {
for (const [pathKey, methods] of Object.entries<any>(next.paths)) {
for (const [method, op] of Object.entries<any>(methods ?? {})) {
if (!op || typeof op !== 'object') continue;
if (Array.isArray(op.parameters)) {
op.parameters = op.parameters.map((p: any) => rewriteNullable(p));
}
if (op.requestBody) op.requestBody = rewriteNullable(op.requestBody);
const body = op.requestBody?.content;
if (body && typeof body === 'object') {
for (const media of Object.values<any>(body)) {
if (media?.schema) media.schema = rewriteNullable(media.schema);
}
}
if (op.responses && typeof op.responses === 'object') {
for (const [code, resp] of Object.entries<any>(op.responses)) {
op.responses[code] = rewriteNullable(resp);
const respBody = op.responses[code]?.content;
if (respBody && typeof respBody === 'object') {
for (const media of Object.values<any>(respBody)) {
if (media?.schema) media.schema = rewriteNullable(media.schema);
}
}
}
}
}
}
}
return next as OpenAPIObject;
}
/**
* Recursively walk a schema and convert legacy `nullable: true` markers
* into JSON Schema 2020-12 `type: ['<orig>', 'null']` unions. Preserve any
* other keyword (`$ref`, `format`, `enum`, etc.).
*/
function rewriteNullable(node: any): any {
if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
// If this is a $ref, leave it alone.
if (typeof node.$ref === 'string') return node;
const out: Record<string, any> = { ...node };
if (out.nullable === true) {
delete out.nullable;
if (typeof out.type === 'string') {
out.type = [out.type, 'null'];
} else if (Array.isArray(out.type)) {
if (!out.type.includes('null')) out.type = [...out.type, 'null'];
} else if (!out.oneOf && !out.anyOf && !out.allOf) {
out.type = ['null'];
}
}
for (const k of Object.keys(out)) {
if (k === 'properties' && out.properties && typeof out.properties === 'object') {
for (const p of Object.keys(out.properties)) {
out.properties[p] = rewriteNullable(out.properties[p]);
}
} else if (k === 'items') {
out.items = rewriteNullable(out.items);
} else if (k === 'schema') {
out.schema = rewriteNullable(out.schema);
} else if ((k === 'oneOf' || k === 'anyOf' || k === 'allOf') && Array.isArray(out[k])) {
out[k] = out[k].map((v: any) => rewriteNullable(v));
}
}
return out;
}
@@ -0,0 +1,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],
},
},
];
+75
View File
@@ -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,
});
}