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
+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,
});
}