From bc309ed9f87c22f27b4662d1d0e95a6dd2347b07 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 8 Jul 2026 01:09:18 +0200 Subject: [PATCH] refactor(frontend): replace axios with the native Fetch API Drop the axios (and qs) dependencies in favor of a native fetch wrapper. axios only ever handled same-origin JSON/form calls, a CSRF header, a 401 redirect, and a 403-retry, all of which the platform now provides directly. - New src/api/http-init.ts (replaces axios-init.ts) reimplements the request/response interceptors on fetch: base-path prefixing, X-Requested-With, same-origin credentials, the CSRF token on unsafe methods, a single 403 retry with token refresh, and the 401 redirect-and-latch. A small encodeForm() reproduces qs's arrayFormat:'repeat' encoding, so the request wire format is unchanged. - HttpUtil (src/utils/index.ts) keeps its public signatures and the Msg envelope, so the ~49 API call sites are untouched. HttpOptions is now hand-rolled instead of extending AxiosRequestConfig. - PanelUpdateModal drops its lone direct axios.get in favor of HttpUtil.get with { silent, timeout }. - Add tests for the fetch core (CSRF header, form/JSON/FormData bodies, base-path prefix, 403 retry, 401 redirect, tolerant body parse) and for HttpUtil's envelope unwrap / toast / error mapping; this logic was previously untested. - Remove the vendor-axios manualChunks branch and the qs type shim, and reword stale "axios" mentions in docs and route comments. --- CONTRIBUTING.md | 4 +- docs/architecture.md | 6 +- frontend/README.md | 2 +- frontend/package-lock.json | 23 +- frontend/package.json | 2 - frontend/public/openapi.json | 2 +- frontend/src/api/axios-init.ts | 123 ----------- frontend/src/api/http-init.ts | 184 ++++++++++++++++ frontend/src/entries/login.tsx | 4 +- frontend/src/env.d.ts | 22 -- frontend/src/main.tsx | 4 +- frontend/src/pages/api-docs/endpoints.ts | 2 +- frontend/src/pages/index/PanelUpdateModal.tsx | 9 +- frontend/src/test/http-init.test.tsx | 196 ++++++++++++++++++ frontend/src/test/httpUtil.test.ts | 101 +++++++++ frontend/src/test/setup.components.ts | 9 - frontend/src/utils/index.ts | 24 ++- frontend/vite.config.js | 1 - internal/web/controller/spa.go | 2 +- internal/web/controller/xray_setting.go | 2 +- internal/xray/process.go | 2 +- 21 files changed, 521 insertions(+), 203 deletions(-) delete mode 100644 frontend/src/api/axios-init.ts create mode 100644 frontend/src/api/http-init.ts create mode 100644 frontend/src/test/http-init.test.tsx create mode 100644 frontend/src/test/httpUtil.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d240e745..8087498f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,7 +151,7 @@ Panel navigation happens client-side through React Router, and per-route code is - **Local UI state stays in the page** (`useState`); shared concerns go through contexts and hooks in `src/hooks/` (`useTheme`, `useWebSocket`, `useClients`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global. - **Zod is the single source of truth.** Schemas in `src/schemas/` define the xray config model; every API response is parsed through them, every form field validates against them, and TypeScript types are inferred with `z.infer` — never hand-written. Go-side types are mirrored into `src/generated/` by `npm run gen:zod` (do not hand-edit that folder). - **xray domain logic** — link generation, protocol defaults, form ⇄ wire adapters — lives as pure functions in `src/lib/xray/`. `src/models/` keeps only thin legacy types still being migrated onto schemas. -- **HTTP** goes through `HttpUtil` in `src/utils/index.ts`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.ts`. +- **HTTP** goes through `HttpUtil` in `src/utils/index.ts`, a thin `fetch` wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The `fetch` setup itself (base path, CSRF, 401/403 handling) lives in `src/api/http-init.ts`. ### i18n @@ -210,7 +210,7 @@ frontend/ ├── pages/ — one folder per route (index, inbounds, clients, groups, nodes, settings, xray, api-docs) plus login, sub ├── components/ — cross-page React components ├── hooks/ — reusable hooks (useTheme, useWebSocket, useClients, useDatepicker, …) - ├── api/ — Axios + CSRF interceptor, TanStack Query provider/keys, WebSocket client + ├── api/ — fetch client + CSRF handling, TanStack Query provider/keys, WebSocket client ├── i18n/ — react-i18next bootstrap (JSON lives in internal/web/translation/) ├── lib/xray/ — pure xray logic: link generation, defaults, form ⇄ wire adapters ├── schemas/ — Zod source of truth for the xray config model diff --git a/docs/architecture.md b/docs/architecture.md index fec0c880a..481a4dc6f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,7 +62,7 @@ Two key ideas that explain most of the complexity: **Frontend (`frontend/`):** - **React 19** + **Ant Design 6** + **Vite 8** + **TypeScript**. -- Data layer: **TanStack Query** (`@tanstack/react-query`) over **axios**; **Zod 4** schemas. +- Data layer: **TanStack Query** (`@tanstack/react-query`) over the native **Fetch API**; **Zod 4** schemas. - Router: **react-router-dom 7**. Charts: **recharts**. Editor: **CodeMirror 6**. - **Build output goes to `internal/web/dist/`** (see `vite.config.js` → `outDir`) and is embedded into the Go binary with `go:embed`. Three HTML entries: `index.html` (panel SPA), @@ -79,7 +79,7 @@ from the embedded Vite `dist/`. Don't look for `.html` templates in `internal/we ### 3.1 Admin API request (e.g. "add a client") ``` -Browser (React, axios) +Browser (React, fetch) → POST {basePath}/panel/api/... → Gin engine (internal/web/web.go: initRouter) → middleware chain: SecurityHeaders → MaxBodyBytes (10 MiB; importDB exempt) @@ -261,7 +261,7 @@ node heartbeat every 5s, periodic traffic resets (hourly/daily/weekly/monthly). │ │ ├── xray/ # raw Xray config UI (routing, dns, outbounds, balancers, overrides) │ │ ├── index/ # dashboard/home │ │ └── settings/, groups/, sub/, login/, api-docs/ -│ ├── api/ # ⭐ Data layer: axios-init, QueryProvider, queryKeys, websocket bridge +│ ├── api/ # ⭐ Data layer: http-init, QueryProvider, queryKeys, websocket bridge │ │ └── queries/ # TanStack Query hooks (useNodesQuery, useStatusQuery, …) │ ├── schemas/ # Zod schemas: protocols, forms, api, primitives │ ├── generated/ # ⚠️ GENERATED from Go (see §5.5): schemas.ts, types.ts, zod.ts, examples.ts diff --git a/frontend/README.md b/frontend/README.md index 8a5c5b343..bb699fb7c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -91,7 +91,7 @@ frontend/ ├── layouts/ # AdminLayout (sidebar + header + outlet) ├── components/ # Cross-page React components ├── hooks/ # useClients, useTheme, useWebSocket, … - ├── api/ # Axios + CSRF interceptor, TanStack Query bridge, + ├── api/ # fetch client + CSRF handling, TanStack Query bridge, │ # WebSocket client + queryClient.ts ├── i18n/ # react-i18next init (locales in internal/web/translation/) ├── lib/xray/ # Pure functions: link generation, defaults, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8fda87ef8..bdc992f3c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,13 +15,11 @@ "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2", "antd": "^6.5.0", - "axios": "^1.18.1", "codemirror": "^6.0.2", "dayjs": "^1.11.21", "i18next": "^26.3.4", "otpauth": "^9.5.1", "persian-calendar-suite": "^1.5.5", - "qs": "^6.15.3", "react": "^19.2.7", "react-dom": "^19.2.7", "react-i18next": "^17.0.8", @@ -7060,6 +7058,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7474,22 +7473,6 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -8190,6 +8173,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8209,6 +8193,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8225,6 +8210,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8243,6 +8229,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", diff --git a/frontend/package.json b/frontend/package.json index 146b3eaa1..21d6596fc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,13 +28,11 @@ "@tanstack/react-query": "^5.101.2", "@tanstack/react-query-devtools": "^5.101.2", "antd": "^6.5.0", - "axios": "^1.18.1", "codemirror": "^6.0.2", "dayjs": "^1.11.21", "i18next": "^26.3.4", "otpauth": "^9.5.1", "persian-calendar-suite": "^1.5.5", - "qs": "^6.15.3", "react": "^19.2.7", "react-dom": "^19.2.7", "react-i18next": "^17.0.8", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index feaf1e2e9..9ca62ec9e 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -10124,7 +10124,7 @@ "tags": [ "Xray Settings" ], - "summary": "Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).", + "summary": "Delete an outbound subscription by id (POST alias of DELETE for clients that cannot send DELETE).", "operationId": "post_panel_api_xray_outbound_subs_id_del", "parameters": [ { diff --git a/frontend/src/api/axios-init.ts b/frontend/src/api/axios-init.ts deleted file mode 100644 index c8769b058..000000000 --- a/frontend/src/api/axios-init.ts +++ /dev/null @@ -1,123 +0,0 @@ -import axios from 'axios'; -import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; -import qs from 'qs'; - -const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); -const CSRF_TOKEN_PATH = '/csrf-token'; - -let csrfToken: string | null = null; -let csrfFetchPromise: Promise | null = null; -let sessionExpired = false; - -type CsrfAwareConfig = InternalAxiosRequestConfig & { __csrfRetried?: boolean }; - -function readMetaToken(): string | null { - return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null; -} - -async function fetchCsrfToken(): Promise { - try { - const basePath = window.X_UI_BASE_PATH; - const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/' - ? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH - : CSRF_TOKEN_PATH); - const res = await fetch(url, { - method: 'GET', - credentials: 'same-origin', - headers: { 'X-Requested-With': 'XMLHttpRequest' }, - }); - if (!res.ok) return null; - const json = (await res.json()) as { success?: boolean; obj?: unknown } | null; - return json?.success && typeof json.obj === 'string' ? json.obj : null; - } catch { - return null; - } -} - -async function ensureCsrfToken(): Promise { - if (csrfToken) return csrfToken; - const meta = readMetaToken(); - if (meta) { - csrfToken = meta; - return csrfToken; - } - if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken(); - const fetched = await csrfFetchPromise; - csrfFetchPromise = null; - if (fetched) csrfToken = fetched; - return csrfToken; -} - -export function setupAxios(): void { - axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; - axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; - - let basePath: string | null | undefined = window.X_UI_BASE_PATH; - if (!basePath) { - const metaTag = document.querySelector('meta[name="base-path"]'); - basePath = metaTag ? metaTag.getAttribute('content') : null; - } - if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') { - axios.defaults.baseURL = basePath; - } - - csrfToken = readMetaToken(); - - axios.interceptors.request.use( - async (config: InternalAxiosRequestConfig) => { - const method = (config.method || 'get').toUpperCase(); - if (!SAFE_METHODS.has(method)) { - const token = await ensureCsrfToken(); - if (token) config.headers.set('X-CSRF-Token', token); - } - if (config.data instanceof FormData) { - config.headers.set('Content-Type', 'multipart/form-data'); - } else { - const declaredType = String(config.headers.get('Content-Type') || config.headers.get('content-type') || ''); - if (declaredType.toLowerCase().startsWith('application/json')) { - if (config.data !== undefined && typeof config.data !== 'string') { - config.data = JSON.stringify(config.data); - } - } else { - config.data = qs.stringify(config.data, { arrayFormat: 'repeat' }); - } - } - return config; - }, - (error: unknown) => Promise.reject(error), - ); - - axios.interceptors.response.use( - (response: AxiosResponse) => response, - async (error: AxiosError) => { - const status = error.response?.status; - if (status === 401) { - if (!sessionExpired) { - sessionExpired = true; - const basePath = window.X_UI_BASE_PATH || '/'; - window.location.replace(basePath); - } - return new Promise(() => {}); - } - const cfg = error.config as CsrfAwareConfig | undefined; - if (status === 403 && cfg && !cfg.__csrfRetried) { - csrfToken = null; - cfg.__csrfRetried = true; - const token = await ensureCsrfToken(); - if (token) { - cfg.headers.set('X-CSRF-Token', token); - const declaredType = String(cfg.headers.get('Content-Type') || cfg.headers.get('content-type') || ''); - if (typeof cfg.data === 'string') { - if (declaredType.toLowerCase().startsWith('application/json')) { - try { cfg.data = JSON.parse(cfg.data); } catch {} - } else { - cfg.data = qs.parse(cfg.data); - } - } - return axios(cfg); - } - } - return Promise.reject(error); - }, - ); -} diff --git a/frontend/src/api/http-init.ts b/frontend/src/api/http-init.ts new file mode 100644 index 000000000..2ead50f9f --- /dev/null +++ b/frontend/src/api/http-init.ts @@ -0,0 +1,184 @@ +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); +const CSRF_TOKEN_PATH = '/csrf-token'; + +let csrfToken: string | null = null; +let csrfFetchPromise: Promise | null = null; +let sessionExpired = false; +let basePathPrefix = ''; + +export interface HttpResponse { + ok: boolean; + status: number; + statusText: string; + data: unknown; +} + +export class HttpError extends Error { + status: number; + response: { status: number; statusText: string; data: unknown }; + + constructor(status: number, statusText: string, data: unknown) { + super(`Request failed with status ${status}`); + this.name = 'HttpError'; + this.status = status; + this.response = { status, statusText, data }; + } +} + +export interface HttpRequestOptions { + headers?: Record | Headers; + params?: unknown; + timeout?: number; + signal?: AbortSignal; +} + +function readMetaToken(): string | null { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null; +} + +async function fetchCsrfToken(): Promise { + try { + const res = await fetch(basePathPrefix + CSRF_TOKEN_PATH, { + method: 'GET', + credentials: 'same-origin', + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }); + if (!res.ok) return null; + const json = (await res.json()) as { success?: boolean; obj?: unknown } | null; + return json?.success && typeof json.obj === 'string' ? json.obj : null; + } catch { + return null; + } +} + +async function ensureCsrfToken(): Promise { + if (csrfToken) return csrfToken; + const meta = readMetaToken(); + if (meta) { + csrfToken = meta; + return csrfToken; + } + if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken(); + const fetched = await csrfFetchPromise; + csrfFetchPromise = null; + if (fetched) csrfToken = fetched; + return csrfToken; +} + +function encodeForm(data: unknown): string { + if (data == null || typeof data !== 'object') return ''; + const parts: string[] = []; + const append = (key: string, value: unknown): void => { + if (value === undefined) return; + if (value === null) { + parts.push(`${encodeURIComponent(key)}=`); + return; + } + if (Array.isArray(value)) { + value.forEach((item) => append(key, item)); + return; + } + if (typeof value === 'object') { + Object.entries(value as Record).forEach(([k, v]) => append(`${key}[${k}]`, v)); + return; + } + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + Object.entries(data as Record).forEach(([k, v]) => append(k, v)); + return parts.join('&'); +} + +async function performFetch( + method: string, + url: string, + data: unknown, + options: HttpRequestOptions, + csrfOverride?: string, +): Promise { + const upper = method.toUpperCase(); + const headers = new Headers(options.headers); + headers.set('X-Requested-With', 'XMLHttpRequest'); + + let body: BodyInit | undefined; + if (data instanceof FormData) { + body = data; + headers.delete('Content-Type'); + } else if (!SAFE_METHODS.has(upper)) { + const declaredType = (headers.get('Content-Type') || '').toLowerCase(); + if (declaredType.startsWith('application/json')) { + if (data !== undefined) { + body = typeof data === 'string' ? data : JSON.stringify(data); + } + } else { + headers.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); + body = encodeForm(data); + } + } + + if (!SAFE_METHODS.has(upper)) { + const token = csrfOverride ?? (await ensureCsrfToken()); + if (token) headers.set('X-CSRF-Token', token); + } + + const query = encodeForm(options.params); + const fullUrl = basePathPrefix + url + (query ? `?${query}` : ''); + const signal = options.timeout ? AbortSignal.timeout(options.timeout) : options.signal; + + return fetch(fullUrl, { method: upper, headers, body, credentials: 'same-origin', signal }); +} + +async function parseBody(res: Response): Promise { + if (res.status === 204 || res.status === 205) return ''; + const text = await res.text(); + if (text === '') return ''; + const contentType = (res.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('application/json') || text[0] === '{' || text[0] === '[') { + try { + return JSON.parse(text); + } catch { + return text; + } + } + return text; +} + +export async function httpRequest( + method: string, + url: string, + data?: unknown, + options: HttpRequestOptions = {}, +): Promise { + let res = await performFetch(method, url, data, options); + + if (res.status === 403 && !SAFE_METHODS.has(method.toUpperCase())) { + csrfToken = null; + const fresh = await ensureCsrfToken(); + if (fresh) res = await performFetch(method, url, data, options, fresh); + } + + if (res.status === 401) { + if (!sessionExpired) { + sessionExpired = true; + window.location.replace(window.X_UI_BASE_PATH || basePathPrefix || '/'); + } + return new Promise(() => {}); + } + + const parsed = await parseBody(res); + if (!res.ok) throw new HttpError(res.status, res.statusText, parsed); + return { ok: true, status: res.status, statusText: res.statusText, data: parsed }; +} + +export function setupHttp(): void { + let basePath: string | null | undefined = window.X_UI_BASE_PATH; + if (!basePath) { + const metaTag = document.querySelector('meta[name="base-path"]'); + basePath = metaTag ? metaTag.getAttribute('content') : null; + } + basePathPrefix = + typeof basePath === 'string' && basePath !== '' && basePath !== '/' + ? basePath.replace(/\/$/, '') + : ''; + + csrfToken = readMetaToken(); +} diff --git a/frontend/src/entries/login.tsx b/frontend/src/entries/login.tsx index 24b28cd23..7107bf210 100644 --- a/frontend/src/entries/login.tsx +++ b/frontend/src/entries/login.tsx @@ -2,14 +2,14 @@ import { createRoot } from 'react-dom/client'; import { message } from 'antd'; import 'antd/dist/reset.css'; -import { setupAxios } from '@/api/axios-init'; +import { setupHttp } from '@/api/http-init'; import { applyDocumentTitle } from '@/utils'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; import { QueryProvider } from '@/api/QueryProvider'; import LoginPage from '@/pages/login/LoginPage'; -setupAxios(); +setupHttp(); applyDocumentTitle(); const messageContainer = document.getElementById('message'); diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index 732af9719..2dbc5aa13 100644 --- a/frontend/src/env.d.ts +++ b/frontend/src/env.d.ts @@ -31,28 +31,6 @@ interface Window { __SUB_PAGE_DATA__?: SubPageData; } -declare module 'qs' { - interface StringifyOptions { - arrayFormat?: 'indices' | 'brackets' | 'repeat' | 'comma'; - encode?: boolean; - encoder?: (str: unknown, defaultEncoder: (s: unknown) => string, charset: string, type: 'key' | 'value') => string; - allowDots?: boolean; - skipNulls?: boolean; - addQueryPrefix?: boolean; - } - interface ParseOptions { - depth?: number; - arrayLimit?: number; - allowDots?: boolean; - parseArrays?: boolean; - ignoreQueryPrefix?: boolean; - } - export function stringify(obj: unknown, options?: StringifyOptions): string; - export function parse(str: string, options?: ParseOptions): Record; - const qs: { stringify: typeof stringify; parse: typeof parse }; - export default qs; -} - declare module 'persian-calendar-suite' { import type { ComponentType, ReactNode } from 'react'; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 91131eed6..9c3f15d0a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,13 +6,13 @@ import '@/styles/utils.css'; import '@/styles/page-shell.css'; import '@/styles/page-cards.css'; -import { setupAxios } from '@/api/axios-init'; +import { setupHttp } from '@/api/http-init'; import { readyI18n } from '@/i18n/react'; import { ThemeProvider } from '@/hooks/useTheme'; import { QueryProvider } from '@/api/QueryProvider'; import { router } from '@/routes'; -setupAxios(); +setupHttp(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 4cb22b2f3..dc9775e1c 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1392,7 +1392,7 @@ export const sections: readonly Section[] = [ { method: 'POST', path: '/panel/api/xray/outbound-subs/:id/del', - summary: 'Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).', + summary: 'Delete an outbound subscription by id (POST alias of DELETE for clients that cannot send DELETE).', params: [ { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' }, ], diff --git a/frontend/src/pages/index/PanelUpdateModal.tsx b/frontend/src/pages/index/PanelUpdateModal.tsx index 83dac3b92..8bb7fb560 100644 --- a/frontend/src/pages/index/PanelUpdateModal.tsx +++ b/frontend/src/pages/index/PanelUpdateModal.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Alert, Button, Modal, Switch, Tag } from 'antd'; import { CloudDownloadOutlined } from '@ant-design/icons'; -import axios from 'axios'; import { HttpUtil, PromiseUtil } from '@/utils'; import { formatPanelVersion } from '@/lib/panel-version'; @@ -53,8 +52,12 @@ export default function PanelUpdateModal({ const deadline = Date.now() + 90_000; while (Date.now() < deadline) { try { - const r = await axios.get('/panel/api/server/getUpdateStatus', { timeout: 2000 }); - const status = r?.data?.obj as PanelUpdateStatus | undefined; + const msg = await HttpUtil.get( + '/panel/api/server/getUpdateStatus', + undefined, + { silent: true, timeout: 2000 }, + ); + const status = msg?.obj ?? undefined; if (status?.runId === expectedRunId) { if (status.state === 'success') return 'success'; if (status.state === 'failed') return 'failed'; diff --git a/frontend/src/test/http-init.test.tsx b/frontend/src/test/http-init.test.tsx new file mode 100644 index 000000000..c685817d2 --- /dev/null +++ b/frontend/src/test/http-init.test.tsx @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type HttpModule = typeof import('@/api/http-init'); + +const okEnvelope = (obj: unknown = {}): Response => + new Response(JSON.stringify({ success: true, msg: '', obj }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +const csrfResponse = (token: string): Response => + new Response(JSON.stringify({ success: true, obj: token }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +describe('http-init fetch wrapper', () => { + let http: HttpModule; + let fetchMock: ReturnType; + let replaceMock: ReturnType; + + const initOf = (call = 0): RequestInit => fetchMock.mock.calls[call][1] as RequestInit; + const urlOf = (call = 0): string => fetchMock.mock.calls[call][0] as string; + const headersOf = (call = 0): Headers => initOf(call).headers as Headers; + + beforeEach(async () => { + vi.resetModules(); + document.head.innerHTML = ''; + delete (window as { X_UI_BASE_PATH?: string }).X_UI_BASE_PATH; + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + replaceMock = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { replace: replaceMock, href: 'http://localhost/', origin: 'http://localhost', pathname: '/' }, + }); + http = await import('@/api/http-init'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('form-encodes bodies and repeats array keys', async () => { + document.head.innerHTML = ''; + http.setupHttp(); + fetchMock.mockResolvedValue(okEnvelope()); + + await http.httpRequest('POST', '/panel/x', { a: 1, b: ['x', 'y'] }); + + expect(initOf().body).toBe('a=1&b=x&b=y'); + expect(headersOf().get('content-type')).toBe('application/x-www-form-urlencoded; charset=UTF-8'); + }); + + it('JSON-encodes bodies when the caller declares application/json', async () => { + document.head.innerHTML = ''; + http.setupHttp(); + fetchMock.mockResolvedValue(okEnvelope()); + + await http.httpRequest('POST', '/panel/x', { a: 1 }, { headers: { 'Content-Type': 'application/json' } }); + + expect(initOf().body).toBe(JSON.stringify({ a: 1 })); + expect(headersOf().get('content-type')).toBe('application/json'); + }); + + it('passes FormData through without a Content-Type header', async () => { + document.head.innerHTML = ''; + http.setupHttp(); + fetchMock.mockResolvedValue(okEnvelope()); + + const fd = new FormData(); + fd.append('db', 'contents'); + await http.httpRequest('POST', '/panel/import', fd, { headers: { 'Content-Type': 'multipart/form-data' } }); + + expect(initOf().body).toBe(fd); + expect(headersOf().has('content-type')).toBe(false); + }); + + it('attaches the CSRF token on POST and omits it on GET', async () => { + document.head.innerHTML = ''; + http.setupHttp(); + fetchMock.mockImplementation(() => Promise.resolve(okEnvelope())); + + await http.httpRequest('POST', '/p', { a: 1 }); + expect(headersOf().get('X-CSRF-Token')).toBe('tok'); + expect(headersOf().get('X-Requested-With')).toBe('XMLHttpRequest'); + + fetchMock.mockClear(); + await http.httpRequest('GET', '/g'); + expect(headersOf().get('X-CSRF-Token')).toBeNull(); + expect(headersOf().get('X-Requested-With')).toBe('XMLHttpRequest'); + }); + + it('prepends the base path to request and csrf-token URLs', async () => { + window.X_UI_BASE_PATH = '/xui'; + http.setupHttp(); + fetchMock.mockImplementation((url: string) => + Promise.resolve(url.endsWith('/csrf-token') ? csrfResponse('fresh') : okEnvelope()), + ); + + await http.httpRequest('POST', '/panel/api/x', { a: 1 }); + + expect(urlOf(0)).toBe('/xui/csrf-token'); + expect(urlOf(1)).toBe('/xui/panel/api/x'); + }); + + it('refreshes the token and retries once on 403', async () => { + http.setupHttp(); + let dataCalls = 0; + fetchMock.mockImplementation((url: string) => { + if (url.endsWith('/csrf-token')) return Promise.resolve(csrfResponse(`tok${dataCalls}`)); + dataCalls += 1; + return Promise.resolve( + dataCalls === 1 + ? new Response('', { status: 403 }) + : okEnvelope(), + ); + }); + + const resp = await http.httpRequest('POST', '/panel/api/x', { a: 1 }); + + expect(resp.ok).toBe(true); + expect(dataCalls).toBe(2); + expect(headersOf(3).get('X-CSRF-Token')).toBe('tok1'); + }); + + it('throws HttpError when the retried request is still 403', async () => { + http.setupHttp(); + let dataCalls = 0; + fetchMock.mockImplementation((url: string) => { + if (url.endsWith('/csrf-token')) return Promise.resolve(csrfResponse('tok')); + dataCalls += 1; + return Promise.resolve(new Response('', { status: 403 })); + }); + + await expect(http.httpRequest('POST', '/panel/api/x', { a: 1 })).rejects.toBeInstanceOf(http.HttpError); + expect(dataCalls).toBe(2); + }); + + it('redirects once on 401 and never settles', async () => { + window.X_UI_BASE_PATH = '/xui'; + document.head.innerHTML = ''; + http.setupHttp(); + fetchMock.mockResolvedValue(new Response('', { status: 401 })); + + const pending = Symbol('pending'); + const first = await Promise.race([ + http.httpRequest('POST', '/p', { a: 1 }), + new Promise((resolve) => setTimeout(() => resolve(pending), 20)), + ]); + expect(first).toBe(pending); + expect(replaceMock).toHaveBeenCalledTimes(1); + expect(replaceMock).toHaveBeenCalledWith('/xui'); + + const second = await Promise.race([ + http.httpRequest('POST', '/p', { a: 1 }), + new Promise((resolve) => setTimeout(() => resolve(pending), 20)), + ]); + expect(second).toBe(pending); + expect(replaceMock).toHaveBeenCalledTimes(1); + }); + + it('parses empty, 204, non-JSON, and malformed bodies tolerantly', async () => { + http.setupHttp(); + + fetchMock.mockResolvedValueOnce(new Response('', { status: 200 })); + expect((await http.httpRequest('GET', '/a')).data).toBe(''); + + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + expect((await http.httpRequest('GET', '/b')).data).toBe(''); + + fetchMock.mockResolvedValueOnce(new Response('hello', { status: 200, headers: { 'content-type': 'text/plain' } })); + expect((await http.httpRequest('GET', '/c')).data).toBe('hello'); + + fetchMock.mockResolvedValueOnce( + new Response('{bad', { status: 200, headers: { 'content-type': 'application/json' } }), + ); + expect((await http.httpRequest('GET', '/d')).data).toBe('{bad'); + }); + + it('rejects when fetch fails at the network level', async () => { + http.setupHttp(); + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + + await expect(http.httpRequest('GET', '/x')).rejects.toThrow('Failed to fetch'); + }); + + it('passes an AbortSignal when a timeout is set', async () => { + http.setupHttp(); + fetchMock.mockResolvedValue(okEnvelope()); + + await http.httpRequest('GET', '/x', undefined, { timeout: 50 }); + + expect(initOf().signal).toBeInstanceOf(AbortSignal); + }); +}); diff --git a/frontend/src/test/httpUtil.test.ts b/frontend/src/test/httpUtil.test.ts new file mode 100644 index 000000000..d5e7b1643 --- /dev/null +++ b/frontend/src/test/httpUtil.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const toast = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), +})); + +vi.mock('@/api/http-init', () => ({ + httpRequest: vi.fn(), + HttpError: class HttpError extends Error { + status: number; + response: { status: number; statusText: string; data: unknown }; + constructor(status: number, statusText: string, data: unknown) { + super(`Request failed with status ${status}`); + this.status = status; + this.response = { status, statusText, data }; + } + }, +})); + +vi.mock('@/utils/messageBus', () => ({ + getMessage: () => toast, +})); + +import { HttpUtil } from '@/utils'; +import { HttpError, httpRequest } from '@/api/http-init'; +import type { HttpResponse } from '@/api/http-init'; + +const mockRequest = vi.mocked(httpRequest); +const envelope = (data: unknown): HttpResponse => ({ ok: true, status: 200, statusText: 'OK', data }); + +describe('HttpUtil', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('unwraps a success envelope and shows a success toast', async () => { + mockRequest.mockResolvedValue(envelope({ success: true, msg: 'done', obj: { id: 1 } })); + + const msg = await HttpUtil.post('/x', { a: 1 }); + + expect(msg.success).toBe(true); + expect(msg.obj).toEqual({ id: 1 }); + expect(toast.success).toHaveBeenCalledWith('done'); + }); + + it('suppresses the success toast with silentSuccess but still warns on nodePending', async () => { + mockRequest.mockResolvedValue(envelope({ success: true, msg: 'saved', obj: { nodePending: true } })); + + await HttpUtil.post('/x', { a: 1 }, { silentSuccess: true }); + + expect(toast.success).not.toHaveBeenCalled(); + expect(toast.warning).toHaveBeenCalled(); + }); + + it('shows an error toast for a failure envelope', async () => { + mockRequest.mockResolvedValue(envelope({ success: false, msg: 'nope', obj: null })); + + const msg = await HttpUtil.post('/x'); + + expect(msg.success).toBe(false); + expect(toast.error).toHaveBeenCalledWith('nope'); + }); + + it('suppresses all toasts with silent', async () => { + mockRequest.mockResolvedValue(envelope({ success: false, msg: 'nope', obj: null })); + + await HttpUtil.post('/x', undefined, { silent: true }); + + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('maps a thrown HttpError to a failure Msg via response.data.message', async () => { + mockRequest.mockRejectedValue(new HttpError(400, 'Bad Request', { message: 'bad input' })); + + const msg = await HttpUtil.post('/x', undefined, { silent: true }); + + expect(msg.success).toBe(false); + expect(msg.msg).toBe('bad input'); + }); + + it('maps a thrown native error to a failure Msg via its message', async () => { + mockRequest.mockRejectedValue(new Error('Network down')); + + const msg = await HttpUtil.get('/x', undefined, { silent: true }); + + expect(msg.msg).toBe('Network down'); + }); + + it('returns "No response data" for an empty body', async () => { + mockRequest.mockResolvedValue(envelope('')); + + const msg = await HttpUtil.get('/x', undefined, { silent: true }); + + expect(msg.success).toBe(false); + expect(msg.msg).toBe('No response data'); + }); +}); diff --git a/frontend/src/test/setup.components.ts b/frontend/src/test/setup.components.ts index 042042442..37320380a 100644 --- a/frontend/src/test/setup.components.ts +++ b/frontend/src/test/setup.components.ts @@ -77,15 +77,6 @@ afterEach(async () => { import { HttpUtil } from '@/utils'; -vi.mock('axios', () => { - return { - default: { - get: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }), - post: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }), - } - }; -}); - // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 1f2c04d81..c58ce781c 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,6 +1,6 @@ -import axios from 'axios'; -import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'; import i18next from 'i18next'; +import { httpRequest } from '@/api/http-init'; +import type { HttpResponse } from '@/api/http-init'; import { getMessage } from './messageBus'; type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown }; @@ -17,7 +17,11 @@ export class Msg { } } -export interface HttpOptions extends AxiosRequestConfig { +export interface HttpOptions { + headers?: Record | Headers; + params?: unknown; + timeout?: number; + signal?: AbortSignal; silent?: boolean; silentSuccess?: boolean; } @@ -48,7 +52,7 @@ export class HttpUtil { getMessage().error(msg.msg); } - static _respToMsg(resp: AxiosResponse | undefined): Msg { + static _respToMsg(resp: HttpResponse | undefined): Msg { if (!resp || !resp.data) { return new Msg(false, 'No response data'); } @@ -64,15 +68,15 @@ export class HttpUtil { } static async get(url: string, params?: unknown, options: HttpOptions = {}): Promise> { - const { silent, silentSuccess, ...axiosOpts } = options; + const { silent, silentSuccess, ...rest } = options; try { - const resp = await axios.get(url, { params, ...axiosOpts }); + const resp = await httpRequest('GET', url, undefined, { ...rest, params }); const msg = this._respToMsg(resp) as Msg; if (!silent) this._handleMsg(msg, silentSuccess); return msg; } catch (error) { console.error('GET request failed:', error); - const err = error as AxiosError<{ message?: string }>; + const err = error as { response?: { data?: { message?: string } }; message?: string }; const errorMsg = new Msg(false, err.response?.data?.message || err.message || 'Request failed'); if (!silent) this._handleMsg(errorMsg); return errorMsg; @@ -80,15 +84,15 @@ export class HttpUtil { } static async post(url: string, data?: unknown, options: HttpOptions = {}): Promise> { - const { silent, silentSuccess, ...axiosOpts } = options; + const { silent, silentSuccess, ...rest } = options; try { - const resp = await axios.post(url, data, axiosOpts); + const resp = await httpRequest('POST', url, data, rest); const msg = this._respToMsg(resp) as Msg; if (!silent) this._handleMsg(msg, silentSuccess); return msg; } catch (error) { console.error('POST request failed:', error); - const err = error as AxiosError<{ message?: string }>; + const err = error as { response?: { data?: { message?: string } }; message?: string }; const errorMsg = new Msg(false, err.response?.data?.message || err.message || 'Request failed'); if (!silent) this._handleMsg(errorMsg); return errorMsg; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index a6edae0be..46cb46139 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -255,7 +255,6 @@ export default defineConfig({ || id.includes('/node_modules/d3-') ) return 'vendor-recharts'; if (id.includes('dayjs')) return 'vendor-dayjs'; - if (id.includes('axios')) return 'vendor-axios'; return 'vendor'; }, }, diff --git a/internal/web/controller/spa.go b/internal/web/controller/spa.go index cd16e0033..913dfc2e5 100644 --- a/internal/web/controller/spa.go +++ b/internal/web/controller/spa.go @@ -48,7 +48,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { // SPA pages built by Vite don't have a server-rendered , // so they fetch the session token via this endpoint at startup and replay it - // on subsequent unsafe requests through axios. + // on subsequent unsafe requests. g.GET("/csrf-token", a.csrfToken) } diff --git a/internal/web/controller/xray_setting.go b/internal/web/controller/xray_setting.go index 46e1cf4d3..446259820 100644 --- a/internal/web/controller/xray_setting.go +++ b/internal/web/controller/xray_setting.go @@ -60,7 +60,7 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) { g.POST("/outbound-subs/:id/move", a.moveOutboundSub) g.POST("/outbound-subs/:id", a.updateOutboundSub) g.DELETE("/outbound-subs/:id", a.deleteOutboundSub) - g.POST("/outbound-subs/:id/del", a.deleteOutboundSub) // axios-friendly alias + g.POST("/outbound-subs/:id/del", a.deleteOutboundSub) // POST alias for clients that can't send DELETE g.POST("/outbound-subs/parse", a.parseOutboundSubURL) // preview without saving } diff --git a/internal/xray/process.go b/internal/xray/process.go index 222ae37ce..2c1cf47dc 100644 --- a/internal/xray/process.go +++ b/internal/xray/process.go @@ -96,7 +96,7 @@ func GetAccessLogPath() (string, error) { // GetErrorLogPath reads the Xray config and returns the error log file path. // GetErrorLogPath reads the Xray config and returns the error log file path. func GetErrorLogPath() (string, error) { - return getLogPath("error") + return getLogPath("error") } // stopProcess calls Stop on the given Process instance.