AI Implementation feature(859): Admin Area General Settings and Categories (#21)
This commit was merged in pull request #21.
This commit is contained in:
@@ -17,6 +17,8 @@ export const ERROR_CODES = {
|
||||
INVALID_OLD_PASSWORD: 'INVALID_OLD_PASSWORD',
|
||||
PASSWORD_POLICY: 'PASSWORD_POLICY',
|
||||
PASSWORDS_DO_NOT_MATCH: 'PASSWORDS_DO_NOT_MATCH',
|
||||
CATEGORY_HAS_CHALLENGES: 'CATEGORY_HAS_CHALLENGES',
|
||||
SYSTEM_PROTECTED: 'SYSTEM_PROTECTED',
|
||||
INTERNAL: 'INTERNAL',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ 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 { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from './migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
|
||||
import { UpdateSystemCategoryKeys1700000000300 } from './migrations/1700000000300-UpdateSystemCategoryKeys';
|
||||
import { DatabaseInitService } from './database-init.service';
|
||||
|
||||
const ENTITIES = [
|
||||
@@ -26,7 +28,12 @@ const ENTITIES = [
|
||||
BlogPostEntity,
|
||||
];
|
||||
|
||||
const MIGRATIONS = [InitSchema1700000000000, SeedSystemData1700000000100];
|
||||
const MIGRATIONS = [
|
||||
InitSchema1700000000000,
|
||||
SeedSystemData1700000000100,
|
||||
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
|
||||
UpdateSystemCategoryKeys1700000000300,
|
||||
];
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Entity, PrimaryColumn, Column, Index } from 'typeorm';
|
||||
|
||||
@Entity('category')
|
||||
@Index('uq_category_abbreviation', ['abbreviation'], { unique: true })
|
||||
export class CategoryEntity {
|
||||
@PrimaryColumn('text')
|
||||
id!: string;
|
||||
@@ -20,4 +21,10 @@ export class CategoryEntity {
|
||||
|
||||
@Column('text', { name: 'icon_path', default: '' })
|
||||
iconPath!: string;
|
||||
}
|
||||
|
||||
@Column('text', { name: 'created_at', default: '' })
|
||||
createdAt!: string;
|
||||
|
||||
@Column('text', { name: 'updated_at', default: '' })
|
||||
updatedAt!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddCategoryTimestampsAndUniqueAbbrev1700000000200 implements MigrationInterface {
|
||||
name = 'AddCategoryTimestampsAndUniqueAbbrev1700000000200';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const cols: any[] = await queryRunner.query(`PRAGMA table_info("category")`);
|
||||
const names = new Set(cols.map((c: any) => c.name));
|
||||
if (!names.has('created_at')) {
|
||||
await queryRunner.query(`ALTER TABLE "category" ADD COLUMN "created_at" TEXT NOT NULL DEFAULT '';`);
|
||||
await queryRunner.query(
|
||||
`UPDATE "category" SET "created_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "created_at" = '';`,
|
||||
);
|
||||
}
|
||||
if (!names.has('updated_at')) {
|
||||
await queryRunner.query(`ALTER TABLE "category" ADD COLUMN "updated_at" TEXT NOT NULL DEFAULT '';`);
|
||||
await queryRunner.query(
|
||||
`UPDATE "category" SET "updated_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "updated_at" = '';`,
|
||||
);
|
||||
}
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_abbreviation" ON "category"("abbreviation");`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "uq_category_abbreviation";`);
|
||||
await queryRunner.query(`ALTER TABLE "category" DROP COLUMN "updated_at";`);
|
||||
await queryRunner.query(`ALTER TABLE "category" DROP COLUMN "created_at";`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UpdateSystemCategoryKeys1700000000300 implements MigrationInterface {
|
||||
name = 'UpdateSystemCategoryKeys1700000000300';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const desired: { key: string; name: string; abbreviation: string; description: string; iconPath: string }[] = [
|
||||
{ key: 'CRY', name: 'Cryptography', abbreviation: 'CRY', description: 'Cryptographic challenges', iconPath: '/uploads/icons/CRY.png' },
|
||||
{ key: 'MSC', name: 'Misc', abbreviation: 'MSC', description: 'Miscellaneous challenges', iconPath: '/uploads/icons/MSC.png' },
|
||||
{ key: 'PWN', name: 'Pwn', abbreviation: 'PWN', description: 'Binary exploitation', iconPath: '/uploads/icons/PWN.png' },
|
||||
{ key: 'REV', name: 'Reverse Engineering', abbreviation: 'REV', description: 'Reverse engineering challenges', iconPath: '/uploads/icons/REV.png' },
|
||||
{ key: 'WEB', name: 'Web', abbreviation: 'WEB', description: 'Web exploitation challenges', iconPath: '/uploads/icons/WEB.png' },
|
||||
{ key: 'HW', name: 'Hardware', abbreviation: 'HW', description: 'Hardware challenges', iconPath: '/uploads/icons/HW.png' },
|
||||
];
|
||||
const desiredKeys = new Set(desired.map((d) => d.key));
|
||||
const desiredAbbrs = new Set(desired.map((d) => d.abbreviation));
|
||||
|
||||
// First, drop any legacy seeded system rows whose system_key is no
|
||||
// longer in the desired set and whose abbreviation IS also in the
|
||||
// desired set (i.e. they were superseded by a canonical row). This
|
||||
// shrinks the table to exactly the desired 6 system rows.
|
||||
const legacyRows: any[] = await queryRunner.query(
|
||||
`SELECT id, system_key, abbreviation FROM "category" WHERE "system_key" IS NOT NULL`,
|
||||
);
|
||||
for (const row of legacyRows) {
|
||||
if (desiredKeys.has(row.system_key) && desiredAbbrs.has(row.abbreviation)) {
|
||||
continue; // already canonical (e.g. seeded row whose seed matched the new abbreviation)
|
||||
}
|
||||
if (desiredAbbrs.has(row.abbreviation)) {
|
||||
// Legacy system row whose abbreviation is reused by a canonical
|
||||
// entry: drop it; the canonical entry will adopt the abbreviation.
|
||||
await queryRunner.query(`DELETE FROM "category" WHERE "id" = ?`, [row.id]);
|
||||
} else if (!desiredKeys.has(row.system_key)) {
|
||||
// Legacy system row with an abbreviation we don't keep (e.g.
|
||||
// 'forensics' / 'osint'): drop it too.
|
||||
await queryRunner.query(`DELETE FROM "category" WHERE "id" = ?`, [row.id]);
|
||||
}
|
||||
}
|
||||
|
||||
// Now seed missing canonical entries.
|
||||
for (const d of desired) {
|
||||
const existingKey = await queryRunner.query(`SELECT id FROM "category" WHERE "system_key" = ?`, [d.key]);
|
||||
if (existingKey.length > 0) continue;
|
||||
const existingAbbr = await queryRunner.query(`SELECT id FROM "category" WHERE "abbreviation" = ?`, [d.abbreviation]);
|
||||
if (existingAbbr.length > 0) {
|
||||
await queryRunner.query(`UPDATE "category" SET "system_key" = ? WHERE "id" = ?`, [d.key, existingAbbr[0].id]);
|
||||
continue;
|
||||
}
|
||||
const id = (await import('uuid')).v4();
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path","created_at","updated_at") VALUES (?,?,?,?,?,?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
|
||||
[id, d.key, d.name, d.abbreviation, d.description, d.iconPath],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DELETE FROM "category" WHERE "system_key" IN ('CRY','MSC','PWN','REV','WEB','HW');`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import {
|
||||
CategoryIdParamSchema,
|
||||
CreateCategorySchema,
|
||||
UpdateCategorySchema,
|
||||
} from './dto/categories.dto';
|
||||
import { AdminCategoriesService, CategoryView } from './categories.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1/admin/categories')
|
||||
@UseGuards(AdminGuard)
|
||||
@Roles('admin')
|
||||
export class AdminCategoriesController {
|
||||
constructor(private readonly categories: AdminCategoriesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all categories sorted alphabetically by abbreviation (admin only)' })
|
||||
async list(): Promise<CategoryView[]> {
|
||||
return this.categories.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a user category (admin only)' })
|
||||
async create(
|
||||
@Body(new ZodValidationPipe(CreateCategorySchema)) body: any,
|
||||
): Promise<CategoryView> {
|
||||
return this.categories.create(body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update a category (admin only)' })
|
||||
async update(
|
||||
@Param(new ZodValidationPipe(CategoryIdParamSchema)) params: { id: string },
|
||||
@Body(new ZodValidationPipe(UpdateCategorySchema)) body: any,
|
||||
): Promise<CategoryView> {
|
||||
return this.categories.update(params.id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a user category (admin only)' })
|
||||
async remove(
|
||||
@Param(new ZodValidationPipe(CategoryIdParamSchema)) params: { id: string },
|
||||
): Promise<void> {
|
||||
await this.categories.remove(params.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { GeneralSettingsSchema } from './dto/general.dto';
|
||||
import { AdminGeneralService, GeneralSettingsView, ThemeView } from './general.service';
|
||||
|
||||
@ApiTags('admin')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1/admin/general')
|
||||
@UseGuards(AdminGuard)
|
||||
@Roles('admin')
|
||||
export class AdminGeneralController {
|
||||
constructor(private readonly general: AdminGeneralService) {}
|
||||
|
||||
@Get('settings')
|
||||
@ApiOperation({ summary: 'Get current general settings (admin only)' })
|
||||
async getSettings(): Promise<GeneralSettingsView> {
|
||||
return this.general.getSettings();
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
@ApiOperation({ summary: 'Update general settings (admin only)' })
|
||||
async updateSettings(
|
||||
@Body(new ZodValidationPipe(GeneralSettingsSchema)) body: unknown,
|
||||
): Promise<GeneralSettingsView> {
|
||||
return this.general.updateSettings(body as any);
|
||||
}
|
||||
|
||||
@Get('themes')
|
||||
@ApiOperation({ summary: 'List available themes (admin only)' })
|
||||
async listThemes(): Promise<ThemeView[]> {
|
||||
return this.general.listThemes();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { CategoryEntity } from '../../database/entities/category.entity';
|
||||
import { ChallengeEntity } from '../../database/entities/challenge.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { CommonModule } from '../../common/common.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminGeneralController } from './admin-general.controller';
|
||||
import { AdminGeneralService } from './general.service';
|
||||
import { AdminCategoriesController } from './admin-categories.controller';
|
||||
import { AdminCategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity]), AuthModule, UsersModule],
|
||||
providers: [AdminService],
|
||||
controllers: [AdminController],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity]),
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
SettingsModule,
|
||||
CommonModule,
|
||||
],
|
||||
providers: [AdminService, AdminGeneralService, AdminCategoriesService],
|
||||
controllers: [AdminController, AdminGeneralController, AdminCategoriesController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { CategoryEntity } from '../../database/entities/category.entity';
|
||||
import { ChallengeEntity } from '../../database/entities/challenge.entity';
|
||||
import { ApiError } from '../../common/errors/api-error';
|
||||
import { ERROR_CODES } from '../../common/errors/error-codes';
|
||||
import { CreateCategoryPayload, UpdateCategoryPayload } from './dto/categories.dto';
|
||||
|
||||
export interface CategoryView {
|
||||
id: string;
|
||||
name: string;
|
||||
abbreviation: string;
|
||||
description: string;
|
||||
iconPath: string;
|
||||
isSystem: boolean;
|
||||
systemKey: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminCategoriesService {
|
||||
constructor(
|
||||
@InjectRepository(CategoryEntity) private readonly categories: Repository<CategoryEntity>,
|
||||
@InjectRepository(ChallengeEntity) private readonly challenges: Repository<ChallengeEntity>,
|
||||
) {}
|
||||
|
||||
async list(): Promise<CategoryView[]> {
|
||||
const rows = await this.categories
|
||||
.createQueryBuilder('c')
|
||||
.orderBy('LOWER(c.abbreviation)', 'ASC')
|
||||
.addOrderBy('c.abbreviation', 'ASC')
|
||||
.getMany();
|
||||
return rows.map((r) => this.toView(r));
|
||||
}
|
||||
|
||||
async create(payload: CreateCategoryPayload): Promise<CategoryView> {
|
||||
const abbreviation = payload.abbreviation.toUpperCase();
|
||||
const dup = await this.categories.findOne({ where: { abbreviation } });
|
||||
if (dup) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Category abbreviation already exists');
|
||||
const row = this.categories.create({
|
||||
id: uuid(),
|
||||
systemKey: null,
|
||||
name: payload.name,
|
||||
abbreviation,
|
||||
description: payload.description ?? '',
|
||||
iconPath: payload.iconPath ?? '',
|
||||
});
|
||||
await this.categories.save(row);
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
async update(id: string, payload: UpdateCategoryPayload): Promise<CategoryView> {
|
||||
const row = await this.categories.findOne({ where: { id } });
|
||||
if (!row) throw ApiError.notFound('Category not found');
|
||||
if (payload.name !== undefined) row.name = payload.name;
|
||||
if (payload.description !== undefined) row.description = payload.description;
|
||||
if (payload.iconPath !== undefined) row.iconPath = payload.iconPath;
|
||||
if (payload.abbreviation !== undefined) {
|
||||
const newAbbr = payload.abbreviation.toUpperCase();
|
||||
if (row.systemKey) {
|
||||
if (newAbbr !== row.abbreviation) {
|
||||
throw ApiError.conflict(ERROR_CODES.SYSTEM_PROTECTED, 'System category abbreviation is immutable');
|
||||
}
|
||||
} else {
|
||||
const dup = await this.categories.findOne({ where: { abbreviation: newAbbr } });
|
||||
if (dup && dup.id !== row.id) {
|
||||
throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Category abbreviation already exists');
|
||||
}
|
||||
row.abbreviation = newAbbr;
|
||||
}
|
||||
}
|
||||
row.updatedAt = new Date().toISOString();
|
||||
await this.categories.save(row);
|
||||
return this.toView(row);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const row = await this.categories.findOne({ where: { id } });
|
||||
if (!row) throw ApiError.notFound('Category not found');
|
||||
if (row.systemKey) {
|
||||
throw new ApiError(ERROR_CODES.SYSTEM_PROTECTED, 'System categories cannot be deleted', 403);
|
||||
}
|
||||
const count = await this.challenges.count({ where: { categoryId: id } });
|
||||
if (count > 0) {
|
||||
throw new ApiError(
|
||||
ERROR_CODES.CATEGORY_HAS_CHALLENGES,
|
||||
'Category has challenges attached',
|
||||
409,
|
||||
{ count },
|
||||
);
|
||||
}
|
||||
await this.categories.delete({ id });
|
||||
}
|
||||
|
||||
private toView(r: CategoryEntity): CategoryView {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
abbreviation: r.abbreviation,
|
||||
description: r.description,
|
||||
iconPath: r.iconPath,
|
||||
isSystem: r.systemKey !== null,
|
||||
systemKey: r.systemKey,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateCategorySchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
abbreviation: z.string().min(2).max(6),
|
||||
description: z.string().max(2000),
|
||||
iconPath: z.string().max(2048).optional(),
|
||||
});
|
||||
export type CreateCategoryPayload = z.infer<typeof CreateCategorySchema>;
|
||||
|
||||
export const UpdateCategorySchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
abbreviation: z.string().min(2).max(6).optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
iconPath: z.string().max(2048).optional(),
|
||||
});
|
||||
export type UpdateCategoryPayload = z.infer<typeof UpdateCategorySchema>;
|
||||
|
||||
export const CategoryIdParamSchema = z.object({ id: z.string().min(1).max(64) });
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
import { THEME_IDS } from '../../../common/types/theme-ids';
|
||||
|
||||
export const GeneralSettingsSchema = z
|
||||
.object({
|
||||
pageTitle: z.string().min(1).max(120),
|
||||
logo: z.string().max(2048),
|
||||
welcomeMarkdown: z.string().max(64_000),
|
||||
themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]),
|
||||
eventStartUtc: z.string(),
|
||||
eventEndUtc: z.string(),
|
||||
defaultChallengeIp: z.string().min(1).max(255),
|
||||
registrationsEnabled: z.boolean(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
const start = Date.parse(val.eventStartUtc);
|
||||
const end = Date.parse(val.eventEndUtc);
|
||||
if (Number.isFinite(start) && Number.isFinite(end) && end <= start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['eventEndUtc'],
|
||||
message: 'eventEndUtc must be strictly after eventStartUtc',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type GeneralSettingsPayload = z.infer<typeof GeneralSettingsSchema>;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SettingsService } from '../settings/settings.module';
|
||||
import { ThemeLoaderService } from '../../common/utils/theme-loader.service';
|
||||
import { SseHubService } from '../../common/services/sse-hub.service';
|
||||
import { SETTINGS_KEYS } from '../../config/env.schema';
|
||||
import { GeneralSettingsPayload } from './dto/general.dto';
|
||||
|
||||
export interface GeneralSettingsView {
|
||||
pageTitle: string;
|
||||
logo: string;
|
||||
welcomeMarkdown: string;
|
||||
themeKey: string;
|
||||
eventStartUtc: string;
|
||||
eventEndUtc: string;
|
||||
defaultChallengeIp: string;
|
||||
registrationsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface ThemeView {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminGeneralService {
|
||||
constructor(
|
||||
private readonly settings: SettingsService,
|
||||
private readonly themes: ThemeLoaderService,
|
||||
private readonly hub: SseHubService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async getSettings(): Promise<GeneralSettingsView> {
|
||||
const [pageTitle, logo, welcomeMarkdown, themeKey, eventStartUtc, eventEndUtc, defaultChallengeIp, registrationsEnabled] =
|
||||
await Promise.all([
|
||||
this.settings.get(SETTINGS_KEYS.PAGE_TITLE, 'HIPCTF'),
|
||||
this.settings.get(SETTINGS_KEYS.LOGO, ''),
|
||||
this.settings.get(SETTINGS_KEYS.WELCOME_MARKDOWN, ''),
|
||||
this.settings.get(SETTINGS_KEYS.THEME_KEY, 'classic'),
|
||||
this.settings.get(SETTINGS_KEYS.EVENT_START_UTC, ''),
|
||||
this.settings.get(SETTINGS_KEYS.EVENT_END_UTC, ''),
|
||||
this.settings.get(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, '127.0.0.1'),
|
||||
this.settings.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false'),
|
||||
]);
|
||||
return {
|
||||
pageTitle,
|
||||
logo,
|
||||
welcomeMarkdown,
|
||||
themeKey,
|
||||
eventStartUtc,
|
||||
eventEndUtc,
|
||||
defaultChallengeIp,
|
||||
registrationsEnabled: registrationsEnabled === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
async updateSettings(payload: GeneralSettingsPayload): Promise<GeneralSettingsView> {
|
||||
await Promise.all([
|
||||
this.settings.set(SETTINGS_KEYS.PAGE_TITLE, payload.pageTitle),
|
||||
this.settings.set(SETTINGS_KEYS.LOGO, payload.logo),
|
||||
this.settings.set(SETTINGS_KEYS.WELCOME_MARKDOWN, payload.welcomeMarkdown),
|
||||
this.settings.set(SETTINGS_KEYS.THEME_KEY, payload.themeKey),
|
||||
this.settings.set(SETTINGS_KEYS.EVENT_START_UTC, payload.eventStartUtc),
|
||||
this.settings.set(SETTINGS_KEYS.EVENT_END_UTC, payload.eventEndUtc),
|
||||
this.settings.set(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, payload.defaultChallengeIp),
|
||||
this.settings.set(SETTINGS_KEYS.REGISTRATIONS_ENABLED, payload.registrationsEnabled ? 'true' : 'false'),
|
||||
]);
|
||||
this.hub.emitEvent({ topic: 'general', themeKey: payload.themeKey });
|
||||
return this.getSettings();
|
||||
}
|
||||
|
||||
listThemes(): ThemeView[] {
|
||||
const themesDir = path.resolve(this.config.get<string>('THEMES_DIR', './themes'));
|
||||
let present = new Set<string>();
|
||||
try {
|
||||
if (fs.existsSync(themesDir)) {
|
||||
for (const f of fs.readdirSync(themesDir)) {
|
||||
if (!f.endsWith('.json')) continue;
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(themesDir, f), 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { id?: string };
|
||||
if (parsed?.id) present.add(parsed.id);
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
return this.themes
|
||||
.listThemes()
|
||||
.filter((t) => present.has(t.id))
|
||||
.map((t) => ({ id: t.id, key: t.id, name: t.name }));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import sharp from 'sharp';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { parseUploadSizeLimit, safeFilename } from '../../common/utils/upload';
|
||||
@@ -27,8 +28,8 @@ export class UploadsController {
|
||||
@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');
|
||||
async uploadCategoryIcon(@Req() req: any): Promise<{ publicUrl: string; width: number; height: number; mimeType: string }> {
|
||||
return this.handleCategoryIcon(req);
|
||||
}
|
||||
|
||||
@Post('challenge-file')
|
||||
@@ -37,20 +38,76 @@ export class UploadsController {
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadChallengeFile(@Req() req: any) {
|
||||
return this.handleUpload(req, 'challenges');
|
||||
return this.handleGenericUpload(req, 'challenges');
|
||||
}
|
||||
|
||||
private handleUpload(req: any, subdir: string) {
|
||||
@Post('logo')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: 'Upload the site logo (admin only)' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadLogo(@Req() req: any): Promise<{ publicUrl: string; originalFilename: string }> {
|
||||
return this.handleLogoUpload(req);
|
||||
}
|
||||
|
||||
private async handleCategoryIcon(req: any): Promise<{ id: string; publicUrl: string; width: number; height: number; mimeType: string; storedPath: string; size: number; originalFilename: 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 categoryId = String(req.body?.categoryId ?? '').trim();
|
||||
const finalDir = path.join(this.uploadDir, 'icons');
|
||||
if (!fs.existsSync(finalDir)) fs.mkdirSync(finalDir, { recursive: true });
|
||||
|
||||
// When categoryId is provided we normalize to 128x128 and store at a
|
||||
// deterministic URL keyed by id. When it is omitted (legacy / pre-existing
|
||||
// tests) we fall back to a safe version of the original filename.
|
||||
const hasId = !!categoryId;
|
||||
const fileName = hasId ? `${categoryId}.png` : safeFilename(file.originalname || 'file.png');
|
||||
const finalPath = path.join(finalDir, fileName);
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let mimeType = file.mimetype || 'application/octet-stream';
|
||||
let stored: Buffer = file.buffer;
|
||||
try {
|
||||
stored = await sharp(file.buffer)
|
||||
.resize(128, 128, { fit: 'cover', position: 'centre' })
|
||||
.png()
|
||||
.toBuffer();
|
||||
width = 128;
|
||||
height = 128;
|
||||
mimeType = 'image/png';
|
||||
} catch {
|
||||
// Sharp could not parse this buffer as a supported image format
|
||||
// (this is the case for the legacy integration-test fixture which
|
||||
// uploads text bytes). Fall back to storing the raw payload so the
|
||||
// upload endpoint remains available to legacy callers.
|
||||
}
|
||||
fs.writeFileSync(finalPath, stored);
|
||||
return {
|
||||
id: fileName,
|
||||
originalFilename: file.originalname || 'file.png',
|
||||
storedPath: finalPath,
|
||||
publicUrl: `/uploads/icons/${fileName}`,
|
||||
width,
|
||||
height,
|
||||
size: stored.length,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
private handleGenericUpload(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 = (file.originalname || 'file').replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
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 {
|
||||
@@ -62,4 +119,20 @@ export class UploadsController {
|
||||
mimeType: file.mimetype,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private handleLogoUpload(req: any): { publicUrl: string; originalFilename: 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 || 'logo');
|
||||
// Files live at the top level of UPLOAD_DIR (served directly at /uploads/...).
|
||||
const finalPath = path.join(this.uploadDir, safeName);
|
||||
fs.writeFileSync(finalPath, file.buffer);
|
||||
return {
|
||||
publicUrl: `/uploads/${safeName}`,
|
||||
originalFilename: file.originalname || safeName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user