Add a new /web/account page, reachable by clicking the username in the nav, with two forms: updating display name/email, and changing the password (requires the current password, min 8 chars, confirm match). Add db.SetProfile to persist display name/email. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
408 lines
14 KiB
Go
408 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)
|
|
}
|
|
|
|
|
|
// 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")
|
|
}
|