Single-container navigation service combining: - Dashy dashboard + SearXNG metasearch + history sync API + admin WebUI - Go services for history/admin APIs, nginx as unified entry - Browser extensions (Chrome MV3 / Firefox MV2) with 30s batched sync - Production guide: portal-nginx unified auth reverse proxy + webui4frpc/frp tunnel - Fully sanitized (no internal IPs/domains/credentials)
365 lines
8.5 KiB
Go
365 lines
8.5 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 handleSync(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", 405)
|
|
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()
|
|
defer mu.Unlock()
|
|
|
|
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()
|
|
sortDashyFavorites()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{"status": "ok", "count": len(req.Items), "total": len(history)})
|
|
}
|
|
|
|
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()
|
|
defer mu.Unlock()
|
|
history = map[string]*Entry{}
|
|
save()
|
|
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) {
|
|
if r.Method == http.MethodGet {
|
|
handleGet(w, r)
|
|
} else if r.Method == http.MethodDelete {
|
|
handleClear(w, r)
|
|
} else {
|
|
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))
|
|
}
|