Files
HomeDash/history-server/main.go
HomeDash Bot b980ce6292 fix(build): 测试文件 import 改为块形式(os/filepath/testing)
main_test.go 原为单行 import "testing",补 import 的替换锚点没命中,
导致 os/filepath 未导入、history-server 编译失败(承接上一提交的坏状态)。
本次改为块形式导入,go vet 与 go test 均通过。
2026-09-12 12:42:08 +08:00

756 lines
21 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 (
"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, Authorization")
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"})
}
// handleDeleteItem 删除单条历史(?url=),随后重建 Dashy 分组。
// 与「清空全部」分开:管理后台需要能逐条删。
func handleDeleteItem(w http.ResponseWriter, r *http.Request) {
raw := r.URL.Query().Get("url")
if raw == "" {
http.Error(w, "url query parameter is required", 400)
return
}
key, err := normalizeURL(raw)
if err != nil {
http.Error(w, "invalid url", 400)
return
}
mu.Lock()
_, existed := history[key]
if existed {
delete(history, key)
save()
}
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if !existed {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]any{"error": "not found", "url": key})
return
}
rebuildDashySections()
json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "url": key})
}
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")
if err := json.NewEncoder(w).Encode(map[string]any{"unique_urls": len(history), "total_visits": total}); err != nil {
log.Printf("stats response: %v", err)
}
}
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
}
// ── 导航项点击计数(独立统计,不与浏览器历史混用)──
//
// 用户要求:导航项自己的访问频率要单独统计,而不是拿浏览器历史反推
// (浏览器历史里混着大量站外页面,反推出来的"常用"并不等于"我常点哪个服务")。
// 客户端在点击导航项时向 /api/history/nav-visit 上报一次,这里计数并落盘。
var navVisitsMu sync.Mutex
func navVisitsPath() string { return filepath.Join(dataDir, "nav-visits.json") }
func loadNavVisits() map[string]int {
out := map[string]int{}
b, err := os.ReadFile(navVisitsPath())
if err != nil {
return out
}
if err := json.Unmarshal(b, &out); err != nil {
fmt.Println("[nav] nav-visits.json 解析失败,按空计数:", err)
return map[string]int{}
}
return out
}
func bumpNavVisit(host string) int {
navVisitsMu.Lock()
defer navVisitsMu.Unlock()
m := loadNavVisits()
m[host]++
b, _ := json.Marshal(m)
tmp := navVisitsPath() + ".tmp"
if err := os.WriteFile(tmp, b, 0644); err == nil {
_ = os.Rename(tmp, navVisitsPath())
}
return m[host]
}
func handleNavVisit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.URL == "" {
http.Error(w, "url required", 400)
return
}
u, err := url.Parse(req.URL)
if err != nil || u.Hostname() == "" {
http.Error(w, "bad url", 400)
return
}
host := strings.ToLower(u.Hostname())
n := bumpNavVisit(host)
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "host": host, "visits": n})
rebuildDashySections() // 让「常用服务」立即按新计数重排
}
// 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. 「常用服务」= 访问频次最高的 5 个导航(不置顶任何服务)
// 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 访问量
navVisits := loadNavVisits() // 导航项点击计数:常用服务的唯一依据
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())
}
// 由 gateway 管理且勾选“自动获取图标”的导航项也复用同一套
// favicon 缓存。首次没有缓存先显示 globe,后台抓完会再次重建。
if auto, _ := m["naviGatewayAutoIcon"].(bool); auto {
if hp := hostPortOf(u); hp != "" {
if name := cachedFavicon(hp); name != "" {
icon = name
m["icon"] = name
} else {
queueFavicon(hp)
}
}
}
v := 0
if host != "" {
v = navVisits[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
}
// 「常用服务」= 纯访问频次前 N,**不置顶任何服务**。
// 曾有一版把 Gitea/代码托管强制放第一位;用户要求取消:每个服务都应归在
// 准确描述其功能的分组里,常用服务只做频次聚合。
const favTopN = 5
finalNavs := make([]dashyNav, 0, favTopN)
for _, c := range candidates {
if len(finalNavs) >= favTopN {
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 := friendlyHistoryTitle(e.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
}
}
foundHist := false
for _, si := range secs {
if isHistSection(si.name) {
si.raw["items"] = histItems
foundHist = true
}
}
// 配置里没有「历史访问」分组时补一个。该分组只能由这里创建:
// 管理后台明确禁止手工新建/改名/删除它(否则下次同步就被覆写),
// 所以若只靠用户手动建,功能就一直不会出现。
if !foundHist {
sectionsRaw = append(sectionsRaw, map[string]any{
"name": histSectionZh,
"icon": "fas fa-history",
"collapsed": false,
"items": histItems,
})
fmt.Println("[history] 已补建「历史访问」分组")
}
}
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)
}
// friendlyHistoryTitle 避免把历史项标题直接显示成长 URL。
// 浏览器不给标题、或标题本身就是 URL 时,改用去掉 www. 的简洁域名。
func friendlyHistoryTitle(title, rawURL string) string {
clean := strings.TrimSpace(title)
low := strings.ToLower(clean)
if clean != "" && !strings.HasPrefix(low, "http://") && !strings.HasPrefix(low, "https://") {
return clean
}
if u, err := url.Parse(rawURL); err == nil {
host := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.")
if host != "" {
return host
}
}
if clean != "" {
return clean
}
return "历史站点"
}
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))
// 导航项点击计数(对外 /api/history/nav-visit,由 navi.js 上报)
http.HandleFunc("/nav-visit", cors(handleNavVisit))
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))
// 管理后台保存 gateway 路由后触发一次导航/收藏重建;只允许容器内
// admin-server 用 admin token 调用,公网 nginx 没有暴露这个路径。
http.HandleFunc("/rebuild", cors(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if !requireToken(w, r, adminToken) {
return
}
rebuildDashySections()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "rebuilt"})
}))
// 逐条删除历史(?url=),需 admin token;供管理后台转发调用
http.HandleFunc("/item", cors(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", 405)
return
}
if !requireToken(w, r, adminToken) {
return
}
handleDeleteItem(w, r)
}))
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
}