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:
2026-08-19 12:43:34 +02:00
co-authored by Copilot
parent 3dc49b6b22
commit 7d4f28de3c
25 changed files with 1440 additions and 365 deletions
+23 -1
View File
@@ -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,
+291
View File
@@ -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")
}