diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx index bf70abb01..0d8399316 100644 --- a/docs/content/docs/en/reference/api/clients.mdx +++ b/docs/content/docs/en/reference/api/clients.mdx @@ -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: `), 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 :` 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: `), and several failures @@ -690,7 +685,22 @@ _openapi: record is written after the inbounds, so a failure there is reported without an `inbound :` 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: `), 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 :` 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: `), and several failures diff --git a/docs/content/docs/fa/reference/api/clients.mdx b/docs/content/docs/fa/reference/api/clients.mdx index 8c3e73c83..ef08359ca 100644 --- a/docs/content/docs/fa/reference/api/clients.mdx +++ b/docs/content/docs/fa/reference/api/clients.mdx @@ -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. diff --git a/docs/content/docs/ru/reference/api/clients.mdx b/docs/content/docs/ru/reference/api/clients.mdx index b8cd10682..7d8dd6ff9 100644 --- a/docs/content/docs/ru/reference/api/clients.mdx +++ b/docs/content/docs/ru/reference/api/clients.mdx @@ -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. diff --git a/docs/content/docs/zh/reference/api/clients.mdx b/docs/content/docs/zh/reference/api/clients.mdx index 52da456f9..bfbb1b6ad 100644 --- a/docs/content/docs/zh/reference/api/clients.mdx +++ b/docs/content/docs/zh/reference/api/clients.mdx @@ -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。 diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 557c7a764..20cd85b2c 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -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: `), 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 :` 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: `), 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 :` 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: `), 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 :` 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: `), 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 :` prefix and leaves the inbound edits in place.", "parameters": [ { "name": "email", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 557c7a764..20cd85b2c 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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: `), 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 :` 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: `), 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 :` 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: `), 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 :` 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: `), 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 :` prefix and leaves the inbound edits in place.", "parameters": [ { "name": "email", diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index c968b440e..8ecb6fa30 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -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: `), 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 :` 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: `), 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 :` 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: `), 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 :` 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: `), 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 :` prefix and leaves the inbound edits in place.', params: [ { name: 'email', diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index e079ca354..e6e24e3e2 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -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 diff --git a/frontend/src/test/client-form-reverse.test.tsx b/frontend/src/test/client-form-reverse.test.tsx new file mode 100644 index 000000000..f22b1ee82 --- /dev/null +++ b/frontend/src/test/client-form-reverse.test.tsx @@ -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( + + + {}} + /> + + , + ); + 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; +} + +// 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' }); + }); +}); diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index 97c924410..41c990971 100644 --- a/internal/web/controller/client.go +++ b/internal/web/controller/client.go @@ -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 { diff --git a/internal/web/controller/client_partial_update_test.go b/internal/web/controller/client_partial_update_test.go new file mode 100644 index 000000000..017236118 --- /dev/null +++ b/internal/web/controller/client_partial_update_test.go @@ -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) + } + }) + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 7f1687fba..30a8cc640 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -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) { diff --git a/internal/web/service/client_partial_update.go b/internal/web/service/client_partial_update.go new file mode 100644 index 000000000..6ddbdd7d9 --- /dev/null +++ b/internal/web/service/client_partial_update.go @@ -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 +} diff --git a/internal/web/service/client_partial_update_test.go b/internal/web/service/client_partial_update_test.go new file mode 100644 index 000000000..0f50489e3 --- /dev/null +++ b/internal/web/service/client_partial_update_test.go @@ -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) + } +}