From fe62c39a53c49e1d56eba6962f9a9141039da062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:55:55 +0200 Subject: [PATCH] fix: inbound edit validation failure and legacy copy to clipboard (#5132) * fix: auto-enable clients when resetting traffic When a client's traffic is exhausted, the panel automatically disables the client and pushes enable: false to the nodes. However, when an admin clicked 'Reset Traffic' or used bulk reset, the counters were zeroed but the client was left disabled. This forced administrators to manually re-enable the client across the central panel and remote nodes. This patch updates ResetTrafficByEmail and BulkResetTraffic to automatically set Enable: true for any previously disabled client and push the updated settings to nodes, ensuring the client is instantly restored upon traffic reset. * fix: inbound edit validation failure and legacy copy to clipboard --- frontend/src/lib/xray/inbound-defaults.ts | 1 + .../src/schemas/protocols/inbound/hysteria.ts | 2 +- .../schemas/protocols/inbound/shadowsocks.ts | 2 +- .../src/schemas/protocols/inbound/trojan.ts | 2 +- .../src/schemas/protocols/inbound/vless.ts | 4 +- .../src/schemas/protocols/inbound/vmess.ts | 5 +- .../inbound-defaults.test.ts.snap | 1 + .../__snapshots__/inbound-full.test.ts.snap | 1 + .../test/__snapshots__/protocols.test.ts.snap | 1 + frontend/src/utils/index.ts | 53 ++++++++----------- util/link/outbound.go | 10 ++-- util/link/outbound_test.go | 2 +- web/job/outbound_subscription_job.go | 2 +- web/service/client.go | 27 +++++++++- web/service/outbound_subscription.go | 2 +- 15 files changed, 69 insertions(+), 46 deletions(-) diff --git a/frontend/src/lib/xray/inbound-defaults.ts b/frontend/src/lib/xray/inbound-defaults.ts index 37912f0e6..6b945db65 100644 --- a/frontend/src/lib/xray/inbound-defaults.ts +++ b/frontend/src/lib/xray/inbound-defaults.ts @@ -81,6 +81,7 @@ export function createDefaultVmessClient(seed: VmessClientSeed = {}): VmessClien return { id: seed.id ?? RandomUtil.randomUUID(), security: seed.security ?? 'auto', + alterId: 0, ...clientBase(seed), }; } diff --git a/frontend/src/schemas/protocols/inbound/hysteria.ts b/frontend/src/schemas/protocols/inbound/hysteria.ts index c6e9fbc92..a9f14eed7 100644 --- a/frontend/src/schemas/protocols/inbound/hysteria.ts +++ b/frontend/src/schemas/protocols/inbound/hysteria.ts @@ -10,7 +10,7 @@ export const HysteriaClientSchema = z.object({ totalGB: z.number().int().min(0).default(0), expiryTime: z.number().int().default(0), enable: z.boolean().default(true), - tgId: z.number().int().default(0), + tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0), subId: z.string().default(''), comment: z.string().default(''), reset: z.number().int().min(0).default(0), diff --git a/frontend/src/schemas/protocols/inbound/shadowsocks.ts b/frontend/src/schemas/protocols/inbound/shadowsocks.ts index 9c18c22f4..9fd0e52a0 100644 --- a/frontend/src/schemas/protocols/inbound/shadowsocks.ts +++ b/frontend/src/schemas/protocols/inbound/shadowsocks.ts @@ -17,7 +17,7 @@ export const ShadowsocksClientSchema = z.object({ totalGB: z.number().int().min(0).default(0), expiryTime: z.number().int().default(0), enable: z.boolean().default(true), - tgId: z.number().int().default(0), + tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0), subId: z.string().default(''), comment: z.string().default(''), reset: z.number().int().min(0).default(0), diff --git a/frontend/src/schemas/protocols/inbound/trojan.ts b/frontend/src/schemas/protocols/inbound/trojan.ts index 587da3ea1..d77b26660 100644 --- a/frontend/src/schemas/protocols/inbound/trojan.ts +++ b/frontend/src/schemas/protocols/inbound/trojan.ts @@ -16,7 +16,7 @@ export const TrojanClientSchema = z.object({ totalGB: z.number().int().min(0).default(0), expiryTime: z.number().int().default(0), enable: z.boolean().default(true), - tgId: z.number().int().default(0), + tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0), subId: z.string().default(''), comment: z.string().default(''), reset: z.number().int().min(0).default(0), diff --git a/frontend/src/schemas/protocols/inbound/vless.ts b/frontend/src/schemas/protocols/inbound/vless.ts index 381cd7d59..666bbafd4 100644 --- a/frontend/src/schemas/protocols/inbound/vless.ts +++ b/frontend/src/schemas/protocols/inbound/vless.ts @@ -12,14 +12,14 @@ export const VlessFallbackSchema = z.object({ export type VlessFallback = z.infer; export const VlessClientSchema = z.object({ - id: z.uuid(), + id: z.string().min(1), email: z.string().min(1), flow: FlowSchema.default(''), limitIp: z.number().int().min(0).default(0), totalGB: z.number().int().min(0).default(0), expiryTime: z.number().int().default(0), enable: z.boolean().default(true), - tgId: z.number().int().default(0), + tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0), subId: z.string().default(''), comment: z.string().default(''), reset: z.number().int().min(0).default(0), diff --git a/frontend/src/schemas/protocols/inbound/vmess.ts b/frontend/src/schemas/protocols/inbound/vmess.ts index b16aded15..7be773d43 100644 --- a/frontend/src/schemas/protocols/inbound/vmess.ts +++ b/frontend/src/schemas/protocols/inbound/vmess.ts @@ -3,14 +3,15 @@ import { z } from 'zod'; import { VmessSecuritySchema } from '../shared/vmess'; export const VmessClientSchema = z.object({ - id: z.uuid(), + id: z.string().min(1), security: VmessSecuritySchema.default('auto'), + alterId: z.number().int().min(0).default(0), email: z.string().min(1), limitIp: z.number().int().min(0).default(0), totalGB: z.number().int().min(0).default(0), expiryTime: z.number().int().default(0), enable: z.boolean().default(true), - tgId: z.number().int().default(0), + tgId: z.union([z.number(), z.string()]).transform((v) => Number(v) || 0).default(0), subId: z.string().default(''), comment: z.string().default(''), reset: z.number().int().min(0).default(0), diff --git a/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap b/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap index 263572e29..b923cd4a6 100644 --- a/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-defaults.test.ts.snap @@ -129,6 +129,7 @@ exports[`createDefaultVlessClient > produces a Zod-valid client 1`] = ` exports[`createDefaultVmessClient > produces a Zod-valid client 1`] = ` { + "alterId": 0, "comment": "", "email": "fixture@example.test", "enable": true, diff --git a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap index f162cadce..87f3b1064 100644 --- a/frontend/src/test/__snapshots__/inbound-full.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-full.test.ts.snap @@ -505,6 +505,7 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] = "settings": { "clients": [ { + "alterId": 0, "comment": "", "email": "carol@example.test", "enable": true, diff --git a/frontend/src/test/__snapshots__/protocols.test.ts.snap b/frontend/src/test/__snapshots__/protocols.test.ts.snap index 40e980241..4c5d0b4a6 100644 --- a/frontend/src/test/__snapshots__/protocols.test.ts.snap +++ b/frontend/src/test/__snapshots__/protocols.test.ts.snap @@ -185,6 +185,7 @@ exports[`InboundSettingsSchema fixtures > parses vmess-basic byte-stably 1`] = ` "settings": { "clients": [ { + "alterId": 0, "comment": "primary tester", "email": "bob@example.test", "enable": true, diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 8545a23f3..dd5d12a4d 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -576,49 +576,42 @@ export class ClipboardManager { } static _legacyCopy(text: string): boolean { - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.setAttribute('aria-hidden', 'true'); - textarea.style.position = 'absolute'; - textarea.style.left = '-9999px'; - textarea.style.top = '0'; - textarea.style.opacity = '1'; + const span = document.createElement('span'); + span.textContent = text; + span.style.whiteSpace = 'pre'; + span.style.position = 'absolute'; + span.style.left = '-9999px'; + span.style.top = '0'; - const active = document.activeElement as HTMLElement | null; - const host = (active && active !== document.body && active.parentElement) - ? active.parentElement - : document.body; - host.appendChild(textarea); + document.body.appendChild(span); - const sel0 = document.getSelection(); - const prevSelection = sel0 && sel0.rangeCount ? sel0.getRangeAt(0) : null; + const selection = window.getSelection(); + if (!selection) { + document.body.removeChild(span); + return false; + } + + const prevSelection = selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + selection.removeAllRanges(); + const range = window.document.createRange(); + range.selectNodeContents(span); + selection.addRange(range); let ok = false; try { - textarea.focus({ preventScroll: true }); - textarea.select(); - textarea.setSelectionRange(0, text.length); - // Routed through a dynamic lookup so the @deprecated tag on - // Document.execCommand doesn't surface here. execCommand is the - // only copy path that works in insecure contexts (HTTP panels - // behind IP/localhost) — reached only after navigator.clipboard - // fails or is unavailable. const exec = (document as unknown as Record)['execCommand']; if (typeof exec === 'function') { ok = (exec as (cmd: string) => boolean).call(document, 'copy'); } } catch {} - host.removeChild(textarea); - if (active && typeof active.focus === 'function') { - try { active.focus({ preventScroll: true }); } catch {} - } + selection.removeAllRanges(); if (prevSelection) { - const sel = document.getSelection(); - sel?.removeAllRanges(); - sel?.addRange(prevSelection); + selection.addRange(prevSelection); } + + document.body.removeChild(span); return ok; } } diff --git a/util/link/outbound.go b/util/link/outbound.go index 77c8ec1eb..4b11bf7c7 100644 --- a/util/link/outbound.go +++ b/util/link/outbound.go @@ -552,12 +552,13 @@ func buildStream(network, security string) map[string]any { default: stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}} } - if security == "tls" { + switch security { + case "tls": stream["tlsSettings"] = map[string]any{ "serverName": "", "alpn": []any{}, "fingerprint": "", "echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "", } - } else if security == "reality" { + case "reality": stream["realitySettings"] = map[string]any{ "publicKey": "", "fingerprint": "chrome", "serverName": "", "shortId": "", "spiderX": "", "mldsa65Verify": "", @@ -624,7 +625,8 @@ func applyTransport(stream map[string]any, p url.Values) { func applySecurity(stream map[string]any, p url.Values) { sec := stream["security"].(string) - if sec == "tls" { + switch sec { + case "tls": tls := stream["tlsSettings"].(map[string]any) tls["serverName"] = p.Get("sni") tls["fingerprint"] = p.Get("fp") @@ -633,7 +635,7 @@ func applySecurity(stream map[string]any, p url.Values) { } tls["echConfigList"] = p.Get("ech") tls["pinnedPeerCertSha256"] = p.Get("pcs") - } else if sec == "reality" { + case "reality": re := stream["realitySettings"].(map[string]any) re["serverName"] = p.Get("sni") re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome") diff --git a/util/link/outbound_test.go b/util/link/outbound_test.go index 95b2ca9ca..1dbadaf00 100644 --- a/util/link/outbound_test.go +++ b/util/link/outbound_test.go @@ -59,4 +59,4 @@ func TestSlugAndSuggest(t *testing.T) { if tag != "hk-sg-01" { t.Errorf("suggest tag got %q", tag) } -} \ No newline at end of file +} diff --git a/web/job/outbound_subscription_job.go b/web/job/outbound_subscription_job.go index 0510a8a7d..412813e64 100644 --- a/web/job/outbound_subscription_job.go +++ b/web/job/outbound_subscription_job.go @@ -45,4 +45,4 @@ func (j *OutboundSubscriptionJob) Run() { // view (new outbounds will be visible after the reload cycle). websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds) } -} \ No newline at end of file +} diff --git a/web/service/client.go b/web/service/client.go index f2114eaed..f0579f359 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -1493,13 +1493,27 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st if err != nil { return false, err } + + needRestart := false + if !rec.Enable { + updated := rec.ToClient() + updated.Enable = true + nr, uErr := s.Update(inboundSvc, rec.Id, *updated) + if uErr != nil { + logger.Warning("Failed to auto-enable client during traffic reset:", uErr) + } + if nr { + needRestart = true + } + } + if len(inboundIds) == 0 { if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil { return false, rErr } - return false, nil + return needRestart, nil } - needRestart := false + for _, ibId := range inboundIds { nr, rErr := inboundSvc.ResetClientTraffic(ibId, email) if rErr != nil { @@ -1809,6 +1823,15 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st return 0, nil } + for _, e := range cleanEmails { + rec, err := s.GetRecordByEmail(nil, e) + if err == nil && !rec.Enable { + updated := rec.ToClient() + updated.Enable = true + s.Update(inboundSvc, rec.Id, *updated) + } + } + affected := 0 err := submitTrafficWrite(func() error { db := database.GetDB() diff --git a/web/service/outbound_subscription.go b/web/service/outbound_subscription.go index d5b206e6d..2c16e4bfd 100644 --- a/web/service/outbound_subscription.go +++ b/web/service/outbound_subscription.go @@ -537,4 +537,4 @@ Consequences for balancers / routing: We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription outbounds are always injected at runtime in GetXrayConfig. -*/ \ No newline at end of file +*/