From 64e85c3076fbf1477331e1beb35933cc6956f1a9 Mon Sep 17 00:00:00 2001 From: yusing Date: Mon, 20 Jan 2025 17:42:17 +0800 Subject: [PATCH] feat: support selfh.st icons, support homepage config overriding --- internal/api/handler.go | 1 + internal/api/v1/favicon/favicon.go | 47 +++++- internal/api/v1/homepage_overrides.go | 58 +++++++ internal/api/v1/list.go | 13 +- internal/common/constants.go | 12 +- internal/homepage/homepage.go | 10 ++ internal/homepage/homepage_test.go | 31 ++++ internal/homepage/icon_url.go | 61 ++++++-- internal/homepage/icon_url_test.go | 45 ++++++ internal/homepage/json_config.go | 147 +++++++++++++++++ internal/list-icons.go | 183 ++++++++++++++++++++-- internal/route/routes/routequery/query.go | 24 ++- next-release.md | 3 + 13 files changed, 591 insertions(+), 44 deletions(-) create mode 100644 internal/api/v1/homepage_overrides.go create mode 100644 internal/homepage/homepage_test.go create mode 100644 internal/homepage/json_config.go diff --git a/internal/api/handler.go b/internal/api/handler.go index e6130ab..8f93cbc 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -34,6 +34,7 @@ func NewHandler(cfg config.ConfigInstance) http.Handler { mux.HandleFunc("GET", "/v1/health/ws", auth.RequireAuth(useCfg(cfg, v1.HealthWS))) mux.HandleFunc("GET", "/v1/logs/ws", auth.RequireAuth(useCfg(cfg, v1.LogsWS()))) mux.HandleFunc("GET", "/v1/favicon/{alias}", auth.RequireAuth(favicon.GetFavIcon)) + mux.HandleFunc("POST", "/v1/homepage/set", auth.RequireAuth(v1.SetHomePageOverrides)) defaultAuth := auth.GetDefaultAuth() if defaultAuth != nil { diff --git a/internal/api/v1/favicon/favicon.go b/internal/api/v1/favicon/favicon.go index 6438a32..a8fa452 100644 --- a/internal/api/v1/favicon/favicon.go +++ b/internal/api/v1/favicon/favicon.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "net" "net/http" @@ -88,6 +89,8 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) { icon, status, errMsg = findIcon(r, req, hp.Icon.Value) case homepage.IconSourceWalkXCode: icon, status, errMsg = fetchWalkxcodeIcon(hp.Icon.Extra.FileType, hp.Icon.Extra.Name) + case homepage.IconSourceSelfhSt: + icon, status, errMsg = fetchSelfhStIcon(hp.Icon.Extra.FileType, hp.Icon.Extra.Name) } } else { // try extract from "link[rel=icon]" @@ -117,8 +120,8 @@ func ResetIconCache(route route.HTTPRoute) { func loadIconCache(key string) (icon []byte, ok bool) { iconCacheMu.RLock() + defer iconCacheMu.RUnlock() icon, ok = iconCache[key] - iconCacheMu.RUnlock() return } @@ -169,9 +172,9 @@ func sanitizeName(name string) string { return strings.ToLower(nameSanitizer.Replace(name)) } -func fetchWalkxcodeIcon(filetype string, name string) ([]byte, int, string) { +func fetchWalkxcodeIcon(filetype, name string) ([]byte, int, string) { // if icon isn't in the list, no need to fetch - if !internal.HasIcon(name, filetype) { + if !internal.HasWalkxCodeIcon(name, filetype) { logging.Debug(). Str("filetype", filetype). Str("name", name). @@ -184,10 +187,39 @@ func fetchWalkxcodeIcon(filetype string, name string) ([]byte, int, string) { return icon, http.StatusOK, "" } - url := homepage.DashboardIconBaseURL + filetype + "/" + name + "." + filetype + // url := homepage.DashboardIconBaseURL + filetype + "/" + name + "." + filetype + url := fmt.Sprintf("https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/%s/%s.%s", filetype, name, filetype) return fetchIconAbsolute(url) } +func fetchSelfhStIcon(filetype, reference string) ([]byte, int, string) { + // if icon isn't in the list, no need to fetch + if !internal.HasSelfhstIcon(reference, filetype) { + logging.Debug(). + Str("filetype", filetype). + Str("reference", reference). + Msg("icon not found") + return nil, http.StatusNotFound, "icon not found" + } + + icon, ok := loadIconCache("selfh.st/" + filetype + "/" + reference) + if ok { + return icon, http.StatusOK, "" + } + + url := fmt.Sprintf("https://cdn.jsdelivr.net/gh/selfhst/icons/%s/%s.%s", filetype, reference, filetype) + return fetchIconAbsolute(url) +} + +func fetchIcon(filetype, filename string) (icon []byte, status int, errMsg string) { + icon, status, errMsg = fetchSelfhStIcon(filetype, filename) + if icon != nil { + return + } + icon, status, errMsg = fetchWalkxcodeIcon(filetype, filename) + return +} + func findIcon(r route.HTTPRoute, req *http.Request, uri string) (icon []byte, status int, errMsg string) { key := r.TargetName() icon, ok := loadIconCache(key) @@ -198,10 +230,10 @@ func findIcon(r route.HTTPRoute, req *http.Request, uri string) (icon []byte, st return icon, http.StatusOK, "" } - icon, status, errMsg = fetchWalkxcodeIcon("png", sanitizeName(r.TargetName())) + icon, status, errMsg = fetchIcon("png", sanitizeName(r.TargetName())) cont := r.RawEntry().Container if icon == nil && cont != nil { - icon, status, errMsg = fetchWalkxcodeIcon("png", sanitizeName(cont.ImageName)) + icon, status, errMsg = fetchIcon("png", sanitizeName(cont.ImageName)) } if icon == nil { // fallback to parse html @@ -213,7 +245,6 @@ func findIcon(r route.HTTPRoute, req *http.Request, uri string) (icon []byte, st } func findIconSlow(r route.HTTPRoute, req *http.Request, uri string) (icon []byte, status int, errMsg string) { - c := newContent() ctx, cancel := context.WithTimeoutCause(req.Context(), 3*time.Second, errors.New("favicon request timeout")) defer cancel() newReq := req.WithContext(ctx) @@ -233,6 +264,8 @@ func findIconSlow(r route.HTTPRoute, req *http.Request, uri string) (icon []byte newReq.URL.RawPath = u.RawPath newReq.URL.RawQuery = u.RawQuery newReq.RequestURI = u.String() + + c := newContent() r.ServeHTTP(c, newReq) if c.status != http.StatusOK { switch c.status { diff --git a/internal/api/v1/homepage_overrides.go b/internal/api/v1/homepage_overrides.go new file mode 100644 index 0000000..f2e0c2f --- /dev/null +++ b/internal/api/v1/homepage_overrides.go @@ -0,0 +1,58 @@ +package v1 + +import ( + "net/http" + "strconv" + + "github.com/yusing/go-proxy/internal/api/v1/utils" + "github.com/yusing/go-proxy/internal/homepage" + "github.com/yusing/go-proxy/internal/utils/strutils" +) + +const ( + HomepageOverrideDisplayname = "display_name" + HomepageOverrideDisplayOrder = "display_order" + HomepageOverrideDisplayCategory = "display_category" + HomepageOverrideCategoryOrder = "category_order" + HomepageOverrideCategoryName = "category_name" + HomepageOverrideIcon = "icon" + HomepageOverrideShow = "show" +) + +func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) { + what, which, value := r.FormValue("what"), r.FormValue("which"), r.FormValue("value") + if what == "" || which == "" { + http.Error(w, "missing what or which", http.StatusBadRequest) + return + } + if value == "" { + http.Error(w, "missing value", http.StatusBadRequest) + return + } + overrides := homepage.GetJSONConfig() + switch what { + case HomepageOverrideDisplayname: + utils.RespondError(w, overrides.SetDisplayNameOverride(which, value)) + case HomepageOverrideDisplayCategory: + utils.RespondError(w, overrides.SetDisplayCategoryOverride(which, value)) + case HomepageOverrideCategoryName: + utils.RespondError(w, overrides.SetCategoryNameOverride(which, value)) + case HomepageOverrideIcon: + utils.RespondError(w, overrides.SetIconOverride(which, value)) + case HomepageOverrideShow: + utils.RespondError(w, overrides.SetShowItemOverride(which, strutils.ParseBool(value))) + case HomepageOverrideDisplayOrder, HomepageOverrideCategoryOrder: + v, err := strconv.Atoi(value) + if err != nil { + http.Error(w, "invalid integer", http.StatusBadRequest) + return + } + if what == HomepageOverrideDisplayOrder { + utils.RespondError(w, overrides.SetDisplayOrder(which, v)) + } else { + utils.RespondError(w, overrides.SetCategoryOrder(which, v)) + } + default: + http.Error(w, "invalid what", http.StatusBadRequest) + } +} diff --git a/internal/api/v1/list.go b/internal/api/v1/list.go index 94121be..c348a7c 100644 --- a/internal/api/v1/list.go +++ b/internal/api/v1/list.go @@ -4,6 +4,7 @@ import ( "net/http" "strings" + "github.com/yusing/go-proxy/internal" U "github.com/yusing/go-proxy/internal/api/v1/utils" "github.com/yusing/go-proxy/internal/common" config "github.com/yusing/go-proxy/internal/config/types" @@ -24,6 +25,7 @@ const ( ListHomepageConfig = "homepage_config" ListRouteProviders = "route_providers" ListHomepageCategories = "homepage_categories" + ListIcons = "icons" ListTasks = "tasks" ) @@ -58,6 +60,13 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { U.RespondJSON(w, r, cfg.RouteProviderList()) case ListHomepageCategories: U.RespondJSON(w, r, routequery.HomepageCategories()) + case ListIcons: + icons, err := internal.ListAvailableIcons() + if err != nil { + U.RespondError(w, err) + return + } + U.RespondJSON(w, r, icons) case ListTasks: U.RespondJSON(w, r, task.DebugTaskList()) default: @@ -80,7 +89,7 @@ func listRoute(which string) any { } func listFiles(w http.ResponseWriter, r *http.Request) { - files, err := utils.ListFiles(common.ConfigBasePath, 0) + files, err := utils.ListFiles(common.ConfigBasePath, 0, true) if err != nil { U.HandleErr(w, r, err) return @@ -97,7 +106,7 @@ func listFiles(w http.ResponseWriter, r *http.Request) { resp[t] = append(resp[t], file) } - mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0) + mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0, true) if err != nil { U.HandleErr(w, r, err) return diff --git a/internal/common/constants.go b/internal/common/constants.go index d00964a..8a928ba 100644 --- a/internal/common/constants.go +++ b/internal/common/constants.go @@ -16,12 +16,12 @@ const ( DotEnvPath = ".env" DotEnvExamplePath = ".env.example" - ConfigBasePath = "config" - ConfigFileName = "config.yml" - ConfigExampleFileName = "config.example.yml" - ConfigPath = ConfigBasePath + "/" + ConfigFileName - - JWTKeyPath = ConfigBasePath + "/jwt.key" + ConfigBasePath = "config" + ConfigFileName = "config.yml" + ConfigExampleFileName = "config.example.yml" + ConfigPath = ConfigBasePath + "/" + ConfigFileName + HomepageJSONConfigPath = ConfigBasePath + "/.homepage.json" + IconListCachePath = ConfigBasePath + "/.icon_list_cache.json" MiddlewareComposeBasePath = ConfigBasePath + "/middlewares" diff --git a/internal/homepage/homepage.go b/internal/homepage/homepage.go index dc402dd..ef07f54 100644 --- a/internal/homepage/homepage.go +++ b/internal/homepage/homepage.go @@ -30,6 +30,16 @@ func (item *Item) IsEmpty() bool { len(item.WidgetConfig) == 0) } +func (item *Item) GetOverriddenItem() *Item { + overrides := GetJSONConfig() + clone := *item + clone.Name = overrides.GetDisplayName(item) + clone.Icon = overrides.GetDisplayIcon(item) + clone.Category = overrides.GetCategory(item) + clone.Show = overrides.GetShowItem(item) + return &clone +} + func NewHomePageConfig() Config { return Config(make(map[string]Category)) } diff --git a/internal/homepage/homepage_test.go b/internal/homepage/homepage_test.go new file mode 100644 index 0000000..4acfce6 --- /dev/null +++ b/internal/homepage/homepage_test.go @@ -0,0 +1,31 @@ +package homepage + +import ( + "testing" + + . "github.com/yusing/go-proxy/internal/utils/testing" +) + +func TestOverrideItem(t *testing.T) { + a := &Item{ + Show: false, + Alias: "foo", + Name: "Foo", + Icon: &IconURL{ + Value: "/favicon.ico", + IconSource: IconSourceRelative, + }, + Category: "App", + } + overrides := GetJSONConfig() + ExpectNoError(t, overrides.SetShowItemOverride(a.Alias, true)) + ExpectNoError(t, overrides.SetDisplayNameOverride(a.Alias, "Bar")) + ExpectNoError(t, overrides.SetDisplayCategoryOverride(a.Alias, "Test")) + ExpectNoError(t, overrides.SetIconOverride(a.Alias, "png/example.png")) + + overridden := a.GetOverriddenItem() + ExpectTrue(t, overridden.Show) + ExpectEqual(t, overridden.Name, "Bar") + ExpectEqual(t, overridden.Category, "Test") + ExpectEqual(t, overridden.Icon.String(), "png/example.png") +} diff --git a/internal/homepage/icon_url.go b/internal/homepage/icon_url.go index c87a846..9ea6314 100644 --- a/internal/homepage/icon_url.go +++ b/internal/homepage/icon_url.go @@ -3,14 +3,15 @@ package homepage import ( "strings" + "github.com/yusing/go-proxy/internal" E "github.com/yusing/go-proxy/internal/error" ) type ( IconURL struct { - Value string `json:"value"` - IconSource - Extra *IconExtra `json:"extra"` + Value string `json:"value"` + IconSource `json:"source"` + Extra *IconExtra `json:"extra"` } IconExtra struct { @@ -25,12 +26,23 @@ const ( IconSourceAbsolute IconSource = iota IconSourceRelative IconSourceWalkXCode + IconSourceSelfhSt ) -const DashboardIconBaseURL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/" - var ErrInvalidIconURL = E.New("invalid icon url") +func (u *IconURL) HasIcon() bool { + if u.IconSource == IconSourceSelfhSt && + !internal.HasSelfhstIcon(u.Extra.Name, u.Extra.FileType) { + return false + } + if u.IconSource == IconSourceWalkXCode && + !internal.HasWalkxCodeIcon(u.Extra.Name, u.Extra.FileType) { + return false + } + return true +} + // Parse implements strutils.Parser. func (u *IconURL) Parse(v string) error { if v == "" { @@ -45,11 +57,12 @@ func (u *IconURL) Parse(v string) error { case "http:", "https:": u.Value = v u.IconSource = IconSourceAbsolute - return nil - case "@target": + case "@target", "": // @target/favicon.ico, /favicon.ico u.Value = v[slashIndex:] u.IconSource = IconSourceRelative - return nil + if u.Value == "/" { + return ErrInvalidIconURL.Withf("%s", "empty path") + } case "png", "svg", "webp": // walkXCode Icons u.Value = v u.IconSource = IconSourceWalkXCode @@ -57,10 +70,38 @@ func (u *IconURL) Parse(v string) error { FileType: beforeSlash, Name: strings.TrimSuffix(v[slashIndex+1:], "."+beforeSlash), } - return nil + case "@selfhst": // selfh.st Icons, @selfhst/. + u.Value = v[slashIndex:] + u.IconSource = IconSourceSelfhSt + parts := strings.Split(v[slashIndex+1:], ".") + if len(parts) != 2 { + return ErrInvalidIconURL.Withf("%s", "expect @selfhst/., e.g. @selfhst/adguard-home.webp") + } + reference, format := parts[0], strings.ToLower(parts[1]) + if reference == "" || format == "" { + return ErrInvalidIconURL + } + switch format { + case "svg", "png", "webp": + default: + return ErrInvalidIconURL.Withf("%s", "invalid format, expect svg/png/webp") + } + u.Extra = &IconExtra{ + FileType: format, + Name: reference, + } default: - return ErrInvalidIconURL + return ErrInvalidIconURL.Withf("%s", v) } + + if u.Value == "" { + return ErrInvalidIconURL.Withf("%s", "empty") + } + + if !u.HasIcon() { + return ErrInvalidIconURL.Withf("no such icon %s", u.Value) + } + return nil } func (u *IconURL) String() string { diff --git a/internal/homepage/icon_url_test.go b/internal/homepage/icon_url_test.go index 89f53b2..9d8ef6a 100644 --- a/internal/homepage/icon_url_test.go +++ b/internal/homepage/icon_url_test.go @@ -29,6 +29,24 @@ func TestIconURL(t *testing.T) { IconSource: IconSourceRelative, }, }, + { + name: "relative2", + input: "/icon.png", + wantValue: &IconURL{ + Value: "/icon.png", + IconSource: IconSourceRelative, + }, + }, + { + name: "relative_empty_path", + input: "@target/", + wantErr: true, + }, + { + name: "relative_empty_path2", + input: "/", + wantErr: true, + }, { name: "walkxcode", input: "png/walkxcode.png", @@ -41,6 +59,33 @@ func TestIconURL(t *testing.T) { }, }, }, + { + name: "walkxcode_invalid_format", + input: "foo/walkxcode.png", + wantErr: true, + }, + { + name: "selfh.st_valid", + input: "@selfhst/foo.png", + wantValue: &IconURL{ + Value: "/foo.png", + IconSource: IconSourceSelfhSt, + Extra: &IconExtra{ + FileType: "png", + Name: "foo", + }, + }, + }, + { + name: "selfh.st_invalid", + input: "@selfhst/foo", + wantErr: true, + }, + { + name: "selfh.st_invalid_format", + input: "@selfhst/foo.bar", + wantErr: true, + }, { name: "invalid", input: "invalid", diff --git a/internal/homepage/json_config.go b/internal/homepage/json_config.go new file mode 100644 index 0000000..54c8990 --- /dev/null +++ b/internal/homepage/json_config.go @@ -0,0 +1,147 @@ +package homepage + +import ( + "errors" + "os" + "sync" + + "github.com/yusing/go-proxy/internal/common" + "github.com/yusing/go-proxy/internal/logging" + "github.com/yusing/go-proxy/internal/utils" +) + +type JSONConfig struct { + DisplayNameOverride map[string]string `json:"display_name_override"` + DisplayCategoryOverride map[string]string `json:"display_category_override"` + DisplayOrder map[string]int `json:"display_order"` // TODO: implement this + CategoryNameOverride map[string]string `json:"category_name_override"` + CategoryOrder map[string]int `json:"category_order"` // TODO: implement this + IconOverride map[string]*IconURL `json:"icon_override"` + ShowItemOverride map[string]bool `json:"show_item_override"` + mu sync.RWMutex +} + +var jsonConfigInstance *JSONConfig + +func InitOverridesConfig() { + jsonConfigInstance = &JSONConfig{ + DisplayNameOverride: make(map[string]string), + DisplayCategoryOverride: make(map[string]string), + DisplayOrder: make(map[string]int), + CategoryNameOverride: make(map[string]string), + CategoryOrder: make(map[string]int), + IconOverride: make(map[string]*IconURL), + ShowItemOverride: make(map[string]bool), + } + err := utils.LoadJSON(common.HomepageJSONConfigPath, jsonConfigInstance) + if err != nil && !os.IsNotExist(err) { + logging.Fatal().Err(err).Msg("failed to load homepage overrides config") + } +} + +func GetJSONConfig() *JSONConfig { + return jsonConfigInstance +} + +func (c *JSONConfig) save() error { + if common.IsTest { + return nil + } + return utils.SaveJSON(common.HomepageJSONConfigPath, c, 0o644) +} + +func (c *JSONConfig) GetDisplayName(item *Item) string { + c.mu.RLock() + defer c.mu.RUnlock() + if override, ok := c.DisplayNameOverride[item.Alias]; ok { + return override + } + return item.Name +} + +func (c *JSONConfig) SetDisplayNameOverride(key, value string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.DisplayNameOverride[key] = value + return c.save() +} + +func (c *JSONConfig) GetCategory(item *Item) string { + c.mu.RLock() + defer c.mu.RUnlock() + category := item.Category + if override, ok := c.DisplayCategoryOverride[item.Alias]; ok { + category = override + } + if override, ok := c.CategoryNameOverride[category]; ok { + return override + } + return category +} + +func (c *JSONConfig) SetDisplayCategoryOverride(key, value string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.DisplayCategoryOverride[key] = value + return c.save() +} + +func (c *JSONConfig) SetDisplayOrder(key string, value int) error { + c.mu.Lock() + defer c.mu.Unlock() + c.DisplayOrder[key] = value + return c.save() +} + +func (c *JSONConfig) SetCategoryNameOverride(key, value string) error { + c.mu.Lock() + defer c.mu.Unlock() + c.CategoryNameOverride[key] = value + return c.save() +} + +func (c *JSONConfig) SetCategoryOrder(key string, value int) error { + c.mu.Lock() + defer c.mu.Unlock() + c.CategoryOrder[key] = value + return c.save() +} + +func (c *JSONConfig) GetDisplayIcon(item *Item) *IconURL { + c.mu.RLock() + defer c.mu.RUnlock() + if override, ok := c.IconOverride[item.Alias]; ok { + return override + } + return item.Icon +} + +func (c *JSONConfig) SetIconOverride(key, value string) error { + c.mu.Lock() + defer c.mu.Unlock() + var url IconURL + if err := url.Parse(value); err != nil { + return err + } + if !url.HasIcon() { + return errors.New("no such icon") + } + c.IconOverride[key] = &url + return c.save() +} + +func (c *JSONConfig) GetShowItem(item *Item) bool { + c.mu.RLock() + defer c.mu.RUnlock() + if override, ok := c.ShowItemOverride[item.Alias]; ok { + return override + } + return true +} + +func (c *JSONConfig) SetShowItemOverride(key string, value bool) error { + c.mu.Lock() + defer c.mu.Unlock() + c.ShowItemOverride[key] = value + return c.save() +} diff --git a/internal/list-icons.go b/internal/list-icons.go index 3f9f0a5..52496c5 100644 --- a/internal/list-icons.go +++ b/internal/list-icons.go @@ -4,10 +4,13 @@ import ( "encoding/json" "io" "net/http" + "os" "sync" "time" + "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/logging" + "github.com/yusing/go-proxy/internal/utils" ) type GitHubContents struct { //! keep this, may reuse in future @@ -18,53 +21,136 @@ type GitHubContents struct { //! keep this, may reuse in future Size int `json:"size"` } -type Icons map[string]map[string]struct{} +type ( + IconsMap map[string]map[string]struct{} + Cache struct { + WalkxCode, Selfhst IconsMap + DisplayNames ReferenceDisplayNameMap + } + ReferenceDisplayNameMap map[string]string +) -// no longer cache for `godoxy ls-icons` +func (icons *Cache) isEmpty() bool { + return len(icons.WalkxCode) == 0 && len(icons.Selfhst) == 0 +} + +func (icons *Cache) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "walkxcode": icons.WalkxCode, + "selfhst": icons.Selfhst, + }) +} const updateInterval = 1 * time.Hour var ( - iconsCache = make(Icons) + iconsCache *Cache iconsCahceMu sync.Mutex lastUpdate time.Time ) -const walkxcodeIcons = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/tree.json" +const ( + walkxcodeIcons = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/tree.json" + selfhstIcons = "https://cdn.selfh.st/directory/icons.json" +) -func ListAvailableIcons() (Icons, error) { +func InitIconListCache() { + iconsCache = &Cache{ + WalkxCode: make(IconsMap), + Selfhst: make(IconsMap), + DisplayNames: make(ReferenceDisplayNameMap), + } + err := utils.LoadJSON(common.IconListCachePath, iconsCache) + if err != nil && !os.IsNotExist(err) { + logging.Fatal().Err(err).Msg("failed to load icon list cache config") + } else if err == nil { + if stats, err := os.Stat(common.IconListCachePath); err != nil { + logging.Fatal().Err(err).Msg("failed to load icon list cache config") + } else { + lastUpdate = stats.ModTime() + } + } +} + +func ListAvailableIcons() (*Cache, error) { iconsCahceMu.Lock() defer iconsCahceMu.Unlock() if time.Since(lastUpdate) < updateInterval { - if len(iconsCache) > 0 { + if !iconsCache.isEmpty() { return iconsCache, nil } } - icons, err := getIcons() + icons, err := fetchIconData() if err != nil { return nil, err } 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 HasIcon(name string, filetype string) bool { +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[filetype]; !ok { + if _, ok := icons.WalkxCode[filetype]; !ok { return false } - _, ok := icons[filetype][name+"."+filetype] + _, 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) { + walkxCodeIcons, err := fetchWalkxCodeIcons() + if err != nil { + return nil, err + } + + selfhstIcons, referenceToNames, err := fetchSelfhstIcons() + if err != nil { + return nil, err + } + + return &Cache{ + WalkxCode: walkxCodeIcons, + Selfhst: selfhstIcons, + DisplayNames: referenceToNames, + }, nil +} + /* format: @@ -74,10 +160,13 @@ format: ], "svg": [ "*.svg", + ], + "webp": [ + "*.webp", ] } */ -func getIcons() (Icons, error) { +func fetchWalkxCodeIcons() (IconsMap, error) { req, err := http.NewRequest(http.MethodGet, walkxcodeIcons, nil) if err != nil { return nil, err @@ -98,7 +187,7 @@ func getIcons() (Icons, error) { if err != nil { return nil, err } - icons := make(Icons, len(data)) + icons := make(IconsMap, len(data)) for fileType, files := range data { icons[fileType] = make(map[string]struct{}, len(files)) for _, icon := range files { @@ -107,3 +196,73 @@ func getIcons() (Icons, error) { } return icons, 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, 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, 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([]SelfhStIcon, 0, 2000) + err = json.Unmarshal(body, &data) + if err != nil { + return nil, nil, err + } + + 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{}{} + } + if item.PNG == "Yes" { + icons["png"][item.Reference+".png"] = struct{}{} + } + if item.WebP == "Yes" { + icons["webp"][item.Reference+".webp"] = struct{}{} + } + referenceToNames[item.Reference] = item.Name + } + + return icons, referenceToNames, nil +} diff --git a/internal/route/routes/routequery/query.go b/internal/route/routes/routequery/query.go index 46ee142..9eadd8a 100644 --- a/internal/route/routes/routequery/query.go +++ b/internal/route/routes/routequery/query.go @@ -4,6 +4,7 @@ import ( "strings" "time" + "github.com/yusing/go-proxy/internal" "github.com/yusing/go-proxy/internal/homepage" "github.com/yusing/go-proxy/internal/route/entry" provider "github.com/yusing/go-proxy/internal/route/provider/types" @@ -83,12 +84,21 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st } if item.Name == "" { - item.Name = strutils.Title( - strings.ReplaceAll( - strings.ReplaceAll(alias, "-", " "), - "_", " ", - ), - ) + reference := r.TargetName() + if r.RawEntry().Container != nil { + reference = r.RawEntry().Container.ImageName + } + name, ok := internal.GetDisplayName(reference) + if ok { + item.Name = name + } else { + item.Name = strutils.Title( + strings.ReplaceAll( + strings.ReplaceAll(alias, "-", " "), + "_", " ", + ), + ) + } } if useDefaultCategories { @@ -128,7 +138,7 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st } item.AltURL = r.TargetURL().String() - hpCfg.Add(item) + hpCfg.Add(item.GetOverriddenItem()) }) return hpCfg } diff --git a/next-release.md b/next-release.md index e398b33..559e7f3 100644 --- a/next-release.md +++ b/next-release.md @@ -115,6 +115,9 @@ GoDoxy v0.8.2 expected changes do: error 403 Forbidden ``` +- **new** Brand new rewritten WebUI +- **new** Support selfh.st icons: `@selfhst/.` _(e.g. `@selfhst/adguard-home.webp`)_ + - also uses the display name on https://selfh.st/icons/ as default for our dashboard! - **new** GoDoxy server side favicon retreiving and caching - deliver smooth dashboard experience by caching favicons - correct icon can show without setting `homepage.icon` by parsing it from app's root path "/", selecting `link[rel=icon]` from HTML as default icon