diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index 81f706c..b253aa3 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -8,11 +8,16 @@ import ( "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" ) @@ -22,18 +27,16 @@ type Server struct { 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 { - 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) - } + s.initRuntime(cfg) } mux := http.NewServeMux() @@ -44,12 +47,49 @@ func NewServer(cfg *config.Config, configPath string) http.Handler { if uiDir == "" { uiDir = "/srv/archivum/ui" } - log.Printf("serving UI from %s", uiDir) + 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 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 { @@ -81,65 +121,212 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) { 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 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 - } + q := req.Query switch { - case strings.Contains(req.Query, "testLdapConnection"): - writeJSON(w, `{"data":{"testLdapConnection":{"success":false,"message":"LDAP resolver not yet implemented"}}}`) + // ── Pre-setup / always-available ───────────────────────────────────────── + case strings.Contains(q, "setup") && !strings.Contains(q, "systemStatus"): + s.handleSetup(w, req) - case strings.Contains(req.Query, "login"): - writeJSON(w, `{"data":{"login":"stub-token"}}`) + case strings.Contains(q, "systemStatus"): + if cfg == nil { + writeJSON(w, `{"data":{"systemStatus":"REQUIRE_SETUP"}}`) + } else { + writeJSON(w, `{"data":{"systemStatus":"OK"}}`) + } - case strings.Contains(req.Query, "documents") || strings.Contains(req.Query, "document"): - s.handleDocuments(w, req, store) + 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 cfg == nil { + 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, "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"}}`) } } -// handleDocuments reads document list (or a single doc) from the real Store. -func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, store *storage.Store) { +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 ") { + return nil + } + token := strings.TrimPrefix(raw, "Bearer ") + + s.mu.RLock() + mgr := s.authMgr + s.mu.RUnlock() + + if mgr == nil { + return nil + } + sess, _ := mgr.Validate(token) + 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 - if strings.Contains(req.Query, "document(") || strings.Contains(req.Query, "document(slug") { + // 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) @@ -147,7 +334,7 @@ func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, store *s return } title := extractTitle(content, slug) - resp := map[string]interface{}{ + writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "document": map[string]interface{}{ "slug": slug, @@ -159,8 +346,7 @@ func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, store *s }, }, }, - } - writeJSONObj(w, resp) + }) return } @@ -172,7 +358,7 @@ func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, store *s return } - log.Printf("[storage] listed %d documents", len(slugs)) + 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 { @@ -195,7 +381,489 @@ func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, store *s }) } -// handleSetup parses the setup input, writes config.json, and activates config + storage. +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": map[string]interface{}{ + "token": token, + "username": username, + "role": role, + }, + }, + }) +} + +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) 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 { @@ -206,9 +874,11 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { storagePath, _ := input["storagePath"].(string) jwtSecret, _ := input["jwtSecret"].(string) + adminUser, _ := input["adminUser"].(string) + adminPass, _ := input["adminPass"].(string) - if storagePath == "" || jwtSecret == "" { - writeGQLError(w, "storagePath and jwtSecret are required") + if storagePath == "" || jwtSecret == "" || adminUser == "" || adminPass == "" { + writeGQLError(w, "storagePath, jwtSecret, adminUser and adminPass are required") return } @@ -234,36 +904,117 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { } 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) + // Open database and create admin user. + 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) + 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) + } + + 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 — storage: %s", storagePath) + 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 @@ -325,8 +1076,6 @@ func requestLogger(next http.Handler) http.Handler { // ── 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() { @@ -335,7 +1084,6 @@ func extractTitle(content, slug string) string { 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] } @@ -393,3 +1141,75 @@ func dirOf(path string) string { } return path[:idx] } + + + +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, + }) + } + 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 } }) +} + +