mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-06 05:05:01 +03:00
fix(api): preserve omitted client fields in partial updates (#7042)
Keep omitted client fields and per-inbound flows unchanged while honoring explicit clears. Support the query aliases used to recover legacy dot-name clients and regenerate the API reference. Add an HTTP regression covering partial updates through those aliases.
This commit is contained in:
@@ -48,15 +48,17 @@ _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 — 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'
|
||||
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 — 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-1'
|
||||
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'
|
||||
- depth: 2
|
||||
title: Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
@@ -351,16 +353,16 @@ _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 — 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: 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-1
|
||||
- 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: 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
|
||||
@@ -671,17 +673,10 @@ _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: '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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
- content: 'Query-parameter alias for the path endpoint. Use email=. or email=..
|
||||
to manage legacy reserved names without URL dot-segment normalization.
|
||||
- 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
|
||||
fails no longer stops the others. Every inbound error names the
|
||||
inbound it came from (`inbound 7: <message>`), and several failures
|
||||
@@ -690,7 +685,22 @@ _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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch-1
|
||||
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
|
||||
- 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
|
||||
|
||||
@@ -44,10 +44,11 @@ _openapi:
|
||||
- depth: 2
|
||||
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).
|
||||
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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
#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: >-
|
||||
Delete a client by email. Removes it from every attached inbound and
|
||||
@@ -358,11 +359,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 — supply the full
|
||||
set of fields you want to keep (the server replaces the row, it does
|
||||
not patch).
|
||||
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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
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: >-
|
||||
Delete a client by email. Removes it from every attached inbound and
|
||||
drops its traffic record unless keepTraffic=1 is passed.
|
||||
|
||||
@@ -44,10 +44,11 @@ _openapi:
|
||||
- depth: 2
|
||||
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).
|
||||
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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
#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: >-
|
||||
Delete a client by email. Removes it from every attached inbound and
|
||||
@@ -358,11 +359,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 — supply the full
|
||||
set of fields you want to keep (the server replaces the row, it does
|
||||
not patch).
|
||||
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--supply-the-full-set-of-fields-you-want-to-keep-the-server-replaces-the-row-it-does-not-patch
|
||||
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: >-
|
||||
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 客户端载荷——
|
||||
请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
|
||||
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷:
|
||||
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。
|
||||
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
|
||||
#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: >-
|
||||
按 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 客户端载荷——
|
||||
请提供你希望保留的完整字段集(服务端会替换整条记录,而非局部更新)。
|
||||
按 email 更新现有客户端。变更会传播到每个挂载的入站。请求体为 JSON 客户端载荷:
|
||||
省略的字段保留当前值,显式的零值、空值或 null 会清除该字段(例外见下文)。
|
||||
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
|
||||
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: >-
|
||||
按 email 删除客户端。将其从每个挂载的入站中移除,并删除其流量记录,除非传入
|
||||
keepTraffic=1。
|
||||
|
||||
@@ -9053,9 +9053,9 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
|
||||
"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_email",
|
||||
"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.",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
@@ -9117,9 +9117,9 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
|
||||
"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. 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": "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",
|
||||
|
||||
@@ -9053,9 +9053,9 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
|
||||
"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_email",
|
||||
"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.",
|
||||
"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.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
@@ -9117,9 +9117,9 @@
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).",
|
||||
"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. 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": "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",
|
||||
|
||||
@@ -1276,9 +1276,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 — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
|
||||
'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:
|
||||
'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.',
|
||||
'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',
|
||||
@@ -1294,9 +1294,9 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/update',
|
||||
summary:
|
||||
'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload — supply the full set of fields you want to keep (the server replaces the row, it does not patch).',
|
||||
'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. 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.',
|
||||
'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',
|
||||
|
||||
@@ -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() : '';
|
||||
if (reverseTagValue) {
|
||||
clientPayload.reverse = { tag: reverseTagValue };
|
||||
}
|
||||
clientPayload.reverse = reverseTagValue ? { tag: reverseTagValue } : null;
|
||||
|
||||
if (showWireguard || showAmneziawg) {
|
||||
// AmneziaWG peers are wire-identical to WireGuard peers (same
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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' });
|
||||
});
|
||||
});
|
||||
@@ -267,16 +267,13 @@ func (a *ClientController) update(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
model.Client
|
||||
LimitHwid int `json:"limitHwid"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
body, err := c.GetRawData()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
|
||||
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
|
||||
needRestart, err := a.clientService.UpdateByEmailJSON(&a.inboundService, email, body, 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 {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -675,6 +675,12 @@ 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
|
||||
@@ -800,6 +806,14 @@ 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
|
||||
@@ -828,6 +842,9 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user