Browse Source

fix(sub): preserve external link names in Clash/JSON (#6049)

expandEntry cleared Name for external subscriptions and single links
with empty remark, so Clash/JSON fell back to the client email. Keep
each link's original name (#fragment / vmess ps); use the row remark
when set. Pass remark from the client form for single external links.

Fixes #6032
H-TTTTT 17 hours ago
parent
commit
11f602fe04

+ 11 - 2
frontend/src/pages/clients/ClientFormModal.tsx

@@ -48,6 +48,7 @@ const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
 interface ExternalLinkRow {
 interface ExternalLinkRow {
   kind: 'link' | 'subscription';
   kind: 'link' | 'subscription';
   value: string;
   value: string;
+  remark: string;
 }
 }
 
 
 interface ApiMsg<T = unknown> {
 interface ApiMsg<T = unknown> {
@@ -138,6 +139,7 @@ function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[
   return (links || []).map((l) => ({
   return (links || []).map((l) => ({
     kind: l.kind === 'subscription' ? 'subscription' : 'link',
     kind: l.kind === 'subscription' ? 'subscription' : 'link',
     value: l.value || '',
     value: l.value || '',
+    remark: l.remark || '',
   }));
   }));
 }
 }
 
 
@@ -207,7 +209,7 @@ export default function ClientFormModal({
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
 
 
   function addExternalLinkRow(kind: 'link' | 'subscription') {
   function addExternalLinkRow(kind: 'link' | 'subscription') {
-    appendExternalLink({ kind, value: '' });
+    appendExternalLink({ kind, value: '', remark: '' });
   }
   }
 
 
   useEffect(() => {
   useEffect(() => {
@@ -547,7 +549,7 @@ export default function ClientFormModal({
     }
     }
 
 
     const externalLinks: ExternalLinkInput[] = values.externalLinks
     const externalLinks: ExternalLinkInput[] = values.externalLinks
-      .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
+      .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: (r.remark || '').trim() }))
       .filter((r) => r.value !== '');
       .filter((r) => r.value !== '');
 
 
     setSubmitting(true);
     setSubmitting(true);
@@ -917,6 +919,13 @@ export default function ClientFormModal({
                                 placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
                                 placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
                               />
                               />
                             </FormField>
                             </FormField>
+                            <FormField name={`externalLinks.${index}.remark`} noStyle>
+                              <Input
+                                style={{ width: 140 }}
+                                aria-label={t('remark')}
+                                placeholder={t('remark')}
+                              />
+                            </FormField>
                             <Tooltip title={t('delete')}>
                             <Tooltip title={t('delete')}>
                               <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
                               <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
                             </Tooltip>
                             </Tooltip>

+ 45 - 4
internal/sub/external_config.go

@@ -74,18 +74,59 @@ func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLink
 }
 }
 
 
 // expandEntry turns one entry into the concrete share links it contributes. A
 // expandEntry turns one entry into the concrete share links it contributes. A
-// "subscription" entry is fetched (cached) and its links are kept with their own
-// names; a "link" entry yields the single link with the row's remark.
+// "subscription" entry is fetched (cached) and its links keep their own names
+// (URL #fragment / vmess ps). A "link" entry uses the row remark when set,
+// otherwise the link's original name — never blank, so Clash/JSON do not fall
+// back to the client email.
 func expandEntry(e externalLinkEntry) []expandedLink {
 func expandEntry(e externalLinkEntry) []expandedLink {
 	if e.Kind == model.ExternalLinkKindSubscription {
 	if e.Kind == model.ExternalLinkKindSubscription {
 		links := fetchSubscriptionLinks(e.Value)
 		links := fetchSubscriptionLinks(e.Value)
 		out := make([]expandedLink, 0, len(links))
 		out := make([]expandedLink, 0, len(links))
 		for _, l := range links {
 		for _, l := range links {
-			out = append(out, expandedLink{Link: l, Name: ""})
+			out = append(out, expandedLink{Link: l, Name: linkDisplayName(l)})
 		}
 		}
 		return out
 		return out
 	}
 	}
-	return []expandedLink{{Link: e.Value, Name: e.Remark}}
+	name := strings.TrimSpace(e.Remark)
+	if name == "" {
+		name = linkDisplayName(e.Value)
+	}
+	return []expandedLink{{Link: e.Value, Name: name}}
+}
+
+// linkDisplayName extracts the human-readable name already carried by a share
+// link: vmess JSON `ps`, or the URL #fragment for every other scheme.
+func linkDisplayName(rawLink string) string {
+	rawLink = strings.TrimSpace(rawLink)
+	if rawLink == "" {
+		return ""
+	}
+	if strings.HasPrefix(rawLink, "vmess://") {
+		b64 := strings.TrimPrefix(rawLink, "vmess://")
+		raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
+		if err != nil {
+			raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
+		}
+		if err != nil {
+			return ""
+		}
+		var j map[string]any
+		if err := json.Unmarshal(raw, &j); err != nil {
+			return ""
+		}
+		if ps, ok := j["ps"].(string); ok {
+			return strings.TrimSpace(ps)
+		}
+		return ""
+	}
+	if i := strings.IndexByte(rawLink, '#'); i >= 0 && i+1 < len(rawLink) {
+		frag := rawLink[i+1:]
+		if decoded, err := url.PathUnescape(frag); err == nil {
+			return strings.TrimSpace(decoded)
+		}
+		return strings.TrimSpace(frag)
+	}
+	return ""
 }
 }
 
 
 // applyRemarkToLink rewrites a share link's display name to remark (when set),
 // applyRemarkToLink rewrites a share link's display name to remark (when set),

+ 29 - 0
internal/sub/external_config_test.go

@@ -98,6 +98,35 @@ func TestExpandEntryLinkAppliesRemark(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestExpandEntryLinkFallsBackToOriginalName(t *testing.T) {
+	got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:8443#orig", Remark: ""})
+	if len(got) != 1 || got[0].Name != "orig" {
+		t.Fatalf("expandEntry empty remark = %#v, want Name=orig", got)
+	}
+}
+
+func TestLinkDisplayName(t *testing.T) {
+	payload := map[string]any{"v": "2", "ps": "NL-Node", "add": "1.2.3.4", "port": "443", "id": "uuid"}
+	b, _ := json.Marshal(payload)
+	vmess := "vmess://" + base64.StdEncoding.EncodeToString(b)
+
+	cases := []struct {
+		link string
+		want string
+	}{
+		{"vless://[email protected]:443#one", "one"},
+		{"trojan://[email protected]:8443#" + url.PathEscape("DE Node"), "DE Node"},
+		{vmess, "NL-Node"},
+		{"ss://def", ""},
+		{"", ""},
+	}
+	for _, c := range cases {
+		if got := linkDisplayName(c.link); got != c.want {
+			t.Errorf("linkDisplayName(%q) = %q, want %q", c.link, got, c.want)
+		}
+	}
+}
+
 func TestClashProxyFromExternalTrojanReality(t *testing.T) {
 func TestClashProxyFromExternalTrojanReality(t *testing.T) {
 	link := "trojan://[email protected]:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
 	link := "trojan://[email protected]:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
 	svc := NewSubClashService(false, "", NewSubService(""))
 	svc := NewSubClashService(false, "", NewSubService(""))