From d00ddc3f5823bea2fc3a81bebd6b919f52d004ee Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 25 May 2026 16:14:00 +0200 Subject: [PATCH] feat(frontend): extend Zod validation to remaining query/mutation hooks Adds Zod schemas for client/inbound/xray/node-probe endpoints and wires useNodeMutations, useClients, useInbounds, useXraySetting, useDatepicker through parseMsg. Drops the duplicated per-file ApiMsg interfaces and the local ClientRecord / OutboundTrafficRow / XraySettingsValue / DefaultsPayload declarations in favour of schema-inferred types re-exported from the new src/schemas/ modules. API boundary now validates: clients list/paged, clients onlines, clients lastOnline, clients get/hydrate, inbounds slim, inbounds get, inbounds options, defaultSettings, xray config, xray outbounds traffic, xray testOutbound, xray getXrayResult, getDefaultJsonConfig, nodes probe, nodes test. Mutation responses that consume obj (bulkAdjust, delDepleted, nodes probe / test) get response validation; pass-through mutations stay agnostic. NodeFormModal type-aligned to Msg. --- frontend/src/api/queries/useNodeMutations.ts | 37 ++--- frontend/src/hooks/useClients.ts | 154 +++++++------------ frontend/src/hooks/useDatepicker.ts | 10 +- frontend/src/hooks/useXraySetting.ts | 100 +++++------- frontend/src/pages/inbounds/useInbounds.ts | 50 +++--- frontend/src/pages/nodes/NodeFormModal.tsx | 17 +- frontend/src/pages/xray/OutboundsTab.tsx | 2 +- frontend/src/schemas/client.ts | 84 ++++++++++ frontend/src/schemas/defaults.ts | 18 +++ frontend/src/schemas/inbound.ts | 19 +++ frontend/src/schemas/node.ts | 8 + frontend/src/schemas/xray.ts | 77 ++++++++++ 12 files changed, 350 insertions(+), 226 deletions(-) create mode 100644 frontend/src/schemas/client.ts create mode 100644 frontend/src/schemas/defaults.ts create mode 100644 frontend/src/schemas/inbound.ts create mode 100644 frontend/src/schemas/xray.ts diff --git a/frontend/src/api/queries/useNodeMutations.ts b/frontend/src/api/queries/useNodeMutations.ts index 2b9f707e..5863cb14 100644 --- a/frontend/src/api/queries/useNodeMutations.ts +++ b/frontend/src/api/queries/useNodeMutations.ts @@ -1,21 +1,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { HttpUtil } from '@/utils'; +import { HttpUtil, Msg } from '@/utils'; +import { parseMsg } from '@/utils/zodValidate'; import { keys } from '@/api/queryKeys'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; +import { ProbeResultSchema, type ProbeResult } from '@/schemas/node'; -interface ApiMsg { - success?: boolean; - msg?: string; - obj?: T; -} - -export interface ProbeResult { - status: string; - latencyMs?: number; - xrayVersion?: string; - error?: string; -} +export type { ProbeResult }; export function useNodeMutations() { const queryClient = useQueryClient(); @@ -23,31 +14,33 @@ export function useNodeMutations() { const createMut = useMutation({ mutationFn: (payload: Partial) => - HttpUtil.post('/panel/api/nodes/add', payload) as Promise, + HttpUtil.post('/panel/api/nodes/add', payload), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const updateMut = useMutation({ mutationFn: ({ id, payload }: { id: number; payload: Partial }) => - HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as Promise, + HttpUtil.post(`/panel/api/nodes/update/${id}`, payload), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const removeMut = useMutation({ mutationFn: (id: number) => - HttpUtil.post(`/panel/api/nodes/del/${id}`) as Promise, + HttpUtil.post(`/panel/api/nodes/del/${id}`), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const setEnableMut = useMutation({ mutationFn: ({ id, enable }: { id: number; enable: boolean }) => - HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as Promise, + HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }), onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); const probeMut = useMutation({ - mutationFn: (id: number) => - HttpUtil.post(`/panel/api/nodes/probe/${id}`) as Promise>, + mutationFn: async (id: number): Promise> => { + const raw = await HttpUtil.post(`/panel/api/nodes/probe/${id}`); + return parseMsg(raw, ProbeResultSchema, 'nodes/probe'); + }, onSuccess: (msg) => { if (msg?.success) invalidate(); }, }); @@ -57,7 +50,9 @@ export function useNodeMutations() { remove: (id: number) => removeMut.mutateAsync(id), setEnable: (id: number, enable: boolean) => setEnableMut.mutateAsync({ id, enable }), probe: (id: number) => probeMut.mutateAsync(id), - testConnection: (payload: Partial) => - HttpUtil.post('/panel/api/nodes/test', payload) as Promise>, + testConnection: async (payload: Partial): Promise> => { + const raw = await HttpUtil.post('/panel/api/nodes/test', payload); + return parseMsg(raw, ProbeResultSchema, 'nodes/test'); + }, }; } diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 4d9b1dfb..fd6fdc61 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -1,55 +1,30 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { HttpUtil } from '@/utils'; +import { HttpUtil, Msg } from '@/utils'; +import { parseMsg } from '@/utils/zodValidate'; import { keys } from '@/api/queryKeys'; +import { + ClientHydrateSchema, + ClientPageResponseSchema, + InboundOptionsSchema, + OnlinesSchema, + BulkAdjustResultSchema, + DelDepletedResultSchema, + type ClientHydrate, + type ClientRecord, + type ClientTraffic, + type ClientsSummary, + type ClientPageResponse, + type InboundOption, + type BulkAdjustResult, +} from '@/schemas/client'; +import { DefaultsPayloadSchema } from '@/schemas/defaults'; + +export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption }; const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const; -export interface ClientTraffic { - up?: number; - down?: number; - total?: number; - expiryTime?: number; - enable?: boolean; - lastOnline?: number; -} - -export interface ClientRecord { - email: string; - subId?: string; - uuid?: string; - password?: string; - auth?: string; - flow?: string; - totalGB?: number; - expiryTime?: number; - limitIp?: number; - tgId?: number | string; - comment?: string; - enable?: boolean; - inboundIds?: number[]; - traffic?: ClientTraffic; - reverse?: { tag?: string }; - createdAt?: number; - updatedAt?: number; - [key: string]: unknown; -} - -export interface InboundOption { - id: number; - remark?: string; - protocol?: string; - port?: number; - tlsFlowCapable?: boolean; -} - -interface ApiMsg { - success?: boolean; - msg?: string; - obj?: T; -} - interface SubSettings { enable: boolean; subURI: string; @@ -68,24 +43,6 @@ export interface ClientQueryParams { order?: 'ascend' | 'descend'; } -export interface ClientsSummary { - total: number; - active: number; - online: string[]; - depleted: string[]; - expiring: string[]; - deactive: string[]; -} - -interface ClientPageResponse { - items: ClientRecord[]; - total: number; - filtered: number; - page: number; - pageSize: number; - summary?: ClientsSummary; -} - const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 }; const DEFAULT_SUMMARY: ClientsSummary = { total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [], @@ -106,21 +63,25 @@ function buildQS(p: ClientQueryParams): string { async function fetchClientPage(params: ClientQueryParams): Promise { const qs = buildQS(params); - const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true }) as ApiMsg; + const msg = await HttpUtil.get(`/panel/api/clients/list/paged?${qs}`, undefined, { silent: true }); if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch clients'); - return msg.obj; + const validated = parseMsg(msg, ClientPageResponseSchema, 'clients/list/paged'); + if (!validated.obj) throw new Error('Empty clients response'); + return validated.obj; } async function fetchInboundOptions(): Promise { - const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true }) as ApiMsg; + const msg = await HttpUtil.get('/panel/api/inbounds/options', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbound options'); - return Array.isArray(msg.obj) ? msg.obj : []; + const validated = parseMsg(msg, InboundOptionsSchema, 'inbounds/options'); + return Array.isArray(validated.obj) ? validated.obj : []; } async function fetchDefaults(): Promise> { - const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg>; + const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults'); - return msg.obj || {}; + const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings'); + return validated.obj || {}; } export function useClients() { @@ -168,9 +129,10 @@ export function useClients() { const onlinesQuery = useQuery({ queryKey: keys.clients.onlines(), queryFn: async () => { - const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg; + const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines'); - return Array.isArray(msg.obj) ? msg.obj : []; + const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines'); + return Array.isArray(validated.obj) ? validated.obj : []; }, staleTime: Infinity, }); @@ -208,22 +170,23 @@ export function useClients() { await invalidateAll(); }, [invalidateAll]); - const hydrate = useCallback(async (email: string): Promise<{ client: ClientRecord; inboundIds: number[] } | null> => { + const hydrate = useCallback(async (email: string): Promise => { if (!email) return null; - const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`) as ApiMsg<{ client: ClientRecord; inboundIds: number[] }>; + const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`); if (!msg?.success || !msg.obj) return null; - return msg.obj; + const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get'); + return validated.obj; }, []); const createMut = useMutation({ mutationFn: (payload: unknown) => - HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as Promise, + HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, }); const updateMut = useMutation({ mutationFn: ({ email, client }: { email: string; client: unknown }) => - HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS) as Promise, + HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, }); @@ -232,7 +195,7 @@ export function useClients() { const url = keepTraffic ? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1` : `/panel/api/clients/del/${encodeURIComponent(email)}`; - return HttpUtil.post(url) as Promise; + return HttpUtil.post(url); }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, }); @@ -242,7 +205,7 @@ export function useClients() { const suffix = keepTraffic ? '?keepTraffic=1' : ''; const results = await Promise.all(emails.map((email) => { const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`; - return HttpUtil.post(url, undefined, { silent: true }) as Promise; + return HttpUtil.post(url, undefined, { silent: true }); })); return results; }, @@ -250,54 +213,55 @@ export function useClients() { }); const bulkAdjustMut = useMutation({ - mutationFn: (payload: { emails: string[]; addDays: number; addBytes: number }) => - HttpUtil.post( - '/panel/api/clients/bulkAdjust', - payload, - JSON_HEADERS, - ) as Promise>, + mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise> => { + const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS); + return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust'); + }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, }); const attachMut = useMutation({ mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) => - HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS) as Promise, + HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, }); const detachMut = useMutation({ mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) => - HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS) as Promise, + HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, }); const resetTrafficMut = useMutation({ mutationFn: (email: string) => - HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`) as Promise, + HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`), onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, }); const resetAllTrafficsMut = useMutation({ - mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics') as Promise, + mutationFn: () => HttpUtil.post('/panel/api/clients/resetAllTraffics'), onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, }); const delDepletedMut = useMutation({ - mutationFn: () => HttpUtil.post('/panel/api/clients/delDepleted') as Promise>, + mutationFn: async () => { + const raw = await HttpUtil.post('/panel/api/clients/delDepleted'); + return parseMsg(raw, DelDepletedResultSchema, 'clients/delDepleted'); + }, onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, }); const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]); const update = useCallback((email: string, client: unknown) => { - if (!email) return Promise.resolve(null as unknown as ApiMsg); + if (!email) return Promise.resolve(null as unknown as Msg); return updateMut.mutateAsync({ email, client }); }, [updateMut]); const remove = useCallback((email: string, keepTraffic = false) => { - if (!email) return Promise.resolve(null as unknown as ApiMsg); + if (!email) return Promise.resolve(null as unknown as Msg); return removeMut.mutateAsync({ email, keepTraffic }); }, [removeMut]); const removeMany = useCallback((emails: string[], keepTraffic = false) => { - if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve([] as ApiMsg[]); + if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve([] as Msg[]); return removeManyMut.mutateAsync({ emails, keepTraffic }); }, [removeManyMut]); const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => { @@ -305,15 +269,15 @@ export function useClients() { return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes }); }, [bulkAdjustMut]); const attach = useCallback((email: string, inboundIds: number[]) => { - if (!email) return Promise.resolve(null as unknown as ApiMsg); + if (!email) return Promise.resolve(null as unknown as Msg); return attachMut.mutateAsync({ email, inboundIds }); }, [attachMut]); const detach = useCallback((email: string, inboundIds: number[]) => { - if (!email) return Promise.resolve(null as unknown as ApiMsg); + if (!email) return Promise.resolve(null as unknown as Msg); return detachMut.mutateAsync({ email, inboundIds }); }, [detachMut]); const resetTraffic = useCallback((client: ClientRecord) => { - if (!client?.email) return Promise.resolve(null as unknown as ApiMsg); + if (!client?.email) return Promise.resolve(null as unknown as Msg); return resetTrafficMut.mutateAsync(client.email); }, [resetTrafficMut]); const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]); diff --git a/frontend/src/hooks/useDatepicker.ts b/frontend/src/hooks/useDatepicker.ts index 381e29bf..60e78363 100644 --- a/frontend/src/hooks/useDatepicker.ts +++ b/frontend/src/hooks/useDatepicker.ts @@ -1,5 +1,7 @@ import { useEffect, useState } from 'react'; import { HttpUtil } from '@/utils'; +import { parseMsg } from '@/utils/zodValidate'; +import { DefaultsPayloadSchema } from '@/schemas/defaults'; type Calendar = 'gregorian' | 'jalalian'; @@ -20,12 +22,10 @@ async function loadOnce(): Promise { } pending = (async () => { try { - const msg = await HttpUtil.post('/panel/setting/defaultSettings') as { - success?: boolean; - obj?: { datepicker?: Calendar }; - }; + const msg = await HttpUtil.post('/panel/setting/defaultSettings'); if (msg?.success) { - cachedValue = msg.obj?.datepicker || 'gregorian'; + const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings'); + cachedValue = validated.obj?.datepicker || 'gregorian'; notify(cachedValue); } } finally { diff --git a/frontend/src/hooks/useXraySetting.ts b/frontend/src/hooks/useXraySetting.ts index 6ae8bd5e..787c0d34 100644 --- a/frontend/src/hooks/useXraySetting.ts +++ b/frontend/src/hooks/useXraySetting.ts @@ -1,30 +1,25 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { z } from 'zod'; -import { HttpUtil, PromiseUtil } from '@/utils'; +import { HttpUtil, Msg, PromiseUtil } from '@/utils'; +import { parseMsg } from '@/utils/zodValidate'; import { keys } from '@/api/queryKeys'; +import { + OutboundTrafficListSchema, + OutboundTestResultSchema, + XrayConfigPayloadSchema, + XraySettingsValueSchema, + type OutboundTestResult, + type OutboundTrafficRow, +} from '@/schemas/xray'; const DIRTY_POLL_MS = 1000; const DEFAULT_TEST_URL = 'https://www.google.com/generate_204'; -export interface OutboundTrafficRow { - tag: string; - up: number; - down: number; -} +export type { OutboundTrafficRow, OutboundTestResult }; -export interface OutboundTestResult { - success: boolean; - delay?: number; - error?: string; - mode?: string; - ttfbMs?: number; - tlsMs?: number; - connectMs?: number; - dnsMs?: number; - statusCode?: number; - endpoints?: { address: string; delay?: number; success: boolean; error?: string }[]; -} +export type XraySettingsValue = z.infer; export interface OutboundTestState { testing?: boolean; @@ -32,23 +27,6 @@ export interface OutboundTestState { mode?: string; } -export interface XraySettingsValue { - inbounds?: unknown[]; - outbounds?: { tag?: string; protocol?: string; settings?: unknown; streamSettings?: unknown }[]; - routing?: { - rules?: { type?: string; outboundTag?: string; balancerTag?: string; [key: string]: unknown }[]; - balancers?: unknown[]; - domainStrategy?: string; - }; - dns?: { tag?: string; servers?: unknown[] }; - log?: Record; - policy?: { system?: Record }; - observatory?: unknown; - burstObservatory?: unknown; - fakedns?: unknown; - [key: string]: unknown; -} - export type SetTemplate = ( next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null), ) => void; @@ -84,35 +62,32 @@ export interface UseXraySettingResult { restartXray: () => Promise; } -interface ApiMsg { - success?: boolean; - obj?: T; - msg?: string; -} - -interface XrayConfigPayload { - xraySetting: XraySettingsValue; - inboundTags?: string[]; - clientReverseTags?: string[]; - outboundTestUrl?: string; -} +type XrayConfigPayload = z.infer; async function fetchXrayConfig(): Promise { - const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true }) as ApiMsg; + const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config'); if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string'); + let parsed: unknown; try { - return JSON.parse(msg.obj) as XrayConfigPayload; + parsed = JSON.parse(msg.obj); } catch (e) { const err = e as Error; throw new Error(`Malformed xray config response: ${err.message}`, { cause: e }); } + const result = XrayConfigPayloadSchema.safeParse(parsed); + if (!result.success) { + console.warn('[zod] xray/ config payload failed validation', result.error.issues); + return parsed as XrayConfigPayload; + } + return result.data; } async function fetchOutboundsTraffic(): Promise { - const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true }) as ApiMsg; + const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic'); - return Array.isArray(msg.obj) ? msg.obj : []; + const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic'); + return Array.isArray(validated.obj) ? validated.obj : []; } export function useXraySetting(): UseXraySettingResult { @@ -219,7 +194,7 @@ export function useXraySetting(): UseXraySettingResult { HttpUtil.post('/panel/xray/update', { xraySetting: xraySettingRef.current, outboundTestUrl: outboundTestUrlRef.current || DEFAULT_TEST_URL, - }) as Promise, + }), onSuccess: (msg) => { if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.config() }); }, @@ -227,7 +202,7 @@ export function useXraySetting(): UseXraySettingResult { const resetTrafficMut = useMutation({ mutationFn: (tag: string) => - HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }) as Promise, + HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }), onSuccess: (msg) => { if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() }); }, @@ -235,17 +210,21 @@ export function useXraySetting(): UseXraySettingResult { const restartMut = useMutation({ mutationFn: async () => { - const msg = await HttpUtil.post('/panel/api/server/restartXrayService') as ApiMsg; + const msg = await HttpUtil.post('/panel/api/server/restartXrayService'); if (!msg?.success) return msg; await PromiseUtil.sleep(500); - const r = await HttpUtil.get('/panel/xray/getXrayResult') as ApiMsg; - if (r?.success) setRestartResult(r.obj || ''); + const r = await HttpUtil.get('/panel/xray/getXrayResult'); + const validated = parseMsg(r, z.string(), 'xray/getXrayResult'); + if (validated?.success) setRestartResult(validated.obj || ''); return msg; }, }); const resetDefaultMut = useMutation({ - mutationFn: async () => HttpUtil.get('/panel/setting/getDefaultJsonConfig') as Promise>, + mutationFn: async (): Promise> => { + const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig'); + return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig'); + }, onSuccess: (msg) => { if (msg?.success && msg.obj) { const cloned = JSON.parse(JSON.stringify(msg.obj)); @@ -269,15 +248,16 @@ export function useXraySetting(): UseXraySettingResult { [index]: { testing: true, result: null, mode }, })); try { - const msg = await HttpUtil.post('/panel/xray/testOutbound', { + const raw = await HttpUtil.post('/panel/xray/testOutbound', { outbound: JSON.stringify(outbound), allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []), mode, - }) as ApiMsg; + }); + const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound'); if (msg?.success && msg.obj) { setOutboundTestStates((prev) => ({ ...prev, - [index]: { testing: false, result: msg.obj as OutboundTestResult }, + [index]: { testing: false, result: msg.obj }, })); return msg.obj; } diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts index 37e18088..faf57801 100644 --- a/frontend/src/pages/inbounds/useInbounds.ts +++ b/frontend/src/pages/inbounds/useInbounds.ts @@ -2,10 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { HttpUtil } from '@/utils'; +import { parseMsg } from '@/utils/zodValidate'; import { DBInbound } from '@/models/dbinbound'; import { Protocols } from '@/models/inbound'; import { setDatepicker } from '@/hooks/useDatepicker'; import { keys } from '@/api/queryKeys'; +import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound'; +import { OnlinesSchema } from '@/schemas/client'; +import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults'; export interface SubSettings { enable: boolean; @@ -27,27 +31,6 @@ interface ClientRollup { comments: Map; } -interface ApiMsg { - success?: boolean; - obj?: T; - msg?: string; -} - -interface DefaultsPayload { - expireDiff?: number; - trafficDiff?: number; - tgBotEnable?: boolean; - subEnable?: boolean; - subTitle?: string; - subURI?: string; - subJsonURI?: string; - subJsonEnable?: boolean; - pageSize?: number; - remarkModel?: string; - datepicker?: string; - ipLimitEnable?: boolean; -} - const TRACKED_PROTOCOLS = [ Protocols.VMESS, Protocols.VLESS, @@ -57,27 +40,31 @@ const TRACKED_PROTOCOLS = [ ]; async function fetchSlimInbounds(): Promise { - const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true }) as ApiMsg; + const msg = await HttpUtil.get('/panel/api/inbounds/list/slim', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch inbounds'); - return Array.isArray(msg.obj) ? msg.obj : []; + const validated = parseMsg(msg, SlimInboundListSchema, 'inbounds/list/slim'); + return Array.isArray(validated.obj) ? validated.obj : []; } async function fetchOnlineClients(): Promise { - const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }) as ApiMsg; + const msg = await HttpUtil.post('/panel/api/clients/onlines', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlines'); - return Array.isArray(msg.obj) ? msg.obj : []; + const validated = parseMsg(msg, OnlinesSchema, 'clients/onlines'); + return Array.isArray(validated.obj) ? validated.obj : []; } async function fetchLastOnlineMap(): Promise> { - const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }) as ApiMsg>; + const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline'); - return (msg.obj && typeof msg.obj === 'object') ? msg.obj : {}; + const validated = parseMsg(msg, LastOnlineMapSchema, 'clients/lastOnline'); + return (validated.obj && typeof validated.obj === 'object') ? validated.obj : {}; } async function fetchDefaultSettings(): Promise { - const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }) as ApiMsg; + const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true }); if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults'); - return (msg.obj as DefaultsPayload) || {}; + const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings'); + return validated.obj ?? {}; } export function useInbounds() { @@ -272,8 +259,9 @@ export function useInbounds() { const hydrateInbound = useCallback(async (id: number) => { const msg = await HttpUtil.get(`/panel/api/inbounds/get/${id}`); if (!msg?.success || !msg.obj) return null; - const full = msg.obj as { id: number; protocol: string }; - const dbInbound = new DBInbound(full) as DBInboundInstance; + const validated = parseMsg(msg, InboundDetailSchema, `inbounds/get/${id}`); + if (!validated.obj) return null; + const dbInbound = new DBInbound(validated.obj) as DBInboundInstance; setDbInbounds((prev) => { const next = prev.map((row) => ( (row as unknown as { id: number }).id === id ? dbInbound : row diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx index 93dc3aba..0343cf53 100644 --- a/frontend/src/pages/nodes/NodeFormModal.tsx +++ b/frontend/src/pages/nodes/NodeFormModal.tsx @@ -14,27 +14,18 @@ import { message, } from 'antd'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; +import type { Msg } from '@/utils'; +import type { ProbeResult } from '@/schemas/node'; import './NodeFormModal.css'; type Mode = 'add' | 'edit'; -interface ApiMsg { - success?: boolean; - msg?: string; - obj?: T; -} - interface NodeFormModalProps { open: boolean; mode: Mode; node: NodeRecord | null; - testConnection: (payload: Partial) => Promise>; - save: (payload: Partial) => Promise; + testConnection: (payload: Partial) => Promise>; + save: (payload: Partial) => Promise>; onOpenChange: (open: boolean) => void; } diff --git a/frontend/src/pages/xray/OutboundsTab.tsx b/frontend/src/pages/xray/OutboundsTab.tsx index a0e1e1ed..3ed1585f 100644 --- a/frontend/src/pages/xray/OutboundsTab.tsx +++ b/frontend/src/pages/xray/OutboundsTab.tsx @@ -130,7 +130,7 @@ export default function OutboundsTab({ const [existingTags, setExistingTags] = useState([]); const outbounds = useMemo( - () => (templateSettings?.outbounds || []) as OutboundRow[], + () => (templateSettings?.outbounds || []) as unknown as OutboundRow[], [templateSettings?.outbounds], ); diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts new file mode 100644 index 00000000..881e609c --- /dev/null +++ b/frontend/src/schemas/client.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +export const ClientTrafficSchema = z.object({ + up: z.number().optional(), + down: z.number().optional(), + total: z.number().optional(), + expiryTime: z.number().optional(), + enable: z.boolean().optional(), + lastOnline: z.number().optional(), +}); + +export const ClientRecordSchema = z.object({ + email: z.string(), + subId: z.string().optional(), + uuid: z.string().optional(), + password: z.string().optional(), + auth: z.string().optional(), + flow: z.string().optional(), + totalGB: z.number().optional(), + expiryTime: z.number().optional(), + limitIp: z.number().optional(), + tgId: z.union([z.number(), z.string()]).optional(), + comment: z.string().optional(), + enable: z.boolean().optional(), + inboundIds: z.array(z.number()).optional(), + traffic: ClientTrafficSchema.optional(), + reverse: z.object({ tag: z.string().optional() }).loose().optional(), + createdAt: z.number().optional(), + updatedAt: z.number().optional(), +}).loose(); + +export const InboundOptionSchema = z.object({ + id: z.number(), + remark: z.string().optional(), + protocol: z.string().optional(), + port: z.number().optional(), + tlsFlowCapable: z.boolean().optional(), +}).loose(); + +export const InboundOptionsSchema = z.array(InboundOptionSchema); + +export const ClientsSummarySchema = z.object({ + total: z.number(), + active: z.number(), + online: z.array(z.string()), + depleted: z.array(z.string()), + expiring: z.array(z.string()), + deactive: z.array(z.string()), +}); + +export const ClientPageResponseSchema = z.object({ + items: z.array(ClientRecordSchema), + total: z.number(), + filtered: z.number(), + page: z.number(), + pageSize: z.number(), + summary: ClientsSummarySchema.optional(), +}); + +export const ClientHydrateSchema = z.object({ + client: ClientRecordSchema, + inboundIds: z.array(z.number()), +}); + +export const BulkAdjustResultSchema = z.object({ + adjusted: z.number(), + skipped: z + .array(z.object({ email: z.string(), reason: z.string() })) + .optional(), +}); + +export const DelDepletedResultSchema = z.object({ + deleted: z.number().optional(), +}); + +export const OnlinesSchema = z.array(z.string()); + +export type ClientRecord = z.infer; +export type ClientTraffic = z.infer; +export type InboundOption = z.infer; +export type ClientsSummary = z.infer; +export type ClientPageResponse = z.infer; +export type ClientHydrate = z.infer; +export type BulkAdjustResult = z.infer; diff --git a/frontend/src/schemas/defaults.ts b/frontend/src/schemas/defaults.ts new file mode 100644 index 00000000..6f2fb809 --- /dev/null +++ b/frontend/src/schemas/defaults.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const DefaultsPayloadSchema = z.object({ + expireDiff: z.number().optional(), + trafficDiff: z.number().optional(), + tgBotEnable: z.boolean().optional(), + subEnable: z.boolean().optional(), + subTitle: z.string().optional(), + subURI: z.string().optional(), + subJsonURI: z.string().optional(), + subJsonEnable: z.boolean().optional(), + pageSize: z.number().optional(), + remarkModel: z.string().optional(), + datepicker: z.enum(['gregorian', 'jalalian']).optional(), + ipLimitEnable: z.boolean().optional(), +}).loose(); + +export type DefaultsPayload = z.infer; diff --git a/frontend/src/schemas/inbound.ts b/frontend/src/schemas/inbound.ts new file mode 100644 index 00000000..d0325029 --- /dev/null +++ b/frontend/src/schemas/inbound.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +export const SlimInboundSchema = z.object({ + id: z.number(), + protocol: z.string(), +}).loose(); + +export const SlimInboundListSchema = z.array(SlimInboundSchema); + +export const InboundDetailSchema = z.object({ + id: z.number(), + protocol: z.string(), +}).loose(); + +export const LastOnlineMapSchema = z.record(z.string(), z.number()); + +export type SlimInbound = z.infer; +export type InboundDetail = z.infer; +export type LastOnlineMap = z.infer; diff --git a/frontend/src/schemas/node.ts b/frontend/src/schemas/node.ts index 805e25f8..1bd01734 100644 --- a/frontend/src/schemas/node.ts +++ b/frontend/src/schemas/node.ts @@ -28,4 +28,12 @@ export const NodeRecordSchema = z.object({ export const NodeListSchema = z.array(NodeRecordSchema); +export const ProbeResultSchema = z.object({ + status: z.string(), + latencyMs: z.number().optional(), + xrayVersion: z.string().optional(), + error: z.string().optional(), +}).loose(); + export type NodeRecord = z.infer; +export type ProbeResult = z.infer; diff --git a/frontend/src/schemas/xray.ts b/frontend/src/schemas/xray.ts new file mode 100644 index 00000000..97e41da7 --- /dev/null +++ b/frontend/src/schemas/xray.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; + +export const XraySettingsValueSchema = z.object({ + inbounds: z.array(z.unknown()).optional(), + outbounds: z + .array( + z.object({ + tag: z.string().optional(), + protocol: z.string().optional(), + settings: z.unknown().optional(), + streamSettings: z.unknown().optional(), + }).loose(), + ) + .optional(), + routing: z.object({ + rules: z.array(z.object({ + type: z.string().optional(), + outboundTag: z.string().optional(), + balancerTag: z.string().optional(), + }).loose()).optional(), + balancers: z.array(z.unknown()).optional(), + domainStrategy: z.string().optional(), + }).loose().optional(), + dns: z.object({ + tag: z.string().optional(), + servers: z.array(z.unknown()).optional(), + }).loose().optional(), + log: z.record(z.string(), z.unknown()).optional(), + policy: z.object({ + system: z.record(z.string(), z.boolean()).optional(), + }).loose().optional(), + observatory: z.unknown().optional(), + burstObservatory: z.unknown().optional(), + fakedns: z.unknown().optional(), +}).loose(); + +export const XrayConfigPayloadSchema = z.object({ + xraySetting: XraySettingsValueSchema, + inboundTags: z.array(z.string()).optional(), + clientReverseTags: z.array(z.string()).optional(), + outboundTestUrl: z.string().optional(), +}).loose(); + +export const OutboundTrafficRowSchema = z.object({ + tag: z.string(), + up: z.number(), + down: z.number(), +}); + +export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema); + +export const OutboundTestResultSchema = z.object({ + success: z.boolean(), + delay: z.number().optional(), + error: z.string().optional(), + mode: z.string().optional(), + ttfbMs: z.number().optional(), + tlsMs: z.number().optional(), + connectMs: z.number().optional(), + dnsMs: z.number().optional(), + statusCode: z.number().optional(), + endpoints: z + .array( + z.object({ + address: z.string(), + delay: z.number().optional(), + success: z.boolean(), + error: z.string().optional(), + }).loose(), + ) + .optional(), +}).loose(); + +export type XraySettingsValue = z.infer; +export type XrayConfigPayload = z.infer; +export type OutboundTrafficRow = z.infer; +export type OutboundTestResult = z.infer;