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:
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user