package graph import ( "bufio" "encoding/json" "fmt" "io" "log" "net/http" "os" "strings" "sync" "time" "github.com/brasse-b/archivum/internal/config" "github.com/brasse-b/archivum/internal/storage" ) // Server holds runtime state that can change after the setup wizard completes. type Server struct { mu sync.RWMutex cfg *config.Config configPath string store *storage.Store } func NewServer(cfg *config.Config, configPath string) http.Handler { s := &Server{cfg: cfg, configPath: configPath} if cfg != nil { if store, err := storage.New(cfg.StoragePath); err != nil { log.Printf("[storage] failed to open storage at %s: %v", cfg.StoragePath, err) } else { s.store = store log.Printf("[storage] opened at %s", cfg.StoragePath) } } 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 store := s.store s.mu.RUnlock() // ── Mutations available before setup ───────────────────────────────────── if strings.Contains(req.Query, "setup") { s.handleSetup(w, req) return } // ── System status (always available) ───────────────────────────────────── if strings.Contains(req.Query, "systemStatus") && !strings.Contains(req.Query, "documents") { 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") || strings.Contains(req.Query, "document"): s.handleDocuments(w, req, store) default: writeJSON(w, `{"data":{"systemStatus":"OK"}}`) } } // handleDocuments reads document list (or a single doc) from the real Store. func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, store *storage.Store) { if store == nil { writeGQLError(w, "storage not initialised") return } // Single document query if strings.Contains(req.Query, "document(") || strings.Contains(req.Query, "document(slug") { slug, _ := req.Variables["s"].(string) if slug == "" { writeGQLError(w, "slug is required") return } content, err := store.Read(slug) if err != nil { log.Printf("[storage] read %q: %v", slug, err) writeGQLError(w, fmt.Sprintf("document not found: %s", slug)) return } title := extractTitle(content, slug) resp := map[string]interface{}{ "data": map[string]interface{}{ "document": map[string]interface{}{ "slug": slug, "content": content, "meta": map[string]string{ "slug": slug, "title": title, "updatedAt": "", }, }, }, } writeJSONObj(w, resp) return } // Document list slugs, err := store.List("") if err != nil { log.Printf("[storage] list: %v", err) writeGQLError(w, fmt.Sprintf("failed to list documents: %v", err)) return } log.Printf("[storage] listed %d documents", len(slugs)) docs := make([]map[string]string, 0, len(slugs)) for _, slug := range slugs { content, err := store.Read(slug) title := slug if err == nil { title = extractTitle(content, slug) } docs = append(docs, map[string]string{ "slug": slug, "title": title, "updatedAt": "", }) } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "documents": docs, }, }) } // handleSetup parses the setup input, writes config.json, and activates config + storage. 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", } 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"), } } 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) store, err := storage.New(storagePath) if err != nil { log.Printf("[setup] failed to open storage at %s: %v", storagePath, err) // Non-fatal — storage can be retried, but config is saved. } else { log.Printf("[setup] storage opened at %s", storagePath) } s.mu.Lock() s.cfg = cfg s.store = store 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() w.Header().Set("Content-Type", "application/json") 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) { f, err := fsys.Open(r.URL.Path) if err == nil { fi, statErr := f.Stat() f.Close() if statErr == nil && !fi.IsDir() { fileServer.ServeHTTP(w, r) return } } 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) 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 ─────────────────────────────────────────────────────────────────── // extractTitle reads the first AsciiDoc heading line (= Title) from content. // Falls back to the slug if no heading is found. func extractTitle(content, slug string) string { scanner := bufio.NewScanner(strings.NewReader(content)) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "= ") { return strings.TrimPrefix(line, "= ") } } // Use the last path segment of the slug as a readable fallback. parts := strings.Split(slug, "/") return parts[len(parts)-1] } func writeJSON(w http.ResponseWriter, body string) { _, _ = w.Write([]byte(body)) } func writeJSONObj(w http.ResponseWriter, v interface{}) { b, _ := json.Marshal(v) _, _ = w.Write(b) } 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) switch { case strings.HasPrefix(q, "mutation"): return "mutation " + firstWord(strings.TrimSpace(q[len("mutation"):])) case strings.HasPrefix(q, "query"): return "query " + firstWord(strings.TrimSpace(q[len("query"):])) case strings.HasPrefix(q, "{"): inner := strings.TrimSpace(q[1:]) return "query {" + firstWord(inner) + "...}" default: 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] }