mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
fix(panel): bound the sponsor logo cache and evict logos that left the list
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.
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -45,6 +46,7 @@ const (
|
|||||||
sponsorsErrTTL = 10 * time.Minute
|
sponsorsErrTTL = 10 * time.Minute
|
||||||
maxSponsorsBytes = 256 << 10
|
maxSponsorsBytes = 256 << 10
|
||||||
maxLogoBytes = 256 << 10
|
maxLogoBytes = 256 << 10
|
||||||
|
maxSponsorLogos = 32
|
||||||
localSponsorsDir = "../sponsors/3X"
|
localSponsorsDir = "../sponsors/3X"
|
||||||
sponsorLogoPath = "/sponsors/logo/"
|
sponsorLogoPath = "/sponsors/logo/"
|
||||||
)
|
)
|
||||||
@@ -118,7 +120,7 @@ func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if !slices.ContainsFunc(sponsors.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name }) {
|
if !sponsors.hasLogo(name) {
|
||||||
return nil, "", ErrSponsorLogoUnknown
|
return nil, "", ErrSponsorLogoUnknown
|
||||||
}
|
}
|
||||||
logosMu.Lock()
|
logosMu.Lock()
|
||||||
@@ -138,10 +140,16 @@ func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) {
|
|||||||
default:
|
default:
|
||||||
l = sponsorLogo{err: err, retryAt: now.Add(sponsorsErrTTL)}
|
l = sponsorLogo{err: err, retryAt: now.Add(sponsorsErrTTL)}
|
||||||
}
|
}
|
||||||
|
// Logos that left the active list go with every store, so the cache never outgrows maxSponsorLogos.
|
||||||
|
maps.DeleteFunc(logos, func(cached string, _ sponsorLogo) bool { return !sponsors.hasLogo(cached) })
|
||||||
logos[name] = l
|
logos[name] = l
|
||||||
return l.data, l.contentType, l.err
|
return l.data, l.contentType, l.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (list *SponsorList) hasLogo(name string) bool {
|
||||||
|
return slices.ContainsFunc(list.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name })
|
||||||
|
}
|
||||||
|
|
||||||
func fetchSponsorLogo(name string) ([]byte, string, error) {
|
func fetchSponsorLogo(name string) ([]byte, string, error) {
|
||||||
data, err := readSponsorSource(sponsorLogoBase+name, filepath.Join(localSponsorsDir, "logos", name), maxLogoBytes)
|
data, err := readSponsorSource(sponsorLogoBase+name, filepath.Join(localSponsorsDir, "logos", name), maxLogoBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -216,6 +224,7 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
|
|||||||
if strings.HasPrefix(raw.Contact, "https://") {
|
if strings.HasPrefix(raw.Contact, "https://") {
|
||||||
out.Contact = raw.Contact
|
out.Contact = raw.Contact
|
||||||
}
|
}
|
||||||
|
logoNames := map[string]bool{}
|
||||||
for _, sp := range raw.Sponsors {
|
for _, sp := range raw.Sponsors {
|
||||||
// A missing enable counts as on, so a forgotten field never hides a paid slot.
|
// A missing enable counts as on, so a forgotten field never hides a paid slot.
|
||||||
disabled := sp.Enable != nil && !*sp.Enable
|
disabled := sp.Enable != nil && !*sp.Enable
|
||||||
@@ -223,12 +232,6 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
|
|||||||
if disabled || notStarted || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
|
if disabled || notStarted || sp.ID == "" || !now.Before(sp.Until) || !strings.HasPrefix(sp.Link, "https://") {
|
||||||
continue
|
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))
|
slots := make([]string, 0, len(sp.Slots))
|
||||||
for _, slot := range sp.Slots {
|
for _, slot := range sp.Slots {
|
||||||
if sponsorSlots[slot] {
|
if sponsorSlots[slot] {
|
||||||
@@ -238,6 +241,14 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
|
|||||||
if len(slots) == 0 {
|
if len(slots) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// A bad or surplus logo name drops only the logo; the paid slot renders with its initial.
|
||||||
|
// Every name kept here becomes one cached image, so the list must not decide how many.
|
||||||
|
if sponsorLogoRe.MatchString(sp.Logo) && (logoNames[sp.Logo] || len(logoNames) < maxSponsorLogos) {
|
||||||
|
logoNames[sp.Logo] = true
|
||||||
|
sp.Logo = sponsorLogoPath + sp.Logo
|
||||||
|
} else {
|
||||||
|
sp.Logo = ""
|
||||||
|
}
|
||||||
sp.Slots = slots
|
sp.Slots = slots
|
||||||
out.Sponsors = append(out.Sponsors, sp)
|
out.Sponsors = append(out.Sponsors, sp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package panel
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -114,6 +115,10 @@ func setupSponsorServer(t *testing.T, body string) *atomic.Int32 {
|
|||||||
w.WriteHeader(http.StatusBadGateway)
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/media/many") {
|
||||||
|
_, _ = w.Write(pngMagic)
|
||||||
|
return
|
||||||
|
}
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
case "/media/acme.png":
|
case "/media/acme.png":
|
||||||
_, _ = w.Write(pngMagic)
|
_, _ = w.Write(pngMagic)
|
||||||
@@ -234,6 +239,40 @@ func TestGetSponsorLogo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestGetSponsorsCachesAndExpiresWhileCached(t *testing.T) {
|
||||||
hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
|
hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
|
||||||
"until":"2026-10-15T00:30:00Z","link":"https://acme.example/"}]}`)
|
"until":"2026-10-15T00:30:00Z","link":"https://acme.example/"}]}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user