mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-17 03:15:01 +03:00
6f1dd0d757
The sidebar no longer carries a sponsor card. The backend stops serving the "sidebar" slot, so a sponsors.json entry that still lists it keeps only its other slots and one listing nothing else is dropped. Its rotation, three-sponsor cap and collapsed-rail logo rendering go with it. The Sponsors page now lists only real sponsors: the "Your brand here" card shows only as the empty state instead of sitting in the grid and repeating the header's Become a sponsor button, and the Available placements section and its slot-occupancy helper are removed. Cards are larger, two per row on wide screens. Unused i18n keys are dropped from every locale.
305 lines
10 KiB
Go
305 lines
10 KiB
Go
package panel
|
|
|
|
import (
|
|
"errors"
|
|
"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
|
|
}
|
|
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)
|
|
}
|
|
}
|