mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-25 10:18:59 +02:00
refactor(favicon): improve cache and error handling
This commit is contained in:
@@ -21,7 +21,7 @@ require (
|
|||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/yusing/godoxy v0.18.6
|
github.com/yusing/godoxy v0.18.6
|
||||||
github.com/yusing/godoxy/socketproxy v0.0.0-00010101000000-000000000000
|
github.com/yusing/godoxy/socketproxy v0.0.0-00010101000000-000000000000
|
||||||
github.com/yusing/goutils v0.5.2
|
github.com/yusing/goutils v0.6.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -31,6 +31,7 @@ require (
|
|||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/containerd/errdefs v1.0.0 // indirect
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
|
|||||||
@@ -208,8 +208,8 @@ github.com/yusing/ds v0.2.0 h1:lPhDU5eA2uvquVrBrzLCrQXRJJgSXlUYA53TbuK2sQY=
|
|||||||
github.com/yusing/ds v0.2.0/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
|
github.com/yusing/ds v0.2.0/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
|
||||||
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
|
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
|
||||||
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
|
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
|
||||||
github.com/yusing/goutils v0.5.2 h1:h3Xbz3buTqH3SaL3LPdd/4wgNIQXcnHjNvCIlhOr5B0=
|
github.com/yusing/goutils v0.6.0 h1:oXdqKlTzaWWzjBPoAeqMVPCrQ4VHLtGMU+VqIS1U02Q=
|
||||||
github.com/yusing/goutils v0.5.2/go.mod h1:3dgYe/A3+8wT88/iAHwXdL44q5bP+qVo2WAOiPBqOrg=
|
github.com/yusing/goutils v0.6.0/go.mod h1:3dgYe/A3+8wT88/iAHwXdL44q5bP+qVo2WAOiPBqOrg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -49,7 +49,7 @@ require (
|
|||||||
github.com/yusing/godoxy/agent v0.0.0-20251005042105-448a2fbd6f50
|
github.com/yusing/godoxy/agent v0.0.0-20251005042105-448a2fbd6f50
|
||||||
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20251005040558-74224c8e8784
|
github.com/yusing/godoxy/internal/dnsproviders v0.0.0-20251005040558-74224c8e8784
|
||||||
github.com/yusing/godoxy/internal/utils v0.1.0
|
github.com/yusing/godoxy/internal/utils v0.1.0
|
||||||
github.com/yusing/goutils v0.5.2
|
github.com/yusing/goutils v0.6.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -217,6 +217,7 @@ require (
|
|||||||
github.com/aziontech/azionapi-go-sdk v0.143.0 // indirect
|
github.com/aziontech/azionapi-go-sdk v0.143.0 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/containerd/errdefs v1.0.0 // indirect
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1646,8 +1646,8 @@ github.com/yusing/ds v0.2.0 h1:lPhDU5eA2uvquVrBrzLCrQXRJJgSXlUYA53TbuK2sQY=
|
|||||||
github.com/yusing/ds v0.2.0/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
|
github.com/yusing/ds v0.2.0/go.mod h1:XhKV4l7cZwBbbl7lRzNC9zX27zvCM0frIwiuD40ULRk=
|
||||||
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
|
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
|
||||||
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
|
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
|
||||||
github.com/yusing/goutils v0.5.2 h1:h3Xbz3buTqH3SaL3LPdd/4wgNIQXcnHjNvCIlhOr5B0=
|
github.com/yusing/goutils v0.6.0 h1:oXdqKlTzaWWzjBPoAeqMVPCrQ4VHLtGMU+VqIS1U02Q=
|
||||||
github.com/yusing/goutils v0.5.2/go.mod h1:3dgYe/A3+8wT88/iAHwXdL44q5bP+qVo2WAOiPBqOrg=
|
github.com/yusing/goutils v0.6.0/go.mod h1:3dgYe/A3+8wT88/iAHwXdL44q5bP+qVo2WAOiPBqOrg=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ func FavIcon(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, apitypes.Error("invalid url", err))
|
c.JSON(http.StatusBadRequest, apitypes.Error("invalid url", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fetchResult := homepage.FetchFavIconFromURL(c.Request.Context(), &iconURL)
|
fetchResult, err := homepage.FetchFavIconFromURL(c.Request.Context(), &iconURL)
|
||||||
if !fetchResult.OK() {
|
if err != nil {
|
||||||
c.JSON(fetchResult.StatusCode, apitypes.Error(fetchResult.ErrMsg))
|
homepage.GinFetchError(c, fetchResult.StatusCode, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Data(fetchResult.StatusCode, fetchResult.ContentType(), fetchResult.Icon)
|
c.Data(fetchResult.StatusCode, fetchResult.ContentType(), fetchResult.Icon)
|
||||||
@@ -54,38 +54,38 @@ func FavIcon(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// try with alias
|
// try with alias
|
||||||
result := GetFavIconFromAlias(c.Request.Context(), request.Alias)
|
result, err := GetFavIconFromAlias(c.Request.Context(), request.Alias)
|
||||||
if !result.OK() {
|
if err != nil {
|
||||||
c.JSON(result.StatusCode, apitypes.Error(result.ErrMsg))
|
homepage.GinFetchError(c, result.StatusCode, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Data(result.StatusCode, result.ContentType(), result.Icon)
|
c.Data(result.StatusCode, result.ContentType(), result.Icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetFavIconFromAlias(ctx context.Context, alias string) *homepage.FetchResult {
|
func GetFavIconFromAlias(ctx context.Context, alias string) (homepage.FetchResult, error) {
|
||||||
// try with route.Icon
|
// try with route.Icon
|
||||||
r, ok := routes.HTTP.Get(alias)
|
r, ok := routes.HTTP.Get(alias)
|
||||||
if !ok {
|
if !ok {
|
||||||
return &homepage.FetchResult{
|
return homepage.FetchResultWithErrorf(http.StatusNotFound, "route not found")
|
||||||
StatusCode: http.StatusNotFound,
|
|
||||||
ErrMsg: "route not found",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result *homepage.FetchResult
|
var (
|
||||||
|
result homepage.FetchResult
|
||||||
|
err error
|
||||||
|
)
|
||||||
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(ctx, r, *hp.Icon.FullURL)
|
result, err = homepage.FindIcon(ctx, r, *hp.Icon.FullURL)
|
||||||
} else {
|
} else {
|
||||||
result = homepage.FetchFavIconFromURL(ctx, hp.Icon)
|
result, err = homepage.FetchFavIconFromURL(ctx, hp.Icon)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// try extract from "link[rel=icon]"
|
// try extract from "link[rel=icon]"
|
||||||
result = homepage.FindIcon(ctx, r, "/")
|
result, err = homepage.FindIcon(ctx, r, "/")
|
||||||
}
|
}
|
||||||
if result.StatusCode == 0 {
|
if result.StatusCode == 0 {
|
||||||
result.StatusCode = http.StatusOK
|
result.StatusCode = http.StatusOK
|
||||||
}
|
}
|
||||||
return result
|
return result, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
github.com/yusing/godoxy/internal/utils v0.1.0 // indirect
|
github.com/yusing/godoxy/internal/utils v0.1.0 // indirect
|
||||||
github.com/yusing/gointernals v0.1.16 // indirect
|
github.com/yusing/gointernals v0.1.16 // indirect
|
||||||
github.com/yusing/goutils v0.5.2 // indirect
|
github.com/yusing/goutils v0.6.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||||
|
|||||||
@@ -1527,8 +1527,8 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
|
github.com/yusing/gointernals v0.1.16 h1:GrhZZdxzA+jojLEqankctJrOuAYDb7kY1C93S1pVR34=
|
||||||
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
|
github.com/yusing/gointernals v0.1.16/go.mod h1:B/0FVXt4WPmgzVy3ynzkqKi+BSGaJVmwCJBRXYapo34=
|
||||||
github.com/yusing/goutils v0.5.2 h1:h3Xbz3buTqH3SaL3LPdd/4wgNIQXcnHjNvCIlhOr5B0=
|
github.com/yusing/goutils v0.6.0 h1:oXdqKlTzaWWzjBPoAeqMVPCrQ4VHLtGMU+VqIS1U02Q=
|
||||||
github.com/yusing/goutils v0.5.2/go.mod h1:3dgYe/A3+8wT88/iAHwXdL44q5bP+qVo2WAOiPBqOrg=
|
github.com/yusing/goutils v0.6.0/go.mod h1:3dgYe/A3+8wT88/iAHwXdL44q5bP+qVo2WAOiPBqOrg=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -12,8 +13,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/vincent-petithory/dataurl"
|
"github.com/vincent-petithory/dataurl"
|
||||||
|
apitypes "github.com/yusing/godoxy/internal/api/types"
|
||||||
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
|
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
|
||||||
|
"github.com/yusing/goutils/cache"
|
||||||
httputils "github.com/yusing/goutils/http"
|
httputils "github.com/yusing/goutils/http"
|
||||||
strutils "github.com/yusing/goutils/strings"
|
strutils "github.com/yusing/goutils/strings"
|
||||||
)
|
)
|
||||||
@@ -21,17 +25,31 @@ import (
|
|||||||
type FetchResult struct {
|
type FetchResult struct {
|
||||||
Icon []byte
|
Icon []byte
|
||||||
StatusCode int
|
StatusCode int
|
||||||
ErrMsg string
|
|
||||||
|
|
||||||
contentType string
|
contentType string
|
||||||
}
|
}
|
||||||
|
|
||||||
const faviconFetchTimeout = 3 * time.Second
|
func FetchResultWithErrorf(statusCode int, msgFmt string, args ...any) (FetchResult, error) {
|
||||||
|
return FetchResult{StatusCode: statusCode}, fmt.Errorf(msgFmt, args...)
|
||||||
func (res *FetchResult) OK() bool {
|
|
||||||
return len(res.Icon) > 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FetchResultOK(icon []byte, contentType string) (FetchResult, error) {
|
||||||
|
return FetchResult{Icon: icon, contentType: contentType}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GinFetchError(c *gin.Context, statusCode int, err error) {
|
||||||
|
if statusCode == 0 {
|
||||||
|
statusCode = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
if statusCode == http.StatusInternalServerError {
|
||||||
|
c.Error(apitypes.InternalServerError(err, "unexpected error"))
|
||||||
|
} else {
|
||||||
|
c.JSON(statusCode, apitypes.Error(err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const faviconFetchTimeout = 3 * time.Second
|
||||||
|
|
||||||
func (res *FetchResult) ContentType() string {
|
func (res *FetchResult) ContentType() string {
|
||||||
if res.contentType == "" {
|
if res.contentType == "" {
|
||||||
if bytes.HasPrefix(res.Icon, []byte("<svg")) || bytes.HasPrefix(res.Icon, []byte("<?xml")) {
|
if bytes.HasPrefix(res.Icon, []byte("<svg")) || bytes.HasPrefix(res.Icon, []byte("<?xml")) {
|
||||||
@@ -44,56 +62,54 @@ func (res *FetchResult) ContentType() string {
|
|||||||
|
|
||||||
const maxRedirectDepth = 5
|
const maxRedirectDepth = 5
|
||||||
|
|
||||||
func FetchFavIconFromURL(ctx context.Context, iconURL *IconURL) *FetchResult {
|
func FetchFavIconFromURL(ctx context.Context, iconURL *IconURL) (FetchResult, error) {
|
||||||
switch iconURL.IconSource {
|
switch iconURL.IconSource {
|
||||||
case IconSourceAbsolute:
|
case IconSourceAbsolute:
|
||||||
return fetchIconAbsolute(ctx, iconURL.URL())
|
return FetchIconAbsolute(ctx, iconURL.URL())
|
||||||
case IconSourceRelative:
|
case IconSourceRelative:
|
||||||
return &FetchResult{StatusCode: http.StatusBadRequest, ErrMsg: "unexpected relative icon"}
|
return FetchResultWithErrorf(http.StatusBadRequest, "unexpected relative icon")
|
||||||
case IconSourceWalkXCode, IconSourceSelfhSt:
|
case IconSourceWalkXCode, IconSourceSelfhSt:
|
||||||
return fetchKnownIcon(ctx, iconURL)
|
return fetchKnownIcon(ctx, iconURL)
|
||||||
}
|
}
|
||||||
return &FetchResult{StatusCode: http.StatusBadRequest, ErrMsg: "invalid icon source"}
|
return FetchResultWithErrorf(http.StatusBadRequest, "invalid icon source")
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchIconAbsolute(ctx context.Context, url string) *FetchResult {
|
var FetchIconAbsolute = cache.NewKeyFunc(func(ctx context.Context, url string) (FetchResult, error) {
|
||||||
if result := loadIconCache(url); result != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot create request: %w", err)
|
||||||
return &FetchResult{StatusCode: http.StatusBadGateway, ErrMsg: "request timeout"}
|
|
||||||
}
|
|
||||||
return &FetchResult{StatusCode: http.StatusInternalServerError, ErrMsg: err.Error()}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := gphttp.Do(req)
|
resp, err := gphttp.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
} else {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
|
return FetchResultWithErrorf(http.StatusBadGateway, "request timeout")
|
||||||
|
}
|
||||||
|
return FetchResultWithErrorf(http.StatusBadGateway, "connection error: %w", err)
|
||||||
}
|
}
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
|
||||||
return &FetchResult{StatusCode: http.StatusBadGateway, ErrMsg: "connection error"}
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return FetchResultWithErrorf(resp.StatusCode, "upstream error: http %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
icon, err := io.ReadAll(resp.Body)
|
icon, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &FetchResult{StatusCode: http.StatusInternalServerError, ErrMsg: "internal error"}
|
return FetchResultWithErrorf(http.StatusInternalServerError, "failed to read response body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(icon) == 0 {
|
if len(icon) == 0 {
|
||||||
return &FetchResult{StatusCode: http.StatusNotFound, ErrMsg: "empty icon"}
|
return FetchResultWithErrorf(http.StatusNotFound, "empty icon")
|
||||||
}
|
}
|
||||||
|
|
||||||
res := &FetchResult{Icon: icon}
|
res := FetchResult{Icon: icon}
|
||||||
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
|
if contentType := resp.Header.Get("Content-Type"); contentType != "" {
|
||||||
res.contentType = contentType
|
res.contentType = contentType
|
||||||
}
|
}
|
||||||
// else leave it empty
|
// else leave it empty
|
||||||
storeIconCache(url, res)
|
return res, nil
|
||||||
return res
|
}).WithMaxEntries(200).WithRetriesExponentialBackoff(3).WithTTL(4 * time.Hour).Build()
|
||||||
}
|
|
||||||
|
|
||||||
var nameSanitizer = strings.NewReplacer(
|
var nameSanitizer = strings.NewReplacer(
|
||||||
"_", "-",
|
"_", "-",
|
||||||
@@ -106,57 +122,57 @@ func sanitizeName(name string) string {
|
|||||||
return strings.ToLower(nameSanitizer.Replace(name))
|
return strings.ToLower(nameSanitizer.Replace(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchKnownIcon(ctx context.Context, url *IconURL) *FetchResult {
|
func fetchKnownIcon(ctx context.Context, url *IconURL) (FetchResult, error) {
|
||||||
// if icon isn't in the list, no need to fetch
|
// if icon isn't in the list, no need to fetch
|
||||||
if !url.HasIcon() {
|
if !url.HasIcon() {
|
||||||
return &FetchResult{StatusCode: http.StatusNotFound, ErrMsg: "no such icon"}
|
return FetchResult{StatusCode: http.StatusNotFound}, errors.New("no such icon")
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetchIconAbsolute(ctx, url.URL())
|
return FetchIconAbsolute(ctx, url.URL())
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchIcon(ctx context.Context, filename string) *FetchResult {
|
func fetchIcon(ctx context.Context, filename string) (FetchResult, error) {
|
||||||
for _, fileType := range []string{"svg", "webp", "png"} {
|
for _, fileType := range []string{"svg", "webp", "png"} {
|
||||||
result := fetchKnownIcon(ctx, NewSelfhStIconURL(filename, fileType))
|
result, err := fetchKnownIcon(ctx, NewSelfhStIconURL(filename, fileType))
|
||||||
if result.OK() {
|
if err == nil {
|
||||||
return result
|
return result, err
|
||||||
}
|
}
|
||||||
result = fetchKnownIcon(ctx, NewWalkXCodeIconURL(filename, fileType))
|
result, err = fetchKnownIcon(ctx, NewWalkXCodeIconURL(filename, fileType))
|
||||||
if result.OK() {
|
if err == nil {
|
||||||
return result
|
return result, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &FetchResult{StatusCode: http.StatusNotFound, ErrMsg: "no icon found"}
|
return FetchResultWithErrorf(http.StatusNotFound, "no icon found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func FindIcon(ctx context.Context, r route, uri string) *FetchResult {
|
func FindIcon(ctx context.Context, r route, uri string) (FetchResult, error) {
|
||||||
if result := loadIconCache(r.Key()); result != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ref := range r.References() {
|
for _, ref := range r.References() {
|
||||||
result := fetchIcon(ctx, sanitizeName(ref))
|
result, err := fetchIcon(ctx, sanitizeName(ref))
|
||||||
if result.OK() {
|
if err == nil {
|
||||||
storeIconCache(r.Key(), result)
|
return result, err
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if r, ok := r.(httpRoute); ok {
|
if r, ok := r.(httpRoute); ok {
|
||||||
// fallback to parse html
|
// fallback to parse html
|
||||||
return findIconSlow(ctx, r, uri, nil)
|
return findIconSlowCached(context.WithValue(ctx, "route", r), uri)
|
||||||
}
|
}
|
||||||
return &FetchResult{StatusCode: http.StatusNotFound, ErrMsg: "no icon found"}
|
return FetchResultWithErrorf(http.StatusNotFound, "no icon found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func findIconSlow(ctx context.Context, r httpRoute, uri string, stack []string) *FetchResult {
|
var findIconSlowCached = cache.NewKeyFunc(func(ctx context.Context, key string) (FetchResult, error) {
|
||||||
|
r := ctx.Value("route").(httpRoute)
|
||||||
|
return findIconSlow(ctx, r, key, nil)
|
||||||
|
}).WithMaxEntries(200).Build() // no retries, no ttl
|
||||||
|
|
||||||
|
func findIconSlow(ctx context.Context, r httpRoute, uri string, stack []string) (FetchResult, error) {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return &FetchResult{StatusCode: http.StatusBadGateway, ErrMsg: "request timeout"}
|
return FetchResultWithErrorf(http.StatusBadGateway, "request timeout")
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(stack) > maxRedirectDepth {
|
if len(stack) > maxRedirectDepth {
|
||||||
return &FetchResult{StatusCode: http.StatusBadGateway, ErrMsg: "too many redirects"}
|
return FetchResultWithErrorf(http.StatusBadGateway, "too many redirects")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeoutCause(ctx, faviconFetchTimeout, errors.New("favicon request timeout"))
|
ctx, cancel := context.WithTimeoutCause(ctx, faviconFetchTimeout, errors.New("favicon request timeout"))
|
||||||
@@ -164,13 +180,13 @@ func findIconSlow(ctx context.Context, r httpRoute, uri string, stack []string)
|
|||||||
|
|
||||||
newReq, err := http.NewRequestWithContext(ctx, http.MethodGet, r.TargetURL().String(), nil)
|
newReq, err := http.NewRequestWithContext(ctx, http.MethodGet, r.TargetURL().String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &FetchResult{StatusCode: http.StatusInternalServerError, ErrMsg: "cannot create request"}
|
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot create request: %w", err)
|
||||||
}
|
}
|
||||||
newReq.Header.Set("Accept-Encoding", "identity") // disable compression
|
newReq.Header.Set("Accept-Encoding", "identity") // disable compression
|
||||||
|
|
||||||
u, err := url.ParseRequestURI(strutils.SanitizeURI(uri))
|
u, err := url.ParseRequestURI(strutils.SanitizeURI(uri))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &FetchResult{StatusCode: http.StatusInternalServerError, ErrMsg: "cannot parse uri"}
|
return FetchResultWithErrorf(http.StatusInternalServerError, "cannot parse uri: %w", err)
|
||||||
}
|
}
|
||||||
newReq.URL.Path = u.Path
|
newReq.URL.Path = u.Path
|
||||||
newReq.URL.RawPath = u.RawPath
|
newReq.URL.RawPath = u.RawPath
|
||||||
@@ -182,48 +198,48 @@ func findIconSlow(ctx context.Context, r httpRoute, uri string, stack []string)
|
|||||||
if c.status != http.StatusOK {
|
if c.status != http.StatusOK {
|
||||||
switch c.status {
|
switch c.status {
|
||||||
case 0:
|
case 0:
|
||||||
return &FetchResult{StatusCode: http.StatusBadGateway, ErrMsg: "connection error"}
|
return FetchResultWithErrorf(http.StatusBadGateway, "connection error")
|
||||||
default:
|
default:
|
||||||
if loc := c.Header().Get("Location"); loc != "" {
|
if loc := c.Header().Get("Location"); loc != "" {
|
||||||
loc = strutils.SanitizeURI(loc)
|
loc = strutils.SanitizeURI(loc)
|
||||||
if loc == "/" || loc == newReq.URL.Path || slices.Contains(stack, loc) {
|
if loc == "/" || loc == newReq.URL.Path || slices.Contains(stack, loc) {
|
||||||
return &FetchResult{StatusCode: http.StatusBadGateway, ErrMsg: "circular redirect"}
|
return FetchResultWithErrorf(http.StatusBadGateway, "circular redirect")
|
||||||
}
|
}
|
||||||
// append current path to stack
|
// append current path to stack
|
||||||
// handles redirect to the same path with different query
|
// handles redirect to the same path with different query
|
||||||
return findIconSlow(ctx, r, loc, append(stack, newReq.URL.Path))
|
return findIconSlow(ctx, r, loc, append(stack, newReq.URL.Path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &FetchResult{StatusCode: c.status, ErrMsg: "upstream error: " + string(c.data)}
|
return FetchResultWithErrorf(c.status, "upstream error: %s", c.data)
|
||||||
}
|
}
|
||||||
// return icon data
|
// return icon data
|
||||||
if !httputils.GetContentType(c.header).IsHTML() {
|
if !httputils.GetContentType(c.header).IsHTML() {
|
||||||
return &FetchResult{Icon: c.data, contentType: c.header.Get("Content-Type")}
|
return FetchResultOK(c.data, c.header.Get("Content-Type"))
|
||||||
}
|
}
|
||||||
// try extract from "link[rel=icon]" from path "/"
|
// try extract from "link[rel=icon]" from path "/"
|
||||||
doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(c.data))
|
doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(c.data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &FetchResult{StatusCode: http.StatusInternalServerError, ErrMsg: "failed to parse html"}
|
return FetchResultWithErrorf(http.StatusInternalServerError, "failed to parse html: %w", err)
|
||||||
}
|
}
|
||||||
ele := doc.Find("head > link[rel=icon]").First()
|
ele := doc.Find("head > link[rel=icon]").First()
|
||||||
if ele.Length() == 0 {
|
if ele.Length() == 0 {
|
||||||
return &FetchResult{StatusCode: http.StatusNotFound, ErrMsg: "icon element not found"}
|
return FetchResultWithErrorf(http.StatusNotFound, "icon element not found")
|
||||||
}
|
}
|
||||||
href := ele.AttrOr("href", "")
|
href := ele.AttrOr("href", "")
|
||||||
if href == "" {
|
if href == "" {
|
||||||
return &FetchResult{StatusCode: http.StatusNotFound, ErrMsg: "icon href not found"}
|
return FetchResultWithErrorf(http.StatusNotFound, "icon href not found")
|
||||||
}
|
}
|
||||||
// https://en.wikipedia.org/wiki/Data_URI_scheme
|
// https://en.wikipedia.org/wiki/Data_URI_scheme
|
||||||
if strings.HasPrefix(href, "data:image/") {
|
if strings.HasPrefix(href, "data:image/") {
|
||||||
dataURI, err := dataurl.DecodeString(href)
|
dataURI, err := dataurl.DecodeString(href)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &FetchResult{StatusCode: http.StatusInternalServerError, ErrMsg: "failed to decode favicon"}
|
return FetchResultWithErrorf(http.StatusInternalServerError, "failed to decode favicon: %w", err)
|
||||||
}
|
}
|
||||||
return &FetchResult{Icon: dataURI.Data, contentType: dataURI.ContentType()}
|
return FetchResultOK(dataURI.Data, dataURI.ContentType())
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(href, "http://"), strings.HasPrefix(href, "https://"):
|
case strings.HasPrefix(href, "http://"), strings.HasPrefix(href, "https://"):
|
||||||
return fetchIconAbsolute(ctx, href)
|
return FetchIconAbsolute(ctx, href)
|
||||||
default:
|
default:
|
||||||
return findIconSlow(ctx, r, href, append(stack, newReq.URL.Path))
|
return findIconSlow(ctx, r, href, append(stack, newReq.URL.Path))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
package homepage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/bytedance/sonic"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
"github.com/yusing/godoxy/internal/common"
|
|
||||||
"github.com/yusing/godoxy/internal/jsonstore"
|
|
||||||
"github.com/yusing/godoxy/internal/utils"
|
|
||||||
"github.com/yusing/godoxy/internal/utils/atomic"
|
|
||||||
"github.com/yusing/goutils/task"
|
|
||||||
)
|
|
||||||
|
|
||||||
type cacheEntry struct {
|
|
||||||
Icon []byte `json:"icon"`
|
|
||||||
ContentType string `json:"content_type,omitempty"`
|
|
||||||
LastAccess atomic.Value[time.Time] `json:"last_access"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// cache key can be absolute url or route name.
|
|
||||||
var (
|
|
||||||
iconCache = jsonstore.Store[*cacheEntry](common.NamespaceIconCache)
|
|
||||||
iconMu sync.RWMutex
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
iconCacheTTL = 3 * 24 * time.Hour
|
|
||||||
cleanUpInterval = time.Minute
|
|
||||||
maxIconSize = 1024 * 1024 // 1MB
|
|
||||||
maxCacheEntries = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
go func() {
|
|
||||||
cleanupTicker := time.NewTicker(cleanUpInterval)
|
|
||||||
defer cleanupTicker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-task.RootContextCanceled():
|
|
||||||
return
|
|
||||||
case <-cleanupTicker.C:
|
|
||||||
pruneExpiredIconCache()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func pruneExpiredIconCache() {
|
|
||||||
nPruned := 0
|
|
||||||
for key, icon := range iconCache.Range {
|
|
||||||
if icon.IsExpired() {
|
|
||||||
iconCache.Delete(key)
|
|
||||||
nPruned++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if iconCache.Size() > maxCacheEntries {
|
|
||||||
iconCache.Clear()
|
|
||||||
newIconCache := make(map[string]*cacheEntry, maxCacheEntries)
|
|
||||||
i := 0
|
|
||||||
for key, icon := range iconCache.Range {
|
|
||||||
if i == maxCacheEntries {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !icon.IsExpired() {
|
|
||||||
newIconCache[key] = icon
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for key, icon := range newIconCache {
|
|
||||||
iconCache.Store(key, icon)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if nPruned > 0 {
|
|
||||||
log.Info().Int("pruned", nPruned).Msg("pruned expired icon cache")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func PruneRouteIconCache(route route) {
|
|
||||||
iconCache.Delete(route.Key())
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadIconCache(key string) *FetchResult {
|
|
||||||
iconMu.RLock()
|
|
||||||
defer iconMu.RUnlock()
|
|
||||||
icon, ok := iconCache.Load(key)
|
|
||||||
if ok && len(icon.Icon) > 0 {
|
|
||||||
log.Debug().
|
|
||||||
Str("key", key).
|
|
||||||
Msg("icon found in cache")
|
|
||||||
icon.LastAccess.Store(utils.TimeNow())
|
|
||||||
return &FetchResult{Icon: icon.Icon, contentType: icon.ContentType}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func storeIconCache(key string, result *FetchResult) {
|
|
||||||
icon := result.Icon
|
|
||||||
if len(icon) > maxIconSize {
|
|
||||||
log.Debug().Int("size", len(icon)).Msg("icon cache size exceeds max cache size")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
iconMu.Lock()
|
|
||||||
defer iconMu.Unlock()
|
|
||||||
|
|
||||||
entry := &cacheEntry{Icon: icon, ContentType: result.contentType}
|
|
||||||
entry.LastAccess.Store(time.Now())
|
|
||||||
iconCache.Store(key, entry)
|
|
||||||
log.Debug().Str("key", key).Int("size", len(icon)).Msg("stored icon cache")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *cacheEntry) IsExpired() bool {
|
|
||||||
return time.Since(e.LastAccess.Load()) > iconCacheTTL
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *cacheEntry) UnmarshalJSON(data []byte) error {
|
|
||||||
var tmp struct {
|
|
||||||
Icon []byte `json:"icon"`
|
|
||||||
ContentType string `json:"content_type,omitempty"`
|
|
||||||
LastAccess time.Time `json:"last_access"`
|
|
||||||
}
|
|
||||||
// check if data is json
|
|
||||||
if sonic.Valid(data) {
|
|
||||||
err := sonic.Unmarshal(data, &tmp)
|
|
||||||
// return only if unmarshal is successful
|
|
||||||
// otherwise fallback to base64
|
|
||||||
if err == nil {
|
|
||||||
e.Icon = tmp.Icon
|
|
||||||
e.ContentType = tmp.ContentType
|
|
||||||
e.LastAccess.Store(tmp.LastAccess)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// fallback to base64
|
|
||||||
icon, err := base64.StdEncoding.DecodeString(string(data))
|
|
||||||
if err == nil {
|
|
||||||
e.Icon = icon
|
|
||||||
e.LastAccess.Store(time.Now())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -62,10 +62,10 @@ func (w *Watcher) wakeFromHTTP(rw http.ResponseWriter, r *http.Request) (shouldN
|
|||||||
|
|
||||||
// handle favicon request
|
// handle favicon request
|
||||||
if isFaviconPath(r.URL.Path) {
|
if isFaviconPath(r.URL.Path) {
|
||||||
result := api.GetFavIconFromAlias(r.Context(), w.route.Name())
|
result, err := api.GetFavIconFromAlias(r.Context(), w.route.Name())
|
||||||
if !result.OK() {
|
if err != nil {
|
||||||
rw.WriteHeader(result.StatusCode)
|
rw.WriteHeader(result.StatusCode)
|
||||||
fmt.Fprint(rw, result.ErrMsg)
|
fmt.Fprint(rw, err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
rw.Header().Set("Content-Type", result.ContentType())
|
rw.Header().Set("Content-Type", result.ContentType())
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
|
|
||||||
"github.com/yusing/godoxy/agent/pkg/agent"
|
"github.com/yusing/godoxy/agent/pkg/agent"
|
||||||
"github.com/yusing/godoxy/agent/pkg/agentproxy"
|
"github.com/yusing/godoxy/agent/pkg/agentproxy"
|
||||||
"github.com/yusing/godoxy/internal/homepage"
|
|
||||||
"github.com/yusing/godoxy/internal/idlewatcher"
|
"github.com/yusing/godoxy/internal/idlewatcher"
|
||||||
"github.com/yusing/godoxy/internal/logging/accesslog"
|
"github.com/yusing/godoxy/internal/logging/accesslog"
|
||||||
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
|
gphttp "github.com/yusing/godoxy/internal/net/gphttp"
|
||||||
@@ -150,8 +149,6 @@ func (r *ReveseProxyRoute) Start(parent task.Parent) gperr.Error {
|
|||||||
routes.HTTP.Del(r)
|
routes.HTTP.Del(r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
r.task.OnCancel("reset_favicon", func() { homepage.PruneRouteIconCache(r) })
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user