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.
This commit is contained in:
MHSanaei
2026-06-24 15:25:41 +02:00
parent bd60e770f4
commit 5dbd5b1d12
6 changed files with 33 additions and 48 deletions
@@ -357,7 +357,7 @@ export default function ClientInfoModal({
const parts = parseLinkParts(link); const parts = parseLinkParts(link);
const fallback = `${t('pages.clients.link')} ${idx + 1}`; const fallback = `${t('pages.clients.link')} ${idx + 1}`;
const rowTitle = (parts && linkMetaText(parts)) || fallback; 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); const canQr = !isPostQuantumLink(link);
return ( return (
<div key={idx} className="link-row"> <div key={idx} className="link-row">
+6 -6
View File
@@ -45,8 +45,8 @@ func TestChar_C1_VlessExternalProxy(t *testing.T) {
}` }`
s := &SubService{} s := &SubService{}
got := s.genVlessLink(charVlessInbound(stream), "user") 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" + 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" "vless://11111111-2222-4333-8444-555555555555@cdn2.example.com:80?encryption=none&security=none&type=tcp#char-R2-user"
if got != want { if got != want {
t.Fatalf("C1 mismatch.\n got: %q\nwant: %q", 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{} s := &SubService{}
got := s.genVmessLink(in, "user") got := s.genVmessLink(in, "user")
want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMSIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogInNuaTEuZXhhbXBsZS5jb20iLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9\n" + want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMS11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJzbmkiOiAic25pMS5leGFtcGxlLmNvbSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMiIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=" "vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMi11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJ0bHMiOiAibm9uZSIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ=="
if got != want { if got != want {
t.Fatalf("C2 mismatch.\n got: %q\nwant: %q", 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{} s := &SubService{}
got := s.genTrojanLink(in, "user") 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 { if got != want {
t.Fatalf("C3-Trojan mismatch.\n got: %q\nwant: %q", 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{} s := &SubService{}
got := s.genShadowsocksLink(in, "user") 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 { if got != want {
t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want) t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
} }
+4 -4
View File
@@ -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(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, "") }, 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" + 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" "vless://uid@b.example.com:80?security=none&type=tcp#ib-B-user"
if got != want { if got != want {
t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", 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"}), externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
} }
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp") got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" + want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEtdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogImEuc25pIiwKICAidGxzIjogInRscyIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ==\n" +
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9" "vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUItdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
if got != want { if got != want {
t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want) t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
} }
+5 -5
View File
@@ -524,13 +524,13 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
return ctx.configName() return ctx.configName()
} }
// genHostRemark builds one host endpoint's remark for a specific client. The // genHostRemark builds one host endpoint's remark for a specific client. In the
// config name is always the inbound's own remark; the host's remark is surfaced // subscription body the {{HOST}} token carries the host's remark and the rest of
// only through the {{HOST}} token. In the subscription body the rest of the // the template still applies; displays show the config name, host and email.
// remark template still applies; displays show just the config name.
func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string { func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
if !s.subscriptionBody { 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) return s.genTemplatedRemark(inbound, client, hostRemark, transport)
} }
+9 -17
View File
@@ -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_AppendsEmail(t *testing.T) {
func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
s, inbound, _ := hostRemarkService("") s, inbound, _ := hostRemarkService("")
got := s.genRemark(inbound, "john@example.com", "Relay", "") got := s.genRemark(inbound, "john@example.com", "Relay", "")
if got != "DE-Relay" { if got != "DE-Relay-john@example.com" {
t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay") 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) { func TestRemarkInDisplayContext(t *testing.T) {
s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D") s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
s.subscriptionBody = false s.subscriptionBody = false
// A host link in a display shows only the config name — the inbound's remark, if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE-CDN-john@example.com" {
// with no per-client email or usage info and the host remark ignored. t.Fatalf("display host link = %q, want %q", got, "DE-CDN-john@example.com")
if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
t.Fatalf("display host link = %q, want config name %q", got, "DE")
} }
// With no host remark, the config name is likewise the inbound's own remark. if got := s.genHostRemark(inbound, client, "", ""); got != "DE-john@example.com" {
if got := s.genHostRemark(inbound, client, "", ""); got != "DE" { t.Fatalf("display host link (no host) = %q, want %q", got, "DE-john@example.com")
t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
} }
// genRemark (non-host) likewise drops the template in display context. if got := s.genRemark(inbound, client.Email, "", ""); got != "DE-john@example.com" {
if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" { t.Fatalf("display genRemark = %q, want %q", got, "DE-john@example.com")
t.Fatalf("display genRemark = %q, want %q", got, "DE")
} }
} }
+8 -15
View File
@@ -1641,24 +1641,17 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
if s.remarkTemplate != "" && s.subscriptionBody { if s.remarkTemplate != "" && s.subscriptionBody {
return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport) return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
} }
// Sub info page + panel link/QR displays: just the config name (no template, return fallbackRemark(inbound.Remark, extra, email)
// 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 func fallbackRemark(parts ...string) string {
// (an operator explicitly cleared it): the inbound remark and the host/extra out := make([]string, 0, len(parts))
// remark joined by "-", skipping empties. The configurable remark model was for _, p := range parts {
// removed in favour of the template, whose default already includes the email. if p != "" {
func fallbackRemark(inboundRemark, extra string) string { out = append(out, p)
switch { }
case inboundRemark == "":
return extra
case extra == "":
return inboundRemark
default:
return inboundRemark + "-" + extra
} }
return strings.Join(out, "-")
} }
// findClientStats returns the inbound's traffic record for email, if present. // findClientStats returns the inbound's traffic record for email, if present.