- Extensions: rewrite both Chrome MV3 & Firefox MV2 with reliable chrome.history.search incremental pull (not onVisited), local search-engine filtering, URL dedup, 30s alarm, retry queue, Bearer token header - Add icons (16/48/96/128) for both browsers - Manifest fixes: chrome action popup, firefox options_ui + browser shim - history-server: require NAVI_SYNC_TOKEN for POST /sync & GET history, NAVI_ADMIN_TOKEN for DELETE; fix RWMutex deadlock (unlock before sortDashyFavorites) that stalled syncs with valid token - admin-server: require NAVI_ADMIN_TOKEN for item add/delete writes
398 lines
9.4 KiB
Go
398 lines
9.4 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 死锁
|
||
|
||
sortDashyFavorites()
|
||
|
||
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()
|
||
|
||
sortDashyFavorites() // 清空后重置 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
|
||
}
|
||
|
||
// sortDashyFavorites reads conf.yml and sorts the first section's items by visit frequency.
|
||
func sortDashyFavorites() {
|
||
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
|
||
}
|
||
|
||
// Find sections by name
|
||
var favIdx, histIdx int = -1, -1
|
||
for i, s := range sectionsRaw {
|
||
m, ok := s.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
name, _ := m["name"].(string)
|
||
switch name {
|
||
case "\u5e38\u7528\u670d\u52a1": // 常用服务
|
||
favIdx = i
|
||
case "\u5386\u53f2\u8bbf\u95ee": // 历史访问
|
||
histIdx = i
|
||
}
|
||
}
|
||
|
||
// Sort 常用服务 by visit count
|
||
if favIdx >= 0 {
|
||
sec := sectionsRaw[favIdx].(map[string]any)
|
||
itemsRaw, _ := sec["items"].([]any)
|
||
if len(itemsRaw) > 0 {
|
||
sort.SliceStable(itemsRaw, func(i, j int) bool {
|
||
ii, _ := itemsRaw[i].(map[string]any)
|
||
ij, _ := itemsRaw[j].(map[string]any)
|
||
ui, _ := ii["url"].(string)
|
||
uj, _ := ij["url"].(string)
|
||
vi := visitCount(ui)
|
||
vj := visitCount(uj)
|
||
return vi > vj
|
||
})
|
||
sec["items"] = itemsRaw
|
||
}
|
||
}
|
||
|
||
// Rebuild 历史访问 from history data (top 20, excluding URLs already in 常用服务)
|
||
if histIdx >= 0 {
|
||
favURLs := map[string]bool{}
|
||
if favIdx >= 0 {
|
||
sec := sectionsRaw[favIdx].(map[string]any)
|
||
if items, ok := sec["items"].([]any); ok {
|
||
for _, it := range items {
|
||
m, _ := it.(map[string]any)
|
||
u, _ := m["url"].(string)
|
||
favURLs[strings.ToLower(u)] = 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)
|
||
for _, e := range entries {
|
||
if favURLs[strings.ToLower(e.URL)] {
|
||
continue
|
||
}
|
||
title := e.Title
|
||
if title == "" {
|
||
title = e.URL
|
||
}
|
||
histItems = append(histItems, map[string]any{
|
||
"title": title,
|
||
"description": fmt.Sprintf("%d\u6b21\u8bbf\u95ee", e.TotalVisits),
|
||
"icon": "fas fa-globe",
|
||
"url": e.URL,
|
||
})
|
||
if len(histItems) >= 20 {
|
||
break
|
||
}
|
||
}
|
||
if len(histItems) == 0 {
|
||
histItems = append(histItems, map[string]any{
|
||
"title": "\u7b49\u5f85\u6d4f\u89c8\u5668\u63d2\u4ef6\u63a5\u5165...",
|
||
"description": "\u540c\u6b65\u540e\u6309\u603b\u8bbf\u95ee\u9891\u6b21\u81ea\u52a8\u6392\u5e8f",
|
||
"icon": "fas fa-info-circle",
|
||
"url": "/admin/",
|
||
})
|
||
}
|
||
sectionsRaw[histIdx].(map[string]any)["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 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()
|
||
|
||
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))
|
||
}
|