mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-06 05:05:01 +03:00
fix(clients): reject dot names and recover legacy clients (#7062)
Reject reserved dot-segment client names and recover legacy records through query aliases. Preserve token scopes and path identity precedence, and update the generated API reference.
This commit is contained in:
committed by
GitHub
parent
2541080c91
commit
cd176faf4e
@@ -8722,6 +8722,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/get": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Fetch one client by email, including the inbound IDs and external config IDs it is attached to.",
|
||||
"operationId": "get_panel_api_clients_get",
|
||||
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Client email (unique identifier).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/get/subId/{subId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -8845,7 +8887,7 @@
|
||||
],
|
||||
"summary": "Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.",
|
||||
"operationId": "post_panel_api_clients_add",
|
||||
"description": "Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
|
||||
"description": "Client email must not be exactly `.` or `..`. Existing reserved-name clients can be read, renamed to a valid name, or deleted using the query-parameter /get, /update and /del aliases. Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -9070,6 +9112,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/update": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
|
||||
"operationId": "post_panel_api_clients_update",
|
||||
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Current client email (unique identifier).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
},
|
||||
"example": {
|
||||
"email": "alice@example.com",
|
||||
"totalGB": 107374182400,
|
||||
"expiryTime": 1767225600000,
|
||||
"limitHwid": 2,
|
||||
"tgId": 123456789,
|
||||
"enable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"msg": "Client updated"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/del/{email}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -9125,6 +9231,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/del": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.",
|
||||
"operationId": "post_panel_api_clients_del",
|
||||
"description": "Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Client email (unique identifier).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "keepTraffic",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Pass 1 to retain the xray_client_traffic row after deletion.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"msg": "Client deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/{email}/attach": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function clientManagementPath(action: 'get' | 'update' | 'del', email: string): string {
|
||||
const base = `/panel/api/clients/${action}`;
|
||||
const encoded = encodeURIComponent(email);
|
||||
return email === '.' || email === '..' ? `${base}?email=${encoded}` : `${base}/${encoded}`;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { clientManagementPath } from '@/api/client-management';
|
||||
import { markLocalInvalidate } from '@/api/invalidationTracker';
|
||||
import {
|
||||
ClientHydrateSchema,
|
||||
@@ -343,7 +344,7 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
|
||||
const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
|
||||
if (!email) return null;
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
|
||||
const msg = await HttpUtil.get(clientManagementPath('get', email));
|
||||
if (!msg?.success || !msg.obj) return null;
|
||||
const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
|
||||
return validated.obj;
|
||||
@@ -375,7 +376,7 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
|
||||
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
|
||||
HttpUtil.post(clientManagementPath('update', email), client, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
@@ -383,9 +384,8 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
|
||||
const url = keepTraffic
|
||||
? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
|
||||
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
|
||||
let url = clientManagementPath('del', email);
|
||||
if (keepTraffic) url += `${url.includes('?') ? '&' : '?'}keepTraffic=1`;
|
||||
return HttpUtil.post(url);
|
||||
},
|
||||
onSuccess: (msg) => {
|
||||
|
||||
@@ -1149,6 +1149,19 @@ export const sections: readonly Section[] = [
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/get',
|
||||
summary:
|
||||
'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.',
|
||||
description:
|
||||
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization.',
|
||||
params: [
|
||||
{ name: 'email', in: 'query', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/clients/get/subId/:subId',
|
||||
@@ -1195,7 +1208,7 @@ export const sections: readonly Section[] = [
|
||||
summary:
|
||||
'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets are generated server-side when omitted, so callers can send only the universal fields.',
|
||||
description:
|
||||
'Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
|
||||
'Client email must not be exactly `.` or `..`. Existing reserved-name clients can be read, renamed to a valid name, or deleted using the query-parameter /get, /update and /del aliases. Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already exists, with its stored `subId`, reuses the stored `id`, `password`, `auth` and `secret` instead of minting new ones, so the identity stays in sync across its inbounds.\n\n- **VLESS / VMess** — `id`, a fresh UUID\n- **Trojan** — `password`\n- **Shadowsocks** — `password`. On a `2022-blake3-*` inbound a supplied password that does not base64-decode to the key length of the cipher (16 or 32 bytes) is replaced by a generated key and the call still succeeds, so read the client back if you did not let the server pick. Legacy ciphers keep any non-empty password\n- **Hysteria** — `auth`\n- **mtproto** — `secret`, a FakeTLS secret derived from the fronting domain of the inbound, or from `www.cloudflare.com` when it has none\n- **WireGuard** — `privateKey` and `publicKey` when both are blank, or `publicKey` alone when only a `privateKey` was sent, plus `allowedIPs`: one free `/32` taken from the /24 the existing peers of that inbound already sit in, or from `10.0.0.0/24` when it has none\n\nAccepted on the same body but never generated: `preSharedKey` and `keepAlive` (WireGuard), `adTag` (mtproto).\n\nWireGuard is the only one of these that can fail. Allocation widens the search to the containing /16 before giving up with `inbound <id>: wireguard: no free address available in <scope>`, and an `allowedIPs` supplied by the caller is validated instead of allocated: `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by another client` when its range overlaps an address or prefix a different client of that same inbound holds, or `... used by a client on <inbound>` when the holder sits on another WireGuard or AmneziaWG inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides with `10.0.0.5/32`; a `/0` entry collides with every address of its family another client holds, and only a `0.0.0.0/0` or `::/0` default route that is already stored (legacy peers) keeps nobody out. Allocation likewise skips every address inside a prefix another client holds. The same validation runs on POST /panel/api/clients/{email}/attach, where a client that already carries an address brings it along.\n\nAn `inboundIds` entry that names no existing inbound rejects the whole call before anything is written. Past that, the inbounds are applied concurrently and independently: one that fails no longer stops the others, so a `success:false` response can still have created the client on the rest. Every error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. `limitHwid` is applied only when every inbound succeeded, so re-run the call after fixing the failure.',
|
||||
params: [
|
||||
{
|
||||
name: 'client',
|
||||
@@ -1277,6 +1290,24 @@ export const sections: readonly Section[] = [
|
||||
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "limitHwid": 2,\n "tgId": 123456789,\n "enable": true\n}',
|
||||
response: '{\n "success": true,\n "msg": "Client updated"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/update',
|
||||
summary:
|
||||
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
|
||||
description:
|
||||
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have applied the edit to the remaining inbounds. The client record is written after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the inbound edits in place.',
|
||||
params: [
|
||||
{
|
||||
name: 'email',
|
||||
in: 'query',
|
||||
type: 'string',
|
||||
desc: 'Current client email (unique identifier).',
|
||||
},
|
||||
],
|
||||
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "limitHwid": 2,\n "tgId": 123456789,\n "enable": true\n}',
|
||||
response: '{\n "success": true,\n "msg": "Client updated"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/del/:email',
|
||||
@@ -1295,6 +1326,24 @@ export const sections: readonly Section[] = [
|
||||
],
|
||||
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/del',
|
||||
summary:
|
||||
'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.',
|
||||
description:
|
||||
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. The inbounds are applied concurrently and independently: one that fails no longer stops the others. Every inbound error names the inbound it came from (`inbound 7: <message>`), and several failures are reported together, one per line. So a `success:false` response can still have removed the client from the remaining inbounds; the client record is kept in that case, so re-running the call retries exactly the leftovers. The record and traffic rows are dropped after the inbounds, so a failure there is reported without an `inbound <id>:` prefix and leaves the client already removed from every inbound.',
|
||||
params: [
|
||||
{ name: 'email', in: 'query', type: 'string', desc: 'Client email (unique identifier).' },
|
||||
{
|
||||
name: 'keepTraffic',
|
||||
in: 'query',
|
||||
type: 'integer',
|
||||
desc: 'Pass 1 to retain the xray_client_traffic row after deletion.',
|
||||
},
|
||||
],
|
||||
response: '{\n "success": true,\n "msg": "Client deleted"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/:email/attach',
|
||||
|
||||
@@ -319,6 +319,7 @@ export const ClientFormSchema = z.object({
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'pages.clients.email')
|
||||
.refine((v) => v !== '.' && v !== '..', 'pages.clients.emailReservedName')
|
||||
.refine((v) => !hasForbiddenClientChars(v), 'pages.clients.emailInvalidChars'),
|
||||
subId: z.string().refine((v) => !hasForbiddenClientChars(v), 'pages.clients.subIdInvalidChars'),
|
||||
uuid: z.string(),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { setupHttp } from '@/api/http-init';
|
||||
import { useClients } from '@/hooks/useClients';
|
||||
import { makeTestQueryClient } from '@/test/test-utils';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const ORIGIN = 'https://example.invalid';
|
||||
const BASE = '/audit/panel/api/clients/';
|
||||
|
||||
describe('legacy dot-name client recovery', () => {
|
||||
const requests: { url: URL; method: string; body: unknown }[] = [];
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mocked(HttpUtil.get).mockRestore();
|
||||
vi.mocked(HttpUtil.post).mockRestore();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
requests.length = 0;
|
||||
window.X_UI_BASE_PATH = `${ORIGIN}/audit/`;
|
||||
setupHttp();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: string, init?: RequestInit) => {
|
||||
const url = new URL(input);
|
||||
const method = init?.method ?? 'GET';
|
||||
if (!url.pathname.startsWith(BASE)) {
|
||||
const obj = url.pathname.endsWith('/csrf-token') ? 'audit-csrf' : {};
|
||||
return Response.json({ success: true, obj });
|
||||
}
|
||||
if (url.pathname.endsWith('/options') || url.pathname.endsWith('/onlines')) {
|
||||
return Response.json({ success: true, obj: [] });
|
||||
}
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null;
|
||||
requests.push({ url, method, body });
|
||||
const [action, segment] = url.pathname.slice(BASE.length).split('/');
|
||||
const email =
|
||||
url.searchParams.get('email') ?? (segment ? decodeURIComponent(segment) : null);
|
||||
if (action === 'get' && email) {
|
||||
return Response.json({
|
||||
success: true,
|
||||
obj: { client: { email, uuid: 'kept-uuid' }, inboundIds: [7] },
|
||||
});
|
||||
}
|
||||
if (['update', 'del'].includes(action) && email) {
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
return Response.json({ success: false, msg: 'Missing client identity' }, { status: 404 });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete window.X_UI_BASE_PATH;
|
||||
setupHttp();
|
||||
});
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
it.each(['.', '..'])('loads the full record and renames a legacy %j client', async (email) => {
|
||||
const { result } = renderHook(() => useClients({ list: false }), { wrapper });
|
||||
await act(async () => {
|
||||
const full = await result.current.hydrate(email);
|
||||
expect(full?.client.uuid).toBe('kept-uuid');
|
||||
expect(full?.inboundIds).toEqual([7]);
|
||||
const msg = await result.current.update(email, {
|
||||
email: 'restored-client',
|
||||
id: full?.client.uuid,
|
||||
});
|
||||
expect(msg.success).toBe(true);
|
||||
});
|
||||
const update = requests.find((r) => r.url.pathname === `${BASE}update`);
|
||||
expect(update?.url.searchParams.get('email')).toBe(email);
|
||||
expect(update?.body).toEqual({ email: 'restored-client', id: 'kept-uuid' });
|
||||
});
|
||||
|
||||
it.each(['.', '..'])(
|
||||
'individually deletes legacy %j while preserving keepTraffic',
|
||||
async (email) => {
|
||||
const { result } = renderHook(() => useClients({ list: false }), { wrapper });
|
||||
await act(async () => {
|
||||
const msg = await result.current.remove(email, true);
|
||||
expect(msg.success).toBe(true);
|
||||
});
|
||||
const request = requests.find((r) => r.url.pathname === `${BASE}del`);
|
||||
expect(request?.method).toBe('POST');
|
||||
expect(request?.url.searchParams.get('email')).toBe(email);
|
||||
expect(request?.url.searchParams.get('keepTraffic')).toBe('1');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['...', '.alice', 'alice.', 'alice.example', 'alice..example'])(
|
||||
'keeps the existing path endpoints for %s',
|
||||
async (email) => {
|
||||
const { result } = renderHook(() => useClients({ list: false }), { wrapper });
|
||||
await act(async () => {
|
||||
expect((await result.current.hydrate(email))?.client.email).toBe(email);
|
||||
expect((await result.current.update(email, { email })).success).toBe(true);
|
||||
expect((await result.current.remove(email, true)).success).toBe(true);
|
||||
});
|
||||
for (const action of ['get', 'update', 'del']) {
|
||||
const request = requests.find((r) => r.url.pathname === `${BASE}${action}/${email}`);
|
||||
expect(request).toBeDefined();
|
||||
expect(request?.url.searchParams.has('email')).toBe(false);
|
||||
}
|
||||
expect(
|
||||
requests
|
||||
.find((r) => r.url.pathname === `${BASE}del/${email}`)
|
||||
?.url.searchParams.get('keepTraffic'),
|
||||
).toBe('1');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ClientCreateFormSchema, ClientFormSchema } from '@/schemas/client';
|
||||
|
||||
describe('client name validation', () => {
|
||||
it.each(['.', '..', ' . ', ' .. '])('rejects the reserved name %j on create and edit', (name) => {
|
||||
for (const schema of [ClientCreateFormSchema, ClientFormSchema]) {
|
||||
const result = schema.shape.email.safeParse(name);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe('pages.clients.emailReservedName');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['...', '.alice', 'alice.', 'alice.example', 'alice..example', 'alice@example.com'])(
|
||||
'preserves ordinary dotted names: %s',
|
||||
(name) => {
|
||||
expect(ClientFormSchema.shape.email.parse(name)).toBe(name);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user