- 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
235 lines
5.8 KiB
Go
235 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var (
|
|
confFile = envOr("DASHY_CONF", "/app/data/conf.yml")
|
|
adminToken = os.Getenv("NAVI_ADMIN_TOKEN")
|
|
mu sync.Mutex
|
|
)
|
|
|
|
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
|
|
}
|
|
jsonErr(w, http.StatusUnauthorized, "invalid or missing token")
|
|
return false
|
|
}
|
|
|
|
type Config struct {
|
|
AppConfig map[string]any `yaml:"appConfig"`
|
|
PageInfo map[string]any `yaml:"pageInfo"`
|
|
Sections []Section `yaml:"sections"`
|
|
Pages []any `yaml:"pages,omitempty"`
|
|
}
|
|
|
|
type Section struct {
|
|
Name string `yaml:"name"`
|
|
Icon string `yaml:"icon,omitempty"`
|
|
Collapsed *bool `yaml:"collapsed,omitempty"`
|
|
Items []NavItem `yaml:"items"`
|
|
}
|
|
|
|
type NavItem struct {
|
|
Title string `yaml:"title"`
|
|
Description string `yaml:"description,omitempty"`
|
|
Icon string `yaml:"icon,omitempty"`
|
|
URL string `yaml:"url"`
|
|
StatusCheck *bool `yaml:"statusCheck,omitempty"`
|
|
}
|
|
|
|
func envOr(k, def string) string {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func loadConfig() (*Config, error) {
|
|
data, err := os.ReadFile(confFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var cfg Config
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
func saveConfig(cfg *Config) error {
|
|
data, err := yaml.Marshal(cfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := confFile + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, confFile)
|
|
}
|
|
|
|
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 jsonResp(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func jsonErr(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
}
|
|
|
|
func handleSections(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
jsonResp(w, cfg.Sections)
|
|
}
|
|
|
|
func handleAddItem(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
jsonErr(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
if !requireToken(w, r, adminToken) {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
var req struct {
|
|
SectionIndex int `json:"sectionIndex"`
|
|
Item NavItem `json:"item"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if req.Item.Title == "" || req.Item.URL == "" {
|
|
jsonErr(w, 400, "title and url required")
|
|
return
|
|
}
|
|
// Block javascript: URLs
|
|
if strings.HasPrefix(strings.ToLower(req.Item.URL), "javascript:") {
|
|
jsonErr(w, 400, "javascript: URLs not allowed")
|
|
return
|
|
}
|
|
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if req.SectionIndex < 0 || req.SectionIndex >= len(cfg.Sections) {
|
|
jsonErr(w, 400, "invalid section index")
|
|
return
|
|
}
|
|
cfg.Sections[req.SectionIndex].Items = append(cfg.Sections[req.SectionIndex].Items, req.Item)
|
|
if err := saveConfig(cfg); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
jsonResp(w, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func handleDeleteItem(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
jsonErr(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
if !requireToken(w, r, adminToken) {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
var req struct {
|
|
SectionIndex int `json:"sectionIndex"`
|
|
ItemIndex int `json:"itemIndex"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if req.SectionIndex < 0 || req.SectionIndex >= len(cfg.Sections) {
|
|
jsonErr(w, 400, "invalid section index")
|
|
return
|
|
}
|
|
sec := &cfg.Sections[req.SectionIndex]
|
|
if req.ItemIndex < 0 || req.ItemIndex >= len(sec.Items) {
|
|
jsonErr(w, 400, "invalid item index")
|
|
return
|
|
}
|
|
sec.Items = append(sec.Items[:req.ItemIndex], sec.Items[req.ItemIndex+1:]...)
|
|
if err := saveConfig(cfg); err != nil {
|
|
jsonErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
jsonResp(w, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func main() {
|
|
// Ensure conf file exists
|
|
if _, err := os.Stat(confFile); os.IsNotExist(err) {
|
|
defaultConf := envOr("NAVI_DEFAULT_CONFIG", "/app/defaults/conf.yml")
|
|
if data, err := os.ReadFile(defaultConf); err == nil {
|
|
os.MkdirAll(filepath.Dir(confFile), 0755)
|
|
os.WriteFile(confFile, data, 0644)
|
|
}
|
|
}
|
|
|
|
// Serve admin UI static files (nginx strips /admin/ prefix)
|
|
http.Handle("/", http.FileServer(http.Dir("/app/admin")))
|
|
|
|
http.HandleFunc("/api/sections", cors(handleSections))
|
|
http.HandleFunc("/api/item", cors(handleAddItem))
|
|
http.HandleFunc("/api/item/delete", cors(handleDeleteItem))
|
|
http.HandleFunc("/api/reload", cors(func(w http.ResponseWriter, r *http.Request) {
|
|
jsonResp(w, map[string]string{"status": "saved"})
|
|
}))
|
|
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
jsonResp(w, map[string]string{"status": "ok"})
|
|
})
|
|
|
|
log.Println("admin-server listening on :3005")
|
|
log.Fatal(http.ListenAndServe(":3005", nil))
|
|
}
|