package db import ( "database/sql" "errors" "strings" "time" _ "modernc.org/sqlite" ) var ErrUserNotFound = errors.New("user not found") var ErrUserExists = errors.New("username already exists") // User represents a local account stored in SQLite. type User struct { ID int64 Username string PassHash string Role string IsLDAP bool CreatedAt time.Time } // DB wraps the SQLite connection. type DB struct { sql *sql.DB } // New opens (or creates) the SQLite database at path and runs migrations. func New(path string) (*DB, error) { sqldb, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000") if err != nil { return nil, err } d := &DB{sql: sqldb} if err := d.init(); err != nil { sqldb.Close() return nil, err } return d, nil } func (d *DB) Close() error { return d.sql.Close() } func (d *DB) init() error { _, err := d.sql.Exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, 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 } // HasUsers returns true if at least one user account exists. func (d *DB) HasUsers() bool { var n int d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) return n > 0 } // CreateUser inserts a new user. Returns ErrUserExists if the username is taken. func (d *DB) CreateUser(username, passHash, role string) error { _, err := d.sql.Exec( `INSERT INTO users (username, pass_hash, role) VALUES (?, ?, ?)`, username, passHash, role, ) if err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") { return ErrUserExists } return err } // 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, 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, &u.IsLDAP, &createdAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } if err != nil { return nil, err } u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) return u, nil } // ListUsers returns all users ordered by id. func (d *DB) ListUsers() ([]*User, error) { rows, err := d.sql.Query( `SELECT id, username, role, is_ldap, created_at FROM users ORDER BY id`, ) if err != nil { return nil, err } defer rows.Close() var users []*User for rows.Next() { u := &User{} var createdAt string 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) users = append(users, u) } return users, rows.Err() } // DeleteUser removes a user. Returns ErrUserNotFound if they don't exist. func (d *DB) DeleteUser(username string) error { res, err := d.sql.Exec(`DELETE FROM users WHERE username = ?`, username) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return ErrUserNotFound } return nil } // UpdatePassword replaces the stored hash for a user. func (d *DB) UpdatePassword(username, passHash string) error { res, err := d.sql.Exec( `UPDATE users SET pass_hash = ? WHERE username = ?`, passHash, username, ) if err != nil { return err } n, _ := res.RowsAffected() if n == 0 { return ErrUserNotFound } return nil } // UserCount returns the total number of local accounts. func (d *DB) UserCount() (int, error) { var n int 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'). // 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 WHERE excluded.pass_hash = '' AND pass_hash = ''; `, 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 } // GetACLsForSubject retrieves all ACL entries for a specific subject (user or group). func (d *DB) GetACLsForSubject(subjectType string, subjectID int64) ([]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 subject_type = ? AND subject_id = ? ORDER BY path `, subjectType, subjectID) 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, rows.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 } // 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 }