simplify icon caching and homepage item override

This commit is contained in:
yusing 2025-01-21 06:16:00 +08:00
parent d429374924
commit 8b1a3a31ff
21 changed files with 395 additions and 331 deletions

View file

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

3
go.mod
View file

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

6
go.sum
View file

@ -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=

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

@ -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/<reference>.<format>
u.Value = v[slashIndex:]
u.IconSource = IconSourceSelfhSt
parts := strings.Split(v[slashIndex+1:], ".")
case "@selfhst", "@walkxcode": // selfh.st / walkxcode Icons, @selfhst/<reference>.<format>
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/<reference>.<format>, e.g. @selfhst/adguard-home.webp")
return ErrInvalidIconURL.Withf("expect @%s/<reference>.<format>, 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))
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

@ -172,6 +172,10 @@ func (e *RawEntry) Finalize() {
}
}
if e.Homepage == nil {
e.Homepage = homepage.NewItem(e.Alias)
}
e.finalized = true
}

View file

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

View file

@ -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,