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:
MHSanaei
2026-11-01 09:03:42 +01:00
parent 1507fd4edf
commit 878d37c2fc
2 changed files with 57 additions and 7 deletions
+18 -7
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"net/http"
"os"
"path/filepath"
@@ -45,6 +46,7 @@ const (
sponsorsErrTTL = 10 * time.Minute
maxSponsorsBytes = 256 << 10
maxLogoBytes = 256 << 10
maxSponsorLogos = 32
localSponsorsDir = "../sponsors/3X"
sponsorLogoPath = "/sponsors/logo/"
)
@@ -118,7 +120,7 @@ func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) {
if err != nil {
return nil, "", err
}
if !slices.ContainsFunc(sponsors.Sponsors, func(sp Sponsor) bool { return sp.Logo == sponsorLogoPath+name }) {
if !sponsors.hasLogo(name) {
return nil, "", ErrSponsorLogoUnknown
}
logosMu.Lock()
@@ -138,10 +140,16 @@ func (s *PanelService) GetSponsorLogo(name string) ([]byte, string, error) {
default:
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
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) {
data, err := readSponsorSource(sponsorLogoBase+name, filepath.Join(localSponsorsDir, "logos", name), maxLogoBytes)
if err != nil {
@@ -216,6 +224,7 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
if strings.HasPrefix(raw.Contact, "https://") {
out.Contact = raw.Contact
}
logoNames := map[string]bool{}
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
@@ -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://") {
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 {
if sponsorSlots[slot] {
@@ -238,6 +241,14 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
if len(slots) == 0 {
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
out.Sponsors = append(out.Sponsors, sp)
}
@@ -2,6 +2,7 @@ package panel
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
@@ -114,6 +115,10 @@ func setupSponsorServer(t *testing.T, body string) *atomic.Int32 {
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)
@@ -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) {
hits := setupSponsorServer(t, `{"sponsors":[{"id":"acme","slots":["page"],
"until":"2026-10-15T00:30:00Z","link":"https://acme.example/"}]}`)