diff --git a/internal/web/service/panel/sponsor.go b/internal/web/service/panel/sponsor.go index 0df1d46db..2e0356ddf 100644 --- a/internal/web/service/panel/sponsor.go +++ b/internal/web/service/panel/sponsor.go @@ -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) } diff --git a/internal/web/service/panel/sponsor_test.go b/internal/web/service/panel/sponsor_test.go index c6bfe0c6b..212ae5e21 100644 --- a/internal/web/service/panel/sponsor_test.go +++ b/internal/web/service/panel/sponsor_test.go @@ -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/"}]}`)