sponsor.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  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. sponsors, err := s.GetSponsors()
  100. if err != nil {
  101. return nil, "", err
  102. }
  103. if !slices.ContainsFunc(sponsors.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name }) {
  104. return nil, "", ErrSponsorLogoUnknown
  105. }
  106. logosMu.Lock()
  107. defer logosMu.Unlock()
  108. now := sponsorNow()
  109. l, ok := logos[name]
  110. if ok && !config.IsDebug() && now.Before(l.retryAt) {
  111. return l.data, l.contentType, l.err
  112. }
  113. // Failures are cached too: this route is public and each miss is an outbound fetch.
  114. data, contentType, err := fetchSponsorLogo(name)
  115. switch {
  116. case err == nil:
  117. l = sponsorLogo{data: data, contentType: contentType, retryAt: now.Add(sponsorsTTL)}
  118. case l.data != nil:
  119. l.retryAt = now.Add(sponsorsErrTTL)
  120. default:
  121. l = sponsorLogo{err: err, retryAt: now.Add(sponsorsErrTTL)}
  122. }
  123. logos[name] = l
  124. return l.data, l.contentType, l.err
  125. }
  126. func fetchSponsorLogo(name string) ([]byte, string, error) {
  127. data, err := readSponsorSource(sponsorLogoBase+name, filepath.Join(localSponsorsDir, "logos", name), maxLogoBytes)
  128. if err != nil {
  129. return nil, "", err
  130. }
  131. contentType := http.DetectContentType(data)
  132. switch contentType {
  133. case "image/png", "image/webp", "image/jpeg":
  134. return data, contentType, nil
  135. default:
  136. return nil, "", fmt.Errorf("sponsor logo %s has content type %s", name, contentType)
  137. }
  138. }
  139. // readSponsorSource reads a sibling checkout of MHSanaei/sponsors under XUI_DEBUG so
  140. // sponsor edits can be previewed locally before they are pushed.
  141. func readSponsorSource(url, localPath string, limit int) ([]byte, error) {
  142. if !config.IsDebug() {
  143. return fetchLimited(url, limit)
  144. }
  145. data, err := os.ReadFile(localPath)
  146. if err != nil {
  147. return nil, err
  148. }
  149. if len(data) > limit {
  150. return nil, fmt.Errorf("%s exceeds %d bytes", localPath, limit)
  151. }
  152. return data, nil
  153. }
  154. func fetchLimited(url string, limit int) ([]byte, error) {
  155. client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
  156. req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
  157. if err != nil {
  158. return nil, err
  159. }
  160. resp, err := client.Do(req)
  161. if err != nil {
  162. return nil, err
  163. }
  164. defer resp.Body.Close()
  165. if resp.StatusCode != http.StatusOK {
  166. return nil, fmt.Errorf("fetch %s returned status %d", url, resp.StatusCode)
  167. }
  168. body, err := io.ReadAll(io.LimitReader(resp.Body, int64(limit)+1))
  169. if err != nil {
  170. return nil, err
  171. }
  172. if len(body) > limit {
  173. return nil, fmt.Errorf("%s exceeds %d bytes", url, limit)
  174. }
  175. return body, nil
  176. }
  177. func fetchSponsors() (*SponsorList, error) {
  178. body, err := readSponsorSource(sponsorsURL, filepath.Join(localSponsorsDir, "sponsors.json"), maxSponsorsBytes)
  179. if err != nil {
  180. return nil, err
  181. }
  182. var list SponsorList
  183. if err := json.Unmarshal(body, &list); err != nil {
  184. return nil, err
  185. }
  186. return &list, nil
  187. }
  188. func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
  189. out := &SponsorList{Sponsors: []Sponsor{}}
  190. if raw == nil {
  191. return out
  192. }
  193. if strings.HasPrefix(raw.Contact, "https://") {
  194. out.Contact = raw.Contact
  195. }
  196. sidebarTaken := 0
  197. for _, sp := range raw.Sponsors {
  198. // A missing enable counts as on, so a forgotten field never hides a paid slot.
  199. disabled := sp.Enable != nil && !*sp.Enable
  200. if disabled || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
  201. continue
  202. }
  203. // A bad logo name drops only the logo; the paid slot still renders with its initial.
  204. if sponsorLogoRe.MatchString(sp.Logo) {
  205. sp.Logo = sponsorLogoPath + sp.Logo
  206. } else {
  207. sp.Logo = ""
  208. }
  209. slots := make([]string, 0, len(sp.Slots))
  210. for _, slot := range sp.Slots {
  211. // The sidebar rotates, so capping it keeps each paid card on screen long enough.
  212. if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) {
  213. continue
  214. }
  215. slots = append(slots, slot)
  216. }
  217. if len(slots) == 0 {
  218. continue
  219. }
  220. if slices.Contains(slots, "sidebar") {
  221. sidebarTaken++
  222. }
  223. sp.Slots = slots
  224. out.Sponsors = append(out.Sponsors, sp)
  225. }
  226. return out
  227. }