feat: enhance LDAP integration with new configuration options and browsing capabilities
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<string[]>([])
|
||||
const ldapGroups = ref<string[]>([])
|
||||
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() {
|
||||
</div>
|
||||
|
||||
<div v-if="ldapEnabled" class="grid gap-4 sm:grid-cols-2 mb-4">
|
||||
<div class="sm:col-span-2 md:col-span-1">
|
||||
<label class="label">Host</label>
|
||||
<input v-model="ldapHost" class="input" placeholder="ldap.example.com" />
|
||||
</div>
|
||||
<div class="sm:col-span-2 md:col-span-1">
|
||||
<label class="label">Port</label>
|
||||
<input v-model.number="ldapPort" type="number" class="input" placeholder="389" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label">Base DN <span class="text-slate-400 text-xs font-normal">(e.g. dc=example,dc=com)</span></label>
|
||||
<input v-model="ldapBaseDN" class="input" placeholder="dc=example,dc=com" />
|
||||
<label class="label">LDAP URL</label>
|
||||
<input v-model="ldapUrl" class="input" placeholder="ldap://ldap.example.com:389" />
|
||||
</div>
|
||||
<div class="sm:col-span-2 md:col-span-1">
|
||||
<label class="label">Bind DN (Service Account)</label>
|
||||
<input v-model="ldapBindDN" class="input" placeholder="cn=reader,dc=example,dc=com" />
|
||||
<label class="label">Admin User (Optional Service Account)</label>
|
||||
<input v-model="ldapAdminUser" class="input" placeholder="cn=reader,dc=example,dc=com" />
|
||||
</div>
|
||||
<div class="sm:col-span-2 md:col-span-1">
|
||||
<label class="label">Bind Password</label>
|
||||
<input v-model="ldapBindPassword" type="password" class="input" />
|
||||
<label class="label">Admin Password</label>
|
||||
<input v-model="ldapAdminPassword" type="password" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<div class="flex justify-end gap-2 mb-6">
|
||||
<button class="btn-secondary" v-if="ldapEnabled" @click="testLdap">Test Connection</button>
|
||||
<button class="btn-primary" @click="saveLdap">
|
||||
{{ ldapEnabled ? 'Save LDAP Config' : 'Save & Disable LDAP' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- LDAP Browser -->
|
||||
<div v-if="ldapEnabled" class="border-t border-slate-200 dark:border-slate-700 pt-6 mt-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-md font-semibold">LDAP Browser</h3>
|
||||
<button class="btn-secondary text-sm py-1 px-3" @click="browseLdap" :disabled="ldapBrowsing">
|
||||
{{ ldapBrowsing ? 'Loading...' : 'Browse Users & Groups' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ldapUsers.length || ldapGroups.length" class="grid sm:grid-cols-2 gap-6">
|
||||
<div class="card p-4">
|
||||
<h4 class="font-medium text-slate-800 dark:text-slate-200 mb-2 border-b pb-1">Users</h4>
|
||||
<ul class="max-h-48 overflow-y-auto space-y-1">
|
||||
<li v-for="u in ldapUsers" :key="u" class="flex justify-between items-center text-sm p-1 hover:bg-slate-50 dark:hover:bg-slate-800 rounded">
|
||||
<span class="truncate">{{ u }}</span>
|
||||
<button class="text-accent-600 hover:text-accent-700 text-xs" @click="importSubject('user', u)">Import</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<h4 class="font-medium text-slate-800 dark:text-slate-200 mb-2 border-b pb-1">Groups</h4>
|
||||
<ul class="max-h-48 overflow-y-auto space-y-1">
|
||||
<li v-for="g in ldapGroups" :key="g" class="flex justify-between items-center text-sm p-1 hover:bg-slate-50 dark:hover:bg-slate-800 rounded">
|
||||
<span class="truncate">{{ g }}</span>
|
||||
<button class="text-accent-600 hover:text-accent-700 text-xs" @click="importSubject('group', g)">Import</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="!ldapBrowsing" class="text-sm text-slate-500 italic">Click browse to load LDAP hierarchy.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user