external_config.go 6.2 KB

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