package graph import ( "bufio" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "runtime" "strings" "sync" "time" "github.com/brasse-b/archivum/internal/auth" "github.com/brasse-b/archivum/internal/config" "github.com/brasse-b/archivum/internal/db" "github.com/brasse-b/archivum/internal/git" "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 database *db.DB authMgr *auth.Manager gitRepo *git.Repo } func NewServer(cfg *config.Config, configPath string) http.Handler { s := &Server{cfg: cfg, configPath: configPath} if cfg != nil { s.initRuntime(cfg) } 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("[server] UI from %s", uiDir) mux.Handle("/", spaHandler(uiDir)) return requestLogger(mux) } // initRuntime opens storage, DB, auth manager and git repo from a valid config. func (s *Server) initRuntime(cfg *config.Config) { var st *storage.Store if store, err := storage.New(cfg.StoragePath); err != nil { log.Printf("[storage] failed to open at %s: %v", cfg.StoragePath, err) } else { st = store log.Printf("[storage] opened at %s", cfg.StoragePath) } var d *db.DB if dir := filepath.Dir(cfg.DBPath); dir != "." && dir != "" { if err := os.MkdirAll(dir, 0755); err != nil { log.Printf("[db] failed to create directory %s: %v", dir, err) } } if database, err := db.New(cfg.DBPath); err != nil { log.Printf("[db] failed to open at %s: %v", cfg.DBPath, err) } else { d = database log.Printf("[db] opened at %s", cfg.DBPath) } am := auth.NewManager(cfg) var gr *git.Repo if repo, err := git.Open(cfg.StoragePath); err != nil { log.Printf("[git] repo init failed: %v", err) } else { gr = repo log.Printf("[git] repo ready at %s", cfg.StoragePath) } s.mu.Lock() s.cfg = cfg s.store = st s.database = d s.authMgr = am s.gitRepo = gr s.mu.Unlock() } // ── 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)) // Extract session — may be nil for unauthenticated requests. sess := s.sessionFromRequest(r) s.mu.RLock() cfg := s.cfg store := s.store database := s.database s.mu.RUnlock() needsSetup := cfg == nil || database == nil || !database.HasUsers() q := req.Query switch { // ── Pre-setup / always-available ───────────────────────────────────────── case strings.Contains(q, "setup") && !strings.Contains(q, "systemStatus"): s.handleSetup(w, req) case strings.Contains(q, "systemStatus"): if needsSetup { writeJSON(w, `{"data":{"systemStatus":"REQUIRE_SETUP"}}`) } else { writeJSON(w, `{"data":{"systemStatus":"OK"}}`) } case strings.Contains(q, "testLdapConnection"): s.handleTestLdapConnection(w, req) // ── Auth ───────────────────────────────────────────────────────────────── case strings.Contains(q, "login"): s.handleLogin(w, req) case strings.Contains(q, "logout"): s.handleLogout(w, sess) // ── All others require initialised config ───────────────────────────────── default: if needsSetup { writeGQLError(w, "REQUIRE_SETUP") return } s.dispatchAuthenticated(w, r, req, sess, store) } } func (s *Server) dispatchAuthenticated( w http.ResponseWriter, r *http.Request, req gqlRequest, sess *auth.Session, store *storage.Store, ) { q := req.Query switch { case strings.Contains(q, "saveDocument"): s.handleSaveDocument(w, req, sess) case strings.Contains(q, "deleteDocument"): s.handleDeleteDocument(w, req, sess) case strings.Contains(q, "createUser"): s.handleCreateUser(w, req, sess) case strings.Contains(q, "deleteUser"): s.handleDeleteUser(w, req, sess) case strings.Contains(q, "updateLdapConfig"): s.handleUpdateLdapConfig(w, req, sess) case strings.Contains(q, "changePassword"): s.handleChangePassword(w, req, sess) case strings.Contains(q, "users"): s.handleUsers(w, sess) case strings.Contains(q, "updateStoragePath"): s.handleUpdateStoragePath(w, req, sess) case strings.Contains(q, "serverDirectories"): s.handleServerDirectories(w, req, sess) case strings.Contains(q, "config"): s.handleConfig(w, sess) case strings.Contains(q, "repoStatus"): s.handleRepoStatus(w, sess) case strings.Contains(q, "initCommit"): s.handleInitCommit(w, req, sess) case strings.Contains(q, "history"): s.handleHistory(w, req, sess) case strings.Contains(q, "diff"): s.handleDiff(w, req, sess) case strings.Contains(q, "documentAtCommit"): s.handleDocumentAtCommit(w, req, sess) case strings.Contains(q, "documents") || strings.Contains(q, "document"): s.handleDocuments(w, req, sess, store) default: writeJSON(w, `{"data":{"systemStatus":"OK"}}`) } } func (s *Server) handleServerDirectories(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { s.mu.RLock() cfg := s.cfg s.mu.RUnlock() // Only allow setup state without auth if cfg != nil { if sess == nil { log.Printf("[unauth] session is nil") writeGQLError(w, "UNAUTHORIZED") return } if sess.Role != "admin" { log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) writeGQLError(w, "UNAUTHORIZED") return } } reqPath, _ := req.Variables["path"].(string) if reqPath == "" { if runtime.GOOS == "windows" { reqPath = "C:\\" } else { reqPath = "/" } } else { reqPath = filepath.Clean(reqPath) } var out []map[string]interface{} // Add parent if not at root parent := filepath.Dir(reqPath) if parent != reqPath && parent != "." { out = append(out, map[string]interface{}{ "name": "..", "path": parent, }) } entries, err := os.ReadDir(reqPath) if err != nil { log.Printf("[directories] failed to read dir %q: %v", reqPath, err) writeGQLError(w, fmt.Sprintf("failed to read directory: %v", err)) return } for _, entry := range entries { if entry.IsDir() { out = append(out, map[string]interface{}{ "name": entry.Name(), "path": filepath.Join(reqPath, entry.Name()), }) } } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "serverDirectories": out, }, }) } // ── Session helpers ─────────────────────────────────────────────────────────── func (s *Server) sessionFromRequest(r *http.Request) *auth.Session { raw := r.Header.Get("Authorization") if !strings.HasPrefix(raw, "Bearer ") { log.Printf("[auth] No Bearer token found in header") return nil } token := strings.TrimPrefix(raw, "Bearer ") s.mu.RLock() mgr := s.authMgr s.mu.RUnlock() if mgr == nil { log.Printf("[auth] authmgr is nil") return nil } sess, err := mgr.Validate(token) if err != nil { log.Printf("[auth] Token validation failed: %v", err) return nil } return sess } // ── Document handlers ───────────────────────────────────────────────────────── func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, sess *auth.Session, store *storage.Store) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } if store == nil { writeGQLError(w, "storage not initialised") return } // Single document query: document(slug: ...) or document( if strings.Contains(req.Query, "document(") { slug, _ := req.Variables["s"].(string) if slug == "" { slug, _ = req.Variables["slug"].(string) } if slug == "" { writeGQLError(w, "slug is required") return } log.Printf("[document] user %q is opening document %q", sess.Username, slug) 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) writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "document": map[string]interface{}{ "slug": slug, "content": content, "meta": map[string]string{ "slug": slug, "title": title, "updatedAt": "", }, }, }, }) 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("[document] user %q listed documents (found %d documents)", sess.Username, 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, }, }) } func (s *Server) handleSaveDocument(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } input, _ := req.Variables["i"].(map[string]interface{}) if input == nil { input, _ = req.Variables["input"].(map[string]interface{}) } if input == nil { writeGQLError(w, "missing input") return } slug, _ := input["slug"].(string) content, _ := input["content"].(string) commitMsg, _ := input["commitMessage"].(string) if slug == "" { writeGQLError(w, "slug is required") return } if commitMsg == "" { commitMsg = "Update " + slug } log.Printf("[document] user %q is attempting to save document %q (commit msg: %q)", sess.Username, slug, commitMsg) s.mu.RLock() store := s.store repo := s.gitRepo s.mu.RUnlock() if store == nil { writeGQLError(w, "storage not initialised") return } // Try git commit (writes file + commits). if repo != nil { log.Printf("[git] user %q is committing changes for %q", sess.Username, slug) if err := repo.Commit(slug+".adoc", content, commitMsg, sess.Username, ""); err != nil { log.Printf("[git] commit failed for %q: %v — falling back to plain write", slug, err) } else { log.Printf("[git] successfully committed %q", slug) } } // Always ensure file is written (handles case where git failed). log.Printf("[storage] user %q is writing raw file content for %q", sess.Username, slug) if err := store.Write(slug, content); err != nil { log.Printf("[storage] failed to save %q: %v", slug, err) writeGQLError(w, fmt.Sprintf("failed to save document: %v", err)) return } log.Printf("[storage] successfully saved %q by %s", slug, sess.Username) title := extractTitle(content, slug) writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "saveDocument": map[string]interface{}{ "slug": slug, "content": content, "meta": map[string]string{ "slug": slug, "title": title, "updatedAt": "", }, }, }, }) } func (s *Server) handleDeleteDocument(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } slug, _ := req.Variables["slug"].(string) if slug == "" { writeGQLError(w, "slug is required") return } s.mu.RLock() store := s.store s.mu.RUnlock() if store == nil { writeGQLError(w, "storage not initialised") return } if err := store.Delete(slug); err != nil { writeGQLError(w, fmt.Sprintf("failed to delete document: %v", err)) return } log.Printf("[storage] deleted %q by %s", slug, sess.Username) writeJSON(w, `{"data":{"deleteDocument":true}}`) } // ── Auth handlers ───────────────────────────────────────────────────────────── func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) { username, _ := req.Variables["u"].(string) password, _ := req.Variables["p"].(string) if username == "" { username, _ = req.Variables["username"].(string) } if password == "" { password, _ = req.Variables["password"].(string) } s.mu.RLock() database := s.database mgr := s.authMgr s.mu.RUnlock() if database == nil || mgr == nil { writeGQLError(w, "server not initialised") return } token, role, err := mgr.Login(database, username, password) if err != nil { log.Printf("[auth] login failed for %q: %v", username, err) writeGQLError(w, "invalid credentials") return } log.Printf("[auth] login: %s (%s)", username, role) writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "login": token, }, }) } func (s *Server) handleLogout(w http.ResponseWriter, sess *auth.Session) { if sess != nil { s.mu.RLock() mgr := s.authMgr s.mu.RUnlock() if mgr != nil { mgr.Logout(sess.Token) } log.Printf("[auth] logout: %s", sess.Username) } writeJSON(w, `{"data":{"logout":true}}`) } // ── Admin handlers ──────────────────────────────────────────────────────────── func (s *Server) handleUsers(w http.ResponseWriter, sess *auth.Session) { if sess == nil { log.Printf("[unauth] session is nil") writeGQLError(w, "UNAUTHORIZED") return } if sess.Role != "admin" { log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) writeGQLError(w, "UNAUTHORIZED") return } s.mu.RLock() database := s.database s.mu.RUnlock() if database == nil { writeGQLError(w, "database not initialised") return } users, err := database.ListUsers() if err != nil { writeGQLError(w, fmt.Sprintf("failed to list users: %v", err)) return } list := make([]map[string]string, 0, len(users)) for _, u := range users { list = append(list, map[string]string{ "username": u.Username, "role": u.Role, "createdAt": u.CreatedAt.Format(time.RFC3339), }) } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{"users": list}, }) } func (s *Server) handleCreateUser(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { log.Printf("[unauth] session is nil") writeGQLError(w, "UNAUTHORIZED") return } if sess.Role != "admin" { log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) writeGQLError(w, "UNAUTHORIZED") return } username, _ := req.Variables["u"].(string) password, _ := req.Variables["p"].(string) role, _ := req.Variables["r"].(string) if role == "" { role = "user" } if username == "" || password == "" { writeGQLError(w, "username and password are required") return } hash, err := auth.HashPassword(password) if err != nil { writeGQLError(w, "failed to hash password") return } s.mu.RLock() database := s.database s.mu.RUnlock() if database == nil { writeGQLError(w, "database not initialised") return } if err := database.CreateUser(username, hash, role); err != nil { writeGQLError(w, fmt.Sprintf("failed to create user: %v", err)) return } log.Printf("[admin] user created: %s (%s) by %s", username, role, sess.Username) writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "createUser": map[string]string{ "username": username, "role": role, "createdAt": time.Now().Format(time.RFC3339), }, }, }) } func (s *Server) handleDeleteUser(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { log.Printf("[unauth] session is nil") writeGQLError(w, "UNAUTHORIZED") return } if sess.Role != "admin" { log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) writeGQLError(w, "UNAUTHORIZED") return } username, _ := req.Variables["username"].(string) if username == "" { writeGQLError(w, "username is required") return } if username == sess.Username { writeGQLError(w, "cannot delete your own account") return } s.mu.RLock() database := s.database s.mu.RUnlock() if database == nil { writeGQLError(w, "database not initialised") return } if err := database.DeleteUser(username); err != nil { writeGQLError(w, fmt.Sprintf("failed to delete user: %v", err)) return } log.Printf("[admin] user deleted: %s by %s", username, sess.Username) writeJSON(w, `{"data":{"deleteUser":true}}`) } func (s *Server) handleChangePassword(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } oldPass, _ := req.Variables["old"].(string) newPass, _ := req.Variables["new"].(string) if oldPass == "" || newPass == "" { writeGQLError(w, "oldPassword and newPassword are required") return } if len(newPass) < 8 { writeGQLError(w, "new password must be at least 8 characters") return } s.mu.RLock() database := s.database s.mu.RUnlock() if database == nil { writeGQLError(w, "database not initialised") return } user, err := database.GetUser(sess.Username) if err != nil { writeGQLError(w, "user not found") return } if err := auth.CheckPassword(user.PassHash, oldPass); err != nil { writeGQLError(w, "incorrect current password") return } hash, err := auth.HashPassword(newPass) if err != nil { writeGQLError(w, "failed to hash password") return } if err := database.UpdatePassword(sess.Username, hash); err != nil { writeGQLError(w, fmt.Sprintf("failed to update password: %v", err)) return } log.Printf("[auth] password changed for %s", sess.Username) writeJSON(w, `{"data":{"changePassword":true}}`) } func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { log.Printf("[unauth] session is nil") writeGQLError(w, "UNAUTHORIZED") return } if sess.Role != "admin" { log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) writeGQLError(w, "UNAUTHORIZED") return } s.mu.RLock() cfg := s.cfg s.mu.RUnlock() if cfg == nil { writeGQLError(w, "server not initialised") return } input, hasInput := req.Variables["input"].(map[string]interface{}) if !hasInput { input, hasInput = req.Variables["i"].(map[string]interface{}) } newCfg := *cfg // copy if hasInput && input != nil { port := 389 if p, ok := input["port"].(float64); ok { port = int(p) } newCfg.LDAP = config.LDAPConfig{ Host: strVal(input, "host"), Port: port, BaseDN: strVal(input, "baseDN"), BindDN: strVal(input, "bindDN"), BindPassword: strVal(input, "bindPassword"), } } else { // Disable LDAP newCfg.LDAP = config.LDAPConfig{} } if err := config.Save(s.configPath, &newCfg); err != nil { writeGQLError(w, fmt.Sprintf("failed to save config: %v", err)) return } s.initRuntime(&newCfg) log.Printf("[admin] LDAP config updated by %s", sess.Username) writeJSON(w, `{"data":{"updateLdapConfig":true}}`) } // ── Setup handler ───────────────────────────────────────────────────────────── func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) { if sess == nil { log.Printf("[unauth] session is nil") writeGQLError(w, "UNAUTHORIZED") return } if sess.Role != "admin" { log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) writeGQLError(w, "UNAUTHORIZED") return } s.mu.RLock() cfg := s.cfg s.mu.RUnlock() if cfg == nil { writeGQLError(w, "server not initialised") return } var ldap map[string]interface{} if cfg.LDAP.Host != "" { ldap = map[string]interface{}{ "host": cfg.LDAP.Host, "port": cfg.LDAP.Port, "baseDN": cfg.LDAP.BaseDN, "bindDN": cfg.LDAP.BindDN, } } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "config": map[string]interface{}{ "storagePath": cfg.StoragePath, "ldap": ldap, }, }, }) } func (s *Server) handleRepoStatus(w http.ResponseWriter, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } s.mu.RLock() repo := s.gitRepo s.mu.RUnlock() hasUncommitted := repo != nil && repo.HasUncommitted() writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "repoStatus": map[string]interface{}{ "hasUncommitted": hasUncommitted, }, }, }) } func (s *Server) handleInitCommit(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } message, _ := req.Variables["message"].(string) if message == "" { message = "Initial commit" } s.mu.RLock() repo := s.gitRepo s.mu.RUnlock() if repo == nil { writeGQLError(w, "storage not initialised") return } if !repo.HasUncommitted() { // Nothing to commit — return success without error. writeJSON(w, `{"data":{"initCommit":true}}`) return } email := sess.Username + "@archivum" if err := repo.CommitAll(message, sess.Username, email); err != nil { log.Printf("[git] initCommit failed for %s: %v", sess.Username, err) writeGQLError(w, fmt.Sprintf("commit failed: %v", err)) return } log.Printf("[git] initCommit by %s: %q", sess.Username, message) writeJSON(w, `{"data":{"initCommit":true}}`) } func (s *Server) handleUpdateStoragePath(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { log.Printf("[unauth] session is nil") writeGQLError(w, "UNAUTHORIZED") return } if sess.Role != "admin" { log.Printf("[unauth] user %s is not admin, role is %q", sess.Username, sess.Role) writeGQLError(w, "UNAUTHORIZED") return } s.mu.RLock() cfg := s.cfg s.mu.RUnlock() if cfg == nil { writeGQLError(w, "server not initialised") return } path, ok := req.Variables["path"].(string) if !ok || path == "" { writeGQLError(w, "invalid or empty path") return } newCfg := *cfg newCfg.StoragePath = path if err := config.Save(s.configPath, &newCfg); err != nil { writeGQLError(w, fmt.Sprintf("failed to save config: %v", err)) return } s.initRuntime(&newCfg) log.Printf("[admin] Storage path updated by %q to %q", sess.Username, path) writeJSON(w, `{"data":{"updateStoragePath":true}}`) } 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) adminUser, _ := input["adminUser"].(string) adminPass, _ := input["adminPass"].(string) if storagePath == "" || jwtSecret == "" || adminUser == "" || adminPass == "" { writeGQLError(w, "storagePath, jwtSecret, adminUser and adminPass are required") return } cfg := &config.Config{ StoragePath: storagePath, DBPath: resolveDBPath(s.configPath), 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"), } } // Open database and create admin user BEFORE saving config, // so a failed DB creation does not leave a broken config.json on disk. if err := os.MkdirAll(dirOf(cfg.DBPath), 0755); err != nil { writeGQLError(w, fmt.Sprintf("could not create db directory: %v", err)) return } database, err := db.New(cfg.DBPath) if err != nil { writeGQLError(w, fmt.Sprintf("failed to open database: %v", err)) return } hash, err := auth.HashPassword(adminPass) if err != nil { writeGQLError(w, "failed to hash admin password") return } if err := database.CreateUser(adminUser, hash, "admin"); err != nil { writeGQLError(w, fmt.Sprintf("failed to create admin user: %v", err)) return } log.Printf("[setup] admin user %q created", adminUser) if err := os.MkdirAll(dirOf(s.configPath), 0755); err != nil { writeGQLError(w, fmt.Sprintf("could not create config directory: %v", err)) return } if err := config.Save(s.configPath, cfg); err != nil { 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) } repo, err := git.Open(storagePath) if err != nil { log.Printf("[setup] git init failed: %v", err) } s.mu.Lock() s.cfg = cfg s.store = store s.database = database s.authMgr = auth.NewManager(cfg) s.gitRepo = repo s.mu.Unlock() log.Printf("[setup] complete") writeJSON(w, `{"data":{"setup":true}}`) } // ── LDAP test handler ───────────────────────────────────────────────────────── func (s *Server) handleTestLdapConnection(w http.ResponseWriter, req gqlRequest) { input, _ := req.Variables["i"].(map[string]interface{}) if input == nil { writeGQLError(w, "missing LDAP input") return } host := strVal(input, "host") port := 389 if p, ok := input["port"].(float64); ok { port = int(p) } bindDN := strVal(input, "bindDN") bindPassword := strVal(input, "bindPassword") if host == "" { writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "testLdapConnection": map[string]interface{}{ "success": false, "message": "host is required", }, }, }) return } err := auth.TestLDAP(host, port, bindDN, bindPassword) if err != nil { log.Printf("[ldap] test failed (%s:%d): %v", host, port, err) writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "testLdapConnection": map[string]interface{}{ "success": false, "message": err.Error(), }, }, }) return } log.Printf("[ldap] test succeeded (%s:%d)", host, port) writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "testLdapConnection": map[string]interface{}{ "success": true, "message": "Connection successful", }, }, }) } // ── Health handler ──────────────────────────────────────────────────────────── 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 ─────────────────────────────────────────────────────────────────── 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, "= ") } } 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] } // resolveDBPath returns the best DB path given the config file location. // Prefers /data/db/archivum.db (Docker volume) if /data is writable; // otherwise places the DB next to the config file. func resolveDBPath(configPath string) string { const dockerDB = "/data/db/archivum.db" if err := os.MkdirAll("/data/db", 0755); err == nil { return dockerDB } return filepath.Join(dirOf(configPath), "archivum.db") } func (s *Server) handleHistory(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED"); return } slug, _ := req.Variables["slug"].(string) if slug == "" { writeGQLError(w, "slug required"); return } s.mu.RLock() repo := s.gitRepo s.mu.RUnlock() if repo == nil { writeGQLError(w, "git not initialized") return } history, err := repo.Log(slug + ".adoc") if err != nil { writeGQLError(w, err.Error()) return } var out []map[string]interface{} for _, entry := range history { out = append(out, map[string]interface{}{ "hash": entry.Hash, "author": entry.Author, "email": entry.Email, "date": entry.Date, "subject": entry.Subject, "added": entry.Added, "removed": entry.Removed, }) } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "history": out } }) } func (s *Server) handleDiff(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED"); return } slug, _ := req.Variables["slug"].(string) fromHash, _ := req.Variables["fromHash"].(string) toHash, _ := req.Variables["toHash"].(string) s.mu.RLock() repo := s.gitRepo s.mu.RUnlock() if repo == nil { writeGQLError(w, "git not initialized"); return } diff, err := repo.Diff(slug + ".adoc", fromHash, toHash) if err != nil { writeGQLError(w, err.Error()); return } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "diff": diff } }) } func (s *Server) handleDocumentAtCommit(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED"); return } slug, _ := req.Variables["slug"].(string) hash, _ := req.Variables["hash"].(string) s.mu.RLock() repo := s.gitRepo s.mu.RUnlock() if repo == nil { writeGQLError(w, "git not initialized"); return } content, err := repo.Show(hash, slug + ".adoc") if err != nil { writeGQLError(w, err.Error()); return } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "documentAtCommit": content } }) }