296 lines
8.8 KiB
TypeScript
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,
|
|
}));
|
|
} |