mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-08 13:46:08 +00:00
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.
This commit is contained in:
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Generated
+5
-18
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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<string | null> | 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<string | null> {
|
||||
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<string | null> {
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<string | null> | 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<string, string> | 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<string | null> {
|
||||
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<string | null> {
|
||||
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<string, unknown>).forEach(([k, v]) => append(`${key}[${k}]`, v));
|
||||
return;
|
||||
}
|
||||
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||
};
|
||||
Object.entries(data as Record<string, unknown>).forEach(([k, v]) => append(k, v));
|
||||
return parts.join('&');
|
||||
}
|
||||
|
||||
async function performFetch(
|
||||
method: string,
|
||||
url: string,
|
||||
data: unknown,
|
||||
options: HttpRequestOptions,
|
||||
csrfOverride?: string,
|
||||
): Promise<Response> {
|
||||
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<unknown> {
|
||||
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<HttpResponse> {
|
||||
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<HttpResponse>(() => {});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
Vendored
-22
@@ -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<string, unknown>;
|
||||
const qs: { stringify: typeof stringify; parse: typeof parse };
|
||||
export default qs;
|
||||
}
|
||||
|
||||
declare module 'persian-calendar-suite' {
|
||||
import type { ComponentType, ReactNode } from 'react';
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.' },
|
||||
],
|
||||
|
||||
@@ -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<PanelUpdateStatus>(
|
||||
'/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';
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
let replaceMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
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 = '<meta name="csrf-token" content="tok">';
|
||||
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 = '<meta name="csrf-token" content="tok">';
|
||||
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 = '<meta name="csrf-token" content="tok">';
|
||||
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 = '<meta name="csrf-token" content="tok">';
|
||||
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 = '<meta name="csrf-token" content="tok">';
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+14
-10
@@ -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<T = unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface HttpOptions extends AxiosRequestConfig {
|
||||
export interface HttpOptions {
|
||||
headers?: Record<string, string> | 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<T = unknown>(url: string, params?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
|
||||
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<T>;
|
||||
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<T>(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<T = unknown>(url: string, data?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
|
||||
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<T>;
|
||||
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<T>(false, err.response?.data?.message || err.message || 'Request failed');
|
||||
if (!silent) this._handleMsg(errorMsg);
|
||||
return errorMsg;
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
// SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user