package homepage import ( "encoding/base64" "encoding/json" "sync" "time" "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/jsonstore" "github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/task" "github.com/yusing/go-proxy/internal/utils" "github.com/yusing/go-proxy/internal/utils/atomic" ) type cacheEntry struct { Icon []byte `json:"icon"` ContentType string `json:"content_type,omitempty"` LastAccess atomic.Value[time.Time] `json:"last_access"` } // cache key can be absolute url or route name. var ( iconCache = jsonstore.Store[*cacheEntry](common.NamespaceIconCache) iconMu sync.RWMutex ) const ( iconCacheTTL = 3 * 24 * time.Hour cleanUpInterval = time.Minute maxIconSize = 1024 * 1024 // 1MB maxCacheEntries = 100 ) func init() { go func() { cleanupTicker := time.NewTicker(cleanUpInterval) defer cleanupTicker.Stop() for { select { case <-task.RootContextCanceled(): return case <-cleanupTicker.C: pruneExpiredIconCache() } } }() } func pruneExpiredIconCache() { nPruned := 0 for key, icon := range iconCache.Range { if icon.IsExpired() { iconCache.Delete(key) nPruned++ } } if iconCache.Size() > maxCacheEntries { iconCache.Clear() newIconCache := make(map[string]*cacheEntry, maxCacheEntries) i := 0 for key, icon := range iconCache.Range { if i == maxCacheEntries { break } if !icon.IsExpired() { newIconCache[key] = icon i++ } } for key, icon := range newIconCache { iconCache.Store(key, icon) } } if nPruned > 0 { logging.Info().Int("pruned", nPruned).Msg("pruned expired icon cache") } } func PruneRouteIconCache(route route) { iconCache.Delete(route.Key()) } func loadIconCache(key string) *FetchResult { iconMu.RLock() defer iconMu.RUnlock() icon, ok := iconCache.Load(key) if ok && len(icon.Icon) > 0 { logging.Debug(). Str("key", key). Msg("icon found in cache") icon.LastAccess.Store(utils.TimeNow()) return &FetchResult{Icon: icon.Icon, contentType: icon.ContentType} } return nil } func storeIconCache(key string, result *FetchResult) { icon := result.Icon if len(icon) > maxIconSize { logging.Debug().Int("size", len(icon)).Msg("icon cache size exceeds max cache size") return } iconMu.Lock() defer iconMu.Unlock() entry := &cacheEntry{Icon: icon, ContentType: result.contentType} entry.LastAccess.Store(time.Now()) iconCache.Store(key, entry) logging.Debug().Str("key", key).Int("size", len(icon)).Msg("stored icon cache") } func (e *cacheEntry) IsExpired() bool { return time.Since(e.LastAccess.Load()) > iconCacheTTL } func (e *cacheEntry) UnmarshalJSON(data []byte) error { var tmp struct { Icon []byte `json:"icon"` ContentType string `json:"content_type,omitempty"` LastAccess time.Time `json:"last_access"` } // check if data is json if json.Valid(data) { err := json.Unmarshal(data, &tmp) // return only if unmarshal is successful // otherwise fallback to base64 if err == nil { e.Icon = tmp.Icon e.ContentType = tmp.ContentType e.LastAccess.Store(tmp.LastAccess) return nil } } // fallback to base64 icon, err := base64.StdEncoding.DecodeString(string(data)) if err == nil { e.Icon = icon e.LastAccess.Store(time.Now()) return nil } return err }