From 5dbd5b1d126ee46176b739eb09a8fd271a54e979 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 24 Jun 2026 15:25:41 +0200 Subject: [PATCH] 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. --- .../src/pages/clients/ClientInfoModal.tsx | 2 +- internal/sub/characterization_test.go | 12 ++++----- internal/sub/endpoint_test.go | 8 +++--- internal/sub/remark_vars.go | 10 +++---- internal/sub/remark_vars_test.go | 26 +++++++------------ internal/sub/service.go | 23 ++++++---------- 6 files changed, 33 insertions(+), 48 deletions(-) diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index c3cb87df6..b755a571f 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/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 (
diff --git a/internal/sub/characterization_test.go b/internal/sub/characterization_test.go index acccb567c..416baba31 100644 --- a/internal/sub/characterization_test.go +++ b/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://11111111-2222-4333-8444-555555555555@cdn1.example.com:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1\n" + - "vless://11111111-2222-4333-8444-555555555555@cdn2.example.com:80?encryption=none&security=none&type=tcp#char-R2" + want := "vless://11111111-2222-4333-8444-555555555555@cdn1.example.com:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1-user\n" + + "vless://11111111-2222-4333-8444-555555555555@cdn2.example.com: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%3D@tj.example.com:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ" + want := "trojan://p%40ss%2Fw%2Brd%3D@tj.example.com: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:clientpw@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS" + want := "ss://2022-blake3-aes-256-gcm:inboundpw:clientpw@ss.example.com: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) } diff --git a/internal/sub/endpoint_test.go b/internal/sub/endpoint_test.go index b420982ce..230ef29f5 100644 --- a/internal/sub/endpoint_test.go +++ b/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://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A\n" + - "vless://uid@b.example.com:80?security=none&type=tcp#ib-B" + want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A-user\n" + + "vless://uid@b.example.com: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) } diff --git a/internal/sub/remark_vars.go b/internal/sub/remark_vars.go index 29eac0f8a..e408926d3 100644 --- a/internal/sub/remark_vars.go +++ b/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) } diff --git a/internal/sub/remark_vars_test.go b/internal/sub/remark_vars_test.go index 6c8c43a23..4a567238d 100644 --- a/internal/sub/remark_vars_test.go +++ b/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, "john@example.com", "Relay", "") - if got != "DE-Relay" { - t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay") + if got != "DE-Relay-john@example.com" { + t.Fatalf("genRemark = %q, want %q", got, "DE-Relay-john@example.com") } } @@ -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 != "DE-CDN-john@example.com" { + t.Fatalf("display host link = %q, want %q", got, "DE-CDN-john@example.com") } - // 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 != "DE-john@example.com" { + t.Fatalf("display host link (no host) = %q, want %q", got, "DE-john@example.com") } - // 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 != "DE-john@example.com" { + t.Fatalf("display genRemark = %q, want %q", got, "DE-john@example.com") } } diff --git a/internal/sub/service.go b/internal/sub/service.go index 76d9c26dd..a9e57b4b8 100644 --- a/internal/sub/service.go +++ b/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) + return fallbackRemark(inbound.Remark, extra, email) } -// 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 +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.