feat(frontend): block invalid settings saves with Zod pre-save check

Tighten AllSettingSchema with the actual valid ranges and patterns:

- webPort / subPort / ldapPort: integer 1-65535
- pageSize: integer 1-1000
- sessionMaxAge: integer >= 1
- tgCpu: integer 0-100 (percentage)
- subUpdates: integer 1-168 (hours)
- expireDiff / trafficDiff / ldapDefault*: non-negative integers
- webBasePath / subPath / subJsonPath / subClashPath: must start with /

The existing useAllSettings save path runs AllSettingSchema.partial()
through safeParse and logs drift without blocking. SettingsPage now
adds a stronger gate before the mutation: run the full schema against
the draft and, on failure, surface the first issue (field path +
message) via the existing messageApi.error so the user actually sees
what's wrong instead of silently sending bad data to the backend.

Use cases caught: port out of range, negative quota, sub path missing
leading slash, page size set to 0, tgCpu > 100.
This commit is contained in:
MHSanaei
2026-05-25 17:55:21 +02:00
parent a3012daa8f
commit 4ecbb0e55f
2 changed files with 34 additions and 17 deletions

View File

@@ -29,6 +29,7 @@ import { setMessageInstance } from '@/utils/messageBus';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useAllSettings } from '@/api/queries/useAllSettings';
import { AllSettingSchema } from '@/schemas/setting';
import AppSidebar from '@/components/AppSidebar';
import GeneralTab from './GeneralTab';
import SecurityTab from './SecurityTab';
@@ -148,6 +149,18 @@ export default function SettingsPage() {
return url.toString();
}
async function onSave() {
const result = AllSettingSchema.safeParse(allSetting);
if (!result.success) {
const issue = result.error.issues[0];
const fieldPath = issue?.path.join('.') ?? 'value';
const msgKey = issue?.message ?? 'somethingWentWrong';
messageApi.error(`${fieldPath}: ${t(msgKey, { defaultValue: msgKey })}`);
return;
}
await saveAll();
}
function restartPanel() {
modal.confirm({
title: t('pages.settings.restartPanel'),
@@ -301,7 +314,7 @@ export default function SettingsPage() {
<Row className="header-row">
<Col xs={24} sm={10} className="header-actions">
<Space>
<Button type="primary" disabled={saveDisabled} onClick={saveAll}>
<Button type="primary" disabled={saveDisabled} onClick={onSave}>
{t('pages.settings.save')}
</Button>
<Button type="primary" danger disabled={!saveDisabled} onClick={restartPanel}>

View File

@@ -1,17 +1,21 @@
import { z } from 'zod';
const port = z.number().int().min(1).max(65535);
const nonNegativeInt = z.number().int().min(0);
const absolutePath = z.string().regex(/^\//, 'pages.settings.validation.pathLeadingSlash');
export const AllSettingSchema = z.object({
webListen: z.string().optional(),
webDomain: z.string().optional(),
webPort: z.number().optional(),
webPort: port.optional(),
webCertFile: z.string().optional(),
webKeyFile: z.string().optional(),
webBasePath: z.string().optional(),
sessionMaxAge: z.number().optional(),
webBasePath: absolutePath.optional(),
sessionMaxAge: z.number().int().min(1).optional(),
trustedProxyCIDRs: z.string().optional(),
pageSize: z.number().optional(),
expireDiff: z.number().optional(),
trafficDiff: z.number().optional(),
pageSize: z.number().int().min(1).max(1000).optional(),
expireDiff: nonNegativeInt.optional(),
trafficDiff: nonNegativeInt.optional(),
remarkModel: z.string().optional(),
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
tgBotEnable: z.boolean().optional(),
@@ -22,7 +26,7 @@ export const AllSettingSchema = z.object({
tgRunTime: z.string().optional(),
tgBotBackup: z.boolean().optional(),
tgBotLoginNotify: z.boolean().optional(),
tgCpu: z.number().optional(),
tgCpu: z.number().int().min(0).max(100).optional(),
tgLang: z.string().optional(),
twoFactorEnable: z.boolean().optional(),
twoFactorToken: z.string().optional(),
@@ -36,18 +40,18 @@ export const AllSettingSchema = z.object({
subEnableRouting: z.boolean().optional(),
subRoutingRules: z.string().optional(),
subListen: z.string().optional(),
subPort: z.number().optional(),
subPath: z.string().optional(),
subJsonPath: z.string().optional(),
subPort: port.optional(),
subPath: absolutePath.optional(),
subJsonPath: absolutePath.optional(),
subClashEnable: z.boolean().optional(),
subClashPath: z.string().optional(),
subClashPath: absolutePath.optional(),
subDomain: z.string().optional(),
externalTrafficInformEnable: z.boolean().optional(),
externalTrafficInformURI: z.string().optional(),
restartXrayOnClientDisable: z.boolean().optional(),
subCertFile: z.string().optional(),
subKeyFile: z.string().optional(),
subUpdates: z.number().optional(),
subUpdates: z.number().int().min(1).max(168).optional(),
subEncrypt: z.boolean().optional(),
subShowInfo: z.boolean().optional(),
subEmailInRemark: z.boolean().optional(),
@@ -61,7 +65,7 @@ export const AllSettingSchema = z.object({
timeLocation: z.string().optional(),
ldapEnable: z.boolean().optional(),
ldapHost: z.string().optional(),
ldapPort: z.number().optional(),
ldapPort: port.optional(),
ldapUseTLS: z.boolean().optional(),
ldapBindDN: z.string().optional(),
ldapPassword: z.string().optional(),
@@ -76,9 +80,9 @@ export const AllSettingSchema = z.object({
ldapInboundTags: z.string().optional(),
ldapAutoCreate: z.boolean().optional(),
ldapAutoDelete: z.boolean().optional(),
ldapDefaultTotalGB: z.number().optional(),
ldapDefaultExpiryDays: z.number().optional(),
ldapDefaultLimitIP: z.number().optional(),
ldapDefaultTotalGB: nonNegativeInt.optional(),
ldapDefaultExpiryDays: nonNegativeInt.optional(),
ldapDefaultLimitIP: nonNegativeInt.optional(),
hasTgBotToken: z.boolean().optional(),
hasTwoFactorToken: z.boolean().optional(),
hasLdapPassword: z.boolean().optional(),