diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts index 6a2d2216..b73a457a 100644 --- a/frontend/src/env.d.ts +++ b/frontend/src/env.d.ts @@ -16,6 +16,7 @@ interface SubPageData { subClashUrl?: string; subTitle?: string; links?: string[]; + emails?: string[]; datepicker?: 'gregorian' | 'jalalian'; downloadByte?: string | number; uploadByte?: string | number; diff --git a/frontend/src/pages/sub/SubPage.css b/frontend/src/pages/sub/SubPage.css index fabeaac2..bc1bfd9a 100644 --- a/frontend/src/pages/sub/SubPage.css +++ b/frontend/src/pages/sub/SubPage.css @@ -61,58 +61,66 @@ .links-section { margin-top: 16px; + display: flex; + flex-direction: column; + gap: 8px; } -.link-row { - position: relative; - margin-bottom: 16px; - text-align: center; -} - -.link-tag { - margin-bottom: -10px; - position: relative; - z-index: 2; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); -} - -.link-box { - cursor: pointer; - border-radius: 12px; - padding: 22px 18px 14px; - margin-top: -10px; - word-break: break-all; - font-size: 13px; - line-height: 1.5; - text-align: left; - font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; - transition: background 120ms ease, border-color 120ms ease; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.08); +.sub-link-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 10px; background: rgba(0, 0, 0, 0.03); border: 1px solid rgba(0, 0, 0, 0.08); + transition: background 120ms ease, border-color 120ms ease; } -.link-box:hover { +.sub-link-row:hover { background: rgba(0, 0, 0, 0.05); border-color: rgba(0, 0, 0, 0.14); } -.link-copy-icon { - margin-right: 6px; - opacity: 0.6; -} - -.is-dark .link-box { +.is-dark .sub-link-row { background: rgba(0, 0, 0, 0.2); border-color: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.85); } -.is-dark .link-box:hover { +.is-dark .sub-link-row:hover { background: rgba(0, 0, 0, 0.3); border-color: rgba(255, 255, 255, 0.2); } +.sub-link-tag { + margin: 0; + flex-shrink: 0; + font-weight: 600; + letter-spacing: 0.3px; +} + +.sub-link-title { + flex: 1; + min-width: 0; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sub-link-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.sub-link-qr-popover { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + .apps-row { margin-top: 24px; } diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx index ebea13fd..8c06e2fb 100644 --- a/frontend/src/pages/sub/SubPage.tsx +++ b/frontend/src/pages/sub/SubPage.tsx @@ -23,6 +23,7 @@ import { DownOutlined, MoonFilled, MoonOutlined, + QrcodeOutlined, SunOutlined, TranslationOutlined, } from '@ant-design/icons'; @@ -51,6 +52,7 @@ const subJsonUrl = subData.subJsonUrl || ''; const subClashUrl = subData.subClashUrl || ''; const subTitle = subData.subTitle || ''; const links: string[] = Array.isArray(subData.links) ? subData.links : []; +const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : []; const datepicker = subData.datepicker || 'gregorian'; const isUnlimited = totalByte <= 0 && expireMs === 0; @@ -65,18 +67,72 @@ const isActive = (() => { return true; })(); -function linkName(link: string, idx: number): string { - if (!link) return `Link ${idx + 1}`; - const hashIdx = link.indexOf('#'); - if (hashIdx >= 0 && hashIdx + 1 < link.length) { +const PROTOCOL_COLORS: Record = { + VLESS: 'blue', + VMESS: 'geekblue', + TROJAN: 'volcano', + SS: 'magenta', + HYSTERIA: 'cyan', + HY2: 'green', +}; + +// Same idea as ClientInfoModal.trimEmail — strip the client email +// suffix from the remark so the row title isn't ugly twice. +function trimEmail(remark: string, email: string): string { + if (!email) return remark; + const e = email.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return remark + .replace(new RegExp(`[-_.\\s|]+${e}$`), '') + .replace(new RegExp(`^${e}[-_.\\s|]+`), '') + .trim(); +} + +// Post-quantum keys blow up the encoded URL past what a single QR can +// hold. The algorithm names don't appear as plain text in the URL — +// they ride inside query params: mldsa65Verify → `pqv=`, +// ML-KEM-768 → `encryption=mlkem768x25519plus.<...>`. The literal +// substrings are also matched in case a config (e.g. wireguard) embeds +// them directly. +function isPostQuantumLink(link: string): boolean { + if (/[?&]pqv=/.test(link)) return true; + if (link.includes('mlkem768') || link.includes('mldsa65')) return true; + if (link.includes('ML-KEM-768')) return true; + return false; +} + +function parseLinkMeta(link: string, idx: number): { protocol: string; remark: string } { + const fallback = `Link ${idx + 1}`; + if (!link) return { protocol: 'LINK', remark: fallback }; + const schemeMatch = /^([a-z0-9]+):\/\//i.exec(link); + const scheme = schemeMatch?.[1]?.toLowerCase() ?? ''; + const protocolMap: Record = { + vless: 'VLESS', + vmess: 'VMESS', + trojan: 'TROJAN', + ss: 'SS', + hysteria: 'HYSTERIA', + hysteria2: 'HY2', + hy2: 'HY2', + }; + const protocol = protocolMap[scheme] ?? scheme.toUpperCase() ?? 'LINK'; + + let remark = ''; + if (scheme === 'vmess') { try { - return decodeURIComponent(link.slice(hashIdx + 1)); - } catch { - return link.slice(hashIdx + 1); + const body = link.slice('vmess://'.length).split('#')[0]; + const json = JSON.parse(atob(body)) as { ps?: unknown }; + if (typeof json?.ps === 'string') remark = json.ps; + } catch { /* fall through */ } + } + if (!remark) { + const hashIdx = link.indexOf('#'); + if (hashIdx >= 0 && hashIdx + 1 < link.length) { + const raw = link.slice(hashIdx + 1); + try { remark = decodeURIComponent(raw); } + catch { remark = raw; } } } - const proto = link.split('://')[0]; - return `${proto.toUpperCase()} ${idx + 1}`; + return { protocol, remark: remark || fallback }; } export default function SubPage() { @@ -344,15 +400,65 @@ export default function SubPage() { {links.length > 0 && (
- {links.map((link, idx) => ( -
copy(link)}> - {linkName(link, idx)} -
- - {link} + {links.map((link, idx) => { + const meta = parseLinkMeta(link, idx); + const rowTitle = trimEmail(meta.remark, linkEmails[idx] || '') || meta.remark; + const canQr = !isPostQuantumLink(link); + return ( +
+ + {meta.protocol} + + + {rowTitle} + +
+
+ } + > +
-
- ))} + ); + })}
)} diff --git a/sub/links.go b/sub/links.go index c961ca95..b8bc5536 100644 --- a/sub/links.go +++ b/sub/links.go @@ -28,7 +28,7 @@ func (p *LinkProvider) build(host string) *SubService { func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) { svc := p.build(host) - links, _, _, err := svc.GetSubs(subId, host) + links, _, _, _, err := svc.GetSubs(subId, host) if err != nil { return nil, err } diff --git a/sub/subController.go b/sub/subController.go index 990c12e5..e5ef6d48 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -115,7 +115,7 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) { func (a *SUBController) subs(c *gin.Context) { subId := c.Param("subid") scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c) - subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host) + subs, emails, lastOnline, traffic, err := a.subService.GetSubs(subId, host) if err != nil || len(subs) == 0 { writeSubError(c, err) } else { @@ -139,7 +139,7 @@ func (a *SUBController) subs(c *gin.Context) { basePath = "/" } basePathStr := basePath.(string) - page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl) + page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl) a.serveSubPage(c, basePathStr, page) return } @@ -213,6 +213,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD "subJsonUrl": page.SubJsonUrl, "subClashUrl": page.SubClashUrl, "links": page.Result, + "emails": page.Emails, "datepicker": datepicker, } subDataJSON, err := json.Marshal(subData) diff --git a/sub/subService.go b/sub/subService.go index cedad581..d417e557 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -57,20 +57,21 @@ func (s *SubService) PrepareForRequest(host string) { } // GetSubs retrieves subscription links for a given subscription ID and host. -func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.ClientTraffic, error) { +func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) { s.PrepareForRequest(host) var result []string + var emails []string var traffic xray.ClientTraffic var lastOnline int64 var hasEnabledClient bool var clientTraffics []xray.ClientTraffic inbounds, err := s.getInboundsBySubId(subId) if err != nil { - return nil, 0, traffic, err + return nil, nil, 0, traffic, err } if len(inbounds) == 0 { - return nil, 0, traffic, nil + return nil, nil, 0, traffic, nil } s.datepicker, err = s.settingService.GetDatepicker() @@ -99,6 +100,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C hasEnabledClient = true } result = append(result, s.GetLink(inbound, client.Email)) + emails = append(emails, client.Email) var ct xray.ClientTraffic ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email) if ct.LastOnline > lastOnline { @@ -130,7 +132,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C } } traffic.Enable = hasEnabledClient - return result, lastOnline, traffic, nil + return result, emails, lastOnline, traffic, nil } func subscriptionExpiryFromClient(nowMs, expiryTime int64) int64 { @@ -1708,6 +1710,7 @@ type PageData struct { SubTitle string SubSupportUrl string Result []string + Emails []string } // ResolveRequest extracts scheme and host info from request/headers consistently. @@ -1821,7 +1824,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string { // BuildPageData parses header and prepares the template view model. // BuildPageData constructs page data for rendering the subscription information page. -func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string, subTitle string, subSupportUrl string) PageData { +func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, emails []string, subURL, subJsonURL, subClashURL string, basePath string, subTitle string, subSupportUrl string) PageData { download := common.FormatTraffic(traffic.Down) upload := common.FormatTraffic(traffic.Up) total := "∞" @@ -1860,6 +1863,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray SubTitle: subTitle, SubSupportUrl: subSupportUrl, Result: subs, + Emails: emails, } }