package homepage import ( "encoding/json" "fmt" "io" "net/http" "strings" "sync" "time" "github.com/lithammer/fuzzysearch/fuzzy" "github.com/yusing/go-proxy/internal/common" "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/strutils" ) type ( IconKey string IconMap map[IconKey]*IconMeta IconList []string IconMeta struct { SVG, PNG, WebP bool Light, Dark bool DisplayName string Tag string } IconMetaSearch struct { Source IconSource Ref string SVG bool PNG bool WebP bool Light bool Dark bool } Cache struct { Icons IconMap LastUpdate time.Time sync.RWMutex `json:"-"` } ) func (icon *IconMeta) Filenames(ref string) []string { filenames := make([]string, 0) if icon.SVG { filenames = append(filenames, fmt.Sprintf("%s.svg", ref)) if icon.Light { filenames = append(filenames, fmt.Sprintf("%s-light.svg", ref)) } if icon.Dark { filenames = append(filenames, fmt.Sprintf("%s-dark.svg", ref)) } } if icon.PNG { filenames = append(filenames, fmt.Sprintf("%s.png", ref)) if icon.Light { filenames = append(filenames, fmt.Sprintf("%s-light.png", ref)) } if icon.Dark { filenames = append(filenames, fmt.Sprintf("%s-dark.png", ref)) } } if icon.WebP { filenames = append(filenames, fmt.Sprintf("%s.webp", ref)) if icon.Light { filenames = append(filenames, fmt.Sprintf("%s-light.webp", ref)) } if icon.Dark { filenames = append(filenames, fmt.Sprintf("%s-dark.webp", ref)) } } return filenames } const updateInterval = 2 * time.Hour var iconsCache = &Cache{ Icons: make(IconMap), } const ( walkxcodeIcons = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/tree.json" selfhstIcons = "https://cdn.selfh.st/directory/icons.json" ) func NewIconKey(source IconSource, reference string) IconKey { return IconKey(fmt.Sprintf("%s/%s", source, reference)) } func (k IconKey) SourceRef() (IconSource, string) { parts := strings.Split(string(k), "/") return IconSource(parts[0]), parts[1] } func InitIconListCache() { iconsCache.Lock() defer iconsCache.Unlock() err := utils.LoadJSONIfExist(common.IconListCachePath, iconsCache) if err != nil { logging.Error().Err(err).Msg("failed to load icons") } else if len(iconsCache.Icons) > 0 { logging.Info(). Int("icons", len(iconsCache.Icons)). Msg("icons loaded") } if err = updateIcons(); err != nil { logging.Error().Err(err).Msg("failed to update icons") } task.OnProgramExit("save_icons_cache", func() { utils.SaveJSON(common.IconListCachePath, iconsCache, 0o644) }) } func ListAvailableIcons() (*Cache, error) { if common.IsTest { return iconsCache, nil } iconsCache.RLock() if time.Since(iconsCache.LastUpdate) < updateInterval { if len(iconsCache.Icons) == 0 { iconsCache.RUnlock() return iconsCache, nil } } iconsCache.RUnlock() iconsCache.Lock() defer iconsCache.Unlock() logging.Info().Msg("updating icon data") if err := updateIcons(); err != nil { return nil, err } logging.Info().Int("icons", len(iconsCache.Icons)).Msg("icons list updated") iconsCache.LastUpdate = time.Now() err := utils.SaveJSON(common.IconListCachePath, iconsCache, 0o644) if err != nil { logging.Warn().Err(err).Msg("failed to save icons") } return iconsCache, nil } func SearchIcons(keyword string, limit int) ([]IconMetaSearch, error) { if keyword == "" { return make([]IconMetaSearch, 0), nil } iconsCache.RLock() defer iconsCache.RUnlock() result := make([]IconMetaSearch, 0) for k, icon := range iconsCache.Icons { if fuzzy.MatchFold(keyword, string(k)) { source, ref := k.SourceRef() result = append(result, IconMetaSearch{ Source: source, Ref: ref, SVG: icon.SVG, PNG: icon.PNG, WebP: icon.WebP, Light: icon.Light, Dark: icon.Dark, }) } if len(result) >= limit { break } } return result, nil } func HasIcon(icon *IconURL) bool { if icon.Extra == nil { return false } if common.IsTest { return true } iconsCache.RLock() defer iconsCache.RUnlock() key := NewIconKey(icon.IconSource, icon.Extra.Ref) meta, ok := iconsCache.Icons[key] if !ok { return false } switch icon.Extra.FileType { case "png": return meta.PNG && (!icon.Extra.IsLight || meta.Light) && (!icon.Extra.IsDark || meta.Dark) case "svg": return meta.SVG && (!icon.Extra.IsLight || meta.Light) && (!icon.Extra.IsDark || meta.Dark) case "webp": return meta.WebP && (!icon.Extra.IsLight || meta.Light) && (!icon.Extra.IsDark || meta.Dark) default: return false } } type HomepageMeta struct { DisplayName string Tag string } func GetHomepageMeta(ref string) (HomepageMeta, bool) { iconsCache.RLock() defer iconsCache.RUnlock() meta, ok := iconsCache.Icons[NewIconKey(IconSourceSelfhSt, ref)] if !ok { return HomepageMeta{}, false } return HomepageMeta{ DisplayName: meta.DisplayName, Tag: meta.Tag, }, true } func updateIcons() error { clear(iconsCache.Icons) if err := UpdateWalkxCodeIcons(); err != nil { return err } return UpdateSelfhstIcons() } var httpGet = httpGetImpl func MockHttpGet(body []byte) { httpGet = func(_ string) ([]byte, error) { return body, nil } } func httpGetImpl(url string) ([]byte, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return body, nil } /* format: { "png": [ "*.png", ], "svg": [ "*.svg", ], "webp": [ "*.webp", ] } */ func UpdateWalkxCodeIcons() error { body, err := httpGet(walkxcodeIcons) if err != nil { return err } data := make(map[string][]string) err = json.Unmarshal(body, &data) if err != nil { return err } for fileType, files := range data { var setExt func(icon *IconMeta) switch fileType { case "png": setExt = func(icon *IconMeta) { icon.PNG = true } case "svg": setExt = func(icon *IconMeta) { icon.SVG = true } case "webp": setExt = func(icon *IconMeta) { icon.WebP = true } } for _, f := range files { f = strings.TrimSuffix(f, "."+fileType) isLight := strings.HasSuffix(f, "-light") if isLight { f = strings.TrimSuffix(f, "-light") } key := NewIconKey(IconSourceWalkXCode, f) icon, ok := iconsCache.Icons[key] if !ok { icon = new(IconMeta) iconsCache.Icons[key] = icon } setExt(icon) if isLight { icon.Light = true } } } return nil } /* format: { "Name": "2FAuth", "Reference": "2fauth", "SVG": "Yes", "PNG": "Yes", "WebP": "Yes", "Light": "Yes", "Dark": "Yes", "Tag": "", "Category": "Self-Hosted", "CreatedAt": "2024-08-16 00:27:23+00:00" } */ func UpdateSelfhstIcons() error { type SelfhStIcon struct { Name string Reference string SVG string PNG string WebP string Light string Dark string Tags string } body, err := httpGet(selfhstIcons) if err != nil { return err } data := make([]SelfhStIcon, 0) err = json.Unmarshal(body, &data) if err != nil { return err } for _, item := range data { var tag string if item.Tags != "" { tag = strutils.CommaSeperatedList(item.Tags)[0] } icon := &IconMeta{ DisplayName: item.Name, Tag: tag, SVG: item.SVG == "Yes", PNG: item.PNG == "Yes", WebP: item.WebP == "Yes", Light: item.Light == "Yes", Dark: item.Dark == "Yes", } key := NewIconKey(IconSourceSelfhSt, item.Reference) iconsCache.Icons[key] = icon } return nil }