mirror of
https://github.com/yusing/godoxy.git
synced 2025-07-22 12:24:02 +02:00
refactor: move favicon into homepage module
This commit is contained in:
parent
fb075a24d7
commit
6a5d324733
14 changed files with 328 additions and 328 deletions
|
@ -6,9 +6,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/yusing/go-proxy/internal"
|
|
||||||
"github.com/yusing/go-proxy/internal/api/v1/auth"
|
"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/api/v1/query"
|
||||||
"github.com/yusing/go-proxy/internal/common"
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
"github.com/yusing/go-proxy/internal/config"
|
"github.com/yusing/go-proxy/internal/config"
|
||||||
|
@ -50,7 +48,7 @@ func main() {
|
||||||
rawLogger.Println("ok")
|
rawLogger.Println("ok")
|
||||||
return
|
return
|
||||||
case common.CommandListIcons:
|
case common.CommandListIcons:
|
||||||
icons, err := internal.ListAvailableIcons()
|
icons, err := homepage.ListAvailableIcons()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rawLogger.Fatal(err)
|
rawLogger.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -79,9 +77,9 @@ func main() {
|
||||||
logging.Info().Msgf("GoDoxy version %s", pkg.GetVersion())
|
logging.Info().Msgf("GoDoxy version %s", pkg.GetVersion())
|
||||||
logging.Trace().Msg("trace enabled")
|
logging.Trace().Msg("trace enabled")
|
||||||
parallel(
|
parallel(
|
||||||
internal.InitIconListCache,
|
homepage.InitIconListCache,
|
||||||
|
homepage.InitIconCache,
|
||||||
homepage.InitOverridesConfig,
|
homepage.InitOverridesConfig,
|
||||||
favicon.InitIconCache,
|
|
||||||
systeminfo.Poller.Start,
|
systeminfo.Poller.Start,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,6 @@ import (
|
||||||
"github.com/yusing/go-proxy/internal/api/v1/auth"
|
"github.com/yusing/go-proxy/internal/api/v1/auth"
|
||||||
"github.com/yusing/go-proxy/internal/api/v1/certapi"
|
"github.com/yusing/go-proxy/internal/api/v1/certapi"
|
||||||
"github.com/yusing/go-proxy/internal/api/v1/dockerapi"
|
"github.com/yusing/go-proxy/internal/api/v1/dockerapi"
|
||||||
"github.com/yusing/go-proxy/internal/api/v1/favicon"
|
|
||||||
"github.com/yusing/go-proxy/internal/common"
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
config "github.com/yusing/go-proxy/internal/config/types"
|
config "github.com/yusing/go-proxy/internal/config/types"
|
||||||
"github.com/yusing/go-proxy/internal/logging"
|
"github.com/yusing/go-proxy/internal/logging"
|
||||||
|
@ -80,7 +79,7 @@ func NewHandler(cfg config.ConfigInstance) http.Handler {
|
||||||
mux.HandleFunc("POST", "/v1/file/validate/{type}", v1.ValidateFile, true)
|
mux.HandleFunc("POST", "/v1/file/validate/{type}", v1.ValidateFile, true)
|
||||||
mux.HandleFunc("GET", "/v1/health", v1.Health, true)
|
mux.HandleFunc("GET", "/v1/health", v1.Health, true)
|
||||||
mux.HandleFunc("GET", "/v1/logs", memlogger.Handler(), true)
|
mux.HandleFunc("GET", "/v1/logs", memlogger.Handler(), true)
|
||||||
mux.HandleFunc("GET", "/v1/favicon", favicon.GetFavIcon, true)
|
mux.HandleFunc("GET", "/v1/favicon", v1.GetFavIcon, true)
|
||||||
mux.HandleFunc("POST", "/v1/homepage/set", v1.SetHomePageOverrides, true)
|
mux.HandleFunc("POST", "/v1/homepage/set", v1.SetHomePageOverrides, true)
|
||||||
mux.HandleFunc("GET", "/v1/agents", v1.ListAgents, true)
|
mux.HandleFunc("GET", "/v1/agents", v1.ListAgents, true)
|
||||||
mux.HandleFunc("GET", "/v1/agents/new", v1.NewAgent, true)
|
mux.HandleFunc("GET", "/v1/agents/new", v1.NewAgent, true)
|
||||||
|
|
77
internal/api/v1/favicon.go
Normal file
77
internal/api/v1/favicon.go
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/internal/gperr"
|
||||||
|
"github.com/yusing/go-proxy/internal/homepage"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||||
|
"github.com/yusing/go-proxy/internal/route/routes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
url, alias := req.FormValue("url"), req.FormValue("alias")
|
||||||
|
if url == "" && alias == "" {
|
||||||
|
gphttp.ClientError(w, gphttp.ErrMissingKey("url or alias"), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if url != "" && alias != "" {
|
||||||
|
gphttp.ClientError(w, gperr.New("url and alias are mutually exclusive"), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// try with url
|
||||||
|
if url != "" {
|
||||||
|
var iconURL homepage.IconURL
|
||||||
|
if err := iconURL.Parse(url); err != nil {
|
||||||
|
gphttp.ClientError(w, err, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetchResult := homepage.FetchFavIconFromURL(&iconURL)
|
||||||
|
if !fetchResult.OK() {
|
||||||
|
http.Error(w, fetchResult.ErrMsg, fetchResult.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", fetchResult.ContentType())
|
||||||
|
gphttp.WriteBody(w, fetchResult.Icon)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// try with route.Icon
|
||||||
|
r, ok := routes.GetHTTPRoute(alias)
|
||||||
|
if !ok {
|
||||||
|
gphttp.ClientError(w, errors.New("no such route"), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result *homepage.FetchResult
|
||||||
|
hp := r.HomepageItem()
|
||||||
|
if hp.Icon != nil {
|
||||||
|
if hp.Icon.IconSource == homepage.IconSourceRelative {
|
||||||
|
result = homepage.FindIcon(req.Context(), r, hp.Icon.Value)
|
||||||
|
} else {
|
||||||
|
result = homepage.FetchFavIconFromURL(hp.Icon)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// try extract from "link[rel=icon]"
|
||||||
|
result = homepage.FindIcon(req.Context(), r, "/")
|
||||||
|
}
|
||||||
|
if result.StatusCode == 0 {
|
||||||
|
result.StatusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
if !result.OK() {
|
||||||
|
http.Error(w, result.ErrMsg, result.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", result.ContentType())
|
||||||
|
gphttp.WriteBody(w, result.Icon)
|
||||||
|
}
|
|
@ -1,284 +0,0 @@
|
||||||
package favicon
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
|
||||||
"github.com/vincent-petithory/dataurl"
|
|
||||||
"github.com/yusing/go-proxy/internal/gperr"
|
|
||||||
"github.com/yusing/go-proxy/internal/homepage"
|
|
||||||
"github.com/yusing/go-proxy/internal/logging"
|
|
||||||
gphttp "github.com/yusing/go-proxy/internal/net/gphttp"
|
|
||||||
"github.com/yusing/go-proxy/internal/route/routes"
|
|
||||||
route "github.com/yusing/go-proxy/internal/route/types"
|
|
||||||
"github.com/yusing/go-proxy/internal/utils/strutils"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fetchResult struct {
|
|
||||||
icon []byte
|
|
||||||
contentType string
|
|
||||||
statusCode int
|
|
||||||
errMsg string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (res *fetchResult) OK() bool {
|
|
||||||
return res.icon != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (res *fetchResult) ContentType() string {
|
|
||||||
if res.contentType == "" {
|
|
||||||
if bytes.HasPrefix(res.icon, []byte("<svg")) || bytes.HasPrefix(res.icon, []byte("<?xml")) {
|
|
||||||
return "image/svg+xml"
|
|
||||||
}
|
|
||||||
return "image/x-icon"
|
|
||||||
}
|
|
||||||
return res.contentType
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
MaxRedirectDepth = 5
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
url, alias := req.FormValue("url"), req.FormValue("alias")
|
|
||||||
if url == "" && alias == "" {
|
|
||||||
gphttp.ClientError(w, gphttp.ErrMissingKey("url or alias"), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if url != "" && alias != "" {
|
|
||||||
gphttp.ClientError(w, gperr.New("url and alias are mutually exclusive"), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// try with url
|
|
||||||
if url != "" {
|
|
||||||
var iconURL homepage.IconURL
|
|
||||||
if err := iconURL.Parse(url); err != nil {
|
|
||||||
gphttp.ClientError(w, err, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fetchResult := getFavIconFromURL(&iconURL)
|
|
||||||
if !fetchResult.OK() {
|
|
||||||
http.Error(w, fetchResult.errMsg, fetchResult.statusCode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", fetchResult.ContentType())
|
|
||||||
gphttp.WriteBody(w, fetchResult.icon)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// try with route.Homepage.Icon
|
|
||||||
r, ok := routes.GetHTTPRoute(alias)
|
|
||||||
if !ok {
|
|
||||||
gphttp.ClientError(w, errors.New("no such route"), http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var result *fetchResult
|
|
||||||
hp := r.HomepageItem()
|
|
||||||
if hp.Icon != nil {
|
|
||||||
if hp.Icon.IconSource == homepage.IconSourceRelative {
|
|
||||||
result = findIcon(r, req, hp.Icon.Value)
|
|
||||||
} else {
|
|
||||||
result = getFavIconFromURL(hp.Icon)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// try extract from "link[rel=icon]"
|
|
||||||
result = findIcon(r, req, "/")
|
|
||||||
}
|
|
||||||
if result.statusCode == 0 {
|
|
||||||
result.statusCode = http.StatusOK
|
|
||||||
}
|
|
||||||
if !result.OK() {
|
|
||||||
http.Error(w, result.errMsg, result.statusCode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", result.ContentType())
|
|
||||||
gphttp.WriteBody(w, result.icon)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getFavIconFromURL(iconURL *homepage.IconURL) *fetchResult {
|
|
||||||
switch iconURL.IconSource {
|
|
||||||
case homepage.IconSourceAbsolute:
|
|
||||||
return fetchIconAbsolute(iconURL.URL())
|
|
||||||
case homepage.IconSourceRelative:
|
|
||||||
return &fetchResult{statusCode: http.StatusBadRequest, errMsg: "unexpected relative icon"}
|
|
||||||
case homepage.IconSourceWalkXCode, homepage.IconSourceSelfhSt:
|
|
||||||
return fetchKnownIcon(iconURL)
|
|
||||||
}
|
|
||||||
return &fetchResult{statusCode: http.StatusBadRequest, errMsg: "invalid icon source"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchIconAbsolute(url string) *fetchResult {
|
|
||||||
if result := loadIconCache(url); result != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := gphttp.Get(url)
|
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
|
||||||
if err == nil {
|
|
||||||
err = errors.New(resp.Status)
|
|
||||||
}
|
|
||||||
logging.Error().Err(err).
|
|
||||||
Str("url", url).
|
|
||||||
Msg("failed to get icon")
|
|
||||||
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "connection error"}
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
icon, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
logging.Error().Err(err).
|
|
||||||
Str("url", url).
|
|
||||||
Msg("failed to read icon")
|
|
||||||
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"}
|
|
||||||
}
|
|
||||||
|
|
||||||
storeIconCache(url, icon)
|
|
||||||
return &fetchResult{icon: icon}
|
|
||||||
}
|
|
||||||
|
|
||||||
var nameSanitizer = strings.NewReplacer(
|
|
||||||
"_", "-",
|
|
||||||
" ", "-",
|
|
||||||
"(", "",
|
|
||||||
")", "",
|
|
||||||
)
|
|
||||||
|
|
||||||
func sanitizeName(name string) string {
|
|
||||||
return strings.ToLower(nameSanitizer.Replace(name))
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchKnownIcon(url *homepage.IconURL) *fetchResult {
|
|
||||||
// if icon isn't in the list, no need to fetch
|
|
||||||
if !url.HasIcon() {
|
|
||||||
logging.Debug().
|
|
||||||
Str("value", url.String()).
|
|
||||||
Str("url", url.URL()).
|
|
||||||
Msg("no such icon")
|
|
||||||
return &fetchResult{statusCode: http.StatusNotFound, errMsg: "no such icon"}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetchIconAbsolute(url.URL())
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchIcon(filetype, filename string) *fetchResult {
|
|
||||||
result := fetchKnownIcon(homepage.NewSelfhStIconURL(filename, filetype))
|
|
||||||
if result.icon == nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
return fetchKnownIcon(homepage.NewWalkXCodeIconURL(filename, filetype))
|
|
||||||
}
|
|
||||||
|
|
||||||
func findIcon(r route.HTTPRoute, req *http.Request, uri string) *fetchResult {
|
|
||||||
key := routeKey(r)
|
|
||||||
if result := loadIconCache(key); result != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
result := fetchIcon("png", sanitizeName(r.TargetName()))
|
|
||||||
cont := r.ContainerInfo()
|
|
||||||
if !result.OK() && cont != nil {
|
|
||||||
result = fetchIcon("png", sanitizeName(cont.Image.Name))
|
|
||||||
}
|
|
||||||
if !result.OK() {
|
|
||||||
// fallback to parse html
|
|
||||||
result = findIconSlow(r, req, uri, 0)
|
|
||||||
}
|
|
||||||
if result.OK() {
|
|
||||||
storeIconCache(key, result.icon)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func findIconSlow(r route.HTTPRoute, req *http.Request, uri string, depth int) *fetchResult {
|
|
||||||
ctx, cancel := context.WithTimeoutCause(req.Context(), 3*time.Second, errors.New("favicon request timeout"))
|
|
||||||
defer cancel()
|
|
||||||
newReq := req.WithContext(ctx)
|
|
||||||
newReq.Header.Set("Accept-Encoding", "identity") // disable compression
|
|
||||||
u, err := url.ParseRequestURI(strutils.SanitizeURI(uri))
|
|
||||||
if err != nil {
|
|
||||||
logging.Error().Err(err).
|
|
||||||
Str("route", r.TargetName()).
|
|
||||||
Str("path", uri).
|
|
||||||
Msg("failed to parse uri")
|
|
||||||
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "cannot parse uri"}
|
|
||||||
}
|
|
||||||
newReq.URL.Path = u.Path
|
|
||||||
newReq.URL.RawPath = u.RawPath
|
|
||||||
newReq.URL.RawQuery = u.RawQuery
|
|
||||||
newReq.RequestURI = u.String()
|
|
||||||
|
|
||||||
c := newContent()
|
|
||||||
r.ServeHTTP(c, newReq)
|
|
||||||
if c.status != http.StatusOK {
|
|
||||||
switch c.status {
|
|
||||||
case 0:
|
|
||||||
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "connection error"}
|
|
||||||
default:
|
|
||||||
if loc := c.Header().Get("Location"); loc != "" {
|
|
||||||
if depth > MaxRedirectDepth {
|
|
||||||
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "too many redirects"}
|
|
||||||
}
|
|
||||||
loc = strutils.SanitizeURI(loc)
|
|
||||||
if loc == "/" || loc == newReq.URL.Path {
|
|
||||||
return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "circular redirect"}
|
|
||||||
}
|
|
||||||
return findIconSlow(r, req, loc, depth+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &fetchResult{statusCode: c.status, errMsg: "upstream error: " + string(c.data)}
|
|
||||||
}
|
|
||||||
// return icon data
|
|
||||||
if !gphttp.GetContentType(c.header).IsHTML() {
|
|
||||||
return &fetchResult{icon: c.data, contentType: c.header.Get("Content-Type")}
|
|
||||||
}
|
|
||||||
// try extract from "link[rel=icon]" from path "/"
|
|
||||||
doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(c.data))
|
|
||||||
if err != nil {
|
|
||||||
logging.Error().Err(err).
|
|
||||||
Str("route", r.TargetName()).
|
|
||||||
Msg("failed to parse html")
|
|
||||||
return &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"}
|
|
||||||
}
|
|
||||||
ele := doc.Find("head > link[rel=icon]").First()
|
|
||||||
if ele.Length() == 0 {
|
|
||||||
return &fetchResult{statusCode: http.StatusNotFound, errMsg: "icon element not found"}
|
|
||||||
}
|
|
||||||
href := ele.AttrOr("href", "")
|
|
||||||
if href == "" {
|
|
||||||
return &fetchResult{statusCode: http.StatusNotFound, errMsg: "icon href 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 &fetchResult{statusCode: http.StatusInternalServerError, errMsg: "internal error"}
|
|
||||||
}
|
|
||||||
return &fetchResult{icon: dataURI.Data, contentType: dataURI.ContentType()}
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case strings.HasPrefix(href, "http://"), strings.HasPrefix(href, "https://"):
|
|
||||||
return fetchIconAbsolute(href)
|
|
||||||
default:
|
|
||||||
return findIconSlow(r, req, href, 0)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -6,9 +6,9 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/yusing/go-proxy/internal"
|
|
||||||
"github.com/yusing/go-proxy/internal/common"
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
config "github.com/yusing/go-proxy/internal/config/types"
|
config "github.com/yusing/go-proxy/internal/config/types"
|
||||||
|
"github.com/yusing/go-proxy/internal/homepage"
|
||||||
"github.com/yusing/go-proxy/internal/net/gphttp"
|
"github.com/yusing/go-proxy/internal/net/gphttp"
|
||||||
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
|
"github.com/yusing/go-proxy/internal/net/gphttp/middleware"
|
||||||
"github.com/yusing/go-proxy/internal/route/routes/routequery"
|
"github.com/yusing/go-proxy/internal/route/routes/routequery"
|
||||||
|
@ -67,7 +67,7 @@ func List(cfg config.ConfigInstance, w http.ResponseWriter, r *http.Request) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
icons, err := internal.SearchIcons(r.FormValue("keyword"), limit)
|
icons, err := homepage.SearchIcons(r.FormValue("keyword"), limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
gphttp.ClientError(w, err)
|
gphttp.ClientError(w, err)
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package favicon
|
package homepage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
195
internal/homepage/favicon.go
Normal file
195
internal/homepage/favicon.go
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
package homepage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
"github.com/vincent-petithory/dataurl"
|
||||||
|
gphttp "github.com/yusing/go-proxy/internal/net/gphttp"
|
||||||
|
"github.com/yusing/go-proxy/internal/utils/strutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FetchResult struct {
|
||||||
|
Icon []byte
|
||||||
|
StatusCode int
|
||||||
|
ErrMsg string
|
||||||
|
|
||||||
|
contentType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res *FetchResult) OK() bool {
|
||||||
|
return res.Icon != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res *FetchResult) ContentType() string {
|
||||||
|
if res.contentType == "" {
|
||||||
|
if bytes.HasPrefix(res.Icon, []byte("<svg")) || bytes.HasPrefix(res.Icon, []byte("<?xml")) {
|
||||||
|
return "image/svg+xml"
|
||||||
|
}
|
||||||
|
return "image/x-icon"
|
||||||
|
}
|
||||||
|
return res.contentType
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRedirectDepth = 5
|
||||||
|
|
||||||
|
func FetchFavIconFromURL(iconURL *IconURL) *FetchResult {
|
||||||
|
switch iconURL.IconSource {
|
||||||
|
case IconSourceAbsolute:
|
||||||
|
return fetchIconAbsolute(iconURL.URL())
|
||||||
|
case IconSourceRelative:
|
||||||
|
return &FetchResult{StatusCode: http.StatusBadRequest, ErrMsg: "unexpected relative icon"}
|
||||||
|
case IconSourceWalkXCode, IconSourceSelfhSt:
|
||||||
|
return fetchKnownIcon(iconURL)
|
||||||
|
}
|
||||||
|
return &FetchResult{StatusCode: http.StatusBadRequest, ErrMsg: "invalid icon source"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchIconAbsolute(url string) *FetchResult {
|
||||||
|
if result := loadIconCache(url); result != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := gphttp.Get(url)
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
if err == nil {
|
||||||
|
err = errors.New(resp.Status)
|
||||||
|
}
|
||||||
|
return &FetchResult{StatusCode: http.StatusBadGateway, ErrMsg: "connection error"}
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
icon, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return &FetchResult{StatusCode: http.StatusInternalServerError, ErrMsg: "internal error"}
|
||||||
|
}
|
||||||
|
|
||||||
|
storeIconCache(url, icon)
|
||||||
|
return &FetchResult{Icon: icon}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameSanitizer = strings.NewReplacer(
|
||||||
|
"_", "-",
|
||||||
|
" ", "-",
|
||||||
|
"(", "",
|
||||||
|
")", "",
|
||||||
|
)
|
||||||
|
|
||||||
|
func sanitizeName(name string) string {
|
||||||
|
return strings.ToLower(nameSanitizer.Replace(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchKnownIcon(url *IconURL) *FetchResult {
|
||||||
|
// if icon isn't in the list, no need to fetch
|
||||||
|
if !url.HasIcon() {
|
||||||
|
return &FetchResult{StatusCode: http.StatusNotFound, ErrMsg: "no such icon"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchIconAbsolute(url.URL())
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchIcon(filetype, filename string) *FetchResult {
|
||||||
|
result := fetchKnownIcon(NewSelfhStIconURL(filename, filetype))
|
||||||
|
if result.Icon == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return fetchKnownIcon(NewWalkXCodeIconURL(filename, filetype))
|
||||||
|
}
|
||||||
|
|
||||||
|
func FindIcon(ctx context.Context, r route, uri string) *FetchResult {
|
||||||
|
key := routeKey(r)
|
||||||
|
if result := loadIconCache(key); result != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result := fetchIcon("png", sanitizeName(r.Reference()))
|
||||||
|
if !result.OK() {
|
||||||
|
if r, ok := r.(httpRoute); ok {
|
||||||
|
// fallback to parse html
|
||||||
|
result = findIconSlow(ctx, r, uri, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.OK() {
|
||||||
|
storeIconCache(key, result.Icon)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func findIconSlow(ctx context.Context, r httpRoute, uri string, depth int) *FetchResult {
|
||||||
|
ctx, cancel := context.WithTimeoutCause(ctx, 3*time.Second, errors.New("favicon request timeout"))
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
newReq, err := http.NewRequestWithContext(ctx, "GET", r.TargetURL().String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return &FetchResult{StatusCode: http.StatusInternalServerError, ErrMsg: "cannot create request"}
|
||||||
|
}
|
||||||
|
newReq.Header.Set("Accept-Encoding", "identity") // disable compression
|
||||||
|
|
||||||
|
u, err := url.ParseRequestURI(strutils.SanitizeURI(uri))
|
||||||
|
if err != nil {
|
||||||
|
return &FetchResult{StatusCode: http.StatusInternalServerError, ErrMsg: "cannot parse uri"}
|
||||||
|
}
|
||||||
|
newReq.URL.Path = u.Path
|
||||||
|
newReq.URL.RawPath = u.RawPath
|
||||||
|
newReq.URL.RawQuery = u.RawQuery
|
||||||
|
newReq.RequestURI = u.String()
|
||||||
|
|
||||||
|
c := newContent()
|
||||||
|
r.ServeHTTP(c, newReq)
|
||||||
|
if c.status != http.StatusOK {
|
||||||
|
switch c.status {
|
||||||
|
case 0:
|
||||||
|
return &FetchResult{StatusCode: http.StatusBadGateway, ErrMsg: "connection error"}
|
||||||
|
default:
|
||||||
|
if loc := c.Header().Get("Location"); loc != "" {
|
||||||
|
if depth > maxRedirectDepth {
|
||||||
|
return &FetchResult{StatusCode: http.StatusBadGateway, ErrMsg: "too many redirects"}
|
||||||
|
}
|
||||||
|
loc = strutils.SanitizeURI(loc)
|
||||||
|
if loc == "/" || loc == newReq.URL.Path {
|
||||||
|
return &FetchResult{StatusCode: http.StatusBadGateway, ErrMsg: "circular redirect"}
|
||||||
|
}
|
||||||
|
return findIconSlow(ctx, r, loc, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &FetchResult{StatusCode: c.status, ErrMsg: "upstream error: " + string(c.data)}
|
||||||
|
}
|
||||||
|
// return icon data
|
||||||
|
if !gphttp.GetContentType(c.header).IsHTML() {
|
||||||
|
return &FetchResult{Icon: c.data, contentType: c.header.Get("Content-Type")}
|
||||||
|
}
|
||||||
|
// try extract from "link[rel=icon]" from path "/"
|
||||||
|
doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(c.data))
|
||||||
|
if err != nil {
|
||||||
|
return &FetchResult{StatusCode: http.StatusInternalServerError, ErrMsg: "failed to parse html"}
|
||||||
|
}
|
||||||
|
ele := doc.Find("head > link[rel=icon]").First()
|
||||||
|
if ele.Length() == 0 {
|
||||||
|
return &FetchResult{StatusCode: http.StatusNotFound, ErrMsg: "icon element not found"}
|
||||||
|
}
|
||||||
|
href := ele.AttrOr("href", "")
|
||||||
|
if href == "" {
|
||||||
|
return &FetchResult{StatusCode: http.StatusNotFound, ErrMsg: "icon href not found"}
|
||||||
|
}
|
||||||
|
// https://en.wikipedia.org/wiki/Data_URI_scheme
|
||||||
|
if strings.HasPrefix(href, "data:image/") {
|
||||||
|
dataURI, err := dataurl.DecodeString(href)
|
||||||
|
if err != nil {
|
||||||
|
return &FetchResult{StatusCode: http.StatusInternalServerError, ErrMsg: "failed to decode favicon"}
|
||||||
|
}
|
||||||
|
return &FetchResult{Icon: dataURI.Data, contentType: dataURI.ContentType()}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(href, "http://"), strings.HasPrefix(href, "https://"):
|
||||||
|
return fetchIconAbsolute(href)
|
||||||
|
default:
|
||||||
|
return findIconSlow(ctx, r, href, 0)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package favicon
|
package homepage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
@ -7,7 +7,6 @@ import (
|
||||||
|
|
||||||
"github.com/yusing/go-proxy/internal/common"
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
"github.com/yusing/go-proxy/internal/logging"
|
"github.com/yusing/go-proxy/internal/logging"
|
||||||
route "github.com/yusing/go-proxy/internal/route/types"
|
|
||||||
"github.com/yusing/go-proxy/internal/task"
|
"github.com/yusing/go-proxy/internal/task"
|
||||||
"github.com/yusing/go-proxy/internal/utils"
|
"github.com/yusing/go-proxy/internal/utils"
|
||||||
)
|
)
|
||||||
|
@ -82,17 +81,17 @@ func pruneExpiredIconCache() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func routeKey(r route.HTTPRoute) string {
|
func routeKey(r route) string {
|
||||||
return r.ProviderName() + ":" + r.TargetName()
|
return r.ProviderName() + ":" + r.TargetName()
|
||||||
}
|
}
|
||||||
|
|
||||||
func PruneRouteIconCache(route route.HTTPRoute) {
|
func PruneRouteIconCache(route route) {
|
||||||
iconCacheMu.Lock()
|
iconCacheMu.Lock()
|
||||||
defer iconCacheMu.Unlock()
|
defer iconCacheMu.Unlock()
|
||||||
delete(iconCache, routeKey(route))
|
delete(iconCache, routeKey(route))
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadIconCache(key string) *fetchResult {
|
func loadIconCache(key string) *FetchResult {
|
||||||
iconCacheMu.RLock()
|
iconCacheMu.RLock()
|
||||||
defer iconCacheMu.RUnlock()
|
defer iconCacheMu.RUnlock()
|
||||||
|
|
||||||
|
@ -102,7 +101,7 @@ func loadIconCache(key string) *fetchResult {
|
||||||
Str("key", key).
|
Str("key", key).
|
||||||
Msg("icon found in cache")
|
Msg("icon found in cache")
|
||||||
icon.LastAccess = time.Now()
|
icon.LastAccess = time.Now()
|
||||||
return &fetchResult{icon: icon.Icon}
|
return &FetchResult{Icon: icon.Icon}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/yusing/go-proxy/internal"
|
|
||||||
"github.com/yusing/go-proxy/internal/gperr"
|
"github.com/yusing/go-proxy/internal/gperr"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -62,10 +61,10 @@ func NewWalkXCodeIconURL(name, format string) *IconURL {
|
||||||
// otherwise returns true.
|
// otherwise returns true.
|
||||||
func (u *IconURL) HasIcon() bool {
|
func (u *IconURL) HasIcon() bool {
|
||||||
if u.IconSource == IconSourceSelfhSt {
|
if u.IconSource == IconSourceSelfhSt {
|
||||||
return internal.HasSelfhstIcon(u.Extra.Name, u.Extra.FileType)
|
return HasSelfhstIcon(u.Extra.Name, u.Extra.FileType)
|
||||||
}
|
}
|
||||||
if u.IconSource == IconSourceWalkXCode {
|
if u.IconSource == IconSourceWalkXCode {
|
||||||
return internal.HasWalkxCodeIcon(u.Extra.Name, u.Extra.FileType)
|
return HasWalkxCodeIcon(u.Extra.Name, u.Extra.FileType)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package internal
|
package homepage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
@ -59,15 +59,15 @@ func InitIconListCache() {
|
||||||
DisplayNames: make(ReferenceDisplayNameMap),
|
DisplayNames: make(ReferenceDisplayNameMap),
|
||||||
IconList: []string{},
|
IconList: []string{},
|
||||||
}
|
}
|
||||||
err := utils.LoadJSONIfExist(common.IconListCachePath, iconsCache)
|
// err := utils.LoadJSONIfExist(common.IconListCachePath, iconsCache)
|
||||||
if err != nil {
|
// if err != nil {
|
||||||
logging.Error().Err(err).Msg("failed to load icon list cache config")
|
// logging.Error().Err(err).Msg("failed to load icon list cache config")
|
||||||
} else if len(iconsCache.IconList) > 0 {
|
// } else if len(iconsCache.IconList) > 0 {
|
||||||
logging.Info().
|
// logging.Info().
|
||||||
Int("icons", len(iconsCache.IconList)).
|
// Int("icons", len(iconsCache.IconList)).
|
||||||
Int("display_names", len(iconsCache.DisplayNames)).
|
// Int("display_names", len(iconsCache.DisplayNames)).
|
||||||
Msg("icon list cache loaded")
|
// Msg("icon list cache loaded")
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListAvailableIcons() (*Cache, error) {
|
func ListAvailableIcons() (*Cache, error) {
|
19
internal/homepage/route.go
Normal file
19
internal/homepage/route.go
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
package homepage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
net "github.com/yusing/go-proxy/internal/net/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type route interface {
|
||||||
|
TargetName() string
|
||||||
|
ProviderName() string
|
||||||
|
Reference() string
|
||||||
|
TargetURL() *net.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
type httpRoute interface {
|
||||||
|
route
|
||||||
|
http.Handler
|
||||||
|
}
|
|
@ -173,7 +173,7 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
r.task.OnCancel("reset_favicon", func() { favicon.PruneRouteIconCache(r) })
|
r.task.OnCancel("reset_favicon", func() { homepage.PruneRouteIconCache(r) })
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -147,6 +147,13 @@ func (r *Route) Started() bool {
|
||||||
return r.impl != nil
|
return r.impl != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Route) Reference() string {
|
||||||
|
if r.Docker != nil {
|
||||||
|
return r.Docker.Image.Name
|
||||||
|
}
|
||||||
|
return r.Alias
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Route) ProviderName() string {
|
func (r *Route) ProviderName() string {
|
||||||
return r.Provider
|
return r.Provider
|
||||||
}
|
}
|
||||||
|
@ -390,21 +397,16 @@ 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()
|
||||||
|
|
||||||
var key string
|
|
||||||
if hp.Name == "" {
|
if hp.Name == "" {
|
||||||
if r.Container != nil {
|
displayName, ok := homepage.GetDisplayName(ref)
|
||||||
key = r.Container.Image.Name
|
|
||||||
} else {
|
|
||||||
key = r.Alias
|
|
||||||
}
|
|
||||||
displayName, ok := internal.GetDisplayName(key)
|
|
||||||
if ok {
|
if ok {
|
||||||
hp.Name = displayName
|
hp.Name = displayName
|
||||||
} else {
|
} else {
|
||||||
hp.Name = strutils.Title(
|
hp.Name = strutils.Title(
|
||||||
strings.ReplaceAll(
|
strings.ReplaceAll(
|
||||||
strings.ReplaceAll(key, "-", " "),
|
strings.ReplaceAll(r.Alias, "-", " "),
|
||||||
"_", " ",
|
"_", " ",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -413,12 +415,7 @@ func (r *Route) FinalizeHomepageConfig() {
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ type (
|
||||||
TargetName() string
|
TargetName() string
|
||||||
TargetURL() *net.URL
|
TargetURL() *net.URL
|
||||||
HealthMonitor() health.HealthMonitor
|
HealthMonitor() health.HealthMonitor
|
||||||
|
Reference() string
|
||||||
|
|
||||||
Started() bool
|
Started() bool
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue