sponsor.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. package panel
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "os"
  10. "path/filepath"
  11. "regexp"
  12. "slices"
  13. "strings"
  14. "sync"
  15. "time"
  16. "github.com/mhsanaei/3x-ui/v3/internal/config"
  17. "github.com/mhsanaei/3x-ui/v3/internal/logger"
  18. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  19. )
  20. // Sponsor is one paid placement published in the repo's sponsors.json.
  21. type Sponsor struct {
  22. ID string `json:"id" example:"acme-2026-10"`
  23. Name string `json:"name" example:"Acme VPS"`
  24. Enable *bool `json:"enable,omitempty" example:"true"`
  25. Slots []string `json:"slots"`
  26. Until time.Time `json:"until" example:"2026-11-01T00:00:00Z"`
  27. Logo string `json:"logo,omitempty" example:"/sponsors/logo/acme.png"`
  28. Title map[string]string `json:"title"`
  29. Text map[string]string `json:"text"`
  30. Link string `json:"link" example:"https://acme.example/?utm_source=3x-ui"`
  31. }
  32. // SponsorList is the active sponsor set plus the contact link for new sponsors.
  33. type SponsorList struct {
  34. Contact string `json:"contact,omitempty" example:"https://t.me/example"`
  35. Sponsors []Sponsor `json:"sponsors"`
  36. }
  37. const (
  38. sponsorsTTL = time.Hour
  39. sponsorsErrTTL = 10 * time.Minute
  40. maxSponsorsBytes = 256 << 10
  41. maxLogoBytes = 256 << 10
  42. maxSidebarSlots = 3
  43. localSponsorsDir = "../sponsors/3X"
  44. sponsorLogoPath = "/sponsors/logo/"
  45. )
  46. // ErrSponsorLogoUnknown rejects logo names not used by an active sponsor.
  47. var ErrSponsorLogoUnknown = errors.New("unknown sponsor logo")
  48. type sponsorLogo struct {
  49. data []byte
  50. contentType string
  51. err error
  52. retryAt time.Time
  53. }
  54. var (
  55. sponsorsURL = "https://sponsors.sanaei.dev/3X/sponsors.json"
  56. sponsorLogoBase = "https://sponsors.sanaei.dev/3X/logos/"
  57. sponsorNow = time.Now
  58. // The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
  59. sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
  60. sponsorSlots = map[string]bool{"dashboard": true, "sidebar": true, "page": true, "login": true}
  61. sponsorsMu sync.Mutex
  62. sponsorsRaw *SponsorList
  63. sponsorsErr error
  64. sponsorsRetryAt time.Time
  65. logosMu sync.Mutex
  66. logos = map[string]sponsorLogo{}
  67. )
  68. // GetSponsors returns the currently active sponsors. The remote file is cached,
  69. // but expiry is re-checked on every call so a slot ends exactly at Until.
  70. func (s *PanelService) GetSponsors() (*SponsorList, error) {
  71. raw, err := cachedSponsors()
  72. if err != nil {
  73. return nil, err
  74. }
  75. return activeSponsors(raw, sponsorNow()), nil
  76. }
  77. func cachedSponsors() (*SponsorList, error) {
  78. sponsorsMu.Lock()
  79. defer sponsorsMu.Unlock()
  80. now := sponsorNow()
  81. if !config.IsDebug() && now.Before(sponsorsRetryAt) {
  82. return sponsorsRaw, sponsorsErr
  83. }
  84. list, err := fetchSponsors()
  85. switch {
  86. case err == nil:
  87. sponsorsRaw, sponsorsErr, sponsorsRetryAt = list, nil, now.Add(sponsorsTTL)
  88. case sponsorsRaw != nil:
  89. // An upstream blip keeps the last good list, so paid slots do not blink out.
  90. logger.Debug("sponsors refresh failed, keeping last list:", err)
  91. sponsorsRetryAt = now.Add(sponsorsErrTTL)
  92. default:
  93. sponsorsErr, sponsorsRetryAt = err, now.Add(sponsorsErrTTL)
  94. }
  95. return sponsorsRaw, sponsorsErr
  96. }
  97. // GetSponsorLogo returns the image bytes for a logo of a currently active sponsor.
  98. func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) {
  99. // Validated here, not only via list membership, so name can never carry a path or URL.
  100. if !sponsorLogoRe.MatchString(name) {
  101. return nil, "", ErrSponsorLogoUnknown
  102. }
  103. sponsors, err := s.GetSponsors()
  104. if err != nil {
  105. return nil, "", err
  106. }
  107. if !slices.ContainsFunc(sponsors.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name }) {
  108. return nil, "", ErrSponsorLogoUnknown
  109. }
  110. logosMu.Lock()
  111. defer logosMu.Unlock()
  112. now := sponsorNow()
  113. l, ok := logos[name]
  114. if ok && !config.IsDebug() && now.Before(l.retryAt) {
  115. return l.data, l.contentType, l.err
  116. }
  117. // Failures are cached too: this route is public and each miss is an outbound fetch.
  118. data, contentType, err := fetchSponsorLogo(name)
  119. switch {
  120. case err == nil:
  121. l = sponsorLogo{data: data, contentType: contentType, retryAt: now.Add(sponsorsTTL)}
  122. case l.data != nil:
  123. l.retryAt = now.Add(sponsorsErrTTL)
  124. default:
  125. l = sponsorLogo{err: err, retryAt: now.Add(sponsorsErrTTL)}
  126. }
  127. logos[name] = l
  128. return l.data, l.contentType, l.err
  129. }
  130. func fetchSponsorLogo(name string) ([]byte, string, error) {
  131. data, err := readSponsorSource(sponsorLogoBase+name, filepath.Join(localSponsorsDir, "logos", name), maxLogoBytes)
  132. if err != nil {
  133. return nil, "", err
  134. }
  135. contentType := http.DetectContentType(data)
  136. switch contentType {
  137. case "image/png", "image/webp", "image/jpeg":
  138. return data, contentType, nil
  139. default:
  140. return nil, "", fmt.Errorf("sponsor logo %s has content type %s", name, contentType)
  141. }
  142. }
  143. // readSponsorSource reads a sibling checkout of MHSanaei/sponsors under XUI_DEBUG so
  144. // sponsor edits can be previewed locally before they are pushed.
  145. func readSponsorSource(url, localPath string, limit int) ([]byte, error) {
  146. if !config.IsDebug() {
  147. return fetchLimited(url, limit)
  148. }
  149. data, err := os.ReadFile(localPath)
  150. if err != nil {
  151. return nil, err
  152. }
  153. if len(data) > limit {
  154. return nil, fmt.Errorf("%s exceeds %d bytes", localPath, limit)
  155. }
  156. return data, nil
  157. }
  158. func fetchLimited(url string, limit int) ([]byte, error) {
  159. client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
  160. req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
  161. if err != nil {
  162. return nil, err
  163. }
  164. resp, err := client.Do(req)
  165. if err != nil {
  166. return nil, err
  167. }
  168. defer resp.Body.Close()
  169. if resp.StatusCode != http.StatusOK {
  170. return nil, fmt.Errorf("fetch %s returned status %d", url, resp.StatusCode)
  171. }
  172. body, err := io.ReadAll(io.LimitReader(resp.Body, int64(limit)+1))
  173. if err != nil {
  174. return nil, err
  175. }
  176. if len(body) > limit {
  177. return nil, fmt.Errorf("%s exceeds %d bytes", url, limit)
  178. }
  179. return body, nil
  180. }
  181. func fetchSponsors() (*SponsorList, error) {
  182. body, err := readSponsorSource(sponsorsURL, filepath.Join(localSponsorsDir, "sponsors.json"), maxSponsorsBytes)
  183. if err != nil {
  184. return nil, err
  185. }
  186. var list SponsorList
  187. if err := json.Unmarshal(body, &list); err != nil {
  188. return nil, err
  189. }
  190. return &list, nil
  191. }
  192. func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
  193. out := &SponsorList{Sponsors: []Sponsor{}}
  194. if raw == nil {
  195. return out
  196. }
  197. if strings.HasPrefix(raw.Contact, "https://") {
  198. out.Contact = raw.Contact
  199. }
  200. sidebarTaken := 0
  201. for _, sp := range raw.Sponsors {
  202. // A missing enable counts as on, so a forgotten field never hides a paid slot.
  203. disabled := sp.Enable != nil && !*sp.Enable
  204. if disabled || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
  205. continue
  206. }
  207. // A bad logo name drops only the logo; the paid slot still renders with its initial.
  208. if sponsorLogoRe.MatchString(sp.Logo) {
  209. sp.Logo = sponsorLogoPath + sp.Logo
  210. } else {
  211. sp.Logo = ""
  212. }
  213. slots := make([]string, 0, len(sp.Slots))
  214. for _, slot := range sp.Slots {
  215. // The sidebar rotates, so capping it keeps each paid card on screen long enough.
  216. if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) {
  217. continue
  218. }
  219. slots = append(slots, slot)
  220. }
  221. if len(slots) == 0 {
  222. continue
  223. }
  224. if slices.Contains(slots, "sidebar") {
  225. sidebarTaken++
  226. }
  227. sp.Slots = slots
  228. out.Sponsors = append(out.Sponsors, sp)
  229. }
  230. return out
  231. }