feat(frontend): add Zod runtime validation at API boundary

Introduces Zod 4 schemas for response validation on the three highest-traffic
endpoints (server/status, nodes/list, setting/all) and a Zod->AntD form rule
adapter, replacing the duplicated per-file ApiMsg<T> interfaces. Validation
runs safeParse with console.warn + raw-payload fallback so backend drift never
breaks the UI for users.

Login form switches to schema-driven rules as the proof-of-life for the
adapter. Class-based models stay untouched; remaining query/mutation hooks
and form modals will migrate in follow-ups.
This commit is contained in:
MHSanaei
2026-05-25 16:02:27 +02:00
parent 20edaee8ed
commit 6846fac1cc
13 changed files with 266 additions and 58 deletions

View File

@@ -26,7 +26,8 @@
"react-i18next": "^17.0.8",
"react-router-dom": "^7.15.1",
"recharts": "^3.8.1",
"swagger-ui-react": "^5.32.6"
"swagger-ui-react": "^5.32.6",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -6819,7 +6820,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@@ -35,7 +35,8 @@
"react-i18next": "^17.0.8",
"react-router-dom": "^7.15.1",
"recharts": "^3.8.1",
"swagger-ui-react": "^5.32.6"
"swagger-ui-react": "^5.32.6",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",

View File

@@ -1,20 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { HttpUtil, Msg } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { AllSetting } from '@/models/setting';
import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
interface ApiMsg<T = unknown> {
success?: boolean;
obj?: T;
msg?: string;
}
async function fetchAllSetting(): Promise<unknown> {
const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true }) as ApiMsg;
async function fetchAllSetting(): Promise<AllSettingInput | null> {
const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
return msg.obj;
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
return validated.obj;
}
export function useAllSettings() {
@@ -45,8 +42,13 @@ export function useAllSettings() {
}, []);
const saveMut = useMutation({
mutationFn: async (next: AllSetting) =>
HttpUtil.post('/panel/setting/update', next) as Promise<ApiMsg>,
mutationFn: async (next: AllSetting): Promise<Msg<unknown>> => {
const body = AllSettingSchema.partial().safeParse(next);
if (!body.success) {
console.warn('[zod] setting/update body failed validation', body.error.issues);
}
return HttpUtil.post('/panel/setting/update', body.success ? body.data : next);
},
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
},

View File

@@ -2,34 +2,12 @@ import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { NodeListSchema } from '@/schemas/node';
import type { NodeRecord } from '@/schemas/node';
import { keys } from '@/api/queryKeys';
export interface NodeRecord {
id: number;
name?: string;
remark?: string;
scheme?: string;
address?: string;
port?: number;
basePath?: string;
apiToken?: string;
enable?: boolean;
status?: 'online' | 'offline' | string;
latencyMs?: number;
cpuPct?: number;
memPct?: number;
xrayVersion?: string;
panelVersion?: string;
uptimeSecs?: number;
inboundCount?: number;
clientCount?: number;
onlineCount?: number;
depletedCount?: number;
lastHeartbeat?: number;
lastError?: string;
allowPrivateAddress?: boolean;
[key: string]: unknown;
}
export type { NodeRecord };
export interface NodeTotals {
total: number;
@@ -42,16 +20,11 @@ export interface NodeTotals {
depleted: number;
}
interface ApiMsg<T = unknown> {
success?: boolean;
msg?: string;
obj?: T;
}
async function fetchNodes(): Promise<NodeRecord[]> {
const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true }) as ApiMsg<NodeRecord[]>;
const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch nodes');
return Array.isArray(msg.obj) ? msg.obj : [];
const validated = parseMsg(msg, NodeListSchema, 'nodes/list');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useNodesQuery() {

View File

@@ -2,7 +2,9 @@ import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { Status } from '@/models/status';
import { StatusSchema } from '@/schemas/status';
import { keys } from '@/api/queryKeys';
const POLL_INTERVAL_MS = 2000;
@@ -10,7 +12,8 @@ const POLL_INTERVAL_MS = 2000;
async function fetchStatus(): Promise<Status> {
const msg = await HttpUtil.get('/panel/api/server/status', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch status');
return new Status(msg.obj);
const validated = parseMsg(msg, StatusSchema, 'server/status');
return new Status(validated.obj);
}
export function useStatusQuery() {

View File

@@ -23,17 +23,15 @@ import {
} from '@ant-design/icons';
import { HttpUtil, LanguageManager } from '@/utils';
import { antdRule } from '@/utils/zodForm';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
import './LoginPage.css';
const HEADLINE_INTERVAL_MS = 2000;
interface LoginForm {
username: string;
password: string;
twoFactorCode?: string;
}
type LoginForm = LoginFormValues;
const basePath = window.X_UI_BASE_PATH || '';
@@ -191,7 +189,7 @@ export default function LoginPage() {
<Form.Item
label={t('username')}
name="username"
rules={[{ required: true, message: t('username') }]}
rules={[antdRule(LoginFormSchema.shape.username, t)]}
>
<Input
prefix={<UserOutlined />}
@@ -205,7 +203,7 @@ export default function LoginPage() {
<Form.Item
label={t('password')}
name="password"
rules={[{ required: true, message: t('password') }]}
rules={[antdRule(LoginFormSchema.shape.password, t)]}
>
<Input.Password
prefix={<LockOutlined />}
@@ -219,7 +217,7 @@ export default function LoginPage() {
<Form.Item
label={t('twoFactorCode')}
name="twoFactorCode"
rules={[{ required: true, message: t('twoFactorCode') }]}
rules={[antdRule(TwoFactorCodeSchema, t)]}
>
<Input
prefix={<KeyOutlined />}

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const msgSchema = <T extends z.ZodTypeAny>(obj: T) =>
z.object({
success: z.boolean(),
msg: z.string().default(''),
obj: obj.nullable(),
});
export type MsgOf<S extends z.ZodTypeAny> = z.infer<ReturnType<typeof msgSchema<S>>>;

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const LoginFormSchema = z.object({
username: z.string().min(1, 'username'),
password: z.string().min(1, 'password'),
twoFactorCode: z.string().optional(),
});
export const TwoFactorCodeSchema = z.string().min(1, 'twoFactorCode');
export type LoginFormValues = z.infer<typeof LoginFormSchema>;

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
export const NodeRecordSchema = z.object({
id: z.number(),
name: z.string().optional(),
remark: z.string().optional(),
scheme: z.string().optional(),
address: z.string().optional(),
port: z.number().optional(),
basePath: z.string().optional(),
apiToken: z.string().optional(),
enable: z.boolean().optional(),
status: z.string().optional(),
latencyMs: z.number().optional(),
cpuPct: z.number().optional(),
memPct: z.number().optional(),
xrayVersion: z.string().optional(),
panelVersion: z.string().optional(),
uptimeSecs: z.number().optional(),
inboundCount: z.number().optional(),
clientCount: z.number().optional(),
onlineCount: z.number().optional(),
depletedCount: z.number().optional(),
lastHeartbeat: z.number().optional(),
lastError: z.string().optional(),
allowPrivateAddress: z.boolean().optional(),
}).loose();
export const NodeListSchema = z.array(NodeRecordSchema);
export type NodeRecord = z.infer<typeof NodeRecordSchema>;

View File

@@ -0,0 +1,90 @@
import { z } from 'zod';
export const AllSettingSchema = z.object({
webListen: z.string().optional(),
webDomain: z.string().optional(),
webPort: z.number().optional(),
webCertFile: z.string().optional(),
webKeyFile: z.string().optional(),
webBasePath: z.string().optional(),
sessionMaxAge: z.number().optional(),
trustedProxyCIDRs: z.string().optional(),
pageSize: z.number().optional(),
expireDiff: z.number().optional(),
trafficDiff: z.number().optional(),
remarkModel: z.string().optional(),
datepicker: z.enum(['gregorian', 'jalalian']).optional(),
tgBotEnable: z.boolean().optional(),
tgBotToken: z.string().optional(),
tgBotProxy: z.string().optional(),
tgBotAPIServer: z.string().optional(),
tgBotChatId: z.string().optional(),
tgRunTime: z.string().optional(),
tgBotBackup: z.boolean().optional(),
tgBotLoginNotify: z.boolean().optional(),
tgCpu: z.number().optional(),
tgLang: z.string().optional(),
twoFactorEnable: z.boolean().optional(),
twoFactorToken: z.string().optional(),
xrayTemplateConfig: z.string().optional(),
subEnable: z.boolean().optional(),
subJsonEnable: z.boolean().optional(),
subTitle: z.string().optional(),
subSupportUrl: z.string().optional(),
subProfileUrl: z.string().optional(),
subAnnounce: z.string().optional(),
subEnableRouting: z.boolean().optional(),
subRoutingRules: z.string().optional(),
subListen: z.string().optional(),
subPort: z.number().optional(),
subPath: z.string().optional(),
subJsonPath: z.string().optional(),
subClashEnable: z.boolean().optional(),
subClashPath: z.string().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(),
subEncrypt: z.boolean().optional(),
subShowInfo: z.boolean().optional(),
subEmailInRemark: z.boolean().optional(),
subURI: z.string().optional(),
subJsonURI: z.string().optional(),
subClashURI: z.string().optional(),
subJsonFragment: z.string().optional(),
subJsonNoises: z.string().optional(),
subJsonMux: z.string().optional(),
subJsonRules: z.string().optional(),
timeLocation: z.string().optional(),
ldapEnable: z.boolean().optional(),
ldapHost: z.string().optional(),
ldapPort: z.number().optional(),
ldapUseTLS: z.boolean().optional(),
ldapBindDN: z.string().optional(),
ldapPassword: z.string().optional(),
ldapBaseDN: z.string().optional(),
ldapUserFilter: z.string().optional(),
ldapUserAttr: z.string().optional(),
ldapVlessField: z.string().optional(),
ldapSyncCron: z.string().optional(),
ldapFlagField: z.string().optional(),
ldapTruthyValues: z.string().optional(),
ldapInvertFlag: z.boolean().optional(),
ldapInboundTags: z.string().optional(),
ldapAutoCreate: z.boolean().optional(),
ldapAutoDelete: z.boolean().optional(),
ldapDefaultTotalGB: z.number().optional(),
ldapDefaultExpiryDays: z.number().optional(),
ldapDefaultLimitIP: z.number().optional(),
hasTgBotToken: z.boolean().optional(),
hasTwoFactorToken: z.boolean().optional(),
hasLdapPassword: z.boolean().optional(),
hasApiToken: z.boolean().optional(),
hasWarpSecret: z.boolean().optional(),
hasNordSecret: z.boolean().optional(),
}).loose();
export type AllSettingInput = z.infer<typeof AllSettingSchema>;

View File

@@ -0,0 +1,56 @@
import { z } from 'zod';
export const CurTotalInputSchema = z.object({
current: z.number().optional(),
total: z.number().optional(),
});
export const NetIOSchema = z.object({
up: z.number(),
down: z.number(),
});
export const NetTrafficSchema = z.object({
sent: z.number(),
recv: z.number(),
});
export const PublicIPSchema = z.object({
ipv4: z.union([z.string(), z.number()]),
ipv6: z.union([z.string(), z.number()]),
});
export const AppStatsSchema = z.object({
threads: z.number(),
mem: z.number(),
uptime: z.number(),
});
export const XrayInfoSchema = z.object({
state: z.string(),
errorMsg: z.string(),
version: z.string(),
color: z.string(),
}).partial();
export const StatusSchema = z.object({
cpu: z.number().optional(),
cpuCores: z.number().optional(),
logicalPro: z.number().optional(),
cpuSpeedMhz: z.number().optional(),
disk: CurTotalInputSchema.optional(),
loads: z.array(z.number()).optional(),
mem: CurTotalInputSchema.optional(),
netIO: NetIOSchema.optional(),
netTraffic: NetTrafficSchema.optional(),
publicIP: PublicIPSchema.optional(),
swap: CurTotalInputSchema.optional(),
tcpCount: z.number().optional(),
udpCount: z.number().optional(),
uptime: z.number().optional(),
appUptime: z.number().optional(),
appStats: AppStatsSchema.optional(),
xray: XrayInfoSchema.optional(),
});
export type StatusInput = z.infer<typeof StatusSchema>;

View File

@@ -0,0 +1,15 @@
import type { Rule } from 'antd/es/form';
import type { TFunction } from 'i18next';
import type { z } from 'zod';
export function antdRule<T extends z.ZodTypeAny>(schema: T, t: TFunction): Rule {
return {
validator: async (_rule, value) => {
const result = schema.safeParse(value);
if (result.success) return;
const issue = result.error.issues[0];
const key = issue?.message ?? 'validation.invalid';
throw new Error(t(key, { defaultValue: key }));
},
};
}

View File

@@ -0,0 +1,18 @@
import type { z } from 'zod';
import { Msg } from '@/utils';
export function parseMsg<T extends z.ZodTypeAny>(
msg: Msg<unknown>,
schema: T,
context: string,
): Msg<z.infer<T>> {
if (!msg.success || msg.obj == null) {
return msg as Msg<z.infer<T>>;
}
const result = schema.safeParse(msg.obj);
if (!result.success) {
console.warn(`[zod] ${context} response failed validation`, result.error.issues);
return msg as Msg<z.infer<T>>;
}
return new Msg<z.infer<T>>(msg.success, msg.msg, result.data);
}