Răsfoiți Sursa

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
Rouzbeh† 8 ore în urmă
părinte
comite
fe62c39a53

+ 1 - 0
frontend/src/lib/xray/inbound-defaults.ts

@@ -81,6 +81,7 @@ export function createDefaultVmessClient(seed: VmessClientSeed = {}): VmessClien
   return {
   return {
     id: seed.id ?? RandomUtil.randomUUID(),
     id: seed.id ?? RandomUtil.randomUUID(),
     security: seed.security ?? 'auto',
     security: seed.security ?? 'auto',
+    alterId: 0,
     ...clientBase(seed),
     ...clientBase(seed),
   };
   };
 }
 }

+ 1 - 1
frontend/src/schemas/protocols/inbound/hysteria.ts

@@ -10,7 +10,7 @@ export const HysteriaClientSchema = z.object({
   totalGB: z.number().int().min(0).default(0),
   totalGB: z.number().int().min(0).default(0),
   expiryTime: z.number().int().default(0),
   expiryTime: z.number().int().default(0),
   enable: z.boolean().default(true),
   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(''),
   subId: z.string().default(''),
   comment: z.string().default(''),
   comment: z.string().default(''),
   reset: z.number().int().min(0).default(0),
   reset: z.number().int().min(0).default(0),

+ 1 - 1
frontend/src/schemas/protocols/inbound/shadowsocks.ts

@@ -17,7 +17,7 @@ export const ShadowsocksClientSchema = z.object({
   totalGB: z.number().int().min(0).default(0),
   totalGB: z.number().int().min(0).default(0),
   expiryTime: z.number().int().default(0),
   expiryTime: z.number().int().default(0),
   enable: z.boolean().default(true),
   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(''),
   subId: z.string().default(''),
   comment: z.string().default(''),
   comment: z.string().default(''),
   reset: z.number().int().min(0).default(0),
   reset: z.number().int().min(0).default(0),

+ 1 - 1
frontend/src/schemas/protocols/inbound/trojan.ts

@@ -16,7 +16,7 @@ export const TrojanClientSchema = z.object({
   totalGB: z.number().int().min(0).default(0),
   totalGB: z.number().int().min(0).default(0),
   expiryTime: z.number().int().default(0),
   expiryTime: z.number().int().default(0),
   enable: z.boolean().default(true),
   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(''),
   subId: z.string().default(''),
   comment: z.string().default(''),
   comment: z.string().default(''),
   reset: z.number().int().min(0).default(0),
   reset: z.number().int().min(0).default(0),

+ 2 - 2
frontend/src/schemas/protocols/inbound/vless.ts

@@ -12,14 +12,14 @@ export const VlessFallbackSchema = z.object({
 export type VlessFallback = z.infer<typeof VlessFallbackSchema>;
 export type VlessFallback = z.infer<typeof VlessFallbackSchema>;
 
 
 export const VlessClientSchema = z.object({
 export const VlessClientSchema = z.object({
-  id: z.uuid(),
+  id: z.string().min(1),
   email: z.string().min(1),
   email: z.string().min(1),
   flow: FlowSchema.default(''),
   flow: FlowSchema.default(''),
   limitIp: z.number().int().min(0).default(0),
   limitIp: z.number().int().min(0).default(0),
   totalGB: z.number().int().min(0).default(0),
   totalGB: z.number().int().min(0).default(0),
   expiryTime: z.number().int().default(0),
   expiryTime: z.number().int().default(0),
   enable: z.boolean().default(true),
   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(''),
   subId: z.string().default(''),
   comment: z.string().default(''),
   comment: z.string().default(''),
   reset: z.number().int().min(0).default(0),
   reset: z.number().int().min(0).default(0),

+ 3 - 2
frontend/src/schemas/protocols/inbound/vmess.ts

@@ -3,14 +3,15 @@ import { z } from 'zod';
 import { VmessSecuritySchema } from '../shared/vmess';
 import { VmessSecuritySchema } from '../shared/vmess';
 
 
 export const VmessClientSchema = z.object({
 export const VmessClientSchema = z.object({
-  id: z.uuid(),
+  id: z.string().min(1),
   security: VmessSecuritySchema.default('auto'),
   security: VmessSecuritySchema.default('auto'),
+  alterId: z.number().int().min(0).default(0),
   email: z.string().min(1),
   email: z.string().min(1),
   limitIp: z.number().int().min(0).default(0),
   limitIp: z.number().int().min(0).default(0),
   totalGB: z.number().int().min(0).default(0),
   totalGB: z.number().int().min(0).default(0),
   expiryTime: z.number().int().default(0),
   expiryTime: z.number().int().default(0),
   enable: z.boolean().default(true),
   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(''),
   subId: z.string().default(''),
   comment: z.string().default(''),
   comment: z.string().default(''),
   reset: z.number().int().min(0).default(0),
   reset: z.number().int().min(0).default(0),

+ 1 - 0
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`] = `
 exports[`createDefaultVmessClient > produces a Zod-valid client 1`] = `
 {
 {
+  "alterId": 0,
   "comment": "",
   "comment": "",
   "email": "[email protected]",
   "email": "[email protected]",
   "enable": true,
   "enable": true,

+ 1 - 0
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": {
   "settings": {
     "clients": [
     "clients": [
       {
       {
+        "alterId": 0,
         "comment": "",
         "comment": "",
         "email": "[email protected]",
         "email": "[email protected]",
         "enable": true,
         "enable": true,

+ 1 - 0
frontend/src/test/__snapshots__/protocols.test.ts.snap

@@ -185,6 +185,7 @@ exports[`InboundSettingsSchema fixtures > parses vmess-basic byte-stably 1`] = `
   "settings": {
   "settings": {
     "clients": [
     "clients": [
       {
       {
+        "alterId": 0,
         "comment": "primary tester",
         "comment": "primary tester",
         "email": "[email protected]",
         "email": "[email protected]",
         "enable": true,
         "enable": true,

+ 25 - 32
frontend/src/utils/index.ts

@@ -576,49 +576,42 @@ export class ClipboardManager {
   }
   }
 
 
   static _legacyCopy(text: string): boolean {
   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 active = document.activeElement as HTMLElement | null;
-    const host = (active && active !== document.body && active.parentElement)
-      ? active.parentElement
-      : document.body;
-    host.appendChild(textarea);
-
-    const sel0 = document.getSelection();
-    const prevSelection = sel0 && sel0.rangeCount ? sel0.getRangeAt(0) : null;
+    const span = document.createElement('span');
+    span.textContent = text;
+    span.style.whiteSpace = 'pre';
+    span.style.position = 'absolute';
+    span.style.left = '-9999px';
+    span.style.top = '0';
+
+    document.body.appendChild(span);
+
+    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;
     let ok = false;
     try {
     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<string, unknown>)['execCommand'];
       const exec = (document as unknown as Record<string, unknown>)['execCommand'];
       if (typeof exec === 'function') {
       if (typeof exec === 'function') {
         ok = (exec as (cmd: string) => boolean).call(document, 'copy');
         ok = (exec as (cmd: string) => boolean).call(document, 'copy');
       }
       }
     } catch {}
     } catch {}
 
 
-    host.removeChild(textarea);
-    if (active && typeof active.focus === 'function') {
-      try { active.focus({ preventScroll: true }); } catch {}
-    }
+    selection.removeAllRanges();
     if (prevSelection) {
     if (prevSelection) {
-      const sel = document.getSelection();
-      sel?.removeAllRanges();
-      sel?.addRange(prevSelection);
+      selection.addRange(prevSelection);
     }
     }
+
+    document.body.removeChild(span);
     return ok;
     return ok;
   }
   }
 }
 }

+ 6 - 4
util/link/outbound.go

@@ -552,12 +552,13 @@ func buildStream(network, security string) map[string]any {
 	default:
 	default:
 		stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
 		stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
 	}
 	}
-	if security == "tls" {
+	switch security {
+	case "tls":
 		stream["tlsSettings"] = map[string]any{
 		stream["tlsSettings"] = map[string]any{
 			"serverName": "", "alpn": []any{}, "fingerprint": "",
 			"serverName": "", "alpn": []any{}, "fingerprint": "",
 			"echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
 			"echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
 		}
 		}
-	} else if security == "reality" {
+	case "reality":
 		stream["realitySettings"] = map[string]any{
 		stream["realitySettings"] = map[string]any{
 			"publicKey": "", "fingerprint": "chrome", "serverName": "",
 			"publicKey": "", "fingerprint": "chrome", "serverName": "",
 			"shortId": "", "spiderX": "", "mldsa65Verify": "",
 			"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) {
 func applySecurity(stream map[string]any, p url.Values) {
 	sec := stream["security"].(string)
 	sec := stream["security"].(string)
-	if sec == "tls" {
+	switch sec {
+	case "tls":
 		tls := stream["tlsSettings"].(map[string]any)
 		tls := stream["tlsSettings"].(map[string]any)
 		tls["serverName"] = p.Get("sni")
 		tls["serverName"] = p.Get("sni")
 		tls["fingerprint"] = p.Get("fp")
 		tls["fingerprint"] = p.Get("fp")
@@ -633,7 +635,7 @@ func applySecurity(stream map[string]any, p url.Values) {
 		}
 		}
 		tls["echConfigList"] = p.Get("ech")
 		tls["echConfigList"] = p.Get("ech")
 		tls["pinnedPeerCertSha256"] = p.Get("pcs")
 		tls["pinnedPeerCertSha256"] = p.Get("pcs")
-	} else if sec == "reality" {
+	case "reality":
 		re := stream["realitySettings"].(map[string]any)
 		re := stream["realitySettings"].(map[string]any)
 		re["serverName"] = p.Get("sni")
 		re["serverName"] = p.Get("sni")
 		re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")
 		re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")

+ 1 - 1
util/link/outbound_test.go

@@ -59,4 +59,4 @@ func TestSlugAndSuggest(t *testing.T) {
 	if tag != "hk-sg-01" {
 	if tag != "hk-sg-01" {
 		t.Errorf("suggest tag got %q", tag)
 		t.Errorf("suggest tag got %q", tag)
 	}
 	}
-}
+}

+ 1 - 1
web/job/outbound_subscription_job.go

@@ -45,4 +45,4 @@ func (j *OutboundSubscriptionJob) Run() {
 		// view (new outbounds will be visible after the reload cycle).
 		// view (new outbounds will be visible after the reload cycle).
 		websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds)
 		websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds)
 	}
 	}
-}
+}

+ 25 - 2
web/service/client.go

@@ -1493,13 +1493,27 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
 	if err != nil {
 	if err != nil {
 		return false, err
 		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 len(inboundIds) == 0 {
 		if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil {
 		if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil {
 			return false, rErr
 			return false, rErr
 		}
 		}
-		return false, nil
+		return needRestart, nil
 	}
 	}
-	needRestart := false
+
 	for _, ibId := range inboundIds {
 	for _, ibId := range inboundIds {
 		nr, rErr := inboundSvc.ResetClientTraffic(ibId, email)
 		nr, rErr := inboundSvc.ResetClientTraffic(ibId, email)
 		if rErr != nil {
 		if rErr != nil {
@@ -1809,6 +1823,15 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 		return 0, nil
 		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
 	affected := 0
 	err := submitTrafficWrite(func() error {
 	err := submitTrafficWrite(func() error {
 		db := database.GetDB()
 		db := database.GetDB()

+ 1 - 1
web/service/outbound_subscription.go

@@ -537,4 +537,4 @@ Consequences for balancers / routing:
 
 
 We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription
 We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription
 outbounds are always injected at runtime in GetXrayConfig.
 outbounds are always injected at runtime in GetXrayConfig.
-*/
+*/