新增 history-server/favicon_test.go(仓库此前没有任何测试),覆盖: - Dashy 渲染白名单(只认 svg/png/jpg,jpeg 归一为 jpg) - sniffExt 必须拒绝 SPA 的 HTML 兜底页 - ICO 内嵌 PNG 提取(取最大条目) - DIB(BMP) 解码:尺寸、像素顺序(自下而上)、全 0 alpha 视为不透明、 不支持的位深/过短数据要报错 - 从多条目 ICO 中挑最大者 - host:port 文件名净化、中英文段落名匹配 写测试时当场抓到实现里的一个真漏洞:判定完全信任 Content-Type, 于是服务端谎报 image/svg+xml 而正文是 HTML 时会被存成 .svg。 现改为以**正文特征**为准(PNG/JPEG/ICO 魔数 + SVG 根元素校验), Content-Type 只用于排除明显不是图片的类型,避免误存。 实测回归:google/github/docker/HN/wikipedia 及内网服务 6/6 拿到真实图标。
650 lines
17 KiB
Go
650 lines
17 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"image/png"
|
||
"io"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// ── 历史条目的站点图标 ──
|
||
//
|
||
// history 里的站点不可能像手工导航项那样预先配好图标,若统一写死 fa-globe
|
||
// 会很难看。这里在后台抓取每个 host 的 favicon,缓存到 data 卷的 item-icons/;
|
||
// 缓存命中后 conf.yml 里写的是本地文件名,与手工图标走同一套解析。
|
||
//
|
||
// 关键约束:Dashy 的 isImage() 只把 svg/png/jpg 当图片渲染
|
||
// (源码里的白名单就是 ["svg","png","jpg"]),其它扩展名写进 conf.yml
|
||
// 会渲染不出任何东西 —— 所以只缓存这三种;.ico 先尝试抽出内嵌 PNG。
|
||
//
|
||
// 抓取顺序:<host>/favicon.ico → 首页 <link rel="icon"> 指向的图标。
|
||
// 每个 host 先试直连;仅当目标不是内网地址时才走代理。
|
||
const (
|
||
faviconMaxBytes = 512 << 10
|
||
faviconTimeout = 6 * time.Second
|
||
faviconRetryGap = 6 * time.Hour
|
||
faviconUA = "Mozilla/5.0 (compatible; HomeDashFavicon/1.0)"
|
||
)
|
||
|
||
// dashyIconExts 是 Dashy 能渲染的图标扩展名(顺序即查找优先级)。
|
||
var dashyIconExts = []string{".png", ".svg", ".jpg"}
|
||
|
||
var faviconProxy string
|
||
|
||
var (
|
||
faviconClients []*http.Client
|
||
faviconMu sync.Mutex
|
||
faviconQueued = map[string]bool{}
|
||
faviconAttempted = map[string]time.Time{}
|
||
faviconQueue = make(chan string, 128)
|
||
faviconSignal = make(chan struct{}, 1)
|
||
rebuildMu sync.Mutex
|
||
)
|
||
|
||
func faviconDir() string { return filepath.Join(dataDir, "item-icons") }
|
||
|
||
func newFaviconClient(proxy string) *http.Client {
|
||
tr := &http.Transport{
|
||
MaxIdleConns: 8,
|
||
IdleConnTimeout: 30 * time.Second,
|
||
DisableKeepAlives: true,
|
||
TLSHandshakeTimeout: 5 * time.Second,
|
||
}
|
||
if proxy != "" {
|
||
if pu, err := url.Parse(proxy); err == nil {
|
||
tr.Proxy = http.ProxyURL(pu)
|
||
}
|
||
}
|
||
return &http.Client{
|
||
Timeout: faviconTimeout,
|
||
Transport: tr,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
if len(via) >= 4 {
|
||
return errors.New("too many redirects")
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
func initFavicon() {
|
||
faviconProxy = detectFaviconProxy()
|
||
faviconClients = []*http.Client{newFaviconClient("")}
|
||
if faviconProxy != "" {
|
||
faviconClients = append(faviconClients, newFaviconClient(faviconProxy))
|
||
log.Printf("favicon: proxy fallback enabled")
|
||
}
|
||
os.MkdirAll(faviconDir(), 0755)
|
||
for i := 0; i < 3; i++ {
|
||
go faviconWorker()
|
||
}
|
||
go faviconRebuildLoop()
|
||
}
|
||
|
||
// detectFaviconProxy 优先用 NAVI_FAVICON_PROXY;未设置时直接复用
|
||
// SearXNG 的 outgoing.proxies —— 需要代理才能出网的环境里,
|
||
// 不必为了图标再多配一个变量(也就不用重建容器)。
|
||
func detectFaviconProxy() string {
|
||
if v := strings.TrimSpace(os.Getenv("NAVI_FAVICON_PROXY")); v != "" {
|
||
return v
|
||
}
|
||
paths := []string{os.Getenv("NAVI_SEARXNG_SETTINGS"), "/etc/searxng/settings.yml"}
|
||
for _, p := range paths {
|
||
if p == "" {
|
||
continue
|
||
}
|
||
data, err := os.ReadFile(p)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
var cfg struct {
|
||
Outgoing struct {
|
||
Proxies map[string]string `yaml:"proxies"`
|
||
} `yaml:"outgoing"`
|
||
}
|
||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||
continue
|
||
}
|
||
if v := strings.TrimSpace(cfg.Outgoing.Proxies["all://"]); v != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// clientsFor 返回该 host 可用的客户端。内网/回环地址只直连,
|
||
// 避免每个内网服务都白等一次代理超时。
|
||
func clientsFor(hostport string) []*http.Client {
|
||
host := hostport
|
||
if h, _, err := net.SplitHostPort(hostport); err == nil {
|
||
host = h
|
||
}
|
||
host = strings.Trim(host, "[]")
|
||
if ip := net.ParseIP(host); ip != nil && (ip.IsPrivate() || ip.IsLoopback()) {
|
||
return faviconClients[:1]
|
||
}
|
||
if !strings.Contains(host, ".") {
|
||
return faviconClients[:1]
|
||
}
|
||
return faviconClients
|
||
}
|
||
|
||
// sanitizeHostKey 把 host:port 变成安全文件名片段。
|
||
func sanitizeHostKey(hostport string) string {
|
||
var b strings.Builder
|
||
for _, r := range strings.ToLower(hostport) {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '-':
|
||
b.WriteRune(r)
|
||
default:
|
||
b.WriteByte('_')
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// cachedFavicon 返回已缓存的图标文件名(相对 item-icons/),未命中返回空串。
|
||
// 只认 Dashy 能渲染的格式,历史遗留的 .ico 缓存会被忽略并重新抓取。
|
||
func cachedFavicon(hostport string) string {
|
||
if hostport == "" {
|
||
return ""
|
||
}
|
||
key := sanitizeHostKey(hostport)
|
||
for _, ext := range dashyIconExts {
|
||
name := "favicon-" + key + ext
|
||
if st, err := os.Stat(filepath.Join(faviconDir(), name)); err == nil && st.Size() > 0 {
|
||
return name
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// queueFavicon 把 host 排进后台抓取队列(幂等、非阻塞)。
|
||
func queueFavicon(hostport string) {
|
||
if hostport == "" || cachedFavicon(hostport) != "" {
|
||
return
|
||
}
|
||
faviconMu.Lock()
|
||
if faviconQueued[hostport] {
|
||
faviconMu.Unlock()
|
||
return
|
||
}
|
||
if t, ok := faviconAttempted[hostport]; ok && time.Since(t) < faviconRetryGap {
|
||
faviconMu.Unlock()
|
||
return
|
||
}
|
||
faviconQueued[hostport] = true
|
||
faviconMu.Unlock()
|
||
|
||
select {
|
||
case faviconQueue <- hostport:
|
||
default:
|
||
faviconMu.Lock()
|
||
delete(faviconQueued, hostport)
|
||
faviconMu.Unlock()
|
||
}
|
||
}
|
||
|
||
func faviconWorker() {
|
||
for hostport := range faviconQueue {
|
||
err := fetchFavicon(hostport)
|
||
faviconMu.Lock()
|
||
delete(faviconQueued, hostport)
|
||
faviconAttempted[hostport] = time.Now()
|
||
faviconMu.Unlock()
|
||
if err != nil {
|
||
log.Printf("favicon: %s: %v", hostport, err)
|
||
continue
|
||
}
|
||
select {
|
||
case faviconSignal <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
}
|
||
|
||
// faviconRebuildLoop 把短时间内完成的多个抓取合并成一次 conf.yml 重建。
|
||
func faviconRebuildLoop() {
|
||
for range faviconSignal {
|
||
time.Sleep(1500 * time.Millisecond)
|
||
drain:
|
||
for {
|
||
select {
|
||
case <-faviconSignal:
|
||
default:
|
||
break drain
|
||
}
|
||
}
|
||
rebuildDashySections()
|
||
}
|
||
}
|
||
|
||
func httpGet(rawURL string, max int, clients []*http.Client) ([]byte, string, error) {
|
||
var lastErr error
|
||
for _, c := range clients {
|
||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
req.Header.Set("User-Agent", faviconUA)
|
||
req.Header.Set("Accept", "*/*")
|
||
resp, err := c.Do(req)
|
||
if err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
resp.Body.Close()
|
||
lastErr = fmt.Errorf("http %d", resp.StatusCode)
|
||
continue
|
||
}
|
||
data, err := io.ReadAll(io.LimitReader(resp.Body, int64(max)+1))
|
||
ct := resp.Header.Get("Content-Type")
|
||
resp.Body.Close()
|
||
if err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
if len(data) > max {
|
||
lastErr = errors.New("body too large")
|
||
continue
|
||
}
|
||
return data, ct, nil
|
||
}
|
||
if lastErr == nil {
|
||
lastErr = errors.New("no http client")
|
||
}
|
||
return nil, "", lastErr
|
||
}
|
||
|
||
// httpGetTruncated 用于抓 HTML:只需要前若干字节里的 <head>,
|
||
// 页面比上限大不应该算失败(GitHub 首页就超 512KB)。
|
||
func httpGetTruncated(rawURL string, max int, clients []*http.Client) ([]byte, string, error) {
|
||
var lastErr error
|
||
for _, c := range clients {
|
||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
req.Header.Set("User-Agent", faviconUA)
|
||
req.Header.Set("Accept", "text/html,*/*;q=0.8")
|
||
resp, err := c.Do(req)
|
||
if err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
resp.Body.Close()
|
||
lastErr = fmt.Errorf("http %d", resp.StatusCode)
|
||
continue
|
||
}
|
||
data, _ := io.ReadAll(io.LimitReader(resp.Body, int64(max)))
|
||
ct := resp.Header.Get("Content-Type")
|
||
resp.Body.Close()
|
||
return data, ct, nil
|
||
}
|
||
if lastErr == nil {
|
||
lastErr = errors.New("no http client")
|
||
}
|
||
return nil, "", lastErr
|
||
}
|
||
|
||
var (
|
||
iconLinkRe = regexp.MustCompile(`(?is)<link\s+[^>]*rel\s*=\s*["']?[^"'>]*icon[^"'>]*["']?[^>]*>`)
|
||
hrefRe = regexp.MustCompile(`(?is)href\s*=\s*["']([^"']+)["']`)
|
||
)
|
||
|
||
// findIconHref 从首页 HTML 里挑出最合适的图标地址。
|
||
func findIconHref(pageURL string, clients []*http.Client) (string, error) {
|
||
data, ct, err := httpGetTruncated(pageURL, 384<<10, clients)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
lower := strings.ToLower(ct)
|
||
if ct != "" && !strings.Contains(lower, "html") {
|
||
return "", fmt.Errorf("not html: %s", ct)
|
||
}
|
||
best, bestScore := "", 0
|
||
for _, tag := range iconLinkRe.FindAllString(string(data), -1) {
|
||
m := hrefRe.FindStringSubmatch(tag)
|
||
if len(m) < 2 {
|
||
continue
|
||
}
|
||
href := strings.TrimSpace(m[1])
|
||
if href == "" || strings.HasPrefix(href, "data:") {
|
||
continue
|
||
}
|
||
low := strings.ToLower(tag)
|
||
score := 0
|
||
switch {
|
||
case strings.Contains(low, "apple-touch-icon"):
|
||
score = 1
|
||
case strings.Contains(low, "icon"):
|
||
score = 2
|
||
}
|
||
// 同分时优先 Dashy 能渲染的格式
|
||
if ext := lowerExtOf(href); ext != "" {
|
||
if _, ok := renderableByDashy(ext); ok {
|
||
score += 2
|
||
}
|
||
}
|
||
if score > bestScore {
|
||
best, bestScore = href, score
|
||
}
|
||
}
|
||
if best == "" {
|
||
return "", errors.New("no icon link")
|
||
}
|
||
return best, nil
|
||
}
|
||
|
||
func lowerExtOf(raw string) string {
|
||
if u, err := url.Parse(raw); err == nil {
|
||
return strings.ToLower(filepath.Ext(u.Path))
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func renderableByDashy(ext string) (string, bool) {
|
||
switch ext {
|
||
case ".png", ".jpg", ".jpeg":
|
||
if ext == ".jpeg" {
|
||
return ".jpg", true
|
||
}
|
||
return ext, true
|
||
case ".svg":
|
||
return ".svg", true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func resolveURL(base, href string) string {
|
||
bu, err := url.Parse(base)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
hu, err := url.Parse(href)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return bu.ResolveReference(hu).String()
|
||
}
|
||
|
||
// sniffExt 只返回 Dashy 可渲染的扩展名,以及需要二次处理的 .ico。
|
||
// 返回空串表示这份内容不能当图标用。
|
||
//
|
||
// 判定以**正文特征**为准,不轻信 Content-Type:很多站点会把 HTML 兜底页
|
||
// 配上 image/svg+xml 之类的类型返回(正文才是事实)。
|
||
func sniffExt(data []byte, contentType, rawURL string) string {
|
||
ct := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
|
||
switch ct {
|
||
case "text/html", "application/xhtml+xml", "text/plain", "application/json":
|
||
return ""
|
||
}
|
||
|
||
switch {
|
||
case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):
|
||
return ".png"
|
||
case bytes.HasPrefix(data, []byte("\xff\xd8\xff")):
|
||
return ".jpg"
|
||
case bytes.HasPrefix(data, []byte("\x00\x00\x01\x00")):
|
||
return ".ico"
|
||
}
|
||
if looksLikeSVG(data) {
|
||
return ".svg"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// looksLikeSVG 要求根元素就是 <svg>;只在别处出现 "svg" 字样的 HTML 不算。
|
||
func looksLikeSVG(data []byte) bool {
|
||
d := bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})) // 去 BOM
|
||
if len(d) == 0 || d[0] != '<' {
|
||
return false
|
||
}
|
||
head := bytes.ToLower(d)
|
||
if len(head) > 512 {
|
||
head = head[:512]
|
||
}
|
||
return bytes.HasPrefix(head, []byte("<svg")) ||
|
||
(bytes.HasPrefix(head, []byte("<?xml")) && bytes.Contains(head, []byte("<svg")))
|
||
}
|
||
|
||
// icoEntries 返回 ICO 内每个图像条目的原始数据。
|
||
func icoEntries(data []byte) [][]byte {
|
||
// ICONDIR: reserved(2)=0, type(2)=1, count(2)
|
||
if len(data) < 6 || data[0] != 0 || data[1] != 0 || data[2] != 1 || data[3] != 0 {
|
||
return nil
|
||
}
|
||
count := int(binary.LittleEndian.Uint16(data[4:6]))
|
||
if count <= 0 || len(data) < 6+16*count {
|
||
return nil
|
||
}
|
||
out := make([][]byte, 0, count)
|
||
for i := 0; i < count; i++ {
|
||
off := 6 + 16*i
|
||
size := binary.LittleEndian.Uint32(data[off+8 : off+12])
|
||
start := binary.LittleEndian.Uint32(data[off+12 : off+16])
|
||
if size == 0 || int(start) >= len(data) || int(start)+int(size) > len(data) {
|
||
continue
|
||
}
|
||
out = append(out, data[start:int(start)+int(size)])
|
||
}
|
||
return out
|
||
}
|
||
|
||
// pngFromICO 抽出 ICO 里内嵌的 PNG(现代 ICO 常常直接内嵌 PNG 数据)。
|
||
func pngFromICO(data []byte) ([]byte, bool) {
|
||
var best []byte
|
||
for _, e := range icoEntries(data) {
|
||
if bytes.HasPrefix(e, []byte("\x89PNG\r\n\x1a\n")) && len(e) > len(best) {
|
||
best = e
|
||
}
|
||
}
|
||
if best == nil {
|
||
return nil, false
|
||
}
|
||
return best, true
|
||
}
|
||
|
||
// dibToPNG 把 ICO 里的传统 DIB(BMP)位图转成 PNG。
|
||
// 这样 Google 这类只给老式 .ico 的站点也能有图标 ——
|
||
// Dashy 不认 .ico,只能自己转。
|
||
func dibToPNG(dib []byte) ([]byte, error) {
|
||
if len(dib) < 40 {
|
||
return nil, errors.New("short dib header")
|
||
}
|
||
hdrSize := int(binary.LittleEndian.Uint32(dib[0:4]))
|
||
if hdrSize < 40 || hdrSize > len(dib) {
|
||
return nil, errors.New("bad dib header size")
|
||
}
|
||
width := int(int32(binary.LittleEndian.Uint32(dib[4:8])))
|
||
dblHeight := int(int32(binary.LittleEndian.Uint32(dib[8:12])))
|
||
bitCount := int(binary.LittleEndian.Uint16(dib[14:16]))
|
||
compression := binary.LittleEndian.Uint32(dib[16:20])
|
||
height := dblHeight / 2 // ICO 里的高度是实际高度的两倍(含 AND 掩码)
|
||
if width <= 0 || height <= 0 || width > 4096 || height > 4096 {
|
||
return nil, errors.New("bad dib dimensions")
|
||
}
|
||
if compression != 0 {
|
||
return nil, errors.New("compressed dib unsupported")
|
||
}
|
||
if bitCount != 32 && bitCount != 24 {
|
||
return nil, fmt.Errorf("unsupported dib bitcount %d", bitCount)
|
||
}
|
||
|
||
bpp := bitCount / 8
|
||
stride := ((width*bpp + 3) / 4) * 4
|
||
pixels := dib[hdrSize:]
|
||
if len(pixels) < stride*height {
|
||
return nil, errors.New("short dib pixel data")
|
||
}
|
||
|
||
// 有些老图标 alpha 全为 0;那样整张图会变全透明,此时按不透明处理。
|
||
alphaUsed := false
|
||
if bpp == 4 {
|
||
for y := 0; y < height && !alphaUsed; y++ {
|
||
row := pixels[(height-1-y)*stride:]
|
||
for x := 0; x < width; x++ {
|
||
if row[x*4+3] != 0 {
|
||
alphaUsed = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
img := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||
for y := 0; y < height; y++ {
|
||
row := pixels[(height-1-y)*stride:] // DIB 自下而上
|
||
for x := 0; x < width; x++ {
|
||
i := x * bpp
|
||
b, g, r := row[i], row[i+1], row[i+2]
|
||
a := uint8(255)
|
||
if bpp == 4 && alphaUsed {
|
||
a = row[i+3]
|
||
}
|
||
img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a})
|
||
}
|
||
}
|
||
|
||
var buf bytes.Buffer
|
||
if err := png.Encode(&buf, img); err != nil {
|
||
return nil, err
|
||
}
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
// dibToPNGFromICO 逐个条目尝试解码,优先取像素数据最多的(分辨率最高)。
|
||
func dibToPNGFromICO(data []byte) ([]byte, error) {
|
||
entries := icoEntries(data)
|
||
if len(entries) == 0 {
|
||
return nil, errors.New("no ico entries")
|
||
}
|
||
sort.Slice(entries, func(i, j int) bool { return len(entries[i]) > len(entries[j]) })
|
||
var lastErr error
|
||
for _, e := range entries {
|
||
out, err := dibToPNG(e)
|
||
if err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
return out, nil
|
||
}
|
||
if lastErr == nil {
|
||
lastErr = errors.New("no decodable ico entry")
|
||
}
|
||
return nil, lastErr
|
||
}
|
||
|
||
func saveFavicon(hostport string, data []byte, ext string) error {
|
||
name := "favicon-" + sanitizeHostKey(hostport) + ext
|
||
dir := faviconDir()
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
return err
|
||
}
|
||
dst := filepath.Join(dir, name)
|
||
tmp := dst + ".tmp"
|
||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmp, dst)
|
||
}
|
||
|
||
// storeIcon 落盘;ICO 会先尝试抽内嵌 PNG,抽不出就放弃(避免存下 Dashy 渲染不了的格式)。
|
||
func storeIcon(hostport string, data []byte, ext string) error {
|
||
if _, ok := renderableByDashy(ext); ok {
|
||
target, _ := renderableByDashy(ext)
|
||
return saveFavicon(hostport, data, target)
|
||
}
|
||
if ext == ".ico" {
|
||
if png, ok := pngFromICO(data); ok {
|
||
return saveFavicon(hostport, png, ".png")
|
||
}
|
||
pngData, err := dibToPNGFromICO(data)
|
||
if err != nil {
|
||
return fmt.Errorf("ico not usable: %w", err)
|
||
}
|
||
return saveFavicon(hostport, pngData, ".png")
|
||
}
|
||
return fmt.Errorf("unsupported icon type %q", ext)
|
||
}
|
||
|
||
func fetchFavicon(hostport string) error {
|
||
if hostport == "" || strings.ContainsAny(hostport, "/\\ \t") {
|
||
return errors.New("invalid host")
|
||
}
|
||
clients := clientsFor(hostport)
|
||
var lastErr error
|
||
|
||
for _, scheme := range []string{"https", "http"} {
|
||
base := scheme + "://" + hostport
|
||
icoURL := base + "/favicon.ico"
|
||
|
||
if data, ct, err := httpGet(icoURL, faviconMaxBytes, clients); err == nil {
|
||
if ext := sniffExt(data, ct, icoURL); ext != "" {
|
||
if err := storeIcon(hostport, data, ext); err == nil {
|
||
return nil
|
||
} else {
|
||
lastErr = err
|
||
}
|
||
} else {
|
||
lastErr = errors.New("favicon.ico is not an image")
|
||
}
|
||
} else {
|
||
lastErr = err
|
||
}
|
||
|
||
href, err := findIconHref(base+"/", clients)
|
||
if err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
target := resolveURL(base+"/", href)
|
||
if target == "" {
|
||
continue
|
||
}
|
||
data, ct, err := httpGet(target, faviconMaxBytes, clients)
|
||
if err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
ext := sniffExt(data, ct, target)
|
||
if ext == "" {
|
||
lastErr = errors.New("declared icon is not a usable image")
|
||
continue
|
||
}
|
||
if err := storeIcon(hostport, data, ext); err != nil {
|
||
lastErr = err
|
||
continue
|
||
}
|
||
return nil
|
||
}
|
||
return lastErr
|
||
}
|
||
|
||
// hostPortOf 返回 host[:port](内网不同端口的服务要区分开)。
|
||
func hostPortOf(rawURL string) string {
|
||
if u, err := url.Parse(rawURL); err == nil && u.Host != "" {
|
||
return strings.ToLower(u.Host)
|
||
}
|
||
return ""
|
||
}
|