package iconfetch import ( "bytes" "context" "crypto/tls" "errors" "fmt" "io" "math" "mime" "net/http" "net/url" "slices" "strings" "time" "github.com/PuerkitoBio/goquery" "github.com/gin-gonic/gin" "github.com/vincent-petithory/dataurl" "github.com/yusing/godoxy/internal/homepage/icons" apitypes "github.com/yusing/goutils/apitypes" "github.com/yusing/goutils/cache" httputils "github.com/yusing/goutils/http" strutils "github.com/yusing/goutils/strings" ) type Result struct { Icon []byte StatusCode int contentType string } // @name IconFetchResult func FetchResultWithErrorf(statusCode int, msgFmt string, args ...any) (Result, error) { return Result{StatusCode: statusCode}, fmt.Errorf(msgFmt, args...) } func FetchResultOK(icon []byte, contentType string) (Result, error) { return Result{Icon: icon, contentType: contentType}, nil } func GinError(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 *Result) ContentType() string { if res.contentType == "" { if bytes.HasPrefix(res.Icon, []byte(" maxRedirectDepth { return FetchResultWithErrorf(http.StatusBadGateway, "too many redirects") } ctx, cancel := context.WithTimeoutCause(ctx, faviconFetchTimeout, errors.New("favicon request timeout")) defer cancel() newReq, err := http.NewRequestWithContext(ctx, http.MethodGet, r.TargetURL().String(), nil) if err != nil { return FetchResultWithErrorf(http.StatusInternalServerError, "cannot create request: %w", err) } newReq.Header.Set("Accept-Encoding", "identity") // disable compression u, err := url.ParseRequestURI(strutils.SanitizeURI(uri)) if err != nil { return FetchResultWithErrorf(http.StatusInternalServerError, "cannot parse uri: %w", err) } 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 FetchResultWithErrorf(http.StatusBadGateway, "connection error") default: if loc := c.Header().Get("Location"); loc != "" { loc = strutils.SanitizeURI(loc) if loc == "/" || loc == newReq.URL.Path || slices.Contains(stack, loc) { return FetchResultWithErrorf(http.StatusBadGateway, "circular redirect") } // append current path to stack // handles redirect to the same path with different query return findIconSlow(ctx, r, loc, append(stack, newReq.URL.Path)) } } return FetchResultWithErrorf(c.status, "upstream error: status %d, %s", c.status, c.data) } // return icon data if !httputils.GetContentType(c.header).IsHTML() { return FetchResultOK(c.data, 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 FetchResultWithErrorf(http.StatusInternalServerError, "failed to parse html: %w", err) } ele := doc.Find("head > link[rel=icon]").First() if ele.Length() == 0 { return FetchResultWithErrorf(http.StatusNotFound, "icon element not found") } href := ele.AttrOr("href", "") if href == "" { return FetchResultWithErrorf(http.StatusNotFound, "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 FetchResultWithErrorf(http.StatusInternalServerError, "failed to decode favicon: %w", err) } return FetchResultOK(dataURI.Data, dataURI.ContentType()) } switch { case strings.HasPrefix(href, "http://"), strings.HasPrefix(href, "https://"): return FetchIconAbsolute(ctx, href) default: return findIconSlow(ctx, r, href, append(stack, newReq.URL.Path)) } }