external_config.go 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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 request.
  14. // Active applies the owning client's enabled and expiry state.
  15. type externalLinkEntry struct {
  16. Kind string
  17. Value string
  18. Remark string
  19. NamePrefix string
  20. Email string
  21. Enable bool
  22. Active bool
  23. }
  24. // expandedLink is a single share link contributed by an entry, with the display
  25. // name to use (empty → keep the link's own remark / fall back to the email).
  26. type expandedLink struct {
  27. Link string
  28. Name string
  29. }
  30. // getClientExternalLinksBySubId returns active rows with owner state attached.
  31. // Consumers keep inactive owners as metadata but omit their link values.
  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. Active: rec.Enable && (rec.ExpiryTime <= 0 || rec.ExpiryTime > now),
  70. })
  71. }
  72. return out, nil
  73. }
  74. // expandEntry turns one entry into the concrete share links it contributes.
  75. // Names are never blank, so Clash/JSON do not fall back to the client email.
  76. func expandEntry(e externalLinkEntry) []expandedLink {
  77. if e.Kind == model.ExternalLinkKindSubscription {
  78. res := fetchSubscriptionLinks(e.Value)
  79. if res.fetched {
  80. recordExternalSubscriptionFetch(e.Value, res.err)
  81. }
  82. out := make([]expandedLink, 0, len(res.links))
  83. for _, l := range res.links {
  84. out = append(out, expandedLink{Link: l, Name: prefixedLinkName(linkDisplayName(l), e.NamePrefix, e.Email)})
  85. }
  86. return out
  87. }
  88. name := strings.TrimSpace(e.Remark)
  89. if name == "" {
  90. name = linkDisplayName(e.Value)
  91. }
  92. return []expandedLink{{Link: e.Value, Name: name}}
  93. }
  94. // linkDisplayName extracts the human-readable name already carried by a share
  95. // link: vmess JSON `ps`, or the URL #fragment for every other scheme.
  96. func linkDisplayName(rawLink string) string {
  97. rawLink = strings.TrimSpace(rawLink)
  98. if rawLink == "" {
  99. return ""
  100. }
  101. if after, ok := strings.CutPrefix(rawLink, "vmess://"); ok {
  102. b64 := after
  103. raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
  104. if err != nil {
  105. raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
  106. }
  107. if err != nil {
  108. return ""
  109. }
  110. var j map[string]any
  111. if err := json.Unmarshal(raw, &j); err != nil {
  112. return ""
  113. }
  114. if ps, ok := j["ps"].(string); ok {
  115. return strings.TrimSpace(ps)
  116. }
  117. return ""
  118. }
  119. if i := strings.IndexByte(rawLink, '#'); i >= 0 && i+1 < len(rawLink) {
  120. frag := rawLink[i+1:]
  121. if decoded, err := url.PathUnescape(frag); err == nil {
  122. return strings.TrimSpace(decoded)
  123. }
  124. return strings.TrimSpace(frag)
  125. }
  126. return ""
  127. }
  128. // prefixedLinkName falls back to the client email so a prefixed row never
  129. // renders as the bare prefix when the link carries no name of its own.
  130. func prefixedLinkName(displayName, prefix, fallback string) string {
  131. if strings.TrimSpace(prefix) == "" {
  132. return displayName
  133. }
  134. name := displayName
  135. if name == "" {
  136. name = strings.TrimSpace(fallback)
  137. }
  138. return prefix + name
  139. }
  140. // applyRemarkToLink rewrites a share link's display name to remark (when set),
  141. // leaving everything else byte-for-byte. vmess carries its remark in the base64
  142. // JSON `ps`; every other scheme carries it in the URL #fragment.
  143. func applyRemarkToLink(rawLink, remark string) string {
  144. rawLink = strings.TrimSpace(rawLink)
  145. if remark == "" {
  146. return rawLink
  147. }
  148. if strings.HasPrefix(rawLink, "vmess://") {
  149. return applyVmessRemark(rawLink, remark)
  150. }
  151. if i := strings.IndexByte(rawLink, '#'); i >= 0 {
  152. rawLink = rawLink[:i]
  153. }
  154. return rawLink + "#" + url.PathEscape(remark)
  155. }
  156. func applyVmessRemark(rawLink, remark string) string {
  157. b64 := strings.TrimPrefix(rawLink, "vmess://")
  158. raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
  159. if err != nil {
  160. raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
  161. }
  162. if err != nil {
  163. return rawLink
  164. }
  165. var j map[string]any
  166. if err := json.Unmarshal(raw, &j); err != nil {
  167. return rawLink
  168. }
  169. j["ps"] = remark
  170. nb, err := json.Marshal(j)
  171. if err != nil {
  172. return rawLink
  173. }
  174. return "vmess://" + base64.StdEncoding.EncodeToString(nb)
  175. }
  176. func padBase64Sub(s string) string {
  177. for len(s)%4 != 0 {
  178. s += "="
  179. }
  180. return s
  181. }
  182. // parsedExternalOutbound turns a pasted share link into a structured Xray
  183. // outbound (tagged "proxy") for the JSON subscription. Returns nil when the
  184. // link can't be parsed — the caller skips it.
  185. func parsedExternalOutbound(rawLink string) json_util.RawMessage {
  186. ob := parseExternalLink(rawLink)
  187. if ob == nil {
  188. return nil
  189. }
  190. ob["tag"] = "proxy"
  191. b, err := json.MarshalIndent(ob, "", " ")
  192. if err != nil {
  193. return nil
  194. }
  195. return b
  196. }
  197. // parseExternalLink parses a share link into the Xray outbound wire shape
  198. // (map), or nil if unsupported/invalid.
  199. func parseExternalLink(rawLink string) map[string]any {
  200. res, err := link.ParseLink(strings.TrimSpace(rawLink))
  201. if err != nil || res == nil || res.Outbound == nil {
  202. return nil
  203. }
  204. return map[string]any(res.Outbound)
  205. }