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,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