feat: support selfh.st icons, support homepage config overriding

This commit is contained in:
yusing 2025-01-20 17:42:17 +08:00
parent 68771ce399
commit 64e85c3076
13 changed files with 591 additions and 44 deletions

View file

@ -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/health/ws", auth.RequireAuth(useCfg(cfg, v1.HealthWS)))
mux.HandleFunc("GET", "/v1/logs/ws", auth.RequireAuth(useCfg(cfg, v1.LogsWS()))) mux.HandleFunc("GET", "/v1/logs/ws", auth.RequireAuth(useCfg(cfg, v1.LogsWS())))
mux.HandleFunc("GET", "/v1/favicon/{alias}", auth.RequireAuth(favicon.GetFavIcon)) mux.HandleFunc("GET", "/v1/favicon/{alias}", auth.RequireAuth(favicon.GetFavIcon))
mux.HandleFunc("POST", "/v1/homepage/set", auth.RequireAuth(v1.SetHomePageOverrides))
defaultAuth := auth.GetDefaultAuth() defaultAuth := auth.GetDefaultAuth()
if defaultAuth != nil { if defaultAuth != nil {

View file

@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
@ -88,6 +89,8 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) {
icon, status, errMsg = findIcon(r, req, hp.Icon.Value) icon, status, errMsg = findIcon(r, req, hp.Icon.Value)
case homepage.IconSourceWalkXCode: case homepage.IconSourceWalkXCode:
icon, status, errMsg = fetchWalkxcodeIcon(hp.Icon.Extra.FileType, hp.Icon.Extra.Name) 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 { } else {
// try extract from "link[rel=icon]" // try extract from "link[rel=icon]"
@ -117,8 +120,8 @@ func ResetIconCache(route route.HTTPRoute) {
func loadIconCache(key string) (icon []byte, ok bool) { func loadIconCache(key string) (icon []byte, ok bool) {
iconCacheMu.RLock() iconCacheMu.RLock()
defer iconCacheMu.RUnlock()
icon, ok = iconCache[key] icon, ok = iconCache[key]
iconCacheMu.RUnlock()
return return
} }
@ -169,9 +172,9 @@ func sanitizeName(name string) string {
return strings.ToLower(nameSanitizer.Replace(name)) 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 icon isn't in the list, no need to fetch
if !internal.HasIcon(name, filetype) { if !internal.HasWalkxCodeIcon(name, filetype) {
logging.Debug(). logging.Debug().
Str("filetype", filetype). Str("filetype", filetype).
Str("name", name). Str("name", name).
@ -184,10 +187,39 @@ func fetchWalkxcodeIcon(filetype string, name string) ([]byte, int, string) {
return icon, http.StatusOK, "" 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) 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) { func findIcon(r route.HTTPRoute, req *http.Request, uri string) (icon []byte, status int, errMsg string) {
key := r.TargetName() key := r.TargetName()
icon, ok := loadIconCache(key) 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, "" return icon, http.StatusOK, ""
} }
icon, status, errMsg = fetchWalkxcodeIcon("png", sanitizeName(r.TargetName())) icon, status, errMsg = fetchIcon("png", sanitizeName(r.TargetName()))
cont := r.RawEntry().Container cont := r.RawEntry().Container
if icon == nil && cont != nil { if icon == nil && cont != nil {
icon, status, errMsg = fetchWalkxcodeIcon("png", sanitizeName(cont.ImageName)) icon, status, errMsg = fetchIcon("png", sanitizeName(cont.ImageName))
} }
if icon == nil { if icon == nil {
// fallback to parse html // 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) { 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")) ctx, cancel := context.WithTimeoutCause(req.Context(), 3*time.Second, errors.New("favicon request timeout"))
defer cancel() defer cancel()
newReq := req.WithContext(ctx) 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.RawPath = u.RawPath
newReq.URL.RawQuery = u.RawQuery newReq.URL.RawQuery = u.RawQuery
newReq.RequestURI = u.String() newReq.RequestURI = u.String()
c := newContent()
r.ServeHTTP(c, newReq) r.ServeHTTP(c, newReq)
if c.status != http.StatusOK { if c.status != http.StatusOK {
switch c.status { switch c.status {

View file

@ -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)
}
}

View file

@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/yusing/go-proxy/internal"
U "github.com/yusing/go-proxy/internal/api/v1/utils" U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/common"
config "github.com/yusing/go-proxy/internal/config/types" config "github.com/yusing/go-proxy/internal/config/types"
@ -24,6 +25,7 @@ const (
ListHomepageConfig = "homepage_config" ListHomepageConfig = "homepage_config"
ListRouteProviders = "route_providers" ListRouteProviders = "route_providers"
ListHomepageCategories = "homepage_categories" ListHomepageCategories = "homepage_categories"
ListIcons = "icons"
ListTasks = "tasks" ListTasks = "tasks"
) )
@ -58,6 +60,13 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
U.RespondJSON(w, r, cfg.RouteProviderList()) U.RespondJSON(w, r, cfg.RouteProviderList())
case ListHomepageCategories: case ListHomepageCategories:
U.RespondJSON(w, r, routequery.HomepageCategories()) 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: case ListTasks:
U.RespondJSON(w, r, task.DebugTaskList()) U.RespondJSON(w, r, task.DebugTaskList())
default: default:
@ -80,7 +89,7 @@ func listRoute(which string) any {
} }
func listFiles(w http.ResponseWriter, r *http.Request) { 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 { if err != nil {
U.HandleErr(w, r, err) U.HandleErr(w, r, err)
return return
@ -97,7 +106,7 @@ func listFiles(w http.ResponseWriter, r *http.Request) {
resp[t] = append(resp[t], file) resp[t] = append(resp[t], file)
} }
mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0) mids, err := utils.ListFiles(common.MiddlewareComposeBasePath, 0, true)
if err != nil { if err != nil {
U.HandleErr(w, r, err) U.HandleErr(w, r, err)
return return

View file

@ -16,12 +16,12 @@ const (
DotEnvPath = ".env" DotEnvPath = ".env"
DotEnvExamplePath = ".env.example" DotEnvExamplePath = ".env.example"
ConfigBasePath = "config" ConfigBasePath = "config"
ConfigFileName = "config.yml" ConfigFileName = "config.yml"
ConfigExampleFileName = "config.example.yml" ConfigExampleFileName = "config.example.yml"
ConfigPath = ConfigBasePath + "/" + ConfigFileName ConfigPath = ConfigBasePath + "/" + ConfigFileName
HomepageJSONConfigPath = ConfigBasePath + "/.homepage.json"
JWTKeyPath = ConfigBasePath + "/jwt.key" IconListCachePath = ConfigBasePath + "/.icon_list_cache.json"
MiddlewareComposeBasePath = ConfigBasePath + "/middlewares" MiddlewareComposeBasePath = ConfigBasePath + "/middlewares"

View file

@ -30,6 +30,16 @@ func (item *Item) IsEmpty() bool {
len(item.WidgetConfig) == 0) 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 { func NewHomePageConfig() Config {
return Config(make(map[string]Category)) return Config(make(map[string]Category))
} }

View file

@ -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")
}

View file

@ -3,14 +3,15 @@ package homepage
import ( import (
"strings" "strings"
"github.com/yusing/go-proxy/internal"
E "github.com/yusing/go-proxy/internal/error" E "github.com/yusing/go-proxy/internal/error"
) )
type ( type (
IconURL struct { IconURL struct {
Value string `json:"value"` Value string `json:"value"`
IconSource IconSource `json:"source"`
Extra *IconExtra `json:"extra"` Extra *IconExtra `json:"extra"`
} }
IconExtra struct { IconExtra struct {
@ -25,12 +26,23 @@ const (
IconSourceAbsolute IconSource = iota IconSourceAbsolute IconSource = iota
IconSourceRelative IconSourceRelative
IconSourceWalkXCode IconSourceWalkXCode
IconSourceSelfhSt
) )
const DashboardIconBaseURL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/"
var ErrInvalidIconURL = E.New("invalid icon url") 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. // Parse implements strutils.Parser.
func (u *IconURL) Parse(v string) error { func (u *IconURL) Parse(v string) error {
if v == "" { if v == "" {
@ -45,11 +57,12 @@ func (u *IconURL) Parse(v string) error {
case "http:", "https:": case "http:", "https:":
u.Value = v u.Value = v
u.IconSource = IconSourceAbsolute u.IconSource = IconSourceAbsolute
return nil case "@target", "": // @target/favicon.ico, /favicon.ico
case "@target":
u.Value = v[slashIndex:] u.Value = v[slashIndex:]
u.IconSource = IconSourceRelative u.IconSource = IconSourceRelative
return nil if u.Value == "/" {
return ErrInvalidIconURL.Withf("%s", "empty path")
}
case "png", "svg", "webp": // walkXCode Icons case "png", "svg", "webp": // walkXCode Icons
u.Value = v u.Value = v
u.IconSource = IconSourceWalkXCode u.IconSource = IconSourceWalkXCode
@ -57,10 +70,38 @@ func (u *IconURL) Parse(v string) error {
FileType: beforeSlash, FileType: beforeSlash,
Name: strings.TrimSuffix(v[slashIndex+1:], "."+beforeSlash), Name: strings.TrimSuffix(v[slashIndex+1:], "."+beforeSlash),
} }
return nil case "@selfhst": // selfh.st Icons, @selfhst/<reference>.<format>
u.Value = v[slashIndex:]
u.IconSource = IconSourceSelfhSt
parts := strings.Split(v[slashIndex+1:], ".")
if len(parts) != 2 {
return ErrInvalidIconURL.Withf("%s", "expect @selfhst/<reference>.<format>, 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: 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 { func (u *IconURL) String() string {

View file

@ -29,6 +29,24 @@ func TestIconURL(t *testing.T) {
IconSource: IconSourceRelative, 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", name: "walkxcode",
input: "png/walkxcode.png", 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", name: "invalid",
input: "invalid", input: "invalid",

View file

@ -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()
}

View file

@ -4,10 +4,13 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"os"
"sync" "sync"
"time" "time"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/logging" "github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/utils"
) )
type GitHubContents struct { //! keep this, may reuse in future 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"` 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 const updateInterval = 1 * time.Hour
var ( var (
iconsCache = make(Icons) iconsCache *Cache
iconsCahceMu sync.Mutex iconsCahceMu sync.Mutex
lastUpdate time.Time 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() iconsCahceMu.Lock()
defer iconsCahceMu.Unlock() defer iconsCahceMu.Unlock()
if time.Since(lastUpdate) < updateInterval { if time.Since(lastUpdate) < updateInterval {
if len(iconsCache) > 0 { if !iconsCache.isEmpty() {
return iconsCache, nil return iconsCache, nil
} }
} }
icons, err := getIcons() icons, err := fetchIconData()
if err != nil { if err != nil {
return nil, err return nil, err
} }
iconsCache = icons iconsCache = icons
lastUpdate = time.Now() 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 return icons, nil
} }
func HasIcon(name string, filetype string) bool { func HasWalkxCodeIcon(name string, filetype string) bool {
icons, err := ListAvailableIcons() icons, err := ListAvailableIcons()
if err != nil { if err != nil {
logging.Error().Err(err).Msg("failed to list icons") logging.Error().Err(err).Msg("failed to list icons")
return false return false
} }
if _, ok := icons[filetype]; !ok { if _, ok := icons.WalkxCode[filetype]; !ok {
return false return false
} }
_, ok := icons[filetype][name+"."+filetype] _, ok := icons.WalkxCode[filetype][name+"."+filetype]
return ok 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: format:
@ -74,10 +160,13 @@ format:
], ],
"svg": [ "svg": [
"*.svg", "*.svg",
],
"webp": [
"*.webp",
] ]
} }
*/ */
func getIcons() (Icons, error) { func fetchWalkxCodeIcons() (IconsMap, error) {
req, err := http.NewRequest(http.MethodGet, walkxcodeIcons, nil) req, err := http.NewRequest(http.MethodGet, walkxcodeIcons, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@ -98,7 +187,7 @@ func getIcons() (Icons, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
icons := make(Icons, len(data)) icons := make(IconsMap, len(data))
for fileType, files := range data { for fileType, files := range data {
icons[fileType] = make(map[string]struct{}, len(files)) icons[fileType] = make(map[string]struct{}, len(files))
for _, icon := range files { for _, icon := range files {
@ -107,3 +196,73 @@ func getIcons() (Icons, error) {
} }
return icons, nil 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
}

View file

@ -4,6 +4,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/yusing/go-proxy/internal"
"github.com/yusing/go-proxy/internal/homepage" "github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/route/entry" "github.com/yusing/go-proxy/internal/route/entry"
provider "github.com/yusing/go-proxy/internal/route/provider/types" provider "github.com/yusing/go-proxy/internal/route/provider/types"
@ -83,12 +84,21 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st
} }
if item.Name == "" { if item.Name == "" {
item.Name = strutils.Title( reference := r.TargetName()
strings.ReplaceAll( if r.RawEntry().Container != nil {
strings.ReplaceAll(alias, "-", " "), 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 { if useDefaultCategories {
@ -128,7 +138,7 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st
} }
item.AltURL = r.TargetURL().String() item.AltURL = r.TargetURL().String()
hpCfg.Add(item) hpCfg.Add(item.GetOverriddenItem())
}) })
return hpCfg return hpCfg
} }

View file

@ -115,6 +115,9 @@ GoDoxy v0.8.2 expected changes
do: error 403 Forbidden do: error 403 Forbidden
``` ```
- **new** Brand new rewritten WebUI
- **new** Support selfh.st icons: `@selfhst/<reference>.<format>` _(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 - **new** GoDoxy server side favicon retreiving and caching
- deliver smooth dashboard experience by caching favicons - 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 - 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