From b0c1156dd686012e5ab0c4025c6d3b1d730716f7 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 24 Jun 2026 16:45:23 +0200 Subject: [PATCH] fix(sub): drive display remarks from the template and split multi-host subpage links Unify remark generation around the Remark Template. Display contexts (Clients-page QR/Info modals and the HTML sub info page) now render the template name-only client/identity part instead of a hardcoded fallback; the subscription body keeps the full template on a client first link and name-only thereafter. The default template gains the email token so the client email shows by default again (#5532). BuildPageData now splits each multi-link entry (one link per host of an inbound) into a separate row, so the sub page no longer collapses several host links onto a single mangled line. QR captions on the Clients QR modal and the sub page reuse the link fragment remark. --- frontend/src/models/setting.ts | 2 +- frontend/src/pages/clients/ClientQrModal.tsx | 2 +- frontend/src/pages/sub/SubPage.tsx | 2 +- internal/sub/page_data_test.go | 37 +++++++++++++++++++ internal/sub/remark_vars.go | 25 +++++++------ internal/sub/remark_vars_test.go | 38 +++++++++++--------- internal/sub/service.go | 26 ++++++++++---- internal/web/service/setting.go | 2 +- 8 files changed, 97 insertions(+), 37 deletions(-) create mode 100644 internal/sub/page_data_test.go diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index c6ee53792..bf498fc21 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -13,7 +13,7 @@ export class AllSetting { pageSize = 25; expireDiff = 0; trafficDiff = 0; - remarkTemplate = '{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D'; + remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D'; datepicker: 'gregorian' | 'jalalian' = 'gregorian'; tgBotEnable = false; tgBotToken = ''; diff --git a/frontend/src/pages/clients/ClientQrModal.tsx b/frontend/src/pages/clients/ClientQrModal.tsx index be0dbcb8e..0b90ba7d3 100644 --- a/frontend/src/pages/clients/ClientQrModal.tsx +++ b/frontend/src/pages/clients/ClientQrModal.tsx @@ -106,7 +106,7 @@ export default function ClientQrModal({ children: ( ), diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx index 38d247b4c..7679dd6e5 100644 --- a/frontend/src/pages/sub/SubPage.tsx +++ b/frontend/src/pages/sub/SubPage.tsx @@ -421,7 +421,7 @@ export default function SubPage() { const parts = parseLinkParts(link); const fallback = `Link ${idx + 1}`; const rowTitle = parts?.remark || fallback; - const qrLabel = [parts?.remark, linkEmails[idx]].filter(Boolean).join('-') || rowTitle; + const qrLabel = parts?.remark || rowTitle; const canQr = !isPostQuantumLink(link); return (
diff --git a/internal/sub/page_data_test.go b/internal/sub/page_data_test.go new file mode 100644 index 000000000..de95a1a4e --- /dev/null +++ b/internal/sub/page_data_test.go @@ -0,0 +1,37 @@ +package sub + +import ( + "reflect" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// A single getSubs entry can hold several links (one per host of an inbound) +// joined by newlines. BuildPageData must split them into one entry per link, with +// the email replicated, so the subpage renders one row per host instead of +// collapsing them onto a single mangled line. +func TestBuildPageData_SplitsMultiHostLinks(t *testing.T) { + s := &SubService{} + subs := []string{ + "vless://a@h1:443?type=tcp#DE-john@x\nvless://a@h2:443?type=tcp#DE-john@x\nvless://a@h3:443?type=tcp#DE-john@x", + "vless://b@h:443?type=tcp#FR-alice@x", + } + emails := []string{"john@x", "alice@x"} + + page := s.BuildPageData("s1", "", xray.ClientTraffic{}, 0, subs, emails, "", "", "", "/", "", "") + + if len(page.Result) != 4 { + t.Fatalf("Result len = %d, want 4 (3 host links + 1 single link)", len(page.Result)) + } + for i, link := range page.Result { + if strings.Contains(link, "\n") { + t.Fatalf("Result[%d] still multi-line: %q", i, link) + } + } + wantEmails := []string{"john@x", "john@x", "john@x", "alice@x"} + if !reflect.DeepEqual(page.Emails, wantEmails) { + t.Fatalf("Emails = %v, want %v", page.Emails, wantEmails) + } +} diff --git a/internal/sub/remark_vars.go b/internal/sub/remark_vars.go index e408926d3..98ca7bbc5 100644 --- a/internal/sub/remark_vars.go +++ b/internal/sub/remark_vars.go @@ -491,7 +491,7 @@ func nameOnlyTemplate(template string) string { // effectiveTemplate picks which template to expand for one body link: the full // template (with the per-client info) for a client's first link, and the // name-only template for every link thereafter — so the info shows once. Only -// called in the subscription-body context (displays bypass the template). +// called in the subscription-body context (displays render name-only directly). func (s *SubService) effectiveTemplate(email string) string { translated := translateUISingleBrackets(s.remarkTemplate) if s.usageShown == nil { @@ -515,22 +515,25 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli hostRemark: hostRemark, transport: transport, } - tmpl := s.effectiveTemplate(client.Email) - // Fall back to the config name when the template is empty or expands to - // nothing (e.g. an all-unlimited template whose only segments dropped out). + var tmpl string + if s.subscriptionBody { + tmpl = s.effectiveTemplate(client.Email) + } else { + tmpl = nameOnlyTemplate(translateUISingleBrackets(s.remarkTemplate)) + } if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" { return out } return ctx.configName() } -// 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. +// genHostRemark builds one host endpoint's remark for a specific client. With a +// remark template set it is template-driven (body shows the full template on the +// first link and the name-only part thereafter; displays render the name-only +// part). With no template it falls back to inbound, host and email joined by "-". func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string { - if !s.subscriptionBody { - name := remarkContext{inbound: inbound, hostRemark: hostRemark}.configName() - return fallbackRemark(name, hostRemark, client.Email) + if s.remarkTemplate != "" { + return s.genTemplatedRemark(inbound, client, hostRemark, transport) } - return s.genTemplatedRemark(inbound, client, hostRemark, transport) + return fallbackRemark(inbound.Remark, hostRemark, client.Email) } diff --git a/internal/sub/remark_vars_test.go b/internal/sub/remark_vars_test.go index 4a567238d..aa0aad810 100644 --- a/internal/sub/remark_vars_test.go +++ b/internal/sub/remark_vars_test.go @@ -164,15 +164,15 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie return s, inbound, client } -// The config name is always the inbound's own remark; the host endpoint's remark -// never substitutes it (it is reachable only through {{HOST}}). -func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) { - s, inbound, client := hostRemarkService("") // no template → config name only - if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE" { - t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE") +// With no template configured, genHostRemark falls back to the inbound remark, +// host and email joined by "-". +func TestGenHostRemark_NoTemplate_Fallback(t *testing.T) { + s, inbound, client := hostRemarkService("") + if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE-Relay-john@example.com" { + t.Fatalf("genHostRemark = %q, want %q", got, "DE-Relay-john@example.com") } - if got := s.genHostRemark(inbound, client, "", ""); got != "DE" { - t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE") + if got := s.genHostRemark(inbound, client, "", ""); got != "DE-john@example.com" { + t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE-john@example.com") } } @@ -232,23 +232,29 @@ func TestUsageOnFirstLinkOnly(t *testing.T) { } func TestRemarkInDisplayContext(t *testing.T) { - s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D") + s, inbound, client := hostRemarkService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D") s.subscriptionBody = false - 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") + const want = "DE-john@example.com" + if got := s.genHostRemark(inbound, client, "CDN", ""); got != want { + t.Fatalf("display host link = %q, want %q", got, want) } - 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") + if got := s.genHostRemark(inbound, client, "", ""); got != want { + t.Fatalf("display host link (no host) = %q, want %q", got, want) } - if got := s.genRemark(inbound, client.Email, "", ""); got != "DE-john@example.com" { - t.Fatalf("display genRemark = %q, want %q", got, "DE-john@example.com") + if got := s.genRemark(inbound, client.Email, "", ""); got != want { + t.Fatalf("display genRemark = %q, want %q", got, want) + } + s2, inbound2, client2 := hostRemarkService("{{INBOUND}}-{{HOST}}|📊{{TRAFFIC_LEFT}}") + s2.subscriptionBody = false + if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE-CDN" { + t.Fatalf("display host link with HOST token = %q, want %q", got, "DE-CDN") } } // nameOnlyTemplate drops the info part (and its leading decoration), keeping name. func TestNameOnlyTemplate(t *testing.T) { cases := map[string]string{ - "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}", // the default → name only + "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}", // usage tail stripped "{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}": "{{EMAIL}} {{INBOUND}}", // multi-token name survives the trim "{{INBOUND}} | {{STATUS}}": "{{INBOUND}}", "{{INBOUND}}-{{EMAIL}}": "{{INBOUND}}-{{EMAIL}}", // no info tokens → unchanged diff --git a/internal/sub/service.go b/internal/sub/service.go index a9e57b4b8..4bded42af 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -1634,11 +1634,12 @@ func cloneStringMap(source map[string]string) map[string]string { } // genRemark builds the remark for a non-host link (raw default / legacy -// externalProxy / synthetic JSON-Clash entry). In the subscription body a set -// remark template takes over; otherwise (and in every display context) the -// remark is just the config name (inbound remark, then extra). +// externalProxy / synthetic JSON-Clash entry). A set remark template drives it +// in both the body and display contexts (genTemplatedRemark renders the +// name-only part on displays); with no template it falls back to the inbound +// remark, extra and email joined by "-". func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, transport string) string { - if s.remarkTemplate != "" && s.subscriptionBody { + if s.remarkTemplate != "" { return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport) } return fallbackRemark(inbound.Remark, extra, email) @@ -2336,6 +2337,19 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray datepicker = "gregorian" } + pageLinks := make([]string, 0, len(subs)) + pageEmails := make([]string, 0, len(subs)) + for i, sub := range subs { + email := "" + if i < len(emails) { + email = emails[i] + } + for _, link := range splitLinkLines(sub) { + pageLinks = append(pageLinks, link) + pageEmails = append(pageEmails, email) + } + } + return PageData{ Host: hostHeader, BasePath: basePath, @@ -2357,8 +2371,8 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray SubClashUrl: subClashURL, SubTitle: subTitle, SubSupportUrl: subSupportUrl, - Result: subs, - Emails: emails, + Result: pageLinks, + Emails: pageEmails, } } diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 15e2a9228..2bf5b13c6 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -55,7 +55,7 @@ var defaultValueMap = map[string]string{ "pageSize": "25", "expireDiff": "0", "trafficDiff": "0", - "remarkTemplate": "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D", + "remarkTemplate": "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D", "timeLocation": "Local", "tgBotEnable": "false", "tgBotToken": "",