server side favicon retrieving and caching

This commit is contained in:
yusing
2025-01-12 10:30:37 +08:00
parent 0ce7f29976
commit c7c6a097f0
9 changed files with 343 additions and 11 deletions

View File

@@ -34,6 +34,7 @@ func NewHandler(cfg config.ConfigInstance) http.Handler {
mux.HandleFunc("GET", "/v1/schema/{filename...}", v1.GetSchemaFile)
mux.HandleFunc("GET", "/v1/stats", useCfg(cfg, v1.Stats))
mux.HandleFunc("GET", "/v1/stats/ws", useCfg(cfg, v1.StatsWS))
mux.HandleFunc("GET", "/v1/favicon/{alias}", auth.RequireAuth(v1.GetFavIcon))
return mux
}

200
internal/api/v1/favicon.go Normal file
View File

@@ -0,0 +1,200 @@
package v1
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/vincent-petithory/dataurl"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/logging"
route "github.com/yusing/go-proxy/internal/route/types"
)
type content struct {
header http.Header
data []byte
status int
}
func newContent() *content {
return &content{
header: make(http.Header),
}
}
func (c *content) Header() http.Header {
return c.header
}
func (c *content) Write(data []byte) (int, error) {
c.data = append(c.data, data...)
return len(data), nil
}
func (c *content) WriteHeader(statusCode int) {
c.status = statusCode
}
// GetFavIcon returns the favicon of the route
//
// Returns:
// - 200 OK: if icon found
// - 400 Bad Request: if alias is empty or route is not HTTPRoute
// - 404 Not Found: if route or icon not found
// - 500 Internal Server Error: if internal error
// - others: depends on route handler response
func GetFavIcon(w http.ResponseWriter, req *http.Request) {
alias := req.PathValue("alias")
if alias == "" {
U.RespondError(w, U.ErrMissingKey("alias"), http.StatusBadRequest)
return
}
r := listRoute(alias)
if r == nil {
http.NotFound(w, req)
return
}
switch r := r.(type) {
case route.HTTPRoute:
var icon []byte
var status int
var errMsg string
homepage := r.RawEntry().Homepage
if homepage != nil && homepage.Icon != nil {
if homepage.Icon.IsRelative {
icon, status, errMsg = findIcon(r, req, homepage.Icon.Value)
} else {
icon, status, errMsg = getIconAbsolute(homepage.Icon.Value)
}
} else {
// try extract from "link[rel=icon]"
icon, status, errMsg = findIcon(r, req, "/")
}
if status != http.StatusOK {
http.Error(w, errMsg, status)
return
}
U.WriteBody(w, icon)
default:
http.Error(w, "bad request", http.StatusBadRequest)
}
}
// cache key can be absolute url or route name.
var (
iconCache = make(map[string][]byte)
iconCacheMu sync.RWMutex
)
func loadIconCache(key string) (icon []byte, ok bool) {
iconCacheMu.RLock()
icon, ok = iconCache[key]
iconCacheMu.RUnlock()
return
}
func storeIconCache(key string, icon []byte) {
iconCacheMu.Lock()
defer iconCacheMu.Unlock()
iconCache[key] = icon
}
func getIconAbsolute(url string) ([]byte, int, string) {
icon, ok := loadIconCache(url)
if ok {
return icon, http.StatusOK, ""
}
resp, err := U.Get(url)
if err != nil {
storeIconCache(url, nil)
logging.Error().Err(err).
Str("url", url).
Msg("failed to get icon")
return nil, http.StatusBadGateway, "connection error"
}
defer resp.Body.Close()
icon, err = io.ReadAll(resp.Body)
if err != nil {
// storeIconCache(url, nil) // can retry
logging.Error().Err(err).
Str("url", url).
Msg("failed to read icon")
return nil, http.StatusInternalServerError, "internal error"
}
storeIconCache(url, icon)
return icon, http.StatusOK, ""
}
func findIcon(r route.HTTPRoute, req *http.Request, path string) (icon []byte, status int, errMsg string) {
key := r.TargetName()
icon, ok := loadIconCache(key)
if ok {
if icon == nil {
return nil, http.StatusNotFound, "icon not found"
}
return icon, http.StatusOK, ""
}
icon, status, errMsg = findIconSlow(r, req, path)
// set even if error (nil)
storeIconCache(key, icon)
return
}
func findIconSlow(r route.HTTPRoute, req *http.Request, path string) (icon []byte, status int, errMsg string) {
c := newContent()
ctx, cancel := context.WithTimeoutCause(req.Context(), 3*time.Second, errors.New("favicon request timeout"))
defer cancel()
newReq := req.WithContext(ctx)
newReq.URL.Path = path
newReq.URL.RawPath = path
newReq.URL.RawQuery = ""
newReq.RequestURI = path
r.ServeHTTP(c, newReq)
if c.status != http.StatusOK {
return nil, c.status, "upstream error: " + http.StatusText(c.status)
}
// return icon data
if path != "/" {
return c.data, http.StatusOK, ""
}
// try extract from "link[rel=icon]" from path "/"
doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(c.data))
if err != nil {
return nil, http.StatusInternalServerError, "internal error"
}
ele := doc.Find("link[rel=icon]").First()
if ele.Length() == 0 {
return nil, http.StatusNotFound, "icon not found"
}
href := ele.AttrOr("href", "")
if href == "" {
return nil, http.StatusNotFound, "icon not found"
}
// https://en.wikipedia.org/wiki/Data_URI_scheme
if strings.HasPrefix(href, "data:image/") {
dataURI, err := dataurl.DecodeString(href)
if err != nil {
logging.Error().Err(err).
Str("route", r.TargetName()).
Msg("failed to decode favicon")
return nil, http.StatusInternalServerError, "internal error"
}
return dataURI.Data, http.StatusOK, ""
}
if href[0] != '/' {
return getIconAbsolute(href)
}
return findIconSlow(r, req, href)
}

View File

@@ -1,5 +1,7 @@
package homepage
import "strings"
type (
//nolint:recvcheck
Config map[string]Category
@@ -8,7 +10,7 @@ type (
Item struct {
Show bool `json:"show"`
Name string `json:"name"` // display name
Icon string `json:"icon"`
Icon *IconURL `json:"icon"`
URL string `json:"url"` // alias + domain
Category string `json:"category"`
Description string `json:"description" aliases:"desc"`
@@ -22,7 +24,7 @@ type (
func (item *Item) IsEmpty() bool {
return item == nil || (item.Name == "" &&
item.Icon == "" &&
item.Icon == nil &&
item.URL == "" &&
item.Category == "" &&
item.Description == "" &&
@@ -37,6 +39,13 @@ func (c *Config) Clear() {
*c = make(Config)
}
var cleanName = strings.NewReplacer(
" ", "-",
"_", "-",
"(", "",
")", "",
)
func (c Config) Add(item *Item) {
if c[item.Category] == nil {
c[item.Category] = make(Category, 0)

View File

@@ -0,0 +1,46 @@
package homepage
import (
"strings"
E "github.com/yusing/go-proxy/internal/error"
)
type IconURL struct {
Value string `json:"value"`
IsRelative bool `json:"is_relative"`
}
const DashboardIconBaseURL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/"
var ErrInvalidIconURL = E.New("invalid icon url")
// Parse implements strutils.Parser.
func (u *IconURL) Parse(v string) error {
if v == "" {
return ErrInvalidIconURL
}
slashIndex := strings.Index(v, "/")
if slashIndex == -1 {
return ErrInvalidIconURL
}
beforeSlash := v[:slashIndex]
switch beforeSlash {
case "http:", "https:":
u.Value = v
return nil
case "@target":
u.Value = v[slashIndex:]
u.IsRelative = true
return nil
case "png", "svg": // walkXCode Icons
u.Value = DashboardIconBaseURL + v
return nil
default:
return ErrInvalidIconURL
}
}
func (u *IconURL) String() string {
return u.Value
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/rs/zerolog"
"github.com/yusing/go-proxy/internal/autocert"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/logging"
"github.com/yusing/go-proxy/internal/task"
)
@@ -52,18 +53,23 @@ func NewServer(opt Options) (s *Server) {
certAvailable = err == nil
}
out := io.Discard
if common.IsDebug {
out = logging.GetLogger()
}
if opt.HTTPAddr != "" {
httpSer = &http.Server{
Addr: opt.HTTPAddr,
Handler: opt.Handler,
ErrorLog: log.New(io.Discard, "", 0), // most are tls related
ErrorLog: log.New(out, "", 0), // most are tls related
}
}
if certAvailable && opt.HTTPSAddr != "" {
httpsSer = &http.Server{
Addr: opt.HTTPSAddr,
Handler: opt.Handler,
ErrorLog: log.New(io.Discard, "", 0), // most are tls related
ErrorLog: log.New(out, "", 0), // most are tls related
TLSConfig: &tls.Config{
GetCertificate: opt.CertProvider.GetCert,
},

View File

@@ -10,6 +10,7 @@ import (
"github.com/yusing/go-proxy/internal/common"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/homepage"
"github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/route/entry"
T "github.com/yusing/go-proxy/internal/route/types"
@@ -123,7 +124,8 @@ func TestApplyLabel(t *testing.T) {
ExpectEqual(t, b.Container.StopSignal, "SIGTERM")
ExpectEqual(t, a.Homepage.Show, true)
ExpectEqual(t, a.Homepage.Icon, "png/example.png")
ExpectEqual(t, a.Homepage.Icon.Value, homepage.DashboardIconBaseURL+"png/example.png")
ExpectEqual(t, a.Homepage.Icon.IsRelative, false)
ExpectEqual(t, a.HealthCheck.Path, "/ping")
ExpectEqual(t, a.HealthCheck.Interval, 10*time.Second)