diff --git a/cmd/main.go b/cmd/main.go index 2946e93..a479a67 100755 --- a/cmd/main.go +++ b/cmd/main.go @@ -12,6 +12,7 @@ import ( "github.com/yusing/go-proxy/internal" v1 "github.com/yusing/go-proxy/internal/api/v1" "github.com/yusing/go-proxy/internal/api/v1/auth" + "github.com/yusing/go-proxy/internal/api/v1/favicon" "github.com/yusing/go-proxy/internal/api/v1/query" "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/config" @@ -32,6 +33,9 @@ func init() { out = io.MultiWriter(out, v1.MemLogger()) } logging.InitLogger(out) + internal.InitIconListCache() + homepage.InitOverridesConfig() + favicon.InitIconCache() } func main() { @@ -97,8 +101,6 @@ func main() { } middleware.LoadComposeFiles() - internal.InitIconListCache() - homepage.InitOverridesConfig() var cfg *config.Config var err E.Error diff --git a/go.mod b/go.mod index 6bfd203..a4cc8cf 100644 --- a/go.mod +++ b/go.mod @@ -14,8 +14,9 @@ require ( github.com/gobwas/glob v0.2.3 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/gotify/server/v2 v2.6.1 + github.com/lithammer/fuzzysearch v1.1.8 github.com/prometheus/client_golang v1.20.5 - github.com/puzpuzpuz/xsync/v3 v3.4.0 + github.com/puzpuzpuz/xsync/v3 v3.4.1 github.com/rs/zerolog v1.33.0 github.com/vincent-petithory/dataurl v1.0.0 golang.org/x/crypto v0.32.0 diff --git a/go.sum b/go.sum index 760e067..25d85f7 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -130,8 +132,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4= -github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/puzpuzpuz/xsync/v3 v3.4.1 h1:wWXLKXwzpsduC3kUSahiL45MWxkGb+AQG0dsri4iftA= +github.com/puzpuzpuz/xsync/v3 v3.4.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= diff --git a/internal/api/handler.go b/internal/api/handler.go index 8f93cbc..09431e4 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -47,6 +47,10 @@ func NewHandler(cfg config.ConfigInstance) http.Handler { }) mux.HandleFunc("GET,POST", "/v1/auth/callback", defaultAuth.LoginCallbackHandler) mux.HandleFunc("GET,POST", "/v1/auth/logout", auth.LogoutCallbackHandler(defaultAuth)) + } else { + mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) } return mux } diff --git a/internal/api/v1/favicon/favicon.go b/internal/api/v1/favicon/favicon.go index a8fa452..f2f5041 100644 --- a/internal/api/v1/favicon/favicon.go +++ b/internal/api/v1/favicon/favicon.go @@ -5,7 +5,6 @@ import ( "bytes" "context" "errors" - "fmt" "io" "net" "net/http" @@ -17,13 +16,15 @@ import ( "github.com/PuerkitoBio/goquery" "github.com/vincent-petithory/dataurl" - "github.com/yusing/go-proxy/internal" U "github.com/yusing/go-proxy/internal/api/v1/utils" + "github.com/yusing/go-proxy/internal/common" "github.com/yusing/go-proxy/internal/homepage" "github.com/yusing/go-proxy/internal/logging" gphttp "github.com/yusing/go-proxy/internal/net/http" "github.com/yusing/go-proxy/internal/route/routes" route "github.com/yusing/go-proxy/internal/route/types" + "github.com/yusing/go-proxy/internal/task" + "github.com/yusing/go-proxy/internal/utils" ) type content struct { @@ -80,17 +81,15 @@ func GetFavIcon(w http.ResponseWriter, req *http.Request) { var status int var errMsg string - hp := r.RawEntry().Homepage - if hp != nil && hp.Icon != nil { + hp := r.RawEntry().Homepage.GetOverride() + if !hp.IsEmpty() && hp.Icon != nil { switch hp.Icon.IconSource { case homepage.IconSourceAbsolute: icon, status, errMsg = fetchIconAbsolute(hp.Icon.Value) case homepage.IconSourceRelative: 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) + case homepage.IconSourceWalkXCode, homepage.IconSourceSelfhSt: + icon, status, errMsg = fetchKnownIcon(hp.Icon) } } else { // try extract from "link[rel=icon]" @@ -112,6 +111,24 @@ var ( iconCacheMu sync.RWMutex ) +func InitIconCache() { + err := utils.LoadJSONIfExist(common.IconCachePath, &iconCache) + if err != nil { + logging.Error().Err(err).Msg("failed to load icon cache") + } else { + logging.Info().Msgf("icon cache loaded (%d icons)", len(iconCache)) + } + + task.OnProgramExit("save_favicon_cache", func() { + iconCacheMu.Lock() + defer iconCacheMu.Unlock() + + if err := utils.SaveJSON(common.IconCachePath, &iconCache, 0o644); err != nil { + logging.Error().Err(err).Msg("failed to save icon cache") + } + }) +} + func ResetIconCache(route route.HTTPRoute) { iconCacheMu.Lock() defer iconCacheMu.Unlock() @@ -122,6 +139,11 @@ func loadIconCache(key string) (icon []byte, ok bool) { iconCacheMu.RLock() defer iconCacheMu.RUnlock() icon, ok = iconCache[key] + if ok { + logging.Debug(). + Str("key", key). + Msg("icon found in cache") + } return } @@ -172,56 +194,30 @@ func sanitizeName(name string) string { return strings.ToLower(nameSanitizer.Replace(name)) } -func fetchWalkxcodeIcon(filetype, name string) ([]byte, int, string) { +func fetchKnownIcon(url *homepage.IconURL) ([]byte, int, string) { // if icon isn't in the list, no need to fetch - if !internal.HasWalkxCodeIcon(name, filetype) { + if !url.HasIcon() { logging.Debug(). - Str("filetype", filetype). - Str("name", name). - Msg("icon not found") - return nil, http.StatusNotFound, "icon not found" + Str("value", url.String()). + Str("url", url.URL()). + Msg("no such icon") + return nil, http.StatusNotFound, "no such icon" } - icon, ok := loadIconCache("walkxcode/" + filetype + "/" + name) - if ok { - return icon, http.StatusOK, "" - } - - // 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) + return fetchIconAbsolute(url.URL()) } func fetchIcon(filetype, filename string) (icon []byte, status int, errMsg string) { - icon, status, errMsg = fetchSelfhStIcon(filetype, filename) + icon, status, errMsg = fetchKnownIcon(homepage.NewSelfhStIconURL(filename, filetype)) if icon != nil { return } - icon, status, errMsg = fetchWalkxcodeIcon(filetype, filename) + icon, status, errMsg = fetchKnownIcon(homepage.NewWalkXCodeIconURL(filename, filetype)) return } func findIcon(r route.HTTPRoute, req *http.Request, uri string) (icon []byte, status int, errMsg string) { - key := r.TargetName() + key := r.RawEntry().Provider + ":" + r.TargetName() icon, ok := loadIconCache(key) if ok { if icon == nil { @@ -239,8 +235,9 @@ func findIcon(r route.HTTPRoute, req *http.Request, uri string) (icon []byte, st // fallback to parse html icon, status, errMsg = findIconSlow(r, req, uri) } - // set even if error (nil) - storeIconCache(key, icon) + if icon != nil { + storeIconCache(key, icon) + } return } diff --git a/internal/api/v1/homepage_overrides.go b/internal/api/v1/homepage_overrides.go index f2e0c2f..48e0d4c 100644 --- a/internal/api/v1/homepage_overrides.go +++ b/internal/api/v1/homepage_overrides.go @@ -4,19 +4,14 @@ 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" + "github.com/yusing/go-proxy/internal/utils" ) const ( - HomepageOverrideDisplayname = "display_name" - HomepageOverrideDisplayOrder = "display_order" - HomepageOverrideDisplayCategory = "display_category" - HomepageOverrideCategoryOrder = "category_order" - HomepageOverrideCategoryName = "category_name" - HomepageOverrideIcon = "icon" - HomepageOverrideShow = "show" + HomepageOverrideItem = "item" + HomepageOverrideCategoryOrder = "category_order" + HomepageOverrideCategoryName = "category_name" ) func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) { @@ -29,30 +24,27 @@ func SetHomePageOverrides(w http.ResponseWriter, r *http.Request) { http.Error(w, "missing value", http.StatusBadRequest) return } - overrides := homepage.GetJSONConfig() + overrides := homepage.GetOverrideConfig() switch what { - case HomepageOverrideDisplayname: - utils.RespondError(w, overrides.SetDisplayNameOverride(which, value)) - case HomepageOverrideDisplayCategory: - utils.RespondError(w, overrides.SetDisplayCategoryOverride(which, value)) + case HomepageOverrideItem: + var override homepage.ItemConfig + if err := utils.DeserializeJSON([]byte(value), &override); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + overrides.OverrideItem(which, &override) 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: + overrides.SetCategoryNameOverride(which, value) + case 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)) - } + overrides.SetCategoryOrder(which, v) default: http.Error(w, "invalid what", http.StatusBadRequest) + return } + w.WriteHeader(http.StatusOK) } diff --git a/internal/api/v1/list.go b/internal/api/v1/list.go index c348a7c..a711022 100644 --- a/internal/api/v1/list.go +++ b/internal/api/v1/list.go @@ -2,6 +2,7 @@ package v1 import ( "net/http" + "strconv" "strings" "github.com/yusing/go-proxy/internal" @@ -61,7 +62,11 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) { case ListHomepageCategories: U.RespondJSON(w, r, routequery.HomepageCategories()) case ListIcons: - icons, err := internal.ListAvailableIcons() + limit, err := strconv.Atoi(r.FormValue("limit")) + if err != nil { + limit = 0 + } + icons, err := internal.SearchIcons(r.FormValue("keyword"), limit) if err != nil { U.RespondError(w, err) return diff --git a/internal/api/v1/utils/ws.go b/internal/api/v1/utils/ws.go index 28db66b..127892d 100644 --- a/internal/api/v1/utils/ws.go +++ b/internal/api/v1/utils/ws.go @@ -60,7 +60,7 @@ func PeriodicWS(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Reques return case <-ticker.C: if err := do(conn); err != nil { - HandleErr(w, r, err) + LogError(r).Msg(err.Error()) return } } diff --git a/internal/common/constants.go b/internal/common/constants.go index 8a928ba..b056178 100644 --- a/internal/common/constants.go +++ b/internal/common/constants.go @@ -22,6 +22,7 @@ const ( ConfigPath = ConfigBasePath + "/" + ConfigFileName HomepageJSONConfigPath = ConfigBasePath + "/.homepage.json" IconListCachePath = ConfigBasePath + "/.icon_list_cache.json" + IconCachePath = ConfigBasePath + "/.icon_cache.json" MiddlewareComposeBasePath = ConfigBasePath + "/middlewares" diff --git a/internal/homepage/homepage.go b/internal/homepage/homepage.go index ef07f54..875aaec 100644 --- a/internal/homepage/homepage.go +++ b/internal/homepage/homepage.go @@ -5,39 +5,45 @@ type ( Config map[string]Category Category []*Item - Item struct { + ItemConfig struct { Show bool `json:"show"` Name string `json:"name"` // display name Icon *IconURL `json:"icon"` - URL string `json:"url"` // alias + domain Category string `json:"category"` Description string `json:"description" aliases:"desc"` + SortOrder int `json:"sort_order"` WidgetConfig map[string]any `json:"widget_config" aliases:"widget"` + URL string `json:"url"` // alias + domain + } + + Item struct { + *ItemConfig Alias string `json:"alias"` // proxy alias SourceType string `json:"source_type"` AltURL string `json:"alt_url"` // original proxy target Provider string `json:"provider"` + + IsUnset bool } ) -func (item *Item) IsEmpty() bool { - return item == nil || (item.Name == "" && - item.Icon == nil && - item.URL == "" && - item.Category == "" && - item.Description == "" && - len(item.WidgetConfig) == 0) +func NewItem(alias string) *Item { + return &Item{ + ItemConfig: &ItemConfig{ + Show: true, + }, + Alias: alias, + IsUnset: true, + } } -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 (item *Item) IsEmpty() bool { + return item == nil || item.IsUnset || item.ItemConfig == nil +} + +func (item *Item) GetOverride() *Item { + return overrideConfigInstance.GetOverride(item) } func NewHomePageConfig() Config { diff --git a/internal/homepage/homepage_test.go b/internal/homepage/homepage_test.go index 4acfce6..e799895 100644 --- a/internal/homepage/homepage_test.go +++ b/internal/homepage/homepage_test.go @@ -7,25 +7,25 @@ import ( ) 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")) + // a := &Item{ + // Show: false, + // Alias: "foo", + // Name: "Foo", + // Icon: &IconURL{ + // Value: "/favicon.ico", + // IconSource: IconSourceRelative, + // }, + // Category: "App", + // } + // overrides := GetJSONConfig() + // overrides.SetShowItemOverride(a.Alias, true) + // overrides.SetDisplayNameOverride(a.Alias, "Bar") + // overrides.SetDisplayCategoryOverride(a.Alias, "Test") + // ExpectNoError(t, overrides.SetIconOverride(a.Alias, "@walkxcode/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") + // 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 9ea6314..0ba7c12 100644 --- a/internal/homepage/icon_url.go +++ b/internal/homepage/icon_url.go @@ -1,6 +1,7 @@ package homepage import ( + "fmt" "strings" "github.com/yusing/go-proxy/internal" @@ -10,6 +11,7 @@ import ( type ( IconURL struct { Value string `json:"value"` + FullValue string `json:"full_value"` IconSource `json:"source"` Extra *IconExtra `json:"extra"` } @@ -31,14 +33,39 @@ const ( 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 +func NewSelfhStIconURL(reference, format string) *IconURL { + return &IconURL{ + Value: reference + "." + format, + FullValue: fmt.Sprintf("@selfhst/%s.%s", reference, format), + IconSource: IconSourceSelfhSt, + Extra: &IconExtra{ + FileType: format, + Name: reference, + }, } - if u.IconSource == IconSourceWalkXCode && - !internal.HasWalkxCodeIcon(u.Extra.Name, u.Extra.FileType) { - return false +} + +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, + }, + } +} + +// 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 internal.HasSelfhstIcon(u.Extra.Name, u.Extra.FileType) + } + if u.IconSource == IconSourceWalkXCode { + return internal.HasWalkxCodeIcon(u.Extra.Name, u.Extra.FileType) } return true } @@ -52,6 +79,7 @@ func (u *IconURL) Parse(v string) error { if slashIndex == -1 { return ErrInvalidIconURL } + u.FullValue = v beforeSlash := v[:slashIndex] switch beforeSlash { case "http:", "https:": @@ -63,19 +91,23 @@ func (u *IconURL) Parse(v string) error { 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.IconSource = IconSourceWalkXCode u.Extra = &IconExtra{ FileType: beforeSlash, Name: strings.TrimSuffix(v[slashIndex+1:], "."+beforeSlash), } - case "@selfhst": // selfh.st Icons, @selfhst/. - u.Value = v[slashIndex:] - u.IconSource = IconSourceSelfhSt - parts := strings.Split(v[slashIndex+1:], ".") + 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, ".") if len(parts) != 2 { - return ErrInvalidIconURL.Withf("%s", "expect @selfhst/., e.g. @selfhst/adguard-home.webp") + return ErrInvalidIconURL.Withf("expect @%s/., e.g. @%s/adguard-home.webp", beforeSlash, beforeSlash) } reference, format := parts[0], strings.ToLower(parts[1]) if reference == "" || format == "" { @@ -84,7 +116,7 @@ func (u *IconURL) Parse(v string) error { switch format { case "svg", "png", "webp": default: - return ErrInvalidIconURL.Withf("%s", "invalid format, expect svg/png/webp") + return ErrInvalidIconURL.Withf("%s", "invalid image format, expect svg/png/webp") } u.Extra = &IconExtra{ FileType: format, @@ -99,11 +131,33 @@ func (u *IconURL) Parse(v string) error { } if !u.HasIcon() { - return ErrInvalidIconURL.Withf("no such icon %s", u.Value) + return ErrInvalidIconURL.Withf("no such icon %s from %s", u.Value, beforeSlash) } return nil } -func (u *IconURL) String() string { - return u.Value +func (u *IconURL) URL() string { + 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) + 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 "" +} + +func (u *IconURL) String() string { + return u.FullValue +} + +func (u *IconURL) MarshalText() ([]byte, error) { + return []byte(u.String()), nil +} + +func (u *IconURL) UnmarshalText(data []byte) error { + return u.Parse(string(data)) } diff --git a/internal/homepage/icon_url_test.go b/internal/homepage/icon_url_test.go index 9d8ef6a..7215397 100644 --- a/internal/homepage/icon_url_test.go +++ b/internal/homepage/icon_url_test.go @@ -49,13 +49,25 @@ func TestIconURL(t *testing.T) { }, { name: "walkxcode", - input: "png/walkxcode.png", + input: "png/adguard-home.png", wantValue: &IconURL{ - Value: "png/walkxcode.png", + Value: "png/adguard-home.png", IconSource: IconSourceWalkXCode, Extra: &IconExtra{ FileType: "png", - Name: "walkxcode", + Name: "adguard-home", + }, + }, + }, + { + name: "walkxcode_alt", + input: "@walkxcode/adguard-home.png", + wantValue: &IconURL{ + Value: "adguard-home.png", + IconSource: IconSourceWalkXCode, + Extra: &IconExtra{ + FileType: "png", + Name: "adguard-home", }, }, }, @@ -66,13 +78,13 @@ func TestIconURL(t *testing.T) { }, { name: "selfh.st_valid", - input: "@selfhst/foo.png", + input: "@selfhst/adguard-home.png", wantValue: &IconURL{ - Value: "/foo.png", + Value: "adguard-home.png", IconSource: IconSourceSelfhSt, Extra: &IconExtra{ FileType: "png", - Name: "foo", + Name: "adguard-home", }, }, }, diff --git a/internal/homepage/json_config.go b/internal/homepage/json_config.go deleted file mode 100644 index 54c8990..0000000 --- a/internal/homepage/json_config.go +++ /dev/null @@ -1,147 +0,0 @@ -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/homepage/override_config.go b/internal/homepage/override_config.go new file mode 100644 index 0000000..5196fab --- /dev/null +++ b/internal/homepage/override_config.go @@ -0,0 +1,94 @@ +package homepage + +import ( + "sync" + + "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 OverrideConfig struct { + ItemOverrides map[string]*ItemConfig `json:"item_overrides"` + DisplayOrder map[string]int `json:"display_order"` // TODO: implement this + CategoryName map[string]string `json:"category_name"` + CategoryOrder map[string]int `json:"category_order"` // TODO: implement this + mu sync.RWMutex +} + +var overrideConfigInstance *OverrideConfig + +func must(b []byte, err error) []byte { + if err != nil { + panic(err) + } + return b +} + +func InitOverridesConfig() { + overrideConfigInstance = &OverrideConfig{ + ItemOverrides: make(map[string]*ItemConfig), + DisplayOrder: make(map[string]int), + CategoryName: make(map[string]string), + CategoryOrder: make(map[string]int), + } + err := utils.LoadJSONIfExist(common.HomepageJSONConfigPath, overrideConfigInstance) + if err != nil { + logging.Error().Err(err).Msg("failed to load homepage overrides config") + } else { + logging.Info().Msgf("homepage overrides config loaded, %d items", len(overrideConfigInstance.ItemOverrides)) + } + task.OnProgramExit("save_homepage_json_config", func() { + if err := utils.SaveJSON(common.HomepageJSONConfigPath, overrideConfigInstance, 0o644); err != nil { + logging.Error().Err(err).Msg("failed to save homepage overrides config") + } + }) +} + +func GetOverrideConfig() *OverrideConfig { + return overrideConfigInstance +} + +func (c *OverrideConfig) UnmarshalJSON(data []byte) error { + return utils.DeserializeJSON(data, c) +} + +func (c *OverrideConfig) OverrideItem(alias string, override *ItemConfig) { + c.mu.Lock() + defer c.mu.Unlock() + c.ItemOverrides[alias] = override +} + +func (c *OverrideConfig) GetOverride(item *Item) *Item { + c.mu.RLock() + defer c.mu.RUnlock() + itemOverride, ok := c.ItemOverrides[item.Alias] + if !ok { + if catOverride, ok := c.CategoryName[item.Category]; ok { + clone := *item + clone.Category = catOverride + return &clone + } + return item + } else { + clone := *item + clone.ItemConfig = itemOverride + if catOverride, ok := c.CategoryName[clone.Category]; ok { + clone.Category = catOverride + } + return &clone + } +} + +func (c *OverrideConfig) SetCategoryNameOverride(key, value string) { + c.mu.Lock() + defer c.mu.Unlock() + c.CategoryName[key] = value +} + +func (c *OverrideConfig) SetCategoryOrder(key string, value int) { + c.mu.Lock() + defer c.mu.Unlock() + c.CategoryOrder[key] = value +} diff --git a/internal/list-icons.go b/internal/list-icons.go index 52496c5..ab6492f 100644 --- a/internal/list-icons.go +++ b/internal/list-icons.go @@ -8,9 +8,11 @@ import ( "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/utils" + "github.com/yusing/go-proxy/internal/utils/strutils" ) type GitHubContents struct { //! keep this, may reuse in future @@ -23,25 +25,20 @@ type GitHubContents struct { //! keep this, may reuse in future 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) isEmpty() bool { - return len(icons.WalkxCode) == 0 && len(icons.Selfhst) == 0 +func (icons *Cache) needUpdate() bool { + return len(icons.WalkxCode) == 0 || len(icons.Selfhst) == 0 || len(icons.IconList) == 0 || len(icons.DisplayNames) == 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 = 2 * time.Hour var ( iconsCache *Cache @@ -59,16 +56,17 @@ func InitIconListCache() { WalkxCode: make(IconsMap), Selfhst: make(IconsMap), DisplayNames: make(ReferenceDisplayNameMap), + IconList: []string{}, } - 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() - } + err := utils.LoadJSONIfExist(common.IconListCachePath, iconsCache) + if err != nil { + logging.Error().Err(err).Msg("failed to load icon list cache config") + } else if stats, err := os.Stat(common.IconListCachePath); err == nil { + lastUpdate = stats.ModTime() + logging.Info().Msgf("icon list cache loaded (%d icons, %d display names), last updated at %s", + len(iconsCache.IconList), + len(iconsCache.DisplayNames), + strutils.FormatTime(lastUpdate)) } } @@ -77,7 +75,7 @@ func ListAvailableIcons() (*Cache, error) { defer iconsCahceMu.Unlock() if time.Since(lastUpdate) < updateInterval { - if !iconsCache.isEmpty() { + if !iconsCache.needUpdate() { return iconsCache, nil } } @@ -87,6 +85,8 @@ func ListAvailableIcons() (*Cache, error) { return nil, err } + logging.Info().Msg("icons list updated") + iconsCache = icons lastUpdate = time.Now() @@ -97,6 +97,17 @@ func ListAvailableIcons() (*Cache, error) { 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 { @@ -134,20 +145,26 @@ func GetDisplayName(reference string) (string, bool) { } func fetchIconData() (*Cache, error) { - walkxCodeIcons, err := fetchWalkxCodeIcons() + walkxCodeIconMap, walkxCodeIconList, err := fetchWalkxCodeIcons() if err != nil { return nil, err } - selfhstIcons, referenceToNames, err := fetchSelfhstIcons() + n := 0 + for _, items := range walkxCodeIconMap { + n += len(items) + } + + selfhstIconMap, selfhstIconList, referenceToNames, err := fetchSelfhstIcons() if err != nil { return nil, err } return &Cache{ - WalkxCode: walkxCodeIcons, - Selfhst: selfhstIcons, + WalkxCode: walkxCodeIconMap, + Selfhst: selfhstIconMap, DisplayNames: referenceToNames, + IconList: append(walkxCodeIconList, selfhstIconList...), }, nil } @@ -166,35 +183,37 @@ format: ] } */ -func fetchWalkxCodeIcons() (IconsMap, error) { +func fetchWalkxCodeIcons() (IconsMap, IconList, error) { req, err := http.NewRequest(http.MethodGet, walkxcodeIcons, nil) if err != nil { - return nil, err + return nil, nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { - return nil, err + return nil, nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + return nil, nil, err } data := make(map[string][]string) err = json.Unmarshal(body, &data) if err != nil { - return nil, err + 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, nil + return icons, iconList, nil } /* @@ -211,7 +230,7 @@ format: "CreatedAt": "2024-08-16 00:27:23+00:00" } */ -func fetchSelfhstIcons() (IconsMap, ReferenceDisplayNameMap, error) { +func fetchSelfhstIcons() (IconsMap, IconList, ReferenceDisplayNameMap, error) { type SelfhStIcon struct { Name string `json:"Name"` Reference string `json:"Reference"` @@ -225,25 +244,26 @@ func fetchSelfhstIcons() (IconsMap, ReferenceDisplayNameMap, error) { req, err := http.NewRequest(http.MethodGet, selfhstIcons, nil) if err != nil { - return nil, nil, err + return nil, nil, nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { - return nil, nil, err + return nil, nil, nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return nil, nil, err + return nil, nil, nil, err } data := make([]SelfhStIcon, 0, 2000) err = json.Unmarshal(body, &data) if err != nil { - return nil, nil, err + 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)) @@ -254,15 +274,18 @@ func fetchSelfhstIcons() (IconsMap, ReferenceDisplayNameMap, error) { 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, referenceToNames, nil + return icons, iconList, referenceToNames, nil } diff --git a/internal/route/http.go b/internal/route/http.go index bfb6401..e6e2542 100755 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -194,7 +194,7 @@ func (r *HTTPRoute) addToLoadBalancer(parent task.Parent) { linked = l.(*HTTPRoute) lb = linked.loadBalancer lb.UpdateConfigIfNeeded(cfg) - if linked.Raw.Homepage == nil && r.Raw.Homepage != nil { + if linked.Raw.Homepage.IsEmpty() && !r.Raw.Homepage.IsEmpty() { linked.Raw.Homepage = r.Raw.Homepage } } else { diff --git a/internal/route/routes/routequery/query.go b/internal/route/routes/routequery/query.go index 9eadd8a..97b38e7 100644 --- a/internal/route/routes/routequery/query.go +++ b/internal/route/routes/routequery/query.go @@ -45,7 +45,7 @@ func HomepageCategories() []string { categories := make([]string, 0) routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) { en := r.RawEntry() - if en.Homepage == nil || en.Homepage.Category == "" { + if en.Homepage.IsEmpty() || en.Homepage.Category == "" { return } if _, ok := check[en.Homepage.Category]; ok { @@ -63,13 +63,14 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st routes.GetHTTPRoutes().RangeAll(func(alias string, r route.HTTPRoute) { en := r.RawEntry() item := en.Homepage - if item == nil { - item = new(homepage.Item) - item.Show = true + + if item.IsEmpty() { + item = homepage.NewItem(alias) } - if !item.IsEmpty() { - item.Show = true + if override := item.GetOverride(); override != item { + hpCfg.Add(override) + return } if !item.Show { @@ -85,8 +86,9 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st if item.Name == "" { reference := r.TargetName() - if r.RawEntry().Container != nil { - reference = r.RawEntry().Container.ImageName + cont := r.RawEntry().Container + if cont != nil { + reference = cont.ImageName } name, ok := internal.GetDisplayName(reference) if ok { @@ -138,7 +140,7 @@ func HomepageConfig(useDefaultCategories bool, categoryFilter, providerFilter st } item.AltURL = r.TargetURL().String() - hpCfg.Add(item.GetOverriddenItem()) + hpCfg.Add(item) }) return hpCfg } diff --git a/internal/route/types/raw_entry.go b/internal/route/types/raw_entry.go index d4dddb1..c3fad07 100644 --- a/internal/route/types/raw_entry.go +++ b/internal/route/types/raw_entry.go @@ -172,6 +172,10 @@ func (e *RawEntry) Finalize() { } } + if e.Homepage == nil { + e.Homepage = homepage.NewItem(e.Alias) + } + e.finalized = true } diff --git a/internal/utils/slices.go b/internal/utils/slices.go index afe2914..787029c 100644 --- a/internal/utils/slices.go +++ b/internal/utils/slices.go @@ -18,3 +18,11 @@ func Intersect[T comparable, Slice ~[]T](slice1 Slice, slice2 Slice) Slice { return result } + +// Slice returns a slice of the first n elements in slice like javascript's slice. +func Slice[T any](slice []T, n int) []T { + if n >= len(slice) { + return slice + } + return slice[:n] +} diff --git a/internal/watcher/directory_watcher.go b/internal/watcher/directory_watcher.go index 5ce176e..171d59e 100644 --- a/internal/watcher/directory_watcher.go +++ b/internal/watcher/directory_watcher.go @@ -113,6 +113,10 @@ func (h *DirWatcher) start() { relPath := strings.TrimPrefix(fsEvent.Name, h.dir) relPath = strings.TrimPrefix(relPath, "/") + if len(relPath) > 0 && relPath[0] == '.' { // hideden file + continue + } + msg := Event{ Type: events.EventTypeFile, ActorName: relPath,