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