fix+feat: real setup persistence, request logging, improved SPA serving
- server.go now accepts configPath so setup mutation writes config.json to disk - setup mutation parses GraphQL variables and persists config — no more setup loop - request logger middleware logs every HTTP request with method/path/status/duration - GraphQL handler logs operation name and setup/status decisions - spaHandler simplified: serves real files directly, falls back to index.html only for paths that don't exist (fixes potential asset-serving issues) - health endpoint returns JSON and 503 in setup mode - main.go logs config path, storage path and db path on startup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,18 +11,26 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load(configPath())
|
cfgPath := configPath()
|
||||||
if err != nil && !errors.Is(err, config.ErrRequireSetup) {
|
log.Printf("config path: %s", cfgPath)
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, config.ErrRequireSetup) {
|
||||||
|
log.Printf("no config found — starting in setup mode (wizard at http://localhost:4000/)")
|
||||||
|
} else {
|
||||||
log.Fatalf("failed to load config: %v", err)
|
log.Fatalf("failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("config loaded — storage: %s db: %s", cfg.StoragePath, cfg.DBPath)
|
||||||
|
}
|
||||||
|
|
||||||
// cfg may be nil when REQUIRE_SETUP — NewServer handles that gracefully.
|
|
||||||
addr := ":4000"
|
addr := ":4000"
|
||||||
if cfg != nil && cfg.ListenAddr != "" {
|
if cfg != nil && cfg.ListenAddr != "" {
|
||||||
addr = cfg.ListenAddr
|
addr = cfg.ListenAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := graph.NewServer(cfg)
|
srv := graph.NewServer(cfg, cfgPath)
|
||||||
|
|
||||||
log.Printf("Archivum listening on %s", addr)
|
log.Printf("Archivum listening on %s", addr)
|
||||||
if err := http.ListenAndServe(addr, srv); err != nil {
|
if err := http.ListenAndServe(addr, srv); err != nil {
|
||||||
|
|||||||
@@ -1,86 +1,286 @@
|
|||||||
package graph
|
package graph
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/brasse-b/archivum/internal/config"
|
"github.com/brasse-b/archivum/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewServer(cfg *config.Config) http.Handler {
|
// Server holds runtime state that can change after the setup wizard completes.
|
||||||
|
type Server struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cfg *config.Config
|
||||||
|
configPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(cfg *config.Config, configPath string) http.Handler {
|
||||||
|
s := &Server{cfg: cfg, configPath: configPath}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/graphql", s.handleGraphQL)
|
||||||
mux.HandleFunc("/graphql", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/health", s.handleHealth)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cfg == nil {
|
|
||||||
_, _ = w.Write([]byte(`{"data":{"systemStatus":"REQUIRE_SETUP"}}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Stub: inspect operation name to return sensible responses.
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
bs := string(body)
|
|
||||||
switch {
|
|
||||||
case strings.Contains(bs, "testLdapConnection"):
|
|
||||||
_, _ = w.Write([]byte(`{"data":{"testLdapConnection":{"success":false,"message":"LDAP resolver not yet implemented"}}}`))
|
|
||||||
case strings.Contains(bs, "setup"):
|
|
||||||
_, _ = w.Write([]byte(`{"data":{"setup":true}}`))
|
|
||||||
case strings.Contains(bs, "login"):
|
|
||||||
_, _ = w.Write([]byte(`{"data":{"login":"stub-token"}}`))
|
|
||||||
default:
|
|
||||||
_, _ = w.Write([]byte(`{"data":{"systemStatus":"OK","documents":[]}}`))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
uiDir := os.Getenv("UI_DIR")
|
uiDir := os.Getenv("UI_DIR")
|
||||||
if uiDir == "" {
|
if uiDir == "" {
|
||||||
uiDir = "/srv/archivum/ui"
|
uiDir = "/srv/archivum/ui"
|
||||||
}
|
}
|
||||||
|
log.Printf("serving UI from %s", uiDir)
|
||||||
mux.Handle("/", spaHandler(uiDir))
|
mux.Handle("/", spaHandler(uiDir))
|
||||||
|
|
||||||
return mux
|
return requestLogger(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
// spaHandler serves static files and falls back to index.html for all paths
|
// ── GraphQL handler ───────────────────────────────────────────────────────────
|
||||||
// that don't resolve to a real file — required for client-side routing.
|
|
||||||
|
type gqlRequest struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Variables map[string]interface{} `json:"variables"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeGQLError(w, "failed to read request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req gqlRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
writeGQLError(w, fmt.Sprintf("invalid JSON: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[graphql] op: %s", gqlOpName(req.Query))
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
cfg := s.cfg
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
// ── Mutations available before setup ─────────────────────────────────────
|
||||||
|
if strings.Contains(req.Query, "setup") {
|
||||||
|
s.handleSetup(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── System status ─────────────────────────────────────────────────────────
|
||||||
|
if strings.Contains(req.Query, "systemStatus") {
|
||||||
|
if cfg == nil {
|
||||||
|
log.Printf("[graphql] systemStatus → REQUIRE_SETUP")
|
||||||
|
writeJSON(w, `{"data":{"systemStatus":"REQUIRE_SETUP"}}`)
|
||||||
|
} else {
|
||||||
|
log.Printf("[graphql] systemStatus → OK")
|
||||||
|
writeJSON(w, `{"data":{"systemStatus":"OK"}}`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── All other operations require a valid config ───────────────────────────
|
||||||
|
if cfg == nil {
|
||||||
|
log.Printf("[graphql] blocked — config not initialised")
|
||||||
|
writeGQLError(w, "REQUIRE_SETUP")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(req.Query, "testLdapConnection"):
|
||||||
|
writeJSON(w, `{"data":{"testLdapConnection":{"success":false,"message":"LDAP resolver not yet implemented"}}}`)
|
||||||
|
|
||||||
|
case strings.Contains(req.Query, "login"):
|
||||||
|
writeJSON(w, `{"data":{"login":"stub-token"}}`)
|
||||||
|
|
||||||
|
case strings.Contains(req.Query, "documents"):
|
||||||
|
writeJSON(w, `{"data":{"documents":[]}}`)
|
||||||
|
|
||||||
|
default:
|
||||||
|
writeJSON(w, `{"data":{"systemStatus":"OK"}}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSetup parses the setup input, writes config.json, and activates the config.
|
||||||
|
func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
|
||||||
|
input, ok := req.Variables["i"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[setup] invalid input — variables: %v", req.Variables)
|
||||||
|
writeGQLError(w, "invalid setup input")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storagePath, _ := input["storagePath"].(string)
|
||||||
|
jwtSecret, _ := input["jwtSecret"].(string)
|
||||||
|
|
||||||
|
if storagePath == "" || jwtSecret == "" {
|
||||||
|
writeGQLError(w, "storagePath and jwtSecret are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
StoragePath: storagePath,
|
||||||
|
DBPath: "/data/db/archivum.db",
|
||||||
|
JWTSecret: jwtSecret,
|
||||||
|
ListenAddr: ":4000",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional LDAP
|
||||||
|
if ldapRaw, ok := input["ldap"].(map[string]interface{}); ok && ldapRaw != nil {
|
||||||
|
port := 389
|
||||||
|
if p, ok := ldapRaw["port"].(float64); ok {
|
||||||
|
port = int(p)
|
||||||
|
}
|
||||||
|
cfg.LDAP = config.LDAPConfig{
|
||||||
|
Host: strVal(ldapRaw, "host"),
|
||||||
|
Port: port,
|
||||||
|
BaseDN: strVal(ldapRaw, "baseDN"),
|
||||||
|
BindDN: strVal(ldapRaw, "bindDN"),
|
||||||
|
BindPassword: strVal(ldapRaw, "bindPassword"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the config directory exists before writing.
|
||||||
|
if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil {
|
||||||
|
log.Printf("[setup] mkdir failed: %v", err)
|
||||||
|
writeGQLError(w, fmt.Sprintf("could not create config directory: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Save(s.configPath, cfg); err != nil {
|
||||||
|
log.Printf("[setup] failed to write config: %v", err)
|
||||||
|
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[setup] config written to %s", s.configPath)
|
||||||
|
|
||||||
|
// Activate new config in memory so subsequent requests see OK immediately.
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cfg = cfg
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[setup] complete — storage: %s", storagePath)
|
||||||
|
writeJSON(w, `{"data":{"setup":true}}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.RLock()
|
||||||
|
ready := s.cfg != nil
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
if ready {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
_, _ = w.Write([]byte(`{"status":"setup_required"}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SPA static file handler ───────────────────────────────────────────────────
|
||||||
|
|
||||||
func spaHandler(dir string) http.Handler {
|
func spaHandler(dir string) http.Handler {
|
||||||
fs := http.Dir(dir)
|
fsys := http.Dir(dir)
|
||||||
fileServer := http.FileServer(fs)
|
fileServer := http.FileServer(fsys)
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Assets (JS, CSS, images) must be served as-is.
|
// Always try to open the exact path first.
|
||||||
if isAssetPath(r.URL.Path) {
|
f, err := fsys.Open(r.URL.Path)
|
||||||
fileServer.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Try to open the path; if it exists serve it, otherwise serve SPA root.
|
|
||||||
f, err := fs.Open(r.URL.Path)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
fi, statErr := f.Stat()
|
||||||
f.Close()
|
f.Close()
|
||||||
|
// Serve directories as SPA root (e.g. GET /)
|
||||||
|
if statErr == nil && !fi.IsDir() {
|
||||||
fileServer.ServeHTTP(w, r)
|
fileServer.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Path doesn't exist or is a directory → serve the SPA shell.
|
||||||
http.ServeFile(w, r, dir+"/index.html")
|
http.ServeFile(w, r, dir+"/index.html")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func isAssetPath(path string) bool {
|
// ── Request logger middleware ─────────────────────────────────────────────────
|
||||||
return strings.HasPrefix(path, "/assets/") ||
|
|
||||||
strings.HasPrefix(path, "/icons/") ||
|
type statusWriter struct {
|
||||||
path == "/favicon.ico" ||
|
http.ResponseWriter
|
||||||
path == "/favicon.svg" ||
|
status int
|
||||||
path == "/manifest.webmanifest" ||
|
}
|
||||||
path == "/registerSW.js" ||
|
|
||||||
path == "/sw.js"
|
func (sw *statusWriter) WriteHeader(status int) {
|
||||||
|
sw.status = status
|
||||||
|
sw.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLogger(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sw := &statusWriter{ResponseWriter: w, status: 200}
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(sw, r)
|
||||||
|
// Skip noisy health-check logs unless they fail.
|
||||||
|
if r.URL.Path == "/health" && sw.status == 200 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[http] %s %s → %d (%s)", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Microsecond))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, body string) {
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGQLError(w http.ResponseWriter, msg string) {
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"errors": []map[string]string{{"message": msg}},
|
||||||
|
})
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func gqlOpName(query string) string {
|
||||||
|
q := strings.TrimSpace(query)
|
||||||
|
if strings.HasPrefix(q, "mutation") {
|
||||||
|
return "mutation " + firstWord(q[len("mutation"):])
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(q, "query") {
|
||||||
|
return "query " + firstWord(q[len("query"):])
|
||||||
|
}
|
||||||
|
return firstWord(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstWord(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
for i, c := range s {
|
||||||
|
if c == ' ' || c == '(' || c == '{' {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func strVal(m map[string]interface{}, key string) string {
|
||||||
|
v, _ := m[key].(string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirOf(path string) string {
|
||||||
|
idx := strings.LastIndexAny(path, "/\\")
|
||||||
|
if idx < 0 {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return path[:idx]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user