Procházet zdrojové kódy

fix(sub): restore client email in panel copy/QR link remark (#5532)

Display-context links (Clients page QR + Information modals and the sub info page) dropped the client email from the link fragment in 3.4.0, showing only the inbound remark. Append the email back so the imported profile keeps its per-client label: inbound-host-email when a host is set, inbound-email otherwise. The usage template stays bypassed in display context, so no traffic or expiry data leaks.
MHSanaei před 7 hodinami
rodič
revize
5dbd5b1d12

+ 1 - 1
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -357,7 +357,7 @@ export default function ClientInfoModal({
                   const parts = parseLinkParts(link);
                   const fallback = `${t('pages.clients.link')} ${idx + 1}`;
                   const rowTitle = (parts && linkMetaText(parts)) || fallback;
-                  const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle;
+                  const qrRemark = parts?.remark || rowTitle;
                   const canQr = !isPostQuantumLink(link);
                   return (
                     <div key={idx} className="link-row">

+ 6 - 6
internal/sub/characterization_test.go

@@ -45,8 +45,8 @@ func TestChar_C1_VlessExternalProxy(t *testing.T) {
 	}`
 	s := &SubService{}
 	got := s.genVlessLink(charVlessInbound(stream), "user")
-	want := "vless://[email protected]:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1\n" +
-		"vless://[email protected]:80?encryption=none&security=none&type=tcp#char-R2"
+	want := "vless://[email protected]:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1-user\n" +
+		"vless://[email protected]:80?encryption=none&security=none&type=tcp#char-R2-user"
 	if got != want {
 		t.Fatalf("C1 mismatch.\n got: %q\nwant: %q", got, want)
 	}
@@ -106,8 +106,8 @@ func TestChar_C2_VmessExternalProxy(t *testing.T) {
 	}
 	s := &SubService{}
 	got := s.genVmessLink(in, "user")
-	want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMSIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogInNuaTEuZXhhbXBsZS5jb20iLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9\n" +
-		"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMiIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
+	want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMS11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJzbmkiOiAic25pMS5leGFtcGxlLmNvbSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
+		"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMi11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJ0bHMiOiAibm9uZSIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ=="
 	if got != want {
 		t.Fatalf("C2 mismatch.\n got: %q\nwant: %q", got, want)
 	}
@@ -143,7 +143,7 @@ func TestChar_C3_TrojanExternalProxy(t *testing.T) {
 	}
 	s := &SubService{}
 	got := s.genTrojanLink(in, "user")
-	want := "trojan://p%40ss%2Fw%2Brd%[email protected]:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ"
+	want := "trojan://p%40ss%2Fw%2Brd%[email protected]:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ-user"
 	if got != want {
 		t.Fatalf("C3-Trojan mismatch.\n got: %q\nwant: %q", got, want)
 	}
@@ -168,7 +168,7 @@ func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
 	}
 	s := &SubService{}
 	got := s.genShadowsocksLink(in, "user")
-	want := "ss://2022-blake3-aes-256-gcm:inboundpw:[email protected]:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS"
+	want := "ss://2022-blake3-aes-256-gcm:inboundpw:[email protected]:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS-user"
 	if got != want {
 		t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
 	}

+ 4 - 4
internal/sub/endpoint_test.go

@@ -83,8 +83,8 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
 		func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
 		func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
 	)
-	want := "vless://[email protected]:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A\n" +
-		"vless://[email protected]:80?security=none&type=tcp#ib-B"
+	want := "vless://[email protected]:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A-user\n" +
+		"vless://[email protected]:80?security=none&type=tcp#ib-B-user"
 	if got != want {
 		t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", got, want)
 	}
@@ -105,8 +105,8 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
 		externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
 	}
 	got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
-	want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
-		"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
+	want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEtdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogImEuc25pIiwKICAidGxzIjogInRscyIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ==\n" +
+		"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUItdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
 	if got != want {
 		t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
 	}

+ 5 - 5
internal/sub/remark_vars.go

@@ -524,13 +524,13 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
 	return ctx.configName()
 }
 
-// genHostRemark builds one host endpoint's remark for a specific client. The
-// config name is always the inbound's own remark; the host's remark is surfaced
-// only through the {{HOST}} token. In the subscription body the rest of the
-// remark template still applies; displays show just the config name.
+// genHostRemark builds one host endpoint's remark for a specific client. In the
+// subscription body the {{HOST}} token carries the host's remark and the rest of
+// the template still applies; displays show the config name, host and email.
 func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
 	if !s.subscriptionBody {
-		return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
+		name := remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
+		return fallbackRemark(name, hostRemark, client.Email)
 	}
 	return s.genTemplatedRemark(inbound, client, hostRemark, transport)
 }

+ 9 - 17
internal/sub/remark_vars_test.go

@@ -206,12 +206,11 @@ func TestGenRemark_GlobalTemplate(t *testing.T) {
 	}
 }
 
-// With no template, genRemark composes the fallback model and adds no suffix.
-func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
+func TestGenRemark_NoTemplate_AppendsEmail(t *testing.T) {
 	s, inbound, _ := hostRemarkService("")
 	got := s.genRemark(inbound, "[email protected]", "Relay", "")
-	if got != "DE-Relay" {
-		t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
+	if got != "DE-Relay[email protected]" {
+		t.Fatalf("genRemark = %q, want %q", got, "DE-Relay[email protected]")
 	}
 }
 
@@ -232,24 +231,17 @@ func TestUsageOnFirstLinkOnly(t *testing.T) {
 	}
 }
 
-// Outside the subscription body (panel link/QR displays, sub info page) the
-// template is bypassed entirely — links show just the config name, with no
-// per-client email or usage info.
 func TestRemarkInDisplayContext(t *testing.T) {
 	s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
 	s.subscriptionBody = false
-	// A host link in a display shows only the config name — the inbound's remark,
-	// with no per-client email or usage info and the host remark ignored.
-	if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
-		t.Fatalf("display host link = %q, want config name %q", got, "DE")
+	if got := s.genHostRemark(inbound, client, "CDN", ""); got != "[email protected]" {
+		t.Fatalf("display host link = %q, want %q", got, "[email protected]")
 	}
-	// With no host remark, the config name is likewise the inbound's own remark.
-	if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
-		t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
+	if got := s.genHostRemark(inbound, client, "", ""); got != "[email protected]" {
+		t.Fatalf("display host link (no host) = %q, want %q", got, "[email protected]")
 	}
-	// genRemark (non-host) likewise drops the template in display context.
-	if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" {
-		t.Fatalf("display genRemark = %q, want %q", got, "DE")
+	if got := s.genRemark(inbound, client.Email, "", ""); got != "[email protected]" {
+		t.Fatalf("display genRemark = %q, want %q", got, "[email protected]")
 	}
 }
 

+ 10 - 17
internal/sub/service.go

@@ -1641,24 +1641,17 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
 	if s.remarkTemplate != "" && s.subscriptionBody {
 		return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
 	}
-	// Sub info page + panel link/QR displays: just the config name (no template,
-	// so no per-client email/usage leaks into the shown remark).
-	return fallbackRemark(inbound.Remark, extra)
-}
-
-// fallbackRemark is the minimal remark used only when no template is configured
-// (an operator explicitly cleared it): the inbound remark and the host/extra
-// remark joined by "-", skipping empties. The configurable remark model was
-// removed in favour of the template, whose default already includes the email.
-func fallbackRemark(inboundRemark, extra string) string {
-	switch {
-	case inboundRemark == "":
-		return extra
-	case extra == "":
-		return inboundRemark
-	default:
-		return inboundRemark + "-" + extra
+	return fallbackRemark(inbound.Remark, extra, email)
+}
+
+func fallbackRemark(parts ...string) string {
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		if p != "" {
+			out = append(out, p)
+		}
 	}
+	return strings.Join(out, "-")
 }
 
 // findClientStats returns the inbound's traffic record for email, if present.