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