实测:删掉 item-icons 里三个被污染的图标后,rebuild 不再重抓,图标永久缺失 (只有重启容器才恢复)。根因:faviconWorker 在**每次**尝试后都写 faviconAttempted, 而 queueFavicon 会用 faviconRetryGap 挡住"近期尝试过"的 host —— 于是 cachedFavicon() 为空(文件确实不存在)也照样被跳过。 修法:成功时 delete(faviconAttempted, hostport),只给失败保留节流记录。 这样缓存文件被手工清理或数据卷恢复后,下一次 rebuild 就能立即重抓。
725 lines
20 KiB
Go
725 lines
20 KiB
Go
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 ""
|
||
}
|