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) mux.HandleFunc("/api/upload", s.handleUpload) mux.HandleFunc("/media/", s.handleMedia) 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 // Ensure guest user exists (handles upgrades from older versions). if err := d.EnsureGuestUser(); err != nil { log.Printf("[db] warning: could not ensure guest user: %v", err) } 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, "userAuthType"): s.handleUserAuthType(w, req) 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, "deleteImage"): s.handleDeleteImage(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, "ldapBrowse"): s.handleLdapBrowse(w, req, sess) case strings.Contains(q, "aclSubjects"): s.handleAclSubjects(w, sess) case strings.Contains(q, "users"): s.handleUsers(w, sess) case strings.Contains(q, "currentUserRole"): s.handleCurrentUserRole(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, "images"): s.handleImages(w, req, sess) case strings.Contains(q, "createFolder"): s.handleCreateFolder(w, req, sess) case strings.Contains(q, "moveDocument"): s.handleMoveDocument(w, req, sess) case strings.Contains(q, "importLdapSubject"): s.handleImportLdapSubject(w, req, sess) case strings.Contains(q, "aclSubjects"): s.handleAclSubjects(w, sess) case strings.Contains(q, "subjectAcl"): s.handleSubjectAcl(w, req, sess) case strings.Contains(q, "acl(") || strings.Contains(q, "acl "): s.handleAcl(w, req, sess) case strings.Contains(q, "setAcl"): s.handleSetAcl(w, req, sess) case strings.Contains(q, "removeAcl"): s.handleRemoveAcl(w, req, sess) case strings.Contains(q, "folders"): s.handleFolders(w, 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 == "" { // Also try "p" just in case the frontend sends p if p, ok := req.Variables["p"].(string); ok && p != "" { reqPath = p } } 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, "isGitRepo": false, "hasDocuments": false, }) } 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() { fullPath := filepath.Join(reqPath, entry.Name()) isGit := false if _, err := os.Stat(filepath.Join(fullPath, ".git")); err == nil { isGit = true } hasDocs := false childEntries, _ := os.ReadDir(fullPath) for _, child := range childEntries { if !child.IsDir() && (strings.HasSuffix(child.Name(), ".md") || strings.HasSuffix(child.Name(), ".adoc")) { hasDocs = true break } } out = append(out, map[string]interface{}{ "name": entry.Name(), "path": fullPath, "isGitRepo": isGit, "hasDocuments": hasDocs, }) } } 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) // Guest users may only read documents they have been given access to. s.mu.RLock() database := s.database s.mu.RUnlock() if sess.Role == "guest" && database != nil { if !database.CanUserReadPath(sess.Username, slug) && !database.CanUserViewPath(sess.Username, slug) { writeGQLError(w, "UNAUTHORIZED") 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) 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)) s.mu.RLock() database := s.database s.mu.RUnlock() docs := make([]map[string]string, 0, len(slugs)) for _, slug := range slugs { // Guest users may only see documents they have explicit read or view access to. if sess.Role == "guest" && database != nil { if !database.CanUserReadPath(sess.Username, slug) && !database.CanUserViewPath(sess.Username, slug) { continue } } 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) } // ldapPassword carries the plaintext password sent by the frontend for LDAP users. ldapPassword, _ := req.Variables["ldapPassword"].(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, ldapPassword) 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) handleUserAuthType(w http.ResponseWriter, req gqlRequest) { username := strVal(req.Variables, "username") if username == "" { username, _ = req.Variables["u"].(string) } s.mu.RLock() database := s.database s.mu.RUnlock() if database == nil { writeGQLError(w, "server not initialised") return } authType := auth.UserAuthType(database, username) writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{"userAuthType": authType}, }) } 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) handleSubjectAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil || sess.Role != "admin" { writeGQLError(w, "UNAUTHORIZED") return } subjectType := strVal(req.Variables, "subjectType") subjectIDf, ok := req.Variables["subjectId"].(float64) if !ok || subjectType == "" { writeGQLError(w, "Missing subjectType or subjectId") return } s.mu.RLock() database := s.database s.mu.RUnlock() acls, err := database.GetACLsForSubject(subjectType, int64(subjectIDf)) if err != nil { writeGQLError(w, err.Error()) return } var aclData []map[string]interface{} for _, a := range acls { aclData = append(aclData, map[string]interface{}{ "id": a.ID, "path": a.Path, "subjectType": a.SubjectType, "subjectId": a.SubjectID, "canSearch": a.CanSearch, "canView": a.CanView, "canRead": a.CanRead, "canEdit": a.CanEdit, "canCreate": a.CanCreate, "canDelete": a.CanDelete, "canMove": a.CanMove, }) } if aclData == nil { aclData = []map[string]interface{}{} } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{"subjectAcl": aclData}, }) } func (s *Server) handleCurrentUserRole(w http.ResponseWriter, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "currentUserRole": sess.Role, }, }) } func (s *Server) handleAclSubjects(w http.ResponseWriter, sess *auth.Session) { if sess == nil || sess.Role != "admin" { 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 } groups, err := database.ListGroups() if err != nil { writeGQLError(w, fmt.Sprintf("failed to list groups: %v", err)) return } userList := make([]map[string]interface{}, 0, len(users)) for _, u := range users { userList = append(userList, map[string]interface{}{ "id": u.ID, "name": u.Username, "isLdap": u.IsLDAP, }) } groupList := make([]map[string]interface{}, 0, len(groups)) for _, g := range groups { groupList = append(groupList, map[string]interface{}{ "id": g.ID, "name": g.Name, "isLdap": g.IsLDAP, }) } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "aclSubjects": map[string]interface{}{ "users": userList, "groups": groupList, }, }, }) } 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 mgr := s.authMgr 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 { newPass := strVal(input, "adminPass") if newPass == "" { newPass = cfg.LDAP.AdminPass // preserve existing password if none provided } newCfg.LDAP = config.LDAPConfig{ Url: strVal(input, "url"), BaseDN: strVal(input, "baseDN"), AdminUser: strVal(input, "adminUser"), AdminPass: newPass, } } 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 } // Update the config in-place without reinitialising the runtime. // This preserves all active sessions while applying the new LDAP settings. s.mu.Lock() s.cfg = &newCfg s.mu.Unlock() if mgr != nil { mgr.UpdateConfig(&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 ldapCfg map[string]interface{} if cfg.LDAP.Url != "" { ldapCfg = map[string]interface{}{ "url": cfg.LDAP.Url, "baseDN": cfg.LDAP.BaseDN, "adminUser": cfg.LDAP.AdminUser, } } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "config": map[string]interface{}{ "storagePath": cfg.StoragePath, "ldap": ldapCfg, }, }, }) } 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 { cfg.LDAP = config.LDAPConfig{ Url: strVal(ldapRaw, "url"), AdminUser: strVal(ldapRaw, "adminUser"), AdminPass: strVal(ldapRaw, "adminPass"), } } // 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 } // Ensure the built-in guest account exists (no password, role="guest"). if err := database.EnsureGuestUser(); err != nil { log.Printf("[setup] warning: could not create guest user: %v", err) } 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) { url_ := strVal(req.Variables, "url") adminUser := strVal(req.Variables, "adminUser") adminPass := strVal(req.Variables, "adminPass") if url_ == "" { writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "testLdapConnection": map[string]interface{}{ "success": false, "message": "url is required", }, }, }) return } err := auth.TestLDAP(url_, adminUser, adminPass) if err != nil { log.Printf("[ldap] test failed (%s): %v", url_, 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)", url_) 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 { if m == nil { return "" } v, _ := m[key].(string) return v } // ── LDAP & ACL Additions ────────────────────────────────────────────────────── func (s *Server) handleLdapBrowse(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil || sess.Role != "admin" { writeGQLError(w, "UNAUTHORIZED") return } url_ := strVal(req.Variables, "url") adminUser := strVal(req.Variables, "adminUser") adminPass := strVal(req.Variables, "adminPass") baseDN := strVal(req.Variables, "baseDN") if url_ == "" { s.mu.RLock() if s.cfg != nil && s.cfg.LDAP.Url != "" { url_ = s.cfg.LDAP.Url baseDN = s.cfg.LDAP.BaseDN adminUser = s.cfg.LDAP.AdminUser adminPass = s.cfg.LDAP.AdminPass } s.mu.RUnlock() } users, groups, err := auth.BrowseLDAP(url_, baseDN, adminUser, adminPass) if err != nil { writeGQLError(w, fmt.Sprintf("LDAP traverse error: %v", err)) return } if users == nil { users = []string{} } if groups == nil { groups = []string{} } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "ldapBrowse": map[string]interface{}{ "users": users, "groups": groups, }, }, }) } func (s *Server) handleImportLdapSubject(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil || sess.Role != "admin" { writeGQLError(w, "UNAUTHORIZED") return } typ := strVal(req.Variables, "type") name := strVal(req.Variables, "name") s.mu.RLock() database := s.database s.mu.RUnlock() var err error if typ == "user" { err = database.CreateOrUpdateLDAPUser(name) } else if typ == "group" { err = database.CreateOrUpdateGroup(name, true) } else { writeGQLError(w, "Invalid subject type") return } if err != nil { writeGQLError(w, fmt.Sprintf("DB Error: %v", err)) return } writeJSON(w, `{"data":{"importLdapSubject":true}}`) } func (s *Server) handleSetAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil || sess.Role != "admin" { writeGQLError(w, "UNAUTHORIZED") return } input, _ := req.Variables["input"].(map[string]interface{}) if input == nil { writeGQLError(w, "Missing input") return } s.mu.RLock() database := s.database s.mu.RUnlock() var entry db.ACLEntry entry.Path = strVal(input, "path") entry.SubjectType = strVal(input, "subjectType") if idF, ok := input["subjectId"].(float64); ok { entry.SubjectID = int64(idF) } else { writeGQLError(w, "Missing subjectId") return } entry.CanSearch, _ = input["canSearch"].(bool) entry.CanView, _ = input["canView"].(bool) entry.CanRead, _ = input["canRead"].(bool) entry.CanEdit, _ = input["canEdit"].(bool) entry.CanCreate, _ = input["canCreate"].(bool) entry.CanDelete, _ = input["canDelete"].(bool) entry.CanMove, _ = input["canMove"].(bool) if err := database.SetACL(entry); err != nil { writeGQLError(w, err.Error()) return } writeJSON(w, `{"data":{"setAcl":true}}`) } func (s *Server) handleRemoveAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil || sess.Role != "admin" { writeGQLError(w, "UNAUTHORIZED") return } idF, ok := req.Variables["id"].(float64) if !ok { writeGQLError(w, "Missing id") return } s.mu.RLock() database := s.database s.mu.RUnlock() if err := database.RemoveACL(int64(idF)); err != nil { writeGQLError(w, err.Error()) return } writeJSON(w, `{"data":{"removeAcl":true}}`) } func (s *Server) handleAcl(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } path := strVal(req.Variables, "path") if path == "" { writeGQLError(w, "Missing path") return } // Example: Allow everyone to see ACLs for now, or restrict to admin s.mu.RLock() database := s.database s.mu.RUnlock() acls, err := database.GetACLsForPath(path) if err != nil { writeGQLError(w, err.Error()) return } var aclData []map[string]interface{} for _, a := range acls { aclData = append(aclData, map[string]interface{}{ "id": a.ID, "path": a.Path, "subjectType": a.SubjectType, "subjectId": a.SubjectID, "canSearch": a.CanSearch, "canView": a.CanView, "canRead": a.CanRead, "canEdit": a.CanEdit, "canCreate": a.CanCreate, "canDelete": a.CanDelete, "canMove": a.CanMove, }) } if aclData == nil { aclData = []map[string]interface{}{} } writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "acl": aclData, }, }) } 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}}) } func (s *Server) handleCreateFolder(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } path, _ := req.Variables["path"].(string) if path == "" { writeGQLError(w, "path is required") return } s.mu.RLock() store := s.store s.mu.RUnlock() if store == nil { writeGQLError(w, "storage not initialised") return } if err := store.CreateFolder(path); err != nil { log.Printf("[storage] failed to create folder %q: %v", path, err) writeGQLError(w, fmt.Sprintf("failed to create folder: %v", err)) return } log.Printf("[storage] folder created at %q by %s", path, sess.Username) writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"createFolder": true}}) } func (s *Server) handleMoveDocument(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } oldSlug, _ := req.Variables["oldSlug"].(string) newSlug, _ := req.Variables["newSlug"].(string) if oldSlug == "" { writeGQLError(w, "oldSlug is required") return } if newSlug == "" { writeGQLError(w, "newSlug is required") return } s.mu.RLock() store := s.store repo := s.gitRepo s.mu.RUnlock() if store == nil { writeGQLError(w, "storage not initialised") return } var err error if repo != nil { // Attempt to use git mv dir := filepath.ToSlash(filepath.Dir(newSlug)) if dir != "." && dir != "" { _ = store.CreateFolder(dir) } err = repo.Move(oldSlug+".adoc", newSlug+".adoc", "Move "+oldSlug+" to "+newSlug, sess.Username, "") if err != nil { err = store.MoveDocument(oldSlug, newSlug) } } else { err = store.MoveDocument(oldSlug, newSlug) } if err != nil { log.Printf("[storage] failed to move document from %q to %q: %v", oldSlug, newSlug, err) writeGQLError(w, fmt.Sprintf("failed to move document: %v", err)) return } log.Printf("[storage] document moved from %q to %q by %s", oldSlug, newSlug, sess.Username) writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"moveDocument": true}}) } func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } s.mu.RLock() am := s.authMgr store := s.store s.mu.RUnlock() // Check auth authHeader := r.Header.Get("Authorization") token := strings.TrimPrefix(authHeader, "Bearer ") if token == "" || am == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } sess, errLogin := am.Validate(token) if errLogin != nil || sess == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } err := r.ParseMultipartForm(10 << 20) // 10 MB if err != nil { http.Error(w, "Failed to parse form", http.StatusBadRequest) return } slug := r.FormValue("slug") if slug == "" { http.Error(w, "Missing slug", http.StatusBadRequest) return } file, header, err := r.FormFile("image") if err != nil { http.Error(w, "Missing image", http.StatusBadRequest) return } defer file.Close() data, err := io.ReadAll(file) if err != nil { http.Error(w, "Failed to read image", http.StatusInternalServerError) return } if err := store.SaveImage(slug, header.Filename, data); err != nil { http.Error(w, "Failed to save image", http.StatusInternalServerError) return } // Calculate url relPath := strings.TrimPrefix(filepath.ToSlash(filepath.Join(filepath.Dir(slug), header.Filename)), "/") url := "/media/" + relPath resp := map[string]interface{}{"url": url, "name": header.Filename, "size": len(data)} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } s.mu.RLock() store := s.store s.mu.RUnlock() if store == nil { http.Error(w, "Storage not initialized", http.StatusInternalServerError) return } relPath := strings.TrimPrefix(r.URL.Path, "/media/") fullPath, err := store.GetImagePath(relPath) if err != nil { http.Error(w, "Invalid path", http.StatusBadRequest) return } http.ServeFile(w, r, fullPath) } func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } s.mu.RLock() store := s.store database := s.database s.mu.RUnlock() if store == nil { writeGQLError(w, "storage not initialised") return } folders, err := store.ListFolders() if err != nil { log.Printf("[folders] failed to list folders: %v", err) writeGQLError(w, fmt.Sprintf("failed to list folders: %v", err)) return } // Guest users may only see folders they have been granted view access to. if sess.Role == "guest" && database != nil { visible := folders[:0] for _, f := range folders { if database.CanUserViewPath(sess.Username, f) { visible = append(visible, f) } } folders = visible } log.Printf("[folders] user %q listed folders (found %d folders)", sess.Username, len(folders)) writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "folders": folders, }, }) } func (s *Server) handleImages(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 initialized") return } images, err := store.ListImages(slug) if err != nil { writeGQLError(w, fmt.Sprintf("failed to list images: %v", err)) return } writeJSONObj(w, map[string]interface{}{"data": map[string]interface{}{"images": images}}) } func (s *Server) handleDeleteImage(w http.ResponseWriter, req gqlRequest, sess *auth.Session) { if sess == nil { writeGQLError(w, "UNAUTHORIZED") return } slug, _ := req.Variables["slug"].(string) filename, _ := req.Variables["filename"].(string) if slug == "" || filename == "" { writeGQLError(w, "slug and filename are required") return } s.mu.RLock() store := s.store s.mu.RUnlock() if store == nil { writeGQLError(w, "storage not initialized") return } if err := store.DeleteImage(slug, filename); err != nil { writeGQLError(w, fmt.Sprintf("failed to delete image: %v", err)) return } log.Printf("[storage] deleted image %q from %q by %s", filename, slug, sess.Username) writeJSON(w, `{"data":{"deleteImage":true}}`) }