Files
nidus/internal/db/users.go
T
arnefandCopilot ebbc7a2a2b Add calendar color support (DAVx5 calendar-color)
Calendars can now have a color (hex, e.g. #3b82f6) that DAVx5 and other
CalDAV clients pick up via the Apple/dav4jvm calendar-color property.

- db: add calendars.color column with migration for existing DBs;
  CreateCalendarWithColor, SetCalendarColor, GetCalendarColor;
  ListCalendars now returns []Calendar{Name, Color} instead of []string
- caldav: since go-webdav's caldav.Backend interface has no extension
  point for vendor properties, wrap the handler with a response-rewriting
  middleware that injects <calendar-color xmlns="http://apple.com/ns/ical/">
  into PROPFIND responses for calendars that have a color set
- web: color picker on the "New calendar" form and an inline color swatch/
  picker on each calendar card (calendars only, not address books)
- nidusctl: `calendar create --color` flag and a new `calendar color`
  subcommand; `calendar list` now also prints the color if set

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-20 06:43:56 +02:00

343 lines
11 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")
// 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 --------
// 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.
func (d *DB) CreateCalendarWithColor(owner, name, color string) error {
_, 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()
}
// -------- 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")
}