mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 20:55:29 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 964cd6c124 |
@@ -365,15 +365,6 @@ jobs:
|
||||
- name: Build Storybook
|
||||
run: npm run build-storybook
|
||||
working-directory: frontend
|
||||
- name: Inbound keyboard and accessibility tree
|
||||
working-directory: frontend
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./node_modules/.bin/vite preview --outDir storybook-static --host 127.0.0.1 --port 6006 > /tmp/inbound-preview.log 2>&1 &
|
||||
preview_pid=$!
|
||||
trap 'kill "$preview_pid" 2>/dev/null || true' EXIT
|
||||
curl --retry 30 --retry-connrefused --retry-delay 1 --retry-max-time 45 -fsS http://127.0.0.1:6006/iframe.html > /dev/null
|
||||
node tools/check-inbound-keyboard.mjs http://127.0.0.1:6006
|
||||
- name: Audit
|
||||
run: npm run audit
|
||||
working-directory: frontend
|
||||
|
||||
@@ -28,10 +28,6 @@ _openapi:
|
||||
title: Fetch one client by email, including the inbound IDs and external config
|
||||
IDs it is attached to.
|
||||
url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to'
|
||||
- depth: 2
|
||||
title: Fetch one client by email, including the inbound IDs and external config
|
||||
IDs it is attached to.
|
||||
url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1'
|
||||
- depth: 2
|
||||
title: Fetch clients by subscription ID.
|
||||
url: '#fetch-clients-by-subscription-id'
|
||||
@@ -48,25 +44,14 @@ _openapi:
|
||||
title: Preview client auto-renewal dates without saving or resetting anything.
|
||||
url: '#preview-client-auto-renewal-dates-without-saving-or-resetting-anything'
|
||||
- depth: 2
|
||||
title: 'Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload: omitted fields keep their
|
||||
current values, and an explicit zero, empty or null value clears a
|
||||
field, except as noted below.'
|
||||
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below'
|
||||
- depth: 2
|
||||
title: 'Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload: omitted fields keep their
|
||||
current values, and an explicit zero, empty or null value clears a
|
||||
field, except as noted below.'
|
||||
url: '#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1'
|
||||
title: 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).
|
||||
url: '#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'
|
||||
- depth: 2
|
||||
title: Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
url: '#delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed'
|
||||
- depth: 2
|
||||
title: Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
url: '#delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1'
|
||||
- depth: 2
|
||||
title: Attach an existing client to one or more additional inbounds. Body is
|
||||
JSON.
|
||||
@@ -339,9 +324,6 @@ _openapi:
|
||||
- content: Fetch one client by email, including the inbound IDs and external
|
||||
config IDs it is attached to.
|
||||
id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to
|
||||
- content: Fetch one client by email, including the inbound IDs and external
|
||||
config IDs it is attached to.
|
||||
id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1
|
||||
- content: Fetch clients by subscription ID.
|
||||
id: fetch-clients-by-subscription-id
|
||||
- content: Fetch clients by Telegram user ID. Returns an array since multiple
|
||||
@@ -353,22 +335,14 @@ _openapi:
|
||||
id: 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
|
||||
- content: Preview client auto-renewal dates without saving or resetting anything.
|
||||
id: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
|
||||
- content: 'Update an existing client by email. Changes propagate to every
|
||||
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||
their current values, and an explicit zero, empty or null value clears
|
||||
a field, except as noted below.'
|
||||
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
- content: 'Update an existing client by email. Changes propagate to every
|
||||
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||
their current values, and an explicit zero, empty or null value clears
|
||||
a field, except as noted below.'
|
||||
id: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1
|
||||
- content: 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).
|
||||
id: 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
|
||||
- content: Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
id: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed
|
||||
- content: Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
id: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1
|
||||
- content: Attach an existing client to one or more additional inbounds. Body is
|
||||
JSON.
|
||||
id: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
|
||||
@@ -590,9 +564,6 @@ _openapi:
|
||||
tunnel) contribute nothing.'
|
||||
id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing
|
||||
contents:
|
||||
- content: Query-parameter alias for the path endpoint. Use email=. or email=.. to
|
||||
manage legacy reserved names without URL dot-segment normalization.
|
||||
heading: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to-1
|
||||
- content: Matches the exact subscription ID and orders results by client ID.
|
||||
Returns an array of the same payloads as GET /clients/get/:email,
|
||||
since multiple clients can share a subscription ID. Includes disabled,
|
||||
@@ -601,14 +572,11 @@ _openapi:
|
||||
email lookup. Requires an admin token or a panel login session.
|
||||
heading: fetch-clients-by-subscription-id
|
||||
- content: >-
|
||||
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.
|
||||
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.
|
||||
|
||||
|
||||
- **VLESS / VMess** — `id`, a fresh UUID
|
||||
@@ -673,11 +641,7 @@ _openapi:
|
||||
preview is informational and does not reserve an allowance or
|
||||
guarantee node availability.
|
||||
heading: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
|
||||
- content: 'An omitted `flow` leaves every attached inbound on its own flow. Send
|
||||
`"reverse": null` to remove the reverse tag. Credentials (`id`,
|
||||
`password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG
|
||||
`allowedIPs` are never cleared: an empty value keeps the stored one.
|
||||
The inbounds are applied concurrently and independently: one that
|
||||
- content: '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
|
||||
@@ -685,22 +649,7 @@ _openapi:
|
||||
record is written after the inbounds, so a failure there is reported
|
||||
without an `inbound <id>:` prefix and leaves the inbound edits in
|
||||
place.'
|
||||
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
- content: 'Query-parameter alias for the path endpoint. Use email=. or email=..
|
||||
to manage legacy reserved names without URL dot-segment normalization.
|
||||
An omitted `flow` leaves every attached inbound on its own flow. Send
|
||||
`"reverse": null` to remove the reverse tag. Credentials (`id`,
|
||||
`password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG
|
||||
`allowedIPs` are never cleared: an empty value keeps the stored one.
|
||||
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.'
|
||||
heading: update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below-1
|
||||
heading: 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
|
||||
- content: '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
|
||||
@@ -711,18 +660,6 @@ _openapi:
|
||||
inbounds, so a failure there is reported without an `inbound <id>:`
|
||||
prefix and leaves the client already removed from every inbound.'
|
||||
heading: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed
|
||||
- content: '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.'
|
||||
heading: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed-1
|
||||
- content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
|
||||
instead of being given a fresh address, so the call fails with
|
||||
`inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address>
|
||||
@@ -748,7 +685,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get","method":"get"},{"path":"/panel/api/clients/get/subId/{subId}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/update","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/del","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
||||
<OpenAPIPage document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/subId/{subId}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -44,11 +44,10 @@ _openapi:
|
||||
- depth: 2
|
||||
title: >-
|
||||
Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload: omitted fields keep their
|
||||
current values, and an explicit zero, empty or null value clears a
|
||||
field, except as noted below.
|
||||
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).
|
||||
url: >-
|
||||
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
#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
|
||||
- depth: 2
|
||||
title: >-
|
||||
Delete a client by email. Removes it from every attached inbound and
|
||||
@@ -359,11 +358,11 @@ _openapi:
|
||||
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
|
||||
- content: >-
|
||||
Update an existing client by email. Changes propagate to every
|
||||
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||
their current values, and an explicit zero, empty or null value clears
|
||||
a field, except as noted below.
|
||||
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).
|
||||
id: >-
|
||||
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
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
|
||||
- content: >-
|
||||
Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
|
||||
@@ -44,11 +44,10 @@ _openapi:
|
||||
- depth: 2
|
||||
title: >-
|
||||
Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload: omitted fields keep their
|
||||
current values, and an explicit zero, empty or null value clears a
|
||||
field, except as noted below.
|
||||
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).
|
||||
url: >-
|
||||
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
#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
|
||||
- depth: 2
|
||||
title: >-
|
||||
Delete a client by email. Removes it from every attached inbound and
|
||||
@@ -359,11 +358,11 @@ _openapi:
|
||||
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
|
||||
- content: >-
|
||||
Update an existing client by email. Changes propagate to every
|
||||
attached inbound. Body is the JSON client payload: omitted fields keep
|
||||
their current values, and an explicit zero, empty or null value clears
|
||||
a field, except as noted below.
|
||||
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).
|
||||
id: >-
|
||||
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
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
|
||||
- content: >-
|
||||
Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
|
||||
@@ -35,10 +35,10 @@ _openapi:
|
||||
#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
|
||||
- depth: 2
|
||||
title: >-
|
||||
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷:
|
||||
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。
|
||||
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
|
||||
请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
|
||||
url: >-
|
||||
#update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
#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
|
||||
- depth: 2
|
||||
title: >-
|
||||
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
|
||||
@@ -292,10 +292,10 @@ _openapi:
|
||||
id: >-
|
||||
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
|
||||
- content: >-
|
||||
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷:
|
||||
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。
|
||||
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷——
|
||||
请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
|
||||
id: >-
|
||||
update-an-existing-client-by-email-changes-propagate-to-every-attached-inbound-body-is-the-json-client-payload-omitted-fields-keep-their-current-values-and-an-explicit-zero-empty-or-null-value-clears-a-field-except-as-noted-below
|
||||
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
|
||||
- content: >-
|
||||
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
|
||||
keepTraffic=1。
|
||||
|
||||
+3
-164
@@ -8722,48 +8722,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -8887,7 +8845,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": "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.",
|
||||
"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.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -9053,9 +9011,9 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
|
||||
"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_email",
|
||||
"description": "An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. 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.",
|
||||
"description": "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",
|
||||
@@ -9112,70 +9070,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
|
||||
"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. An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. 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": [
|
||||
@@ -9231,61 +9125,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
|
||||
@@ -47,33 +47,6 @@ production-style links work without round-tripping through Go.
|
||||
CI runs `typecheck`, `lint`, `format:check`, `test`, `build`, and
|
||||
`build-storybook` on every PR (see `../.github/workflows/ci.yml`).
|
||||
|
||||
### Inbound accessibility verification
|
||||
|
||||
`npm test` includes the real inbound form and native-select regression fixtures.
|
||||
After `npm run build-storybook`, serve the built stories with
|
||||
`npx vite preview --outDir storybook-static --host 127.0.0.1 --port 6006` and, in
|
||||
another terminal, run `node tools/check-inbound-keyboard.mjs`. CI runs this check
|
||||
too. It uses real Chromium Tab/Arrow/Enter input, checks native selected values
|
||||
in the accessibility tree, captures create/edit requests, and verifies focus
|
||||
containment and return. Its HTTP boundary is stubbed; backend configuration
|
||||
generation and server validation are unchanged.
|
||||
|
||||
Mobile viewport emulation and accessibility-tree checks do not prove TalkBack
|
||||
or NVDA compatibility. On actual Android TalkBack/Chrome and Windows NVDA:
|
||||
|
||||
1. Open Add Inbound, explore Protocol, open the native picker, choose Shadowsocks
|
||||
and confirm the control announces its selected value. Cancel another picker
|
||||
and confirm the value and focus stay unchanged.
|
||||
2. Visit Protocol, select an encryption method, then Stream; choose RAW,
|
||||
WebSocket, gRPC and XHTTP and verify the dependent fields and their labels.
|
||||
3. Check Security's None/TLS/REALITY radios; try certificate/uTLS selections and
|
||||
the ALPN multi-select, searchable node/fallback fields, clearing and custom tags.
|
||||
4. Trigger a custom-share-address validation error from another tab. Confirm
|
||||
the form reveals/focuses the field and reads its hint/error. Correct it and save.
|
||||
5. Edit the inbound, confirm Protocol remains disabled, save a transport change,
|
||||
and close/reopen with keyboard and touch exploration. Focus must return to
|
||||
the opener and Tab/Shift+Tab must stay inside the open dialog.
|
||||
|
||||
### One-off: scan for deprecated APIs
|
||||
|
||||
Run this command to sweep the codebase for usages of APIs marked
|
||||
|
||||
Generated
+4
-4
@@ -24,7 +24,7 @@
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"react-i18next": "^17.1.10",
|
||||
"react-i18next": "^17.1.11",
|
||||
"react-router": "^8.5.1",
|
||||
"swagger-ui-react": "^5.34.1",
|
||||
"uplot": "^1.6.32",
|
||||
@@ -9604,9 +9604,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.1.10",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.10.tgz",
|
||||
"integrity": "sha512-XuUuA/rxukE4hXuR//PN308DuSQkWqKNNWBUj0dDTR087l3+6WLid3NC/w55ALrXA/dtWb7lychJ4rQLR3DCDg==",
|
||||
"version": "17.1.11",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.11.tgz",
|
||||
"integrity": "sha512-nQqkvaD+N4kSrM0bosDc7yW2IvaC3chHPhZb0FoRXX6Buf1JzfOLLLs2u7SfXcnAE3nKAIn0Kvu68lQGObPlyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"react-i18next": "^17.1.10",
|
||||
"react-i18next": "^17.1.11",
|
||||
"react-router": "^8.5.1",
|
||||
"swagger-ui-react": "^5.34.1",
|
||||
"uplot": "^1.6.32",
|
||||
|
||||
@@ -8722,48 +8722,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
@@ -8887,7 +8845,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": "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.",
|
||||
"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.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
@@ -9053,9 +9011,9 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
|
||||
"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_email",
|
||||
"description": "An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. 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.",
|
||||
"description": "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",
|
||||
@@ -9112,70 +9070,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.",
|
||||
"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. An omitted `flow` leaves every attached inbound on its own flow. Send `\"reverse\": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. 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": [
|
||||
@@ -9231,61 +9125,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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": [
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.native-select {
|
||||
padding-inline: 11px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.native-select:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.native-select:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
|
||||
import { NativeSelect } from './NativeSelect';
|
||||
|
||||
const meta = {
|
||||
title: 'Form/NativeSelect',
|
||||
component: NativeSelect,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Native single selection for short, fixed lists. Labels, values, keyboard interaction and the mobile picker belong to the browser. Index keys keep number and string values distinct in onChange; searchable, clearable and multi-select fields continue using Ant Design.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
options: {
|
||||
description: 'Ordered labels, typed string/number values and optional disabled choices.',
|
||||
},
|
||||
value: {
|
||||
description: 'Controlled form value. Saved values absent from the list remain visible.',
|
||||
},
|
||||
onChange: {
|
||||
description: 'Receives the original typed option value for RHF or Ant Design Form.',
|
||||
},
|
||||
placeholder: { description: 'Shown until the form has a value.' },
|
||||
disabled: {
|
||||
description: 'Uses native disabled behavior and preserves the controlled form value.',
|
||||
},
|
||||
size: {
|
||||
description: 'Ant Design token size, without turning the control into a multi-row listbox.',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
'aria-label': 'Protocol',
|
||||
options: [
|
||||
{ value: 'vless', label: 'VLESS' },
|
||||
{ value: 'shadowsocks', label: 'Shadowsocks' },
|
||||
{ value: 'tuic', label: 'TUIC', disabled: true },
|
||||
],
|
||||
},
|
||||
} satisfies Meta<typeof NativeSelect>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const SingleSelection: Story = {
|
||||
render: function Render(args) {
|
||||
const [value, setValue] = useState<string | number>('vless');
|
||||
return <NativeSelect {...args} value={value} onChange={setValue} />;
|
||||
},
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const select = canvas.getByRole('combobox', { name: 'Protocol' });
|
||||
await expect(select).toHaveDisplayValue('VLESS');
|
||||
await userEvent.selectOptions(
|
||||
select,
|
||||
within(select).getByRole('option', { name: 'Shadowsocks' }),
|
||||
);
|
||||
await expect(select).toHaveDisplayValue('Shadowsocks');
|
||||
await expect(within(select).getByRole('option', { name: 'Shadowsocks' })).toHaveProperty(
|
||||
'selected',
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { ComponentPropsWithRef } from 'react';
|
||||
import { theme } from 'antd';
|
||||
|
||||
import './NativeSelect.css';
|
||||
|
||||
export interface NativeSelectProps<Value extends string | number> extends Omit<
|
||||
ComponentPropsWithRef<'select'>,
|
||||
'value' | 'defaultValue' | 'onChange' | 'multiple' | 'children' | 'size'
|
||||
> {
|
||||
value?: Value | null;
|
||||
onChange?: (value: Value) => void;
|
||||
options: readonly { value: Value; label: string | number; disabled?: boolean }[];
|
||||
placeholder?: string;
|
||||
size?: 'small' | 'middle' | 'large';
|
||||
}
|
||||
|
||||
export function NativeSelect<Value extends string | number>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled,
|
||||
className,
|
||||
style,
|
||||
size,
|
||||
...props
|
||||
}: NativeSelectProps<Value>) {
|
||||
const { token } = theme.useToken();
|
||||
const selected = options.findIndex((option) => Object.is(option.value, value));
|
||||
const hasUnlistedValue = selected < 0 && value != null;
|
||||
return (
|
||||
<select
|
||||
{...props}
|
||||
className={['native-select', className].filter(Boolean).join(' ')}
|
||||
disabled={disabled}
|
||||
value={selected < 0 ? '' : String(selected)}
|
||||
onChange={(event) => {
|
||||
const option = options[Number(event.target.value)];
|
||||
if (event.target.value !== '' && option && !option.disabled) onChange?.(option.value);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
height:
|
||||
size === 'small'
|
||||
? token.controlHeightSM
|
||||
: size === 'large'
|
||||
? token.controlHeightLG
|
||||
: token.controlHeight,
|
||||
border: `1px solid ${props['aria-invalid'] === true || props['aria-invalid'] === 'true' ? token.colorError : token.colorBorder}`,
|
||||
borderRadius: token.borderRadius,
|
||||
backgroundColor: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
|
||||
color: disabled ? token.colorTextDisabled : token.colorText,
|
||||
fontSize: token.fontSize,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{selected < 0 && (
|
||||
<option value="" disabled={!hasUnlistedValue} hidden={!hasUnlistedValue}>
|
||||
{hasUnlistedValue ? String(value) : (placeholder ?? '')}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option, index) => (
|
||||
<option key={String(index)} value={String(index)} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -221,9 +221,6 @@ export const ValidationErrors: Story = {
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const remarkError = await canvas.findByText('Remark is required');
|
||||
await waitFor(() => expect(remarkError).toBeVisible());
|
||||
await expect(canvas.getByRole('textbox', { name: 'Remark' })).toHaveAccessibleDescription(
|
||||
'Remark is required',
|
||||
);
|
||||
await waitFor(() => expect(canvas.getByText('Port must be between 1 and 65535')).toBeVisible());
|
||||
await userEvent.type(canvas.getByPlaceholderText('vless-reality-443'), 'vless-reality-443');
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Validate' }));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cloneElement, useId } from 'react';
|
||||
import type { AriaAttributes, CSSProperties, ReactElement, ReactNode } from 'react';
|
||||
import type { CSSProperties, ReactElement, ReactNode } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
|
||||
import { Form } from 'antd';
|
||||
@@ -50,10 +50,12 @@ export function FormField<T extends FieldValues = FieldValues>({
|
||||
const dottedName = toDotted(name) as Path<T>;
|
||||
const generatedId = useId();
|
||||
const hintId = useId();
|
||||
const errorId = `${generatedId}-error`;
|
||||
const extraId = `${generatedId}-extra`;
|
||||
const childOwnProps = (children as ReactElement<{ id?: string } & AriaAttributes>).props;
|
||||
const childOwnProps = (children as ReactElement<{ id?: string; 'aria-describedby'?: string }>)
|
||||
.props;
|
||||
const fieldId = childOwnProps.id ?? generatedId;
|
||||
const describedBy = tooltip
|
||||
? [childOwnProps['aria-describedby'], hintId].filter(Boolean).join(' ')
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
@@ -65,14 +67,8 @@ export function FormField<T extends FieldValues = FieldValues>({
|
||||
const help = fieldState.error?.message
|
||||
? t(fieldState.error.message, { defaultValue: fieldState.error.message })
|
||||
: undefined;
|
||||
const describedBy =
|
||||
[childOwnProps['aria-describedby'], tooltip && hintId, extra && extraId, help && errorId]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined;
|
||||
const childProps: Record<string, unknown> = {
|
||||
id: fieldId,
|
||||
'aria-describedby': describedBy,
|
||||
'aria-invalid': fieldState.invalid || childOwnProps['aria-invalid'],
|
||||
[valueProp]: displayValue,
|
||||
onChange: (...args: unknown[]) => {
|
||||
const raw = normalizeAntdOnChange(args, valueProp);
|
||||
@@ -83,15 +79,16 @@ export function FormField<T extends FieldValues = FieldValues>({
|
||||
onBlur: field.onBlur,
|
||||
ref: field.ref,
|
||||
};
|
||||
if (describedBy) childProps['aria-describedby'] = describedBy;
|
||||
return (
|
||||
<Form.Item
|
||||
label={label}
|
||||
htmlFor={label ? fieldId : undefined}
|
||||
tooltip={tooltip}
|
||||
extra={extra ? <span id={extraId}>{extra}</span> : extra}
|
||||
extra={extra}
|
||||
required={required}
|
||||
validateStatus={fieldState.error ? 'error' : undefined}
|
||||
help={help ? <span id={errorId}>{help}</span> : undefined}
|
||||
help={help}
|
||||
noStyle={noStyle}
|
||||
className={className}
|
||||
style={style}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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,
|
||||
@@ -344,7 +343,7 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
|
||||
const hydrate = useCallback(async (email: string): Promise<ClientHydrate | null> => {
|
||||
if (!email) return null;
|
||||
const msg = await HttpUtil.get(clientManagementPath('get', email));
|
||||
const msg = await HttpUtil.get(`/panel/api/clients/get/${encodeURIComponent(email)}`);
|
||||
if (!msg?.success || !msg.obj) return null;
|
||||
const validated = parseMsg(msg, ClientHydrateSchema, 'clients/get');
|
||||
return validated.obj;
|
||||
@@ -376,7 +375,7 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ email, client }: { email: string; client: unknown }) =>
|
||||
HttpUtil.post(clientManagementPath('update', email), client, JSON_HEADERS),
|
||||
HttpUtil.post(`/panel/api/clients/update/${encodeURIComponent(email)}`, client, JSON_HEADERS),
|
||||
onSuccess: (msg) => {
|
||||
if (msg?.success) invalidateAll();
|
||||
},
|
||||
@@ -384,8 +383,9 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: ({ email, keepTraffic }: { email: string; keepTraffic?: boolean }) => {
|
||||
let url = clientManagementPath('del', email);
|
||||
if (keepTraffic) url += `${url.includes('?') ? '&' : '?'}keepTraffic=1`;
|
||||
const url = keepTraffic
|
||||
? `/panel/api/clients/del/${encodeURIComponent(email)}?keepTraffic=1`
|
||||
: `/panel/api/clients/del/${encodeURIComponent(email)}`;
|
||||
return HttpUtil.post(url);
|
||||
},
|
||||
onSuccess: (msg) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { Button, Divider, Form, Input, Select } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -69,7 +68,7 @@ export default function CustomSockoptList({
|
||||
<Input placeholder="decimal, e.g. 19" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Type" name={[field.name, 'type']}>
|
||||
<NativeSelect options={TYPE_OPTIONS} />
|
||||
<Select options={TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Value" name={[field.name, 'value']}>
|
||||
<Input placeholder="value" />
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import {
|
||||
AutoComplete,
|
||||
@@ -408,7 +407,7 @@ function TcpMasksList({
|
||||
}
|
||||
/>
|
||||
{allowPresets && (
|
||||
<NativeSelect
|
||||
<Select
|
||||
id={`${uid}-tcp-presets`}
|
||||
size="small"
|
||||
style={{ minWidth: 160 }}
|
||||
@@ -479,7 +478,7 @@ function TcpMaskItem({
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type" name={[fieldName, 'type']}>
|
||||
<NativeSelect
|
||||
<Select
|
||||
onChange={(v) =>
|
||||
form.setFieldValue([...absolutePath, 'settings'], defaultTcpMaskSettings(v))
|
||||
}
|
||||
@@ -1124,7 +1123,7 @@ function UdpMaskItem({
|
||||
</Divider>
|
||||
|
||||
<Form.Item label="Type" name={[fieldName, 'type']}>
|
||||
<NativeSelect onChange={onTypeChange} options={options} />
|
||||
<Select onChange={onTypeChange} options={options} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -1148,7 +1147,7 @@ function UdpMaskItem({
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Header" name={[fieldName, 'settings', 'header']}>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Original / AES-128-GCM' },
|
||||
{ value: 'dns', label: 'DNS' },
|
||||
@@ -1328,7 +1327,7 @@ function SalamanderUdpMaskSettings({
|
||||
: 'Scrambles each packet into random-looking bytes.'
|
||||
}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
id={`${uid}-salamander-mode`}
|
||||
value={mode}
|
||||
onChange={(next) => {
|
||||
@@ -1948,7 +1947,7 @@ function ItemEditor({
|
||||
<>
|
||||
{allowVariables && (
|
||||
<Form.Item label="Kind" htmlFor={`${uid}-kind`}>
|
||||
<NativeSelect
|
||||
<Select
|
||||
id={`${uid}-kind`}
|
||||
value={kind}
|
||||
onChange={onKindChange}
|
||||
@@ -1958,7 +1957,7 @@ function ItemEditor({
|
||||
)}
|
||||
{kind === 'bytes' && (
|
||||
<Form.Item label="Type" name={[fieldName, 'type']}>
|
||||
<NativeSelect
|
||||
<Select
|
||||
onChange={onTypeChange}
|
||||
options={[
|
||||
{ value: 'array', label: 'Array' },
|
||||
@@ -2123,7 +2122,7 @@ function QuicParamsForm({
|
||||
return (
|
||||
<>
|
||||
<Form.Item label="Congestion" name={[...base, 'congestion']}>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'reno', label: 'Reno' },
|
||||
{ value: 'bbr', label: 'BBR' },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, InputNumber, Select, Switch, Typography } from 'antd';
|
||||
@@ -90,7 +89,7 @@ export default function XDriveForm() {
|
||||
return (
|
||||
<>
|
||||
<FormField label={t('pages.inbounds.form.xdrive.service')} name={[...BASE, 'service']}>
|
||||
<NativeSelect options={serviceOptions} />
|
||||
<Select options={serviceOptions} />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.inbounds.form.xdrive.remoteFolder')}
|
||||
|
||||
@@ -1149,19 +1149,6 @@ 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',
|
||||
@@ -1208,7 +1195,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:
|
||||
'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.',
|
||||
'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',
|
||||
@@ -1276,9 +1263,9 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/update/:email',
|
||||
summary:
|
||||
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.',
|
||||
'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:
|
||||
'An omitted `flow` leaves every attached inbound on its own flow. Send `"reverse": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. 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.',
|
||||
'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',
|
||||
@@ -1290,24 +1277,6 @@ 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: omitted fields keep their current values, and an explicit zero, empty or null value clears a field, except as noted below.',
|
||||
description:
|
||||
'Query-parameter alias for the path endpoint. Use email=. or email=.. to manage legacy reserved names without URL dot-segment normalization. An omitted `flow` leaves every attached inbound on its own flow. Send `"reverse": null` to remove the reverse tag. Credentials (`id`, `password`, `auth`, `secret`, `subId`) and WireGuard/AmneziaWG `allowedIPs` are never cleared: an empty value keeps the stored one. 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',
|
||||
@@ -1326,24 +1295,6 @@ 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',
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function ClientBulkAdjustModal({
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('pages.clients.bulkAdjustTitle', { count })}
|
||||
okText={t('confirm')}
|
||||
okText={t('apply')}
|
||||
cancelText={t('cancel')}
|
||||
confirmLoading={submitting}
|
||||
onOk={handleOk}
|
||||
|
||||
@@ -798,10 +798,10 @@ export default function ClientFormModal({
|
||||
comment: values.comment,
|
||||
enable: !!values.enable,
|
||||
};
|
||||
// Sent even when empty, so clearing the tag never rests on how the server
|
||||
// reads an omitted key.
|
||||
const reverseTagValue = showReverseTag ? (values.reverseTag || '').trim() : '';
|
||||
clientPayload.reverse = reverseTagValue ? { tag: reverseTagValue } : null;
|
||||
if (reverseTagValue) {
|
||||
clientPayload.reverse = { tag: reverseTagValue };
|
||||
}
|
||||
|
||||
if (showWireguard || showAmneziawg) {
|
||||
// AmneziaWG peers are wire-identical to WireGuard peers (same
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function FilterDrawer({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.clients.attachedInbounds')} htmlFor={`${uid}-inbounds`}>
|
||||
<Form.Item label={t('inbounds')} htmlFor={`${uid}-inbounds`}>
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
@@ -136,7 +136,7 @@ export default function FilterDrawer({
|
||||
value={filters.inboundIds}
|
||||
onChange={(v) => patch('inboundIds', v as number[])}
|
||||
options={inboundOptions}
|
||||
placeholder={t('pages.clients.attachedInbounds')}
|
||||
placeholder={t('inbounds')}
|
||||
allowClear
|
||||
showSearch={{ optionFilterProp: 'label' }}
|
||||
listHeight={220}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within, waitFor } from 'storybook/test';
|
||||
import { Button } from 'antd';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
|
||||
import { DBInbound } from '@/models/dbinbound';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import { setDatepicker } from '@/hooks/useDatepicker';
|
||||
|
||||
import InboundFormModal from './InboundFormModal';
|
||||
|
||||
const meta = {
|
||||
title: 'Inbounds/InboundFormModal',
|
||||
component: InboundFormModal,
|
||||
tags: ['autodocs'],
|
||||
beforeEach: () => {
|
||||
setDatepicker('gregorian');
|
||||
},
|
||||
args: {
|
||||
open: false,
|
||||
mode: 'add',
|
||||
dbInbound: null,
|
||||
dbInbounds: [],
|
||||
onClose: () => {},
|
||||
onSaved: () => {},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'The real inbound form. Exercise named options, keyboard navigation, validation and modal focus here; verify TalkBack/Chrome and NVDA separately before claiming screen-reader compatibility.',
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof InboundFormModal>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
function InboundDemo({ edit = false }: { edit?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [queryClient] = useState(
|
||||
() => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
|
||||
);
|
||||
const [inbound] = useState(
|
||||
() =>
|
||||
new DBInbound({
|
||||
id: 41,
|
||||
protocol: 'shadowsocks',
|
||||
enable: true,
|
||||
port: 14443,
|
||||
settings: {
|
||||
method: 'chacha20-ietf-poly1305',
|
||||
password: 'example-password',
|
||||
network: 'tcp,udp',
|
||||
clients: [],
|
||||
},
|
||||
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AntdRootConfig>
|
||||
<ThemeProvider>
|
||||
<Button onClick={() => setOpen(true)}>{edit ? 'Edit inbound' : 'Add inbound'}</Button>
|
||||
<InboundFormModal
|
||||
open={open}
|
||||
mode={edit ? 'edit' : 'add'}
|
||||
dbInbound={edit ? inbound : null}
|
||||
dbInbounds={[]}
|
||||
availableNodes={[]}
|
||||
onClose={() => setOpen(false)}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</AntdRootConfig>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const Create: Story = {
|
||||
render: () => <InboundDemo />,
|
||||
};
|
||||
|
||||
export const SelectableOptions: Story = {
|
||||
...Create,
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
const opener = canvas.getByRole('button', { name: 'Add inbound' });
|
||||
await userEvent.click(opener);
|
||||
const dialog = await page.findByRole('dialog', { name: 'Add Inbound' });
|
||||
const protocol = within(dialog).getByRole('combobox', { name: 'Protocol' });
|
||||
await userEvent.selectOptions(
|
||||
protocol,
|
||||
within(protocol).getByRole('option', { name: 'shadowsocks' }),
|
||||
);
|
||||
await expect(protocol).toHaveDisplayValue('shadowsocks');
|
||||
await expect(protocol).toHaveFocus();
|
||||
await userEvent.click(within(dialog).getByRole('tab', { name: 'Protocol' }));
|
||||
const cipher = within(dialog).getByRole('combobox', { name: 'Encryption method' });
|
||||
await userEvent.selectOptions(
|
||||
cipher,
|
||||
within(cipher).getByRole('option', { name: 'chacha20-ietf-poly1305' }),
|
||||
);
|
||||
await userEvent.click(within(dialog).getByRole('tab', { name: 'Stream' }));
|
||||
const transport = within(dialog).getByRole('combobox', { name: 'Transmission' });
|
||||
await userEvent.selectOptions(
|
||||
transport,
|
||||
within(transport).getByRole('option', { name: 'WebSocket' }),
|
||||
);
|
||||
await expect(within(dialog).getByRole('textbox', { name: 'Path' })).toHaveValue('/');
|
||||
await userEvent.click(within(dialog).getAllByRole('button', { name: 'Close' })[0]);
|
||||
await waitFor(() => expect(opener).toHaveFocus());
|
||||
},
|
||||
};
|
||||
|
||||
export const Edit: Story = {
|
||||
render: () => <InboundDemo edit />,
|
||||
};
|
||||
@@ -1,22 +1,8 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useEffect, useRef, useState, useId } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Alert,
|
||||
ConfigProvider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Switch,
|
||||
Tabs,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tabs, message } from 'antd';
|
||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
|
||||
@@ -106,28 +92,6 @@ const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
|
||||
const SHARE_ADDR_HOSTNAME_RE =
|
||||
/^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
|
||||
|
||||
function inboundPopupContainer(trigger?: HTMLElement): HTMLElement {
|
||||
return (
|
||||
trigger?.closest<HTMLElement>('.ant-modal-body') ?? trigger?.parentElement ?? document.body
|
||||
);
|
||||
}
|
||||
|
||||
function loopInboundFocus(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key !== 'Tab') return;
|
||||
const first = event.currentTarget.querySelector<HTMLButtonElement>(
|
||||
'.ant-modal-close:not(:disabled)',
|
||||
);
|
||||
const last = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||
'.ant-modal-footer button:not(:disabled)',
|
||||
),
|
||||
).at(-1);
|
||||
if (first && last && event.target === (event.shiftKey ? first : last)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
}
|
||||
}
|
||||
|
||||
function isValidShareAddrInput(value: string): boolean {
|
||||
const v = value.trim();
|
||||
if (v.length === 0) return true;
|
||||
@@ -713,7 +677,7 @@ export default function InboundFormModal({
|
||||
)}
|
||||
|
||||
<FormField name="protocol" label={t('pages.inbounds.protocol')}>
|
||||
<NativeSelect id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
|
||||
<Select id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
@@ -731,7 +695,7 @@ export default function InboundFormModal({
|
||||
label={t('pages.inbounds.form.shareAddrStrategy')}
|
||||
tooltip={t('pages.inbounds.form.shareAddrStrategyHelp')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={SHARE_ADDR_STRATEGIES.filter(
|
||||
(strategy) => strategy !== 'node' || nodeShareOptionAvailable,
|
||||
).map((strategy) => ({
|
||||
@@ -816,7 +780,7 @@ export default function InboundFormModal({
|
||||
</Form.Item>
|
||||
|
||||
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={TRAFFIC_RESETS.map((r) => ({
|
||||
value: r,
|
||||
label: t(`pages.inbounds.periodicTrafficReset.${r}`),
|
||||
@@ -994,7 +958,7 @@ export default function InboundFormModal({
|
||||
<>
|
||||
{hasSelectableTransport && (
|
||||
<Form.Item htmlFor={`${uid}-network`} label={t('transmission')}>
|
||||
<NativeSelect
|
||||
<Select
|
||||
id={`${uid}-network`}
|
||||
style={{ width: '75%' }}
|
||||
value={network}
|
||||
@@ -1070,11 +1034,8 @@ export default function InboundFormModal({
|
||||
|
||||
const securityTab = (
|
||||
<>
|
||||
<Form.Item
|
||||
label={<span id={`${uid}-security-label`}>{t('pages.inbounds.securityTab')}</span>}
|
||||
>
|
||||
<Form.Item label={t('pages.inbounds.securityTab')}>
|
||||
<Radio.Group
|
||||
aria-labelledby={`${uid}-security-label`}
|
||||
value={security}
|
||||
buttonStyle="solid"
|
||||
disabled={!tlsOk}
|
||||
@@ -1213,7 +1174,7 @@ export default function InboundFormModal({
|
||||
const sniffingTab = <SniffingTab />;
|
||||
|
||||
return (
|
||||
<ConfigProvider virtual={false} getPopupContainer={inboundPopupContainer}>
|
||||
<>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<Modal
|
||||
@@ -1227,11 +1188,6 @@ export default function InboundFormModal({
|
||||
onOk={submit}
|
||||
onCancel={onClose}
|
||||
destroyOnHidden
|
||||
modalRender={(content) => (
|
||||
<div role="presentation" onKeyDown={loopInboundFocus}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<Form
|
||||
@@ -1317,6 +1273,6 @@ export default function InboundFormModal({
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
</ConfigProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Switch } from 'antd';
|
||||
import { Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
@@ -61,7 +60,7 @@ export default function HysteriaFields() {
|
||||
{masq && (
|
||||
<>
|
||||
<FormField label={t('pages.inbounds.form.type')} name={[...MASQ_PATH, 'type']}>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'default (404 page)' },
|
||||
{ value: 'proxy', label: 'proxy (reverse proxy)' },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, Switch } from 'antd';
|
||||
import { Input, Select, Switch } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import AccountsList from './accounts-list';
|
||||
@@ -11,7 +10,7 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
|
||||
<>
|
||||
<AccountsList />
|
||||
<FormField name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'noauth', label: 'noauth' },
|
||||
{ value: 'password', label: 'password' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, Space, Switch } from 'antd';
|
||||
import { Button, Form, Input, Select, Space, Switch } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
@@ -26,9 +25,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
|
||||
setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
|
||||
}}
|
||||
>
|
||||
<NativeSelect
|
||||
options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))}
|
||||
/>
|
||||
<Select options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))} />
|
||||
</FormField>
|
||||
{isSSWith2022 && (
|
||||
<Form.Item htmlFor={`${uid}-password`} label={t('password')}>
|
||||
@@ -51,7 +48,7 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
|
||||
</Form.Item>
|
||||
)}
|
||||
<FormField name={['settings', 'network']} label={t('pages.inbounds.network')}>
|
||||
<NativeSelect
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'tcp,udp', label: 'TCP, UDP' },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useState, useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -69,7 +68,7 @@ export default function TuicFields() {
|
||||
if (obj.webKeyFile) {
|
||||
setValue('settings.server.private_key', obj.webKeyFile);
|
||||
}
|
||||
messageApi.success(t('success'));
|
||||
messageApi.success(t('pages.inbounds.setSuccess'));
|
||||
} catch {
|
||||
messageApi.error(t('somethingWentWrong'));
|
||||
} finally {
|
||||
@@ -112,7 +111,7 @@ export default function TuicFields() {
|
||||
label={t('pages.xray.tuic.logLevel')}
|
||||
tooltip={t('pages.xray.tuic.logLevelHint')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Info', value: 'info' },
|
||||
{ label: 'Warn', value: 'warn' },
|
||||
@@ -200,7 +199,7 @@ export default function TuicFields() {
|
||||
label={t('pages.xray.tuic.congestionControl')}
|
||||
tooltip={t('pages.xray.tuic.congestionControlHint')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'BBR', value: 'bbr' },
|
||||
{ label: 'CUBIC', value: 'cubic' },
|
||||
@@ -245,7 +244,7 @@ export default function TuicFields() {
|
||||
label={t('pages.xray.tuic.udpRelayMode')}
|
||||
tooltip={t('pages.xray.tuic.udpRelayModeHint')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Native (Recommended)', value: 'native' },
|
||||
{ label: 'QUIC', value: 'quic' },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input, InputNumber, Switch } from 'antd';
|
||||
import { Input, InputNumber, Select, Switch } from 'antd';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
@@ -22,7 +21,7 @@ export default function TunnelFields() {
|
||||
name={['settings', 'allowedNetwork']}
|
||||
label={t('pages.inbounds.form.allowedNetwork')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'tcp,udp', label: 'TCP, UDP' },
|
||||
{ value: 'tcp', label: 'TCP' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useState, useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Space, Typography } from 'antd';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
@@ -51,7 +50,7 @@ export default function VlessFields({
|
||||
</FormField>
|
||||
<Form.Item htmlFor={`${uid}-authKind`} label={t('pages.inbounds.vlessAuthGenerate')}>
|
||||
<Space size={8} wrap>
|
||||
<NativeSelect
|
||||
<Select
|
||||
id={`${uid}-authKind`}
|
||||
value={authKind}
|
||||
onChange={(v) => setAuthKind(v)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useState, useId } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -113,9 +112,7 @@ export default function RealityForm({
|
||||
name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
|
||||
label="uTLS"
|
||||
>
|
||||
<NativeSelect
|
||||
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
||||
/>
|
||||
<Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} />
|
||||
</FormField>
|
||||
<Form.Item
|
||||
htmlFor={`${uid}-target`}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -155,7 +154,7 @@ function CertRow({
|
||||
name={['streamSettings', 'tlsSettings', 'certificates', index, 'usage']}
|
||||
label={t('pages.inbounds.form.usageOption')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
|
||||
/>
|
||||
@@ -213,7 +212,7 @@ function EchSockoptSection() {
|
||||
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
|
||||
label={t('pages.xray.wireguard.domainStrategy')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -355,14 +354,14 @@ export default function TlsForm({
|
||||
<Form.Item htmlFor={`${uid}-minVersion`} label={t('pages.inbounds.form.minMaxVersion')}>
|
||||
<Space.Compact block>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
|
||||
<NativeSelect
|
||||
<Select
|
||||
id={`${uid}-minVersion`}
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
|
||||
<NativeSelect
|
||||
<Select
|
||||
aria-label={t('pages.inbounds.form.maxVersion')}
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
@@ -371,7 +370,7 @@ export default function TlsForm({
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']} label="uTLS">
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'None' },
|
||||
...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
|
||||
@@ -208,13 +207,13 @@ export default function SockoptForm({
|
||||
name={['streamSettings', 'sockopt', 'tcpcongestion']}
|
||||
label={t('pages.inbounds.form.tcpCongestion')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
|
||||
<NativeSelect
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={[
|
||||
{ value: 'off', label: 'Off' },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutoComplete, Input, InputNumber, Switch } from 'antd';
|
||||
import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HeaderMapEditor } from '@/components/form';
|
||||
@@ -82,7 +81,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'mode']}
|
||||
label={t('pages.inbounds.info.mode')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
style={{ width: '50%' }}
|
||||
options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
|
||||
value: m,
|
||||
@@ -150,7 +149,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
|
||||
label={t('pages.inbounds.form.uplinkHttpMethod')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (POST)' },
|
||||
{ value: 'POST', label: 'POST' },
|
||||
@@ -188,7 +187,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
|
||||
label={t('pages.inbounds.form.paddingPlacement')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (queryInHeader)' },
|
||||
{ value: 'queryInHeader', label: 'queryInHeader' },
|
||||
@@ -202,7 +201,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
|
||||
label={t('pages.inbounds.form.paddingMethod')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (repeat-x)' },
|
||||
{ value: 'repeat-x', label: 'repeat-x' },
|
||||
@@ -216,7 +215,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
|
||||
label={t('pages.inbounds.form.sessionPlacement')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (path)' },
|
||||
{ value: 'path', label: 'path' },
|
||||
@@ -260,7 +259,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
|
||||
label={t('pages.inbounds.form.sequencePlacement')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (path)' },
|
||||
{ value: 'path', label: 'path' },
|
||||
@@ -284,7 +283,7 @@ export default function XhttpForm() {
|
||||
name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
|
||||
label={t('pages.inbounds.form.uplinkDataPlacement')}
|
||||
>
|
||||
<NativeSelect
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Default (auto)' },
|
||||
{ value: 'auto', label: 'auto' },
|
||||
|
||||
@@ -169,7 +169,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }:
|
||||
if (!token) return;
|
||||
const ok = await ClipboardManager.copyText(token);
|
||||
if (ok) messageApi.success(t('copySuccess'));
|
||||
else messageApi.error(t('copyFail'));
|
||||
else messageApi.error(t('copyFail') ?? 'Copy failed');
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
|
||||
@@ -291,7 +291,7 @@ export default function WarpModal({
|
||||
interval: methods.getValues('updateInterval'),
|
||||
});
|
||||
if (msg?.success) {
|
||||
messageApi.success(t('pages.settings.toasts.modifySettings'));
|
||||
messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -319,7 +319,6 @@ 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(),
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
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');
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, fireEvent, waitFor, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import ClientFormModal from '@/pages/clients/ClientFormModal';
|
||||
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
|
||||
|
||||
const VLESS_INBOUND = {
|
||||
id: 7,
|
||||
port: 10443,
|
||||
protocol: 'vless',
|
||||
tag: 'in-10443-tcp',
|
||||
enable: true,
|
||||
} as unknown as InboundOption;
|
||||
|
||||
const CLIENT = {
|
||||
email: 'reverse-user',
|
||||
uuid: '11111111-1111-1111-1111-111111111111',
|
||||
subId: 'subid-reverse',
|
||||
enable: true,
|
||||
reverse: { tag: 'back' },
|
||||
} as unknown as ClientRecord;
|
||||
|
||||
async function saveEdited(edit?: (input: HTMLInputElement) => void) {
|
||||
const save = vi.fn().mockResolvedValue({ success: true });
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<ClientFormModal
|
||||
open
|
||||
mode="edit"
|
||||
client={CLIENT}
|
||||
inbounds={[VLESS_INBOUND]}
|
||||
attachedIds={[7]}
|
||||
save={save}
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
const input = (await screen.findByPlaceholderText('Optional reverse tag')) as HTMLInputElement;
|
||||
await waitFor(() => expect(input.value).toBe('back'));
|
||||
edit?.(input);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /save/i }));
|
||||
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||
return save.mock.calls[0][0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// A cleared tag goes out as an explicit null rather than a missing key, which a
|
||||
// partial update would read as "keep the stored tag".
|
||||
describe('ClientFormModal — reverse tag', () => {
|
||||
it('sends an explicit null when the operator clears the tag', async () => {
|
||||
const payload = await saveEdited((input) => fireEvent.change(input, { target: { value: '' } }));
|
||||
expect(payload).toHaveProperty('reverse', null);
|
||||
});
|
||||
|
||||
it('round-trips an unchanged tag', async () => {
|
||||
const payload = await saveEdited();
|
||||
expect(payload.reverse).toEqual({ tag: 'back' });
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -82,15 +82,14 @@ describe('i18n keys', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('every literal t() key is an en-US string', () => {
|
||||
const leaves = new Set(enKeys);
|
||||
const literalKeys = new Set(
|
||||
Array.from(blob.matchAll(/\bt\(\s*(['"`])([\w.-]+)\1\s*[,)]/g), (m) => m[2]),
|
||||
);
|
||||
const unresolved = [...literalKeys].filter((key) => !leaves.has(key));
|
||||
it('no literal t() key resolves to an object', () => {
|
||||
const objectKeys = Array.from(
|
||||
blob.matchAll(/\bt\(\s*(['"`])([\w.-]+)\1\s*[,)]/g),
|
||||
(m) => m[2],
|
||||
).filter((key) => enKeys.some((leaf) => leaf.startsWith(`${key}.`)));
|
||||
expect(
|
||||
unresolved,
|
||||
`t() keys that are not an en-US string (a missing key renders raw, a group renders "key '…' returned an object instead of string."):\n ${unresolved.join('\n ')}`,
|
||||
objectKeys,
|
||||
`t() keys naming a group, rendered as "key '…' returned an object instead of string.":\n ${objectKeys.join('\n ')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ConfigProvider } from 'antd';
|
||||
|
||||
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
|
||||
import { DBInbound } from '@/models/dbinbound';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function renderInbound(mode: 'add' | 'edit' = 'add') {
|
||||
const inbound = new DBInbound({
|
||||
id: 41,
|
||||
protocol: 'vless',
|
||||
enable: true,
|
||||
port: 14443,
|
||||
settings: { clients: [], decryption: 'none', encryption: 'none' },
|
||||
streamSettings: { network: 'tcp', security: 'none', tcpSettings: {} },
|
||||
});
|
||||
renderWithProviders(
|
||||
<ConfigProvider theme={{ token: { motion: false } }}>
|
||||
<InboundFormModal
|
||||
open
|
||||
mode={mode}
|
||||
dbInbound={mode === 'edit' ? inbound : null}
|
||||
dbInbounds={[]}
|
||||
availableNodes={[]}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
</ConfigProvider>,
|
||||
);
|
||||
return userEvent.setup();
|
||||
}
|
||||
|
||||
describe('Inbound screen-reader interaction', () => {
|
||||
it('exposes the selected protocol as the closed control value', () => {
|
||||
renderInbound();
|
||||
expect(screen.getByRole('combobox', { name: 'Protocol' })).toHaveDisplayValue('vless');
|
||||
});
|
||||
it('exposes all visible protocol options as selectable items within its dialog', async () => {
|
||||
const user = renderInbound();
|
||||
const dialog = screen.getByRole('dialog', { name: 'Add Inbound' });
|
||||
const protocol = within(dialog).getByRole('combobox', { name: 'Protocol' });
|
||||
const shadowsocks = within(protocol).getByRole('option', { name: 'shadowsocks' });
|
||||
expect(within(protocol).getByRole('option', { name: 'tuic' })).toBeInTheDocument();
|
||||
expect(
|
||||
within(protocol).getByRole('option', { name: 'vless', selected: true }),
|
||||
).toBeInTheDocument();
|
||||
await user.selectOptions(protocol, shadowsocks);
|
||||
expect(protocol).toHaveDisplayValue('shadowsocks');
|
||||
await user.click(screen.getByRole('tab', { name: 'Protocol' }));
|
||||
expect(screen.getByRole('combobox', { name: 'Encryption method' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('selects transport by its option role and submits the actual create payload', async () => {
|
||||
const post = vi.mocked(HttpUtil.post).mockClear();
|
||||
const user = renderInbound();
|
||||
await user.click(screen.getByRole('tab', { name: 'Stream' }));
|
||||
const transport = screen.getByRole('combobox', { name: 'Transmission' });
|
||||
await user.selectOptions(
|
||||
transport,
|
||||
within(transport).getByRole('option', { name: 'WebSocket' }),
|
||||
);
|
||||
const path = screen.getByRole('textbox', { name: 'Path' });
|
||||
await user.clear(path);
|
||||
await user.type(path, '/accessible');
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledWith('/panel/api/inbounds/add', expect.anything()),
|
||||
);
|
||||
const payload = post.mock.calls.find(
|
||||
([url]) => url === '/panel/api/inbounds/add',
|
||||
)![1] as Record<string, unknown>;
|
||||
expect(JSON.parse(String(payload.streamSettings))).toMatchObject({
|
||||
network: 'ws',
|
||||
security: 'none',
|
||||
wsSettings: { path: '/accessible' },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the disabled protocol in edit mode and exposes a named security group', async () => {
|
||||
const user = renderInbound('edit');
|
||||
expect(screen.getByRole('combobox', { name: 'Protocol' })).toBeDisabled();
|
||||
await user.click(screen.getByRole('tab', { name: 'Security' }));
|
||||
const security = screen.getByRole('radiogroup', { name: 'Security' });
|
||||
expect(
|
||||
within(security)
|
||||
.getAllByRole('radio')
|
||||
.map((radio) => (radio as HTMLInputElement).value),
|
||||
).toEqual(['none', 'tls', 'reality']);
|
||||
});
|
||||
|
||||
it('preserves edit serialization after a keyboard-accessible transport change', async () => {
|
||||
const post = vi.mocked(HttpUtil.post).mockClear();
|
||||
const user = renderInbound('edit');
|
||||
await user.click(screen.getByRole('tab', { name: 'Stream' }));
|
||||
const transport = screen.getByRole('combobox', { name: 'Transmission' });
|
||||
await user.selectOptions(transport, within(transport).getByRole('option', { name: 'gRPC' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Save Changes' }));
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledWith('/panel/api/inbounds/update/41', expect.anything()),
|
||||
);
|
||||
const payload = post.mock.calls.find(
|
||||
([url]) => url === '/panel/api/inbounds/update/41',
|
||||
)![1] as Record<string, unknown>;
|
||||
expect(payload).toMatchObject({ protocol: 'vless', port: 14443, enable: true });
|
||||
expect(JSON.parse(String(payload.streamSettings))).toMatchObject({
|
||||
network: 'grpc',
|
||||
security: 'none',
|
||||
grpcSettings: { serviceName: '' },
|
||||
});
|
||||
expect(JSON.parse(String(payload.settings))).not.toHaveProperty('clients');
|
||||
});
|
||||
|
||||
it('reveals and describes a validation error on a hidden tab without submitting', async () => {
|
||||
const post = vi.mocked(HttpUtil.post).mockClear();
|
||||
const user = renderInbound();
|
||||
const strategy = screen.getByRole('combobox', { name: 'Share address strategy' });
|
||||
await user.selectOptions(strategy, within(strategy).getByRole('option', { name: 'Custom' }));
|
||||
const address = screen.getByRole('textbox', { name: 'Custom share address' });
|
||||
await user.type(address, 'https://example.test/path');
|
||||
await user.click(screen.getByRole('tab', { name: 'Stream' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await waitFor(() => expect(address).toBeVisible());
|
||||
expect(address).toHaveAttribute('aria-invalid', 'true');
|
||||
expect(address).toHaveAccessibleDescription(expect.stringContaining('without a scheme'));
|
||||
await waitFor(() => expect(address).toHaveFocus());
|
||||
expect(post.mock.calls.filter(([url]) => url === '/panel/api/inbounds/add')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import { describe, it, expect, onTestFinished, vi } from 'vitest';
|
||||
import { screen, act, render, cleanup, fireEvent, waitFor, within } from '@testing-library/react';
|
||||
import { screen, act, render, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
import InboundFormModal from '@/pages/inbounds/form/InboundFormModal';
|
||||
import { DBInbound } from '@/models/dbinbound';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import { AntdRootConfig } from '@/i18n/AntdRootConfig';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
import { Protocols } from '@/schemas/primitives';
|
||||
import {
|
||||
renderWithProviders,
|
||||
fieldLabels,
|
||||
@@ -41,8 +40,20 @@ function renderModal() {
|
||||
);
|
||||
}
|
||||
|
||||
function chooseProtocol(optionText: string) {
|
||||
chooseSelectOption('protocol', optionText);
|
||||
// The protocol dropdown is virtualized, so jsdom renders only its first options; step
|
||||
// the active option by keyboard from the current protocol in the options' own order.
|
||||
function chooseVirtualizedProtocol(optionText: string) {
|
||||
const input = document.getElementById('protocol') as HTMLElement;
|
||||
const select = input.closest('.ant-select') as HTMLElement;
|
||||
const current = (select.textContent ?? '').trim();
|
||||
const order = Object.values(Protocols) as string[];
|
||||
const steps = order.indexOf(optionText) - order.indexOf(current);
|
||||
if (order.indexOf(optionText) < 0 || order.indexOf(current) < 0 || steps < 0) {
|
||||
throw new Error(`cannot step from '${current}' to '${optionText}'`);
|
||||
}
|
||||
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
|
||||
for (let i = 0; i < steps; i++) fireEvent.keyDown(input, { key: 'ArrowDown', keyCode: 40 });
|
||||
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 });
|
||||
}
|
||||
|
||||
function primaryButton(): HTMLElement {
|
||||
@@ -104,6 +115,12 @@ async function renderCloneLikeEdit(dbInbound: DBInbound) {
|
||||
}
|
||||
|
||||
describe('InboundFormModal', () => {
|
||||
it('renders add mode without crashing', () => {
|
||||
renderModal();
|
||||
expect(document.querySelector('.ant-modal')).toBeTruthy();
|
||||
expect(fieldLabels().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('field structure differs per protocol (not a vacuous snapshot loop)', async () => {
|
||||
renderModal();
|
||||
const protocols = listSelectOptions('protocol');
|
||||
@@ -126,8 +143,9 @@ describe('InboundFormModal', () => {
|
||||
expect(distinctShapes.size).toBeGreaterThan(1);
|
||||
|
||||
// Spot-check a protocol-distinguishing field that must appear after the switch.
|
||||
expect(labelsByProto.shadowsocks).toContain('Encryption method');
|
||||
expect(labelsByProto.vless).not.toContain('Encryption method');
|
||||
if (labelsByProto.shadowsocks) {
|
||||
expect(labelsByProto.shadowsocks).toContain('Encryption method');
|
||||
}
|
||||
}, 30000); // iterates every protocol, re-rendering a heavy modal each time — slow on CI runners
|
||||
|
||||
it('preserves custom share address strategy when editing a local inbound', async () => {
|
||||
@@ -223,31 +241,27 @@ describe('InboundFormModal', () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
};
|
||||
const selectedStrategy = () =>
|
||||
within(screen.getByRole('combobox', { name: 'Share address strategy' })).getByRole('option', {
|
||||
selected: true,
|
||||
}).textContent;
|
||||
const strategyItem = (title: string) =>
|
||||
document.querySelector(`.ant-select-content[title="${title}"]`);
|
||||
const modal = (nodes: never[], fetched: boolean) => (
|
||||
<AntdRootConfig>
|
||||
<ThemeProvider>
|
||||
<InboundFormModal
|
||||
open
|
||||
mode="edit"
|
||||
dbInbound={buildInbound()}
|
||||
dbInbounds={[]}
|
||||
availableNodes={nodes}
|
||||
availableNodesFetched={fetched}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</AntdRootConfig>
|
||||
<ThemeProvider>
|
||||
<InboundFormModal
|
||||
open
|
||||
mode="edit"
|
||||
dbInbound={buildInbound()}
|
||||
dbInbounds={[]}
|
||||
availableNodes={nodes}
|
||||
availableNodesFetched={fetched}
|
||||
onClose={() => {}}
|
||||
onSaved={() => {}}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Baseline: nodes already loaded, so the node option is offered and selected.
|
||||
render(modal([node], true));
|
||||
await flush();
|
||||
expect(selectedStrategy()).toBe('Node address');
|
||||
expect(strategyItem('Node address')).toBeTruthy();
|
||||
cleanup();
|
||||
|
||||
// Race: the modal mounts before /nodes/list resolves (empty placeholder),
|
||||
@@ -257,7 +271,8 @@ describe('InboundFormModal', () => {
|
||||
await flush();
|
||||
rerender(modal([node], true));
|
||||
await flush();
|
||||
expect(selectedStrategy()).toBe('Node address');
|
||||
expect(strategyItem('Node address')).toBeTruthy();
|
||||
expect(strategyItem('Inbound listen')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('surfaces a Reality validation error and switches to its tab', async () => {
|
||||
@@ -329,9 +344,16 @@ describe('InboundFormModal', () => {
|
||||
await renderCloneLikeEdit(cloneLikeVlessInbound('example.com:443'));
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Stream' }));
|
||||
const transport = screen.getByRole('combobox', { name: 'Transmission' });
|
||||
const option = within(transport).getByRole('option', { name: 'XDRIVE' }) as HTMLOptionElement;
|
||||
fireEvent.change(transport, { target: { value: option.value } });
|
||||
const transmission = Array.from(document.querySelectorAll('.ant-form-item')).find(
|
||||
(el) =>
|
||||
el.querySelector('.ant-form-item-label label')?.textContent?.trim() === 'Transmission',
|
||||
);
|
||||
const select = transmission?.querySelector('.ant-select') as HTMLElement;
|
||||
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
|
||||
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
|
||||
(o) => (o.getAttribute('title') ?? o.textContent ?? '').trim() === 'XDRIVE',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(option);
|
||||
for (const [label, value] of [
|
||||
['Remote folder', 'xray-tunnel'],
|
||||
['Client ID', 'id.apps.googleusercontent.com'],
|
||||
@@ -365,7 +387,7 @@ describe('InboundFormModal', () => {
|
||||
post.mockClear();
|
||||
renderModal();
|
||||
|
||||
chooseProtocol('masque');
|
||||
chooseVirtualizedProtocol('masque');
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { NativeSelect } from '@/components/form/NativeSelect';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
|
||||
const options = [
|
||||
{ value: 7, label: 'Number seven' },
|
||||
{ value: '7', label: 'Text seven' },
|
||||
{ value: 0, label: 'Zero' },
|
||||
{ value: '', label: 'None' },
|
||||
{ value: 9, label: 'Unavailable', disabled: true },
|
||||
];
|
||||
|
||||
function Harness({
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
}: {
|
||||
onSubmit: (values: { choice: string | number }) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const methods = useForm({ defaultValues: { choice: 7 as string | number } });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<FormField name="choice" label="Choice">
|
||||
<NativeSelect<string | number> disabled={disabled} options={options} />
|
||||
</FormField>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('NativeSelect form values', () => {
|
||||
it('preserves numeric, string and empty values through RHF instead of coercing to DOM strings', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<Harness onSubmit={onSubmit} />);
|
||||
const user = userEvent.setup();
|
||||
const select = screen.getByRole('combobox', { name: 'Choice' });
|
||||
expect(select).toHaveDisplayValue('Number seven');
|
||||
for (const [label, value] of [
|
||||
['Text seven', '7'],
|
||||
['Zero', 0],
|
||||
['None', ''],
|
||||
] as const) {
|
||||
await user.selectOptions(select, screen.getByRole('option', { name: label }));
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() =>
|
||||
expect(onSubmit).toHaveBeenLastCalledWith({ choice: value }, expect.anything()),
|
||||
);
|
||||
}
|
||||
await user.selectOptions(select, screen.getByRole('option', { name: 'Unavailable' }));
|
||||
expect(select).toHaveDisplayValue('None');
|
||||
});
|
||||
|
||||
it('keeps disabled values intact and skips the control during tab navigation', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<Harness disabled onSubmit={onSubmit} />);
|
||||
const user = userEvent.setup();
|
||||
await user.tab();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toHaveFocus();
|
||||
await user.keyboard('{Enter}');
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ choice: 7 }, expect.anything()));
|
||||
});
|
||||
|
||||
it('preserves an unlisted edit value and follows reordered dynamic options without changing it', async () => {
|
||||
const onChange = vi.fn();
|
||||
const view = render(
|
||||
<NativeSelect
|
||||
aria-label="Choice"
|
||||
value="saved-custom"
|
||||
options={[{ value: 'default', label: 'Default' }]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveDisplayValue('saved-custom');
|
||||
view.rerender(
|
||||
<NativeSelect
|
||||
aria-label="Choice"
|
||||
value="saved-custom"
|
||||
options={[
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'saved-custom', label: 'Saved choice' },
|
||||
]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('combobox', { name: 'Choice' })).toHaveDisplayValue('Saved choice');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
await userEvent
|
||||
.setup()
|
||||
.selectOptions(screen.getByRole('combobox'), screen.getByRole('option', { name: 'New' }));
|
||||
expect(onChange).toHaveBeenCalledWith('new');
|
||||
});
|
||||
});
|
||||
@@ -31,13 +31,6 @@ function formItemByLabel(label: string): Element | undefined {
|
||||
}
|
||||
|
||||
function chooseOptionByLabel(label: string, optionText: string) {
|
||||
const native = formItemByLabel(label)?.querySelector('select');
|
||||
if (native) {
|
||||
const option = Array.from(native.options).find((item) => item.text === optionText);
|
||||
if (!option) throw new Error(`Option '${optionText}' not found for ${label}`);
|
||||
fireEvent.change(native, { target: { value: option.value } });
|
||||
return;
|
||||
}
|
||||
const select = formItemByLabel(label)?.querySelector('.ant-select');
|
||||
if (!select) throw new Error(`${label} select not found`);
|
||||
fireEvent.mouseDown(select.querySelector('.ant-select-selector') ?? select);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -94,10 +93,6 @@ describe('FormField', () => {
|
||||
fireEvent.click(screen.getByText('Save'));
|
||||
|
||||
await screen.findByText('email-required');
|
||||
expect(screen.getByRole('textbox', { name: 'email' })).toHaveAccessibleDescription(
|
||||
'email-required',
|
||||
);
|
||||
expect(screen.getByRole('textbox', { name: 'email' })).toHaveAttribute('aria-invalid', 'true');
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,12 +49,6 @@ function openDropdownOptions(): string[] {
|
||||
}
|
||||
|
||||
export function listSelectOptions(fieldId: string): string[] {
|
||||
const native = document.getElementById(fieldId);
|
||||
if (native instanceof HTMLSelectElement) {
|
||||
return Array.from(native.options)
|
||||
.filter((option) => !option.hidden)
|
||||
.map((option) => option.text);
|
||||
}
|
||||
const select = selectRootForField(fieldId);
|
||||
openSelect(select);
|
||||
const opts = openDropdownOptions();
|
||||
@@ -63,13 +57,6 @@ export function listSelectOptions(fieldId: string): string[] {
|
||||
}
|
||||
|
||||
export function chooseSelectOption(fieldId: string, optionText: string) {
|
||||
const native = document.getElementById(fieldId);
|
||||
if (native instanceof HTMLSelectElement) {
|
||||
const option = Array.from(native.options).find((item) => item.text === optionText);
|
||||
if (!option) throw new Error(`Option '${optionText}' not found for field '${fieldId}'`);
|
||||
fireEvent.change(native, { target: { value: option.value } });
|
||||
return;
|
||||
}
|
||||
const select = selectRootForField(fieldId);
|
||||
openSelect(select);
|
||||
const option = Array.from(document.querySelectorAll('.ant-select-item-option')).find(
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium, devices } from 'playwright';
|
||||
|
||||
const base = process.argv[2] || 'http://127.0.0.1:6006';
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
try {
|
||||
for (const mobile of [false, true]) {
|
||||
const context = await browser.newContext(mobile ? devices['Pixel 7'] : {});
|
||||
const page = await context.newPage();
|
||||
const requests = [];
|
||||
await page.route('**/panel/api/**', async (route) => {
|
||||
const request = route.request();
|
||||
if (/\/inbounds\/(add|update\/41)$/.test(new URL(request.url()).pathname)) {
|
||||
requests.push({ url: new URL(request.url()).pathname, payload: request.postDataJSON() });
|
||||
}
|
||||
await route.fulfill({
|
||||
json: { success: true, msg: '', obj: { id: 41, datepicker: 'gregorian' } },
|
||||
});
|
||||
});
|
||||
|
||||
async function tabTo(locator) {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (await locator.evaluate((element) => element === document.activeElement)) return;
|
||||
await page.keyboard.press('Tab');
|
||||
const escaped = await page.evaluate(() => {
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
return dialog && !dialog.contains(document.activeElement)
|
||||
? document.activeElement.outerHTML
|
||||
: '';
|
||||
});
|
||||
assert.ok(!escaped, `Tab escaped the open inbound dialog: ${escaped.slice(0, 200)}`);
|
||||
}
|
||||
assert.fail('Control was not reachable with Tab');
|
||||
}
|
||||
|
||||
async function activateTab(dialog, name) {
|
||||
const tabs = dialog.getByRole('tab');
|
||||
const names = await tabs.allTextContents();
|
||||
const current = dialog.getByRole('tab', { selected: true });
|
||||
const from = names.indexOf(await current.textContent());
|
||||
const to = names.findIndex((text) => text.endsWith(name));
|
||||
assert.ok(from >= 0 && to >= 0, `Missing named tab ${name}`);
|
||||
await tabTo(current);
|
||||
for (let i = 0; i < Math.abs(to - from); i++) {
|
||||
await page.keyboard.press(to > from ? 'ArrowRight' : 'ArrowLeft');
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
assert.equal(
|
||||
await dialog
|
||||
.getByRole('tab', { name: new RegExp(`${name}$`) })
|
||||
.getAttribute('aria-selected'),
|
||||
'true',
|
||||
);
|
||||
}
|
||||
|
||||
async function selectByKey(locator, key, label) {
|
||||
await tabTo(locator);
|
||||
if (key.length === 1) await page.keyboard.press(key);
|
||||
else await page.keyboard.type(key);
|
||||
assert.equal(await locator.evaluate((element) => element.selectedOptions[0].text), label);
|
||||
assert.ok(
|
||||
await locator.evaluate((element) => element === document.activeElement),
|
||||
'Selection lost focus',
|
||||
);
|
||||
}
|
||||
|
||||
for (const edit of [false, true]) {
|
||||
await page.goto(
|
||||
`${base}/iframe.html?id=inbounds-inboundformmodal--${edit ? 'edit' : 'create'}&viewMode=story`,
|
||||
);
|
||||
const opener = page.getByRole('button', {
|
||||
name: edit ? 'Edit inbound' : 'Add inbound',
|
||||
exact: true,
|
||||
});
|
||||
await opener.waitFor();
|
||||
await tabTo(opener);
|
||||
await page.keyboard.press('Enter');
|
||||
const dialog = page.getByRole('dialog', { name: edit ? 'Modify Inbound' : 'Add Inbound' });
|
||||
await dialog.waitFor();
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!document
|
||||
.querySelector('[role="dialog"]')
|
||||
?.getAnimations()
|
||||
.some((animation) => animation.playState === 'running'),
|
||||
);
|
||||
const protocol = dialog.getByRole('combobox', { name: 'Protocol', exact: true });
|
||||
if (edit) assert.ok(await protocol.isDisabled());
|
||||
else {
|
||||
await activateTab(dialog, 'Security');
|
||||
const group = dialog.getByRole('radiogroup', { name: 'Security' });
|
||||
await tabTo(group.getByRole('radio', { name: 'None' }));
|
||||
await page.keyboard.press('ArrowRight');
|
||||
assert.ok(await group.getByRole('radio', { name: 'TLS' }).isChecked());
|
||||
await page.keyboard.press('ArrowRight');
|
||||
assert.ok(await group.getByRole('radio', { name: 'Reality' }).isChecked());
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
assert.ok(await group.getByRole('radio', { name: 'None' }).isChecked());
|
||||
await activateTab(dialog, 'Basics');
|
||||
if (!mobile) {
|
||||
await tabTo(protocol);
|
||||
await page.keyboard.press('Alt+ArrowDown');
|
||||
await page.keyboard.press('Escape');
|
||||
assert.ok(await dialog.isVisible(), 'Canceling a native picker closed the dialog');
|
||||
}
|
||||
await selectByKey(protocol, 's', 'shadowsocks');
|
||||
await activateTab(dialog, 'Protocol');
|
||||
await selectByKey(
|
||||
dialog.getByRole('combobox', { name: 'Encryption method' }),
|
||||
'chacha20-ietf-poly1305',
|
||||
'chacha20-ietf-poly1305',
|
||||
);
|
||||
}
|
||||
await activateTab(dialog, 'Stream');
|
||||
await selectByKey(
|
||||
dialog.getByRole('combobox', { name: 'Transmission' }),
|
||||
edit ? 'g' : 'w',
|
||||
edit ? 'gRPC' : 'WebSocket',
|
||||
);
|
||||
const cdp = await context.newCDPSession(page);
|
||||
const ax = await cdp.send('Accessibility.getFullAXTree');
|
||||
const transport = ax.nodes.find(
|
||||
(node) => node.role?.value === 'combobox' && node.name?.value.trim() === 'Transmission',
|
||||
);
|
||||
assert.equal(transport?.value?.value, edit ? 'gRPC' : 'WebSocket');
|
||||
const submit = dialog.getByRole('button', {
|
||||
name: edit ? 'Save Changes' : 'Create',
|
||||
exact: true,
|
||||
});
|
||||
await tabTo(submit);
|
||||
await page.keyboard.press('Enter');
|
||||
await dialog.waitFor({ state: 'hidden' });
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.activeElement?.textContent === 'Add inbound' ||
|
||||
document.activeElement?.textContent === 'Edit inbound',
|
||||
);
|
||||
const saved = requests.at(-1);
|
||||
assert.equal(saved?.url, edit ? '/panel/api/inbounds/update/41' : '/panel/api/inbounds/add');
|
||||
assert.equal(saved.payload.protocol, 'shadowsocks');
|
||||
const settings = JSON.parse(saved.payload.settings);
|
||||
assert.equal(settings.method, 'chacha20-ietf-poly1305');
|
||||
assert.equal(JSON.parse(saved.payload.streamSettings).network, edit ? 'grpc' : 'ws');
|
||||
if (edit) assert.ok(!('clients' in settings), 'Editing must preserve server-owned clients');
|
||||
console.log(
|
||||
`PASS ${mobile ? 'mobile viewport' : 'desktop'} ${edit ? 'edit' : 'create'}: keyboard, AX value, payload, focus return`,
|
||||
);
|
||||
}
|
||||
await context.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -9,7 +9,7 @@ require (
|
||||
github.com/gin-contrib/sessions v1.1.3
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.15
|
||||
github.com/go-playground/validator/v10 v10.30.6
|
||||
github.com/go-playground/validator/v10 v10.30.5
|
||||
github.com/goccy/go-json v0.12.0
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/google/uuid v1.6.0
|
||||
|
||||
@@ -51,8 +51,8 @@ github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9
|
||||
github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
|
||||
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
|
||||
github.com/go-playground/validator/v10 v10.30.6 h1:E+mymlPNRzAMNz1FShrRvLeGWcprF5w305eLLgWieuo=
|
||||
github.com/go-playground/validator/v10 v10.30.6/go.mod h1:jPLL8SKCXGe/g87QmEyNw3NO28nmGt5GCnZnufm7rj4=
|
||||
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
|
||||
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
|
||||
github.com/goccy/go-json v0.12.0 h1:pD+oWouAiz4yruwXIt/iT+lOMeyhr1aTtxzaMP/RgVI=
|
||||
github.com/goccy/go-json v0.12.0/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
|
||||
@@ -105,10 +105,8 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
|
||||
"/inbounds/:id/subSortIndex": {http.MethodPost: {}},
|
||||
"/clients/add": {http.MethodPost: {}},
|
||||
"/clients/del/:email": {http.MethodPost: {}},
|
||||
"/clients/del": {http.MethodPost: {}},
|
||||
"/clients/:email/detach": {http.MethodPost: {}},
|
||||
"/clients/update/:email": {http.MethodPost: {}},
|
||||
"/clients/update": {http.MethodPost: {}},
|
||||
"/server/restartXrayService": {http.MethodPost: {}},
|
||||
"/server/getWebCertFiles": {http.MethodGet: {}},
|
||||
"/server/descendants": {http.MethodGet: {}},
|
||||
|
||||
@@ -53,7 +53,6 @@ func NewClientController(g *gin.RouterGroup) *ClientController {
|
||||
func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/list/paged", a.listPaged)
|
||||
g.GET("/get", a.get)
|
||||
g.GET("/get/:email", a.get)
|
||||
g.GET("/get/subId/:subId", a.getBySubId)
|
||||
g.GET("/get/tgId/:tgId", a.getByTgId)
|
||||
@@ -64,9 +63,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
||||
|
||||
g.POST("/add", a.create)
|
||||
g.POST("/renewalPreview", a.renewalPreview)
|
||||
g.POST("/update", a.update)
|
||||
g.POST("/update/:email", a.update)
|
||||
g.POST("/del", a.delete)
|
||||
g.POST("/del/:email", a.delete)
|
||||
g.POST("/:email/attach", a.attach)
|
||||
g.POST("/:email/detach", a.detach)
|
||||
@@ -162,24 +159,8 @@ func (a *ClientController) buildClientPayload(rec *model.ClientRecord) (gin.H, e
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Query aliases keep legacy dot-segment names addressable without changing path clients.
|
||||
func clientEmailParam(c *gin.Context) (string, bool) {
|
||||
email := c.Param("email")
|
||||
if email == "" {
|
||||
email = c.Query("email")
|
||||
}
|
||||
if email == "" {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), errors.New("client email is required"))
|
||||
return "", false
|
||||
}
|
||||
return email, true
|
||||
}
|
||||
|
||||
func (a *ClientController) get(c *gin.Context) {
|
||||
email, ok := clientEmailParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
email := c.Param("email")
|
||||
rec, err := a.clientService.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
|
||||
@@ -263,17 +244,17 @@ func (a *ClientController) create(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *ClientController) update(c *gin.Context) {
|
||||
email, ok := clientEmailParam(c)
|
||||
if !ok {
|
||||
return
|
||||
email := c.Param("email")
|
||||
var req struct {
|
||||
model.Client
|
||||
LimitHwid int `json:"limitHwid"`
|
||||
}
|
||||
body, err := c.GetRawData()
|
||||
if err != nil {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
|
||||
needRestart, err := a.clientService.UpdateByEmailJSON(&a.inboundService, email, body, inboundFilter...)
|
||||
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
|
||||
// Flagged before the error check: a partly-applied edit leaves the change
|
||||
// committed on the inbounds that succeeded, and those still need the restart.
|
||||
if needRestart {
|
||||
@@ -292,10 +273,7 @@ func (a *ClientController) update(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *ClientController) delete(c *gin.Context) {
|
||||
email, ok := clientEmailParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
email := c.Param("email")
|
||||
keepTraffic := c.Query("keepTraffic") == "1"
|
||||
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
|
||||
// Flagged before the error check: a partly-applied delete already removed
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func seedLegacyDotClient(t *testing.T, email string) (*gin.Engine, model.ClientRecord, model.Inbound) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dir)
|
||||
dbtest.InitDB(t, filepath.Join(dir, "x-ui.db"))
|
||||
db := database.GetDB()
|
||||
rec := model.ClientRecord{Email: email, UUID: "11111111-2222-3333-4444-555555555555", SubID: "legacy-dot-sub", Enable: true}
|
||||
if err := db.Create(&rec).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := json.Marshal(map[string]any{
|
||||
"clients": []model.Client{*rec.ToClient()},
|
||||
"decryption": "none",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ib := model.Inbound{Tag: "legacy-dot", Port: 443, Protocol: model.VLESS, Settings: string(settings), StreamSettings: `{"network":"tcp","security":"none"}`}
|
||||
if err := db.Create(&ib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{Email: email, InboundId: ib.Id, Up: 111, Down: 222, Enable: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
|
||||
c.Next()
|
||||
})
|
||||
NewClientController(router.Group("/base/panel/api/clients"))
|
||||
return router, rec, ib
|
||||
}
|
||||
|
||||
func dotClientRequest(t *testing.T, router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(method, "/base/panel/api/clients/"+path, bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func assertDotClientSuccess(t *testing.T, w *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
var msg entity.Msg
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if w.Code != http.StatusOK || !msg.Success {
|
||||
t.Fatalf("status/body = %d/%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientQueryAliasRecoversLegacyDotName(t *testing.T) {
|
||||
for _, email := range []string{".", ".."} {
|
||||
t.Run(email, func(t *testing.T) {
|
||||
router, rec, ib := seedLegacyDotClient(t, email)
|
||||
query := "?email=" + url.QueryEscape(email)
|
||||
w := dotClientRequest(t, router, http.MethodGet, "get"+query, nil)
|
||||
assertDotClientSuccess(t, w)
|
||||
var full struct {
|
||||
Obj struct {
|
||||
Client model.ClientRecord `json:"client"`
|
||||
InboundIDs []int `json:"inboundIds"`
|
||||
UsedTraffic int64 `json:"usedTraffic"`
|
||||
} `json:"obj"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &full); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if full.Obj.Client.Email != email || full.Obj.Client.UUID != rec.UUID || len(full.Obj.InboundIDs) != 1 || full.Obj.InboundIDs[0] != ib.Id || full.Obj.UsedTraffic != 333 {
|
||||
t.Fatalf("unexpected legacy payload: %s", w.Body.String())
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.Email = "restored-client"
|
||||
w = dotClientRequest(t, router, http.MethodPost, "update"+query, updated)
|
||||
assertDotClientSuccess(t, w)
|
||||
db := database.GetDB()
|
||||
var after model.ClientRecord
|
||||
if err := db.First(&after, rec.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Email != updated.Email || after.UUID != rec.UUID || after.SubID != rec.SubID {
|
||||
t.Fatalf("rename changed identity: %+v", after)
|
||||
}
|
||||
if err := db.First(&ib, ib.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var settings struct {
|
||||
Clients []model.Client `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(settings.Clients) != 1 || settings.Clients[0].Email != updated.Email || settings.Clients[0].ID != rec.UUID {
|
||||
t.Fatalf("rename lost inbound credentials: %s", ib.Settings)
|
||||
}
|
||||
var traffic xray.ClientTraffic
|
||||
if err := db.Where("email = ?", updated.Email).First(&traffic).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if traffic.Up != 111 || traffic.Down != 222 {
|
||||
t.Fatalf("rename lost usage: %+v", traffic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientQueryAliasDeletesLegacyDotName(t *testing.T) {
|
||||
for _, email := range []string{".", ".."} {
|
||||
for _, keep := range []bool{false, true} {
|
||||
t.Run(email+"/keep="+strconv.FormatBool(keep), func(t *testing.T) {
|
||||
router, rec, ib := seedLegacyDotClient(t, email)
|
||||
path := "del?email=" + url.QueryEscape(email)
|
||||
if keep {
|
||||
path += "&keepTraffic=1"
|
||||
}
|
||||
w := dotClientRequest(t, router, http.MethodPost, path, nil)
|
||||
assertDotClientSuccess(t, w)
|
||||
db := database.GetDB()
|
||||
var count int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("client remains: count=%d err=%v", count, err)
|
||||
}
|
||||
if err := db.Model(&model.ClientInbound{}).Where("client_id = ?", rec.Id).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("attachment remains: count=%d err=%v", count, err)
|
||||
}
|
||||
if err := db.First(&ib, ib.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var settings struct {
|
||||
Clients []model.Client `json:"clients"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil || len(settings.Clients) != 0 {
|
||||
t.Fatalf("inbound client remains: %s err=%v", ib.Settings, err)
|
||||
}
|
||||
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want int64
|
||||
if keep {
|
||||
want = 1
|
||||
}
|
||||
if count != want {
|
||||
t.Fatalf("retained traffic count=%d keep=%v", count, keep)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientQueryAliasRequiresEmail(t *testing.T) {
|
||||
router, _, _ := seedLegacyDotClient(t, ".")
|
||||
for _, route := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "get"}, {http.MethodPost, "update"}, {http.MethodPost, "del"},
|
||||
} {
|
||||
w := dotClientRequest(t, router, route.method, route.path, map[string]any{"email": "must-not-be-used-as-target"})
|
||||
var msg entity.Msg
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg.Success || !strings.Contains(msg.Msg, "client email is required") {
|
||||
t.Fatalf("missing query accepted: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPathEmailTakesPrecedenceOverQuery(t *testing.T) {
|
||||
router, legacy, _ := seedLegacyDotClient(t, ".")
|
||||
normal := model.ClientRecord{Email: "audit-client", UUID: "normal-uuid", SubID: "normal-sub", Enable: true}
|
||||
db := database.GetDB()
|
||||
if err := db.Create(&normal).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := dotClientRequest(t, router, http.MethodPost, "del/audit-client?email=.", nil)
|
||||
assertDotClientSuccess(t, w)
|
||||
var count int64
|
||||
if err := db.Model(&model.ClientRecord{}).Where("id = ?", normal.Id).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("path client remains: count=%d err=%v", count, err)
|
||||
}
|
||||
if err := db.Model(&model.ClientRecord{}).Where("id = ?", legacy.Id).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("query overrode the path identity: count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -152,43 +152,3 @@ func TestClientGetBySubIDRequiresAdmin(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientQueryAliasTokenScopes(t *testing.T) {
|
||||
router, api := newAPIAuthTestEngine(t)
|
||||
NewClientController(router.Group("/base/panel/api/clients", api.checkAPIAuth, api.enforceTokenScope))
|
||||
for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync, model.ApiScopeNodeAdmin} {
|
||||
if err := database.GetDB().Create(&model.ApiToken{Name: scope, Token: crypto.HashTokenSHA256(scope), Enabled: true, Scope: scope}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, route := range []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "get"},
|
||||
{http.MethodPost, "update"},
|
||||
{http.MethodPost, "del"},
|
||||
} {
|
||||
for _, scope := range []string{"", model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync, model.ApiScopeNodeAdmin} {
|
||||
t.Run(route.path+"/scope="+scope, func(t *testing.T) {
|
||||
req := httptest.NewRequest(route.method, "/base/panel/api/clients/"+route.path+"?email=missing", strings.NewReader(`{"email":"restored-client"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Requested-With", "XMLHttpRequest")
|
||||
if scope != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+scope)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
want := http.StatusOK
|
||||
if scope == "" {
|
||||
want = http.StatusUnauthorized
|
||||
} else if scope == model.ApiScopeMonitor || (route.method == http.MethodGet && scope != model.ApiScopeAdmin) {
|
||||
want = http.StatusForbidden
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("status = %d, want %d, body = %s", w.Code, want, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// A handler that binds the body straight into model.Client turns every omitted
|
||||
// field into its zero: this expiry-only body would disable the client.
|
||||
func TestUpdateHandlerKeepsFieldsTheBodyOmits(t *testing.T) {
|
||||
const email = "partial-body@example.com"
|
||||
dbDir := t.TempDir()
|
||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
||||
db := database.GetDB()
|
||||
ib := &model.Inbound{
|
||||
UserId: 1, Enable: true, Port: 43350, Tag: "in-partial-body",
|
||||
Protocol: model.VLESS, Settings: `{"clients": []}`,
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
if _, err := (&service.ClientService{}).Create(&service.InboundService{}, &service.ClientCreatePayload{
|
||||
Client: model.Client{
|
||||
Email: email, ID: "11111111-2222-3333-4444-555555555555", Enable: true,
|
||||
LimitIP: 2, TotalGB: 10 << 30, TgID: 42, Comment: "phone",
|
||||
},
|
||||
InboundIds: []int{ib.Id},
|
||||
}); err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
|
||||
a := &ClientController{}
|
||||
c, w := postCtx(t, email, map[string]any{"email": email, "expiryTime": 1893456000000})
|
||||
a.update(c)
|
||||
|
||||
var rec model.ClientRecord
|
||||
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
|
||||
t.Fatalf("reload client: %v (response %s)", err, w.Body.String())
|
||||
}
|
||||
if rec.ExpiryTime != 1893456000000 {
|
||||
t.Fatalf("expiry not applied: %d (response %s)", rec.ExpiryTime, w.Body.String())
|
||||
}
|
||||
if !rec.Enable || rec.LimitIP != 2 || rec.TotalGB != 10<<30 || rec.TgID != 42 || rec.Comment != "phone" {
|
||||
t.Fatalf("handler reset fields the body omitted: %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientQueryAliasPartialUpdateKeepsOmittedFields(t *testing.T) {
|
||||
for _, email := range []string{".", ".."} {
|
||||
t.Run(email, func(t *testing.T) {
|
||||
router, rec, _ := seedLegacyDotClient(t, email)
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).Updates(map[string]any{"limit_ip": 2, "total_gb": int64(10 << 30), "comment": "retained"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := dotClientRequest(t, router, "POST", "update?email="+email, map[string]any{"email": "recovered-partial", "expiryTime": int64(1893456000000)})
|
||||
assertDotClientSuccess(t, w)
|
||||
var after model.ClientRecord
|
||||
if err := db.First(&after, rec.Id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Email != "recovered-partial" || after.ExpiryTime != 1893456000000 || !after.Enable || after.UUID != rec.UUID || after.SubID != rec.SubID || after.LimitIP != 2 || after.TotalGB != 10<<30 || after.Comment != "retained" {
|
||||
t.Fatalf("query alias partial update lost client state: %+v", after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,6 @@ func hasForbiddenClientChar(s string) bool {
|
||||
}
|
||||
|
||||
func validateClientEmail(email string) error {
|
||||
if email == "." || email == ".." {
|
||||
return common.NewError("client email must not be '.' or '..'")
|
||||
}
|
||||
if hasForbiddenClientChar(email) {
|
||||
return common.NewError("client email contains an invalid character:", email)
|
||||
}
|
||||
@@ -675,12 +672,6 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
|
||||
}
|
||||
|
||||
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
|
||||
return s.update(inboundSvc, id, updated, limitHwid, false, inboundFilter)
|
||||
}
|
||||
|
||||
// keepInboundFlows: the caller sent no flow, so every inbound keeps its own and an
|
||||
// unlinked record keeps its stored one.
|
||||
func (s *ClientService) update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, keepInboundFlows bool, inboundFilter []int) (bool, error) {
|
||||
existing, err := s.GetByID(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
@@ -782,12 +773,6 @@ func (s *ClientService) update(inboundSvc *InboundService, id int, updated model
|
||||
updated.Secret = existing.Secret
|
||||
}
|
||||
|
||||
// A client the quota loop switched off comes back on when this update
|
||||
// restores a usable window, as BulkAdjust already does on a renewal (#5619).
|
||||
if !updated.Enable && s.renewalReenables(existing.Email, updated.TotalGB, updated.ExpiryTime) {
|
||||
updated.Enable = true
|
||||
}
|
||||
|
||||
if updated.Email != existing.Email {
|
||||
var collisionCount int64
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
@@ -812,14 +797,6 @@ func (s *ClientService) update(inboundSvc *InboundService, id int, updated model
|
||||
}
|
||||
}
|
||||
|
||||
var inboundFlows map[int]string
|
||||
if keepInboundFlows {
|
||||
updated.Flow = existing.Flow
|
||||
if inboundFlows, err = inboundFlowOverrides(id); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
tunnelCount, tcErr := tunnelInboundCount(inboundIds)
|
||||
if tcErr != nil {
|
||||
return false, tcErr
|
||||
@@ -848,9 +825,6 @@ func (s *ClientService) update(inboundSvc *InboundService, id int, updated model
|
||||
return false, err
|
||||
}
|
||||
clientForInbound := updated
|
||||
if keepInboundFlows {
|
||||
clientForInbound.Flow = inboundFlows[ibId]
|
||||
}
|
||||
if ips, ok := updated.AllowedIPsByInbound[ibId]; ok {
|
||||
clientForInbound.AllowedIPs = ips
|
||||
} else if tunnelCount > 1 && (inbound.Protocol == model.WireGuard || inbound.Protocol == model.AmneziaWG) {
|
||||
@@ -942,15 +916,6 @@ func (s *ClientService) update(inboundSvc *InboundService, id int, updated model
|
||||
}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
// No inbound left, so UpdateClientStat never runs; re-assert the
|
||||
// enforcement row's off switch here or a re-enabled client stays off.
|
||||
if merged.Enable {
|
||||
if err := database.GetDB().Model(xray.ClientTraffic{}).
|
||||
Where("email = ?", existing.Email).
|
||||
UpdateColumn("enable", true).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reverseStr := ""
|
||||
|
||||
@@ -7,10 +7,6 @@ func TestValidateClientEmail(t *testing.T) {
|
||||
"alice",
|
||||
"alice@example.com",
|
||||
"user-123_test.name",
|
||||
"...",
|
||||
".alice",
|
||||
"alice.",
|
||||
"alice..example",
|
||||
"имя",
|
||||
}
|
||||
for _, email := range valid {
|
||||
@@ -20,8 +16,6 @@ func TestValidateClientEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
".",
|
||||
"..",
|
||||
"i6dui/",
|
||||
"a/b",
|
||||
"client with spaces",
|
||||
|
||||
@@ -1537,31 +1537,9 @@ func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, cl
|
||||
})
|
||||
}
|
||||
|
||||
// renewalReenableFor projects the post-renewal limits for a single-field
|
||||
// renewal (nil side keeps the stored limit; no row means no quota state).
|
||||
func (s *ClientService) renewalReenableFor(email string, setExpiry *int64, setTotal *int64) bool {
|
||||
total, expiry, ok := storedTrafficLimits(email)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if setExpiry != nil {
|
||||
expiry = *setExpiry
|
||||
}
|
||||
if setTotal != nil {
|
||||
total = *setTotal
|
||||
}
|
||||
return s.renewalReenables(email, total, expiry)
|
||||
}
|
||||
|
||||
func (s *ClientService) ResetClientExpiryTimeByEmail(inboundSvc *InboundService, clientEmail string, expiry_time int64) (bool, error) {
|
||||
reenable := s.renewalReenableFor(clientEmail, &expiry_time, nil)
|
||||
return s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
|
||||
c["expiryTime"] = expiry_time
|
||||
if reenable {
|
||||
// A renewed client that the quota loop had switched off comes back
|
||||
// with the renewal instead of waiting for a manual enable (#5619).
|
||||
c["enable"] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1569,12 +1547,7 @@ func (s *ClientService) ResetClientTrafficLimitByEmail(inboundSvc *InboundServic
|
||||
if totalGB < 0 {
|
||||
return false, common.NewError("totalGB must be >= 0")
|
||||
}
|
||||
newTotal := int64(totalGB) * 1024 * 1024 * 1024
|
||||
reenable := s.renewalReenableFor(clientEmail, nil, &newTotal)
|
||||
return s.applyClientFieldByEmail(inboundSvc, clientEmail, func(c map[string]any) {
|
||||
c["totalGB"] = newTotal
|
||||
if reenable {
|
||||
c["enable"] = true
|
||||
}
|
||||
c["totalGB"] = totalGB * 1024 * 1024 * 1024
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
)
|
||||
|
||||
// UpdateByEmailJSON applies a POST /clients/update body: an omitted key keeps the
|
||||
// stored value and an explicit zero, empty or null value clears it.
|
||||
func (s *ClientService) UpdateByEmailJSON(inboundSvc *InboundService, email string, body []byte, inboundFilter ...int) (bool, error) {
|
||||
var req clientPayloadWithHwid
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if email == "" {
|
||||
return false, common.NewError("client email is required")
|
||||
}
|
||||
rec, err := s.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
keys := clientPayloadKeys(body)
|
||||
fillAbsentClientFields(rec, &req.Client, &req.LimitHwid, keys)
|
||||
return s.update(inboundSvc, rec.Id, req.Client, req.LimitHwid, !keys.has("flow"), inboundFilter)
|
||||
}
|
||||
|
||||
// payloadKeys is a struct so tools/openapigen, which exports every named
|
||||
// non-struct type of this package, leaves it out of the generated frontend types.
|
||||
type payloadKeys struct{ names []string }
|
||||
|
||||
func clientPayloadKeys(body []byte) payloadKeys {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return payloadKeys{}
|
||||
}
|
||||
keys := payloadKeys{names: make([]string, 0, len(raw))}
|
||||
for k := range raw {
|
||||
keys.names = append(keys.names, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// has matches keys the way encoding/json binds them to fields: case-insensitively,
|
||||
// so a body sending "limitIP" counts as carrying limitIp.
|
||||
func (k payloadKeys) has(name string) bool {
|
||||
for _, key := range k.names {
|
||||
if strings.EqualFold(key, name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fillAbsentClientFields leaves flow to update, which keeps it per inbound.
|
||||
func fillAbsentClientFields(rec *model.ClientRecord, updated *model.Client, limitHwid *int, keys payloadKeys) {
|
||||
stored := rec.ToClient()
|
||||
if !keys.has("email") {
|
||||
updated.Email = stored.Email
|
||||
}
|
||||
if !keys.has("security") {
|
||||
updated.Security = stored.Security
|
||||
}
|
||||
if !keys.has("limitIp") {
|
||||
updated.LimitIP = stored.LimitIP
|
||||
}
|
||||
if !keys.has("totalGB") {
|
||||
updated.TotalGB = stored.TotalGB
|
||||
}
|
||||
if !keys.has("expiryTime") {
|
||||
updated.ExpiryTime = stored.ExpiryTime
|
||||
}
|
||||
if !keys.has("enable") {
|
||||
updated.Enable = stored.Enable
|
||||
}
|
||||
if !keys.has("tgId") {
|
||||
updated.TgID = stored.TgID
|
||||
}
|
||||
if !keys.has("group") {
|
||||
updated.Group = stored.Group
|
||||
}
|
||||
if !keys.has("comment") {
|
||||
updated.Comment = stored.Comment
|
||||
}
|
||||
if !keys.has("reset") {
|
||||
updated.Reset = stored.Reset
|
||||
}
|
||||
if !keys.has("resetDay") {
|
||||
updated.ResetDay = stored.ResetDay
|
||||
}
|
||||
if !keys.has("resetWeekday") {
|
||||
updated.ResetWeekday = stored.ResetWeekday
|
||||
}
|
||||
if !keys.has("resetMax") {
|
||||
updated.ResetMax = stored.ResetMax
|
||||
}
|
||||
if !keys.has("trafficReset") {
|
||||
updated.TrafficReset = stored.TrafficReset
|
||||
}
|
||||
if !keys.has("trafficResetDay") {
|
||||
updated.TrafficResetDay = stored.TrafficResetDay
|
||||
}
|
||||
if !keys.has("adTag") {
|
||||
updated.AdTag = stored.AdTag
|
||||
}
|
||||
if !keys.has("reverse") {
|
||||
updated.Reverse = stored.Reverse
|
||||
}
|
||||
if !keys.has("limitHwid") {
|
||||
*limitHwid = rec.LimitHwid
|
||||
}
|
||||
}
|
||||
|
||||
// inboundFlowOverrides returns each linked inbound's flow_override: the reliable
|
||||
// per-inbound flow, unlike clients.flow (see EffectiveFlow).
|
||||
func inboundFlowOverrides(recordId int) (map[int]string, error) {
|
||||
var links []model.ClientInbound
|
||||
if err := database.GetDB().Select("inbound_id", "flow_override").
|
||||
Where("client_id = ?", recordId).Find(&links).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
flows := make(map[int]string, len(links))
|
||||
for _, link := range links {
|
||||
flows[link.InboundId] = link.FlowOverride
|
||||
}
|
||||
return flows, nil
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
const partialTestPSK = "Pm4Hn0f8i8b6v3gJ0vZQ3yHq4mUe7qkq0m3o2l9sY9c="
|
||||
|
||||
func updateByEmailJSON(t *testing.T, svc *ClientService, email, body string) {
|
||||
t.Helper()
|
||||
if _, err := svc.UpdateByEmailJSON(&InboundService{}, email, []byte(body)); err != nil {
|
||||
t.Fatalf("UpdateByEmailJSON(%s): %v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func inboundClientByEmail(t *testing.T, inboundId int, email string) model.Client {
|
||||
t.Helper()
|
||||
inboundSvc := &InboundService{}
|
||||
ib, err := inboundSvc.GetInbound(inboundId)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInbound(%d): %v", inboundId, err)
|
||||
}
|
||||
clients, err := inboundSvc.GetClients(ib)
|
||||
if err != nil {
|
||||
t.Fatalf("GetClients(%d): %v", inboundId, err)
|
||||
}
|
||||
for _, c := range clients {
|
||||
if c.Email == email {
|
||||
return c
|
||||
}
|
||||
}
|
||||
t.Fatalf("inbound %d has no client %q", inboundId, email)
|
||||
return model.Client{}
|
||||
}
|
||||
|
||||
// A body carrying only email and expiryTime used to disable the client and zero its
|
||||
// limits, Telegram id, comment, group, renewal settings and limitHwid.
|
||||
func TestUpdateByEmailJSONKeepsOmittedFields(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
ib := mkInbound(t, 52111, model.AmneziaWG, `{"server":{"privateKey":"server","publicKey":"server-pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
|
||||
|
||||
seed := model.Client{
|
||||
Email: "full@awg", Enable: true,
|
||||
PrivateKey: "client-private", PublicKey: "client-public",
|
||||
PreSharedKey: partialTestPSK,
|
||||
AllowedIPs: []string{"10.8.1.8/32"},
|
||||
LimitIP: 3, TotalGB: 50 << 30, ExpiryTime: 1700000000000,
|
||||
TgID: 123456, Comment: "laptop", Group: "family",
|
||||
Reset: 30, ResetMax: 4,
|
||||
}
|
||||
seed.AllowedIPsByInbound = map[int][]string{ib.Id: seed.AllowedIPs}
|
||||
if _, err := svc.CreateOne(&InboundService{}, ib.Id, seed); err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", seed.Email).
|
||||
UpdateColumn("limit_hwid", 2).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updateByEmailJSON(t, svc, seed.Email, `{"email": "full@awg", "expiryTime": 1893456000000}`)
|
||||
|
||||
rec := lookupClientRecord(t, seed.Email)
|
||||
if !rec.Enable {
|
||||
t.Fatal("partial update disabled the client")
|
||||
}
|
||||
if rec.ExpiryTime != 1893456000000 {
|
||||
t.Fatalf("expiry not applied: %d", rec.ExpiryTime)
|
||||
}
|
||||
if rec.LimitIP != 3 || rec.TotalGB != 50<<30 || rec.TgID != 123456 || rec.Comment != "laptop" ||
|
||||
rec.Group != "family" || rec.Reset != 30 || rec.ResetMax != 4 || rec.LimitHwid != 2 {
|
||||
t.Fatalf("partial update reset omitted fields: %+v", rec)
|
||||
}
|
||||
peer := inboundClientByEmail(t, ib.Id, seed.Email)
|
||||
if !peer.Enable || peer.PreSharedKey != partialTestPSK || peer.PrivateKey != seed.PrivateKey {
|
||||
t.Fatalf("inbound peer lost its state: %+v", peer)
|
||||
}
|
||||
|
||||
updateByEmailJSON(t, svc, seed.Email, `{"enable": false, "comment": "", "totalGB": 0}`)
|
||||
rec = lookupClientRecord(t, seed.Email)
|
||||
if rec.Enable || rec.Comment != "" || rec.TotalGB != 0 {
|
||||
t.Fatalf("explicit zeros were not applied: %+v", rec)
|
||||
}
|
||||
if rec.ExpiryTime != 1893456000000 || rec.LimitIP != 3 || rec.Group != "family" || rec.LimitHwid != 2 {
|
||||
t.Fatalf("second partial update reset omitted fields: %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
// encoding/json binds "limitIP" to limitIp; a case-sensitive presence check then
|
||||
// overwrote the decoded zero with the stored value.
|
||||
func TestUpdateByEmailJSONMatchesKeysCaseInsensitively(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
ib := mkInbound(t, 52112, model.VLESS, `{"clients":[]}`)
|
||||
seed := model.Client{
|
||||
Email: "case@vless", ID: "0d1b6f8e-3c2a-4f61-9a43-5b7f0c2e9d11", Enable: true,
|
||||
LimitIP: 3, TgID: 123456, ExpiryTime: 1700000000000,
|
||||
}
|
||||
if _, err := svc.CreateOne(&InboundService{}, ib.Id, seed); err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
|
||||
updateByEmailJSON(t, svc, seed.Email, `{"limitIP": 0, "tgID": 0, "expirytime": 1893456000000}`)
|
||||
|
||||
rec := lookupClientRecord(t, seed.Email)
|
||||
if rec.LimitIP != 0 || rec.TgID != 0 || rec.ExpiryTime != 1893456000000 {
|
||||
t.Fatalf("differently-cased keys were not applied: limitIp=%d tgId=%d expiry=%d", rec.LimitIP, rec.TgID, rec.ExpiryTime)
|
||||
}
|
||||
if !rec.Enable {
|
||||
t.Fatal("partial update disabled the client")
|
||||
}
|
||||
}
|
||||
|
||||
// clients.flow is unreliable for a multi-inbound client (#4792): an omitted flow
|
||||
// must keep each inbound's own flow instead of copying that column everywhere.
|
||||
func TestUpdateByEmailJSONOmittedFlowKeepsEachInboundsFlow(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
const vision = "xtls-rprx-vision"
|
||||
reality := mkInboundStream(t, 52113, model.VLESS, `{"clients":[]}`, realityStream)
|
||||
ws := mkInboundStream(t, 52114, model.VLESS, `{"clients":[]}`, wsStream)
|
||||
seed := model.Client{
|
||||
Email: "flow@vless", ID: "6a0f2c1e-8b4d-4e7a-9c35-1d2e3f4a5b6c", Enable: true, Flow: vision,
|
||||
Reverse: &model.ClientReverse{Tag: "back"},
|
||||
}
|
||||
if _, err := svc.Create(&InboundService{}, &ClientCreatePayload{Client: seed, InboundIds: []int{reality.Id, ws.Id}}); err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
if rec := lookupClientRecord(t, seed.Email); rec.Reverse == "" {
|
||||
t.Fatal("seed did not store the reverse tag")
|
||||
}
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", seed.Email).
|
||||
UpdateColumn("flow", "").Error; err != nil {
|
||||
t.Fatalf("zero clients.flow: %v", err)
|
||||
}
|
||||
|
||||
updateByEmailJSON(t, svc, seed.Email, `{"expiryTime": 1893456000000}`)
|
||||
|
||||
if got := inboundClientByEmail(t, reality.Id, seed.Email); got.Flow != vision || got.ExpiryTime != 1893456000000 {
|
||||
t.Fatalf("Reality inbound: flow=%q expiry=%d, want %q and the new expiry", got.Flow, got.ExpiryTime, vision)
|
||||
}
|
||||
if got := inboundClientByEmail(t, ws.Id, seed.Email); got.Flow != "" {
|
||||
t.Fatalf("WS inbound gained flow %q", got.Flow)
|
||||
}
|
||||
if rec := lookupClientRecord(t, seed.Email); rec.Reverse == "" {
|
||||
t.Fatal("an omitted reverse must keep the tag")
|
||||
}
|
||||
updateByEmailJSON(t, svc, seed.Email, `{"reverse": null}`)
|
||||
if rec := lookupClientRecord(t, seed.Email); rec.Reverse != "" {
|
||||
t.Fatalf("an explicit null reverse must clear the tag: %q", rec.Reverse)
|
||||
}
|
||||
|
||||
updateByEmailJSON(t, svc, seed.Email, `{"flow": ""}`)
|
||||
if got := inboundClientByEmail(t, reality.Id, seed.Email); got.Flow != "" {
|
||||
t.Fatalf("an explicit empty flow must clear Vision, got %q", got.Flow)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// renewalReenables reports whether the quota loop switched the client off and
|
||||
// this renewal (newTotal/newExpiry, usage unchanged) clears the depletion (#5619).
|
||||
func (s *ClientService) renewalReenables(email string, newTotal, newExpiry int64) bool {
|
||||
db := database.GetDB()
|
||||
var traffic xray.ClientTraffic
|
||||
if err := db.Where("email = ?", email).First(&traffic).Error; err != nil {
|
||||
return false // no enforcement row: the quota loop never switched this client off
|
||||
}
|
||||
if traffic.Enable {
|
||||
return false
|
||||
}
|
||||
|
||||
cond, condArgs := depletedCond(db)
|
||||
var wasDepleted int64
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Where(cond+" AND email = ?", append(append([]any{}, condArgs...), email)...).
|
||||
Count(&wasDepleted).Error; err != nil || wasDepleted == 0 {
|
||||
return false // off for another reason than depletion: the operator's off stands
|
||||
}
|
||||
|
||||
// Project the post-renewal row: total/expiry move, up/down and a fresh
|
||||
// cross-panel push can still deplete it.
|
||||
now := time.Now().UnixMilli()
|
||||
used := traffic.Up + traffic.Down
|
||||
if newTotal > 0 && used >= newTotal {
|
||||
return false // still over quota
|
||||
}
|
||||
if newExpiry > 0 && newExpiry <= now {
|
||||
return false // still expired
|
||||
}
|
||||
if newTotal > 0 {
|
||||
var globalDepleted int64
|
||||
if err := db.Model(&model.ClientGlobalTraffic{}).
|
||||
Where("email = ? AND updated_at >= ? AND up + down >= ?", email, globalTrafficFreshSince(time.Now()), newTotal).
|
||||
Count(&globalDepleted).Error; err == nil && globalDepleted > 0 {
|
||||
return false // a master's pushed usage still depletes the fresh quota
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// storedTrafficLimits reads a client's enforcement row; ok is false when the
|
||||
// client has no row, in which case no quota state can be projected for it.
|
||||
func storedTrafficLimits(email string) (total, expiry int64, ok bool) {
|
||||
var row xray.ClientTraffic
|
||||
if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return row.Total, row.ExpiryTime, true
|
||||
}
|
||||
@@ -50,22 +50,17 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
|
||||
needRestart = nr
|
||||
}
|
||||
|
||||
// Restore quota-disabled clients after zeroing; preserve an operator
|
||||
// disable, including when no inbound remains.
|
||||
// Enable only a client the quota switched off, once the counters are zero: a
|
||||
// hand-disabled one stays off, and a still-depleted one would be switched off again.
|
||||
if !rec.Enable && len(quotaDisabled) > 0 {
|
||||
// Inbound resets already restored hosted clients. Only an unhosted
|
||||
// record still needs the full update.
|
||||
fresh, fErr := s.GetRecordByEmail(nil, email)
|
||||
if fErr == nil && !fresh.Enable {
|
||||
updated := fresh.ToClient()
|
||||
updated.Enable = true
|
||||
nr, uErr := s.Update(inboundSvc, fresh.Id, *updated, fresh.LimitHwid)
|
||||
if uErr != nil {
|
||||
logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.Enable = true
|
||||
nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
|
||||
if uErr != nil {
|
||||
logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
|
||||
}
|
||||
if nr {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
return needRestart, nil
|
||||
|
||||
@@ -67,7 +67,7 @@ func requireResetSurvivesTick(t *testing.T, reset func() error) {
|
||||
tickDone <- err
|
||||
}()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for trafficWriterQueueLen() < 2 && time.Now().Before(deadline) {
|
||||
for len(twQueue) < 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
close(release)
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// renewalReenableDay keeps the seeds one day past (or before) their boundary.
|
||||
const renewalReenableDay = int64(24 * 60 * 60 * 1000)
|
||||
|
||||
// seedRenewableDepletedClient seeds a local inbound whose client the quota loop
|
||||
// switched off: record off, inbound JSON off, and the traffic row off and depleted.
|
||||
func seedRenewableDepletedClient(t *testing.T, svc *ClientService, port int, email string, total, expiry, up, down int64) *model.Inbound {
|
||||
t.Helper()
|
||||
ib := seedLocalDisabledClient(t, svc, port, "", email, total, expiry, up, down)
|
||||
if got := trafficOf(t, email); got.Enable {
|
||||
t.Fatalf("seed %s: want an off traffic row, got %+v", email, got)
|
||||
}
|
||||
return ib
|
||||
}
|
||||
|
||||
func TestUpdate_ReenablesDepletedClient_WhenExpiryExtended(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
email := "renew-exp@x"
|
||||
ib := seedRenewableDepletedClient(t, svc, 53101, email, 0, now-2*renewalReenableDay, 0, 0)
|
||||
|
||||
rec, err := svc.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordByEmail: %v", err)
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.ExpiryTime = now + 30*renewalReenableDay
|
||||
// The editor round-trips the switch as the stored off state; the renewal
|
||||
// must turn it back on by itself.
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
|
||||
if got := trafficOf(t, email).ExpiryTime; got != now+30*renewalReenableDay {
|
||||
t.Fatalf("%s: expiry = %d, want %d", email, got, now+30*renewalReenableDay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_ReenablesDepletedClient_WhenTotalRaised(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
email := "renew-total@x"
|
||||
ib := seedRenewableDepletedClient(t, svc, 53102, email, 100, 0, 60, 40)
|
||||
|
||||
rec, err := svc.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordByEmail: %v", err)
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.TotalGB = 300
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
|
||||
if got := trafficOf(t, email).Total; got != 300 {
|
||||
t.Fatalf("%s: total = %d, want 300", email, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_KeepsDisabled_StillExpiredAfterUpdate(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
email := "renew-sml@x"
|
||||
ib := seedRenewableDepletedClient(t, svc, 53103, email, 0, now-10*renewalReenableDay, 0, 0)
|
||||
|
||||
rec, err := svc.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordByEmail: %v", err)
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.ExpiryTime = now - 5*renewalReenableDay // an extension that still lands in the past
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
|
||||
}
|
||||
|
||||
func TestUpdate_KeepsDisabled_StillOverQuotaAfterUpdate(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
email := "renew-quota@x"
|
||||
ib := seedRenewableDepletedClient(t, svc, 53104, email, 100, 0, 60, 40)
|
||||
|
||||
rec, err := svc.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordByEmail: %v", err)
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.TotalGB = 80 // still below the 100 bytes of usage
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
|
||||
}
|
||||
|
||||
func TestUpdate_KeepsDisabled_ManuallyDisabledNotDepleted(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
email := "renew-manual@x"
|
||||
ib := seedRenewableDepletedClient(t, svc, 53105, email, 0, now+30*renewalReenableDay, 0, 0)
|
||||
|
||||
rec, err := svc.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordByEmail: %v", err)
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.ExpiryTime = now + 60*renewalReenableDay
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
// The quota loop never switched this client off — it reads under quota —
|
||||
// so a manual switch-off survives a renewal.
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
|
||||
}
|
||||
|
||||
func TestUpdate_ReenablesDepletedClient_SkipsStillDepletedGlobalUsage(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
email := "renew-global@x"
|
||||
ib := seedRenewableDepletedClient(t, svc, 53106, email, 100, now+30*renewalReenableDay, 0, 0)
|
||||
// A fresh master push depletes the local limits even though up/down read 0.
|
||||
global := model.ClientGlobalTraffic{MasterGuid: "master-a", Email: email, Up: 90, Down: 60}
|
||||
if err := database.GetDB().Create(&global).Error; err != nil {
|
||||
t.Fatalf("seed global traffic: %v", err)
|
||||
}
|
||||
|
||||
rec, err := svc.GetRecordByEmail(nil, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordByEmail: %v", err)
|
||||
}
|
||||
updated := rec.ToClient()
|
||||
updated.TotalGB = 100
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
|
||||
|
||||
// Raising the quota above the pushed usage re-enables it.
|
||||
updated.TotalGB = 1000
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
|
||||
}
|
||||
|
||||
func TestUpdate_ReenablesDepletedClient_WithoutInbound(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
email := "renew-noib@x"
|
||||
rec := &model.ClientRecord{
|
||||
Email: email,
|
||||
UUID: "44444444-4444-4444-4444-444444444444",
|
||||
SubID: email,
|
||||
}
|
||||
if err := database.GetDB().Create(rec).Error; err != nil {
|
||||
t.Fatalf("create record: %v", err)
|
||||
}
|
||||
forceRecordDisabled(t, svc, email)
|
||||
if err := database.GetDB().Create(&xray.ClientTraffic{
|
||||
Email: email, Enable: false, ExpiryTime: now - 2*renewalReenableDay,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed client_traffics: %v", err)
|
||||
}
|
||||
|
||||
updated := rec.ToClient()
|
||||
updated.ExpiryTime = now + 30*renewalReenableDay
|
||||
if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if got := recordEnableOf(t, svc, email); !got {
|
||||
t.Fatalf("%s: client_records.enable = false, want true", email)
|
||||
}
|
||||
if got := trafficOf(t, email).Enable; !got {
|
||||
t.Fatalf("%s: client_traffics.enable = false, want true (no-inbound re-enable gap)", email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetClientExpiryTimeByEmail_ReenablesDepletedClient(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
email := "renew-setter@x"
|
||||
ib := seedRenewableDepletedClient(t, svc, 53107, email, 0, now-2*renewalReenableDay, 0, 0)
|
||||
|
||||
if _, err := svc.ResetClientExpiryTimeByEmail(inboundSvc, email, now+30*renewalReenableDay); err != nil {
|
||||
t.Fatalf("ResetClientExpiryTimeByEmail: %v", err)
|
||||
}
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
|
||||
}
|
||||
|
||||
func TestResetClientExpiryTimeByEmail_KeepsDisabled_ManuallyDisabledNotDepleted(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
email := "renew-setter-off@x"
|
||||
ib := seedRenewableDepletedClient(t, svc, 53108, email, 0, now+30*renewalReenableDay, 0, 0)
|
||||
|
||||
if _, err := svc.ResetClientExpiryTimeByEmail(inboundSvc, email, now+60*renewalReenableDay); err != nil {
|
||||
t.Fatalf("ResetClientExpiryTimeByEmail: %v", err)
|
||||
}
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, false)
|
||||
}
|
||||
|
||||
func TestResetClientTrafficLimitByEmail_ReenablesDepletedClient(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
email := "renew-setter-total@x"
|
||||
ib := seedRenewableDepletedClient(t, svc, 53109, email, 100, 0, 60, 40)
|
||||
|
||||
if _, err := svc.ResetClientTrafficLimitByEmail(inboundSvc, email, 1); err != nil {
|
||||
t.Fatalf("ResetClientTrafficLimitByEmail: %v", err)
|
||||
}
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
|
||||
if got := trafficOf(t, email).Total; got != 1<<30 {
|
||||
t.Fatalf("%s: total = %d, want %d", email, got, 1<<30)
|
||||
}
|
||||
}
|
||||
|
||||
// A client living on several inbounds must come back on every one of them,
|
||||
// not only the inbound whose reset ran first.
|
||||
func TestResetTrafficByEmail_ReenablesEveryInboundOfTheClient(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
|
||||
email := "renew-multi@x"
|
||||
c := model.Client{
|
||||
Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email,
|
||||
Enable: false, TotalGB: 1000,
|
||||
}
|
||||
ibA := mkInbound(t, 53201, model.VLESS, clientsSettings(t, []model.Client{c}))
|
||||
ibB := mkInbound(t, 53202, model.VLESS, clientsSettings(t, []model.Client{c}))
|
||||
for _, ib := range []*model.Inbound{ibA, ibB} {
|
||||
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
|
||||
t.Fatalf("SyncInbound %d: %v", ib.Id, err)
|
||||
}
|
||||
}
|
||||
mkTraffic(t, ibA.Id, email, 600, 500, 1000, 0, false)
|
||||
forceRecordDisabled(t, svc, email)
|
||||
|
||||
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
|
||||
t.Fatalf("ResetTrafficByEmail: %v", err)
|
||||
}
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ibA.Id, email, true)
|
||||
assertEnableEverywhere(t, svc, inboundSvc, ibB.Id, email, true)
|
||||
if got := trafficOf(t, email); !got.Enable || got.Up+got.Down != 0 {
|
||||
t.Fatalf("%s: traffic row enable=%v used=%d, want enabled at 0", email, got.Enable, got.Up+got.Down)
|
||||
}
|
||||
}
|
||||
@@ -275,48 +275,6 @@ func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID in
|
||||
return &snapshot, &ib, nil
|
||||
}
|
||||
|
||||
// markClientEnabledInSettings is the re-enable twin of
|
||||
// markClientsDisabledInSettings: flip one email's stored entry back on.
|
||||
func (s *InboundService) markClientEnabledInSettings(tx *gorm.DB, inboundID int, email string) error {
|
||||
var ib model.Inbound
|
||||
if err := tx.Model(&model.Inbound{}).Where("id = ?", inboundID).First(&ib).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
settings := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
|
||||
return err
|
||||
}
|
||||
clients, _ := settings["clients"].([]any)
|
||||
now := time.Now().Unix() * 1000
|
||||
mutated := false
|
||||
for i := range clients {
|
||||
entry, ok := clients[i].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if e, _ := entry["email"].(string); e != email {
|
||||
continue
|
||||
}
|
||||
if cur, _ := entry["enable"].(bool); cur {
|
||||
continue
|
||||
}
|
||||
entry["enable"] = true
|
||||
entry["updated_at"] = now
|
||||
clients[i] = entry
|
||||
mutated = true
|
||||
}
|
||||
if !mutated {
|
||||
return nil
|
||||
}
|
||||
settings["clients"] = clients
|
||||
bs, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Inbound{}).Where("id = ?", inboundID).
|
||||
Update("settings", string(bs)).Error
|
||||
}
|
||||
|
||||
// trafficDisabledEmails reports which emails have a switched-off stats row. The
|
||||
// table is email-keyed and its inbound_id goes stale, so never filter on it.
|
||||
func trafficDisabledEmails(db *gorm.DB, emails []string) (map[string]struct{}, error) {
|
||||
|
||||
@@ -390,9 +390,7 @@ func (s *InboundService) SnapshotHasUnadoptedInbounds(nodeID int, snap *runtime.
|
||||
// config push just landed, whose snapshot may still predate it (#6228).
|
||||
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty, justPushed bool) (bool, error) {
|
||||
var structuralChange bool
|
||||
// Node merges take the background lane: a per-client mutation the operator
|
||||
// is waiting on must not queue behind the next batch of 5s snapshots.
|
||||
err := submitTrafficWriteBackground(func() error {
|
||||
err := submitTrafficWrite(func() error {
|
||||
var inner error
|
||||
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty, justPushed)
|
||||
return inner
|
||||
|
||||
@@ -28,9 +28,7 @@ func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraff
|
||||
var disabledNodeIDs []int
|
||||
var remotePlans []trafficInboundUpdatePlan
|
||||
var renewed []string
|
||||
// The periodic poll takes the background lane: an operator's save or reset
|
||||
// queued while a poll runs must not sit behind the next merge batch.
|
||||
err = submitTrafficWriteBackground(func() error {
|
||||
err = submitTrafficWrite(func() error {
|
||||
var inner error
|
||||
needRestart, clientsDisabled, disabledNodeIDs, remotePlans, renewed, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
|
||||
return inner
|
||||
@@ -722,61 +720,55 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||
needRestart := false
|
||||
var reenablePlan *trafficLocalApplyPlan
|
||||
var reenableNodeID *int
|
||||
// This inbound's stored entry read off and this reset flips it back on,
|
||||
// in the same transaction as the zeroing.
|
||||
flipStoredEntry := false
|
||||
|
||||
traffic, err := s.GetClientTrafficByEmail(clientEmail)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
// A reset lifts the quota gate only: a client switched off by hand stays off.
|
||||
keepOff, err := handDisabledEmails(database.GetDB(), []string{clientEmail})
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
handDisabled := len(keepOff) > 0
|
||||
inbound, err := s.GetInbound(id)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
clients, err := s.GetClients(inbound)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
// Check each stored entry: the first reset already enables the shared
|
||||
// row. A row-only check would leave sibling inbounds disabled.
|
||||
entry := (*model.Client)(nil)
|
||||
for i := range clients {
|
||||
if clients[i].Email == clientEmail {
|
||||
entry = &clients[i]
|
||||
break
|
||||
|
||||
if !traffic.Enable && !handDisabled {
|
||||
inbound, err := s.GetInbound(id)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
}
|
||||
if entry != nil && !handDisabled && (!entry.Enable || !traffic.Enable) {
|
||||
flipStoredEntry = true
|
||||
cipher := ""
|
||||
if string(inbound.Protocol) == "shadowsocks" {
|
||||
var oldSettings map[string]any
|
||||
err = json.Unmarshal([]byte(inbound.Settings), &oldSettings)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
clients, err := s.GetClients(inbound)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.Email == clientEmail && client.Enable {
|
||||
cipher := ""
|
||||
if string(inbound.Protocol) == "shadowsocks" {
|
||||
var oldSettings map[string]any
|
||||
err = json.Unmarshal([]byte(inbound.Settings), &oldSettings)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
cipher, _ = oldSettings["method"].(string)
|
||||
}
|
||||
clientMap := map[string]any{
|
||||
"email": client.Email,
|
||||
"id": client.ID,
|
||||
"auth": client.Auth,
|
||||
"security": client.Security,
|
||||
"flow": client.Flow,
|
||||
"password": client.Password,
|
||||
"cipher": cipher,
|
||||
"reverse": client.Reverse,
|
||||
}
|
||||
if inbound.NodeID != nil {
|
||||
reenableNodeID = inbound.NodeID
|
||||
} else {
|
||||
reenablePlan = &trafficLocalApplyPlan{action: trafficAddUser, inbound: *inbound, client: clientMap}
|
||||
}
|
||||
break
|
||||
}
|
||||
cipher, _ = oldSettings["method"].(string)
|
||||
}
|
||||
clientMap := map[string]any{
|
||||
"email": entry.Email,
|
||||
"id": entry.ID,
|
||||
"auth": entry.Auth,
|
||||
"security": entry.Security,
|
||||
"flow": entry.Flow,
|
||||
"password": entry.Password,
|
||||
"cipher": cipher,
|
||||
"reverse": entry.Reverse,
|
||||
}
|
||||
if inbound.NodeID != nil {
|
||||
reenableNodeID = inbound.NodeID
|
||||
} else {
|
||||
reenablePlan = &trafficLocalApplyPlan{action: trafficAddUser, inbound: *inbound, client: clientMap}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,6 +778,10 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||
|
||||
db := database.GetDB()
|
||||
now := time.Now().UnixMilli()
|
||||
inbound, err := s.GetInbound(id)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
|
||||
return err
|
||||
@@ -807,32 +803,16 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||
Update("last_traffic_reset_time", now).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if flipStoredEntry {
|
||||
// Restore quota-disabled entries with the counters; an operator
|
||||
// disable has already been excluded.
|
||||
if err := s.markClientEnabledInSettings(tx, id, clientEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.ClientRecord{}).Where("email = ?", clientEmail).
|
||||
Updates(map[string]any{"enable": true, "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if reenableNodeID != nil {
|
||||
return (&NodeService{}).MarkNodeDirtyTx(tx, *reenableNodeID)
|
||||
}
|
||||
if inbound.NodeID != nil {
|
||||
if inbound != nil && inbound.NodeID != nil {
|
||||
return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
if flipStoredEntry {
|
||||
// The entry left the subscription in its disabled state; a just
|
||||
// re-enabled client must not keep missing from it.
|
||||
InvalidateSubData()
|
||||
}
|
||||
|
||||
if reenablePlan != nil {
|
||||
rt, err := s.runtimeFor(&reenablePlan.inbound)
|
||||
@@ -846,7 +826,10 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||
}
|
||||
}
|
||||
|
||||
return needRestart, inbound.NodeID, nil
|
||||
if inbound != nil {
|
||||
return needRestart, inbound.NodeID, nil
|
||||
}
|
||||
return needRestart, nil, nil
|
||||
}
|
||||
|
||||
// ResetAllTraffics zeroes every inbound's counters and brings back the inbounds a traffic
|
||||
|
||||
@@ -37,7 +37,7 @@ func (s *InboundService) AcceptGlobalTraffic(masterGuid string, traffics []*xray
|
||||
return nil
|
||||
}
|
||||
|
||||
return submitTrafficWriteBackground(func() error {
|
||||
return submitTrafficWrite(func() error {
|
||||
db := database.GetDB()
|
||||
known := make([]string, 0, len(emails))
|
||||
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||
|
||||
@@ -26,12 +26,11 @@ type trafficWriteRequest struct {
|
||||
type serializedTxContextKey struct{}
|
||||
|
||||
var (
|
||||
twMu sync.Mutex
|
||||
twAdminQueue chan *trafficWriteRequest
|
||||
twBgQueue chan *trafficWriteRequest
|
||||
twCtx context.Context
|
||||
twCancel context.CancelFunc
|
||||
twDone chan struct{}
|
||||
twMu sync.Mutex
|
||||
twQueue chan *trafficWriteRequest
|
||||
twCtx context.Context
|
||||
twCancel context.CancelFunc
|
||||
twDone chan struct{}
|
||||
)
|
||||
|
||||
// StartTrafficWriter spins up the serial writer goroutine. Safe to call again
|
||||
@@ -54,18 +53,16 @@ func StartTrafficWriter() {
|
||||
}
|
||||
}
|
||||
|
||||
adminQueue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
|
||||
bgQueue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
|
||||
queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
twAdminQueue = adminQueue
|
||||
twBgQueue = bgQueue
|
||||
twQueue = queue
|
||||
twCtx = ctx
|
||||
twCancel = cancel
|
||||
twDone = done
|
||||
|
||||
go runTrafficWriter(ctx, adminQueue, bgQueue, done)
|
||||
go runTrafficWriter(ctx, queue, done)
|
||||
}
|
||||
|
||||
// StopTrafficWriter cancels the writer context and waits for the goroutine to
|
||||
@@ -92,68 +89,31 @@ func StopTrafficWriter() {
|
||||
}
|
||||
|
||||
func clearTrafficWriterState() {
|
||||
twAdminQueue = nil
|
||||
twBgQueue = nil
|
||||
twQueue = nil
|
||||
twCtx = nil
|
||||
twCancel = nil
|
||||
twDone = nil
|
||||
}
|
||||
|
||||
// trafficWriterQueueLen is the test's view of both lanes' pending requests.
|
||||
func trafficWriterQueueLen() int {
|
||||
twMu.Lock()
|
||||
defer twMu.Unlock()
|
||||
return len(twAdminQueue) + len(twBgQueue)
|
||||
}
|
||||
|
||||
// One goroutine serves both lanes (no two writes side by side — the Postgres
|
||||
// deadlock guard c5d31de4e; the admin lane is preferred over the 5s poll and node merges, #6035).
|
||||
func runTrafficWriter(ctx context.Context, adminQueue, bgQueue chan *trafficWriteRequest, done chan struct{}) {
|
||||
func runTrafficWriter(ctx context.Context, queue chan *trafficWriteRequest, done chan struct{}) {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case req := <-adminQueue:
|
||||
case req := <-queue:
|
||||
req.done <- safeApply(req.apply)
|
||||
case <-ctx.Done():
|
||||
drainTrafficWriter(adminQueue, bgQueue)
|
||||
return
|
||||
default:
|
||||
select {
|
||||
case req := <-adminQueue:
|
||||
req.done <- safeApply(req.apply)
|
||||
case req := <-bgQueue:
|
||||
req.done <- safeApply(req.apply)
|
||||
case <-ctx.Done():
|
||||
drainTrafficWriter(adminQueue, bgQueue)
|
||||
return
|
||||
for {
|
||||
select {
|
||||
case req := <-queue:
|
||||
req.done <- safeApply(req.apply)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainTrafficWriter finishes every request already queued on shutdown, as the
|
||||
// single-queue writer used to: both lanes drain until empty, so no write is dropped.
|
||||
func drainTrafficWriter(adminQueue, bgQueue chan *trafficWriteRequest) {
|
||||
for {
|
||||
drained := false
|
||||
select {
|
||||
case req := <-adminQueue:
|
||||
req.done <- safeApply(req.apply)
|
||||
drained = true
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case req := <-bgQueue:
|
||||
req.done <- safeApply(req.apply)
|
||||
drained = true
|
||||
default:
|
||||
}
|
||||
if !drained {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runSerializedTx runs fn inside one DB transaction on the shared serial
|
||||
// traffic-writer goroutine, so it can never execute concurrently with the
|
||||
// @every 5s traffic poll (AddTraffic). Both touch the hot client_traffics and
|
||||
@@ -207,28 +167,13 @@ func safeApply(fn func() error) (err error) {
|
||||
return fn()
|
||||
}
|
||||
|
||||
// submitTrafficWrite runs fn on the serial writer's admin lane, ahead of any
|
||||
// queued periodic work (the traffic poll, the node sync merges).
|
||||
func submitTrafficWrite(fn func() error) error {
|
||||
return submitTrafficWriteLane(fn, false)
|
||||
}
|
||||
|
||||
// submitTrafficWriteBackground runs fn on the periodic lane (the 5s traffic poll
|
||||
// and node snapshot merges), served only when no admin-lane write is queued.
|
||||
func submitTrafficWriteBackground(fn func() error) error {
|
||||
return submitTrafficWriteLane(fn, true)
|
||||
}
|
||||
|
||||
func submitTrafficWriteLane(fn func() error, background bool) error {
|
||||
req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
|
||||
|
||||
twMu.Lock()
|
||||
queue := twAdminQueue
|
||||
queue := twQueue
|
||||
ctx := twCtx
|
||||
done := twDone
|
||||
if background {
|
||||
queue = twBgQueue
|
||||
}
|
||||
if queue == nil || ctx == nil || done == nil {
|
||||
twMu.Unlock()
|
||||
return safeApply(fn)
|
||||
|
||||
@@ -161,7 +161,13 @@ func waitTrafficWriterQueued(t *testing.T) {
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if trafficWriterQueueLen() > 0 {
|
||||
twMu.Lock()
|
||||
queued := 0
|
||||
if twQueue != nil {
|
||||
queued = len(twQueue)
|
||||
}
|
||||
twMu.Unlock()
|
||||
if queued > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -211,7 +217,10 @@ func parkSerializedWriter(t *testing.T) (release func()) {
|
||||
func waitForQueuedWrite() bool {
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if trafficWriterQueueLen() > 0 {
|
||||
twMu.Lock()
|
||||
queued := len(twQueue) > 0
|
||||
twMu.Unlock()
|
||||
if queued {
|
||||
return true
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
@@ -219,99 +228,6 @@ func waitForQueuedWrite() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// A write the operator is waiting on must not queue behind the periodic work
|
||||
// that keeps arriving every 5s (#6035): with both lanes filled, admin runs first.
|
||||
func TestTrafficWriterAdminLanePreemptsBackgroundQueue(t *testing.T) {
|
||||
resetTrafficWriterForTest(t)
|
||||
|
||||
StartTrafficWriter()
|
||||
parkStarted := make(chan struct{})
|
||||
releasePark := make(chan struct{})
|
||||
go func() {
|
||||
_ = submitTrafficWrite(func() error {
|
||||
close(parkStarted)
|
||||
<-releasePark
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitTrafficWriterSignal(t, parkStarted, "parking write did not start")
|
||||
|
||||
order := make(chan string, 2)
|
||||
bgDone := make(chan error, 1)
|
||||
go func() {
|
||||
bgDone <- submitTrafficWriteBackground(func() error {
|
||||
order <- "background"
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
adminDone := make(chan error, 1)
|
||||
go func() {
|
||||
adminDone <- submitTrafficWrite(func() error {
|
||||
order <- "admin"
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for trafficWriterQueueLen() < 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if trafficWriterQueueLen() < 2 {
|
||||
t.Fatal("both writes did not reach the queues")
|
||||
}
|
||||
|
||||
close(releasePark)
|
||||
if got := <-order; got != "admin" {
|
||||
t.Fatalf("first write served after the parked one = %q, want admin: the lane the operator waits on", got)
|
||||
}
|
||||
if got := <-order; got != "background" {
|
||||
t.Fatalf("second write served = %q, want background", got)
|
||||
}
|
||||
if err := <-adminDone; err != nil {
|
||||
t.Fatalf("admin write: %v", err)
|
||||
}
|
||||
if err := <-bgDone; err != nil {
|
||||
t.Fatalf("background write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A stop must drain every queued write on both lanes, not one per lane: a
|
||||
// submitter blocked behind two queued writes must not read "stopped".
|
||||
func TestTrafficWriterStopDrainsEveryQueuedWrite(t *testing.T) {
|
||||
resetTrafficWriterForTest(t)
|
||||
|
||||
StartTrafficWriter()
|
||||
parkStarted := make(chan struct{})
|
||||
releasePark := make(chan struct{})
|
||||
go func() {
|
||||
_ = submitTrafficWrite(func() error {
|
||||
close(parkStarted)
|
||||
<-releasePark
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitTrafficWriterSignal(t, parkStarted, "parking write did not start")
|
||||
|
||||
results := make(chan error, 3)
|
||||
go func() { results <- submitTrafficWrite(func() error { return nil }) }()
|
||||
go func() { results <- submitTrafficWrite(func() error { return nil }) }()
|
||||
go func() { results <- submitTrafficWriteBackground(func() error { return nil }) }()
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for trafficWriterQueueLen() < 3 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if trafficWriterQueueLen() < 3 {
|
||||
t.Fatal("queued writes did not all reach the lanes")
|
||||
}
|
||||
|
||||
close(releasePark)
|
||||
StopTrafficWriter()
|
||||
for range 3 {
|
||||
if err := waitTrafficWriterErr(t, results); err != nil {
|
||||
t.Fatalf("a queued write was dropped on stop: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitWriterResult(t *testing.T, done <-chan error) error {
|
||||
t.Helper()
|
||||
select {
|
||||
|
||||
@@ -17,8 +17,7 @@ func (s *InboundService) AddTuicTrafficBatch(id string, traffic []*xray.ClientTr
|
||||
if id == "" {
|
||||
return errors.New("TUIC traffic batch has no ID")
|
||||
}
|
||||
// The receipt journal is periodic accounting, not an operator action.
|
||||
return submitTrafficWriteBackground(func() error {
|
||||
return submitTrafficWrite(func() error {
|
||||
return database.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.TuicTrafficReceipt{ID: id})
|
||||
if result.Error != nil {
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "غير محدد",
|
||||
"unlimited": "غير محدود",
|
||||
"none": "لا شيء",
|
||||
"empty": "فارغ",
|
||||
"qrCode": "كود QR",
|
||||
"info": "معلومات أكتر",
|
||||
"edit": "تحرير",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "إعادة تعيين",
|
||||
"noData": "لا توجد بيانات.",
|
||||
"copySuccess": "اتنسخ بنجاح",
|
||||
"copyFail": "فشل النسخ",
|
||||
"sure": "متأكد؟",
|
||||
"encryption": "تشفير",
|
||||
"transmission": "نقل",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "يُستخدم أيضًا كرمز دعوة لهذا العميل في بوت تيليجرام: من يرسله إلى البوت يُربط بهذا العميل. اجعله طويلًا وعشوائيًا — فالمعرّف القصير أو السهل التخمين قد يستولي عليه شخص آخر.",
|
||||
"online": "متصل",
|
||||
"email": "البريد",
|
||||
"emailReservedName": "لا يمكن أن يكون اسم العميل «.» أو «..» فقط",
|
||||
"emailInvalidChars": "لا يمكن أن يحتوي البريد الإلكتروني على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
||||
"subIdInvalidChars": "لا يمكن أن يحتوي معرّف الاشتراك على مسافات أو '/' أو '\\' أو أحرف تحكم",
|
||||
"group": "المجموعة",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "Indefinite",
|
||||
"unlimited": "Unlimited",
|
||||
"none": "None",
|
||||
"empty": "Empty",
|
||||
"qrCode": "QR Code",
|
||||
"info": "More Information",
|
||||
"edit": "Edit",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "Reset",
|
||||
"noData": "Nothing here yet",
|
||||
"copySuccess": "Copied successfully",
|
||||
"copyFail": "Copy failed",
|
||||
"sure": "Sure",
|
||||
"encryption": "Encryption",
|
||||
"transmission": "Transmission",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "Also works as this client's Telegram bot invite code: whoever sends it to the bot is linked to this client. Keep it long and random — a short or guessable ID can be claimed by someone else.",
|
||||
"online": "Online",
|
||||
"email": "Email",
|
||||
"emailReservedName": "Client name cannot be exactly \".\" or \"..\"",
|
||||
"emailInvalidChars": "Email cannot contain spaces, '/', '\\', or control characters",
|
||||
"subIdInvalidChars": "Subscription ID cannot contain spaces, '/', '\\', or control characters",
|
||||
"group": "Group",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "Indefinido",
|
||||
"unlimited": "Ilimitado",
|
||||
"none": "Ninguno",
|
||||
"empty": "Vacío",
|
||||
"qrCode": "Código QR",
|
||||
"info": "Más Información",
|
||||
"edit": "Editar",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "Restablecer",
|
||||
"noData": "Sin datos",
|
||||
"copySuccess": "Copiado exitosamente",
|
||||
"copyFail": "Error al copiar",
|
||||
"sure": "Seguro",
|
||||
"encryption": "Encriptación",
|
||||
"transmission": "Transmisión",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "También sirve como código de invitación de este cliente en el bot de Telegram: quien lo envíe al bot queda vinculado a este cliente. Mantenlo largo y aleatorio: un ID corto o fácil de adivinar puede ser reclamado por otra persona.",
|
||||
"online": "En línea",
|
||||
"email": "Email",
|
||||
"emailReservedName": "El nombre del cliente no puede ser exactamente \".\" o \"..\"",
|
||||
"emailInvalidChars": "El correo no puede contener espacios, '/', '\\' ni caracteres de control",
|
||||
"subIdInvalidChars": "El ID de suscripción no puede contener espacios, '/', '\\' ni caracteres de control",
|
||||
"group": "Grupo",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "نامحدود",
|
||||
"unlimited": "نامحدود",
|
||||
"none": "هیچ",
|
||||
"empty": "خالی",
|
||||
"qrCode": "QRکد",
|
||||
"info": "اطلاعات بیشتر",
|
||||
"edit": "ویرایش",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "بازنشانی",
|
||||
"noData": "دادهای وجود ندارد.",
|
||||
"copySuccess": "باموفقیت کپیشد",
|
||||
"copyFail": "کپی ناموفق بود",
|
||||
"sure": "مطمئن",
|
||||
"encryption": "رمزگذاری",
|
||||
"transmission": "راهاتصال",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "این شناسه، کد دعوت این کاربر در ربات تلگرام هم هست: هر کسی آن را به ربات بفرستد به این کاربر متصل میشود. آن را طولانی و تصادفی نگه دارید؛ شناسهٔ کوتاه یا قابل حدس ممکن است توسط شخص دیگری تصاحب شود.",
|
||||
"online": "آنلاین",
|
||||
"email": "ایمیل",
|
||||
"emailReservedName": "نام کاربر نمیتواند فقط «.» یا «..» باشد",
|
||||
"emailInvalidChars": "ایمیل نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
||||
"subIdInvalidChars": "شناسهی اشتراک نمیتواند شامل فاصله، '/'، '\\' یا کاراکترهای کنترلی باشد",
|
||||
"group": "گروه",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "Tak Terbatas",
|
||||
"unlimited": "Tanpa Batas",
|
||||
"none": "Tidak ada",
|
||||
"empty": "Kosong",
|
||||
"qrCode": "Kode QR",
|
||||
"info": "Informasi Lebih Lanjut",
|
||||
"edit": "Edit",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "Reset",
|
||||
"noData": "Tidak ada data.",
|
||||
"copySuccess": "Berhasil Disalin",
|
||||
"copyFail": "Gagal Menyalin",
|
||||
"sure": "Yakin",
|
||||
"encryption": "Enkripsi",
|
||||
"transmission": "Transmisi",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "Juga berfungsi sebagai kode undangan bot Telegram untuk klien ini: siapa pun yang mengirimkannya ke bot akan ditautkan ke klien ini. Buat panjang dan acak — ID yang pendek atau mudah ditebak bisa diklaim orang lain.",
|
||||
"online": "Online",
|
||||
"email": "Email",
|
||||
"emailReservedName": "Nama klien tidak boleh hanya \".\" atau \"..\"",
|
||||
"emailInvalidChars": "Email tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol",
|
||||
"subIdInvalidChars": "ID langganan tidak boleh mengandung spasi, '/', '\\', atau karakter kontrol",
|
||||
"group": "Grup",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "無期限",
|
||||
"unlimited": "無制限",
|
||||
"none": "なし",
|
||||
"empty": "空欄",
|
||||
"qrCode": "QRコード",
|
||||
"info": "詳細情報",
|
||||
"edit": "編集",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "リセット",
|
||||
"noData": "データなし。",
|
||||
"copySuccess": "コピー成功",
|
||||
"copyFail": "コピー失敗",
|
||||
"sure": "確定",
|
||||
"encryption": "暗号化",
|
||||
"transmission": "伝送",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "このクライアントの Telegram ボット招待コードとしても使われます。ボットに送信した人がこのクライアントに紐付けられます。長くランダムな値にしてください。短い、または推測しやすい ID は他人に取得されるおそれがあります。",
|
||||
"online": "オンライン",
|
||||
"email": "メール",
|
||||
"emailReservedName": "クライアント名を「.」または「..」だけにすることはできません",
|
||||
"emailInvalidChars": "メールアドレスにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
||||
"subIdInvalidChars": "サブスクリプションIDにスペース、'/'、'\\'、または制御文字を含めることはできません",
|
||||
"group": "グループ",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "Indeterminado",
|
||||
"unlimited": "Ilimitado",
|
||||
"none": "Nenhum",
|
||||
"empty": "Vazio",
|
||||
"qrCode": "Código QR",
|
||||
"info": "Mais Informações",
|
||||
"edit": "Editar",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "Redefinir",
|
||||
"noData": "Sem dados.",
|
||||
"copySuccess": "Copiado com Sucesso",
|
||||
"copyFail": "Falha ao Copiar",
|
||||
"sure": "Certo",
|
||||
"encryption": "Criptografia",
|
||||
"transmission": "Transmissão",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "Também funciona como código de convite deste cliente no bot do Telegram: quem enviá-lo ao bot fica vinculado a este cliente. Mantenha-o longo e aleatório — um ID curto ou fácil de adivinhar pode ser reivindicado por outra pessoa.",
|
||||
"online": "Online",
|
||||
"email": "Email",
|
||||
"emailReservedName": "O nome do cliente não pode ser exatamente \".\" ou \"..\"",
|
||||
"emailInvalidChars": "O e-mail não pode conter espaços, '/', '\\' ou caracteres de controle",
|
||||
"subIdInvalidChars": "O ID de assinatura não pode conter espaços, '/', '\\' ou caracteres de controle",
|
||||
"group": "Grupo",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "Бесконечно",
|
||||
"unlimited": "Безлимит",
|
||||
"none": "Нет",
|
||||
"empty": "Пусто",
|
||||
"qrCode": "QR-код",
|
||||
"info": "Информация",
|
||||
"edit": "Изменить",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "Сброс",
|
||||
"noData": "Нет данных.",
|
||||
"copySuccess": "Скопировано",
|
||||
"copyFail": "Не удалось скопировать",
|
||||
"sure": "Да",
|
||||
"encryption": "Шифрование",
|
||||
"transmission": "Транспорт",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "Также служит кодом приглашения этого клиента в Telegram-боте: тот, кто отправит его боту, будет привязан к этому клиенту. Используйте длинное случайное значение — короткий или легко угадываемый ID может присвоить кто-то другой.",
|
||||
"online": "В сети",
|
||||
"email": "Email",
|
||||
"emailReservedName": "Имя клиента не может состоять только из «.» или «..»",
|
||||
"emailInvalidChars": "Email не может содержать пробелы, '/', '\\' или управляющие символы",
|
||||
"subIdInvalidChars": "ID подписки не может содержать пробелы, '/', '\\' или управляющие символы",
|
||||
"group": "Группа",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "Bez časového obmedzenia",
|
||||
"unlimited": "Bez obmedzenia",
|
||||
"none": "Žiadne",
|
||||
"empty": "Prázdne",
|
||||
"qrCode": "QR kód",
|
||||
"info": "Ďalšie informácie",
|
||||
"edit": "Upraviť",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "Vynulovať",
|
||||
"noData": "Zatiaľ tu nič nie je",
|
||||
"copySuccess": "Úspešne skopírované",
|
||||
"copyFail": "Kopírovanie zlyhalo",
|
||||
"sure": "Áno",
|
||||
"encryption": "Šifrovanie",
|
||||
"transmission": "Prenos",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "Slúži aj ako pozývací kód klienta do Telegram bota. Kto ho pošle botovi, priradí sa k tomuto klientovi. Použite dlhé náhodné ID. Krátke alebo odhadnuteľné ID si môže privlastniť niekto iný.",
|
||||
"online": "Online",
|
||||
"email": "Názov klienta",
|
||||
"emailReservedName": "Názov klienta nesmie byť presne \".\" ani \"..\"",
|
||||
"emailInvalidChars": "Názov klienta nesmie obsahovať medzery, '/', '\\' ani riadiace znaky",
|
||||
"subIdInvalidChars": "ID odberu nesmie obsahovať medzery, '/', '\\' ani riadiace znaky",
|
||||
"group": "Skupina",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "Belirsiz",
|
||||
"unlimited": "Sınırsız",
|
||||
"none": "Yok",
|
||||
"empty": "Boş",
|
||||
"qrCode": "QR Kod",
|
||||
"info": "Daha Fazla Bilgi",
|
||||
"edit": "Düzenle",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "Sıfırla",
|
||||
"noData": "Veri yok.",
|
||||
"copySuccess": "Başarıyla kopyalandı",
|
||||
"copyFail": "Kopyalama başarısız",
|
||||
"sure": "Emin misiniz?",
|
||||
"encryption": "Şifreleme",
|
||||
"transmission": "İletim",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "Bu istemcinin Telegram botu davet kodu olarak da kullanılır: bunu bota gönderen kişi bu istemciye bağlanır. Uzun ve rastgele tutun — kısa veya tahmin edilebilir bir kimlik başkası tarafından sahiplenilebilir.",
|
||||
"online": "Çevrimiçi",
|
||||
"email": "E-posta",
|
||||
"emailReservedName": "İstemci adı yalnızca \".\" veya \"..\" olamaz",
|
||||
"emailInvalidChars": "E-posta boşluk, '/', '\\' veya kontrol karakterleri içeremez",
|
||||
"subIdInvalidChars": "Abonelik kimliği boşluk, '/', '\\' veya kontrol karakterleri içeremez",
|
||||
"group": "Grup",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "Безстроково",
|
||||
"unlimited": "Безлімітний",
|
||||
"none": "Немає",
|
||||
"empty": "Порожньо",
|
||||
"qrCode": "QR-Код",
|
||||
"info": "Більше інформації",
|
||||
"edit": "Змінити",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "Скидання",
|
||||
"noData": "Немає даних.",
|
||||
"copySuccess": "Скопійовано успішно",
|
||||
"copyFail": "Не вдалося скопіювати",
|
||||
"sure": "Звичайно",
|
||||
"encryption": "Шифрування",
|
||||
"transmission": "Протокол передачи",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "Також слугує кодом запрошення цього клієнта в Telegram-боті: той, хто надішле його боту, буде прив'язаний до цього клієнта. Використовуйте довге випадкове значення — короткий або легко вгадуваний ID може привласнити хтось інший.",
|
||||
"online": "У мережі",
|
||||
"email": "Email",
|
||||
"emailReservedName": "Ім’я клієнта не може складатися лише з «.» або «..»",
|
||||
"emailInvalidChars": "Email не може містити пробіли, '/', '\\' або керуючі символи",
|
||||
"subIdInvalidChars": "ID підписки не може містити пробіли, '/', '\\' або керуючі символи",
|
||||
"group": "Група",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "Không xác định",
|
||||
"unlimited": "Không giới hạn",
|
||||
"none": "Không",
|
||||
"empty": "Trống",
|
||||
"qrCode": "Mã QR",
|
||||
"info": "Thông tin thêm",
|
||||
"edit": "Sửa",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "Đặt lại",
|
||||
"noData": "Không có dữ liệu.",
|
||||
"copySuccess": "Đã sao chép thành công",
|
||||
"copyFail": "Sao chép thất bại",
|
||||
"sure": "Chắc chắn",
|
||||
"encryption": "Mã hóa",
|
||||
"transmission": "Truyền tải",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "Cũng được dùng làm mã mời bot Telegram của client này: ai gửi mã này cho bot sẽ được liên kết với client. Hãy để mã dài và ngẫu nhiên — ID ngắn hoặc dễ đoán có thể bị người khác chiếm.",
|
||||
"online": "Trực tuyến",
|
||||
"email": "Email",
|
||||
"emailReservedName": "Tên máy khách không được chỉ là \".\" hoặc \"..\"",
|
||||
"emailInvalidChars": "Email không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển",
|
||||
"subIdInvalidChars": "ID đăng ký không được chứa khoảng trắng, '/', '\\' hoặc ký tự điều khiển",
|
||||
"group": "Nhóm",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "无限期",
|
||||
"unlimited": "无限制",
|
||||
"none": "无",
|
||||
"empty": "空",
|
||||
"qrCode": "二维码",
|
||||
"info": "更多信息",
|
||||
"edit": "编辑",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "重置",
|
||||
"noData": "无数据。",
|
||||
"copySuccess": "复制成功",
|
||||
"copyFail": "复制失败",
|
||||
"sure": "确定",
|
||||
"encryption": "加密",
|
||||
"transmission": "传输",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "同时用作该客户端的 Telegram 机器人邀请码:任何人将其发送给机器人即会绑定到此客户端。请保持足够长且随机——过短或易猜的 ID 可能被他人抢先绑定。",
|
||||
"online": "在线",
|
||||
"email": "邮箱",
|
||||
"emailReservedName": "客户端名称不能仅为“.”或“..”",
|
||||
"emailInvalidChars": "邮箱不能包含空格、'/'、'\\' 或控制字符",
|
||||
"subIdInvalidChars": "订阅 ID 不能包含空格、'/'、'\\' 或控制字符",
|
||||
"group": "分组",
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
"indefinite": "無限期",
|
||||
"unlimited": "無限制",
|
||||
"none": "無",
|
||||
"empty": "空",
|
||||
"qrCode": "二維碼",
|
||||
"info": "更多資訊",
|
||||
"edit": "編輯",
|
||||
@@ -48,7 +47,6 @@
|
||||
"reset": "重置",
|
||||
"noData": "無數據。",
|
||||
"copySuccess": "複製成功",
|
||||
"copyFail": "複製失敗",
|
||||
"sure": "確定",
|
||||
"encryption": "加密",
|
||||
"transmission": "傳輸",
|
||||
@@ -878,7 +876,6 @@
|
||||
"subIdDesc": "同時作為此用戶端的 Telegram 機器人邀請碼:任何人將其傳送給機器人即會綁定到此用戶端。請保持足夠長且隨機——過短或容易猜到的 ID 可能被他人搶先綁定。",
|
||||
"online": "上線",
|
||||
"email": "電子郵件",
|
||||
"emailReservedName": "用戶端名稱不能僅為「.」或「..」",
|
||||
"emailInvalidChars": "電子郵件不能包含空格、'/'、'\\' 或控制字元",
|
||||
"subIdInvalidChars": "訂閱 ID 不能包含空格、'/'、'\\' 或控制字元",
|
||||
"group": "群組",
|
||||
|
||||
Reference in New Issue
Block a user