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}(? ({ 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 interface StageFileOutcome { accepted: { file: File; placeholder: ChallengeStagedFileUi }[]; rejected: { file: File; reason: string }[]; } export function partitionFilesBySize( files: File[], globalFileLimit: number, ): StageFileOutcome { const accepted: StageFileOutcome['accepted'] = []; const rejected: StageFileOutcome['rejected'] = []; for (const file of files) { if (file.size > globalFileLimit) { rejected.push({ file, reason: `Failed to upload '${file.name}': File exceeds the global size limit (${file.size} > ${globalFileLimit})`, }); } else { accepted.push({ file, placeholder: { kind: 'staged', token: undefined, originalFilename: file.name, mimeType: file.type || 'application/octet-stream', sizeBytes: file.size, uploading: true, }, }); } } return { accepted, rejected }; } export function firstErrorTab( errors: ChallengeFormErrors, ): 'general' | 'connection' | 'files' | null { if ( errors.name || errors.descriptionMd || errors.categoryId || errors.initialPoints || errors.minimumPoints || errors.decaySolves || errors.flag ) { return 'general'; } if (errors.protocol || errors.port || errors.ipAddress) { return 'connection'; } return null; } 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, })); }