package graph import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "strings" "sync" "time" "github.com/brasse-b/archivum/internal/config" ) // 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", 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 requestLogger(mux) } // ── 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 { fsys := http.Dir(dir) fileServer := http.FileServer(fsys) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Always try to open the exact path first. f, err := fsys.Open(r.URL.Path) if err == nil { fi, statErr := f.Stat() f.Close() // 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") }) } // ── 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] }