历史条目的图标原先写死 fas fa-globe,整栏都是地球图标。 - 新增 history-server/favicon.go:后台抓取每个 host 的 favicon 并缓存到 data/item-icons/,命中后 conf.yml 写本地文件名,与手工图标同一套解析。 抓取顺序 <host>/favicon.ico → 首页 <link rel=icon>;3 个 worker, 失败 6 小时后再试;完成后合并触发一次 conf.yml 重建。 - 只缓存 Dashy 能渲染的格式:其 isImage() 白名单是 [svg,png,jpg], 写 .ico 进去完全渲染不出来。因此 .ico 先抽内嵌 PNG,若是传统 BMP/DIB 编码则解码后转 PNG(Google 这类站点只给老式 .ico)。 - 内网地址用 host:port 区分(不同端口的服务不再共用一个图标),且只直连; 外网站点直连失败时自动复用 SearXNG outgoing.proxies 的代理, 也可用 NAVI_FAVICON_PROXY 覆盖。 - 修掉一个真实缺陷:导航项都没有访问数据时,原来会提前 return 并把 「历史访问」清成只剩安装入口 —— 只浏览站外站点时真实历史会整个消失。 现在只冻结「常用服务」,历史照常生成。 - Dockerfile 去掉 # syntax=docker/dockerfile:1:该指令会让 BuildKit 去 pull docker/dockerfile 前端镜像,无外网环境直接构建失败;本文件未用 BuildKit 语法。 实测:6/6 站点(含 google.com 的 DIB 型 ico、github.com、docker.com、 news.ycombinator.com 与两个内网服务)均得到真实图标,页面 naturalWidth>0。
607 lines
17 KiB
Go
607 lines
17 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
var (
|
||
dataDir = envOr("NAVI_DATA_DIR", "/app/data")
|
||
historyFile = filepath.Join(dataDir, "history.json")
|
||
syncToken = os.Getenv("NAVI_SYNC_TOKEN")
|
||
adminToken = os.Getenv("NAVI_ADMIN_TOKEN")
|
||
mu sync.RWMutex
|
||
history = map[string]*Entry{}
|
||
)
|
||
|
||
type Entry struct {
|
||
URL string `json:"url"`
|
||
Title string `json:"title"`
|
||
TotalVisits int `json:"total_visits"`
|
||
LastVisit float64 `json:"last_visit"`
|
||
Devices []string `json:"devices"`
|
||
}
|
||
|
||
type SyncItem struct {
|
||
URL string `json:"url"`
|
||
Title string `json:"title"`
|
||
Device string `json:"device"`
|
||
Timestamp float64 `json:"timestamp"`
|
||
VisitCount int `json:"visit_count"`
|
||
}
|
||
|
||
type SyncRequest struct {
|
||
Items []SyncItem `json:"items"`
|
||
}
|
||
|
||
func envOr(k, def string) string {
|
||
if v := os.Getenv(k); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
func load() {
|
||
data, err := os.ReadFile(historyFile)
|
||
if err != nil {
|
||
return
|
||
}
|
||
var entries []*Entry
|
||
if err := json.Unmarshal(data, &entries); err != nil {
|
||
log.Printf("load error: %v", err)
|
||
return
|
||
}
|
||
for _, e := range entries {
|
||
history[e.URL] = e
|
||
}
|
||
log.Printf("history: loaded %d entries", len(history))
|
||
}
|
||
|
||
func save() {
|
||
entries := make([]*Entry, 0, len(history))
|
||
for _, e := range history {
|
||
entries = append(entries, e)
|
||
}
|
||
data, _ := json.MarshalIndent(entries, "", " ")
|
||
os.MkdirAll(filepath.Dir(historyFile), 0755)
|
||
os.WriteFile(historyFile, data, 0644)
|
||
}
|
||
|
||
func cors(next http.HandlerFunc) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||
if r.Method == http.MethodOptions {
|
||
w.WriteHeader(204)
|
||
return
|
||
}
|
||
next(w, r)
|
||
}
|
||
}
|
||
|
||
func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool {
|
||
if expected == "" {
|
||
return true // 未配置 token 则不校验
|
||
}
|
||
auth := r.Header.Get("Authorization")
|
||
if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == expected {
|
||
return true
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid or missing token"})
|
||
return false
|
||
}
|
||
|
||
func handleSync(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method not allowed", 405)
|
||
return
|
||
}
|
||
// 浏览器插件回传:必须携带 NAVI_SYNC_TOKEN(免 portal 登录,改用自定义 token)
|
||
if !requireToken(w, r, syncToken) {
|
||
return
|
||
}
|
||
var req SyncRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
http.Error(w, err.Error(), 400)
|
||
return
|
||
}
|
||
if len(req.Items) == 0 || len(req.Items) > 1000 {
|
||
http.Error(w, "items must be 1-1000", 400)
|
||
return
|
||
}
|
||
|
||
mu.Lock()
|
||
|
||
for _, item := range req.Items {
|
||
normalized, err := normalizeURL(item.URL)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if item.VisitCount <= 0 {
|
||
item.VisitCount = 1
|
||
}
|
||
if item.Device == "" {
|
||
item.Device = "unknown"
|
||
}
|
||
if e, ok := history[normalized]; ok {
|
||
e.TotalVisits += item.VisitCount
|
||
if item.Timestamp > e.LastVisit {
|
||
e.LastVisit = item.Timestamp
|
||
}
|
||
if len(item.Title) > len(e.Title) {
|
||
e.Title = item.Title
|
||
}
|
||
found := false
|
||
for _, d := range e.Devices {
|
||
if d == item.Device {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
e.Devices = append(e.Devices, item.Device)
|
||
}
|
||
} else {
|
||
history[normalized] = &Entry{
|
||
URL: normalized, Title: item.Title, TotalVisits: item.VisitCount,
|
||
LastVisit: item.Timestamp, Devices: []string{item.Device},
|
||
}
|
||
}
|
||
}
|
||
save()
|
||
mu.Unlock() // 写锁释放后再排序,避免 sortDashyFavorites 内 RLock 死锁
|
||
|
||
rebuildDashySections()
|
||
|
||
mu.RLock()
|
||
total := len(history)
|
||
mu.RUnlock()
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "count": len(req.Items), "total": total})
|
||
}
|
||
|
||
func handleGet(w http.ResponseWriter, r *http.Request) {
|
||
mu.RLock()
|
||
defer mu.RUnlock()
|
||
|
||
entries := make([]*Entry, 0, len(history))
|
||
for _, e := range history {
|
||
entries = append(entries, e)
|
||
}
|
||
sort.Slice(entries, func(i, j int) bool {
|
||
return entries[i].TotalVisits > entries[j].TotalVisits
|
||
})
|
||
if len(entries) > 100 {
|
||
entries = entries[:100]
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]any{"items": entries})
|
||
}
|
||
|
||
func handleClear(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodDelete {
|
||
http.Error(w, "method not allowed", 405)
|
||
return
|
||
}
|
||
mu.Lock()
|
||
history = map[string]*Entry{}
|
||
save()
|
||
mu.Unlock()
|
||
|
||
rebuildDashySections() // 清空后重置 conf.yml 历史 section 为占位
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]string{"status": "cleared"})
|
||
}
|
||
|
||
func handleStats(w http.ResponseWriter, r *http.Request) {
|
||
mu.RLock()
|
||
defer mu.RUnlock()
|
||
total := 0
|
||
for _, e := range history {
|
||
total += e.TotalVisits
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]any{"unique_urls": len(history), "total_visits": total})
|
||
}
|
||
|
||
func normalizeURL(raw string) (string, error) {
|
||
u, err := url.Parse(raw)
|
||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||
return "", fmt.Errorf("invalid url: %s", raw)
|
||
}
|
||
u.Fragment = ""
|
||
u.Host = strings.ToLower(u.Host)
|
||
return u.String(), nil
|
||
}
|
||
|
||
// navItem 是 conf.yml 里一个导航项(任一 section)
|
||
type dashyNav struct {
|
||
title, desc, icon, url string
|
||
}
|
||
|
||
// dashySection 是 conf.yml 里的一个 section
|
||
type dashySection struct {
|
||
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
|
||
}
|
||
|
||
// 段落名兼容中英文:公开示例 conf.yml 用英文名,生产实例用中文名。
|
||
const (
|
||
favSectionZh = "\u5e38\u7528\u670d\u52a1" // 常用服务
|
||
favSectionEn = "Favorites"
|
||
histSectionZh = "\u5386\u53f2\u8bbf\u95ee" // 历史访问
|
||
histSectionEn = "History"
|
||
)
|
||
|
||
// extensionInstallURL 是「一键安装浏览历史插件」入口。
|
||
// 服务端始终把它放在「历史访问」第一条;已装插件的浏览器由 navi.js 隐藏。
|
||
const extensionInstallURL = "/extension/install.html"
|
||
|
||
func isFavSection(name string) bool {
|
||
return name == favSectionZh || strings.EqualFold(name, favSectionEn)
|
||
}
|
||
|
||
func isHistSection(name string) bool {
|
||
return name == histSectionZh || strings.EqualFold(name, histSectionEn)
|
||
}
|
||
|
||
// extensionInstallItem 返回「历史访问」首条的插件安装入口。
|
||
func extensionInstallItem() map[string]any {
|
||
return map[string]any{
|
||
"title": "\u5b89\u88c5\u6d4f\u89c8\u5386\u53f2\u63d2\u4ef6",
|
||
"description": "\u4e00\u952e\u5b89\u88c5\uff0c\u81ea\u52a8\u6309\u8bbf\u95ee\u9891\u6b21\u6392\u5e8f",
|
||
"icon": "fas fa-download",
|
||
"url": extensionInstallURL,
|
||
}
|
||
}
|
||
|
||
// rebuildDashySections 读 conf.yml:
|
||
// 1) 收集除「历史访问」外所有导航项
|
||
// 2) 按各导航 host 在 history 的累计访问统计
|
||
// 3) 「常用服务」= 访问最高的 4 个导航(Gitea/代码托管恒置顶)
|
||
// 4) 「历史访问」= 非导航 host 的 top 20
|
||
func rebuildDashySections() {
|
||
// 序列化重建:同步请求、启动、以及 favicon 抓完后的重试都会调到这里。
|
||
rebuildMu.Lock()
|
||
defer rebuildMu.Unlock()
|
||
|
||
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
|
||
}
|
||
|
||
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})
|
||
}
|
||
|
||
// 收集所有导航项(排除历史访问 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,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 排序:按访问量降序;同量保持原序
|
||
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
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 更新各 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
|
||
}
|
||
}
|
||
}
|
||
|
||
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 ""
|
||
}
|
||
|
||
func visitCount(rawURL string) int {
|
||
u, err := url.Parse(rawURL)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
u.Fragment = ""
|
||
u.Host = strings.ToLower(u.Host)
|
||
key := u.String()
|
||
mu.RLock()
|
||
defer mu.RUnlock()
|
||
if e, ok := history[key]; ok {
|
||
return e.TotalVisits
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func main() {
|
||
os.MkdirAll(dataDir, 0755)
|
||
load()
|
||
initFavicon()
|
||
// 启动时重建一次:让「历史访问」首条的插件安装入口立即生效,
|
||
// 而不必等到下一次浏览器插件同步。
|
||
rebuildDashySections()
|
||
|
||
http.HandleFunc("/sync", cors(handleSync))
|
||
http.HandleFunc("/", cors(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case http.MethodGet:
|
||
if !requireToken(w, r, syncToken) {
|
||
return
|
||
}
|
||
handleGet(w, r)
|
||
case http.MethodDelete:
|
||
if !requireToken(w, r, adminToken) {
|
||
return
|
||
}
|
||
handleClear(w, r)
|
||
default:
|
||
http.Error(w, "method not allowed", 405)
|
||
}
|
||
}))
|
||
http.HandleFunc("/stats", cors(handleStats))
|
||
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||
})
|
||
|
||
log.Println("history-server listening on :3004")
|
||
log.Fatal(http.ListenAndServe(":3004", nil))
|
||
}
|
||
|
||
// 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
|
||
}
|