Files
decent-webui/cmd/unified/main.go
2025-09-27 09:33:40 +02:00

399 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
// ← Passe diese Import-Pfade an dein go.mod an
"git.send.nrw/sendnrw/decent-webui/internal/admin"
"git.send.nrw/sendnrw/decent-webui/internal/filesvc"
"git.send.nrw/sendnrw/decent-webui/internal/mesh"
)
/*** Config ***/
func loadConfig() AppConfig {
// HTTP
httpAddr := getenvDefault("ADDR", ":8080")
// API
apiKey := os.Getenv("FILE_SERVICE_API_KEY")
// Admin UI (BasicAuth optional)
adminUser := os.Getenv("ADMIN_USER")
adminPass := os.Getenv("ADMIN_PASS")
// Mesh (mit sinnvollen Defaults)
m := mesh.Config{
BindAddr: getenvDefault("MESH_BIND", ":9090"),
AdvertURL: os.Getenv("MESH_ADVERT"), // kann leer sein → wir leiten ab
Seeds: splitCSV(os.Getenv("MESH_SEEDS")),
ClusterSecret: os.Getenv("MESH_CLUSTER_SECRET"),
EnableDiscovery: parseBoolEnv("MESH_ENABLE_DISCOVERY", false),
DiscoveryAddress: getenvDefault("MESH_DISCOVERY_ADDR", "239.8.8.8:9898"),
}
// Wenn keine AdvertURL gesetzt ist, versuche eine sinnvolle Herleitung:
if strings.TrimSpace(m.AdvertURL) == "" {
m.AdvertURL = inferAdvertURL(m.BindAddr)
log.Printf("[mesh] MESH_ADVERT nicht gesetzt abgeleitet: %s", m.AdvertURL)
}
// Minimal-Validierung mit hilfreicher Meldung
if strings.TrimSpace(m.BindAddr) == "" {
log.Fatal("MESH_BIND fehlt (z.B. :9090)")
}
if strings.TrimSpace(m.AdvertURL) == "" {
log.Fatal("MESH_ADVERT fehlt und konnte nicht abgeleitet werden (z.B. http://unified_a:9090)")
}
if strings.TrimSpace(m.ClusterSecret) == "" {
log.Printf("[mesh] WARN: MESH_CLUSTER_SECRET ist leer für produktive Netze unbedingt setzen!")
}
return AppConfig{
HTTPAddr: httpAddr,
APIKey: apiKey,
AdminUser: adminUser,
AdminPass: adminPass,
Mesh: m,
}
}
// --- Helpers
func getenvDefault(k, def string) string {
v := os.Getenv(k)
if v == "" {
return def
}
return v
}
func parseBoolEnv(k string, def bool) bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv(k)))
if v == "" {
return def
}
return v == "1" || v == "true" || v == "yes" || v == "on"
}
func splitCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
seen := map[string]struct{}{}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return out
}
// inferAdvertURL baut eine brauchbare Default-AdvertURL:
// - MESH_ADVERT_HOST (wenn gesetzt) → z.B. Service-Name aus Compose
// - sonst HOSTNAME
// - sonst "localhost"
//
// Port wird aus MESH_BIND entnommen (z.B. ":9090" → 9090)
func inferAdvertURL(meshBind string) string {
host := strings.TrimSpace(os.Getenv("MESH_ADVERT_HOST"))
if host == "" {
host = strings.TrimSpace(os.Getenv("HOSTNAME"))
}
if host == "" {
host = "localhost"
}
port := "9090"
if i := strings.LastIndex(meshBind, ":"); i != -1 && len(meshBind) > i+1 {
port = meshBind[i+1:]
}
return fmt.Sprintf("http://%s:%s", host, port)
}
type AppConfig struct {
HTTPAddr string
APIKey string
AdminUser string
AdminPass string
Mesh mesh.Config
}
/*** Middleware ***/
func authMiddleware(apiKey string, next http.Handler) http.Handler {
// Dev-Mode: ohne API-Key kein Auth-Zwang
if strings.TrimSpace(apiKey) == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := r.Header.Get("Authorization")
if !strings.HasPrefix(got, "Bearer ") || strings.TrimPrefix(got, "Bearer ") != apiKey {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Passe nach Bedarf an (Origin-Whitelist etc.)
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
/*** HTTP helpers ***/
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
/*** API-Routen ***/
func fileRoutes(mux *http.ServeMux, store filesvc.MeshStore) {
// Health
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// List + Create
mux.HandleFunc("/api/v1/items", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
nextStr := r.URL.Query().Get("next")
var next filesvc.ID
if nextStr != "" {
if n, err := strconv.ParseInt(nextStr, 10, 64); err == nil {
next = filesvc.ID(n)
}
}
items, nextOut, err := store.List(r.Context(), next, 100)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next": nextOut})
case http.MethodPost:
var in struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
it, err := store.Create(r.Context(), in.Name)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusCreated, it)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
})
// Rename
mux.HandleFunc("/api/v1/items/rename", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var in struct {
ID filesvc.ID `json:"id"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
it, err := store.Rename(r.Context(), in.ID, in.Name)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, filesvc.ErrNotFound) {
status = http.StatusNotFound
}
writeJSON(w, status, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, it)
})
// Delete
mux.HandleFunc("/api/v1/items/delete", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var in struct {
ID filesvc.ID `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
it, err := store.Delete(r.Context(), in.ID)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, filesvc.ErrNotFound) {
status = http.StatusNotFound
}
writeJSON(w, status, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, it)
})
}
/*** Mesh <-> Store Mapping (falls Typen getrennt sind) ***/
func toMeshSnapshot(s filesvc.Snapshot) mesh.Snapshot {
out := mesh.Snapshot{Items: make([]mesh.Item, 0, len(s.Items))}
for _, it := range s.Items {
out.Items = append(out.Items, mesh.Item{
ID: int64(it.ID),
Name: it.Name,
UpdatedAt: it.UpdatedAt,
Deleted: it.Deleted,
})
}
return out
}
func fromMeshSnapshot(ms mesh.Snapshot) filesvc.Snapshot {
out := filesvc.Snapshot{Items: make([]filesvc.File, 0, len(ms.Items))}
for _, it := range ms.Items {
out.Items = append(out.Items, filesvc.File{
ID: filesvc.ID(it.ID),
Name: it.Name,
UpdatedAt: it.UpdatedAt,
Deleted: it.Deleted,
})
}
return out
}
/*** main ***/
func main() {
cfg := loadConfig()
// Domain-Store (mesh-fähig)
st := filesvc.NewMemStore()
// Mesh starten
mcfg := mesh.FromEnv()
mnode, err := mesh.New(mcfg, mesh.Callbacks{
GetSnapshot: func(ctx context.Context) (mesh.Snapshot, error) {
s, err := st.Snapshot(ctx)
if err != nil {
return mesh.Snapshot{}, err
}
return toMeshSnapshot(s), nil
},
ApplyRemote: func(ctx context.Context, s mesh.Snapshot) error {
return st.ApplyRemote(ctx, fromMeshSnapshot(s))
},
})
if err != nil {
log.Fatalf("mesh init: %v", err)
}
go func() {
log.Printf("[mesh] listening on %s advertise %s seeds=%v discovery=%v",
mcfg.BindAddr, mcfg.AdvertURL, mcfg.Seeds, mcfg.EnableDiscovery)
if err := mnode.Serve(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("mesh serve: %v", err)
}
}()
// Root-Mux
root := http.NewServeMux()
// API (Bearer-Auth)
apiMux := http.NewServeMux()
fileRoutes(apiMux, st)
root.Handle("/api/", authMiddleware(cfg.APIKey, apiMux))
// Admin-UI (optional BasicAuth via ADMIN_USER/ADMIN_PASS)
adminRoot := http.NewServeMux()
admin.Register(adminRoot, admin.Deps{Store: st, Mesh: mnode})
adminUser := os.Getenv("ADMIN_USER")
adminPass := os.Getenv("ADMIN_PASS")
if strings.TrimSpace(adminUser) != "" {
wrapped := admin.BasicAuth(adminUser, adminPass, adminRoot)
root.Handle("/admin", wrapped)
root.Handle("/admin/", wrapped)
} else {
root.Handle("/admin", adminRoot)
root.Handle("/admin/", adminRoot)
}
// Startseite → /admin
root.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusFound)
})
// Finaler Handler-Stack
handler := cors(accessLog(root))
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
// Graceful shutdown
go func() {
log.Printf("http listening on %s (api=/api/v1, admin=/admin)", cfg.Mesh.BindAddr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("http server: %v", err)
}
}()
// OS-Signale abfangen
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
_ = mnode.Close(ctx)
log.Println("shutdown complete")
}