From 6846fac1ccd27730cd9a2ce1e82006fca38b834a Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 25 May 2026 16:02:27 +0200 Subject: [PATCH] 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 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. --- frontend/package-lock.json | 4 +- frontend/package.json | 3 +- frontend/src/api/queries/useAllSettings.ts | 26 ++++--- frontend/src/api/queries/useNodesQuery.ts | 41 ++-------- frontend/src/api/queries/useStatusQuery.ts | 5 +- frontend/src/pages/login/LoginPage.tsx | 14 ++-- frontend/src/schemas/_envelope.ts | 10 +++ frontend/src/schemas/login.ts | 11 +++ frontend/src/schemas/node.ts | 31 ++++++++ frontend/src/schemas/setting.ts | 90 ++++++++++++++++++++++ frontend/src/schemas/status.ts | 56 ++++++++++++++ frontend/src/utils/zodForm.ts | 15 ++++ frontend/src/utils/zodValidate.ts | 18 +++++ 13 files changed, 266 insertions(+), 58 deletions(-) create mode 100644 frontend/src/schemas/_envelope.ts create mode 100644 frontend/src/schemas/login.ts create mode 100644 frontend/src/schemas/node.ts create mode 100644 frontend/src/schemas/setting.ts create mode 100644 frontend/src/schemas/status.ts create mode 100644 frontend/src/utils/zodForm.ts create mode 100644 frontend/src/utils/zodValidate.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2042a04f..f3951011 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" diff --git a/frontend/package.json b/frontend/package.json index 524f4a9d..c7943f4e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/queries/useAllSettings.ts b/frontend/src/api/queries/useAllSettings.ts index cd2566d9..593853c9 100644 --- a/frontend/src/api/queries/useAllSettings.ts +++ b/frontend/src/api/queries/useAllSettings.ts @@ -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 { - success?: boolean; - obj?: T; - msg?: string; -} - -async function fetchAllSetting(): Promise { - const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true }) as ApiMsg; +async function fetchAllSetting(): Promise { + 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, + mutationFn: async (next: AllSetting): Promise> => { + 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() }); }, diff --git a/frontend/src/api/queries/useNodesQuery.ts b/frontend/src/api/queries/useNodesQuery.ts index 5c7c6b07..a916fd61 100644 --- a/frontend/src/api/queries/useNodesQuery.ts +++ b/frontend/src/api/queries/useNodesQuery.ts @@ -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 { - success?: boolean; - msg?: string; - obj?: T; -} - async function fetchNodes(): Promise { - const msg = await HttpUtil.get('/panel/api/nodes/list', undefined, { silent: true }) as ApiMsg; + 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() { diff --git a/frontend/src/api/queries/useStatusQuery.ts b/frontend/src/api/queries/useStatusQuery.ts index bb33eb50..755b73aa 100644 --- a/frontend/src/api/queries/useStatusQuery.ts +++ b/frontend/src/api/queries/useStatusQuery.ts @@ -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 { 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() { diff --git a/frontend/src/pages/login/LoginPage.tsx b/frontend/src/pages/login/LoginPage.tsx index fe2ab6d9..55c358fa 100644 --- a/frontend/src/pages/login/LoginPage.tsx +++ b/frontend/src/pages/login/LoginPage.tsx @@ -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() { } @@ -205,7 +203,7 @@ export default function LoginPage() { } @@ -219,7 +217,7 @@ export default function LoginPage() { } diff --git a/frontend/src/schemas/_envelope.ts b/frontend/src/schemas/_envelope.ts new file mode 100644 index 00000000..3c08df17 --- /dev/null +++ b/frontend/src/schemas/_envelope.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const msgSchema = (obj: T) => + z.object({ + success: z.boolean(), + msg: z.string().default(''), + obj: obj.nullable(), + }); + +export type MsgOf = z.infer>>; diff --git a/frontend/src/schemas/login.ts b/frontend/src/schemas/login.ts new file mode 100644 index 00000000..bba389af --- /dev/null +++ b/frontend/src/schemas/login.ts @@ -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; diff --git a/frontend/src/schemas/node.ts b/frontend/src/schemas/node.ts new file mode 100644 index 00000000..805e25f8 --- /dev/null +++ b/frontend/src/schemas/node.ts @@ -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; diff --git a/frontend/src/schemas/setting.ts b/frontend/src/schemas/setting.ts new file mode 100644 index 00000000..3cdb52d0 --- /dev/null +++ b/frontend/src/schemas/setting.ts @@ -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; diff --git a/frontend/src/schemas/status.ts b/frontend/src/schemas/status.ts new file mode 100644 index 00000000..0aa4c0f8 --- /dev/null +++ b/frontend/src/schemas/status.ts @@ -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; diff --git a/frontend/src/utils/zodForm.ts b/frontend/src/utils/zodForm.ts new file mode 100644 index 00000000..2657e228 --- /dev/null +++ b/frontend/src/utils/zodForm.ts @@ -0,0 +1,15 @@ +import type { Rule } from 'antd/es/form'; +import type { TFunction } from 'i18next'; +import type { z } from 'zod'; + +export function antdRule(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 })); + }, + }; +} diff --git a/frontend/src/utils/zodValidate.ts b/frontend/src/utils/zodValidate.ts new file mode 100644 index 00000000..cc1e0ff1 --- /dev/null +++ b/frontend/src/utils/zodValidate.ts @@ -0,0 +1,18 @@ +import type { z } from 'zod'; +import { Msg } from '@/utils'; + +export function parseMsg( + msg: Msg, + schema: T, + context: string, +): Msg> { + if (!msg.success || msg.obj == null) { + return msg as Msg>; + } + const result = schema.safeParse(msg.obj); + if (!result.success) { + console.warn(`[zod] ${context} response failed validation`, result.error.issues); + return msg as Msg>; + } + return new Msg>(msg.success, msg.msg, result.data); +}