mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
878d37c2fc
The logo cache was keyed by whatever names the sponsors list named and never pruned, so a list naming thousands of logos made the public logo route pin hundreds of megabytes, and every hourly refresh with new names added more. Only the first 32 distinct logo names of the active list are kept, surplus sponsors render with their initial as a bad logo name already does, and each store drops cached logos no longer on the list.
344 lines
12 KiB
Go
344 lines
12 KiB
Go
package panel
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
|
)
|
|
|
|
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"},
|
|
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},
|
|
{"not started yet", func(s *Sponsor) { s.From = new(sponsorTestNow.Add(time.Hour)) }, false},
|
|
{"starts exactly now", func(s *Sponsor) { s.From = new(sponsorTestNow) }, true},
|
|
{"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 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", "sidebar", "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())
|
|
dbtest.InitDB(t, config.GetDBPath())
|
|
|
|
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
|
|
}
|
|
if strings.HasPrefix(r.URL.Path, "/media/many") {
|
|
_, _ = w.Write(pngMagic)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// The feed decides which logo names exist, so it must not decide how many the panel holds in memory.
|
|
func TestSponsorLogoCacheStaysBounded(t *testing.T) {
|
|
var list []string
|
|
for i := range 3 * maxSponsorLogos {
|
|
until := "2099-01-01T00:00:00Z"
|
|
if i < maxSponsorLogos {
|
|
until = sponsorTestNow.Add(time.Minute).Format(time.RFC3339)
|
|
}
|
|
list = append(list, fmt.Sprintf(`{"id":"s%d","slots":["page"],"until":%q,
|
|
"link":"https://s.example/","logo":"many%d.png"}`, i, until, i))
|
|
}
|
|
setupSponsorServer(t, `{"sponsors":[`+strings.Join(list, ",")+`]}`)
|
|
now := sponsorTestNow
|
|
sponsorNow = func() time.Time { return now }
|
|
svc := &PanelService{}
|
|
|
|
got, err := svc.GetSponsors()
|
|
if err != nil || len(got.Sponsors) != 3*maxSponsorLogos {
|
|
t.Fatalf("GetSponsors() = %d sponsors, %v; want all %d kept (only surplus logos drop)", len(got.Sponsors), err, 3*maxSponsorLogos)
|
|
}
|
|
for _, step := range []string{"full list", "first sponsors expired"} {
|
|
served := 0
|
|
for i := range 3 * maxSponsorLogos {
|
|
if _, _, err := svc.GetSponsorLogo(fmt.Sprintf("many%d.png", i)); err == nil {
|
|
served++
|
|
}
|
|
}
|
|
if served != maxSponsorLogos || len(logos) != maxSponsorLogos {
|
|
t.Errorf("%s: served %d logos, cache holds %d, want %d for both", step, served, len(logos), maxSponsorLogos)
|
|
}
|
|
now = now.Add(2 * time.Minute)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|