From 1f73a291371b06c59ae40f3490419b6c853f18c7 Mon Sep 17 00:00:00 2001 From: rednakse Date: Thu, 2 Apr 2026 00:25:14 +0300 Subject: [PATCH] Add editable system configuration flow --- docs/PLAN.md | 11 +- docs/PROJECT_INDEX.md | 12 +- server/app.test.ts | 56 ++++++++ server/app.ts | 16 ++- server/lib/config.ts | 46 +------ src/App.test.tsx | 23 ++++ src/App.tsx | 190 +++++++++++++++------------ src/SystemTab.tsx | 273 +++++++++++++++++++++++++++++++++++++++ src/app.css | 92 ++++++++++++- src/shared/contracts.ts | 18 ++- src/shared/validation.ts | 171 ++++++++++++++++++++++++ 11 files changed, 756 insertions(+), 152 deletions(-) create mode 100644 src/SystemTab.tsx create mode 100644 src/shared/validation.ts diff --git a/docs/PLAN.md b/docs/PLAN.md index efdba67..cd4d5ec 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -1,16 +1,16 @@ # Plan -Updated: 2026-04-01 +Updated: 2026-04-02 ## Active -1. Harden the new backend/runtime layer, expand system configuration flows, and keep wiring the UI to real panel state instead of fallbacks. +1. Harden the backend/runtime layer, keep replacing fallback UI behavior with runtime-backed signals, and prepare the next slice of real traffic/counter ingestion. ## Next -1. Extend the backend to support system-tab editing for services, ports, and runtime configuration. -2. Add stronger validation and tests for unsafe credentials, conflicting ports, and invalid service assignment. -3. Refine Docker/runtime behavior and document real host-access expectations and deployment constraints. +1. Wire real counter/log ingestion into dashboard traffic and user status instead of seeded snapshot values. +2. Refine Docker/runtime behavior and document real host-access expectations and deployment constraints. +3. Expand validation and tests around service mutations, credential safety, and runtime failure reporting. ## Done @@ -28,3 +28,4 @@ Updated: 2026-04-01 12. Added a backend control plane with persisted state, 3proxy config generation, runtime actions, and API-backed frontend wiring. 13. Added Docker build/compose runtime that compiles 3proxy in-container and starts the panel with a managed 3proxy process. 14. Added backend tests for config rendering and user-management API edge cases. +15. Added editable System settings with shared validation, a system update API, service/port conflict protection, and UI coverage for local save flows. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index 1797f27..5d74202 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -1,6 +1,6 @@ # Project Index -Updated: 2026-04-01 +Updated: 2026-04-02 ## Root @@ -23,20 +23,22 @@ Updated: 2026-04-01 ## Frontend - `src/main.tsx`: application bootstrap -- `src/App.tsx`: authenticated panel shell wired to backend APIs with local fallback behavior -- `src/App.test.tsx`: login-gate, modal interaction, pause/resume, and delete-confirm tests +- `src/App.tsx`: authenticated panel shell with API-backed dashboard/user flows and validated local fallback mutations +- `src/SystemTab.tsx`: editable runtime/system form for host, modes, and managed services +- `src/App.test.tsx`: login-gate, modal interaction, pause/resume, delete-confirm, and system-save UI tests - `src/app.css`: full panel styling - `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot - `src/lib/3proxy.ts`: formatting and status helpers - `src/lib/3proxy.test.ts`: paranoia-oriented tests for core domain rules - `src/shared/contracts.ts`: shared panel, service, user, and API data contracts +- `src/shared/validation.ts`: shared validation for user creation, system edits, protocol mapping, and quota conversion - `src/test/setup.ts`: Testing Library matchers ## Server - `server/index.ts`: backend entrypoint and runtime bootstrap -- `server/app.ts`: Express app with panel state and runtime routes -- `server/app.test.ts`: API tests for user management edge cases +- `server/app.ts`: Express app with panel state, runtime routes, and writable system configuration API +- `server/app.test.ts`: API tests for user management plus system-update safety edge cases - `server/lib/config.ts`: 3proxy config renderer, validation, and dashboard derivation - `server/lib/config.test.ts`: config-generation regression tests - `server/lib/runtime.ts`: managed 3proxy process controller diff --git a/server/app.test.ts b/server/app.test.ts index a1a2125..4f33727 100644 --- a/server/app.test.ts +++ b/server/app.test.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import request from 'supertest'; import { afterEach, describe, expect, it } from 'vitest'; +import type { UpdateSystemInput } from '../src/shared/contracts'; import { createApp } from './app'; import type { RuntimeSnapshot } from './lib/config'; import type { RuntimeController } from './lib/runtime'; @@ -75,6 +76,56 @@ describe('panel api', () => { false, ); }); + + it('rejects system updates when two services reuse the same port', async () => { + const app = await createTestApp(); + const initial = await request(app).get('/api/state'); + const system = createSystemPayload(initial.body); + + system.services[1].port = system.services[0].port; + + const response = await request(app).put('/api/system').send(system); + + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/cannot share port/i); + }); + + it('rejects system updates that strand existing users on a disabled service', async () => { + const app = await createTestApp(); + const initial = await request(app).get('/api/state'); + const system = createSystemPayload(initial.body); + + system.services = system.services.map((service) => + service.id === 'socks-main' ? { ...service, enabled: false } : service, + ); + + const response = await request(app).put('/api/system').send(system); + + expect(response.status).toBe(400); + expect(response.body.error).toMatch(/enabled assignable service/i); + expect(response.body.error).toMatch(/night-shift/i); + }); + + it('updates system settings and regenerates the rendered config', async () => { + const app = await createTestApp(); + const initial = await request(app).get('/api/state'); + const system = createSystemPayload(initial.body); + + system.publicHost = 'ops-gateway.example.net'; + system.services = system.services.map((service) => + service.id === 'socks-main' ? { ...service, port: 1180 } : service, + ); + + const response = await request(app).put('/api/system').send(system); + + expect(response.status).toBe(200); + expect(response.body.system.publicHost).toBe('ops-gateway.example.net'); + expect(response.body.system.services.find((service: { id: string }) => service.id === 'socks-main').port).toBe( + 1180, + ); + expect(response.body.system.previewConfig).toContain('socks -p1180 -u2'); + expect(response.body.service.lastEvent).toBe('System configuration updated from panel'); + }); }); async function createTestApp() { @@ -90,3 +141,8 @@ async function createTestApp() { runtimeRootDir: dir, }); } + +function createSystemPayload(body: { system: Record }): UpdateSystemInput { + const { previewConfig: _previewConfig, ...system } = body.system; + return structuredClone(system) as unknown as UpdateSystemInput; +} diff --git a/server/app.ts b/server/app.ts index 7f559eb..6598f2a 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1,13 +1,13 @@ import express, { type Request, type Response } from 'express'; import fs from 'node:fs/promises'; import path from 'node:path'; -import type { ControlPlaneState, CreateUserInput } from '../src/shared/contracts'; +import type { ControlPlaneState, CreateUserInput, UpdateSystemInput } from '../src/shared/contracts'; +import { validateCreateUserInput, validateSystemInput } from '../src/shared/validation'; import { buildRuntimePaths, createUserRecord, deriveDashboardSnapshot, render3proxyConfig, - validateCreateUserInput, type RuntimePaths, } from './lib/config'; import type { RuntimeController } from './lib/runtime'; @@ -85,6 +85,18 @@ export function createApp({ store, runtime, runtimeRootDir }: AppServices) { } }); + app.put('/api/system', async (request, response, next) => { + try { + const state = await store.read(); + state.system = validateSystemInput(request.body as Partial, state.userRecords); + state.service.lastEvent = 'System configuration updated from panel'; + await persistRuntimeMutation(store, runtime, state, runtimePaths); + response.json(await getSnapshot(store, runtime, runtimePaths)); + } catch (error) { + next(error); + } + }); + app.post('/api/users/:id/pause', async (request, response, next) => { try { const state = await store.read(); diff --git a/server/lib/config.ts b/server/lib/config.ts index 9c2cf66..318b676 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -7,6 +7,7 @@ import type { ProxyServiceRecord, ProxyUserRecord, } from '../../src/shared/contracts'; +import { quotaMbToBytes } from '../../src/shared/validation'; import type { ServiceState } from '../../src/lib/3proxy'; const MB = 1024 * 1024; @@ -42,39 +43,6 @@ export function buildRuntimePaths(rootDir: string): RuntimePaths { }; } -export function validateCreateUserInput( - input: Partial, - services: ProxyServiceRecord[], -): CreateUserInput { - const username = input.username?.trim() ?? ''; - const password = input.password?.trim() ?? ''; - const serviceId = input.serviceId?.trim() ?? ''; - const quotaMb = input.quotaMb ?? null; - - assertSafeToken(username, 'Username'); - assertSafeToken(password, 'Password'); - - if (!serviceId) { - throw new Error('Service is required.'); - } - - const service = services.find((entry) => entry.id === serviceId); - if (!service || !service.enabled || !service.assignable) { - throw new Error('Service must reference an enabled assignable entry.'); - } - - if (quotaMb !== null && (!Number.isFinite(quotaMb) || quotaMb <= 0 || !Number.isInteger(quotaMb))) { - throw new Error('Quota must be a positive integer number of megabytes.'); - } - - return { - username, - password, - serviceId, - quotaMb, - }; -} - export function createUserRecord(state: ControlPlaneState, input: CreateUserInput): ProxyUserRecord { if (state.userRecords.some((user) => user.username === input.username)) { throw new Error('Username already exists.'); @@ -88,7 +56,7 @@ export function createUserRecord(state: ControlPlaneState, input: CreateUserInpu status: 'idle', paused: false, usedBytes: 0, - quotaBytes: input.quotaMb === null ? null : input.quotaMb * MB, + quotaBytes: quotaMbToBytes(input.quotaMb), }; } @@ -241,16 +209,6 @@ function renderServiceCommand(service: ProxyServiceRecord): string { return `proxy -p${service.port}`; } -function assertSafeToken(value: string, label: string): void { - if (!value) { - throw new Error(`${label} is required.`); - } - - if (!/^[A-Za-z0-9._~!@#$%^&*+=:/-]+$/.test(value)) { - throw new Error(`${label} contains unsupported characters.`); - } -} - function normalizePath(value: string): string { return value.replace(/\\/g, '/'); } diff --git a/src/App.test.tsx b/src/App.test.tsx index 2772946..bc010c1 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -88,4 +88,27 @@ describe('App login gate', () => { expect(screen.queryByText(/night-shift/i)).not.toBeInTheDocument(); expect(screen.queryByRole('dialog', { name: /delete user/i })).not.toBeInTheDocument(); }); + + it('saves system settings from the system tab and applies them to the local fallback state', async () => { + const user = userEvent.setup(); + render(); + + await loginIntoPanel(user); + await user.click(screen.getByRole('button', { name: /system/i })); + + await user.clear(screen.getByLabelText(/public host/i)); + await user.type(screen.getByLabelText(/public host/i), 'ops-gateway.example.net'); + + const firstPortInput = screen.getAllByLabelText(/port/i)[0]; + await user.clear(firstPortInput); + await user.type(firstPortInput, '1180'); + + await user.click(screen.getByRole('button', { name: /save system/i })); + + expect(screen.getByText(/ops-gateway\.example\.net/i)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /users/i })); + + expect(screen.getAllByText(/ops-gateway\.example\.net:1180/i).length).toBeGreaterThan(0); + }); }); diff --git a/src/App.tsx b/src/App.tsx index b06872e..9f8bb90 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,10 @@ import type { DashboardSnapshot, ProxyServiceRecord, ProxyUserRecord, + UpdateSystemInput, } from './shared/contracts'; +import SystemTab from './SystemTab'; +import { quotaMbToBytes, validateCreateUserInput } from './shared/validation'; type TabId = 'dashboard' | 'users' | 'system'; @@ -385,11 +388,14 @@ function UsersTab({ {snapshot.users.nearQuota} near quota {snapshot.users.exceeded} exceeded - + {assignableServices.length === 0 ? ( +

Enable an assignable service in System before creating new users.

+ ) : null}
@@ -503,61 +509,6 @@ function UsersTab({ ); } -function SystemTab({ snapshot }: { snapshot: DashboardSnapshot }) { - return ( -
-
-
-

Runtime

-
-
-
-
Config mode
-
{snapshot.system.configMode}
-
-
-
Reload
-
{snapshot.system.reloadMode}
-
-
-
Storage
-
{snapshot.system.storageMode}
-
-
-
- -
-
-

Services

-
-
- {snapshot.system.services.map((service) => ( -
-
- {service.name} -

{service.description}

-
-
- {service.port} - - {service.enabled ? 'enabled' : 'disabled'} - -
-
- ))} -
-
- -
-
-

Generated config

-
-
{snapshot.system.previewConfig}
-
-
- ); -} - export default function App() { const [isAuthed, setIsAuthed] = useState(false); const [activeTab, setActiveTab] = useState('dashboard'); @@ -620,34 +571,45 @@ export default function App() { }; const handleCreateUser = async (input: CreateUserInput) => { - await mutateSnapshot( - () => - fetch('/api/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - }), - (current) => { - const nextUser: ProxyUserRecord = { - id: `u-${Math.random().toString(36).slice(2, 10)}`, - username: input.username.trim(), - password: input.password.trim(), - serviceId: input.serviceId, - status: 'idle', - paused: false, - usedBytes: 0, - quotaBytes: input.quotaMb === null ? null : input.quotaMb * 1024 * 1024, - }; + const validated = validateCreateUserInput(input, snapshot.system.services); - return withDerivedSnapshot({ - ...current, - service: { - ...current.service, - lastEvent: `User ${nextUser.username} created from panel`, - }, - userRecords: [...current.userRecords, nextUser], - }); - }, + if (snapshot.userRecords.some((user) => user.username === validated.username)) { + throw new Error('Username already exists.'); + } + + const payload = await requestSnapshot(() => + fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validated), + }), + ); + + if (payload) { + setSnapshot(payload); + return; + } + + const nextUser: ProxyUserRecord = { + id: `u-${Math.random().toString(36).slice(2, 10)}`, + username: validated.username, + password: validated.password, + serviceId: validated.serviceId, + status: 'idle', + paused: false, + usedBytes: 0, + quotaBytes: quotaMbToBytes(validated.quotaMb), + }; + + setSnapshot((current) => + withDerivedSnapshot({ + ...current, + service: { + ...current.service, + lastEvent: `User ${nextUser.username} created from panel`, + }, + userRecords: [...current.userRecords, nextUser], + }), ); }; @@ -675,6 +637,35 @@ export default function App() { ); }; + const handleSaveSystem = async (input: UpdateSystemInput) => { + const payload = await requestSnapshot(() => + fetch('/api/system', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }), + ); + + if (payload) { + setSnapshot(payload); + return; + } + + setSnapshot((current) => + withDerivedSnapshot({ + ...current, + service: { + ...current.service, + lastEvent: 'System configuration updated from panel', + }, + system: { + ...input, + previewConfig: current.system.previewConfig, + }, + }), + ); + }; + return (
@@ -720,7 +711,7 @@ export default function App() { onDeleteUser={handleDeleteUser} /> ) : null} - {activeTab === 'system' ? : null} + {activeTab === 'system' ? : null}
); } @@ -752,3 +743,34 @@ function withDerivedSnapshot(snapshot: DashboardSnapshot): DashboardSnapshot { }, }; } + +async function requestSnapshot(request: () => Promise): Promise { + try { + const response = await request(); + + if (!response.ok) { + throw new Error(await readApiError(response)); + } + + return (await response.json()) as DashboardSnapshot; + } catch (error) { + if (error instanceof TypeError) { + return null; + } + + throw error; + } +} + +async function readApiError(response: Response): Promise { + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) { + return payload.error; + } + } catch { + // Ignore invalid JSON and fall through to status-based message. + } + + return `Request failed with status ${response.status}.`; +} diff --git a/src/SystemTab.tsx b/src/SystemTab.tsx new file mode 100644 index 0000000..2add3dc --- /dev/null +++ b/src/SystemTab.tsx @@ -0,0 +1,273 @@ +import { FormEvent, useEffect, useState } from 'react'; +import type { DashboardSnapshot, ProxyServiceRecord, SystemSettings, UpdateSystemInput } from './shared/contracts'; +import { getProtocolForCommand, validateSystemInput } from './shared/validation'; + +interface SystemTabProps { + snapshot: DashboardSnapshot; + onSaveSystem: (input: UpdateSystemInput) => Promise; +} + +export default function SystemTab({ snapshot, onSaveSystem }: SystemTabProps) { + const [draft, setDraft] = useState(() => cloneSystemSettings(snapshot.system)); + const [error, setError] = useState(''); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + setDraft(cloneSystemSettings(snapshot.system)); + setError(''); + }, [snapshot.system]); + + const updateService = (serviceId: string, updater: (service: ProxyServiceRecord) => ProxyServiceRecord) => { + setDraft((current) => ({ + ...current, + services: current.services.map((service) => (service.id === serviceId ? updater(service) : service)), + })); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSaving(true); + setError(''); + + try { + const validated = validateSystemInput(draft, snapshot.userRecords); + await onSaveSystem(validated); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : 'Unable to save system settings.'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+
+
+

Runtime

+ editable +
+
+ + + + +
+

+ Saving writes a new generated config and keeps existing user assignments on enabled assignable + services only. +

+
+ +
+
+

Services

+ +
+
+ {draft.services.map((service, index) => ( +
+
+
+ Service {index + 1} +

{service.id}

+
+ +
+
+ + + + + +
+
+ + +
+
+ ))} +
+ {error ?

{error}

: null} +
+ + +
+
+ +
+
+

Generated config

+
+
{snapshot.system.previewConfig}
+
+
+ + ); +} + +function cloneSystemSettings(system: SystemSettings): UpdateSystemInput { + return { + publicHost: system.publicHost, + configMode: system.configMode, + reloadMode: system.reloadMode, + storageMode: system.storageMode, + services: system.services.map((service) => ({ ...service })), + }; +} + +function createServiceDraft(existingServices: ProxyServiceRecord[]): ProxyServiceRecord { + const usedPorts = new Set(existingServices.map((service) => service.port)); + let port = 1080; + + while (usedPorts.has(port) && port < 65535) { + port += 1; + } + + return { + id: `service-${Math.random().toString(36).slice(2, 8)}`, + name: `Service ${existingServices.length + 1}`, + command: 'socks', + protocol: 'socks5', + description: 'Additional SOCKS5 entrypoint managed from the panel.', + port, + enabled: true, + assignable: true, + }; +} diff --git a/src/app.css b/src/app.css index 1db51ad..022a701 100644 --- a/src/app.css +++ b/src/app.css @@ -43,7 +43,8 @@ body { button, input, -select { +select, +textarea { font: inherit; } @@ -51,6 +52,11 @@ button { cursor: pointer; } +button:disabled { + cursor: not-allowed; + opacity: 0.65; +} + .login-shell { min-height: 100vh; display: grid; @@ -106,7 +112,8 @@ button { } .login-form label, -.modal-form label { +.modal-form label, +.field-group { display: grid; gap: 6px; font-weight: 500; @@ -114,7 +121,10 @@ button { .login-form input, .modal-form input, -.modal-form select { +.modal-form select, +.field-group input, +.field-group select, +.field-group textarea { width: 100%; height: 40px; padding: 0 12px; @@ -126,7 +136,10 @@ button { .login-form input:focus, .modal-form input:focus, -.modal-form select:focus { +.modal-form select:focus, +.field-group input:focus, +.field-group select:focus, +.field-group textarea:focus { outline: 2px solid rgba(37, 99, 235, 0.12); border-color: var(--accent); } @@ -413,6 +426,13 @@ button, line-height: 1.2; } +.table-note, +.system-hint, +.service-editor-header p { + margin: 0; + color: var(--muted); +} + .toolbar-actions { flex-wrap: wrap; } @@ -522,6 +542,62 @@ tbody tr:last-child td { gap: 8px; } +.system-editor { + display: block; +} + +.system-fields, +.service-editor-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.service-editor-list { + display: grid; + gap: 12px; +} + +.service-editor-row { + display: grid; + gap: 12px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-muted); +} + +.service-editor-header, +.system-actions, +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.toggle-row { + justify-content: flex-start; + flex-wrap: wrap; +} + +.toggle-check { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 500; +} + +.toggle-check input { + width: 16px; + height: 16px; + margin: 0; +} + +.field-span-2 { + grid-column: 1 / -1; +} + pre { margin: 0; padding: 14px; @@ -611,13 +687,19 @@ pre { .page-grid, .stats-strip, - .modal-form { + .modal-form, + .system-fields, + .service-editor-grid { grid-template-columns: 1fr; } .modal-actions { justify-content: stretch; } + + .system-actions { + justify-content: stretch; + } } @media (max-width: 640px) { diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 4fba398..b8d1f77 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -31,6 +31,14 @@ export interface ProxyUserRecord { paused?: boolean; } +export interface SystemSettings { + publicHost: string; + configMode: string; + reloadMode: string; + storageMode: string; + services: ProxyServiceRecord[]; +} + export interface ControlPlaneState { service: { versionLabel: string; @@ -44,13 +52,7 @@ export interface ControlPlaneState { daily: DailyTrafficBucket[]; }; userRecords: ProxyUserRecord[]; - system: { - publicHost: string; - configMode: string; - reloadMode: string; - storageMode: string; - services: ProxyServiceRecord[]; - }; + system: SystemSettings; } export interface DashboardSnapshot { @@ -85,3 +87,5 @@ export interface CreateUserInput { serviceId: string; quotaMb: number | null; } + +export type UpdateSystemInput = SystemSettings; diff --git a/src/shared/validation.ts b/src/shared/validation.ts new file mode 100644 index 0000000..4558ab9 --- /dev/null +++ b/src/shared/validation.ts @@ -0,0 +1,171 @@ +import type { + CreateUserInput, + ProxyServiceRecord, + ProxyUserRecord, + ServiceCommand, + ServiceProtocol, + UpdateSystemInput, +} from './contracts'; + +const TOKEN_PATTERN = /^[A-Za-z0-9._~!@#$%^&*+=:/-]+$/; +const SERVICE_ID_PATTERN = /^[A-Za-z0-9._-]+$/; +const HOST_PATTERN = /^[A-Za-z0-9.-]+$/; + +const COMMANDS: ServiceCommand[] = ['socks', 'proxy', 'admin']; + +const MB = 1024 * 1024; + +export function getProtocolForCommand(command: ServiceCommand): ServiceProtocol { + return command === 'socks' ? 'socks5' : 'http'; +} + +export function validateCreateUserInput( + input: Partial, + services: ProxyServiceRecord[], +): CreateUserInput { + const username = input.username?.trim() ?? ''; + const password = input.password?.trim() ?? ''; + const serviceId = input.serviceId?.trim() ?? ''; + const quotaMb = input.quotaMb ?? null; + + assertSafeToken(username, 'Username'); + assertSafeToken(password, 'Password'); + + if (!serviceId) { + throw new Error('Service is required.'); + } + + const service = services.find((entry) => entry.id === serviceId); + if (!service || !service.enabled || !service.assignable) { + throw new Error('Service must reference an enabled assignable entry.'); + } + + if (quotaMb !== null && (!Number.isFinite(quotaMb) || quotaMb <= 0 || !Number.isInteger(quotaMb))) { + throw new Error('Quota must be a positive integer number of megabytes.'); + } + + return { + username, + password, + serviceId, + quotaMb, + }; +} + +export function validateSystemInput( + input: Partial, + existingUsers: ProxyUserRecord[], +): UpdateSystemInput { + const publicHost = input.publicHost?.trim() ?? ''; + const configMode = input.configMode?.trim() ?? ''; + const reloadMode = input.reloadMode?.trim() ?? ''; + const storageMode = input.storageMode?.trim() ?? ''; + const rawServices = Array.isArray(input.services) ? input.services : []; + + if (!publicHost || !HOST_PATTERN.test(publicHost)) { + throw new Error('Public host must contain only letters, digits, dots, and hyphens.'); + } + + assertPresent(configMode, 'Config mode'); + assertPresent(reloadMode, 'Reload mode'); + assertPresent(storageMode, 'Storage mode'); + + if (rawServices.length === 0) { + throw new Error('At least one service is required.'); + } + + const seenIds = new Set(); + const seenPorts = new Map(); + + const services = rawServices.map((service, index) => { + const id = service.id?.trim() ?? ''; + const name = service.name?.trim() ?? ''; + const description = service.description?.trim() ?? ''; + const command = service.command; + const port = Number(service.port); + const enabled = Boolean(service.enabled); + const assignable = Boolean(service.assignable); + const label = name || `service ${index + 1}`; + + if (!id || !SERVICE_ID_PATTERN.test(id)) { + throw new Error(`Service ${index + 1} must have a safe id.`); + } + + if (seenIds.has(id)) { + throw new Error(`Service id ${id} is duplicated.`); + } + seenIds.add(id); + + assertPresent(name, `Service ${index + 1} name`); + assertPresent(description, `Service ${index + 1} description`); + + if (!COMMANDS.includes(command)) { + throw new Error(`Service ${label} uses an unsupported command.`); + } + + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Service ${label} must use a port between 1 and 65535.`); + } + + const conflict = seenPorts.get(port); + if (conflict) { + throw new Error(`Services ${conflict} and ${label} cannot share port ${port}.`); + } + seenPorts.set(port, label); + + if (command === 'admin' && assignable) { + throw new Error('Admin services cannot be assignable.'); + } + + return { + id, + name, + description, + command, + protocol: getProtocolForCommand(command), + port, + enabled, + assignable: command === 'admin' ? false : assignable, + }; + }); + + const servicesById = new Map(services.map((service) => [service.id, service] as const)); + const invalidUser = existingUsers.find((user) => { + const service = servicesById.get(user.serviceId); + return !service || !service.enabled || !service.assignable; + }); + + if (invalidUser) { + throw new Error( + `User ${invalidUser.username} must stay attached to an enabled assignable service before saving system changes.`, + ); + } + + return { + publicHost, + configMode, + reloadMode, + storageMode, + services, + }; +} + +export function quotaMbToBytes(value: number | null): number | null { + return value === null ? null : value * MB; +} + +function assertPresent(value: string, label: string): void { + if (!value) { + throw new Error(`${label} is required.`); + } +} + +function assertSafeToken(value: string, label: string): void { + if (!value) { + throw new Error(`${label} is required.`); + } + + if (!TOKEN_PATTERN.test(value)) { + throw new Error(`${label} contains unsupported characters.`); + } +}