package favicon import ( "bytes" "context" "errors" "io" "net/http" "net/url" "path" "strings" "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/homepage" "github.com/yusing/go-proxy/internal/logging" gphttp "github.com/yusing/go-proxy/internal/net/http" "github.com/yusing/go-proxy/internal/route/routes" route "github.com/yusing/go-proxy/internal/route/types" ) 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" } else { return "image/x-icon" } } return res.contentType } // 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 == "" { U.RespondError(w, U.ErrMissingKey("url or alias"), http.StatusBadRequest) return } if url != "" && alias != "" { U.RespondError(w, U.ErrInvalidKey("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 { U.RespondError(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()) U.WriteBody(w, fetchResult.icon) return } // try with route.Homepage.Icon r, ok := routes.GetHTTPRoute(alias) if !ok { U.RespondError(w, errors.New("no such route"), http.StatusNotFound) return } var result *fetchResult hp := r.HomepageConfig().GetOverride() if !hp.IsEmpty() && 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()) U.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 := U.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.ImageName)) } if !result.OK() { // fallback to parse html result = findIconSlow(r, req, uri) } if result.OK() { storeIconCache(key, result.icon) } return result } func findIconSlow(r route.HTTPRoute, req *http.Request, uri string) *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 if !strings.HasPrefix(uri, "/") { uri = "/" + uri } u, err := url.ParseRequestURI(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 != "" { loc = path.Clean(loc) if !strings.HasPrefix(loc, "/") { loc = "/" + loc } if loc == newReq.URL.Path { return &fetchResult{statusCode: http.StatusBadGateway, errMsg: "circular redirect"} } return findIconSlow(r, req, loc) } } 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, path.Clean(href)) } }