629 lines
26 KiB
TypeScript
629 lines
26 KiB
TypeScript
import {
|
|
ChangeDetectionStrategy,
|
|
ChangeDetectorRef,
|
|
Component,
|
|
DestroyRef,
|
|
HostListener,
|
|
effect,
|
|
inject,
|
|
input,
|
|
output,
|
|
signal,
|
|
} from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { AbstractControl, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
|
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
import {
|
|
AdminCategory,
|
|
AdminChallengeDetail,
|
|
AdminChallengeFile,
|
|
AdminService,
|
|
} from '../../../core/services/admin.service';
|
|
import { MarkdownService } from '../../../core/services/markdown.service';
|
|
import {
|
|
ChallengeFormDefaults,
|
|
ChallengeFormErrors,
|
|
ChallengeFormFileItem,
|
|
ChallengeStagedFileUi,
|
|
CATEGORY_ID_PATTERN,
|
|
buildInitialFiles,
|
|
defaultMinimumPoints,
|
|
deriveMinimumPoints,
|
|
emptyChallengeFormDefaults,
|
|
firstErrorTab,
|
|
partitionFilesBySize,
|
|
prefillChallengeFormDefaults,
|
|
toCreateBody,
|
|
validateChallengeForm,
|
|
} from './challenge-form.pure';
|
|
|
|
export type ChallengeFormMode = 'create' | 'edit';
|
|
|
|
export interface ChallengeFormSubmitPayload {
|
|
body: ReturnType<typeof toCreateBody>;
|
|
}
|
|
|
|
interface ChallengeFormControls {
|
|
name: FormControl<string>;
|
|
descriptionMd: FormControl<string>;
|
|
categoryId: FormControl<string>;
|
|
difficulty: FormControl<'LOW' | 'MEDIUM' | 'HIGH'>;
|
|
initialPoints: FormControl<number>;
|
|
minimumPoints: FormControl<number>;
|
|
decaySolves: FormControl<number>;
|
|
flag: FormControl<string>;
|
|
protocol: FormControl<'NC' | 'WEB'>;
|
|
port: FormControl<number | string | null>;
|
|
ipAddress: FormControl<string>;
|
|
enabled: FormControl<boolean>;
|
|
}
|
|
|
|
export function buildChallengeFormGroup(fb: FormBuilder): FormGroup<ChallengeFormControls> {
|
|
return fb.nonNullable.group({
|
|
name: fb.nonNullable.control('', [Validators.required, Validators.maxLength(128)]),
|
|
descriptionMd: fb.nonNullable.control('', [Validators.required]),
|
|
categoryId: fb.nonNullable.control('', [Validators.required, categoryIdUuidValidator]),
|
|
difficulty: fb.nonNullable.control<'LOW' | 'MEDIUM' | 'HIGH'>('MEDIUM'),
|
|
initialPoints: fb.nonNullable.control(100, [Validators.required, Validators.min(1), Validators.max(100000)]),
|
|
minimumPoints: fb.nonNullable.control(50, [Validators.required, Validators.min(1)]),
|
|
decaySolves: fb.nonNullable.control(10, [Validators.required, Validators.min(1), Validators.max(10000)]),
|
|
flag: fb.nonNullable.control('', [Validators.required]),
|
|
protocol: fb.nonNullable.control<'NC' | 'WEB'>('WEB'),
|
|
port: fb.nonNullable.control<number | string | null>(null, [
|
|
Validators.pattern(/^\d+$/),
|
|
]),
|
|
ipAddress: fb.nonNullable.control(''),
|
|
enabled: fb.nonNullable.control(true),
|
|
});
|
|
}
|
|
|
|
export function syncChallengeForm(
|
|
form: FormGroup<ChallengeFormControls>,
|
|
mode: ChallengeFormMode,
|
|
detail: AdminChallengeDetail | null,
|
|
): ChallengeFormDefaults {
|
|
if (mode === 'edit' && detail) {
|
|
const defaults = prefillChallengeFormDefaults(detail);
|
|
form.patchValue(
|
|
{
|
|
name: defaults.name,
|
|
descriptionMd: defaults.descriptionMd,
|
|
categoryId: defaults.categoryId,
|
|
difficulty: defaults.difficulty,
|
|
initialPoints: defaults.initialPoints,
|
|
minimumPoints: defaults.minimumPoints,
|
|
decaySolves: defaults.decaySolves,
|
|
flag: defaults.flag,
|
|
protocol: defaults.protocol,
|
|
port: defaults.port,
|
|
ipAddress: defaults.ipAddress,
|
|
enabled: defaults.enabled,
|
|
},
|
|
{ emitEvent: false },
|
|
);
|
|
for (const name of Object.keys(form.controls) as (keyof ChallengeFormControls)[]) {
|
|
form.controls[name].markAsPristine();
|
|
form.controls[name].markAsUntouched();
|
|
}
|
|
return defaults;
|
|
}
|
|
const empty = emptyChallengeFormDefaults();
|
|
form.reset({
|
|
name: empty.name,
|
|
descriptionMd: empty.descriptionMd,
|
|
categoryId: empty.categoryId,
|
|
difficulty: empty.difficulty,
|
|
initialPoints: empty.initialPoints,
|
|
minimumPoints: empty.minimumPoints,
|
|
decaySolves: empty.decaySolves,
|
|
flag: empty.flag,
|
|
protocol: empty.protocol,
|
|
port: empty.port,
|
|
ipAddress: empty.ipAddress,
|
|
enabled: empty.enabled,
|
|
});
|
|
for (const name of Object.keys(form.controls) as (keyof ChallengeFormControls)[]) {
|
|
form.controls[name].markAsPristine();
|
|
form.controls[name].markAsUntouched();
|
|
}
|
|
return empty;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-challenge-form-modal',
|
|
standalone: true,
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [CommonModule, ReactiveFormsModule],
|
|
styles: [`
|
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 60; padding: 16px; }
|
|
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(720px, 100%); max-height: 90vh; overflow: auto; }
|
|
.tabs { display: flex; gap: 4px; margin-bottom: 8px; }
|
|
.tabs button { padding: 6px 12px; border-radius: 4px; border: 1px solid var(--color-secondary, #ccc); background: transparent; cursor: pointer; }
|
|
.tabs button.active { background: var(--color-primary, #3b82f6); color: #fff; border-color: var(--color-primary, #3b82f6); }
|
|
.row { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
|
|
.row label { font-weight: 600; font-size: 13px; }
|
|
.row .error { color: var(--color-danger, #f00); font-size: 12px; }
|
|
.markdown-preview { border: 1px solid var(--color-secondary, #ccc); padding: 8px; border-radius: 4px; min-height: 60px; }
|
|
.field-row { display: flex; gap: 8px; }
|
|
.field-row > * { flex: 1; }
|
|
.file-list { list-style: none; padding: 0; margin: 0; }
|
|
.file-list li { display: flex; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--color-secondary, #ccc); }
|
|
.file-list li .remove { margin-left: auto; }
|
|
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
|
.flag-input { display: flex; gap: 4px; }
|
|
.flag-input input { flex: 1; }
|
|
.preview-toggle { font-size: 12px; }
|
|
.upload-error { color: var(--color-danger, #f00); font-size: 12px; }
|
|
.drop-zone { border: 2px dashed var(--color-secondary, #ccc); border-radius: 6px; padding: 12px; text-align: center; transition: background 0.15s, border-color 0.15s; }
|
|
.drop-zone.dragover { background: rgba(59,130,246,0.08); border-color: var(--color-primary, #3b82f6); }
|
|
.drop-zone__hint { margin: 0 0 8px; font-size: 12px; color: var(--color-secondary, #666); }
|
|
`],
|
|
template: `
|
|
@if (open()) {
|
|
<div class="modal-overlay" role="presentation" (click)="onCancel()" data-testid="challenge-form-backdrop">
|
|
<form class="modal" role="dialog" aria-modal="true" [attr.aria-label]="mode() === 'create' ? 'Add challenge' : 'Edit challenge'" (click)="$event.stopPropagation()" (submit)="$event.preventDefault()" data-testid="challenge-form-modal">
|
|
<h2>{{ mode() === 'create' ? 'Add challenge' : 'Edit challenge' }}</h2>
|
|
|
|
<div class="tabs" role="tablist">
|
|
<button type="button" role="tab" [class.active]="tab() === 'general'" [attr.aria-selected]="tab() === 'general'" (click)="setTab('general')" data-testid="cf-tab-general">General</button>
|
|
<button type="button" role="tab" [class.active]="tab() === 'connection'" [attr.aria-selected]="tab() === 'connection'" (click)="setTab('connection')" data-testid="cf-tab-connection">Connection</button>
|
|
<button type="button" role="tab" [class.active]="tab() === 'files'" [attr.aria-selected]="tab() === 'files'" (click)="setTab('files')" data-testid="cf-tab-files">Files</button>
|
|
</div>
|
|
|
|
<div [formGroup]="form">
|
|
@if (tab() === 'general') {
|
|
<div class="row">
|
|
<label for="cf-name">Name</label>
|
|
<input id="cf-name" type="text" formControlName="name" maxlength="128" data-testid="cf-name" />
|
|
@if (showError('name'); as msg) { <span class="error" data-testid="cf-error-name">{{ msg }}</span> }
|
|
</div>
|
|
|
|
<div class="row">
|
|
<label for="cf-desc">Description (Markdown)</label>
|
|
<textarea id="cf-desc" rows="4" formControlName="descriptionMd" data-testid="cf-desc"></textarea>
|
|
<button type="button" class="preview-toggle" (click)="togglePreview()" data-testid="cf-desc-preview-toggle">
|
|
{{ showPreview() ? 'Hide preview' : 'Preview' }}
|
|
</button>
|
|
@if (showPreview()) {
|
|
<div class="markdown-preview" data-testid="cf-desc-preview" [innerHTML]="previewHtml()"></div>
|
|
}
|
|
@if (showError('descriptionMd'); as msg) { <span class="error" data-testid="cf-error-description">{{ msg }}</span> }
|
|
</div>
|
|
|
|
<div class="row">
|
|
<label for="cf-category">Category</label>
|
|
<select id="cf-category" formControlName="categoryId" data-testid="cf-category">
|
|
<option value="">Select a category…</option>
|
|
@for (cat of categories(); track cat.id) {
|
|
<option [value]="cat.id">{{ cat.abbreviation }} — {{ cat.name }}</option>
|
|
}
|
|
</select>
|
|
@if (showError('categoryId'); as msg) { <span class="error" data-testid="cf-error-category">{{ msg }}</span> }
|
|
</div>
|
|
|
|
<div class="row">
|
|
<label>Difficulty</label>
|
|
<div role="radiogroup" aria-label="Difficulty">
|
|
@for (d of difficulties; track d) {
|
|
<label>
|
|
<input type="radio" [value]="d" formControlName="difficulty" [attr.data-testid]="'cf-difficulty-' + d" /> {{ d }}
|
|
</label>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field-row">
|
|
<div class="row">
|
|
<label for="cf-initial">Initial points</label>
|
|
<input id="cf-initial" type="number" min="1" max="100000" formControlName="initialPoints" data-testid="cf-initial" />
|
|
@if (showError('initialPoints'); as msg) { <span class="error" data-testid="cf-error-initial">{{ msg }}</span> }
|
|
</div>
|
|
<div class="row">
|
|
<label for="cf-minimum">Minimum points</label>
|
|
<input id="cf-minimum" type="number" min="1" max="100000" formControlName="minimumPoints" data-testid="cf-minimum" />
|
|
@if (showError('minimumPoints'); as msg) { <span class="error" data-testid="cf-error-minimum">{{ msg }}</span> }
|
|
</div>
|
|
<div class="row">
|
|
<label for="cf-decay">Decay solves</label>
|
|
<input id="cf-decay" type="number" min="1" max="10000" formControlName="decaySolves" data-testid="cf-decay" />
|
|
@if (showError('decaySolves'); as msg) { <span class="error" data-testid="cf-error-decay">{{ msg }}</span> }
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<label for="cf-flag">Secret flag</label>
|
|
<div class="flag-input">
|
|
<input id="cf-flag" [type]="flagVisible() ? 'text' : 'password'" formControlName="flag" data-testid="cf-flag" />
|
|
<button type="button" (click)="toggleFlag()" [attr.aria-label]="flagVisible() ? 'Hide flag' : 'Reveal flag'" data-testid="cf-flag-toggle">{{ flagVisible() ? '🙈' : '👁' }}</button>
|
|
</div>
|
|
@if (showError('flag'); as msg) { <span class="error" data-testid="cf-error-flag">{{ msg }}</span> }
|
|
</div>
|
|
}
|
|
|
|
@if (tab() === 'connection') {
|
|
<div class="row">
|
|
<label>Protocol</label>
|
|
<div role="radiogroup" aria-label="Protocol">
|
|
@for (p of protocols; track p) {
|
|
<label>
|
|
<input type="radio" [value]="p" formControlName="protocol" [attr.data-testid]="'cf-protocol-' + p" /> {{ p }}
|
|
</label>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<label for="cf-port">Port</label>
|
|
<input id="cf-port" type="text" inputmode="numeric" formControlName="port" data-testid="cf-port" />
|
|
@if (showError('port'); as msg) { <span class="error" data-testid="cf-error-port">{{ msg }}</span> }
|
|
</div>
|
|
|
|
<div class="row">
|
|
<label for="cf-ip">IP address (optional; falls back to Default Challenge IP)</label>
|
|
<input id="cf-ip" type="text" formControlName="ipAddress" data-testid="cf-ip" />
|
|
@if (showError('ipAddress'); as msg) { <span class="error" data-testid="cf-error-ip">{{ msg }}</span> }
|
|
</div>
|
|
}
|
|
|
|
@if (tab() === 'files') {
|
|
<ul class="file-list" data-testid="cf-files">
|
|
@for (file of files(); track $index) {
|
|
<li>
|
|
<span>{{ file.originalFilename }}</span>
|
|
<small>{{ file.mimeType }} · {{ formatSize(file.sizeBytes) }}</small>
|
|
<button type="button" class="remove" (click)="removeFile(file)" [attr.aria-label]="'Remove ' + file.originalFilename" data-testid="cf-file-remove">🗑</button>
|
|
</li>
|
|
}
|
|
</ul>
|
|
<div class="row">
|
|
<label for="cf-upload">Upload files (any file type)</label>
|
|
<div
|
|
class="drop-zone"
|
|
[class.dragover]="dragOver()"
|
|
(dragover)="onDragOver($event)"
|
|
(dragleave)="onDragLeave($event)"
|
|
(drop)="onDrop($event)"
|
|
data-testid="cf-drop-zone"
|
|
>
|
|
<p class="drop-zone__hint">Drop files here or use the picker below</p>
|
|
<input id="cf-upload" type="file" multiple (change)="onFileChange($event)" data-testid="cf-upload" />
|
|
</div>
|
|
@if (fileUploadError(); as err) {
|
|
<span class="upload-error" data-testid="cf-upload-error">{{ err }}</span>
|
|
}
|
|
<small>Limit: {{ formatSize(globalFileLimit()) }} per file</small>
|
|
</div>
|
|
}
|
|
|
|
@if (errorMessage(); as msg) {
|
|
<p class="error" data-testid="cf-error" role="alert" style="margin-top: 8px;">{{ msg }}</p>
|
|
}
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="cf-cancel">Cancel</button>
|
|
<button type="button" (click)="onOk()" [disabled]="submitting()" data-testid="cf-ok">
|
|
{{ submitting() ? 'Saving…' : (mode() === 'create' ? 'Create' : 'Save') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
}
|
|
`,
|
|
})
|
|
export class ChallengeFormModalComponent {
|
|
private readonly fb = inject(FormBuilder);
|
|
private readonly destroyRef = inject(DestroyRef);
|
|
private readonly cdr = inject(ChangeDetectorRef);
|
|
private readonly markdown = inject(MarkdownService);
|
|
private readonly admin = inject(AdminService);
|
|
|
|
readonly open = input(false);
|
|
readonly mode = input<ChallengeFormMode>('create');
|
|
readonly detail = input<AdminChallengeDetail | null>(null);
|
|
readonly categories = input<AdminCategory[]>([]);
|
|
readonly globalFileLimit = input<number>(100 * 1024 * 1024);
|
|
readonly errorMessage = input<string | null>(null);
|
|
readonly fieldErrors = input<ChallengeFormErrors | Record<string, string> | null>(null);
|
|
readonly saving = input(false);
|
|
|
|
readonly cancel = output<void>();
|
|
readonly submit = output<ChallengeFormSubmitPayload>();
|
|
|
|
readonly form: FormGroup<ChallengeFormControls> = buildChallengeFormGroup(this.fb);
|
|
readonly tab = signal<'general' | 'connection' | 'files'>('general');
|
|
readonly flagVisible = signal(false);
|
|
readonly showPreview = signal(false);
|
|
readonly previewHtml = signal('');
|
|
readonly files = signal<ChallengeStagedFileUi[]>([]);
|
|
readonly fileUploadError = signal<string | null>(null);
|
|
readonly submitting = signal(false);
|
|
readonly clientFieldErrors = signal<ChallengeFormErrors | null>(null);
|
|
readonly dragOver = signal(false);
|
|
|
|
readonly difficulties = ['LOW', 'MEDIUM', 'HIGH'] as const;
|
|
readonly protocols = ['NC', 'WEB'] as const;
|
|
|
|
private minimumTouched = false;
|
|
private previousInitial = 100;
|
|
private pendingRemoval: string[] = [];
|
|
|
|
constructor() {
|
|
effect(
|
|
() => {
|
|
const result = syncChallengeForm(this.form, this.mode(), this.detail());
|
|
this.minimumTouched = false;
|
|
this.previousInitial = result.initialPoints;
|
|
this.flagVisible.set(false);
|
|
this.showPreview.set(false);
|
|
this.previewHtml.set(this.markdown.render(result.descriptionMd));
|
|
this.files.set(buildInitialFiles(this.detail()));
|
|
this.pendingRemoval = [];
|
|
this.fileUploadError.set(null);
|
|
this.submitting.set(false);
|
|
this.clientFieldErrors.set(null);
|
|
this.dragOver.set(false);
|
|
this.cdr.markForCheck();
|
|
},
|
|
{ allowSignalWrites: true },
|
|
);
|
|
|
|
this.form.controls.descriptionMd.valueChanges
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe((v) => {
|
|
this.previewHtml.set(this.markdown.render(v));
|
|
});
|
|
|
|
this.form.controls.initialPoints.valueChanges
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe((value) => {
|
|
const next = Number(value);
|
|
if (Number.isFinite(next)) {
|
|
const currentMin = this.form.controls.minimumPoints.value;
|
|
const newMin = deriveMinimumPoints(next, currentMin, this.previousInitial, this.minimumTouched);
|
|
this.form.controls.minimumPoints.setValue(newMin, { emitEvent: false });
|
|
this.previousInitial = next;
|
|
}
|
|
});
|
|
|
|
this.form.controls.minimumPoints.valueChanges
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe(() => {
|
|
this.minimumTouched = true;
|
|
});
|
|
|
|
this.form.controls.protocol.valueChanges
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe((p) => {
|
|
if (p === 'WEB') {
|
|
this.form.controls.port.setValue(null, { emitEvent: false });
|
|
}
|
|
});
|
|
}
|
|
|
|
@HostListener('document:keydown.escape')
|
|
onEscape(): void {
|
|
if (this.open()) this.onCancel();
|
|
}
|
|
|
|
setTab(tab: 'general' | 'connection' | 'files'): void {
|
|
this.tab.set(tab);
|
|
}
|
|
|
|
toggleFlag(): void {
|
|
this.flagVisible.set(!this.flagVisible());
|
|
}
|
|
|
|
togglePreview(): void {
|
|
this.showPreview.set(!this.showPreview());
|
|
}
|
|
|
|
showError(field: keyof ChallengeFormErrors): string | null {
|
|
const fromClient = (this.clientFieldErrors() ?? {})[field as string];
|
|
if (fromClient) return fromClient;
|
|
const fromServer = (this.fieldErrors() ?? {})[field as string];
|
|
if (fromServer) return fromServer;
|
|
const ctrl = this.form.controls[field as keyof ChallengeFormControls];
|
|
if (!ctrl || (!ctrl.touched && !ctrl.dirty)) return null;
|
|
if (ctrl.errors?.['required']) {
|
|
return `${humanLabel(field)} is required`;
|
|
}
|
|
if (ctrl.errors?.['maxlength']) {
|
|
return `${humanLabel(field)} is too long`;
|
|
}
|
|
if (ctrl.errors?.['min']) {
|
|
return `${humanLabel(field)} must be at least ${ctrl.errors['min'].min}`;
|
|
}
|
|
if (ctrl.errors?.['max']) {
|
|
return `${humanLabel(field)} must be at most ${ctrl.errors['max'].max}`;
|
|
}
|
|
if (ctrl.errors?.['pattern']) {
|
|
return `${humanLabel(field)} must be a whole number`;
|
|
}
|
|
if (ctrl.errors?.['uuid']) {
|
|
return `${humanLabel(field)} is required`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
onCancel(): void {
|
|
// discard any pending staged tokens
|
|
for (const f of this.files()) {
|
|
if (f.kind === 'staged' && f.token) {
|
|
void this.admin.discardChallengeFile(f.token).catch(() => undefined);
|
|
}
|
|
}
|
|
this.files.set([]);
|
|
this.pendingRemoval = [];
|
|
this.cancel.emit();
|
|
}
|
|
|
|
async onFileChange(ev: Event): Promise<void> {
|
|
const input = ev.target as HTMLInputElement;
|
|
const list = input.files ? Array.from(input.files) : [];
|
|
input.value = '';
|
|
await this.stageFiles(list);
|
|
}
|
|
|
|
onDragOver(ev: DragEvent): void {
|
|
ev.preventDefault();
|
|
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'copy';
|
|
this.dragOver.set(true);
|
|
}
|
|
|
|
onDragLeave(ev: DragEvent): void {
|
|
ev.preventDefault();
|
|
this.dragOver.set(false);
|
|
}
|
|
|
|
async onDrop(ev: DragEvent): Promise<void> {
|
|
ev.preventDefault();
|
|
this.dragOver.set(false);
|
|
const list = ev.dataTransfer?.files ? Array.from(ev.dataTransfer.files) : [];
|
|
if (list.length === 0) return;
|
|
await this.stageFiles(list);
|
|
}
|
|
|
|
private async stageFiles(list: File[]): Promise<void> {
|
|
this.fileUploadError.set(null);
|
|
const { accepted, rejected } = partitionFilesBySize(list, this.globalFileLimit());
|
|
for (const r of rejected) {
|
|
this.fileUploadError.set(r.reason);
|
|
}
|
|
for (const a of accepted) {
|
|
this.files.update((arr) => [...arr, a.placeholder]);
|
|
try {
|
|
const res = await this.admin.stageChallengeFile(a.file);
|
|
this.files.update((arr) =>
|
|
arr.map((entry) =>
|
|
entry === a.placeholder
|
|
? { ...entry, token: res.token, uploading: false }
|
|
: entry,
|
|
),
|
|
);
|
|
} catch (e: any) {
|
|
this.files.update((arr) => arr.filter((entry) => entry !== a.placeholder));
|
|
this.fileUploadError.set(
|
|
`Failed to upload '${a.file.name}': ${e?.error?.message ?? e?.message ?? 'unknown error'}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
removeFile(file: ChallengeStagedFileUi): void {
|
|
if (file.kind === 'staged' && file.token) {
|
|
void this.admin.discardChallengeFile(file.token).catch(() => undefined);
|
|
this.files.update((arr) => arr.filter((f) => f !== file));
|
|
} else if (file.kind === 'existing' && file.id) {
|
|
this.pendingRemoval.push(file.id);
|
|
this.files.update((arr) => arr.filter((f) => f !== file));
|
|
}
|
|
}
|
|
|
|
onOk(): void {
|
|
if (this.files().some((f) => f.uploading)) {
|
|
this.fileUploadError.set('Wait for uploads to finish before saving.');
|
|
return;
|
|
}
|
|
const categoryRaw = this.form.controls.categoryId.value;
|
|
if (!categoryRaw || !CATEGORY_ID_PATTERN.test(categoryRaw)) {
|
|
this.clientFieldErrors.set({ categoryId: 'Category is required' });
|
|
this.form.controls.categoryId.markAsTouched();
|
|
this.form.controls.categoryId.markAsDirty();
|
|
this.form.controls.categoryId.setErrors({ ...(this.form.controls.categoryId.errors ?? {}), uuid: true });
|
|
this.tab.set('general');
|
|
return;
|
|
}
|
|
const portRaw = this.form.controls.port.value;
|
|
const portAsNumber =
|
|
portRaw === null || portRaw === undefined || portRaw === ''
|
|
? null
|
|
: Number(portRaw);
|
|
const values: ChallengeFormDefaults = {
|
|
name: this.form.controls.name.value,
|
|
descriptionMd: this.form.controls.descriptionMd.value,
|
|
categoryId: this.form.controls.categoryId.value,
|
|
difficulty: this.form.controls.difficulty.value,
|
|
initialPoints: this.form.controls.initialPoints.value,
|
|
minimumPoints: this.form.controls.minimumPoints.value,
|
|
decaySolves: this.form.controls.decaySolves.value,
|
|
flag: this.form.controls.flag.value,
|
|
protocol: this.form.controls.protocol.value,
|
|
port: Number.isFinite(portAsNumber) ? portAsNumber : null,
|
|
ipAddress: this.form.controls.ipAddress.value,
|
|
enabled: this.form.controls.enabled.value,
|
|
};
|
|
const clientErrors = validateChallengeForm(values);
|
|
if (Object.keys(clientErrors).length > 0) {
|
|
this.clientFieldErrors.set(clientErrors);
|
|
for (const f of Object.keys(clientErrors) as (keyof ChallengeFormControls)[]) {
|
|
const ctrl = this.form.controls[f];
|
|
if (ctrl) {
|
|
ctrl.markAsTouched();
|
|
ctrl.markAsDirty();
|
|
}
|
|
}
|
|
const tab = firstErrorTab(clientErrors);
|
|
if (tab) this.tab.set(tab);
|
|
return;
|
|
}
|
|
this.clientFieldErrors.set(null);
|
|
if (this.form.invalid) {
|
|
this.form.markAllAsTouched();
|
|
return;
|
|
}
|
|
this.form.markAllAsTouched();
|
|
const filePayload: ChallengeFormFileItem[] = this.files().map((f) => ({
|
|
id: f.kind === 'existing' ? f.id : undefined,
|
|
stagedToken: f.kind === 'staged' ? f.token : undefined,
|
|
originalFilename: f.originalFilename,
|
|
mimeType: f.mimeType,
|
|
sizeBytes: f.sizeBytes,
|
|
}));
|
|
for (const id of this.pendingRemoval) {
|
|
filePayload.push({
|
|
id,
|
|
originalFilename: '',
|
|
mimeType: '',
|
|
sizeBytes: 0,
|
|
remove: true,
|
|
});
|
|
}
|
|
const body = toCreateBody(values, filePayload);
|
|
this.submit.emit({ body });
|
|
}
|
|
|
|
formatSize(bytes: number): string {
|
|
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
}
|
|
|
|
function humanLabel(field: keyof ChallengeFormErrors): string {
|
|
switch (field) {
|
|
case 'name': return 'Name';
|
|
case 'descriptionMd': return 'Description';
|
|
case 'categoryId': return 'Category';
|
|
case 'initialPoints': return 'Initial points';
|
|
case 'minimumPoints': return 'Minimum points';
|
|
case 'decaySolves': return 'Decay solves';
|
|
case 'flag': return 'Flag';
|
|
case 'port': return 'Port';
|
|
case 'ipAddress': return 'IP address';
|
|
case 'protocol': return 'Protocol';
|
|
default: return 'Field';
|
|
}
|
|
}
|
|
|
|
function categoryIdUuidValidator(control: AbstractControl): ValidationErrors | null {
|
|
const raw = control.value;
|
|
if (raw === null || raw === undefined || raw === '') {
|
|
return null;
|
|
}
|
|
return CATEGORY_ID_PATTERN.test(String(raw)) ? null : { uuid: true };
|
|
}
|
|
|
|
// Re-export so tests can find default helpers.
|
|
export { defaultMinimumPoints }; |