Files
nidus/internal/db/users.go
T
arnef 2fd39c8180 refactor: unify data directory structure
Unify user data directory from fragmented layout to consistent nested format:

- Move WebDAV from: data/files/<username> → data/<username>/files/
- Move CalDAV from: data/<username>/cal-<name> → data/<username>/calendars/<name>
- Move CardDAV from: data/<username>/card-<name> → data/<username>/addressbooks/<name>

Changes:
- internal/store/store.go: Update collectionPath() to map collection names
- internal/store/migrate.go: Add idempotent Migrate() method
- internal/store/migrate_test.go: Comprehensive migration tests
- internal/webdav/handler.go: Use new unified path structure
- cmd/server/main.go: Auto-run migration on startup
- tools/nidusctl/main.go: Add migrate subcommand
- Update tests to verify new structure

URL endpoints unchanged - only on-disk structure modified. All tests pass.
2026-08-30 12:15:31 +02:00

419 lines
14 KiB
Go

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")
// ErrReservedName is returned when trying to create a calendar whose name
// is reserved for a computed/virtual calendar (see reservedCalendarNames).
var ErrReservedName = errors.New("calendar name is reserved")
// reservedCalendarNames are calendar names that can't be used for a real,
// user-created calendar because they're reserved for a synthetic,
// computed calendar shown alongside real ones (e.g. "birthdays", see
// internal/web/calendar.go and internal/caldav/backend.go). Matched
// case-insensitively.
var reservedCalendarNames = map[string]bool{
"birthdays": true,
}
// 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)
}
// SetProfile updates username's display name and email address. Returns
// ErrUserNotFound if the user doesn't exist.
func (d *DB) SetProfile(username, displayName, email string) error {
res, err := d.conn.Exec(`UPDATE users SET display_name = ?, email = ? WHERE username = ?`, displayName, email, username)
if err != nil {
return fmt.Errorf("setting profile: %w", err)
}
return requireRowsAffected(res, ErrUserNotFound)
}
// DisplayName returns username's display name if one is set, otherwise
// username itself. This is the friendly label to show wherever a user's
// identity is surfaced in the UI (e.g. "shared by <name>"), instead of
// the raw login username.
func (d *DB) DisplayName(username string) string {
u, err := d.GetUser(username)
if err != nil || u.DisplayName == "" {
return username
}
return u.DisplayName
}
// 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 --------
// Calendar is a calendar registration, including its display color.
type Calendar struct {
Name string
Color string
}
// CreateCalendar registers a new calendar owned by owner. Returns
// ErrResourceExists if it already exists.
func (d *DB) CreateCalendar(owner, name string) error {
return d.CreateCalendarWithColor(owner, name, "")
}
// CreateCalendarWithColor registers a new calendar owned by owner with the
// given color (an empty string means no color is set, in which case the
// client picks its own default). Returns ErrResourceExists if it already
// exists, or ErrReservedName if name is reserved for a computed calendar.
func (d *DB) CreateCalendarWithColor(owner, name, color string) error {
if reservedCalendarNames[strings.ToLower(name)] {
return ErrReservedName
}
// The "/cal/home/<name>/" namespace is shared with ICS subscriptions
// (see internal/db/ics.go), so a real calendar can't be created under
// a name already taken by one of those either.
var subExists int
if err := d.conn.QueryRow(`SELECT COUNT(*) FROM ics_subscriptions WHERE owner = ? AND name = ?`, owner, name).Scan(&subExists); err != nil {
return fmt.Errorf("checking ics subscription name: %w", err)
}
if subExists > 0 {
return ErrResourceExists
}
_, err := d.conn.Exec(`INSERT INTO calendars (owner, name, color) VALUES (?, ?, ?)`, owner, name, color)
if err != nil {
if isUniqueConstraintErr(err) {
return ErrResourceExists
}
return fmt.Errorf("creating calendar: %w", err)
}
return nil
}
// SetCalendarColor updates a calendar's display color. Returns
// ErrResourceNotFound if it doesn't exist.
func (d *DB) SetCalendarColor(owner, name, color string) error {
res, err := d.conn.Exec(`UPDATE calendars SET color = ? WHERE owner = ? AND name = ?`, color, owner, name)
if err != nil {
return fmt.Errorf("setting calendar color: %w", err)
}
return requireRowsAffected(res, ErrResourceNotFound)
}
// GetCalendarColor returns a calendar's display color. Returns
// ErrResourceNotFound if it doesn't exist.
func (d *DB) GetCalendarColor(owner, name string) (string, error) {
var color string
err := d.conn.QueryRow(`SELECT color FROM calendars WHERE owner = ? AND name = ?`, owner, name).Scan(&color)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrResourceNotFound
}
return "", fmt.Errorf("getting calendar color: %w", err)
}
return color, 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 all calendars owner has registered, sorted by name.
func (d *DB) ListCalendars(owner string) ([]Calendar, error) {
rows, err := d.conn.Query(`SELECT name, color FROM calendars WHERE owner = ? ORDER BY name`, owner)
if err != nil {
return nil, fmt.Errorf("listing calendars: %w", err)
}
defer rows.Close()
var cals []Calendar
for rows.Next() {
var c Calendar
if err := rows.Scan(&c.Name, &c.Color); err != nil {
return nil, fmt.Errorf("scanning calendar: %w", err)
}
cals = append(cals, c)
}
return cals, rows.Err()
}
// GetBirthdayCalendarColor returns owner's display color for the
// synthetic "Birthdays" calendar, or "" if they haven't set one (callers
// should fall back to a built-in default in that case).
func (d *DB) GetBirthdayCalendarColor(owner string) (string, error) {
var color string
err := d.conn.QueryRow(`SELECT color FROM birthday_calendars WHERE owner = ?`, owner).Scan(&color)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("getting birthday calendar color: %w", err)
}
return color, nil
}
// SetBirthdayCalendarColor sets (or updates) owner's display color for
// the synthetic "Birthdays" calendar.
func (d *DB) SetBirthdayCalendarColor(owner, color string) error {
_, err := d.conn.Exec(`
INSERT INTO birthday_calendars (owner, color) VALUES (?, ?)
ON CONFLICT (owner) DO UPDATE SET color = excluded.color`,
owner, color)
if err != nil {
return fmt.Errorf("setting birthday calendar color: %w", err)
}
return nil
}
// -------- 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")
}