mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-31 10:15:00 +03:00
feat(panel): add sponsor slots fed from sponsors.sanaei.dev
Monthly sponsor placements need to change without cutting a panel release. Panels now read 3X/sponsors.json from the MHSanaei/sponsors repo (GitHub Pages on sponsors.sanaei.dev) and show active sponsors in four slots: an overview banner, a rotating sidebar card (max three), the login page and a new Sponsors page that also lists open placements. An entry shows only while enable is not false and until is in the future; links must be https and logos are png/webp/jpg by name only. The list is cached for an hour and the last good copy survives upstream failures; logos are proxied through /sponsors/logo/:name with failures cached, so CSP stays 'self' and admin browsers never reach a third party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads a sibling ../sponsors/3X checkout so edits can be previewed before push.
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// Sponsor is one paid placement published in the repo's sponsors.json.
|
||||
type Sponsor struct {
|
||||
ID string `json:"id" example:"acme-2026-10"`
|
||||
Name string `json:"name" example:"Acme VPS"`
|
||||
Enable *bool `json:"enable,omitempty" example:"true"`
|
||||
Slots []string `json:"slots"`
|
||||
Until time.Time `json:"until" example:"2026-11-01T00:00:00Z"`
|
||||
Logo string `json:"logo,omitempty" example:"/sponsors/logo/acme.png"`
|
||||
Title map[string]string `json:"title"`
|
||||
Text map[string]string `json:"text"`
|
||||
Link string `json:"link" example:"https://acme.example/?utm_source=3x-ui"`
|
||||
}
|
||||
|
||||
// SponsorList is the active sponsor set plus the contact link for new sponsors.
|
||||
type SponsorList struct {
|
||||
Contact string `json:"contact,omitempty" example:"https://t.me/example"`
|
||||
Sponsors []Sponsor `json:"sponsors"`
|
||||
}
|
||||
|
||||
const (
|
||||
sponsorsTTL = time.Hour
|
||||
sponsorsErrTTL = 10 * time.Minute
|
||||
maxSponsorsBytes = 256 << 10
|
||||
maxLogoBytes = 256 << 10
|
||||
maxSidebarSlots = 3
|
||||
localSponsorsDir = "../sponsors/3X"
|
||||
sponsorLogoPath = "/sponsors/logo/"
|
||||
)
|
||||
|
||||
// ErrSponsorLogoUnknown rejects logo names not used by an active sponsor.
|
||||
var ErrSponsorLogoUnknown = errors.New("unknown sponsor logo")
|
||||
|
||||
type sponsorLogo struct {
|
||||
data []byte
|
||||
contentType string
|
||||
err error
|
||||
retryAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
sponsorsURL = "https://sponsors.sanaei.dev/3X/sponsors.json"
|
||||
sponsorLogoBase = "https://sponsors.sanaei.dev/3X/logos/"
|
||||
sponsorNow = time.Now
|
||||
|
||||
// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
|
||||
sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
|
||||
sponsorSlots = map[string]bool{"dashboard": true, "sidebar": true, "page": true, "login": true}
|
||||
|
||||
sponsorsMu sync.Mutex
|
||||
sponsorsRaw *SponsorList
|
||||
sponsorsErr error
|
||||
sponsorsRetryAt time.Time
|
||||
|
||||
logosMu sync.Mutex
|
||||
logos = map[string]sponsorLogo{}
|
||||
)
|
||||
|
||||
// GetSponsors returns the currently active sponsors. The remote file is cached,
|
||||
// but expiry is re-checked on every call so a slot ends exactly at Until.
|
||||
func (s *PanelService) GetSponsors() (*SponsorList, error) {
|
||||
raw, err := cachedSponsors()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return activeSponsors(raw, sponsorNow()), nil
|
||||
}
|
||||
|
||||
func cachedSponsors() (*SponsorList, error) {
|
||||
sponsorsMu.Lock()
|
||||
defer sponsorsMu.Unlock()
|
||||
now := sponsorNow()
|
||||
if !config.IsDebug() && now.Before(sponsorsRetryAt) {
|
||||
return sponsorsRaw, sponsorsErr
|
||||
}
|
||||
list, err := fetchSponsors()
|
||||
switch {
|
||||
case err == nil:
|
||||
sponsorsRaw, sponsorsErr, sponsorsRetryAt = list, nil, now.Add(sponsorsTTL)
|
||||
case sponsorsRaw != nil:
|
||||
// An upstream blip keeps the last good list, so paid slots do not blink out.
|
||||
logger.Debug("sponsors refresh failed, keeping last list:", err)
|
||||
sponsorsRetryAt = now.Add(sponsorsErrTTL)
|
||||
default:
|
||||
sponsorsErr, sponsorsRetryAt = err, now.Add(sponsorsErrTTL)
|
||||
}
|
||||
return sponsorsRaw, sponsorsErr
|
||||
}
|
||||
|
||||
// GetSponsorLogo returns the image bytes for a logo of a currently active sponsor.
|
||||
func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) {
|
||||
sponsors, err := s.GetSponsors()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !slices.ContainsFunc(sponsors.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name }) {
|
||||
return nil, "", ErrSponsorLogoUnknown
|
||||
}
|
||||
logosMu.Lock()
|
||||
defer logosMu.Unlock()
|
||||
now := sponsorNow()
|
||||
l, ok := logos[name]
|
||||
if ok && !config.IsDebug() && now.Before(l.retryAt) {
|
||||
return l.data, l.contentType, l.err
|
||||
}
|
||||
// Failures are cached too: this route is public and each miss is an outbound fetch.
|
||||
data, contentType, err := fetchSponsorLogo(name)
|
||||
switch {
|
||||
case err == nil:
|
||||
l = sponsorLogo{data: data, contentType: contentType, retryAt: now.Add(sponsorsTTL)}
|
||||
case l.data != nil:
|
||||
l.retryAt = now.Add(sponsorsErrTTL)
|
||||
default:
|
||||
l = sponsorLogo{err: err, retryAt: now.Add(sponsorsErrTTL)}
|
||||
}
|
||||
logos[name] = l
|
||||
return l.data, l.contentType, l.err
|
||||
}
|
||||
|
||||
func fetchSponsorLogo(name string) ([]byte, string, error) {
|
||||
data, err := readSponsorSource(sponsorLogoBase+name, filepath.Join(localSponsorsDir, "logos", name), maxLogoBytes)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
contentType := http.DetectContentType(data)
|
||||
switch contentType {
|
||||
case "image/png", "image/webp", "image/jpeg":
|
||||
return data, contentType, nil
|
||||
default:
|
||||
return nil, "", fmt.Errorf("sponsor logo %s has content type %s", name, contentType)
|
||||
}
|
||||
}
|
||||
|
||||
// readSponsorSource reads a sibling checkout of MHSanaei/sponsors under XUI_DEBUG so
|
||||
// sponsor edits can be previewed locally before they are pushed.
|
||||
func readSponsorSource(url, localPath string, limit int) ([]byte, error) {
|
||||
if !config.IsDebug() {
|
||||
return fetchLimited(url, limit)
|
||||
}
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > limit {
|
||||
return nil, fmt.Errorf("%s exceeds %d bytes", localPath, limit)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func fetchLimited(url string, limit int) ([]byte, error) {
|
||||
client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetch %s returned status %d", url, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(limit)+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(body) > limit {
|
||||
return nil, fmt.Errorf("%s exceeds %d bytes", url, limit)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func fetchSponsors() (*SponsorList, error) {
|
||||
body, err := readSponsorSource(sponsorsURL, filepath.Join(localSponsorsDir, "sponsors.json"), maxSponsorsBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var list SponsorList
|
||||
if err := json.Unmarshal(body, &list); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &list, nil
|
||||
}
|
||||
|
||||
func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
|
||||
out := &SponsorList{Sponsors: []Sponsor{}}
|
||||
if raw == nil {
|
||||
return out
|
||||
}
|
||||
if strings.HasPrefix(raw.Contact, "https://") {
|
||||
out.Contact = raw.Contact
|
||||
}
|
||||
sidebarTaken := 0
|
||||
for _, sp := range raw.Sponsors {
|
||||
// A missing enable counts as on, so a forgotten field never hides a paid slot.
|
||||
disabled := sp.Enable != nil && !*sp.Enable
|
||||
if disabled || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
|
||||
continue
|
||||
}
|
||||
// A bad logo name drops only the logo; the paid slot still renders with its initial.
|
||||
if sponsorLogoRe.MatchString(sp.Logo) {
|
||||
sp.Logo = sponsorLogoPath + sp.Logo
|
||||
} else {
|
||||
sp.Logo = ""
|
||||
}
|
||||
slots := make([]string, 0, len(sp.Slots))
|
||||
for _, slot := range sp.Slots {
|
||||
// The sidebar rotates, so capping it keeps each paid card on screen long enough.
|
||||
if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) {
|
||||
continue
|
||||
}
|
||||
slots = append(slots, slot)
|
||||
}
|
||||
if len(slots) == 0 {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(slots, "sidebar") {
|
||||
sidebarTaken++
|
||||
}
|
||||
sp.Slots = slots
|
||||
out.Sponsors = append(out.Sponsors, sp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
)
|
||||
|
||||
var sponsorTestNow = time.Date(2026, 10, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func validSponsor() Sponsor {
|
||||
return Sponsor{
|
||||
ID: "acme",
|
||||
Name: "Acme",
|
||||
Slots: []string{"dashboard", "sidebar"},
|
||||
Until: sponsorTestNow.Add(24 * time.Hour),
|
||||
Logo: "acme.png",
|
||||
Link: "https://acme.example/",
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSponsorsFilters(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Sponsor)
|
||||
kept bool
|
||||
}{
|
||||
{"valid", func(*Sponsor) {}, true},
|
||||
{"expired", func(s *Sponsor) { s.Until = sponsorTestNow }, false},
|
||||
{"enable false with future until", func(s *Sponsor) { s.Enable = new(false) }, false},
|
||||
{"enable true without until", func(s *Sponsor) { s.Enable, s.Until = new(true), time.Time{} }, false},
|
||||
{"enable true with future until", func(s *Sponsor) { s.Enable = new(true) }, true},
|
||||
{"missing id", func(s *Sponsor) { s.ID = "" }, false},
|
||||
{"http link", func(s *Sponsor) { s.Link = "http://acme.example/" }, false},
|
||||
{"javascript link", func(s *Sponsor) { s.Link = "javascript:alert(1)" }, false},
|
||||
{"only unknown slots", func(s *Sponsor) { s.Slots = []string{"subpage"} }, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sp := validSponsor()
|
||||
tc.mutate(&sp)
|
||||
got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow)
|
||||
if kept := len(got.Sponsors) == 1; kept != tc.kept {
|
||||
t.Fatalf("kept = %v, want %v", kept, tc.kept)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSponsorsCapsSidebarAtThree(t *testing.T) {
|
||||
raw := &SponsorList{}
|
||||
for _, id := range []string{"expired", "a", "b", "c", "d", "e"} {
|
||||
sp := validSponsor()
|
||||
sp.ID = id
|
||||
sp.Slots = []string{"sidebar", "page"}
|
||||
if id == "expired" {
|
||||
sp.Until = sponsorTestNow
|
||||
}
|
||||
if id == "e" {
|
||||
sp.Slots = []string{"sidebar"}
|
||||
}
|
||||
raw.Sponsors = append(raw.Sponsors, sp)
|
||||
}
|
||||
got := activeSponsors(raw, sponsorTestNow)
|
||||
want := map[string][]string{
|
||||
"a": {"sidebar", "page"}, "b": {"sidebar", "page"}, "c": {"sidebar", "page"}, "d": {"page"},
|
||||
}
|
||||
if len(got.Sponsors) != len(want) {
|
||||
t.Fatalf("got %d sponsors, want %d (e has only sidebar and must drop)", len(got.Sponsors), len(want))
|
||||
}
|
||||
for _, sp := range got.Sponsors {
|
||||
if !slices.Equal(sp.Slots, want[sp.ID]) {
|
||||
t.Errorf("%s slots = %v, want %v", sp.ID, sp.Slots, want[sp.ID])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSponsorsLogoName(t *testing.T) {
|
||||
cases := []struct{ logo, want string }{
|
||||
{"acme.png", "/sponsors/logo/acme.png"},
|
||||
{"VPS.png", "/sponsors/logo/VPS.png"},
|
||||
{"../x.png", ""},
|
||||
{"https://evil.example/x.png", ""},
|
||||
{"..png", ""},
|
||||
{"logo.svg", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.logo, func(t *testing.T) {
|
||||
sp := validSponsor()
|
||||
sp.Logo = tc.logo
|
||||
got := activeSponsors(&SponsorList{Sponsors: []Sponsor{sp}}, sponsorTestNow)
|
||||
if len(got.Sponsors) != 1 {
|
||||
t.Fatalf("sponsor dropped for logo %q; want it kept", tc.logo)
|
||||
}
|
||||
if got.Sponsors[0].Logo != tc.want {
|
||||
t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
|
||||
sp := validSponsor()
|
||||
sp.Slots = []string{"subpage", "login"}
|
||||
got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
|
||||
if len(got.Sponsors) != 1 {
|
||||
t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
|
||||
}
|
||||
if want := "/sponsors/logo/acme.png"; got.Sponsors[0].Logo != want {
|
||||
t.Errorf("logo = %q, want %q", got.Sponsors[0].Logo, want)
|
||||
}
|
||||
if s := got.Sponsors[0].Slots; len(s) != 1 || s[0] != "login" {
|
||||
t.Errorf("slots = %v, want [login]", s)
|
||||
}
|
||||
if got.Contact != "" {
|
||||
t.Errorf("contact = %q, want empty for non-https", got.Contact)
|
||||
}
|
||||
}
|
||||
|
||||
func setupSponsorServer(t *testing.T, body string) *atomic.Int32 {
|
||||
t.Helper()
|
||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||
if err := database.InitDB(config.GetDBPath()); err != nil {
|
||||
t.Fatalf("init db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
isLogo := strings.HasPrefix(r.URL.Path, "/media/")
|
||||
if (isLogo && failSponsorLogos.Load()) || (!isLogo && failSponsorList.Load()) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/media/acme.png":
|
||||
_, _ = w.Write(pngMagic)
|
||||
case "/media/fake.png":
|
||||
_, _ = w.Write([]byte("<svg onload=alert(1)>"))
|
||||
default:
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
prevURL, prevBase, prevNow := sponsorsURL, sponsorLogoBase, sponsorNow
|
||||
sponsorsURL, sponsorLogoBase = srv.URL+"/sponsors.json", srv.URL+"/media/"
|
||||
resetSponsorCache()
|
||||
t.Cleanup(func() {
|
||||
sponsorsURL, sponsorLogoBase, sponsorNow = prevURL, prevBase, prevNow
|
||||
failSponsorList.Store(false)
|
||||
failSponsorLogos.Store(false)
|
||||
resetSponsorCache()
|
||||
})
|
||||
return &hits
|
||||
}
|
||||
|
||||
var failSponsorList, failSponsorLogos atomic.Bool
|
||||
|
||||
func TestGetSponsorsKeepsLastListWhenRefreshFails(t *testing.T) {
|
||||
hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
|
||||
"until":"2099-01-01T00:00:00Z","link":"https://acme.example/"}]}`)
|
||||
now := sponsorTestNow
|
||||
sponsorNow = func() time.Time { return now }
|
||||
svc := &PanelService{}
|
||||
if got, err := svc.GetSponsors(); err != nil || len(got.Sponsors) != 1 {
|
||||
t.Fatalf("first call = %+v, %v; want 1 sponsor", got, err)
|
||||
}
|
||||
|
||||
failSponsorList.Store(true)
|
||||
now = now.Add(sponsorsTTL)
|
||||
got, err := svc.GetSponsors()
|
||||
if err != nil || len(got.Sponsors) != 1 || got.Sponsors[0].ID != "acme" {
|
||||
t.Fatalf("after failed refresh = %+v, %v; want the last good sponsor kept", got, err)
|
||||
}
|
||||
now = now.Add(sponsorsErrTTL - time.Second)
|
||||
if _, err := svc.GetSponsors(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := hits.Load(); n != 2 {
|
||||
t.Fatalf("remote hits = %d, want 2 (failed refresh retried only after sponsorsErrTTL)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSponsorLogoCachesFailuresAndKeepsLastImage(t *testing.T) {
|
||||
hits := setupSponsorServer(t, `{"sponsors":[
|
||||
{"id":"acme","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/","logo":"acme.png"},
|
||||
{"id":"fake","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://f.example/","logo":"fake.png"}]}`)
|
||||
now := sponsorTestNow
|
||||
sponsorNow = func() time.Time { return now }
|
||||
svc := &PanelService{}
|
||||
|
||||
const wantErr = "sponsor logo fake.png has content type text/plain; charset=utf-8"
|
||||
for range 2 {
|
||||
if _, _, err := svc.GetSponsorLogo("fake.png"); err == nil || err.Error() != wantErr {
|
||||
t.Fatalf("fake.png err = %v, want %q", err, wantErr)
|
||||
}
|
||||
}
|
||||
if n := hits.Load(); n != 2 {
|
||||
t.Fatalf("remote hits = %d, want 2 (list + one fake.png fetch; the failure must be cached)", n)
|
||||
}
|
||||
|
||||
if _, _, err := svc.GetSponsorLogo("acme.png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failSponsorLogos.Store(true)
|
||||
now = now.Add(sponsorsTTL)
|
||||
data, ctype, err := svc.GetSponsorLogo("acme.png")
|
||||
if err != nil || ctype != "image/png" || string(data) != string(pngMagic) {
|
||||
t.Fatalf("acme.png after failed refresh = %q, %q, %v; want the last good image", data, ctype, err)
|
||||
}
|
||||
}
|
||||
|
||||
func resetSponsorCache() {
|
||||
sponsorsMu.Lock()
|
||||
defer sponsorsMu.Unlock()
|
||||
sponsorsRaw, sponsorsErr, sponsorsRetryAt = nil, nil, time.Time{}
|
||||
logosMu.Lock()
|
||||
defer logosMu.Unlock()
|
||||
logos = map[string]sponsorLogo{}
|
||||
}
|
||||
|
||||
var pngMagic = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
|
||||
|
||||
func TestGetSponsorLogo(t *testing.T) {
|
||||
hits := setupSponsorServer(t, `{"sponsors":[
|
||||
{"id":"acme","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/","logo":"acme.png"},
|
||||
{"id":"fake","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://f.example/","logo":"fake.png"},
|
||||
{"id":"old","slots":["page"],"until":"2000-01-01T00:00:00Z","link":"https://o.example/","logo":"old.png"}]}`)
|
||||
svc := &PanelService{}
|
||||
|
||||
data, ctype, err := svc.GetSponsorLogo("acme.png")
|
||||
if err != nil || ctype != "image/png" || string(data) != string(pngMagic) {
|
||||
t.Fatalf("acme.png = %q, %q, %v; want png bytes", data, ctype, err)
|
||||
}
|
||||
before := hits.Load()
|
||||
if _, _, err := svc.GetSponsorLogo("acme.png"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits.Load() != before {
|
||||
t.Fatalf("second logo fetch hit the remote; want cached")
|
||||
}
|
||||
|
||||
for _, name := range []string{"old.png", "other.png", "../sponsors.json"} {
|
||||
if _, _, err := svc.GetSponsorLogo(name); !errors.Is(err, ErrSponsorLogoUnknown) {
|
||||
t.Errorf("%s: err = %v, want ErrSponsorLogoUnknown", name, err)
|
||||
}
|
||||
}
|
||||
_, _, err = svc.GetSponsorLogo("fake.png")
|
||||
if want := "sponsor logo fake.png has content type text/plain; charset=utf-8"; err == nil || err.Error() != want {
|
||||
t.Errorf("fake.png: err = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSponsorsCachesAndExpiresWhileCached(t *testing.T) {
|
||||
hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
|
||||
"until":"2026-10-15T00:30:00Z","link":"https://acme.example/"}]}`)
|
||||
now := sponsorTestNow
|
||||
sponsorNow = func() time.Time { return now }
|
||||
svc := &PanelService{}
|
||||
|
||||
got, err := svc.GetSponsors()
|
||||
if err != nil || len(got.Sponsors) != 1 {
|
||||
t.Fatalf("first call = %+v, %v; want 1 sponsor", got, err)
|
||||
}
|
||||
now = now.Add(45 * time.Minute)
|
||||
got, err = svc.GetSponsors()
|
||||
if err != nil || len(got.Sponsors) != 0 {
|
||||
t.Fatalf("after until = %+v, %v; want 0 sponsors", got, err)
|
||||
}
|
||||
if n := hits.Load(); n != 1 {
|
||||
t.Fatalf("remote hits = %d, want 1 (cached within TTL)", n)
|
||||
}
|
||||
now = now.Add(sponsorsTTL)
|
||||
if _, err := svc.GetSponsors(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := hits.Load(); n != 2 {
|
||||
t.Fatalf("remote hits after TTL = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSponsorsRejectsOversizeBody(t *testing.T) {
|
||||
setupSponsorServer(t, `{"contact":"`+strings.Repeat("a", maxSponsorsBytes)+`"}`)
|
||||
_, err := (&PanelService{}).GetSponsors()
|
||||
want := sponsorsURL + " exceeds 262144 bytes"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Fatalf("err = %v, want %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSponsorsDebugReadsLocalCheckout(t *testing.T) {
|
||||
hits := setupSponsorServer(t, `{"sponsors":[]}`)
|
||||
t.Setenv("XUI_DEBUG", "true")
|
||||
root := t.TempDir()
|
||||
local := filepath.Join(root, "sponsors", "3X")
|
||||
if err := os.MkdirAll(local, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "3x-ui"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(filepath.Join(root, "3x-ui"))
|
||||
write := func(id string) {
|
||||
t.Helper()
|
||||
body := `{"sponsors":[{"id":"` + id + `","slots":["page"],"until":"2099-01-01T00:00:00Z","link":"https://a.example/"}]}`
|
||||
if err := os.WriteFile(filepath.Join(local, "sponsors.json"), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
svc := &PanelService{}
|
||||
for _, id := range []string{"first", "edited"} {
|
||||
write(id)
|
||||
got, err := svc.GetSponsors()
|
||||
if err != nil || len(got.Sponsors) != 1 || got.Sponsors[0].ID != id {
|
||||
t.Fatalf("GetSponsors() = %+v, %v; want local sponsor %q", got, err, id)
|
||||
}
|
||||
}
|
||||
if n := hits.Load(); n != 0 {
|
||||
t.Fatalf("remote hits = %d, want 0 in debug mode", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user