main_test.go 原为单行 import "testing",补 import 的替换锚点没命中, 导致 os/filepath 未导入、history-server 编译失败(承接上一提交的坏状态)。 本次改为块形式导入,go vet 与 go test 均通过。
756 lines
21 KiB
Go
756 lines
21 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, 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
|
||
}
|