Files
HomeDash/history-server/favicon.go
HomeDash Bot b256a8d7f1 fix(favicon): 失败重试间隔不再挡住"缓存文件丢失后的重抓"
实测:删掉 item-icons 里三个被污染的图标后,rebuild 不再重抓,图标永久缺失
(只有重启容器才恢复)。根因:faviconWorker 在**每次**尝试后都写 faviconAttempted,
而 queueFavicon 会用 faviconRetryGap 挡住"近期尝试过"的 host —— 于是
cachedFavicon() 为空(文件确实不存在)也照样被跳过。

修法:成功时 delete(faviconAttempted, hostport),只给失败保留节流记录。
这样缓存文件被手工清理或数据卷恢复后,下一次 rebuild 就能立即重抓。
2026-09-13 09:02:50 +08:00

725 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"bytes"
"crypto/tls"
"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)
if err != nil {
// 只给**失败**留节流记录
faviconAttempted[hostport] = time.Now()
} else {
// 成功则不留下尝试记录:这样日后缓存文件被删(手工清理、数据卷恢复)时,
// 下一次 rebuild 能立刻重抓,而不是被 faviconRetryGap 永久挡住
// (实测踩到:删掉缓存的三个图标后一直不再抓,只有重启容器才会重抓)。
delete(faviconAttempted, hostport)
}
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()
}
}
// httpGet 抓取图标;hostHeader 非空时用作 Host 头(内部上游要按对外域名做
// 虚拟主机路由,例如 gitea/pi 这类按域名分站的程序)。
func httpGet(rawURL string, max int, clients []*http.Client, hostHeader string) ([]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", "*/*")
if hostHeader != "" {
req.Host = hostHeader
}
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, hostHeader string) ([]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")
if hostHeader != "" {
req.Host = hostHeader
}
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, hostHeader string) (string, error) {
data, ct, err := httpGetTruncated(pageURL, 384<<10, clients, hostHeader)
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)
}
// gatewayUpstream 把对外域名映射到路由表(gateway.yml)里的内部上游地址。
//
// 为什么需要它:受统一登录保护(requireLogin)的域名,直接抓 https://<域名>/favicon.ico
// 会先撞上门户鉴权;再加上网关曾给每个 server 块无条件写了
//
// location = /favicon.ico { auth_request off; try_files $uri =404; }
//
// (在门户自己的静态目录里找文件),于是所有被代理域名的 favicon 都变成门户图标 ——
// 实测 pi / ittools / vikunja 三个站点存下了**同一张**门户图标。绕过门户直接问内部服务才对。
func gatewayUpstream(hostport string) string {
if hostport == "" || strings.Contains(hostport, ":") {
return "" // 带端口的不是路由域名(路由只按域名匹配)
}
data, err := os.ReadFile(filepath.Join(dataDir, "gateway.yml"))
if err != nil {
return ""
}
var cfg struct {
Routes []struct {
Host string `yaml:"host"`
Upstream string `yaml:"upstream"`
} `yaml:"routes"`
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return ""
}
want := strings.ToLower(strings.TrimSpace(hostport))
for _, r := range cfg.Routes {
if strings.ToLower(strings.TrimSpace(r.Host)) == want {
return strings.TrimSpace(r.Upstream)
}
}
return ""
}
// faviconInternalClient 抓内部上游专用:不走代理,并容忍自签证书
// (cockpit / fnOS 这类上游本身就是 https + 自签,走默认校验必然失败)。
var faviconInternalClient = func() *http.Client {
tr := &http.Transport{
DisableKeepAlives: true,
TLSHandshakeTimeout: 5 * time.Second,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
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
},
}
}()
// fetchIconFrom 在某个 base(对外地址或内部上游)上按
// /favicon.ico → 首页 <link rel="icon"> 的顺序取图标。
func fetchIconFrom(hostport, base, hostHeader string, clients []*http.Client) error {
icoURL := base + "/favicon.ico"
if data, ct, err := httpGet(icoURL, faviconMaxBytes, clients, hostHeader); err == nil {
if ext := sniffExt(data, ct, icoURL); ext != "" {
return storeIcon(hostport, data, ext)
}
return errors.New("favicon.ico is not an image")
}
href, err := findIconHref(base+"/", clients, hostHeader)
if err != nil {
return err
}
target := resolveURL(base+"/", href)
if target == "" {
return errors.New("unresolvable icon href")
}
data, ct, err := httpGet(target, faviconMaxBytes, clients, hostHeader)
if err != nil {
return err
}
ext := sniffExt(data, ct, target)
if ext == "" {
return errors.New("declared icon is not a usable image")
}
return storeIcon(hostport, data, ext)
}
func fetchFavicon(hostport string) error {
if hostport == "" || strings.ContainsAny(hostport, "/\\ \t") {
return errors.New("invalid host")
}
var lastErr error
// ① 先走内部上游:绕过门户统一登录,也避开"门户图标顶掉上游图标"的老问题
if up := gatewayUpstream(hostport); up != "" {
if err := fetchIconFrom(hostport, up, hostport, []*http.Client{faviconInternalClient}); err == nil {
return nil
} else {
lastErr = err
}
}
// ② 回落对外地址(站外站点、或路由表里没有的 host)
clients := clientsFor(hostport)
for _, scheme := range []string{"https", "http"} {
if err := fetchIconFrom(hostport, scheme+"://"+hostport, "", clients); err == nil {
return nil
} else {
lastErr = err
}
}
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 ""
}