mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 15:47:14 +00:00
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
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
// "subscription" entry is fetched (cached) and its links keep their own names
|
||||||
// names; a "link" entry yields the single link with the row's remark.
|
// (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),
|
||||||
|
|||||||
@@ -98,6 +98,35 @@ func TestExpandEntryLinkAppliesRemark(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExpandEntryLinkFallsBackToOriginalName(t *testing.T) {
|
||||||
|
got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindLink, Value: "trojan://pw@b.com: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://uuid@a.com:443#one", "one"},
|
||||||
|
{"trojan://pw@b.com: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://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
|
link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
|
||||||
svc := NewSubClashService(false, "", NewSubService(""))
|
svc := NewSubClashService(false, "", NewSubService(""))
|
||||||
|
|||||||
Reference in New Issue
Block a user