sponsor.go 7.6 KB

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