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 拿到真实图标。
This commit is contained in:
JianFeeeee
2026-09-11 10:57:30 +08:00
parent e7ea091e19
commit 0a6199b240
3 changed files with 543 additions and 288 deletions

View File

@ -388,24 +388,15 @@ func resolveURL(base, href string) string {
}
// sniffExt 只返回 Dashy 可渲染的扩展名,以及需要二次处理的 .ico。
// 返回空串表示这份内容不能当图标用(例如 SPA 用 index.html 兜底的 200)。
// 返回空串表示这份内容不能当图标用。
//
// 判定以**正文特征**为准,不轻信 Content-Type:很多站点会把 HTML 兜底页
// 配上 image/svg+xml 之类的类型返回(正文才是事实)。
func sniffExt(data []byte, contentType, rawURL string) string {
ct := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
// 很多 SPA 对 /favicon.ico 也回 200 + text/html(把 index.html 吐回来),
// 这种必须先排掉,否则会存下一份 HTML 当图标。
switch ct {
case "text/html", "application/xhtml+xml", "text/plain":
case "text/html", "application/xhtml+xml", "text/plain", "application/json":
return ""
case "image/x-icon", "image/vnd.microsoft.icon":
return ".ico"
}
switch ct {
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "image/svg+xml":
return ".svg"
}
switch {
@ -416,26 +407,26 @@ func sniffExt(data []byte, contentType, rawURL string) string {
case bytes.HasPrefix(data, []byte("\x00\x00\x01\x00")):
return ".ico"
}
trimmed := bytes.TrimSpace(data)
if len(trimmed) > 0 && trimmed[0] == '<' {
head := bytes.ToLower(trimmed)
if len(head) > 512 {
head = head[:512]
}
// 必须根元素就是 <svg;只在别处出现 "svg" 字样的 HTML 不算。
if bytes.HasPrefix(head, []byte("<svg")) ||
(bytes.HasPrefix(head, []byte("<?xml")) && bytes.Contains(head, []byte("<svg"))) {
return ".svg"
}
}
if ext, ok := renderableByDashy(lowerExtOf(rawURL)); ok {
return ext // 兜底:类型识别失败但 URL 后缀是图片
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)

View File

@ -0,0 +1,264 @@
package main
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/png"
"testing"
)
// ── 图标格式白名单 ──
func TestRenderableByDashy(t *testing.T) {
// Dashy 的 isImage() 白名单只有 svg/png/jpg,其余一律渲染不出来。
cases := map[string]string{
".png": ".png",
".PNG": "", // 调用方负责先转小写
".svg": ".svg",
".jpg": ".jpg",
".jpeg": ".jpg",
".ico": "",
".gif": "",
".webp": "",
"": "",
}
for in, want := range cases {
got, ok := renderableByDashy(in)
if want == "" {
if ok {
t.Errorf("renderableByDashy(%q) = %q,true; want not renderable", in, got)
}
continue
}
if !ok || got != want {
t.Errorf("renderableByDashy(%q) = %q,%v; want %q,true", in, got, ok, want)
}
}
}
// ── sniffExt:绝不能把 SPA 的 HTML 兜底页当图标 ──
func TestSniffExtRejectsHTMLFallback(t *testing.T) {
// 真实场景:SPA 对 /favicon.ico 回 200 + text/html,body 是 index.html,
// 而 index.html 里恰好引用了 image/svg+xml(webui4frpc 就是这样)。
body := []byte(`<!DOCTYPE html><html><head><link rel="icon" href="/favicon.svg" type="image/svg+xml"><title>x</title></head></html>`)
if got := sniffExt(body, "text/html; charset=utf-8", "http://h/favicon.ico"); got != "" {
t.Fatalf("HTML fallback body must be rejected, got %q", got)
}
// 即使服务端把 Content-Type 谎报成 svg,只要正文不是 SVG 根元素也要拒。
if got := sniffExt(body, "image/svg+xml", "http://h/favicon.ico"); got == ".svg" {
t.Fatalf("non-SVG body must not be stored as .svg")
}
}
func TestSniffExtByMagicAndType(t *testing.T) {
pngMagic := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 16)...)
if got := sniffExt(pngMagic, "", "http://h/favicon.ico"); got != ".png" {
t.Errorf("png magic: got %q want .png", got)
}
jpgMagic := append([]byte("\xff\xd8\xff"), make([]byte, 16)...)
if got := sniffExt(jpgMagic, "", "http://h/favicon.ico"); got != ".jpg" {
t.Errorf("jpeg magic: got %q want .jpg", got)
}
svg := []byte(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>`)
if got := sniffExt(svg, "", "http://h/favicon.svg"); got != ".svg" {
t.Errorf("svg body: got %q want .svg", got)
}
// ico 要被识别出来交给后续转换,而不是直接落盘
icoMagic := []byte("\x00\x00\x01\x00\x01\x00")
if got := sniffExt(icoMagic, "", "http://h/favicon.ico"); got != ".ico" {
t.Errorf("ico magic: got %q want .ico", got)
}
// Dashy 渲染不了的格式直接拒绝
gif := append([]byte("GIF89a"), make([]byte, 16)...)
if got := sniffExt(gif, "", "http://h/favicon.gif"); got != "" {
t.Errorf("gif must be rejected (Dashy cannot render it), got %q", got)
}
webp := append([]byte("RIFF\x00\x00\x00\x00WEBP"), make([]byte, 8)...)
if got := sniffExt(webp, "", "http://h/favicon.webp"); got != "" {
t.Errorf("webp must be rejected, got %q", got)
}
}
// ── ICO 解析 ──
// buildICO 把若干条目打包成 ICO 容器。
func buildICO(entries ...[]byte) []byte {
var buf bytes.Buffer
buf.Write([]byte{0, 0, 1, 0})
_ = binary.Write(&buf, binary.LittleEndian, uint16(len(entries)))
offset := 6 + 16*len(entries)
for _, e := range entries {
buf.Write([]byte{16, 16, 0, 0}) // w,h,colors,reserved
_ = binary.Write(&buf, binary.LittleEndian, uint16(1))
_ = binary.Write(&buf, binary.LittleEndian, uint16(32))
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(e)))
_ = binary.Write(&buf, binary.LittleEndian, uint32(offset))
offset += len(e)
}
for _, e := range entries {
buf.Write(e)
}
return buf.Bytes()
}
func encodePNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, w, h))
img.SetNRGBA(0, 0, color.NRGBA{R: 1, G: 2, B: 3, A: 255})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("png encode: %v", err)
}
return buf.Bytes()
}
func TestPngFromICO(t *testing.T) {
small := encodePNG(t, 1, 1)
big := encodePNG(t, 16, 16)
ico := buildICO(small, big)
got, ok := pngFromICO(ico)
if !ok {
t.Fatal("expected an embedded png")
}
if !bytes.Equal(got, big) {
t.Fatalf("should pick the largest embedded png: got %d bytes want %d", len(got), len(big))
}
if _, ok := pngFromICO([]byte("not an ico")); ok {
t.Fatal("garbage must not yield a png")
}
}
// buildDIB 构造 ICO 内常见的 DIB 位图(32bpp,自下而上,行 4 字节对齐)。
func buildDIB(w, h int, rows [][]color.NRGBA) []byte {
var buf bytes.Buffer
hdr := make([]byte, 40)
binary.LittleEndian.PutUint32(hdr[0:4], 40)
binary.LittleEndian.PutUint32(hdr[4:8], uint32(w))
binary.LittleEndian.PutUint32(hdr[8:12], uint32(2*h)) // ICO 高度是两倍
binary.LittleEndian.PutUint16(hdr[12:14], 1)
binary.LittleEndian.PutUint16(hdr[14:16], 32)
buf.Write(hdr)
for _, row := range rows {
for _, c := range row {
buf.Write([]byte{c.B, c.G, c.R, c.A})
}
}
return buf.Bytes()
}
func decodePNGSize(t *testing.T, data []byte) image.Image {
t.Helper()
img, err := png.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode produced png: %v", err)
}
return img
}
func TestDibToPNG(t *testing.T) {
// DIB 自下而上:rows[0] 是图像最底下那一行。
topLeft := color.NRGBA{R: 10, G: 20, B: 30, A: 255}
bottomLeft := color.NRGBA{R: 200, G: 100, B: 50, A: 255}
rows := [][]color.NRGBA{
{bottomLeft, {R: 1, G: 1, B: 1, A: 255}},
{topLeft, {R: 2, G: 2, B: 2, A: 255}},
}
out, err := dibToPNG(buildDIB(2, 2, rows))
if err != nil {
t.Fatalf("dibToPNG: %v", err)
}
img := decodePNGSize(t, out)
if b := img.Bounds(); b.Dx() != 2 || b.Dy() != 2 {
t.Fatalf("size = %dx%d, want 2x2", b.Dx(), b.Dy())
}
r, g, b, a := img.At(0, 0).RGBA()
// 注意:解出的 PNG 是 NRGBA,At() 返回预乘值;这里 alpha=255 故直读高位
if uint8(r>>8) != topLeft.R || uint8(g>>8) != topLeft.G || uint8(b>>8) != topLeft.B || uint8(a>>8) != 255 {
t.Fatalf("pixel(0,0) = %d,%d,%d,%d; want %d,%d,%d,255",
r>>8, g>>8, b>>8, a>>8, topLeft.R, topLeft.G, topLeft.B)
}
}
func TestDibToPNGTreatsAllZeroAlphaAsOpaque(t *testing.T) {
// 老图标常把 alpha 全写 0;若照实解释会得到整张全透明图。
rows := [][]color.NRGBA{{{R: 9, G: 8, B: 7, A: 0}, {R: 9, G: 8, B: 7, A: 0}}}
out, err := dibToPNG(buildDIB(2, 1, rows))
if err != nil {
t.Fatalf("dibToPNG: %v", err)
}
img := decodePNGSize(t, out)
if _, _, _, a := img.At(0, 0).RGBA(); a>>8 != 255 {
t.Fatalf("all-zero alpha should be treated as opaque, got alpha=%d", a>>8)
}
}
func TestDibToPNGRejectsUnsupported(t *testing.T) {
hdr := make([]byte, 40)
binary.LittleEndian.PutUint32(hdr[0:4], 40)
binary.LittleEndian.PutUint32(hdr[4:8], 16)
binary.LittleEndian.PutUint32(hdr[8:12], 32)
binary.LittleEndian.PutUint16(hdr[14:16], 8) // 8bpp 不支持
if _, err := dibToPNG(hdr); err == nil {
t.Fatal("8bpp dib should be rejected")
}
if _, err := dibToPNG([]byte("short")); err == nil {
t.Fatal("short data should be rejected")
}
}
func TestDibToPNGFromICOPrefersLargest(t *testing.T) {
rows := [][]color.NRGBA{{{R: 1, G: 1, B: 1, A: 255}}}
small := buildDIB(1, 1, rows)
tallRows := [][]color.NRGBA{
{{R: 3, G: 3, B: 3, A: 255}, {R: 3, G: 3, B: 3, A: 255}},
{{R: 3, G: 3, B: 3, A: 255}, {R: 3, G: 3, B: 3, A: 255}},
}
large := buildDIB(2, 2, tallRows)
out, err := dibToPNGFromICO(buildICO(small, large))
if err != nil {
t.Fatalf("dibToPNGFromICO: %v", err)
}
if b := decodePNGSize(t, out).Bounds(); b.Dx() != 2 || b.Dy() != 2 {
t.Fatalf("should pick the largest entry, got %dx%d", b.Dx(), b.Dy())
}
}
// ── 文件名与段落名 ──
func TestSanitizeHostKey(t *testing.T) {
cases := map[string]string{
"example.com": "example.com",
"10.0.0.10:3000": "10.0.0.10_3000",
"example.com:8443": "example.com_8443",
"[::1]:8080": "___1__8080",
}
for in, want := range cases {
if got := sanitizeHostKey(in); got != want {
t.Errorf("sanitizeHostKey(%q) = %q, want %q", in, got, want)
}
}
}
func TestSectionNameMatching(t *testing.T) {
// 公开示例 conf.yml 用英文段落名,生产用中文;两者都必须能匹配。
for _, n := range []string{"常用服务", "Favorites", "favorites", "FAVORITES"} {
if !isFavSection(n) {
t.Errorf("isFavSection(%q) = false, want true", n)
}
}
for _, n := range []string{"历史访问", "History", "history", "HISTORY"} {
if !isHistSection(n) {
t.Errorf("isHistSection(%q) = false, want true", n)
}
}
for _, n := range []string{"Tools", "工具", "", "Favourite"} {
if isFavSection(n) || isHistSection(n) {
t.Errorf("section %q should not match favorites/history", n)
}
}
}

View File

@ -232,32 +232,32 @@ func normalizeURL(raw string) (string, error) {
// navItem 是 conf.yml 里一个导航项(任一 section)
type dashyNav struct {
title, desc, icon, url string
title, desc, icon, url string
}
// dashySection 是 conf.yml 里的一个 section
type dashySection struct {
name, icon string
collapsed bool
items []dashyNav
name, icon string
collapsed bool
items []dashyNav
}
// hostVisits 统计 history 中每个 host 的累计访问
func hostVisits() map[string]int {
mu.RLock()
defer mu.RUnlock()
out := map[string]int{}
for _, e := range history {
if u, err := url.Parse(e.URL); err == nil {
out[strings.ToLower(u.Hostname())] += e.TotalVisits
}
}
return out
mu.RLock()
defer mu.RUnlock()
out := map[string]int{}
for _, e := range history {
if u, err := url.Parse(e.URL); err == nil {
out[strings.ToLower(u.Hostname())] += e.TotalVisits
}
}
return out
}
// 段落名兼容中英文:公开示例 conf.yml 用英文名,生产实例用中文名。
const (
favSectionZh = "\u5e38\u7528\u670d\u52a1" // 常用服务
favSectionZh = "\u5e38\u7528\u670d\u52a1" // 常用服务
favSectionEn = "Favorites"
histSectionZh = "\u5386\u53f2\u8bbf\u95ee" // 历史访问
histSectionEn = "History"
@ -286,250 +286,250 @@ func extensionInstallItem() map[string]any {
}
// rebuildDashySections 读 conf.yml:
// 1) 收集除「历史访问」外所有导航项
// 2) 按各导航 host 在 history 的累计访问统计
// 3) 「常用服务」= 访问最高的 4 个导航(Gitea/代码托管恒置顶)
// 4) 「历史访问」= 非导航 host 的 top 20
// 1. 收集除「历史访问」外所有导航项
// 2. 按各导航 host 在 history 的累计访问统计
// 3. 「常用服务」= 访问最高的 4 个导航(Gitea/代码托管恒置顶)
// 4. 「历史访问」= 非导航 host 的 top 20
func rebuildDashySections() {
// 序列化重建:同步请求、启动、以及 favicon 抓完后的重试都会调到这里。
rebuildMu.Lock()
defer rebuildMu.Unlock()
// 序列化重建:同步请求、启动、以及 favicon 抓完后的重试都会调到这里。
rebuildMu.Lock()
defer rebuildMu.Unlock()
confFile := filepath.Join(dataDir, "conf.yml")
data, err := os.ReadFile(confFile)
if err != nil {
return
}
confFile := filepath.Join(dataDir, "conf.yml")
data, err := os.ReadFile(confFile)
if err != nil {
return
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
return
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
return
}
sectionsRaw, ok := cfg["sections"].([]any)
if !ok || len(sectionsRaw) == 0 {
return
}
sectionsRaw, ok := cfg["sections"].([]any)
if !ok || len(sectionsRaw) == 0 {
return
}
// 段落名匹配见 isFavSection / isHistSection(兼容中英文)
type secInfo struct {
raw map[string]any
name string
collapsed bool
}
secs := make([]secInfo, 0, len(sectionsRaw))
for _, s := range sectionsRaw {
m, ok := s.(map[string]any)
if !ok {
continue
}
name, _ := m["name"].(string)
coll := false
if c, ok := m["collapsed"].(bool); ok {
coll = c
}
secs = append(secs, secInfo{raw: m, name: name, collapsed: coll})
}
// 段落名匹配见 isFavSection / isHistSection(兼容中英文)
type secInfo struct {
raw map[string]any
name string
collapsed bool
}
secs := make([]secInfo, 0, len(sectionsRaw))
for _, s := range sectionsRaw {
m, ok := s.(map[string]any)
if !ok {
continue
}
name, _ := m["name"].(string)
coll := false
if c, ok := m["collapsed"].(bool); ok {
coll = c
}
secs = append(secs, secInfo{raw: m, name: name, collapsed: coll})
}
// 收集所有导航项(排除历史访问 section 本身),带 host 访问量
hv := hostVisits()
// 收集所有导航项(排除历史访问 section 本身),带 host 访问量
hv := hostVisits()
type cand struct {
nav dashyNav
host string
visits int
}
candidates := make([]cand, 0)
for _, si := range secs {
items, _ := si.raw["items"].([]any)
for _, it := range items {
m, ok := it.(map[string]any)
if !ok {
continue
}
title, _ := m["title"].(string)
desc, _ := m["description"].(string)
icon, _ := m["icon"].(string)
u, _ := m["url"].(string)
if u == "" {
continue
}
host := ""
if pu, err := url.Parse(u); err == nil {
host = strings.ToLower(pu.Hostname())
}
v := 0
if host != "" {
v = hv[host]
}
if isHistSection(si.name) {
continue
} // 所有导航项(含常用服务原本手动配置的)都进候选,
// 由访问量 + code-host 置顶决定最终 Top4
candidates = append(candidates, cand{
nav: dashyNav{title: title, desc: desc, icon: icon, url: u},
host: host, visits: v,
})
}
}
type cand struct {
nav dashyNav
host string
visits int
}
candidates := make([]cand, 0)
for _, si := range secs {
items, _ := si.raw["items"].([]any)
for _, it := range items {
m, ok := it.(map[string]any)
if !ok {
continue
}
title, _ := m["title"].(string)
desc, _ := m["description"].(string)
icon, _ := m["icon"].(string)
u, _ := m["url"].(string)
if u == "" {
continue
}
host := ""
if pu, err := url.Parse(u); err == nil {
host = strings.ToLower(pu.Hostname())
}
v := 0
if host != "" {
v = hv[host]
}
if isHistSection(si.name) {
continue
} // 所有导航项(含常用服务原本手动配置的)都进候选,
// 由访问量 + code-host 置顶决定最终 Top4
candidates = append(candidates, cand{
nav: dashyNav{title: title, desc: desc, icon: icon, url: u},
host: host, visits: v,
})
}
}
// 排序:按访问量降序;同量保持原序
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].visits > candidates[j].visits
})
// 排序:按访问量降序;同量保持原序
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].visits > candidates[j].visits
})
// 导航项若均无访问记录(首次/纯外站访问),保持常用服务原手动配置不动
updateFavorites := true
{
anyNav := false
for _, c := range candidates {
if c.visits > 0 {
anyNav = true
break
}
}
// 导航项都没有访问数据时不重排「常用服务」,保留手工配置;
// 但「历史访问」仍要正常生成 —— 只浏览站外站点时,那些历史恰恰是最该展示的。
updateFavorites = anyNav
}
// 导航项若均无访问记录(首次/纯外站访问),保持常用服务原手动配置不动
updateFavorites := true
{
anyNav := false
for _, c := range candidates {
if c.visits > 0 {
anyNav = true
break
}
}
// 导航项都没有访问数据时不重排「常用服务」,保留手工配置;
// 但「历史访问」仍要正常生成 —— 只浏览站外站点时,那些历史恰恰是最该展示的。
updateFavorites = anyNav
}
// host 去重:同 host 只保留访问最高的一条导航项
// (rebuild 前常用服务可能已有上一轮写入的副本,与其它 section 同 host 会重复)
{
seenH := map[string]bool{}
uniq := candidates[:0]
for _, c := range candidates {
if c.host == "" {
uniq = append(uniq, c)
continue
}
if seenH[c.host] {
continue
}
seenH[c.host] = true
uniq = append(uniq, c)
}
candidates = uniq
}
// host 去重:同 host 只保留访问最高的一条导航项
// (rebuild 前常用服务可能已有上一轮写入的副本,与其它 section 同 host 会重复)
{
seenH := map[string]bool{}
uniq := candidates[:0]
for _, c := range candidates {
if c.host == "" {
uniq = append(uniq, c)
continue
}
if seenH[c.host] {
continue
}
seenH[c.host] = true
uniq = append(uniq, c)
}
candidates = uniq
}
// 强制 Gitea/代码托管置顶:host 或标题含 gitea/git/code/代码/仓库
// 代码托管识别(独立函数便于单测)
var top []cand
var codeHost *cand
rest := make([]cand, 0, len(candidates))
for i := range candidates {
if codeHost == nil && isCodeHostVal(candidates[i].nav.title, candidates[i].host, candidates[i].nav.url) {
codeHost = &candidates[i]
} else {
rest = append(rest, candidates[i])
}
}
top = append(top, rest...)
// 组装:Gitea 第一(若存在),再加访问最高的其余导航,凑满 4
finalNavs := make([]dashyNav, 0, 4)
if codeHost != nil {
finalNavs = append(finalNavs, codeHost.nav)
}
for _, c := range top {
if len(finalNavs) >= 4 {
break
}
finalNavs = append(finalNavs, c.nav)
}
// 强制 Gitea/代码托管置顶:host 或标题含 gitea/git/code/代码/仓库
// 代码托管识别(独立函数便于单测)
var top []cand
var codeHost *cand
rest := make([]cand, 0, len(candidates))
for i := range candidates {
if codeHost == nil && isCodeHostVal(candidates[i].nav.title, candidates[i].host, candidates[i].nav.url) {
codeHost = &candidates[i]
} else {
rest = append(rest, candidates[i])
}
}
top = append(top, rest...)
// 组装:Gitea 第一(若存在),再加访问最高的其余导航,凑满 4
finalNavs := make([]dashyNav, 0, 4)
if codeHost != nil {
finalNavs = append(finalNavs, codeHost.nav)
}
for _, c := range top {
if len(finalNavs) >= 4 {
break
}
finalNavs = append(finalNavs, c.nav)
}
// 更新各 section
for _, si := range secs {
if updateFavorites && isFavSection(si.name) {
newItems := make([]any, 0, len(finalNavs))
for _, n := range finalNavs {
it := map[string]any{
"title": n.title, "description": n.desc,
"icon": n.icon, "url": n.url,
}
if n.desc == "" {
it["description"] = ""
}
newItems = append(newItems, it)
}
si.raw["items"] = newItems
}
}
// 更新各 section
for _, si := range secs {
if updateFavorites && isFavSection(si.name) {
newItems := make([]any, 0, len(finalNavs))
for _, n := range finalNavs {
it := map[string]any{
"title": n.title, "description": n.desc,
"icon": n.icon, "url": n.url,
}
if n.desc == "" {
it["description"] = ""
}
newItems = append(newItems, it)
}
si.raw["items"] = newItems
}
}
// 历史访问 = 非导航 host 的历史 top 20(排除已进常用/导航的)
if true {
navHosts := map[string]bool{}
for _, c := range candidates {
if c.host != "" {
navHosts[c.host] = true
}
}
for _, n := range finalNavs {
if pu, err := url.Parse(n.url); err == nil {
navHosts[strings.ToLower(pu.Hostname())] = true
}
}
mu.RLock()
entries := make([]*Entry, 0, len(history))
for _, e := range history {
entries = append(entries, e)
}
mu.RUnlock()
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].TotalVisits > entries[j].TotalVisits
})
histItems := make([]any, 0, 21)
// 首条固定为插件安装入口:未装插件(未注入 data-navi-ext)时保留,
// 已装的浏览器由 navi.js 隐藏。
histItems = append(histItems, extensionInstallItem())
for _, e := range entries {
if navHosts[strings.ToLower(hostOf(e.URL))] {
continue // 已是导航项,不重复进历史
}
title := e.Title
if title == "" {
title = e.URL
}
icon := "fas fa-globe"
if hp := hostPortOf(e.URL); hp != "" {
if name := cachedFavicon(hp); name != "" {
icon = name // 与手工导航项同一套本地图标机制
} else {
queueFavicon(hp) // 后台抓取,完成后会重建 conf.yml
}
}
histItems = append(histItems, map[string]any{
"title": title,
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
"icon": icon, "url": e.URL,
})
if len(histItems) >= 21 {
break
}
}
for _, si := range secs {
if isHistSection(si.name) {
si.raw["items"] = histItems
}
}
}
// 历史访问 = 非导航 host 的历史 top 20(排除已进常用/导航的)
if true {
navHosts := map[string]bool{}
for _, c := range candidates {
if c.host != "" {
navHosts[c.host] = true
}
}
for _, n := range finalNavs {
if pu, err := url.Parse(n.url); err == nil {
navHosts[strings.ToLower(pu.Hostname())] = true
}
}
mu.RLock()
entries := make([]*Entry, 0, len(history))
for _, e := range history {
entries = append(entries, e)
}
mu.RUnlock()
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].TotalVisits > entries[j].TotalVisits
})
histItems := make([]any, 0, 21)
// 首条固定为插件安装入口:未装插件(未注入 data-navi-ext)时保留,
// 已装的浏览器由 navi.js 隐藏。
histItems = append(histItems, extensionInstallItem())
for _, e := range entries {
if navHosts[strings.ToLower(hostOf(e.URL))] {
continue // 已是导航项,不重复进历史
}
title := e.Title
if title == "" {
title = e.URL
}
icon := "fas fa-globe"
if hp := hostPortOf(e.URL); hp != "" {
if name := cachedFavicon(hp); name != "" {
icon = name // 与手工导航项同一套本地图标机制
} else {
queueFavicon(hp) // 后台抓取,完成后会重建 conf.yml
}
}
histItems = append(histItems, map[string]any{
"title": title,
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
"icon": icon, "url": e.URL,
})
if len(histItems) >= 21 {
break
}
}
for _, si := range secs {
if isHistSection(si.name) {
si.raw["items"] = histItems
}
}
}
cfg["sections"] = sectionsRaw
out, err := yaml.Marshal(cfg)
if err != nil {
return
}
tmp := confFile + ".sort-tmp"
if err := os.WriteFile(tmp, out, 0644); err != nil {
return
}
os.Rename(tmp, confFile)
cfg["sections"] = sectionsRaw
out, err := yaml.Marshal(cfg)
if err != nil {
return
}
tmp := confFile + ".sort-tmp"
if err := os.WriteFile(tmp, out, 0644); err != nil {
return
}
os.Rename(tmp, confFile)
}
func hostOf(rawURL string) string {
if u, err := url.Parse(rawURL); err == nil {
return strings.ToLower(u.Hostname())
}
return ""
if u, err := url.Parse(rawURL); err == nil {
return strings.ToLower(u.Hostname())
}
return ""
}
func visitCount(rawURL string) int {
@ -586,21 +586,21 @@ func main() {
// isCodeHostVal 判断导航项是否代码托管:只认知名托管 host 与标题关键词,
// 避免 "git." 子串误匹配域名中段(如 your-dify.example)。
func isCodeHostVal(title, host, url string) bool {
lowT := strings.ToLower(title)
if strings.Contains(lowT, "gitea") || strings.Contains(lowT, "github") ||
strings.Contains(lowT, "gitlab") || strings.Contains(lowT, "代码") ||
strings.Contains(lowT, "仓库") || strings.Contains(lowT, "codeberg") {
return true
}
h := strings.ToLower(host)
if strings.HasPrefix(h, "git.") || strings.HasPrefix(h, "gitea.") ||
strings.HasPrefix(h, "gitlab.") || strings.HasPrefix(h, "codeberg.") ||
strings.HasPrefix(h, "github.") {
return true
}
if strings.Contains(h, "gitea") || strings.Contains(h, "github") ||
strings.Contains(h, "gitlab") || strings.Contains(h, "codeberg") {
return true
}
return false
lowT := strings.ToLower(title)
if strings.Contains(lowT, "gitea") || strings.Contains(lowT, "github") ||
strings.Contains(lowT, "gitlab") || strings.Contains(lowT, "代码") ||
strings.Contains(lowT, "仓库") || strings.Contains(lowT, "codeberg") {
return true
}
h := strings.ToLower(host)
if strings.HasPrefix(h, "git.") || strings.HasPrefix(h, "gitea.") ||
strings.HasPrefix(h, "gitlab.") || strings.HasPrefix(h, "codeberg.") ||
strings.HasPrefix(h, "github.") {
return true
}
if strings.Contains(h, "gitea") || strings.Contains(h, "github") ||
strings.Contains(h, "gitlab") || strings.Contains(h, "codeberg") {
return true
}
return false
}