Files
HomeDash/history-server/main.go
JianFeeeee 7e49d2b51c feat: HomeDash - unified self-hosted navigation portal
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)
2026-09-08 22:25:00 +08:00

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))
}