AI Implementation feature(861): Admin Area Challenges: List, Import/Export and Add/Edit Modal (#40)

This commit was merged in pull request #40.
This commit is contained in:
2026-07-22 20:26:30 +00:00
parent 1a40e5708a
commit 8dcbd48045
41 changed files with 4548 additions and 145 deletions
@@ -0,0 +1,242 @@
import {
AdminChallengeDetail,
AdminChallengeFile,
CreateChallengeBody,
UpdateChallengeBody,
} from '../../../core/services/admin.service';
export type Difficulty = 'LOW' | 'MEDIUM' | 'HIGH';
export type Protocol = 'NC' | 'WEB';
export const DEFAULT_INITIAL_POINTS = 100;
export const DEFAULT_DECAY_SOLVES = 10;
export function defaultMinimumPoints(initial: number): number {
if (!Number.isFinite(initial) || initial < 1) return 1;
return Math.max(1, Math.floor(initial / 2));
}
export interface ChallengeFormDefaults {
name: string;
descriptionMd: string;
categoryId: string;
difficulty: Difficulty;
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
protocol: Protocol;
port: number | null;
ipAddress: string;
enabled: boolean;
}
export function emptyChallengeFormDefaults(): ChallengeFormDefaults {
return {
name: '',
descriptionMd: '',
categoryId: '',
difficulty: 'MEDIUM',
initialPoints: DEFAULT_INITIAL_POINTS,
minimumPoints: defaultMinimumPoints(DEFAULT_INITIAL_POINTS),
decaySolves: DEFAULT_DECAY_SOLVES,
flag: '',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
};
}
export function prefillChallengeFormDefaults(detail: AdminChallengeDetail): ChallengeFormDefaults {
return {
name: detail.name,
descriptionMd: detail.descriptionMd,
categoryId: detail.categoryId,
difficulty: detail.difficulty,
initialPoints: detail.initialPoints,
minimumPoints: detail.minimumPoints,
decaySolves: detail.decaySolves,
flag: detail.flag,
protocol: detail.protocol,
port: detail.port,
ipAddress: detail.ipAddress,
enabled: detail.enabled,
};
}
export interface ChallengeFormErrors {
name?: string;
descriptionMd?: string;
categoryId?: string;
initialPoints?: string;
minimumPoints?: string;
decaySolves?: string;
flag?: string;
protocol?: string;
port?: string;
ipAddress?: string;
}
export function validateChallengeForm(values: ChallengeFormDefaults): ChallengeFormErrors {
const errs: ChallengeFormErrors = {};
if (!values.name.trim()) errs.name = 'Name is required';
else if (values.name.length > 128) errs.name = 'Name must be 128 characters or fewer';
if (!values.descriptionMd.trim()) errs.descriptionMd = 'Description is required';
if (!values.categoryId) errs.categoryId = 'Category is required';
if (!Number.isInteger(values.initialPoints) || values.initialPoints < 1 || values.initialPoints > 100000) {
errs.initialPoints = 'Initial points must be between 1 and 100000';
}
if (!Number.isInteger(values.minimumPoints) || values.minimumPoints < 1) {
errs.minimumPoints = 'Minimum points must be at least 1';
} else if (values.minimumPoints > values.initialPoints) {
errs.minimumPoints = 'Minimum points must be less than or equal to initial points';
}
if (!Number.isInteger(values.decaySolves) || values.decaySolves < 1 || values.decaySolves > 10000) {
errs.decaySolves = 'Decay solves must be between 1 and 10000';
}
if (!values.flag.trim()) errs.flag = 'Flag is required';
if (values.protocol === 'NC') {
if (values.port === null || values.port === undefined || !Number.isInteger(values.port)) {
errs.port = 'Port is required for NC';
} else if (values.port < 1 || values.port > 65535) {
errs.port = 'Port must be between 1 and 65535';
}
} else if (values.protocol === 'WEB' && values.port !== null && values.port !== undefined) {
if (!Number.isInteger(values.port) || values.port < 1 || values.port > 65535) {
errs.port = 'Port must be between 1 and 65535';
}
}
if (values.ipAddress && !isValidIpOrHostname(values.ipAddress)) {
errs.ipAddress = 'Must be a valid IPv4/IPv6 address or hostname';
}
return errs;
}
const IPV4_PART = /^(0|[1-9][0-9]{0,2})$/;
export function isValidIpOrHostname(value: string): boolean {
if (!value) return true;
if (value.length > 255) return false;
if (value.includes(':')) {
return /^[0-9A-Fa-f:.]+$/.test(value);
}
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(value)) {
return value.split('.').every((p) => IPV4_PART.test(p) && Number(p) <= 255);
}
if (value.startsWith('.') || value.endsWith('.')) return false;
const labels = value.split('.');
if (labels.length < 2) return false;
if (/^[0-9]+$/.test(labels[0])) return false;
return labels.every((label) => /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/.test(label));
}
export function deriveMinimumPoints(
initial: number,
previousMinimum: number,
previousInitial: number,
minimumTouched: boolean,
): number {
if (minimumTouched) {
return previousMinimum;
}
if (!minimumTouched && previousMinimum === defaultMinimumPoints(previousInitial)) {
return defaultMinimumPoints(initial);
}
return previousMinimum;
}
export interface ChallengeFormFileItem {
id?: string;
stagedToken?: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
remove?: boolean;
}
export function toCreateBody(
values: ChallengeFormDefaults,
files: ChallengeFormFileItem[],
): CreateChallengeBody {
const protocolPort =
values.protocol === 'NC'
? values.port ?? null
: values.port === null || values.port === undefined
? null
: values.port;
return {
name: values.name.trim(),
descriptionMd: values.descriptionMd,
categoryId: values.categoryId,
difficulty: values.difficulty,
initialPoints: values.initialPoints,
minimumPoints: values.minimumPoints,
decaySolves: values.decaySolves,
flag: values.flag,
protocol: values.protocol,
port: protocolPort,
ipAddress: values.ipAddress ?? '',
enabled: values.enabled,
files: files.map((f) => ({
id: f.id,
stagedToken: f.stagedToken,
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
remove: f.remove,
})),
};
}
export function toUpdateBody(
values: ChallengeFormDefaults,
files: ChallengeFormFileItem[],
): UpdateChallengeBody {
return toCreateBody(values, files) as UpdateChallengeBody;
}
export function mapServerFieldErrors(details: any[] | undefined): ChallengeFormErrors {
if (!details || !Array.isArray(details)) return {};
const out: ChallengeFormErrors = {};
for (const d of details) {
const path = String(d.path ?? '');
const message = String(d.message ?? '');
switch (path) {
case 'name': out.name = message; break;
case 'descriptionMd': out.descriptionMd = message; break;
case 'categoryId': out.categoryId = message; break;
case 'initialPoints': out.initialPoints = message; break;
case 'minimumPoints': out.minimumPoints = message; break;
case 'decaySolves': out.decaySolves = message; break;
case 'flag': out.flag = message; break;
case 'protocol': out.protocol = message; break;
case 'port': out.port = message; break;
case 'ipAddress': out.ipAddress = message; break;
default: break;
}
}
return out;
}
export interface ChallengeStagedFileUi {
kind: 'existing' | 'staged';
id?: string;
token?: string;
originalFilename: string;
mimeType: string;
sizeBytes: number;
error?: string;
uploading?: boolean;
}
export function buildInitialFiles(detail?: AdminChallengeDetail | null): ChallengeStagedFileUi[] {
if (!detail) return [];
return detail.files.map((f: AdminChallengeFile) => ({
kind: 'existing' as const,
id: f.id,
originalFilename: f.originalFilename,
mimeType: f.mimeType,
sizeBytes: f.sizeBytes,
}));
}