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) } // 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 } _, 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") }