From 61f33d350a0b2cf216e87a4e1487db99fca089ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Blomberg?= Date: Tue, 14 Apr 2026 16:07:52 +0200 Subject: [PATCH] feat: enhance LDAP integration with new configuration options and browsing capabilities --- backend/internal/auth/auth.go | 100 +++++-- backend/internal/config/config.go | 8 +- backend/internal/db/db.go | 123 ++++++++- backend/internal/graph/schema.graphql | 63 ++++- backend/internal/graph/server.go | 370 +++++++++++++++++++------- frontend/src/views/AdminView.vue | 130 ++++++--- 6 files changed, 631 insertions(+), 163 deletions(-) diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 0a14f77..ed2f209 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -73,16 +73,12 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role cfg := m.cfg m.mu.RUnlock() - if cfg == nil || cfg.LDAP.Host == "" { - return "", "", errors.New("invalid credentials") - } - - if err := ldapBind(cfg.LDAP.Host, cfg.LDAP.Port, cfg.LDAP.BindDN, cfg.LDAP.BindPassword); err != nil { + if cfg == nil || cfg.LDAP.Url == "" { return "", "", errors.New("invalid credentials") } if err := ldapUserBind(cfg, username, password); err != nil { - return "", "", errors.New("invalid credentials") + return "", "", err } tok, err := m.createSession(username, "user") @@ -90,44 +86,104 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role } // TestLDAP tests an LDAP configuration by performing a service-account bind. -func TestLDAP(host string, port int, bindDN, bindPassword string) error { - return ldapBind(host, port, bindDN, bindPassword) -} - -func ldapBind(host string, port int, bindDN, bindPassword string) error { - l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", host, port)) +func TestLDAP(url string, adminUser, adminPassword string) error { + l, err := ldap.DialURL(url) if err != nil { return err } defer l.Close() - return l.Bind(bindDN, bindPassword) + if adminUser != "" { + return l.Bind(adminUser, adminPassword) + } + return nil } func ldapUserBind(cfg *config.Config, username, password string) error { - l, err := ldap.Dial("tcp", fmt.Sprintf("%s:%d", cfg.LDAP.Host, cfg.LDAP.Port)) + l, err := ldap.DialURL(cfg.LDAP.Url) if err != nil { return err } defer l.Close() - if err := l.Bind(cfg.LDAP.BindDN, cfg.LDAP.BindPassword); err != nil { - return err + if cfg.LDAP.AdminUser != "" { + if err := l.Bind(cfg.LDAP.AdminUser, cfg.LDAP.AdminPass); err != nil { + return err + } } - sr, err := l.Search(&ldap.SearchRequest{ - BaseDN: cfg.LDAP.BaseDN, - Filter: fmt.Sprintf("(uid=%s)", ldap.EscapeFilter(username)), - Scope: ldap.ScopeWholeSubtree, - }) + searchRequest := ldap.NewSearchRequest( + "", // Search from root or a specific BaseDN if needed. Assuming root here since URL can contain it, or we rely on LDAP configured properly + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(|(uid=%s)(sAMAccountName=%s))", ldap.EscapeFilter(username), ldap.EscapeFilter(username)), + []string{"dn"}, + nil, + ) + + sr, err := l.Search(searchRequest) if err != nil { return err } - if len(sr.Entries) != 1 { + if len(sr.Entries) == 0 { return errors.New("user not found in LDAP") } + + // Bind to the exact DN of the user discovered return l.Bind(sr.Entries[0].DN, password) } +// BrowseLDAP returns users and groups from the LDAP server. +func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string, error) { + l, err := ldap.DialURL(url) + if err != nil { + return nil, nil, err + } + defer l.Close() + + if adminUser != "" { + if err := l.Bind(adminUser, adminPassword); err != nil { + return nil, nil, err + } + } + + // Search for Users + userReq := ldap.NewSearchRequest( + "", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + "(|(objectClass=person)(objectClass=user))", + []string{"uid", "sAMAccountName", "cn"}, + nil, + ) + var users []string + if sr, err := l.Search(userReq); err == nil { + for _, e := range sr.Entries { + if v := e.GetAttributeValue("sAMAccountName"); v != "" { + users = append(users, v) + } else if v := e.GetAttributeValue("uid"); v != "" { + users = append(users, v) + } else if v := e.GetAttributeValue("cn"); v != "" { + users = append(users, v) + } + } + } + + // Search for Groups + groupReq := ldap.NewSearchRequest( + "", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + "(|(objectClass=groupOfNames)(objectClass=group))", + []string{"cn"}, + nil, + ) + var groups []string + if sr, err := l.Search(groupReq); err == nil { + for _, e := range sr.Entries { + if v := e.GetAttributeValue("cn"); v != "" { + groups = append(groups, v) + } + } + } + + return users, groups, nil +} + func (m *Manager) createSession(username, role string) (string, error) { token, err := generateToken() if err != nil { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 057425a..8b08ff6 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -16,11 +16,9 @@ type Config struct { } type LDAPConfig struct { - Host string `json:"host"` - Port int `json:"port"` - BaseDN string `json:"base_dn"` - BindDN string `json:"bind_dn"` - BindPassword string `json:"bind_password"` + Url string `json:"url"` + AdminUser string `json:"admin_user"` + AdminPass string `json:"admin_pass"` } // ErrRequireSetup is returned when config is missing or empty, diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 104ac68..e9704c0 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -51,8 +51,36 @@ func (d *DB) init() error { username TEXT NOT NULL UNIQUE, pass_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', + is_ldap BOOLEAN NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) + ); + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + is_ldap BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS user_groups ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + PRIMARY KEY(user_id, group_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS acl ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + subject_type TEXT NOT NULL CHECK(subject_type IN ('user', 'group')), + subject_id INTEGER NOT NULL, + can_search BOOLEAN NOT NULL DEFAULT 0, + can_view BOOLEAN NOT NULL DEFAULT 0, + can_read BOOLEAN NOT NULL DEFAULT 0, + can_edit BOOLEAN NOT NULL DEFAULT 0, + can_create BOOLEAN NOT NULL DEFAULT 0, + can_delete BOOLEAN NOT NULL DEFAULT 0, + can_move BOOLEAN NOT NULL DEFAULT 0, + UNIQUE(path, subject_type, subject_id) + ); `) return err } @@ -153,3 +181,96 @@ func (d *DB) UserCount() (int, error) { err := d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) return n, err } + +// --- LDAP & Roles --- + +// CreateOrUpdateLDAPUser inserts or updates an LDAP user (without password, role='user'). +func (d *DB) CreateOrUpdateLDAPUser(username string) error { + _, err := d.sql.Exec(` + INSERT INTO users (username, pass_hash, role, is_ldap) + VALUES (?, '', 'user', 1) + ON CONFLICT(username) DO UPDATE SET is_ldap=1; + `, username) + return err +} + +// CreateOrUpdateGroup inserts or updates a group (local or LDAP). +func (d *DB) CreateOrUpdateGroup(name string, isLdap bool) error { + ldVal := 0 + if isLdap { + ldVal = 1 + } + _, err := d.sql.Exec(` + INSERT INTO groups (name, is_ldap) + VALUES (?, ?) + ON CONFLICT(name) DO UPDATE SET is_ldap=excluded.is_ldap; + `, name, ldVal) + return err +} + +// --- ACL Methods --- + +type ACLEntry struct { + ID int64 + Path string + SubjectType string + SubjectID int64 + CanSearch bool + CanView bool + CanRead bool + CanEdit bool + CanCreate bool + CanDelete bool + CanMove bool +} + +// SetACL inserts or replaces an ACL entry. +func (d *DB) SetACL(entry ACLEntry) error { + _, err := d.sql.Exec(` + INSERT INTO acl (path, subject_type, subject_id, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(path, subject_type, subject_id) DO UPDATE SET + can_search = excluded.can_search, + can_view = excluded.can_view, + can_read = excluded.can_read, + can_edit = excluded.can_edit, + can_create = excluded.can_create, + can_delete = excluded.can_delete, + can_move = excluded.can_move; + `, entry.Path, entry.SubjectType, entry.SubjectID, + entry.CanSearch, entry.CanView, entry.CanRead, entry.CanEdit, + entry.CanCreate, entry.CanDelete, entry.CanMove) + return err +} + +// RemoveACL deletes an ACL entry by ID. +func (d *DB) RemoveACL(id int64) error { + _, err := d.sql.Exec(`DELETE FROM acl WHERE id = ?`, id) + return err +} + +// GetACLsForPath retrieves all ACL definitions for a specific document or folder. +func (d *DB) GetACLsForPath(path string) ([]ACLEntry, error) { + rows, err := d.sql.Query(` + SELECT id, path, subject_type, subject_id, can_search, can_view, can_read, can_edit, can_create, can_delete, can_move + FROM acl WHERE path = ? + `, path) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []ACLEntry + for rows.Next() { + var e ACLEntry + if err := rows.Scan( + &e.ID, &e.Path, &e.SubjectType, &e.SubjectID, + &e.CanSearch, &e.CanView, &e.CanRead, &e.CanEdit, + &e.CanCreate, &e.CanDelete, &e.CanMove, + ); err != nil { + return nil, err + } + entries = append(entries, e) + } + return entries, nil +} diff --git a/backend/internal/graph/schema.graphql b/backend/internal/graph/schema.graphql index d796208..4007faa 100644 --- a/backend/internal/graph/schema.graphql +++ b/backend/internal/graph/schema.graphql @@ -31,14 +31,20 @@ type Query { # List images next to a document. images(slug: String!): [ImageFile!]! + + # Browse LDAP tree (admin only) + ldapBrowse(url: String, adminUser: String, adminPass: String): LDAPTree! + + # Fetch permissions for a path + acl(path: String!): [ACLEntry!]! } type Mutation { # First-run setup. setup(input: SetupInput!): Boolean! - # Test an LDAP configuration before saving. - testLdapConnection(input: LDAPInput!): LDAPTestResult! + # Test an LDAP configuration. + testLdapConnection(url: String!, adminUser: String, adminPass: String): LDAPTestResult! # Authenticate and receive a bearer token. login(username: String!, password: String!): String! @@ -55,6 +61,13 @@ type Mutation { # Modify LDAP Configuration (admin only). updateLdapConfig(input: LDAPInput): Boolean! + # Sync users/groups from LDAP + importLdapSubject(type: String!, name: String!): Boolean! + + # ACL Mutations + setAcl(input: ACLInput!): Boolean! + removeAcl(id: Int!): Boolean! + # Modify Storage Path (admin only). updateStoragePath(path: String!): Boolean! @@ -87,10 +100,27 @@ type AppConfig { } type LDAPConfig { - host: String! - port: Int! - baseDN: String! - bindDN: String! + url: String! + adminUser: String! +} + +type LDAPTree { + users: [String!]! + groups: [String!]! +} + +type ACLEntry { + id: Int! + path: String! + subjectType: String! + subjectId: Int! + canSearch: Boolean! + canView: Boolean! + canRead: Boolean! + canEdit: Boolean! + canCreate: Boolean! + canDelete: Boolean! + canMove: Boolean! } type ServerDirectory { @@ -148,11 +178,22 @@ input SetupInput { } input LDAPInput { - host: String! - port: Int! - baseDN: String! - bindDN: String! - bindPassword: String! + url: String! + adminUser: String! + adminPass: String! +} + +input ACLInput { + path: String! + subjectType: String! + subjectId: Int! + canSearch: Boolean! + canView: Boolean! + canRead: Boolean! + canEdit: Boolean! + canCreate: Boolean! + canDelete: Boolean! + canMove: Boolean! } input SaveDocumentInput { diff --git a/backend/internal/graph/server.go b/backend/internal/graph/server.go index ed5718f..22cdc0d 100644 --- a/backend/internal/graph/server.go +++ b/backend/internal/graph/server.go @@ -232,6 +232,21 @@ func (s *Server) dispatchAuthenticated( case strings.Contains(q, "moveDocument"): s.handleMoveDocument(w, req, sess) + case strings.Contains(q, "ldapBrowse"): + s.handleLdapBrowse(w, req, sess) + + case strings.Contains(q, "importLdapSubject"): + s.handleImportLdapSubject(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) @@ -813,16 +828,10 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s 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"), + Url: strVal(input, "url"), + AdminUser: strVal(input, "adminUser"), + AdminPass: strVal(input, "adminPass"), } } else { // Disable LDAP @@ -863,12 +872,10 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) { } var ldap map[string]interface{} - if cfg.LDAP.Host != "" { + if cfg.LDAP.Url != "" { ldap = map[string]interface{}{ - "host": cfg.LDAP.Host, - "port": cfg.LDAP.Port, - "baseDN": cfg.LDAP.BaseDN, - "bindDN": cfg.LDAP.BindDN, + "url": cfg.LDAP.Url, + "adminUser": cfg.LDAP.AdminUser, } } @@ -1005,16 +1012,10 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { } 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"), + Url: strVal(ldapRaw, "url"), + AdminUser: strVal(ldapRaw, "adminUser"), + AdminPass: strVal(ldapRaw, "adminPass"), } } @@ -1081,34 +1082,24 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) { // ── 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 - } + url_ := strVal(req.Variables, "url") + adminUser := strVal(req.Variables, "adminUser") + adminPass := strVal(req.Variables, "adminPass") - 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 == "" { + if url_ == "" { writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "testLdapConnection": map[string]interface{}{ - "success": false, "message": "host is required", + "success": false, "message": "url is required", }, }, }) return } - err := auth.TestLDAP(host, port, bindDN, bindPassword) + err := auth.TestLDAP(url_, adminUser, adminPass) if err != nil { - log.Printf("[ldap] test failed (%s:%d): %v", host, port, err) + log.Printf("[ldap] test failed (%s): %v", url_, err) writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "testLdapConnection": map[string]interface{}{ @@ -1119,7 +1110,7 @@ func (s *Server) handleTestLdapConnection(w http.ResponseWriter, req gqlRequest) return } - log.Printf("[ldap] test succeeded (%s:%d)", host, port) + log.Printf("[ldap] test succeeded (%s)", url_) writeJSONObj(w, map[string]interface{}{ "data": map[string]interface{}{ "testLdapConnection": map[string]interface{}{ @@ -1246,10 +1237,203 @@ func firstWord(s string) string { } 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") + + if url_ == "" { + s.mu.RLock() + if s.cfg != nil && s.cfg.LDAP.Url != "" { + url_ = s.cfg.LDAP.Url + adminUser = s.cfg.LDAP.AdminUser + adminPass = s.cfg.LDAP.AdminPass + } + s.mu.RUnlock() + } + + users, groups, err := auth.BrowseLDAP(url_, 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 { @@ -1564,64 +1748,62 @@ func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) { } 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 -} + 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() + s.mu.RLock() + store := s.store + s.mu.RUnlock() -if store == nil { -writeGQLError(w, "storage not initialized") -return -} + 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 -} + 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}}) + 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 + 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}}`) } - -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}}`) -} - - diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 0aa2e26..5567167 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -5,11 +5,9 @@ import FolderPicker from '@/components/FolderPicker.vue' const storagePath = ref('') const ldapEnabled = ref(false) -const ldapHost = ref('') -const ldapPort = ref(389) -const ldapBaseDN = ref('') -const ldapBindDN = ref('') -const ldapBindPassword = ref('') +const ldapUrl = ref('') +const ldapAdminUser = ref('') +const ldapAdminPassword = ref('') const oldPass = ref('') const newPass = ref('') @@ -22,17 +20,20 @@ const showGitPrompt = ref(false) const gitCommitMessage = ref('Initial commit') const gitCommitting = ref(false) +// LDAP Browser State +const ldapUsers = ref([]) +const ldapGroups = ref([]) +const ldapBrowsing = ref(false) + onMounted(async () => { try { - const data = await gql<{ config: any }>(`{ config { storagePath ldap { host port baseDN bindDN } } }`) + const data = await gql<{ config: any }>(`{ config { storagePath ldap { url adminUser } } }`) storagePath.value = data.config.storagePath || '' if (data.config.ldap) { ldapEnabled.value = true - ldapHost.value = data.config.ldap.host - ldapPort.value = data.config.ldap.port - ldapBaseDN.value = data.config.ldap.baseDN - ldapBindDN.value = data.config.ldap.bindDN + ldapUrl.value = data.config.ldap.url + ldapAdminUser.value = data.config.ldap.adminUser } } catch (err: any) { status.value = { msg: err.message, isError: true } @@ -83,11 +84,9 @@ async function saveLdap() { let input = null if (ldapEnabled.value) { input = { - host: ldapHost.value, - port: ldapPort.value, - baseDN: ldapBaseDN.value, - bindDN: ldapBindDN.value, - bindPassword: ldapBindPassword.value + url: ldapUrl.value, + adminUser: ldapAdminUser.value, + adminPass: ldapAdminPassword.value } } @@ -98,6 +97,52 @@ async function saveLdap() { } } +async function testLdap() { + try { + const data = await gql<{ testLdapConnection: { success: boolean, message: string } }>( + `mutation TestLdap($url: String!, $adminUser: String, $adminPass: String) { + testLdapConnection(url: $url, adminUser: $adminUser, adminPass: $adminPass) { success message } + }`, + { url: ldapUrl.value, adminUser: ldapAdminUser.value, adminPass: ldapAdminPassword.value } + ) + if (data.testLdapConnection.success) { + showStatus('LDAP connection successful.', false) + } else { + showStatus('LDAP connection failed: ' + data.testLdapConnection.message, true) + } + } catch (err: any) { + showStatus(err.message, true) + } +} + +async function browseLdap() { + try { + ldapBrowsing.value = true + const data = await gql<{ ldapBrowse: { users: string[], groups: string[] } }>( + `query LdapBrowse($url: String, $adminUser: String, $adminPass: String) { + ldapBrowse(url: $url, adminUser: $adminUser, adminPass: $adminPass) { users groups } + }`, + { url: ldapUrl.value || undefined, adminUser: ldapAdminUser.value || undefined, adminPass: ldapAdminPassword.value || undefined } + ) + ldapUsers.value = data.ldapBrowse.users + ldapGroups.value = data.ldapBrowse.groups + showStatus('LDAP structure loaded.') + } catch (err: any) { + showStatus(err.message, true) + } finally { + ldapBrowsing.value = false + } +} + +async function importSubject(type: 'user' | 'group', name: string) { + try { + await gql(`mutation ImportSubj($type: String!, $name: String!) { importLdapSubject(type: $type, name: $name) }`, { type, name }) + showStatus(`Imported ${type} ${name} successfully.`) + } catch (err: any) { + showStatus(err.message, true) + } +} + async function savePassword() { if (newPass.value.length < 8) { showStatus('New password must be at least 8 characters.', true) @@ -217,33 +262,58 @@ async function savePassword() {
-
- - -
-
- - -
- - + +
- - + +
- - + +
-
+
+
+ + +
+
+

LDAP Browser

+ +
+ +
+
+

Users

+
    +
  • + {{ u }} + +
  • +
+
+
+

Groups

+
    +
  • + {{ g }} + +
  • +
+
+
+

Click browse to load LDAP hierarchy.

+