1
0
Эх сурвалжийг харах

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.
MHSanaei 19 цаг өмнө
parent
commit
6846fac1cc

+ 2 - 2
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"

+ 2 - 1
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",

+ 14 - 12
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<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() });
     },

+ 7 - 34
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<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() {

+ 4 - 1
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<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() {

+ 6 - 8
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() {
                   <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 - 0
frontend/src/schemas/_envelope.ts

@@ -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 - 0
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<typeof LoginFormSchema>;

+ 31 - 0
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<typeof NodeRecordSchema>;

+ 90 - 0
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<typeof AllSettingSchema>;

+ 56 - 0
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<typeof StatusSchema>;

+ 15 - 0
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<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 - 0
frontend/src/utils/zodValidate.ts

@@ -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);
+}