1
0

external_config.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. package sub
  2. import (
  3. "encoding/base64"
  4. "net/url"
  5. "strings"
  6. "github.com/goccy/go-json"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  9. "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
  10. "github.com/mhsanaei/3x-ui/v3/internal/util/link"
  11. )
  12. // externalLinkEntry is one client × external-link row, resolved for a
  13. // subscription request. Email/Enable come from the owning client.
  14. type externalLinkEntry struct {
  15. Kind string
  16. Value string
  17. Remark string
  18. Email string
  19. Enable bool
  20. }
  21. // expandedLink is a single share link contributed by an entry, with the display
  22. // name to use (empty → keep the link's own remark / fall back to the email).
  23. type expandedLink struct {
  24. Link string
  25. Name string
  26. }
  27. // getClientExternalLinksBySubId returns every external-link row attached to a
  28. // client that carries the given subId, in stable order. Stays inside
  29. // internal/sub + database + util/link — no dependency on the panel service layer.
  30. func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
  31. db := database.GetDB()
  32. var recs []model.ClientRecord
  33. if err := db.Where("sub_id = ?", subId).Find(&recs).Error; err != nil {
  34. return nil, err
  35. }
  36. if len(recs) == 0 {
  37. return nil, nil
  38. }
  39. clientIds := make([]int, 0, len(recs))
  40. byId := make(map[int]model.ClientRecord, len(recs))
  41. for _, rec := range recs {
  42. clientIds = append(clientIds, rec.Id)
  43. byId[rec.Id] = rec
  44. }
  45. var rows []model.ClientExternalLink
  46. if err := db.Where("client_id IN ?", clientIds).
  47. Order("client_id ASC, sort_index ASC, id ASC").
  48. Find(&rows).Error; err != nil {
  49. return nil, err
  50. }
  51. if len(rows) == 0 {
  52. return nil, nil
  53. }
  54. out := make([]externalLinkEntry, 0, len(rows))
  55. for _, r := range rows {
  56. rec := byId[r.ClientId]
  57. out = append(out, externalLinkEntry{
  58. Kind: r.Kind,
  59. Value: r.Value,
  60. Remark: r.Remark,
  61. Email: rec.Email,
  62. Enable: rec.Enable,
  63. })
  64. }
  65. return out, nil
  66. }
  67. // expandEntry turns one entry into the concrete share links it contributes. A
  68. // "subscription" entry is fetched (cached) and its links keep their own names
  69. // (URL #fragment / vmess ps). A "link" entry uses the row remark when set,
  70. // otherwise the link's original name — never blank, so Clash/JSON do not fall
  71. // back to the client email.
  72. func expandEntry(e externalLinkEntry) []expandedLink {
  73. if e.Kind == model.ExternalLinkKindSubscription {
  74. links := fetchSubscriptionLinks(e.Value)
  75. out := make([]expandedLink, 0, len(links))
  76. for _, l := range links {
  77. out = append(out, expandedLink{Link: l, Name: linkDisplayName(l)})
  78. }
  79. return out
  80. }
  81. name := strings.TrimSpace(e.Remark)
  82. if name == "" {
  83. name = linkDisplayName(e.Value)
  84. }
  85. return []expandedLink{{Link: e.Value, Name: name}}
  86. }
  87. // linkDisplayName extracts the human-readable name already carried by a share
  88. // link: vmess JSON `ps`, or the URL #fragment for every other scheme.
  89. func linkDisplayName(rawLink string) string {
  90. rawLink = strings.TrimSpace(rawLink)
  91. if rawLink == "" {
  92. return ""
  93. }
  94. if strings.HasPrefix(rawLink, "vmess://") {
  95. b64 := strings.TrimPrefix(rawLink, "vmess://")
  96. raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
  97. if err != nil {
  98. raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
  99. }
  100. if err != nil {
  101. return ""
  102. }
  103. var j map[string]any
  104. if err := json.Unmarshal(raw, &j); err != nil {
  105. return ""
  106. }
  107. if ps, ok := j["ps"].(string); ok {
  108. return strings.TrimSpace(ps)
  109. }
  110. return ""
  111. }
  112. if i := strings.IndexByte(rawLink, '#'); i >= 0 && i+1 < len(rawLink) {
  113. frag := rawLink[i+1:]
  114. if decoded, err := url.PathUnescape(frag); err == nil {
  115. return strings.TrimSpace(decoded)
  116. }
  117. return strings.TrimSpace(frag)
  118. }
  119. return ""
  120. }
  121. // applyRemarkToLink rewrites a share link's display name to remark (when set),
  122. // leaving everything else byte-for-byte. vmess carries its remark in the base64
  123. // JSON `ps`; every other scheme carries it in the URL #fragment.
  124. func applyRemarkToLink(rawLink, remark string) string {
  125. rawLink = strings.TrimSpace(rawLink)
  126. if remark == "" {
  127. return rawLink
  128. }
  129. if strings.HasPrefix(rawLink, "vmess://") {
  130. return applyVmessRemark(rawLink, remark)
  131. }
  132. if i := strings.IndexByte(rawLink, '#'); i >= 0 {
  133. rawLink = rawLink[:i]
  134. }
  135. return rawLink + "#" + url.PathEscape(remark)
  136. }
  137. func applyVmessRemark(rawLink, remark string) string {
  138. b64 := strings.TrimPrefix(rawLink, "vmess://")
  139. raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
  140. if err != nil {
  141. raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
  142. }
  143. if err != nil {
  144. return rawLink
  145. }
  146. var j map[string]any
  147. if err := json.Unmarshal(raw, &j); err != nil {
  148. return rawLink
  149. }
  150. j["ps"] = remark
  151. nb, err := json.Marshal(j)
  152. if err != nil {
  153. return rawLink
  154. }
  155. return "vmess://" + base64.StdEncoding.EncodeToString(nb)
  156. }
  157. func padBase64Sub(s string) string {
  158. for len(s)%4 != 0 {
  159. s += "="
  160. }
  161. return s
  162. }
  163. // parsedExternalOutbound turns a pasted share link into a structured Xray
  164. // outbound (tagged "proxy") for the JSON subscription. Returns nil when the
  165. // link can't be parsed — the caller skips it.
  166. func parsedExternalOutbound(rawLink string) json_util.RawMessage {
  167. ob := parseExternalLink(rawLink)
  168. if ob == nil {
  169. return nil
  170. }
  171. ob["tag"] = "proxy"
  172. b, err := json.MarshalIndent(ob, "", " ")
  173. if err != nil {
  174. return nil
  175. }
  176. return b
  177. }
  178. // parseExternalLink parses a share link into the Xray outbound wire shape
  179. // (map), or nil if unsupported/invalid.
  180. func parseExternalLink(rawLink string) map[string]any {
  181. res, err := link.ParseLink(strings.TrimSpace(rawLink))
  182. if err != nil || res == nil || res.Outbound == nil {
  183. return nil
  184. }
  185. return map[string]any(res.Outbound)
  186. }