diff --git a/internal/api/v1/favicon/favicon.go b/internal/api/v1/favicon/favicon.go index 9b9d614..13de01f 100644 --- a/internal/api/v1/favicon/favicon.go +++ b/internal/api/v1/favicon/favicon.go @@ -57,7 +57,7 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) { hp := r.HomepageItem() if hp.Icon != nil { if hp.Icon.IconSource == homepage.IconSourceRelative { - result = homepage.FindIcon(req.Context(), r, hp.Icon.Value) + result = homepage.FindIcon(req.Context(), r, *hp.Icon.FullURL) } else { result = homepage.FetchFavIconFromURL(req.Context(), hp.Icon) } diff --git a/internal/api/v1/list.go b/internal/api/v1/list.go index 99cb1b1..77c23f8 100644 --- a/internal/api/v1/list.go +++ b/internal/api/v1/list.go @@ -72,9 +72,6 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { gphttp.ClientError(w, err) return } - if icons == nil { - icons = []string{} - } gphttp.RespondJSON(w, r, icons) case ListTasks: gphttp.RespondJSON(w, r, task.DebugTaskList()) diff --git a/internal/common/constants.go b/internal/common/constants.go index 6ddf633..24b9cab 100644 --- a/internal/common/constants.go +++ b/internal/common/constants.go @@ -15,7 +15,8 @@ const ( ConfigExampleFileName = "config.example.yml" ConfigPath = ConfigBasePath + "/" + ConfigFileName - IconListCachePath = ConfigBasePath + "/.icon_list_cache.json" + DataDir = "data" + IconListCachePath = DataDir + "/.icon_list_cache.json" NamespaceHomepageOverrides = ".homepage" NamespaceIconCache = ".icon_cache" @@ -24,14 +25,12 @@ const ( ComposeFileName = "compose.yml" ComposeExampleFileName = "compose.example.yml" - - DataDir = "data" - - ErrorPagesBasePath = "error_pages" + ErrorPagesBasePath = "error_pages" ) var RequiredDirectories = []string{ ConfigBasePath, + DataDir, ErrorPagesBasePath, MiddlewareComposeBasePath, } diff --git a/internal/homepage/homepage_test.go b/internal/homepage/homepage_test.go index 7ea580c..cda3689 100644 --- a/internal/homepage/homepage_test.go +++ b/internal/homepage/homepage_test.go @@ -1,8 +1,9 @@ -package homepage +package homepage_test import ( "testing" + . "github.com/yusing/go-proxy/internal/homepage" . "github.com/yusing/go-proxy/internal/utils/testing" ) @@ -13,7 +14,7 @@ func TestOverrideItem(t *testing.T) { Show: false, Name: "Foo", Icon: &IconURL{ - Value: "/favicon.ico", + FullURL: strPtr("/favicon.ico"), IconSource: IconSourceRelative, }, Category: "App", @@ -24,7 +25,7 @@ func TestOverrideItem(t *testing.T) { Name: "Bar", Category: "Test", Icon: &IconURL{ - Value: "@walkxcode/example.png", + FullURL: strPtr("@walkxcode/example.png"), IconSource: IconSourceWalkXCode, }, } diff --git a/internal/homepage/icon_url.go b/internal/homepage/icon_url.go index b6847aa..21bdb64 100644 --- a/internal/homepage/icon_url.go +++ b/internal/homepage/icon_url.go @@ -9,64 +9,70 @@ import ( type ( IconURL struct { - Value string `json:"value"` - FullValue string `json:"full_value"` + FullURL *string `json:"value,omitempty"` // only for absolute/relative icons + Extra *IconExtra `json:"extra,omitempty"` // only for walkxcode/selfhst icons IconSource `json:"source"` - Extra *IconExtra `json:"extra"` } IconExtra struct { - FileType string `json:"file_type"` - Name string `json:"name"` + Key IconKey `json:"key"` + Ref string `json:"ref"` + FileType string `json:"file_type"` + IsLight bool `json:"is_light"` + IsDark bool `json:"is_dark"` } - IconSource int + IconSource string ) const ( - IconSourceAbsolute IconSource = iota - IconSourceRelative - IconSourceWalkXCode - IconSourceSelfhSt + IconSourceAbsolute IconSource = "https://" + IconSourceRelative IconSource = "@target" + IconSourceWalkXCode IconSource = "@walkxcode" + IconSourceSelfhSt IconSource = "@selfhst" ) var ErrInvalidIconURL = gperr.New("invalid icon url") -func NewSelfhStIconURL(reference, format string) *IconURL { +func NewIconURL(source IconSource, refOrName, format string) *IconURL { + switch source { + case IconSourceWalkXCode, IconSourceSelfhSt: + default: + panic("invalid icon source") + } + isLight, isDark := false, false + if strings.HasSuffix(refOrName, "-light") { + isLight = true + refOrName = strings.TrimSuffix(refOrName, "-light") + } else if strings.HasSuffix(refOrName, "-dark") { + isDark = true + refOrName = strings.TrimSuffix(refOrName, "-dark") + } return &IconURL{ - Value: reference + "." + format, - FullValue: fmt.Sprintf("@selfhst/%s.%s", reference, format), - IconSource: IconSourceSelfhSt, + IconSource: source, Extra: &IconExtra{ + Key: NewIconKey(source, refOrName), FileType: format, - Name: reference, + Ref: refOrName, + IsLight: isLight, + IsDark: isDark, }, } } +func NewSelfhStIconURL(refOrName, format string) *IconURL { + return NewIconURL(IconSourceSelfhSt, refOrName, format) +} + func NewWalkXCodeIconURL(name, format string) *IconURL { - return &IconURL{ - Value: name + "." + format, - FullValue: fmt.Sprintf("@walkxcode/%s.%s", name, format), - IconSource: IconSourceWalkXCode, - Extra: &IconExtra{ - FileType: format, - Name: name, - }, - } + return NewIconURL(IconSourceWalkXCode, name, format) } // HasIcon checks if the icon referenced by the IconURL exists in the cache based on its source. // Returns false if the icon does not exist for IconSourceSelfhSt or IconSourceWalkXCode, // otherwise returns true. func (u *IconURL) HasIcon() bool { - if u.IconSource == IconSourceSelfhSt { - return HasSelfhstIcon(u.Extra.Name, u.Extra.FileType) - } - if u.IconSource == IconSourceWalkXCode { - return HasWalkxCodeIcon(u.Extra.Name, u.Extra.FileType) - } - return true + return HasIcon(u) } // Parse implements strutils.Parser. @@ -78,33 +84,25 @@ func (u *IconURL) Parse(v string) error { if slashIndex == -1 { return ErrInvalidIconURL } - u.FullValue = v beforeSlash := v[:slashIndex] switch beforeSlash { case "http:", "https:": - u.Value = v + u.FullURL = &v u.IconSource = IconSourceAbsolute case "@target", "": // @target/favicon.ico, /favicon.ico - u.Value = v[slashIndex:] - u.IconSource = IconSourceRelative - if u.Value == "/" { + url := v[slashIndex:] + if url == "/" { return ErrInvalidIconURL.Withf("%s", "empty path") } - case "png", "svg", "webp": // walkxcode Icons - u.Value = v - u.IconSource = IconSourceWalkXCode - u.Extra = &IconExtra{ - FileType: beforeSlash, - Name: strings.TrimSuffix(v[slashIndex+1:], "."+beforeSlash), - } + u.FullURL = &url + u.IconSource = IconSourceRelative case "@selfhst", "@walkxcode": // selfh.st / walkxcode Icons, @selfhst/. - u.Value = v[slashIndex+1:] if beforeSlash == "@selfhst" { u.IconSource = IconSourceSelfhSt } else { u.IconSource = IconSourceWalkXCode } - parts := strings.Split(u.Value, ".") + parts := strings.Split(v[slashIndex+1:], ".") if len(parts) != 2 { return ErrInvalidIconURL.Withf("expect @%s/., e.g. @%s/adguard-home.webp", beforeSlash, beforeSlash) } @@ -117,40 +115,67 @@ func (u *IconURL) Parse(v string) error { default: return ErrInvalidIconURL.Withf("%s", "invalid image format, expect svg/png/webp") } + isLight, isDark := false, false + if strings.HasSuffix(reference, "-light") { + isLight = true + reference = strings.TrimSuffix(reference, "-light") + } else if strings.HasSuffix(reference, "-dark") { + isDark = true + reference = strings.TrimSuffix(reference, "-dark") + } u.Extra = &IconExtra{ + Key: NewIconKey(u.IconSource, reference), FileType: format, - Name: reference, + Ref: reference, + IsLight: isLight, + IsDark: isDark, + } + if !u.HasIcon() { + return ErrInvalidIconURL.Withf("no such icon %s from %s", reference, u.IconSource) } default: return ErrInvalidIconURL.Withf("%s", v) } - if u.Value == "" { - return ErrInvalidIconURL.Withf("%s", "empty") - } - - if !u.HasIcon() { - return ErrInvalidIconURL.Withf("no such icon %s from %s", u.Value, beforeSlash) - } return nil } func (u *IconURL) URL() string { + if u.FullURL != nil { + return *u.FullURL + } + if u.Extra == nil { + return "" + } + filename := u.Extra.Ref + if u.Extra.IsLight { + filename += "-light" + } else if u.Extra.IsDark { + filename += "-dark" + } switch u.IconSource { - case IconSourceAbsolute: - return u.Value - case IconSourceRelative: - return "/" + u.Value case IconSourceWalkXCode: - return fmt.Sprintf("https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/%s/%s.%s", u.Extra.FileType, u.Extra.Name, u.Extra.FileType) + return fmt.Sprintf("https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/%s/%s.%s", u.Extra.FileType, filename, u.Extra.FileType) case IconSourceSelfhSt: - return fmt.Sprintf("https://cdn.jsdelivr.net/gh/selfhst/icons/%s/%s.%s", u.Extra.FileType, u.Extra.Name, u.Extra.FileType) + return fmt.Sprintf("https://cdn.jsdelivr.net/gh/selfhst/icons/%s/%s.%s", u.Extra.FileType, filename, u.Extra.FileType) } return "" } func (u *IconURL) String() string { - return u.FullValue + if u.FullURL != nil { + return *u.FullURL + } + if u.Extra == nil { + return "" + } + var suffix string + if u.Extra.IsLight { + suffix = "-light" + } else if u.Extra.IsDark { + suffix = "-dark" + } + return fmt.Sprintf("%s/%s%s.%s", u.IconSource, u.Extra.Ref, suffix, u.Extra.FileType) } func (u *IconURL) MarshalText() ([]byte, error) { diff --git a/internal/homepage/icon_url_test.go b/internal/homepage/icon_url_test.go index 1b655fc..98e4958 100644 --- a/internal/homepage/icon_url_test.go +++ b/internal/homepage/icon_url_test.go @@ -1,11 +1,16 @@ -package homepage +package homepage_test import ( "testing" + . "github.com/yusing/go-proxy/internal/homepage" expect "github.com/yusing/go-proxy/internal/utils/testing" ) +func strPtr(s string) *string { + return &s +} + func TestIconURL(t *testing.T) { tests := []struct { name string @@ -17,7 +22,7 @@ func TestIconURL(t *testing.T) { name: "absolute", input: "http://example.com/icon.png", wantValue: &IconURL{ - Value: "http://example.com/icon.png", + FullURL: strPtr("http://example.com/icon.png"), IconSource: IconSourceAbsolute, }, }, @@ -25,7 +30,7 @@ func TestIconURL(t *testing.T) { name: "relative", input: "@target/icon.png", wantValue: &IconURL{ - Value: "/icon.png", + FullURL: strPtr("/icon.png"), IconSource: IconSourceRelative, }, }, @@ -33,7 +38,7 @@ func TestIconURL(t *testing.T) { name: "relative2", input: "/icon.png", wantValue: &IconURL{ - Value: "/icon.png", + FullURL: strPtr("/icon.png"), IconSource: IconSourceRelative, }, }, @@ -49,25 +54,26 @@ func TestIconURL(t *testing.T) { }, { name: "walkxcode", - input: "png/adguard-home.png", + input: "@walkxcode/adguard-home.png", wantValue: &IconURL{ - Value: "png/adguard-home.png", IconSource: IconSourceWalkXCode, Extra: &IconExtra{ + Key: NewIconKey(IconSourceWalkXCode, "adguard-home"), FileType: "png", - Name: "adguard-home", + Ref: "adguard-home", }, }, }, { - name: "walkxcode_alt", - input: "@walkxcode/adguard-home.png", + name: "walkxcode_light", + input: "@walkxcode/pfsense-light.png", wantValue: &IconURL{ - Value: "adguard-home.png", IconSource: IconSourceWalkXCode, Extra: &IconExtra{ + Key: NewIconKey(IconSourceWalkXCode, "pfsense"), FileType: "png", - Name: "adguard-home", + Ref: "pfsense", + IsLight: true, }, }, }, @@ -78,13 +84,39 @@ func TestIconURL(t *testing.T) { }, { name: "selfh.st_valid", - input: "@selfhst/adguard-home.png", + input: "@selfhst/adguard-home.webp", wantValue: &IconURL{ - Value: "adguard-home.png", IconSource: IconSourceSelfhSt, Extra: &IconExtra{ + Key: NewIconKey(IconSourceSelfhSt, "adguard-home"), + FileType: "webp", + Ref: "adguard-home", + }, + }, + }, + { + name: "selfh.st_light", + input: "@selfhst/adguard-home-light.png", + wantValue: &IconURL{ + IconSource: IconSourceSelfhSt, + Extra: &IconExtra{ + Key: NewIconKey(IconSourceSelfhSt, "adguard-home"), FileType: "png", - Name: "adguard-home", + Ref: "adguard-home", + IsLight: true, + }, + }, + }, + { + name: "selfh.st_dark", + input: "@selfhst/adguard-home-dark.svg", + wantValue: &IconURL{ + IconSource: IconSourceSelfhSt, + Extra: &IconExtra{ + Key: NewIconKey(IconSourceSelfhSt, "adguard-home"), + FileType: "svg", + Ref: "adguard-home", + IsDark: true, }, }, }, @@ -116,7 +148,6 @@ func TestIconURL(t *testing.T) { if tc.wantErr { expect.ErrorIs(t, ErrInvalidIconURL, err) } else { - tc.wantValue.FullValue = tc.input expect.NoError(t, err) expect.Equal(t, u, tc.wantValue) } diff --git a/internal/homepage/list-icons.go b/internal/homepage/list-icons.go deleted file mode 100644 index 927d694..0000000 --- a/internal/homepage/list-icons.go +++ /dev/null @@ -1,302 +0,0 @@ -package homepage - -import ( - "encoding/json" - "io" - "net/http" - "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" -) - -type GitHubContents struct { //! keep this, may reuse in future - Type string `json:"type"` - Path string `json:"path"` - Name string `json:"name"` - Sha string `json:"sha"` - Size int `json:"size"` -} - -type ( - IconsMap map[string]map[string]struct{} - IconList []string - Cache struct { - WalkxCode, Selfhst IconsMap - DisplayNames ReferenceDisplayNameMap - IconList IconList // combined into a single list - } - ReferenceDisplayNameMap map[string]string -) - -func (icons *Cache) needUpdate() bool { - return len(icons.WalkxCode) == 0 || len(icons.Selfhst) == 0 || len(icons.IconList) == 0 || len(icons.DisplayNames) == 0 -} - -const updateInterval = 2 * time.Hour - -var ( - iconsCache *Cache - iconsCahceMu sync.RWMutex - lastUpdate time.Time -) - -const ( - walkxcodeIcons = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/tree.json" - selfhstIcons = "https://cdn.selfh.st/directory/icons.json" -) - -func InitIconListCache() { - iconsCahceMu.Lock() - defer iconsCahceMu.Unlock() - - iconsCache = &Cache{ - WalkxCode: make(IconsMap), - Selfhst: make(IconsMap), - DisplayNames: make(ReferenceDisplayNameMap), - IconList: []string{}, - } - err := utils.LoadJSONIfExist(common.IconListCachePath, iconsCache) - if err != nil { - logging.Error().Err(err).Msg("failed to load icon list cache config") - } else if len(iconsCache.IconList) > 0 { - logging.Info(). - Int("icons", len(iconsCache.IconList)). - Int("display_names", len(iconsCache.DisplayNames)). - Msg("icon list cache loaded") - } - - task.OnProgramExit("save_icon_list_cache", func() { - utils.SaveJSON(common.IconListCachePath, iconsCache, 0o644) - }) -} - -func ListAvailableIcons() (*Cache, error) { - iconsCahceMu.RLock() - if time.Since(lastUpdate) < updateInterval { - if !iconsCache.needUpdate() { - iconsCahceMu.RUnlock() - return iconsCache, nil - } - } - iconsCahceMu.RUnlock() - - iconsCahceMu.Lock() - defer iconsCahceMu.Unlock() - - logging.Info().Msg("updating icon data") - icons, err := fetchIconData() - if err != nil { - return nil, err - } - logging.Info(). - Int("icons", len(icons.IconList)). - Int("display_names", len(icons.DisplayNames)). - Msg("icons list updated") - - iconsCache = icons - lastUpdate = time.Now() - - err = utils.SaveJSON(common.IconListCachePath, iconsCache, 0o644) - if err != nil { - logging.Warn().Err(err).Msg("failed to save icon list cache") - } - return icons, nil -} - -func SearchIcons(keyword string, limit int) ([]string, error) { - icons, err := ListAvailableIcons() - if err != nil { - return nil, err - } - if keyword == "" { - return utils.Slice(icons.IconList, limit), nil - } - return utils.Slice(fuzzy.Find(keyword, icons.IconList), limit), nil -} - -func HasWalkxCodeIcon(name string, filetype string) bool { - icons, err := ListAvailableIcons() - if err != nil { - logging.Error().Err(err).Msg("failed to list icons") - return false - } - if _, ok := icons.WalkxCode[filetype]; !ok { - return false - } - _, ok := icons.WalkxCode[filetype][name+"."+filetype] - return ok -} - -func HasSelfhstIcon(name string, filetype string) bool { - icons, err := ListAvailableIcons() - if err != nil { - logging.Error().Err(err).Msg("failed to list icons") - return false - } - if _, ok := icons.Selfhst[filetype]; !ok { - return false - } - _, ok := icons.Selfhst[filetype][name+"."+filetype] - return ok -} - -func GetDisplayName(reference string) (string, bool) { - icons, err := ListAvailableIcons() - if err != nil { - logging.Error().Err(err).Msg("failed to list icons") - return "", false - } - displayName, ok := icons.DisplayNames[reference] - return displayName, ok -} - -func fetchIconData() (*Cache, error) { - walkxCodeIconMap, walkxCodeIconList, err := fetchWalkxCodeIcons() - if err != nil { - return nil, err - } - - n := 0 - for _, items := range walkxCodeIconMap { - n += len(items) - } - - selfhstIconMap, selfhstIconList, referenceToNames, err := fetchSelfhstIcons() - if err != nil { - return nil, err - } - - return &Cache{ - WalkxCode: walkxCodeIconMap, - Selfhst: selfhstIconMap, - DisplayNames: referenceToNames, - IconList: append(walkxCodeIconList, selfhstIconList...), - }, nil -} - -/* -format: - - { - "png": [ - "*.png", - ], - "svg": [ - "*.svg", - ], - "webp": [ - "*.webp", - ] - } -*/ -func fetchWalkxCodeIcons() (IconsMap, IconList, error) { - req, err := http.NewRequest(http.MethodGet, walkxcodeIcons, nil) - if err != nil { - return nil, nil, err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, err - } - - data := make(map[string][]string) - err = json.Unmarshal(body, &data) - if err != nil { - return nil, nil, err - } - icons := make(IconsMap, len(data)) - iconList := make(IconList, 0, 2000) - for fileType, files := range data { - icons[fileType] = make(map[string]struct{}, len(files)) - for _, icon := range files { - icons[fileType][icon] = struct{}{} - iconList = append(iconList, "@walkxcode/"+icon) - } - } - return icons, iconList, nil -} - -/* -format: - - { - "Name": "2FAuth", - "Reference": "2fauth", - "SVG": "Yes", - "PNG": "Yes", - "WebP": "Yes", - "Light": "Yes", - "Category": "Self-Hosted", - "CreatedAt": "2024-08-16 00:27:23+00:00" - } -*/ -func fetchSelfhstIcons() (IconsMap, IconList, ReferenceDisplayNameMap, error) { - type SelfhStIcon struct { - Name string `json:"Name"` - Reference string `json:"Reference"` - SVG string `json:"SVG"` - PNG string `json:"PNG"` - WebP string `json:"WebP"` - // Light string - // Category string - // CreatedAt string - } - - req, err := http.NewRequest(http.MethodGet, selfhstIcons, nil) - if err != nil { - return nil, nil, nil, err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, nil, nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, nil, err - } - - data := make([]SelfhStIcon, 0, 2000) - err = json.Unmarshal(body, &data) - if err != nil { - return nil, nil, nil, err - } - - iconList := make(IconList, 0, len(data)*3) - icons := make(IconsMap) - icons["svg"] = make(map[string]struct{}, len(data)) - icons["png"] = make(map[string]struct{}, len(data)) - icons["webp"] = make(map[string]struct{}, len(data)) - - referenceToNames := make(ReferenceDisplayNameMap, len(data)) - - for _, item := range data { - if item.SVG == "Yes" { - icons["svg"][item.Reference+".svg"] = struct{}{} - iconList = append(iconList, "@selfhst/"+item.Reference+".svg") - } - if item.PNG == "Yes" { - icons["png"][item.Reference+".png"] = struct{}{} - iconList = append(iconList, "@selfhst/"+item.Reference+".png") - } - if item.WebP == "Yes" { - icons["webp"][item.Reference+".webp"] = struct{}{} - iconList = append(iconList, "@selfhst/"+item.Reference+".webp") - } - referenceToNames[item.Reference] = item.Name - } - - return icons, iconList, referenceToNames, nil -} diff --git a/internal/homepage/list_icons.go b/internal/homepage/list_icons.go new file mode 100644 index 0000000..734282d --- /dev/null +++ b/internal/homepage/list_icons.go @@ -0,0 +1,373 @@ +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 +} diff --git a/internal/homepage/list_icons_test.go b/internal/homepage/list_icons_test.go new file mode 100644 index 0000000..1428b34 --- /dev/null +++ b/internal/homepage/list_icons_test.go @@ -0,0 +1,175 @@ +package homepage_test + +import ( + "testing" + + . "github.com/yusing/go-proxy/internal/homepage" +) + +const walkxcodeIcons = `{ + "png": [ + "app1.png", + "app1-light.png", + "app2.png" + ], + "svg": [ + "app1.svg", + "app1-light.svg" + ], + "webp": [ + "app1.webp", + "app1-light.webp", + "app2.webp" + ] +}` + +const selfhstIcons = `[ + { + "Name": "2FAuth", + "Reference": "2fauth", + "SVG": "Yes", + "PNG": "Yes", + "WebP": "Yes", + "Light": "Yes", + "Dark": "Yes", + "Category": "Self-Hosted", + "Tags": "", + "CreatedAt": "2024-08-16 00:27:23+00:00" + }, + { + "Name": "Dittofeed", + "Reference": "dittofeed", + "SVG": "No", + "PNG": "Yes", + "WebP": "Yes", + "Light": "No", + "Dark": "No", + "Category": "Self-Hosted", + "Tags": "", + "CreatedAt": "2024-08-22 11:33:37+00:00" + }, + { + "Name": "Ars Technica", + "Reference": "ars-technica", + "SVG": "Yes", + "PNG": "Yes", + "WebP": "Yes", + "Light": "Yes", + "Dark": "Yes", + "Category": "Other", + "Tags": "News", + "CreatedAt": "2025-04-09 11:15:01+00:00" + } +]` + +type testCases struct { + Key IconKey + IconMeta +} + +func runTests(t *testing.T, iconsCache *Cache, test []testCases) { + for _, item := range test { + icon, ok := iconsCache.Icons[item.Key] + if !ok { + t.Fatalf("icon %s not found", item.Key) + } + if icon.PNG != item.PNG || icon.SVG != item.SVG || icon.WebP != item.WebP { + t.Fatalf("icon %s file format mismatch", item.Key) + } + if icon.Light != item.Light || icon.Dark != item.Dark { + t.Fatalf("icon %s variant mismatch", item.Key) + } + if icon.DisplayName != item.DisplayName { + t.Fatalf("icon %s display name mismatch, expect %s, got %s", item.Key, item.DisplayName, icon.DisplayName) + } + if icon.Tag != item.Tag { + t.Fatalf("icon %s tag mismatch, expect %s, got %s", item.Key, item.Tag, icon.Tag) + } + } +} + +func TestListWalkxCodeIcons(t *testing.T) { + MockHttpGet([]byte(walkxcodeIcons)) + if err := UpdateWalkxCodeIcons(); err != nil { + t.Fatal(err) + } + iconsCache, err := ListAvailableIcons() + if err != nil { + t.Fatal(err) + } + if len(iconsCache.Icons) != 2 { + t.Fatalf("expect 2 icons, got %d", len(iconsCache.Icons)) + } + test := []testCases{ + { + Key: NewIconKey(IconSourceWalkXCode, "2fauth"), + IconMeta: IconMeta{ + SVG: true, + PNG: true, + WebP: true, + Light: true, + DisplayName: "2FAuth", + }, + }, + { + Key: NewIconKey(IconSourceWalkXCode, "dittofeed"), + IconMeta: IconMeta{ + PNG: true, + WebP: true, + DisplayName: "Dittofeed", + }, + }, + } + runTests(t, iconsCache, test) +} + +func TestListSelfhstIcons(t *testing.T) { + MockHttpGet([]byte(selfhstIcons)) + if err := UpdateSelfhstIcons(); err != nil { + t.Fatal(err) + } + iconsCache, err := ListAvailableIcons() + if err != nil { + t.Fatal(err) + } + if len(iconsCache.Icons) != 3 { + t.Fatalf("expect 3 icons, got %d", len(iconsCache.Icons)) + } + // if len(iconsCache.IconList) != 8 { + // t.Fatalf("expect 8 icons, got %d", len(iconsCache.IconList)) + // } + test := []testCases{ + { + Key: NewIconKey(IconSourceSelfhSt, "2fauth"), + IconMeta: IconMeta{ + SVG: true, + PNG: true, + WebP: true, + Light: true, + Dark: true, + DisplayName: "2FAuth", + }, + }, + { + Key: NewIconKey(IconSourceSelfhSt, "dittofeed"), + IconMeta: IconMeta{ + PNG: true, + WebP: true, + DisplayName: "Dittofeed", + }, + }, + { + Key: NewIconKey(IconSourceSelfhSt, "ars-technica"), + IconMeta: IconMeta{ + SVG: true, + PNG: true, + WebP: true, + Light: true, + Dark: true, + DisplayName: "Ars Technica", + Tag: "News", + }, + }, + } + runTests(t, iconsCache, test) +} diff --git a/internal/route/provider/docker_test.go b/internal/route/provider/docker_test.go index 309eff1..8c919cf 100644 --- a/internal/route/provider/docker_test.go +++ b/internal/route/provider/docker_test.go @@ -81,7 +81,7 @@ func TestApplyLabel(t *testing.T) { "proxy.a.middlewares.request.set_headers.X-Header": "value1", "proxy.a.middlewares.request.add_headers.X-Header2": "value2", "proxy.a.homepage.show": "true", - "proxy.a.homepage.icon": "png/adguard-home.png", + "proxy.a.homepage.icon": "@selfhst/adguard-home.png", "proxy.a.healthcheck.path": "/ping", "proxy.a.healthcheck.interval": "10s", }, @@ -127,9 +127,8 @@ func TestApplyLabel(t *testing.T) { expect.Equal(t, b.Container.IdlewatcherConfig.StopSignal, "SIGTERM") expect.Equal(t, a.Homepage.Show, true) - expect.Equal(t, a.Homepage.Icon.Value, "png/adguard-home.png") expect.Equal(t, a.Homepage.Icon.Extra.FileType, "png") - expect.Equal(t, a.Homepage.Icon.Extra.Name, "adguard-home") + expect.Equal(t, a.Homepage.Icon.Extra.Ref, "adguard-home") expect.Equal(t, a.HealthCheck.Path, "/ping") expect.Equal(t, a.HealthCheck.Interval, 10*time.Second) diff --git a/internal/route/route.go b/internal/route/route.go index 7adfcbd..ad56157 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -479,35 +479,29 @@ func (r *Route) FinalizeHomepageConfig() { r.Homepage = r.Homepage.GetOverride(r.Alias) hp := r.Homepage + ref := r.Reference() + meta, ok := homepage.GetHomepageMeta(ref) + if ok { + if hp.Name == "" { + hp.Name = meta.DisplayName + } + if hp.Category == "" { + hp.Category = meta.Tag + } + } - var key string if hp.Name == "" { - if r.Container != nil { - key = r.Container.Image.Name - } else { - key = r.Alias - } - displayName, ok := homepage.GetDisplayName(key) - if ok { - hp.Name = displayName - } else { - hp.Name = strutils.Title( - strings.ReplaceAll( - strings.ReplaceAll(key, "-", " "), - "_", " ", - ), - ) - } + hp.Name = strutils.Title( + strings.ReplaceAll( + strings.ReplaceAll(ref, "-", " "), + "_", " ", + ), + ) } if hp.Category == "" { if config.GetInstance().Value().Homepage.UseDefaultCategories { - if isDocker { - key = r.Container.Image.Name - } else { - key = strings.ToLower(r.Alias) - } - if category, ok := homepage.PredefinedCategories[key]; ok { + if category, ok := homepage.PredefinedCategories[ref]; ok { hp.Category = category } }