Files
HIPCTF2/frontend/src/app/features/admin/challenges/challenge-form.pure.ts
T

296 lines
8.8 KiB
TypeScript

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 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,
}));
}