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)
This commit is contained in:
5
admin-server/go.mod
Normal file
5
admin-server/go.mod
Normal file
@ -0,0 +1,5 @@
|
||||
module admin-server
|
||||
|
||||
go 1.21
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
4
admin-server/go.sum
Normal file
4
admin-server/go.sum
Normal file
@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
215
admin-server/main.go
Normal file
215
admin-server/main.go
Normal file
@ -0,0 +1,215 @@
|
||||
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")
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user