project-nomad/admin/app/validators/settings.ts
Yi-111-a 9aad8ff3c9 fix(ui): let the Service Logs sidebar link honor a custom URL
#991 added custom_url overrides for app launch links, but the Settings
sidebar's "Service Logs & Metrics" entry still built its href from just a
port: getServiceLink('9999') in SettingsLayout.tsx.

Dozzle is a management-compose container (nomad_dozzle), not a `services`
row, so that override has nothing to attach to and the link always
resolved to http://<current-host>:9999. For anyone reaching NOMAD through
a reverse proxy or a local DNS name — the exact audience #991 served — the
logs link is broken with no workaround in the UI.

Add a KV-backed ui.serviceLogsUrl override, editable under Advanced >
Reverse Proxy, and route the sidebar entry through getServiceLink's
existing customUrl argument. With nothing set the link is unchanged.

Validation accepts a full http(s) URL or a bare host (normalizeCustomUrl
prepends http://), treats empty as "clear", and refuses a declared
non-http(s) scheme so an input like file:///etc/passwd is rejected at the
boundary instead of being silently reinterpreted as a hostname.
2026-09-26 00:44:23 +08:00

101 lines
4.2 KiB
TypeScript

import vine from "@vinejs/vine";
import { SETTINGS_KEYS } from "../../constants/kv_store.js";
import type { KVStoreKey } from "../../types/kv_store.js";
export const getSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),
}))
export const updateSettingSchema = vine.compile(vine.object({
key: vine.enum(SETTINGS_KEYS),
value: vine.any().optional(),
}))
const HHMM_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/
/**
* Validate the *value* for keys that have format constraints beyond the generic
* enum/any check (the generic validator only constrains the key). Returns an
* error message string when invalid, or null when the value is acceptable.
*/
export function validateSettingValue(key: KVStoreKey, value: unknown): string | null {
switch (key) {
case 'autoUpdate.windowStart':
case 'autoUpdate.windowEnd':
case 'contentAutoUpdate.windowStart':
case 'contentAutoUpdate.windowEnd':
if (typeof value !== 'string' || !HHMM_PATTERN.test(value)) {
return 'Time window values must be in 24-hour HH:MM format (e.g. "20:00").'
}
return null
case 'autoUpdate.cooloffHours':
case 'contentAutoUpdate.cooloffHours': {
const num = Number(value)
if (!Number.isInteger(num) || num < 0 || num > 8760) {
return 'Cool-off must be a whole number of hours between 0 and 8760.'
}
return null
}
case 'system.internetStatusTestUrl': {
// Empty clears the setting (reverts to env var / built-in defaults).
if (value === '' || value === undefined || value === null) {
return null
}
if (typeof value !== 'string') {
return 'Test URL must be a string.'
}
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'Test URL must use http or https.'
}
} catch {
return 'Test URL must be a valid URL (e.g. "https://example.com").'
}
return null
}
case 'ui.serviceLogsUrl': {
if (value === undefined || value === null) {
return null
}
if (typeof value !== 'string') {
return 'Service logs URL must be a string.'
}
// Empty clears the setting (reverts to the port-derived default). The Advanced
// page trims before submitting, so whitespace-only has to mean "clear" too.
const trimmed = value.trim()
if (trimmed === '') {
return null
}
// A bare host ("logs.lan", "10.0.0.5:9999") is accepted and normalized to
// http:// by normalizeCustomUrl before it becomes an href, so validate the
// same way rather than demanding a scheme. An explicitly declared
// non-http(s) scheme ("file://", "ftp://") is refused rather than silently
// reinterpreted as a hostname by that http:// prefix.
const isHttpUrl = /^https?:\/\//i.test(trimmed)
if (!isHttpUrl && /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
return 'Service logs URL must use http or https.'
}
const withScheme = isHttpUrl ? trimmed : `http://${trimmed}`
try {
const url = new URL(withScheme)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'Service logs URL must use http or https.'
}
} catch {
return 'Service logs URL must be a valid URL (e.g. "https://logs.example.com").'
}
return null
}
case 'contentAutoUpdate.maxBytesPerWindow': {
// Per-window download budget in bytes. 0 = unlimited.
const num = Number(value)
if (!Number.isInteger(num) || num < 0) {
return 'The per-window data cap must be a whole number of bytes (0 = unlimited).'
}
return null
}
default:
return null
}
}