Harden scaffold (reviewer followups)

- main.ts awaits DatabaseInitService.init() before app.listen(), ensuring
  migrations + seed run before the HTTP server accepts traffic.
- AppModule now uses NestModule with consumer.apply() no longer needed for
  CSRF (registered globally via app.use after body parsers in main.ts).
- JwtAuthGuard extended from AuthGuard('jwt') so protected endpoints
  actually validate the bearer token.
- Admin controller now uses Zod-validated DTOs for body/path/query:
  createUser, updateUserRole, userIdParam, listUsersQuery; with @Public
  / @Roles decorators and AdminGuard applied.
- Multer upload module + controller (POST /api/v1/uploads/category-icon
  and /challenge-file) with safe-filename strategy, configured
  UPLOAD_SIZE_LIMIT, admin-only via AdminGuard, served via /uploads
  static handler.
- ThemeLoaderService now requires all 10 canonical theme ids at startup,
  backfilling missing themes from built-ins with a warning; validates the
  configured themeKey setting and falls back to 'classic' if invalid;
  gracefully tolerates missing setting table during early boot.
- Test suite expanded to 76 tests / 17 suites; new specs:
  admin-validation, uploads, theme-required, database-init.
This commit is contained in:
OpenVelo Agent
2026-07-21 14:04:27 +00:00
parent af3c24275d
commit ac6c834525
23 changed files with 934 additions and 75 deletions
+2
View File
@@ -24,6 +24,7 @@
"class-validator": "^0.14.1",
"cookie-parser": "^1.4.6",
"helmet": "^7.1.0",
"multer": "^2.0.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
@@ -37,6 +38,7 @@
"@types/better-sqlite3": "^7.6.10",
"@types/cookie-parser": "^1.4.7",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8",
+4 -7
View File
@@ -1,4 +1,4 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
import { validateEnv } from './config/env.schema';
@@ -9,10 +9,10 @@ import { UsersModule } from './modules/users/users.module';
import { SystemModule } from './modules/system/system.module';
import { SettingsModule } from './modules/settings/settings.module';
import { AdminModule } from './modules/admin/admin.module';
import { UploadsModule } from './modules/uploads/uploads.module';
import { FrontendModule } from './frontend/frontend.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
@Module({
imports: [
@@ -28,6 +28,7 @@ import { CsrfMiddleware } from './common/middleware/csrf.middleware';
UsersModule,
SystemModule,
AdminModule,
UploadsModule,
FrontendModule,
],
providers: [
@@ -35,8 +36,4 @@ import { CsrfMiddleware } from './common/middleware/csrf.middleware';
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(CsrfMiddleware).forRoutes('*');
}
}
export class AppModule {}
@@ -4,6 +4,8 @@ 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 {
@@ -12,28 +14,26 @@ export class ThemeLoaderService implements OnModuleInit {
private defaultThemeId: ThemeId = 'classic';
private themesDir = './themes';
constructor(private readonly config: ConfigService) {}
constructor(
private readonly config: ConfigService,
private readonly settings: SettingsService,
) {}
async onModuleInit(): Promise<void> {
this.themesDir = this.config.get<string>('THEMES_DIR', './themes');
this.loadAll();
this.loadFromDisk();
this.backfillMissing();
await this.validateConfiguredKey();
this.logger.log(`Loaded ${this.themes.size} themes; default='${this.defaultThemeId}'`);
}
private loadAll(): void {
private loadFromDisk(): void {
const absDir = path.resolve(this.themesDir);
if (!fs.existsSync(absDir)) {
this.logger.warn(`Themes directory not found at ${absDir}; using built-in defaults`);
this.installBuiltins();
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'));
if (files.length === 0) {
this.logger.warn(`No theme files found in ${absDir}; using built-in defaults`);
this.installBuiltins();
return;
}
const validIds = new Set<string>(THEME_IDS);
for (const file of files) {
try {
@@ -52,10 +52,50 @@ export class ThemeLoaderService implements OnModuleInit {
this.logger.warn(`Failed to parse theme file ${file}: ${(err as Error).message}`);
}
}
}
if (this.themes.size === 0) {
this.logger.warn('No valid themes loaded; using built-in defaults');
this.installBuiltins();
/**
* 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;
}
}
@@ -69,11 +109,6 @@ export class ThemeLoaderService implements OnModuleInit {
return true;
}
private installBuiltins(): void {
const builtins = BUILTIN_THEMES;
for (const t of builtins) this.themes.set(t.id, t);
}
getTheme(id: string | null | undefined): Theme {
if (!id) return this.themes.get(this.defaultThemeId)!;
const t = this.themes.get(id);
@@ -89,6 +124,11 @@ export class ThemeLoaderService implements OnModuleInit {
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[] = [
+51
View File
@@ -1,4 +1,8 @@
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;
@@ -21,4 +25,51 @@ export function parseUploadSizeLimit(value: string | undefined): number {
export function buildUploadLimits(config: ConfigService): UploadLimits {
const v = config.get<string>('UPLOAD_SIZE_LIMIT', '50mb');
return { fileSize: parseUploadSizeLimit(v) };
}
/**
* Produce a safe, collision-resistant filename from the original upload
* filename. Strips directory traversal, lowercases, restricts to a safe
* charset, caps the stem length, and appends a short random suffix so
* identical names from different uploads never overwrite each other.
*/
export function safeFilename(original: string): string {
const base = path.basename(original || 'file').toLowerCase();
const cleaned = base
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 120);
const stem = cleaned || 'file';
const dot = stem.lastIndexOf('.');
const head = dot > 0 ? stem.slice(0, dot) : stem;
const ext = dot > 0 ? stem.slice(dot) : '';
const suffix = crypto.randomBytes(4).toString('hex');
const safeHead = (head || 'file').slice(0, 100);
return `${safeHead}-${suffix}${ext}`;
}
/**
* Build a configured Multer instance with a per-route `fileSize` limit
* (taken from `UPLOAD_SIZE_LIMIT`) and safe-filename storage at the
* supplied destination directory.
*/
export interface MulterOptions {
destination?: string;
fieldName?: string;
fileSize?: number;
}
export function buildMulter(config: ConfigService, opts: MulterOptions = {}): multer.Multer {
const limits = opts.fileSize !== undefined
? { fileSize: opts.fileSize }
: buildUploadLimits(config);
const dest = opts.destination ?? path.resolve(config.get<string>('UPLOAD_DIR', '/data/hipctf/uploads'));
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
return multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, dest),
filename: (_req, file, cb) => cb(null, safeFilename(file.originalname)),
}),
limits,
});
}
@@ -0,0 +1,76 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
@Injectable()
export class DatabaseInitService implements OnApplicationBootstrap {
private readonly logger = new Logger(DatabaseInitService.name);
private initialized = false;
constructor(
private readonly dataSource: DataSource,
private readonly config: ConfigService,
) {}
/**
* Explicit, awaitable database initialization.
*
* - Initializes the DataSource (idempotent — TypeORM returns the existing
* connection if already initialized).
* - Runs all pending migrations in order.
* - Verifies that the expected schema and seeded data are present.
*
* `main.ts` calls this BEFORE `app.listen()` so the HTTP server never
* accepts traffic until the DB is fully migrated and seeded.
*/
async init(): Promise<void> {
if (this.initialized) return;
if (!this.dataSource.isInitialized) {
await this.dataSource.initialize();
}
const pending = await this.dataSource.showMigrations();
const pendingList: string[] = Array.isArray(pending) ? pending : [];
this.logger.log(`Pending migrations: ${pendingList.length}`);
if (pendingList.length > 0 || !(await this.hasCategoryTable())) {
const ran = await this.dataSource.runMigrations({ transaction: 'each' });
this.logger.log(`Ran ${ran.length} migration(s): ${ran.map((m) => m.name).join(', ')}`);
} else {
this.logger.log('Database schema is up to date');
}
try {
await this.verifySeed();
} catch (e) {
this.logger.warn(`verifySeed skipped: ${(e as Error).message}`);
}
this.initialized = true;
}
async onApplicationBootstrap(): Promise<void> {
await this.init();
}
private async verifySeed(): Promise<void> {
const categoryCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "category"').then((rows: any[]) => Number(rows[0]?.c ?? 0));
const settingsCount = await this.dataSource.query('SELECT COUNT(*) AS c FROM "setting"').then((rows: any[]) => Number(rows[0]?.c ?? 0));
this.logger.log(`Seed verified: ${categoryCount} categories, ${settingsCount} settings`);
if (categoryCount < 6) {
this.logger.warn(`Expected at least 6 seeded categories, found ${categoryCount}`);
}
}
private async hasCategoryTable(): Promise<boolean> {
try {
const rows: any[] = await this.dataSource.query("SELECT name FROM sqlite_master WHERE type='table' AND name='category'");
return rows.length > 0;
} catch {
return false;
}
}
getDbPath(): string {
return this.config.get<string>('DATABASE_PATH', '/data/hipctf/db.sqlite');
}
}
+6 -12
View File
@@ -1,4 +1,4 @@
import { Module, Global, OnModuleInit, Logger } from '@nestjs/common';
import { Module, Global, Logger } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import * as fs from 'fs';
@@ -13,6 +13,7 @@ import { RefreshTokenEntity } from './entities/refresh-token.entity';
import { BlogPostEntity } from './entities/blog-post.entity';
import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema';
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
import { DatabaseInitService } from './database-init.service';
const ENTITIES = [
UserEntity,
@@ -45,7 +46,7 @@ const MIGRATIONS = [InitSchema1700000000000, SeedSystemData1700000000100];
database: dbPath,
entities: ENTITIES,
migrations: MIGRATIONS,
migrationsRun: true,
migrationsRun: false,
synchronize: false,
logging: false,
};
@@ -53,16 +54,9 @@ const MIGRATIONS = [InitSchema1700000000000, SeedSystemData1700000000100];
}),
TypeOrmModule.forFeature(ENTITIES),
],
exports: [TypeOrmModule],
providers: [DatabaseInitService],
exports: [TypeOrmModule, DatabaseInitService],
})
export class DatabaseModule implements OnModuleInit {
export class DatabaseModule {
private readonly logger = new Logger(DatabaseModule.name);
constructor(private readonly config: ConfigService) {}
onModuleInit(): void {
const dbPath = this.config.get<string>('DATABASE_PATH', '/data/hipctf/db.sqlite');
if (dbPath === ':memory:' || dbPath.startsWith('file:')) return;
this.logger.log(`Database ready at ${dbPath}`);
}
}
+14 -2
View File
@@ -11,6 +11,8 @@ import * as path from 'path';
import * as express from 'express';
import { AppModule } from './app.module';
import { SpaFallbackMiddleware } from './frontend/spa.controller';
import { DatabaseInitService } from './database/database-init.service';
import { CsrfMiddleware } from './common/middleware/csrf.middleware';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
@@ -31,6 +33,8 @@ async function bootstrap(): Promise<void> {
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
await app.get(DatabaseInitService).init();
app.use(
helmet({
contentSecurityPolicy: {
@@ -58,9 +62,19 @@ async function bootstrap(): Promise<void> {
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
});
// Body parsers MUST run before the CSRF middleware so that route handlers
// can read `req.body` when the CsrfMiddleware short-circuits to next().
app.use(cookieParser());
app.use(express.json({ limit: bodyLimit }));
app.use(express.urlencoded({ extended: true, limit: bodyLimit }));
// CSRF: register globally via express so we have explicit ordering after
// the body parsers. (Nest's configure()-time middleware is registered
// before app.use middlewares, which would consume the body before the
// CsrfMiddleware short-circuits to next().)
const csrf = new CsrfMiddleware(config);
app.use((req, res, next) => csrf.use(req as any, res as any, next));
app.use('/uploads', express.static(uploadDir));
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
@@ -77,7 +91,6 @@ async function bootstrap(): Promise<void> {
jsonDocumentUrl: 'api/docs-json',
});
// Serve built SPA assets (chunks, css, js, images) before the SPA fallback so /api and /uploads still win.
if (fs.existsSync(frontendDist)) {
app.use(express.static(frontendDist, { index: false, fallthrough: true }));
const browserDir = path.join(frontendDist, 'browser');
@@ -86,7 +99,6 @@ async function bootstrap(): Promise<void> {
}
}
// SPA fallback as plain express middleware registered LAST so /api, /uploads, and asset static all win first.
const spa = new SpaFallbackMiddleware(config);
app.use((req, res, next) => spa.use(req as any, res as any, next));
+32 -7
View File
@@ -6,14 +6,29 @@ import {
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { UserRole } from '../../database/entities/user.entity';
import { AdminGuard } from '../../common/guards/admin.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import {
CreateUserDtoSchema,
ListUsersQueryDtoSchema,
UpdateUserRoleDtoSchema,
UserIdParamDtoSchema,
} from './dto/admin.dto';
import { AdminService } from './admin.service';
/**
* All admin handlers use Zod validation via `ZodValidationPipe` and an
* INLINE body/param/query type. Using inline types (rather than class
* metatypes) avoids the Nest class-transformer pass which would otherwise
* overwrite the raw body before our pipe sees it.
*/
@ApiTags('admin')
@ApiBearerAuth()
@Controller('api/v1/admin')
@@ -24,25 +39,35 @@ export class AdminController {
@Get('users')
@ApiOperation({ summary: 'List all users (admin only)' })
list() {
return this.admin.listUsers();
list(
@Query(new ZodValidationPipe(ListUsersQueryDtoSchema))
query: { limit?: number; cursor?: string; role?: UserRole },
) {
return this.admin.listUsers(query);
}
@Patch('users/:id')
@ApiOperation({ summary: 'Update a user role (admin only)' })
update(@Param('id') id: string, @Body() body: { role: UserRole }) {
return this.admin.updateUserRole(id, body.role);
update(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole },
) {
return this.admin.updateUserRole(params.id, body.role);
}
@Delete('users/:id')
@ApiOperation({ summary: 'Delete a user (admin only)' })
async remove(@Param('id') id: string): Promise<void> {
await this.admin.deleteUser(id);
async remove(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
): Promise<void> {
await this.admin.deleteUser(params.id);
}
@Post('users')
@ApiOperation({ summary: 'Create a user (admin only)' })
create(@Body() body: { username: string; password: string; role: UserRole }) {
create(
@Body(new ZodValidationPipe(CreateUserDtoSchema)) body: { username: string; password: string; role: UserRole },
) {
return this.admin.createUser(body.username, body.password, body.role);
}
}
+23 -5
View File
@@ -6,16 +6,35 @@ import { UserEntity, UserRole } from '../../database/entities/user.entity';
import { UsersService } from '../users/users.service';
import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes';
import { validatePassword } from '../../common/utils/password-policy';
import { ConfigService } from '@nestjs/config';
export interface ListUsersQuery {
limit?: number;
cursor?: string;
role?: UserRole;
}
@Injectable()
export class AdminService {
constructor(
private readonly usersService: UsersService,
private readonly dataSource: DataSource,
private readonly config: ConfigService,
) {}
listUsers(): Promise<UserEntity[]> {
return this.usersService.listAll();
async listUsers(query: ListUsersQuery = {}): Promise<UserEntity[]> {
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
const repo = this.dataSource.getRepository(UserEntity);
const qb = repo
.createQueryBuilder('u')
.orderBy('u.createdAt', 'ASC')
.addOrderBy('u.id', 'ASC')
.limit(limit + 1);
if (query.role) qb.andWhere('u.role = :role', { role: query.role });
if (query.cursor) qb.andWhere('u.id > :cursor', { cursor: query.cursor });
const rows = await qb.getMany();
return rows.slice(0, limit);
}
async updateUserRole(targetId: string, role: UserRole): Promise<UserEntity> {
@@ -24,7 +43,6 @@ export class AdminService {
}
return this.dataSource.transaction(async (manager) => {
await this.usersService.enforceLastAdminInvariant(manager, targetId, role);
const repo = manager.getRepository(UserEntity);
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
if (!user) throw ApiError.notFound('User not found');
user.role = role;
@@ -36,13 +54,13 @@ export class AdminService {
async deleteUser(targetId: string): Promise<void> {
return this.dataSource.transaction(async (manager) => {
await this.usersService.enforceLastAdminInvariant(manager, targetId);
const repo = manager.getRepository(UserEntity);
const res = await repo.delete({ id: targetId });
const res = await manager.delete(UserEntity, { id: targetId });
if (!res.affected) throw ApiError.notFound('User not found');
});
}
async createUser(username: string, password: string, role: UserRole): Promise<UserEntity> {
validatePassword(password, this.config);
return this.dataSource.transaction(async (manager) => {
const existing = await manager.findOne(UserEntity, { where: { username } });
if (existing) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Username already taken');
@@ -0,0 +1,25 @@
import { z } from 'zod';
export const CreateUserDtoSchema = z.object({
username: z.string().trim().min(3).max(64).regex(/^[a-zA-Z0-9._-]+$/, 'Username may contain letters, digits, dot, underscore, dash'),
password: z.string().min(1).max(256),
role: z.enum(['admin', 'player']),
});
export type CreateUserDto = z.infer<typeof CreateUserDtoSchema>;
export const UpdateUserRoleDtoSchema = z.object({
role: z.enum(['admin', 'player']),
});
export type UpdateUserRoleDto = z.infer<typeof UpdateUserRoleDtoSchema>;
export const UserIdParamDtoSchema = z.object({
id: z.string().uuid(),
});
export type UserIdParamDto = z.infer<typeof UserIdParamDtoSchema>;
export const ListUsersQueryDtoSchema = z.object({
limit: z.coerce.number().int().positive().max(200).optional().default(50),
cursor: z.string().uuid().optional(),
role: z.enum(['admin', 'player']).optional(),
});
export type ListUsersQueryDto = z.infer<typeof ListUsersQueryDtoSchema>;
@@ -0,0 +1,65 @@
import { Controller, Post, UseGuards, UseInterceptors, Req, HttpCode, HttpStatus, BadRequestException } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
import { AdminGuard } from '../../common/guards/admin.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { parseUploadSizeLimit, safeFilename } from '../../common/utils/upload';
@ApiTags('uploads')
@ApiBearerAuth()
@Controller('api/v1/uploads')
@UseGuards(AdminGuard)
@Roles('admin')
export class UploadsController {
private readonly uploadDir: string;
private readonly globalLimit: number;
constructor(private readonly config: ConfigService) {
this.uploadDir = path.resolve(this.config.get<string>('UPLOAD_DIR', '/data/hipctf/uploads'));
this.globalLimit = parseUploadSizeLimit(this.config.get<string>('UPLOAD_SIZE_LIMIT', '50mb'));
}
@Post('category-icon')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Upload a category icon (admin only)' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file'))
async uploadCategoryIcon(@Req() req: any) {
return this.handleUpload(req, 'icons');
}
@Post('challenge-file')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Upload a challenge attachment (admin only)' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file'))
async uploadChallengeFile(@Req() req: any) {
return this.handleUpload(req, 'challenges');
}
private handleUpload(req: any, subdir: string) {
const file = req.file as any;
if (!file) throw new BadRequestException('No file uploaded under field "file"');
if (file.size > this.globalLimit) {
throw new BadRequestException(`File exceeds UPLOAD_SIZE_LIMIT (${file.size} > ${this.globalLimit})`);
}
const safeName = safeFilename(file.originalname || 'file');
const finalDir = path.join(this.uploadDir, subdir);
if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true });
const finalPath = path.join(finalDir, safeName);
// FileInterceptor uses memoryStorage; the bytes are in file.buffer.
fs.writeFileSync(finalPath, file.buffer);
const publicUrl = `/uploads/${subdir}/${safeName}`;
return {
id: safeName,
originalFilename: file.originalname,
storedPath: finalPath,
publicUrl,
size: file.size,
mimeType: file.mimetype,
};
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { UploadsController } from './uploads.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [UploadsController],
})
export class UploadsModule {}