diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index cc82759..ea8b818 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -11,18 +11,26 @@ import ( ) func main() { - cfg, err := config.Load(configPath()) - if err != nil && !errors.Is(err, config.ErrRequireSetup) { - log.Fatalf("failed to load config: %v", err) + cfgPath := configPath() + 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) + } + } 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" if cfg != nil && cfg.ListenAddr != "" { addr = cfg.ListenAddr } - srv := graph.NewServer(cfg) + srv := graph.NewServer(cfg, cfgPath) log.Printf("Archivum listening on %s", addr) if err := http.ListenAndServe(addr, srv); err != nil { diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index 754a44d..ae697c3 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -1,86 +1,286 @@ package graph import ( + "encoding/json" + "fmt" "io" + "log" "net/http" "os" "strings" + "sync" + "time" "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.HandleFunc("/graphql", func(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 - } - 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) - }) + mux.HandleFunc("/graphql", s.handleGraphQL) + mux.HandleFunc("/health", s.handleHealth) uiDir := os.Getenv("UI_DIR") if uiDir == "" { uiDir = "/srv/archivum/ui" } + log.Printf("serving UI from %s", uiDir) mux.Handle("/", spaHandler(uiDir)) - return mux + return requestLogger(mux) } -// spaHandler serves static files and falls back to index.html for all paths -// that don't resolve to a real file — required for client-side routing. +// ── GraphQL handler ─────────────────────────────────────────────────────────── + +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 { - fs := http.Dir(dir) - fileServer := http.FileServer(fs) + fsys := http.Dir(dir) + fileServer := http.FileServer(fsys) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Assets (JS, CSS, images) must be served as-is. - if isAssetPath(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) + // Always try to open the exact path first. + f, err := fsys.Open(r.URL.Path) if err == nil { + fi, statErr := f.Stat() f.Close() - fileServer.ServeHTTP(w, r) - return + // Serve directories as SPA root (e.g. GET /) + if statErr == nil && !fi.IsDir() { + fileServer.ServeHTTP(w, r) + return + } } + // Path doesn't exist or is a directory → serve the SPA shell. http.ServeFile(w, r, dir+"/index.html") }) } -func isAssetPath(path string) bool { - return strings.HasPrefix(path, "/assets/") || - strings.HasPrefix(path, "/icons/") || - path == "/favicon.ico" || - path == "/favicon.svg" || - path == "/manifest.webmanifest" || - path == "/registerSW.js" || - path == "/sw.js" +// ── Request logger middleware ───────────────────────────────────────────────── + +type statusWriter struct { + http.ResponseWriter + status int +} + +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] }