mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-28 07:59:35 +00:00
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:
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() });
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
10
frontend/src/schemas/_envelope.ts
Normal file
10
frontend/src/schemas/_envelope.ts
Normal 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>>>;
|
||||
11
frontend/src/schemas/login.ts
Normal file
11
frontend/src/schemas/login.ts
Normal 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>;
|
||||
31
frontend/src/schemas/node.ts
Normal file
31
frontend/src/schemas/node.ts
Normal 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>;
|
||||
90
frontend/src/schemas/setting.ts
Normal file
90
frontend/src/schemas/setting.ts
Normal 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>;
|
||||
56
frontend/src/schemas/status.ts
Normal file
56
frontend/src/schemas/status.ts
Normal 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>;
|
||||
15
frontend/src/utils/zodForm.ts
Normal file
15
frontend/src/utils/zodForm.ts
Normal 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 }));
|
||||
},
|
||||
};
|
||||
}
|
||||
18
frontend/src/utils/zodValidate.ts
Normal file
18
frontend/src/utils/zodValidate.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user