Files
chess/internal/server/storage/user.go
T

279 lines
8.9 KiB
Go

package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
)
var (
ErrUserAlreadyExists = errors.New("username or email already exists")
ErrUserCapacity = errors.New("user capacity reached")
)
// userCreateLockKey serializes account creation across processes (server and
// CLI) so the capacity check cannot be raced past.
const userCreateLockKey int64 = 0x6368657375 // "chesu"
const userSelectColumns = `user_id, username, email, password_hash, created_at, last_login_at`
// UserLimits defines public-registration constraints.
type UserLimits struct {
MaxUsers int // total accounts at which registration closes
}
// CreateUser creates an administratively managed user without applying the
// public-registration capacity policy.
func (s *Store) CreateUser(record UserRecord) error {
return s.createUser(record, nil, nil)
}
// CreateUserWithinLimits atomically applies the registration limit, creates
// the user, and optionally creates its initial session. A session failure
// rolls back the account.
func (s *Store) CreateUserWithinLimits(
record UserRecord,
session *SessionRecord,
limits UserLimits,
) error {
return s.createUser(record, session, &limits)
}
func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *UserLimits) error {
if session != nil && session.UserID != record.UserID {
return errors.New("initial session user does not match new user")
}
ctx, cancel := opContext()
defer cancel()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, userCreateLockKey); err != nil {
return fmt.Errorf("lock user creation: %w", err)
}
// Report a duplicate as such even when registration is full. The advisory
// lock orders this check with concurrent creations; the unique constraints
// still catch a race with renames.
var exists bool
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM users WHERE username = lower($1) OR email = lower($2)
)`, record.Username, nullableString(record.Email)).Scan(&exists); err != nil {
return fmt.Errorf("check user uniqueness: %w", err)
}
if exists {
return ErrUserAlreadyExists
}
if limits != nil && limits.MaxUsers > 0 {
var total int
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&total); err != nil {
return fmt.Errorf("count users: %w", err)
}
if total >= limits.MaxUsers {
return ErrUserCapacity
}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO users (
user_id, username, email, password_hash, created_at
) VALUES ($1, lower($2), lower($3), $4, $5)`,
record.UserID, record.Username, nullableString(record.Email),
record.PasswordHash, record.CreatedAt,
); err != nil {
return mapUserConflict(err)
}
if session != nil {
if _, err := tx.ExecContext(ctx,
`INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES ($1, $2, $3, $4)`,
session.SessionID, session.UserID, session.CreatedAt, session.ExpiresAt,
); err != nil {
return fmt.Errorf("create initial session: %w", err)
}
}
if err := tx.Commit(); err != nil {
return err
}
slog.Debug("storage user created", "user_id", record.UserID, "initial_session", session != nil)
return nil
}
// mapUserConflict converts a username/email uniqueness violation, including one
// lost to a concurrent writer, into ErrUserAlreadyExists.
func mapUserConflict(err error) error {
if constraint, ok := uniqueViolation(err); ok &&
(constraint == "users_username_key" || constraint == "users_email_key") {
return ErrUserAlreadyExists
}
return err
}
// DeleteUser removes a user synchronously; sessions cascade, while games keep
// the claim ID and name snapshot. It returns sql.ErrNoRows when no such user
// exists.
func (s *Store) DeleteUser(userID string) error {
if err := s.execUserUpdate(`DELETE FROM users WHERE user_id = $1`, userID); err != nil {
return err
}
slog.Debug("storage user deleted", "user_id", userID)
return nil
}
// DeleteAccount removes an account at its owner's request. Unlike DeleteUser,
// which keeps the claim IDs and name snapshots for the record, it first clears
// the user's claims and names from every stored game, so nothing in the
// database still names the person, and a game nobody else claimed becomes
// anonymous and falls to the inactivity purge. Sessions cascade.
//
// It runs on the ordered game writer, behind every gameplay write already
// queued, so a queued claim cannot land on a game after it was cleared. It
// returns sql.ErrNoRows when no such user exists.
func (s *Store) DeleteAccount(userID string) error {
if !validUUID(userID) {
return sql.ErrNoRows
}
// Set inside the transaction, read after the writer reports back: the
// barrier channel orders the two. A missing row is an answer, not a
// failed write, so it must not degrade the writer.
var deleted int64
// Behind the queue, then its own transaction.
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout+writeTimeout)
defer cancel()
err := s.enqueueWait(ctx, "delete_account", func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `UPDATE games SET
white_claimed_by = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_claimed_by END,
white_name = CASE WHEN white_claimed_by = $1 THEN NULL ELSE white_name END,
black_claimed_by = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_claimed_by END,
black_name = CASE WHEN black_claimed_by = $1 THEN NULL ELSE black_name END
WHERE white_claimed_by = $1 OR black_claimed_by = $1`, userID); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `DELETE FROM users WHERE user_id = $1`, userID)
if err != nil {
return err
}
deleted, err = result.RowsAffected()
return err
})
if err != nil {
return err
}
if deleted == 0 {
return sql.ErrNoRows
}
slog.Debug("storage account deleted", "user_id", userID)
return nil
}
// UpdateUserPassword updates user password hash
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
return s.execUserUpdate(`UPDATE users SET password_hash = $2 WHERE user_id = $1`,
userID, passwordHash)
}
// UpdateUserEmail updates the email; an empty value removes it.
func (s *Store) UpdateUserEmail(userID, email string) error {
return s.execUserUpdate(`UPDATE users SET email = lower($2) WHERE user_id = $1`,
userID, nullableString(email))
}
// UpdateUserUsername updates username
func (s *Store) UpdateUserUsername(userID, username string) error {
return s.execUserUpdate(`UPDATE users SET username = lower($2) WHERE user_id = $1`,
userID, username)
}
// execUserUpdate runs a single-row statement keyed by user_id ($1).
func (s *Store) execUserUpdate(query, userID string, args ...any) error {
if !validUUID(userID) {
return sql.ErrNoRows
}
ctx, cancel := opContext()
defer cancel()
result, err := s.db.ExecContext(ctx, query, append([]any{userID}, args...)...)
if err != nil {
return mapUserConflict(err)
}
if rows, err := result.RowsAffected(); err != nil {
return err
} else if rows == 0 {
return sql.ErrNoRows
}
return nil
}
// GetAllUsers retrieves all users, newest first
func (s *Store) GetAllUsers() ([]UserRecord, error) {
ctx, cancel := opContext()
defer cancel()
rows, err := s.db.QueryContext(ctx,
`SELECT `+userSelectColumns+` FROM users ORDER BY created_at DESC, user_id`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []UserRecord
for rows.Next() {
var user UserRecord
if err := scanUser(rows, &user); err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
// GetUserByUsername retrieves a user by case-insensitive username.
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
return s.getUser(`username = lower($1)`, username)
}
// GetUserByEmail retrieves a user by case-insensitive email.
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
if email == "" {
return nil, sql.ErrNoRows
}
return s.getUser(`email = lower($1)`, email)
}
// GetUserByID retrieves user by unique user ID
func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
if !validUUID(userID) {
return nil, sql.ErrNoRows
}
return s.getUser(`user_id = $1`, userID)
}
func (s *Store) getUser(predicate string, arg any) (*UserRecord, error) {
ctx, cancel := opContext()
defer cancel()
var user UserRecord
row := s.db.QueryRowContext(ctx, `SELECT `+userSelectColumns+` FROM users WHERE `+predicate, arg)
if err := scanUser(row, &user); err != nil {
return nil, err
}
return &user, nil
}
func scanUser(scanner rowScanner, user *UserRecord) error {
var email sql.NullString
var lastLoginAt sql.NullTime
if err := scanner.Scan(
&user.UserID, &user.Username, &email,
&user.PasswordHash, &user.CreatedAt, &lastLoginAt,
); err != nil {
return err
}
user.Email = email.String
user.CreatedAt = user.CreatedAt.UTC()
user.LastLoginAt = utcPointer(lastLoginAt)
return nil
}