Move users, calendars, and address books from config.yaml into the database
BREAKING CHANGE: the users:/config-based collection setup is gone. All
user, calendar, and address-book data now lives in the SQLite DB
(internal/db) and is managed exclusively via nidusctl or the web UI.
Existing deployments must recreate their users after upgrading:
nidusctl user create <username>
nidusctl calendar create <username> <name>
nidusctl addressbook create <username> <name>
- internal/db: new users, calendars, addressbooks tables with FK cascade
delete; foreign_keys pragma enabled; internal/db/users.go implements
full CRUD + bcrypt auth (CreateUser, VerifyPassword, ListUsers,
CreateCalendar/AddressBook, etc).
- internal/config: removed Users/UserConfig entirely.
- internal/auth: Basic Auth now checks credentials via db.DB instead of
cfg.Users.
- internal/caldav, internal/carddav: ListCalendars/ListAddressBooks and
Create/Delete now backed by the DB.
- internal/web: login uses db.VerifyPassword; new resources.go adds
create/delete handlers for calendars/address books at
/web/resources/{calendar,addressbook}; dashboard gained create forms
and per-card delete buttons (templ + htmx, no hyperscript).
- tools/nidusctl: new user create/delete/list/passwd commands (masked
interactive password prompt via golang.org/x/term) plus create/delete/
list subcommands for calendar/addressbook.
- cmd/server/main.go: pre-creates on-disk collections from the DB at
startup instead of cfg.Users; warns when no users exist yet.
- Updated tests to seed data via the DB; added resources_test.go for the
new web UI handlers.
- README.md and .github/copilot-instructions.md updated to document the
new nidusctl commands and the DB-backed architecture.
Verified end-to-end against a live test server: nidusctl user/calendar/
addressbook create, DAV Basic Auth PROPFIND, web login, dashboard
rendering, and web UI create/delete of resources all confirmed working.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+23
-1
@@ -30,7 +30,7 @@ func Open(path string) (*DB, error) {
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := sql.Open("sqlite", path)
|
||||
conn, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening database %q: %w", path, err)
|
||||
}
|
||||
@@ -53,6 +53,28 @@ func (d *DB) Close() error {
|
||||
|
||||
func (d *DB) migrate() error {
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT PRIMARY KEY,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendars (
|
||||
owner TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (owner, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS addressbooks (
|
||||
owner TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (owner, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_shares (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// ErrUserNotFound is returned when a username doesn't exist.
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
|
||||
// ErrUserExists is returned when trying to create a user that already
|
||||
// exists.
|
||||
var ErrUserExists = errors.New("user already exists")
|
||||
|
||||
// ErrResourceExists is returned when creating a calendar/address book that
|
||||
// already exists for that owner.
|
||||
var ErrResourceExists = errors.New("resource already exists")
|
||||
|
||||
// ErrResourceNotFound is returned when deleting a calendar/address book
|
||||
// that doesn't exist.
|
||||
var ErrResourceNotFound = errors.New("resource not found")
|
||||
|
||||
// User is an account stored in the database.
|
||||
type User struct {
|
||||
Username string
|
||||
PasswordHash string
|
||||
DisplayName string
|
||||
Email string
|
||||
}
|
||||
|
||||
// CreateUser adds a new account with the given (already plaintext)
|
||||
// password, which is bcrypt-hashed before being stored. Returns
|
||||
// ErrUserExists if the username is already taken.
|
||||
func (d *DB) CreateUser(username, password, displayName, email string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hashing password: %w", err)
|
||||
}
|
||||
_, err = d.conn.Exec(
|
||||
`INSERT INTO users (username, password_hash, display_name, email) VALUES (?, ?, ?, ?)`,
|
||||
username, string(hash), displayName, email,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueConstraintErr(err) {
|
||||
return ErrUserExists
|
||||
}
|
||||
return fmt.Errorf("creating user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPassword updates username's password hash. Returns ErrUserNotFound
|
||||
// if the user doesn't exist.
|
||||
func (d *DB) SetPassword(username, password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hashing password: %w", err)
|
||||
}
|
||||
res, err := d.conn.Exec(`UPDATE users SET password_hash = ? WHERE username = ?`, string(hash), username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting password: %w", err)
|
||||
}
|
||||
return requireRowsAffected(res, ErrUserNotFound)
|
||||
}
|
||||
|
||||
// DeleteUser removes username along with all of its calendars, address
|
||||
// books, and sharing grants (calendars/addressbooks cascade via foreign
|
||||
// key; shares are cleaned up explicitly since they reference usernames as
|
||||
// plain text, not a foreign key, on both sides of the grant).
|
||||
func (d *DB) DeleteUser(username string) error {
|
||||
tx, err := d.conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting user: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM users WHERE username = ?`, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting user: %w", err)
|
||||
}
|
||||
if err := requireRowsAffected(res, ErrUserNotFound); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM calendar_shares WHERE owner = ? OR shared_with = ?`, username, username); err != nil {
|
||||
return fmt.Errorf("deleting user's calendar shares: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM addressbook_shares WHERE owner = ? OR shared_with = ?`, username, username); err != nil {
|
||||
return fmt.Errorf("deleting user's address book shares: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM web_sessions WHERE username = ?`, username); err != nil {
|
||||
return fmt.Errorf("deleting user's sessions: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetUser looks up a user by username. Returns ErrUserNotFound if it
|
||||
// doesn't exist.
|
||||
func (d *DB) GetUser(username string) (*User, error) {
|
||||
row := d.conn.QueryRow(
|
||||
`SELECT username, password_hash, display_name, email FROM users WHERE username = ?`,
|
||||
username,
|
||||
)
|
||||
var u User
|
||||
if err := row.Scan(&u.Username, &u.PasswordHash, &u.DisplayName, &u.Email); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("looking up user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// VerifyPassword returns true if password matches username's stored hash.
|
||||
// It also returns false (without distinguishing why) if the user doesn't
|
||||
// exist, running a dummy bcrypt comparison first to keep the timing
|
||||
// consistent regardless of whether the account exists.
|
||||
func (d *DB) VerifyPassword(username, password string) bool {
|
||||
u, err := d.GetUser(username)
|
||||
if err != nil {
|
||||
_ = bcrypt.CompareHashAndPassword([]byte("$2b$12$invalidinvalidinvalidinvalidinvalidinval"), []byte(password))
|
||||
return false
|
||||
}
|
||||
return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
// ListUsers returns all usernames, sorted.
|
||||
func (d *DB) ListUsers() ([]User, error) {
|
||||
rows, err := d.conn.Query(`SELECT username, password_hash, display_name, email FROM users ORDER BY username`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.Username, &u.PasswordHash, &u.DisplayName, &u.Email); err != nil {
|
||||
return nil, fmt.Errorf("scanning user: %w", err)
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// UserCount returns the number of users in the database (used to detect a
|
||||
// fresh install so config.yaml's legacy `users:` section, if present, can
|
||||
// be imported once).
|
||||
func (d *DB) UserCount() (int, error) {
|
||||
var n int
|
||||
err := d.conn.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("counting users: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// -------- calendars --------
|
||||
|
||||
// CreateCalendar registers a new calendar owned by owner. Returns
|
||||
// ErrResourceExists if it already exists.
|
||||
func (d *DB) CreateCalendar(owner, name string) error {
|
||||
_, err := d.conn.Exec(`INSERT INTO calendars (owner, name) VALUES (?, ?)`, owner, name)
|
||||
if err != nil {
|
||||
if isUniqueConstraintErr(err) {
|
||||
return ErrResourceExists
|
||||
}
|
||||
return fmt.Errorf("creating calendar: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCalendar removes a calendar registration (not the underlying
|
||||
// files/objects — callers are responsible for also removing those via
|
||||
// store.Store). Returns ErrResourceNotFound if it doesn't exist. Any
|
||||
// sharing grants for it are removed as well.
|
||||
func (d *DB) DeleteCalendar(owner, name string) error {
|
||||
tx, err := d.conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting calendar: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM calendars WHERE owner = ? AND name = ?`, owner, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting calendar: %w", err)
|
||||
}
|
||||
if err := requireRowsAffected(res, ErrResourceNotFound); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM calendar_shares WHERE owner = ? AND calendar_name = ?`, owner, name); err != nil {
|
||||
return fmt.Errorf("deleting calendar's shares: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListCalendars returns the names of all calendars owner has registered,
|
||||
// sorted.
|
||||
func (d *DB) ListCalendars(owner string) ([]string, error) {
|
||||
return listNames(d, `SELECT name FROM calendars WHERE owner = ? ORDER BY name`, owner)
|
||||
}
|
||||
|
||||
// -------- address books --------
|
||||
|
||||
// CreateAddressBook registers a new address book owned by owner. Returns
|
||||
// ErrResourceExists if it already exists.
|
||||
func (d *DB) CreateAddressBook(owner, name string) error {
|
||||
_, err := d.conn.Exec(`INSERT INTO addressbooks (owner, name) VALUES (?, ?)`, owner, name)
|
||||
if err != nil {
|
||||
if isUniqueConstraintErr(err) {
|
||||
return ErrResourceExists
|
||||
}
|
||||
return fmt.Errorf("creating address book: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAddressBook removes an address book registration (not the
|
||||
// underlying files/objects — callers are responsible for also removing
|
||||
// those via store.Store). Returns ErrResourceNotFound if it doesn't
|
||||
// exist. Any sharing grants for it are removed as well.
|
||||
func (d *DB) DeleteAddressBook(owner, name string) error {
|
||||
tx, err := d.conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting address book: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
res, err := tx.Exec(`DELETE FROM addressbooks WHERE owner = ? AND name = ?`, owner, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting address book: %w", err)
|
||||
}
|
||||
if err := requireRowsAffected(res, ErrResourceNotFound); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM addressbook_shares WHERE owner = ? AND addressbook_name = ?`, owner, name); err != nil {
|
||||
return fmt.Errorf("deleting address book's shares: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListAddressBooks returns the names of all address books owner has
|
||||
// registered, sorted.
|
||||
func (d *DB) ListAddressBooks(owner string) ([]string, error) {
|
||||
return listNames(d, `SELECT name FROM addressbooks WHERE owner = ? ORDER BY name`, owner)
|
||||
}
|
||||
|
||||
// -------- helpers --------
|
||||
|
||||
func listNames(d *DB, query, arg string) ([]string, error) {
|
||||
rows, err := d.conn.Query(query, arg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var names []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
return nil, fmt.Errorf("scanning: %w", err)
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
return names, rows.Err()
|
||||
}
|
||||
|
||||
func requireRowsAffected(res sql.Result, errIfZero error) error {
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return errIfZero
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUniqueConstraintErr reports whether err looks like a SQLite UNIQUE /
|
||||
// PRIMARY KEY constraint violation. modernc.org/sqlite doesn't expose a
|
||||
// typed error for this, so this matches on the driver's error message.
|
||||
func isUniqueConstraintErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "UNIQUE constraint failed") || strings.Contains(msg, "constraint failed: UNIQUE")
|
||||
}
|
||||
Reference in New Issue
Block a user