AI Implementation feature(820): Project Scaffold and Platform Foundations (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user