refactor(homepage): reorganize icons into dedicated package structure

Split the monolithic `internal/homepage` icons functionality into a structured package hierarchy:

- `internal/homepage/icons/` - Core types (URL, Key, Meta, Provider, Source, Variant)
- `internal/homepage/icons/fetch/` - Icon fetching logic (content.go, fetch.go, route.go)
- `internal/homepage/icons/list/` - Icon listing and search (list_icons.go, list_icons_test.go)

Moved icon-related code from `internal/homepage/`:
- `icon_url.go` → `icons/url.go` (+ url_test.go)
- `content.go` → `icons/fetch/content.go`
- `route.go` → `icons/fetch/route.go`
- `list_icons.go` → `icons/list/list_icons.go` (+ list_icons_test.go)

Updated all consumers to use the new package structure:
- `cmd/main.go`
- `internal/api/v1/favicon.go`
- `internal/api/v1/icons.go`
- `internal/idlewatcher/handle_http.go`
- `internal/route/route.go`
This commit is contained in:
yusing
2026-01-09 12:06:54 +08:00
parent dc1b70d2d7
commit 74f97a6621
17 changed files with 291 additions and 260 deletions

View File

@@ -10,7 +10,7 @@ import (
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/config"
"github.com/yusing/godoxy/internal/dnsproviders"
"github.com/yusing/godoxy/internal/homepage"
iconlist "github.com/yusing/godoxy/internal/homepage/icons/list"
"github.com/yusing/godoxy/internal/logging"
"github.com/yusing/godoxy/internal/logging/memlogger"
"github.com/yusing/godoxy/internal/metrics/systeminfo"
@@ -39,7 +39,7 @@ func main() {
log.Trace().Msg("trace enabled")
parallel(
dnsproviders.InitProviders,
homepage.InitIconListCache,
iconlist.InitCache,
systeminfo.Poller.Start,
middleware.LoadComposeFiles,
)

View File

@@ -5,7 +5,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/homepage/icons"
iconfetch "github.com/yusing/godoxy/internal/homepage/icons/fetch"
"github.com/yusing/godoxy/internal/route/routes"
apitypes "github.com/yusing/goutils/apitypes"
@@ -13,9 +14,9 @@ import (
)
type GetFavIconRequest struct {
URL string `form:"url" binding:"required_without=Alias"`
Alias string `form:"alias" binding:"required_without=URL"`
Variant homepage.IconVariant `form:"variant" binding:"omitempty,oneof=light dark"`
URL string `form:"url" binding:"required_without=Alias"`
Alias string `form:"alias" binding:"required_without=URL"`
Variant icons.Variant `form:"variant" binding:"omitempty,oneof=light dark"`
} // @name GetFavIconRequest
// @x-id "favicon"
@@ -42,18 +43,18 @@ func FavIcon(c *gin.Context) {
// try with url
if request.URL != "" {
var iconURL homepage.IconURL
var iconURL icons.URL
if err := iconURL.Parse(request.URL); err != nil {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid url", err))
return
}
icon := &iconURL
if request.Variant != homepage.IconVariantNone {
if request.Variant != icons.VariantNone {
icon = icon.WithVariant(request.Variant)
}
fetchResult, err := homepage.FetchFavIconFromURL(c.Request.Context(), icon)
fetchResult, err := iconfetch.FetchFavIconFromURL(c.Request.Context(), icon)
if err != nil {
homepage.GinFetchError(c, fetchResult.StatusCode, err)
iconfetch.GinError(c, fetchResult.StatusCode, err)
return
}
c.Data(fetchResult.StatusCode, fetchResult.ContentType(), fetchResult.Icon)
@@ -63,40 +64,40 @@ func FavIcon(c *gin.Context) {
// try with alias
result, err := GetFavIconFromAlias(c.Request.Context(), request.Alias, request.Variant)
if err != nil {
homepage.GinFetchError(c, result.StatusCode, err)
iconfetch.GinError(c, result.StatusCode, err)
return
}
c.Data(result.StatusCode, result.ContentType(), result.Icon)
}
//go:linkname GetFavIconFromAlias v1.GetFavIconFromAlias
func GetFavIconFromAlias(ctx context.Context, alias string, variant homepage.IconVariant) (homepage.FetchResult, error) {
func GetFavIconFromAlias(ctx context.Context, alias string, variant icons.Variant) (iconfetch.Result, error) {
// try with route.Icon
r, ok := routes.HTTP.Get(alias)
if !ok {
return homepage.FetchResultWithErrorf(http.StatusNotFound, "route not found")
return iconfetch.FetchResultWithErrorf(http.StatusNotFound, "route not found")
}
var (
result homepage.FetchResult
result iconfetch.Result
err error
)
hp := r.HomepageItem()
if hp.Icon != nil {
if hp.Icon.IconSource == homepage.IconSourceRelative {
result, err = homepage.FindIcon(ctx, r, *hp.Icon.FullURL, variant)
} else if variant != homepage.IconVariantNone {
result, err = homepage.FetchFavIconFromURL(ctx, hp.Icon.WithVariant(variant))
if hp.Icon.Source == icons.SourceRelative {
result, err = iconfetch.FindIcon(ctx, r, *hp.Icon.FullURL, variant)
} else if variant != icons.VariantNone {
result, err = iconfetch.FetchFavIconFromURL(ctx, hp.Icon.WithVariant(variant))
if err != nil {
// fallback to no variant
result, err = homepage.FetchFavIconFromURL(ctx, hp.Icon.WithVariant(homepage.IconVariantNone))
result, err = iconfetch.FetchFavIconFromURL(ctx, hp.Icon.WithVariant(icons.VariantNone))
}
} else {
result, err = homepage.FetchFavIconFromURL(ctx, hp.Icon)
result, err = iconfetch.FetchFavIconFromURL(ctx, hp.Icon)
}
} else {
// try extract from "link[rel=icon]"
result, err = homepage.FindIcon(ctx, r, "/", variant)
result, err = iconfetch.FindIcon(ctx, r, "/", variant)
}
if result.StatusCode == 0 {
result.StatusCode = http.StatusOK

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yusing/godoxy/internal/homepage"
iconlist "github.com/yusing/godoxy/internal/homepage/icons/list"
apitypes "github.com/yusing/goutils/apitypes"
)
@@ -32,6 +32,6 @@ func Icons(c *gin.Context) {
c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err))
return
}
icons := homepage.SearchIcons(request.Keyword, request.Limit)
icons := iconlist.SearchIcons(request.Keyword, request.Limit)
c.JSON(http.StatusOK, icons)
}

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/yusing/ds/ordered"
"github.com/yusing/godoxy/internal/homepage/icons"
"github.com/yusing/godoxy/internal/homepage/widgets"
"github.com/yusing/godoxy/internal/serialization"
strutils "github.com/yusing/goutils/strings"
@@ -22,13 +23,13 @@ type (
} // @name HomepageCategory
ItemConfig struct {
Show bool `json:"show"`
Name string `json:"name"` // display name
Icon *IconURL `json:"icon" swaggertype:"string"`
Category string `json:"category" validate:"omitempty"`
Description string `json:"description" aliases:"desc"`
URL string `json:"url,omitempty"`
Favorite bool `json:"favorite"`
Show bool `json:"show"`
Name string `json:"name"` // display name
Icon *icons.URL `json:"icon" swaggertype:"string"`
Category string `json:"category" validate:"omitempty"`
Description string `json:"description" aliases:"desc"`
URL string `json:"url,omitempty"`
Favorite bool `json:"favorite"`
WidgetConfig *widgets.Config `json:"widget_config,omitempty" aliases:"widget" extensions:"x-nullable"`
} // @name HomepageItemConfig

View File

@@ -4,19 +4,24 @@ import (
"testing"
. "github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/homepage/icons"
expect "github.com/yusing/goutils/testing"
)
func strPtr(s string) *string {
return &s
}
func TestOverrideItem(t *testing.T) {
a := &Item{
Alias: "foo",
ItemConfig: ItemConfig{
Show: false,
Name: "Foo",
Icon: &IconURL{
FullURL: strPtr("/favicon.ico"),
IconSource: IconSourceRelative,
Icon: &icons.URL{
FullURL: strPtr("/favicon.ico"),
Source: icons.SourceRelative,
},
Category: "App",
},
@@ -25,9 +30,9 @@ func TestOverrideItem(t *testing.T) {
Show: true,
Name: "Bar",
Category: "Test",
Icon: &IconURL{
FullURL: strPtr("@walkxcode/example.png"),
IconSource: IconSourceWalkXCode,
Icon: &icons.URL{
FullURL: strPtr("@walkxcode/example.png"),
Source: icons.SourceWalkXCode,
},
}
overrides := GetOverrideConfig()

View File

@@ -1,4 +1,4 @@
package homepage
package iconfetch
import (
"bufio"

View File

@@ -1,4 +1,4 @@
package homepage
package iconfetch
import (
"bytes"
@@ -15,6 +15,7 @@ import (
"github.com/PuerkitoBio/goquery"
"github.com/gin-gonic/gin"
"github.com/vincent-petithory/dataurl"
"github.com/yusing/godoxy/internal/homepage/icons"
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
apitypes "github.com/yusing/goutils/apitypes"
"github.com/yusing/goutils/cache"
@@ -22,22 +23,22 @@ import (
strutils "github.com/yusing/goutils/strings"
)
type FetchResult struct {
type Result struct {
Icon []byte
StatusCode int
contentType string
}
func FetchResultWithErrorf(statusCode int, msgFmt string, args ...any) (FetchResult, error) {
return FetchResult{StatusCode: statusCode}, fmt.Errorf(msgFmt, args...)
func FetchResultWithErrorf(statusCode int, msgFmt string, args ...any) (Result, error) {
return Result{StatusCode: statusCode}, fmt.Errorf(msgFmt, args...)
}
func FetchResultOK(icon []byte, contentType string) (FetchResult, error) {
return FetchResult{Icon: icon, contentType: contentType}, nil
func FetchResultOK(icon []byte, contentType string) (Result, error) {
return Result{Icon: icon, contentType: contentType}, nil
}
func GinFetchError(c *gin.Context, statusCode int, err error) {
func GinError(c *gin.Context, statusCode int, err error) {
if statusCode == 0 {
statusCode = http.StatusInternalServerError
}
@@ -50,7 +51,7 @@ func GinFetchError(c *gin.Context, statusCode int, err error) {
const faviconFetchTimeout = 3 * time.Second
func (res *FetchResult) ContentType() string {
func (res *Result) ContentType() string {
if res.contentType == "" {
if bytes.HasPrefix(res.Icon, []byte("<svg")) || bytes.HasPrefix(res.Icon, []byte("<?xml")) {
return "image/svg+xml"
@@ -62,19 +63,19 @@ func (res *FetchResult) ContentType() string {
const maxRedirectDepth = 5
func FetchFavIconFromURL(ctx context.Context, iconURL *IconURL) (FetchResult, error) {
switch iconURL.IconSource {
case IconSourceAbsolute:
func FetchFavIconFromURL(ctx context.Context, iconURL *icons.URL) (Result, error) {
switch iconURL.Source {
case icons.SourceAbsolute:
return FetchIconAbsolute(ctx, iconURL.URL())
case IconSourceRelative:
case icons.SourceRelative:
return FetchResultWithErrorf(http.StatusBadRequest, "unexpected relative icon")
case IconSourceWalkXCode, IconSourceSelfhSt:
case icons.SourceWalkXCode, icons.SourceSelfhSt:
return fetchKnownIcon(ctx, iconURL)
}
return FetchResultWithErrorf(http.StatusBadRequest, "invalid icon source")
}
var FetchIconAbsolute = cache.NewKeyFunc(func(ctx context.Context, url string) (FetchResult, error) {
var FetchIconAbsolute = cache.NewKeyFunc(func(ctx context.Context, url string) (Result, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot create request: %w", err)
@@ -103,7 +104,7 @@ var FetchIconAbsolute = cache.NewKeyFunc(func(ctx context.Context, url string) (
return FetchResultWithErrorf(http.StatusNotFound, "empty icon")
}
res := FetchResult{Icon: icon}
res := Result{Icon: icon}
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
res.contentType = contentType
}
@@ -122,22 +123,22 @@ func sanitizeName(name string) string {
return strings.ToLower(nameSanitizer.Replace(name))
}
func fetchKnownIcon(ctx context.Context, url *IconURL) (FetchResult, error) {
func fetchKnownIcon(ctx context.Context, url *icons.URL) (Result, error) {
// if icon isn't in the list, no need to fetch
if !url.HasIcon() {
return FetchResult{StatusCode: http.StatusNotFound}, errors.New("no such icon")
return Result{StatusCode: http.StatusNotFound}, errors.New("no such icon")
}
return FetchIconAbsolute(ctx, url.URL())
}
func fetchIcon(ctx context.Context, filename string) (FetchResult, error) {
func fetchIcon(ctx context.Context, filename string) (Result, error) {
for _, fileType := range []string{"svg", "webp", "png"} {
result, err := fetchKnownIcon(ctx, NewSelfhStIconURL(filename, fileType))
result, err := fetchKnownIcon(ctx, icons.NewURL(icons.SourceSelfhSt, filename, fileType))
if err == nil {
return result, err
}
result, err = fetchKnownIcon(ctx, NewWalkXCodeIconURL(filename, fileType))
result, err = fetchKnownIcon(ctx, icons.NewURL(icons.SourceWalkXCode, filename, fileType))
if err == nil {
return result, err
}
@@ -150,10 +151,10 @@ type contextValue struct {
uri string
}
func FindIcon(ctx context.Context, r route, uri string, variant IconVariant) (FetchResult, error) {
func FindIcon(ctx context.Context, r route, uri string, variant icons.Variant) (Result, error) {
for _, ref := range r.References() {
ref = sanitizeName(ref)
if variant != IconVariantNone {
if variant != icons.VariantNone {
ref += "-" + string(variant)
}
result, err := fetchIcon(ctx, ref)
@@ -168,12 +169,12 @@ func FindIcon(ctx context.Context, r route, uri string, variant IconVariant) (Fe
return FetchResultWithErrorf(http.StatusNotFound, "no icon found")
}
var findIconSlowCached = cache.NewKeyFunc(func(ctx context.Context, key string) (FetchResult, error) {
var findIconSlowCached = cache.NewKeyFunc(func(ctx context.Context, key string) (Result, error) {
v := ctx.Value("route").(contextValue)
return findIconSlow(ctx, v.r, v.uri, nil)
}).WithMaxEntries(200).Build() // no retries, no ttl
func findIconSlow(ctx context.Context, r httpRoute, uri string, stack []string) (FetchResult, error) {
func findIconSlow(ctx context.Context, r httpRoute, uri string, stack []string) (Result, error) {
select {
case <-ctx.Done():
return FetchResultWithErrorf(http.StatusBadGateway, "request timeout")

View File

@@ -1,4 +1,4 @@
package homepage
package iconfetch
import (
"net/http"

View File

@@ -0,0 +1,17 @@
package icons
import (
"fmt"
"strings"
)
type Key string
func NewKey(source Source, reference string) Key {
return Key(fmt.Sprintf("%s/%s", source, reference))
}
func (k Key) SourceRef() (Source, string) {
source, ref, _ := strings.Cut(string(k), "/")
return Source(source), ref
}

View File

@@ -1,8 +1,7 @@
package homepage
package iconlist
import (
"context"
"fmt"
"net/http"
"slices"
"strings"
@@ -12,6 +11,7 @@ import (
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/rs/zerolog/log"
"github.com/yusing/godoxy/internal/common"
"github.com/yusing/godoxy/internal/homepage/icons"
"github.com/yusing/godoxy/internal/serialization"
httputils "github.com/yusing/goutils/http"
"github.com/yusing/goutils/intern"
@@ -21,60 +21,19 @@ import (
)
type (
IconKey string
IconMap map[IconKey]*IconMeta
IconMap map[icons.Key]*icons.Meta
IconList []string
IconMeta struct {
SVG bool `json:"SVG"`
PNG bool `json:"PNG"`
WebP bool `json:"WebP"`
Light bool `json:"Light"`
Dark bool `json:"Dark"`
DisplayName string `json:"-"`
Tag string `json:"-"`
}
IconMetaSearch struct {
*IconMeta
Source IconSource `json:"Source"`
Ref string `json:"Ref"`
IconMetaSearch struct {
*icons.Meta
Source icons.Source `json:"Source"`
Ref string `json:"Ref"`
rank int
}
)
func (icon *IconMeta) Filenames(ref string) []string {
filenames := make([]string, 0)
if icon.SVG {
filenames = append(filenames, ref+".svg")
if icon.Light {
filenames = append(filenames, ref+"-light.svg")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.svg")
}
}
if icon.PNG {
filenames = append(filenames, ref+".png")
if icon.Light {
filenames = append(filenames, ref+"-light.png")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.png")
}
}
if icon.WebP {
filenames = append(filenames, ref+".webp")
if icon.Light {
filenames = append(filenames, ref+"-light.webp")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.webp")
}
}
return filenames
}
const updateInterval = 2 * time.Hour
var iconsCache synk.Value[IconMap]
@@ -84,16 +43,7 @@ const (
selfhstIcons = "https://raw.githubusercontent.com/selfhst/icons/refs/heads/main/index.json"
)
func NewIconKey(source IconSource, reference string) IconKey {
return IconKey(fmt.Sprintf("%s/%s", source, reference))
}
func (k IconKey) SourceRef() (IconSource, string) {
source, ref, _ := strings.Cut(string(k), "/")
return IconSource(source), ref
}
func InitIconListCache() {
func InitCache() {
m := make(IconMap)
err := serialization.LoadJSONIfExist(common.IconListCachePath, &m)
if err != nil {
@@ -196,10 +146,10 @@ func SearchIcons(keyword string, limit int) []*IconMetaSearch {
source, ref := k.SourceRef()
ranked := &IconMetaSearch{
Source: source,
Ref: ref,
IconMeta: icon,
rank: rank,
Source: source,
Ref: ref,
Meta: icon,
rank: rank,
}
// Sorted insert based on rank (lower rank = better match)
insertPos, _ := slices.BinarySearchFunc(results, ranked, sortByRank)
@@ -213,7 +163,7 @@ func SearchIcons(keyword string, limit int) []*IconMetaSearch {
return results[:min(len(results), limit)]
}
func HasIcon(icon *IconURL) bool {
func HasIcon(icon *icons.URL) bool {
if icon.Extra == nil {
return false
}
@@ -241,11 +191,11 @@ type HomepageMeta struct {
Tag string
}
func GetHomepageMeta(ref string) (HomepageMeta, bool) {
meta, ok := ListAvailableIcons()[NewIconKey(IconSourceSelfhSt, ref)]
func GetMetadata(ref string) (HomepageMeta, bool) {
meta, ok := ListAvailableIcons()[icons.NewKey(icons.SourceSelfhSt, ref)]
// these info is not available in walkxcode
// if !ok {
// meta, ok = iconsCache.Icons[NewIconKey(IconSourceWalkXCode, ref)]
// meta, ok = iconsCache.Icons[icons.NewIconKey(icons.IconSourceWalkXCode, ref)]
// }
if !ok {
return HomepageMeta{}, false
@@ -317,14 +267,14 @@ func UpdateWalkxCodeIcons(m IconMap) error {
}
for fileType, files := range data {
var setExt func(icon *IconMeta)
var setExt func(icon *icons.Meta)
switch fileType {
case "png":
setExt = func(icon *IconMeta) { icon.PNG = true }
setExt = func(icon *icons.Meta) { icon.PNG = true }
case "svg":
setExt = func(icon *IconMeta) { icon.SVG = true }
setExt = func(icon *icons.Meta) { icon.SVG = true }
case "webp":
setExt = func(icon *IconMeta) { icon.WebP = true }
setExt = func(icon *icons.Meta) { icon.WebP = true }
}
for _, f := range files {
f = strings.TrimSuffix(f, "."+fileType)
@@ -336,10 +286,10 @@ func UpdateWalkxCodeIcons(m IconMap) error {
if isDark {
f = strings.TrimSuffix(f, "-dark")
}
key := NewIconKey(IconSourceWalkXCode, f)
key := icons.NewKey(icons.SourceWalkXCode, f)
icon, ok := m[key]
if !ok {
icon = new(IconMeta)
icon = new(icons.Meta)
m[key] = icon
}
setExt(icon)
@@ -401,7 +351,7 @@ func UpdateSelfhstIcons(m IconMap) error {
tag, _, _ = strings.Cut(item.Tags, ",")
tag = strings.TrimSpace(tag)
}
icon := &IconMeta{
icon := &icons.Meta{
DisplayName: item.Name,
Tag: intern.Make(tag).Value(),
SVG: item.SVG == "Yes",
@@ -410,7 +360,7 @@ func UpdateSelfhstIcons(m IconMap) error {
Light: item.Light == "Yes",
Dark: item.Dark == "Yes",
}
key := NewIconKey(IconSourceSelfhSt, item.Reference)
key := icons.NewKey(icons.SourceSelfhSt, item.Reference)
m[key] = icon
}
return nil

View File

@@ -1,9 +1,10 @@
package homepage_test
package iconlist_test
import (
"testing"
. "github.com/yusing/godoxy/internal/homepage"
. "github.com/yusing/godoxy/internal/homepage/icons"
. "github.com/yusing/godoxy/internal/homepage/icons/list"
)
const walkxcodeIcons = `{
@@ -69,8 +70,8 @@ const selfhstIcons = `[
]`
type testCases struct {
Key IconKey
IconMeta
Key Key
Meta
}
func runTests(t *testing.T, iconsCache IconMap, test []testCases) {
@@ -109,8 +110,8 @@ func TestListWalkxCodeIcons(t *testing.T) {
}
test := []testCases{
{
Key: NewIconKey(IconSourceWalkXCode, "app1"),
IconMeta: IconMeta{
Key: NewKey(SourceWalkXCode, "app1"),
Meta: Meta{
SVG: true,
PNG: true,
WebP: true,
@@ -118,15 +119,15 @@ func TestListWalkxCodeIcons(t *testing.T) {
},
},
{
Key: NewIconKey(IconSourceWalkXCode, "app2"),
IconMeta: IconMeta{
Key: NewKey(SourceWalkXCode, "app2"),
Meta: Meta{
PNG: true,
WebP: true,
},
},
{
Key: NewIconKey(IconSourceWalkXCode, "karakeep"),
IconMeta: IconMeta{
Key: NewKey(SourceWalkXCode, "karakeep"),
Meta: Meta{
SVG: true,
PNG: true,
WebP: true,
@@ -149,8 +150,8 @@ func TestListSelfhstIcons(t *testing.T) {
}
test := []testCases{
{
Key: NewIconKey(IconSourceSelfhSt, "2fauth"),
IconMeta: IconMeta{
Key: NewKey(SourceSelfhSt, "2fauth"),
Meta: Meta{
SVG: true,
PNG: true,
WebP: true,
@@ -160,16 +161,16 @@ func TestListSelfhstIcons(t *testing.T) {
},
},
{
Key: NewIconKey(IconSourceSelfhSt, "dittofeed"),
IconMeta: IconMeta{
Key: NewKey(SourceSelfhSt, "dittofeed"),
Meta: Meta{
PNG: true,
WebP: true,
DisplayName: "Dittofeed",
},
},
{
Key: NewIconKey(IconSourceSelfhSt, "ars-technica"),
IconMeta: IconMeta{
Key: NewKey(SourceSelfhSt, "ars-technica"),
Meta: Meta{
SVG: true,
PNG: true,
WebP: true,

View File

@@ -0,0 +1,43 @@
package icons
type Meta struct {
SVG bool `json:"SVG"`
PNG bool `json:"PNG"`
WebP bool `json:"WebP"`
Light bool `json:"Light"`
Dark bool `json:"Dark"`
DisplayName string `json:"-"`
Tag string `json:"-"`
}
func (icon *Meta) Filenames(ref string) []string {
filenames := make([]string, 0)
if icon.SVG {
filenames = append(filenames, ref+".svg")
if icon.Light {
filenames = append(filenames, ref+"-light.svg")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.svg")
}
}
if icon.PNG {
filenames = append(filenames, ref+".png")
if icon.Light {
filenames = append(filenames, ref+"-light.png")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.png")
}
}
if icon.WebP {
filenames = append(filenames, ref+".webp")
if icon.Light {
filenames = append(filenames, ref+"-light.webp")
}
if icon.Dark {
filenames = append(filenames, ref+"-dark.webp")
}
}
return filenames
}

View File

@@ -0,0 +1,21 @@
package icons
import "sync/atomic"
type Provider interface {
HasIcon(u *URL) bool
}
var provider atomic.Value
func SetProvider(p Provider) {
provider.Store(p)
}
func hasIcon(u *URL) bool {
v := provider.Load()
if v == nil {
return false
}
return v.(Provider).HasIcon(u)
}

View File

@@ -1,4 +1,4 @@
package homepage
package icons
import (
"fmt"
@@ -8,43 +8,43 @@ import (
)
type (
IconURL struct {
IconSource `json:"source"`
URL struct {
Source `json:"source"`
FullURL *string `json:"value,omitempty"` // only for absolute/relative icons
Extra *IconExtra `json:"extra,omitempty"` // only for walkxcode/selfhst icons
FullURL *string `json:"value,omitempty"` // only for absolute/relative icons
Extra *Extra `json:"extra,omitempty"` // only for walkxcode/selfhst icons
}
IconExtra struct {
Key IconKey `json:"key"`
Ref string `json:"ref"`
FileType string `json:"file_type"`
IsLight bool `json:"is_light"`
IsDark bool `json:"is_dark"`
Extra struct {
Key Key `json:"key"`
Ref string `json:"ref"`
FileType string `json:"file_type"`
IsLight bool `json:"is_light"`
IsDark bool `json:"is_dark"`
}
IconSource string
IconVariant string
Source string
Variant string
)
const (
IconSourceAbsolute IconSource = "https://"
IconSourceRelative IconSource = "@target"
IconSourceWalkXCode IconSource = "@walkxcode"
IconSourceSelfhSt IconSource = "@selfhst"
SourceAbsolute Source = "https://"
SourceRelative Source = "@target"
SourceWalkXCode Source = "@walkxcode"
SourceSelfhSt Source = "@selfhst"
)
const (
IconVariantNone IconVariant = ""
IconVariantLight IconVariant = "light"
IconVariantDark IconVariant = "dark"
VariantNone Variant = ""
VariantLight Variant = "light"
VariantDark Variant = "dark"
)
var ErrInvalidIconURL = gperr.New("invalid icon url")
func NewIconURL(source IconSource, refOrName, format string) *IconURL {
func NewURL(source Source, refOrName, format string) *URL {
switch source {
case IconSourceWalkXCode, IconSourceSelfhSt:
case SourceWalkXCode, SourceSelfhSt:
default:
panic("invalid icon source")
}
@@ -56,10 +56,10 @@ func NewIconURL(source IconSource, refOrName, format string) *IconURL {
isDark = true
refOrName = strings.TrimSuffix(refOrName, "-dark")
}
return &IconURL{
IconSource: source,
Extra: &IconExtra{
Key: NewIconKey(source, refOrName),
return &URL{
Source: source,
Extra: &Extra{
Key: NewKey(source, refOrName),
FileType: format,
Ref: refOrName,
IsLight: isLight,
@@ -68,53 +68,42 @@ func NewIconURL(source IconSource, refOrName, format string) *IconURL {
}
}
func NewSelfhStIconURL(refOrName, format string) *IconURL {
return NewIconURL(IconSourceSelfhSt, refOrName, format)
func (u *URL) HasIcon() bool {
return hasIcon(u)
}
func NewWalkXCodeIconURL(name, format string) *IconURL {
return NewIconURL(IconSourceWalkXCode, name, format)
}
// 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 {
return HasIcon(u)
}
func (u *IconURL) WithVariant(variant IconVariant) *IconURL {
switch u.IconSource {
case IconSourceWalkXCode, IconSourceSelfhSt:
func (u *URL) WithVariant(variant Variant) *URL {
switch u.Source {
case SourceWalkXCode, SourceSelfhSt:
default:
return u // no variant for absolute/relative icons
}
var extra *IconExtra
var extra *Extra
if u.Extra != nil {
extra = &IconExtra{
extra = &Extra{
Key: u.Extra.Key,
Ref: u.Extra.Ref,
FileType: u.Extra.FileType,
IsLight: variant == IconVariantLight,
IsDark: variant == IconVariantDark,
IsLight: variant == VariantLight,
IsDark: variant == VariantDark,
}
extra.Ref = strings.TrimSuffix(extra.Ref, "-light")
extra.Ref = strings.TrimSuffix(extra.Ref, "-dark")
}
return &IconURL{
IconSource: u.IconSource,
FullURL: u.FullURL,
Extra: extra,
return &URL{
Source: u.Source,
FullURL: u.FullURL,
Extra: extra,
}
}
// Parse implements strutils.Parser.
func (u *IconURL) Parse(v string) error {
func (u *URL) Parse(v string) error {
return u.parse(v, true)
}
func (u *IconURL) parse(v string, checkExists bool) error {
func (u *URL) parse(v string, checkExists bool) error {
if v == "" {
return ErrInvalidIconURL
}
@@ -126,19 +115,19 @@ func (u *IconURL) parse(v string, checkExists bool) error {
switch beforeSlash {
case "http:", "https:":
u.FullURL = &v
u.IconSource = IconSourceAbsolute
u.Source = SourceAbsolute
case "@target", "": // @target/favicon.ico, /favicon.ico
url := v[slashIndex:]
if url == "/" {
return ErrInvalidIconURL.Withf("%s", "empty path")
}
u.FullURL = &url
u.IconSource = IconSourceRelative
u.Source = SourceRelative
case "@selfhst", "@walkxcode": // selfh.st / walkxcode Icons, @selfhst/<reference>.<format>
if beforeSlash == "@selfhst" {
u.IconSource = IconSourceSelfhSt
u.Source = SourceSelfhSt
} else {
u.IconSource = IconSourceWalkXCode
u.Source = SourceWalkXCode
}
parts := strings.Split(v[slashIndex+1:], ".")
if len(parts) != 2 {
@@ -161,15 +150,15 @@ func (u *IconURL) parse(v string, checkExists bool) error {
isDark = true
reference = strings.TrimSuffix(reference, "-dark")
}
u.Extra = &IconExtra{
Key: NewIconKey(u.IconSource, reference),
u.Extra = &Extra{
Key: NewKey(u.Source, reference),
FileType: format,
Ref: reference,
IsLight: isLight,
IsDark: isDark,
}
if checkExists && !u.HasIcon() {
return ErrInvalidIconURL.Withf("no such icon %s.%s from %s", reference, format, u.IconSource)
return ErrInvalidIconURL.Withf("no such icon %s.%s from %s", reference, format, u.Source)
}
default:
return ErrInvalidIconURL.Subject(v)
@@ -178,7 +167,7 @@ func (u *IconURL) parse(v string, checkExists bool) error {
return nil
}
func (u *IconURL) URL() string {
func (u *URL) URL() string {
if u.FullURL != nil {
return *u.FullURL
}
@@ -191,16 +180,16 @@ func (u *IconURL) URL() string {
} else if u.Extra.IsDark {
filename += "-dark"
}
switch u.IconSource {
case IconSourceWalkXCode:
switch u.Source {
case SourceWalkXCode:
return fmt.Sprintf("https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/%s/%s.%s", u.Extra.FileType, filename, u.Extra.FileType)
case IconSourceSelfhSt:
case SourceSelfhSt:
return fmt.Sprintf("https://cdn.jsdelivr.net/gh/selfhst/icons/%s/%s.%s", u.Extra.FileType, filename, u.Extra.FileType)
}
return ""
}
func (u *IconURL) String() string {
func (u *URL) String() string {
if u.FullURL != nil {
return *u.FullURL
}
@@ -213,14 +202,14 @@ func (u *IconURL) String() string {
} else if u.Extra.IsDark {
suffix = "-dark"
}
return fmt.Sprintf("%s/%s%s.%s", u.IconSource, u.Extra.Ref, suffix, u.Extra.FileType)
return fmt.Sprintf("%s/%s%s.%s", u.Source, u.Extra.Ref, suffix, u.Extra.FileType)
}
func (u *IconURL) MarshalText() ([]byte, error) {
func (u *URL) MarshalText() ([]byte, error) {
return []byte(u.String()), nil
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (u *IconURL) UnmarshalText(data []byte) error {
func (u *URL) UnmarshalText(data []byte) error {
return u.parse(string(data), false)
}

View File

@@ -1,9 +1,9 @@
package homepage_test
package icons_test
import (
"testing"
. "github.com/yusing/godoxy/internal/homepage"
. "github.com/yusing/godoxy/internal/homepage/icons"
expect "github.com/yusing/goutils/testing"
)
@@ -15,31 +15,31 @@ func TestIconURL(t *testing.T) {
tests := []struct {
name string
input string
wantValue *IconURL
wantValue *URL
wantErr bool
}{
{
name: "absolute",
input: "http://example.com/icon.png",
wantValue: &IconURL{
FullURL: strPtr("http://example.com/icon.png"),
IconSource: IconSourceAbsolute,
wantValue: &URL{
FullURL: strPtr("http://example.com/icon.png"),
Source: SourceAbsolute,
},
},
{
name: "relative",
input: "@target/icon.png",
wantValue: &IconURL{
FullURL: strPtr("/icon.png"),
IconSource: IconSourceRelative,
wantValue: &URL{
FullURL: strPtr("/icon.png"),
Source: SourceRelative,
},
},
{
name: "relative2",
input: "/icon.png",
wantValue: &IconURL{
FullURL: strPtr("/icon.png"),
IconSource: IconSourceRelative,
wantValue: &URL{
FullURL: strPtr("/icon.png"),
Source: SourceRelative,
},
},
{
@@ -55,10 +55,10 @@ func TestIconURL(t *testing.T) {
{
name: "walkxcode",
input: "@walkxcode/adguard-home.png",
wantValue: &IconURL{
IconSource: IconSourceWalkXCode,
Extra: &IconExtra{
Key: NewIconKey(IconSourceWalkXCode, "adguard-home"),
wantValue: &URL{
Source: SourceWalkXCode,
Extra: &Extra{
Key: NewKey(SourceWalkXCode, "adguard-home"),
FileType: "png",
Ref: "adguard-home",
},
@@ -67,10 +67,10 @@ func TestIconURL(t *testing.T) {
{
name: "walkxcode_light",
input: "@walkxcode/pfsense-light.png",
wantValue: &IconURL{
IconSource: IconSourceWalkXCode,
Extra: &IconExtra{
Key: NewIconKey(IconSourceWalkXCode, "pfsense"),
wantValue: &URL{
Source: SourceWalkXCode,
Extra: &Extra{
Key: NewKey(SourceWalkXCode, "pfsense"),
FileType: "png",
Ref: "pfsense",
IsLight: true,
@@ -85,10 +85,10 @@ func TestIconURL(t *testing.T) {
{
name: "selfh.st_valid",
input: "@selfhst/adguard-home.webp",
wantValue: &IconURL{
IconSource: IconSourceSelfhSt,
Extra: &IconExtra{
Key: NewIconKey(IconSourceSelfhSt, "adguard-home"),
wantValue: &URL{
Source: SourceSelfhSt,
Extra: &Extra{
Key: NewKey(SourceSelfhSt, "adguard-home"),
FileType: "webp",
Ref: "adguard-home",
},
@@ -97,10 +97,10 @@ func TestIconURL(t *testing.T) {
{
name: "selfh.st_light",
input: "@selfhst/adguard-home-light.png",
wantValue: &IconURL{
IconSource: IconSourceSelfhSt,
Extra: &IconExtra{
Key: NewIconKey(IconSourceSelfhSt, "adguard-home"),
wantValue: &URL{
Source: SourceSelfhSt,
Extra: &Extra{
Key: NewKey(SourceSelfhSt, "adguard-home"),
FileType: "png",
Ref: "adguard-home",
IsLight: true,
@@ -110,10 +110,10 @@ func TestIconURL(t *testing.T) {
{
name: "selfh.st_dark",
input: "@selfhst/adguard-home-dark.svg",
wantValue: &IconURL{
IconSource: IconSourceSelfhSt,
Extra: &IconExtra{
Key: NewIconKey(IconSourceSelfhSt, "adguard-home"),
wantValue: &URL{
Source: SourceSelfhSt,
Extra: &Extra{
Key: NewKey(SourceSelfhSt, "adguard-home"),
FileType: "svg",
Ref: "adguard-home",
IsDark: true,
@@ -143,7 +143,7 @@ func TestIconURL(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
u := &IconURL{}
u := &URL{}
err := u.Parse(tc.input)
if tc.wantErr {
expect.ErrorIs(t, ErrInvalidIconURL, err)

View File

@@ -7,7 +7,8 @@ import (
"net/http"
"strconv"
"github.com/yusing/godoxy/internal/homepage"
"github.com/yusing/godoxy/internal/homepage/icons"
iconfetch "github.com/yusing/godoxy/internal/homepage/icons/fetch"
idlewatcher "github.com/yusing/godoxy/internal/idlewatcher/types"
gperr "github.com/yusing/goutils/errs"
httputils "github.com/yusing/goutils/http"
@@ -99,18 +100,18 @@ func (w *Watcher) handleWakeEventsSSE(rw http.ResponseWriter, r *http.Request) {
}
}
func (w *Watcher) getFavIcon(ctx context.Context) (result homepage.FetchResult, err error) {
func (w *Watcher) getFavIcon(ctx context.Context) (result iconfetch.Result, err error) {
r := w.route
hp := r.HomepageItem()
if hp.Icon != nil {
if hp.Icon.IconSource == homepage.IconSourceRelative {
result, err = homepage.FindIcon(ctx, r, *hp.Icon.FullURL, homepage.IconVariantNone)
if hp.Icon.Source == icons.SourceRelative {
result, err = iconfetch.FindIcon(ctx, r, *hp.Icon.FullURL, icons.VariantNone)
} else {
result, err = homepage.FetchFavIconFromURL(ctx, hp.Icon)
result, err = iconfetch.FetchFavIconFromURL(ctx, hp.Icon)
}
} else {
// try extract from "link[rel=icon]"
result, err = homepage.FindIcon(ctx, r, "/", homepage.IconVariantNone)
result, err = iconfetch.FindIcon(ctx, r, "/", icons.VariantNone)
}
if result.StatusCode == 0 {
result.StatusCode = http.StatusOK

View File

@@ -20,6 +20,7 @@ import (
"github.com/yusing/godoxy/internal/docker"
"github.com/yusing/godoxy/internal/health/monitor"
"github.com/yusing/godoxy/internal/homepage"
iconlist "github.com/yusing/godoxy/internal/homepage/icons/list"
homepagecfg "github.com/yusing/godoxy/internal/homepage/types"
netutils "github.com/yusing/godoxy/internal/net"
nettypes "github.com/yusing/godoxy/internal/net/types"
@@ -849,7 +850,7 @@ func (r *Route) FinalizeHomepageConfig() {
hp := r.Homepage
refs := r.References()
for _, ref := range refs {
meta, ok := homepage.GetHomepageMeta(ref)
meta, ok := iconlist.GetMetadata(ref)
if ok {
if hp.Name == "" {
hp.Name = meta.DisplayName