feat: add LDAP support and guest user functionality
- Introduced BaseDN configuration for LDAP in the config. - Enhanced User model to include IsLDAP field. - Updated database queries to handle LDAP users. - Implemented GraphQL queries for LDAP browsing and user authentication type. - Added ACL management for users and groups, including guest user permissions. - Updated frontend to support LDAP login and guest user login. - Improved error handling and user feedback in login and ACL management.
This commit is contained in:
@@ -57,18 +57,49 @@ func CheckPassword(hashedPassword, password string) error {
|
||||
|
||||
// Login authenticates via local DB first, then falls back to LDAP if configured.
|
||||
// Returns the session token, the user's role, and any error.
|
||||
func (m *Manager) Login(database *db.DB, username, password string) (token, role string, err error) {
|
||||
// Try local account first.
|
||||
user, dbErr := database.GetUser(username)
|
||||
if dbErr == nil {
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PassHash), []byte(password)) != nil {
|
||||
return "", "", errors.New("invalid credentials")
|
||||
}
|
||||
tok, err := m.createSession(username, user.Role)
|
||||
return tok, user.Role, err
|
||||
// password is a SHA-256 hash (for local accounts); ldapPassword is plaintext (for LDAP bind).
|
||||
func (m *Manager) Login(database *db.DB, username, password, ldapPassword string) (token, role string, err error) {
|
||||
// Guest login — no password required.
|
||||
if username == "guest" {
|
||||
tok, err := m.createSession("guest", "guest")
|
||||
return tok, "guest", err
|
||||
}
|
||||
|
||||
// If not found locally and LDAP is configured, try LDAP.
|
||||
user, dbErr := database.GetUser(username)
|
||||
if dbErr == nil {
|
||||
// If the account has a local password hash, always authenticate locally.
|
||||
// This ensures admin accounts are never accidentally routed through LDAP,
|
||||
// even if they were also imported from the directory.
|
||||
if user.PassHash != "" {
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PassHash), []byte(password)) != nil {
|
||||
return "", "", errors.New("invalid credentials")
|
||||
}
|
||||
tok, err := m.createSession(username, user.Role)
|
||||
return tok, user.Role, err
|
||||
}
|
||||
// No local password — must be an LDAP-only account.
|
||||
if user.IsLDAP {
|
||||
m.mu.RLock()
|
||||
cfg := m.cfg
|
||||
m.mu.RUnlock()
|
||||
if cfg == nil || cfg.LDAP.Url == "" {
|
||||
return "", "", errors.New("LDAP not configured")
|
||||
}
|
||||
ldapPwd := ldapPassword
|
||||
if ldapPwd == "" {
|
||||
ldapPwd = password
|
||||
}
|
||||
if err := ldapUserBind(cfg, username, ldapPwd); err != nil {
|
||||
return "", "", errors.New("invalid credentials")
|
||||
}
|
||||
tok, err := m.createSession(username, user.Role)
|
||||
return tok, user.Role, err
|
||||
}
|
||||
// In DB but no password and not LDAP — refuse.
|
||||
return "", "", errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// User not in DB. If LDAP is configured try a bind with the provided password.
|
||||
m.mu.RLock()
|
||||
cfg := m.cfg
|
||||
m.mu.RUnlock()
|
||||
@@ -77,7 +108,11 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role
|
||||
return "", "", errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
if err := ldapUserBind(cfg, username, password); err != nil {
|
||||
ldapPwd := ldapPassword
|
||||
if ldapPwd == "" {
|
||||
ldapPwd = password
|
||||
}
|
||||
if err := ldapUserBind(cfg, username, ldapPwd); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
@@ -85,6 +120,27 @@ func (m *Manager) Login(database *db.DB, username, password string) (token, role
|
||||
return tok, "user", err
|
||||
}
|
||||
|
||||
// UserAuthType returns "guest", "ldap", or "local" for the given username.
|
||||
// A user with a non-empty pass_hash is always "local", regardless of is_ldap,
|
||||
// so that the frontend hashes the password before sending it.
|
||||
func UserAuthType(database *db.DB, username string) string {
|
||||
if username == "guest" {
|
||||
return "guest"
|
||||
}
|
||||
user, err := database.GetUser(username)
|
||||
if err != nil {
|
||||
// User not found in DB — assume LDAP so the frontend sends plaintext.
|
||||
return "ldap"
|
||||
}
|
||||
if user.PassHash != "" {
|
||||
return "local"
|
||||
}
|
||||
if user.IsLDAP {
|
||||
return "ldap"
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
// TestLDAP tests an LDAP configuration by performing a service-account bind.
|
||||
func TestLDAP(url string, adminUser, adminPassword string) error {
|
||||
l, err := ldap.DialURL(url)
|
||||
@@ -111,10 +167,14 @@ func ldapUserBind(cfg *config.Config, username, password string) error {
|
||||
}
|
||||
}
|
||||
|
||||
baseDN := cfg.LDAP.BaseDN
|
||||
escaped := ldap.EscapeFilter(username)
|
||||
// Include cn so OpenLDAP entries of the form cn=username are matched too.
|
||||
filter := fmt.Sprintf("(|(uid=%s)(sAMAccountName=%s)(cn=%s))", escaped, escaped, escaped)
|
||||
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
|
||||
baseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
fmt.Sprintf("(|(uid=%s)(sAMAccountName=%s))", ldap.EscapeFilter(username), ldap.EscapeFilter(username)),
|
||||
filter,
|
||||
[]string{"dn"},
|
||||
nil,
|
||||
)
|
||||
@@ -132,7 +192,7 @@ func ldapUserBind(cfg *config.Config, username, password string) error {
|
||||
}
|
||||
|
||||
// BrowseLDAP returns users and groups from the LDAP server.
|
||||
func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string, error) {
|
||||
func BrowseLDAP(url, baseDN, adminUser, adminPassword string) ([]string, []string, error) {
|
||||
l, err := ldap.DialURL(url)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -145,10 +205,11 @@ func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string
|
||||
}
|
||||
}
|
||||
|
||||
// Search for Users
|
||||
// Search for Users — cover inetOrgPerson (OpenLDAP), person, user (AD), posixAccount
|
||||
userReq := ldap.NewSearchRequest(
|
||||
"", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
"(|(objectClass=person)(objectClass=user))",
|
||||
baseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
"(|(objectClass=inetOrgPerson)(objectClass=person)(objectClass=user)(objectClass=posixAccount))",
|
||||
[]string{"uid", "sAMAccountName", "cn"},
|
||||
nil,
|
||||
)
|
||||
@@ -165,10 +226,11 @@ func BrowseLDAP(url string, adminUser, adminPassword string) ([]string, []string
|
||||
}
|
||||
}
|
||||
|
||||
// Search for Groups
|
||||
// Search for Groups — cover groupOfNames, group (AD), posixGroup, groupOfUniqueNames
|
||||
groupReq := ldap.NewSearchRequest(
|
||||
"", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
"(|(objectClass=groupOfNames)(objectClass=group))",
|
||||
baseDN,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||
"(|(objectClass=groupOfNames)(objectClass=group)(objectClass=posixGroup)(objectClass=groupOfUniqueNames))",
|
||||
[]string{"cn"},
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ type Config struct {
|
||||
|
||||
type LDAPConfig struct {
|
||||
Url string `json:"url"`
|
||||
BaseDN string `json:"base_dn"`
|
||||
AdminUser string `json:"admin_user"`
|
||||
AdminPass string `json:"admin_pass"`
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type User struct {
|
||||
Username string
|
||||
PassHash string
|
||||
Role string
|
||||
IsLDAP bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -107,12 +108,12 @@ func (d *DB) CreateUser(username, passHash, role string) error {
|
||||
// GetUser fetches a single user by username.
|
||||
func (d *DB) GetUser(username string) (*User, error) {
|
||||
row := d.sql.QueryRow(
|
||||
`SELECT id, username, pass_hash, role, created_at FROM users WHERE username = ?`,
|
||||
`SELECT id, username, pass_hash, role, is_ldap, created_at FROM users WHERE username = ?`,
|
||||
username,
|
||||
)
|
||||
u := &User{}
|
||||
var createdAt string
|
||||
err := row.Scan(&u.ID, &u.Username, &u.PassHash, &u.Role, &createdAt)
|
||||
err := row.Scan(&u.ID, &u.Username, &u.PassHash, &u.Role, &u.IsLDAP, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
@@ -126,7 +127,7 @@ func (d *DB) GetUser(username string) (*User, error) {
|
||||
// ListUsers returns all users ordered by id.
|
||||
func (d *DB) ListUsers() ([]*User, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, username, role, created_at FROM users ORDER BY id`,
|
||||
`SELECT id, username, role, is_ldap, created_at FROM users ORDER BY id`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -137,7 +138,7 @@ func (d *DB) ListUsers() ([]*User, error) {
|
||||
for rows.Next() {
|
||||
u := &User{}
|
||||
var createdAt string
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Role, &createdAt); err != nil {
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Role, &u.IsLDAP, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
@@ -185,11 +186,14 @@ func (d *DB) UserCount() (int, error) {
|
||||
// --- LDAP & Roles ---
|
||||
|
||||
// CreateOrUpdateLDAPUser inserts or updates an LDAP user (without password, role='user').
|
||||
// Existing accounts with a non-empty pass_hash are never overwritten so that
|
||||
// a local admin cannot be accidentally converted to an LDAP-only account.
|
||||
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;
|
||||
ON CONFLICT(username) DO UPDATE SET is_ldap=1
|
||||
WHERE excluded.pass_hash = '' AND pass_hash = '';
|
||||
`, username)
|
||||
return err
|
||||
}
|
||||
@@ -274,3 +278,89 @@ func (d *DB) GetACLsForPath(path string) ([]ACLEntry, error) {
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Group represents a group stored in SQLite.
|
||||
type Group struct {
|
||||
ID int64
|
||||
Name string
|
||||
IsLDAP bool
|
||||
}
|
||||
|
||||
// ListGroups returns all groups ordered by name.
|
||||
func (d *DB) ListGroups() ([]*Group, error) {
|
||||
rows, err := d.sql.Query(`SELECT id, name, is_ldap FROM groups ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var groups []*Group
|
||||
for rows.Next() {
|
||||
g := &Group{}
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.IsLDAP); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
// EnsureGuestUser creates the built-in guest account if it does not already exist.
|
||||
func (d *DB) EnsureGuestUser() error {
|
||||
_, err := d.sql.Exec(`
|
||||
INSERT INTO users (username, pass_hash, role) VALUES ('guest', '', 'guest')
|
||||
ON CONFLICT(username) DO NOTHING;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// CanUserReadPath returns true if the given username has can_read access on the
|
||||
// exact path or any of its ancestor folder paths.
|
||||
func (d *DB) CanUserReadPath(username, path string) bool {
|
||||
// Build a list of candidate paths: the path itself and each parent segment.
|
||||
candidates := pathAncestors(path)
|
||||
for _, p := range candidates {
|
||||
var n int
|
||||
d.sql.QueryRow(`
|
||||
SELECT COUNT(*) FROM acl
|
||||
JOIN users ON users.id = acl.subject_id AND acl.subject_type = 'user'
|
||||
WHERE users.username = ? AND acl.path = ? AND acl.can_read = 1
|
||||
`, username, p).Scan(&n)
|
||||
if n > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CanUserViewPath returns true if the given username has can_view access on the
|
||||
// exact path or any of its ancestor folder paths.
|
||||
func (d *DB) CanUserViewPath(username, path string) bool {
|
||||
candidates := pathAncestors(path)
|
||||
for _, p := range candidates {
|
||||
var n int
|
||||
d.sql.QueryRow(`
|
||||
SELECT COUNT(*) FROM acl
|
||||
JOIN users ON users.id = acl.subject_id AND acl.subject_type = 'user'
|
||||
WHERE users.username = ? AND acl.path = ? AND acl.can_view = 1
|
||||
`, username, p).Scan(&n)
|
||||
if n > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pathAncestors returns the path and all its parent segments.
|
||||
// e.g. "a/b/c" → ["a/b/c", "a/b", "a"]
|
||||
func pathAncestors(path string) []string {
|
||||
out := []string{path}
|
||||
for {
|
||||
idx := strings.LastIndex(path, "/")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
path = path[:idx]
|
||||
out = append(out, path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -33,10 +33,16 @@ type Query {
|
||||
images(slug: String!): [ImageFile!]!
|
||||
|
||||
# Browse LDAP tree (admin only)
|
||||
ldapBrowse(url: String, adminUser: String, adminPass: String): LDAPTree!
|
||||
ldapBrowse(url: String, baseDN: String, adminUser: String, adminPass: String): LDAPTree!
|
||||
|
||||
# Fetch permissions for a path
|
||||
acl(path: String!): [ACLEntry!]!
|
||||
|
||||
# Returns all users and groups that can be assigned to ACL entries (admin only).
|
||||
aclSubjects: ACLSubjects!
|
||||
|
||||
# Returns "local", "ldap", or "guest" for a given username.
|
||||
userAuthType(username: String!): String!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
@@ -47,7 +53,7 @@ type Mutation {
|
||||
testLdapConnection(url: String!, adminUser: String, adminPass: String): LDAPTestResult!
|
||||
|
||||
# Authenticate and receive a bearer token.
|
||||
login(username: String!, password: String!): String!
|
||||
login(username: String!, password: String!, ldapPassword: String): String!
|
||||
|
||||
# Invalidate the current session.
|
||||
logout: Boolean!
|
||||
@@ -101,6 +107,7 @@ type AppConfig {
|
||||
|
||||
type LDAPConfig {
|
||||
url: String!
|
||||
baseDN: String!
|
||||
adminUser: String!
|
||||
}
|
||||
|
||||
@@ -179,8 +186,20 @@ input SetupInput {
|
||||
|
||||
input LDAPInput {
|
||||
url: String!
|
||||
baseDN: String
|
||||
adminUser: String!
|
||||
adminPass: String!
|
||||
adminPass: String
|
||||
}
|
||||
|
||||
type ACLSubject {
|
||||
id: Int!
|
||||
name: String!
|
||||
isLdap: Boolean!
|
||||
}
|
||||
|
||||
type ACLSubjects {
|
||||
users: [ACLSubject!]!
|
||||
groups: [ACLSubject!]!
|
||||
}
|
||||
|
||||
input ACLInput {
|
||||
|
||||
@@ -75,6 +75,10 @@ func (s *Server) initRuntime(cfg *config.Config) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -157,6 +161,9 @@ func (s *Server) handleGraphQL(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleTestLdapConnection(w, req)
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────────────
|
||||
case strings.Contains(q, "userAuthType"):
|
||||
s.handleUserAuthType(w, req)
|
||||
|
||||
case strings.Contains(q, "login"):
|
||||
s.handleLogin(w, req)
|
||||
|
||||
@@ -202,6 +209,9 @@ func (s *Server) dispatchAuthenticated(
|
||||
case strings.Contains(q, "changePassword"):
|
||||
s.handleChangePassword(w, req, sess)
|
||||
|
||||
case strings.Contains(q, "ldapBrowse"):
|
||||
s.handleLdapBrowse(w, req, sess)
|
||||
|
||||
case strings.Contains(q, "users"):
|
||||
s.handleUsers(w, sess)
|
||||
|
||||
@@ -232,12 +242,12 @@ 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, "aclSubjects"):
|
||||
s.handleAclSubjects(w, sess)
|
||||
|
||||
case strings.Contains(q, "acl(") || strings.Contains(q, "acl "):
|
||||
s.handleAcl(w, req, sess)
|
||||
|
||||
@@ -404,6 +414,17 @@ func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, sess *au
|
||||
|
||||
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)
|
||||
@@ -437,8 +458,18 @@ func (s *Server) handleDocuments(w http.ResponseWriter, req gqlRequest, sess *au
|
||||
|
||||
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 {
|
||||
@@ -573,6 +604,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) {
|
||||
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
|
||||
@@ -584,7 +617,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
token, role, err := mgr.Login(database, username, password)
|
||||
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")
|
||||
@@ -599,6 +632,27 @@ func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) {
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -655,6 +709,59 @@ func (s *Server) handleUsers(w http.ResponseWriter, sess *auth.Session) {
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -814,6 +921,7 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
|
||||
|
||||
s.mu.RLock()
|
||||
cfg := s.cfg
|
||||
mgr := s.authMgr
|
||||
s.mu.RUnlock()
|
||||
|
||||
if cfg == nil {
|
||||
@@ -828,10 +936,15 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
|
||||
|
||||
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: strVal(input, "adminPass"),
|
||||
AdminPass: newPass,
|
||||
}
|
||||
} else {
|
||||
// Disable LDAP
|
||||
@@ -843,7 +956,15 @@ func (s *Server) handleUpdateLdapConfig(w http.ResponseWriter, req gqlRequest, s
|
||||
return
|
||||
}
|
||||
|
||||
s.initRuntime(&newCfg)
|
||||
// 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}}`)
|
||||
}
|
||||
@@ -871,10 +992,11 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
|
||||
return
|
||||
}
|
||||
|
||||
var ldap map[string]interface{}
|
||||
var ldapCfg map[string]interface{}
|
||||
if cfg.LDAP.Url != "" {
|
||||
ldap = map[string]interface{}{
|
||||
ldapCfg = map[string]interface{}{
|
||||
"url": cfg.LDAP.Url,
|
||||
"baseDN": cfg.LDAP.BaseDN,
|
||||
"adminUser": cfg.LDAP.AdminUser,
|
||||
}
|
||||
}
|
||||
@@ -883,7 +1005,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, sess *auth.Session) {
|
||||
"data": map[string]interface{}{
|
||||
"config": map[string]interface{}{
|
||||
"storagePath": cfg.StoragePath,
|
||||
"ldap": ldap,
|
||||
"ldap": ldapCfg,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1043,6 +1165,11 @@ func (s *Server) handleSetup(w http.ResponseWriter, req gqlRequest) {
|
||||
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 {
|
||||
@@ -1255,18 +1382,20 @@ func (s *Server) handleLdapBrowse(w http.ResponseWriter, req gqlRequest, sess *a
|
||||
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_, adminUser, adminPass)
|
||||
users, groups, err := auth.BrowseLDAP(url_, baseDN, adminUser, adminPass)
|
||||
if err != nil {
|
||||
writeGQLError(w, fmt.Sprintf("LDAP traverse error: %v", err))
|
||||
return
|
||||
@@ -1724,6 +1853,7 @@ func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) {
|
||||
|
||||
s.mu.RLock()
|
||||
store := s.store
|
||||
database := s.database
|
||||
s.mu.RUnlock()
|
||||
|
||||
if store == nil {
|
||||
@@ -1738,6 +1868,17 @@ func (s *Server) handleFolders(w http.ResponseWriter, sess *auth.Session) {
|
||||
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{}{
|
||||
|
||||
Reference in New Issue
Block a user