Files
HomeDash/history-server/favicon.go
JianFeeeee 0a6199b240 test(history): cover favicon/ICO logic; base format detection on body, not Content-Type
新增 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 拿到真实图标。
2026-09-11 10:57:30 +08:00

650 lines
17 KiB
Go
Raw 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"
"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 ""
}