v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-26 20:58:19 -04:00
1 parent 5d2be4abb2
commit d0326b059e
66 files changed
+4016 -2433

No files matched your search

+122 -229
View File
@@ -5,76 +5,22 @@ import (
"errors"
"fmt"
"log/slog"
"time"
)
var (
ErrUserAlreadyExists = errors.New("username or email already exists")
ErrUserCapacity = errors.New("user capacity reached")
ErrPermanentCapacity = errors.New("permanent user capacity reached")
)
// UserLimits defines registration constraints
// 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
PermanentSlots int
}
// DefaultUserLimits returns default POC limits
func DefaultUserLimits() UserLimits {
return UserLimits{
MaxUsers: 100,
PermanentSlots: 10,
}
}
// GetUserCounts returns current user counts by type
func (s *Store) GetUserCounts() (total, permanent, temp int, err error) {
query := `SELECT
COUNT(*) as total,
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END) as permanent,
COUNT(CASE WHEN account_type = 'temp' THEN 1 END) as temp
FROM users`
err = s.db.QueryRow(query).Scan(&total, &permanent, &temp)
return
}
// GetOldestTempUser returns the oldest temporary user for replacement
func (s *Store) GetOldestTempUser() (*UserRecord, error) {
var user UserRecord
var email sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users
WHERE account_type = 'temp'
ORDER BY created_at ASC
LIMIT 1`
err := s.db.QueryRow(query).Scan(
&user.UserID, &user.Username, &email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
user.Email = email.String
return &user, nil
}
// DeleteExpiredTempUsers removes temporary users past their expiry
func (s *Store) DeleteExpiredTempUsers() (int64, error) {
query := `DELETE FROM users
WHERE account_type = 'temp' AND expires_at IS NOT NULL AND expires_at < ?`
result, err := s.db.Exec(query, time.Now().UTC())
if err != nil {
return 0, err
}
deleted, err := result.RowsAffected()
if err == nil && deleted > 0 {
slog.Debug("storage expired temporary users deleted", "count", deleted)
}
return deleted, err
MaxUsers int // total accounts at which registration closes
}
// CreateUser creates an administratively managed user without applying the
@@ -83,10 +29,9 @@ func (s *Store) CreateUser(record UserRecord) error {
return s.createUser(record, nil, nil)
}
// CreateUserWithinLimits atomically applies registration limits, evicts the
// oldest temporary account when required, creates the user, and optionally
// creates its initial session. No account is evicted on a duplicate request,
// and a session failure rolls back the user and eviction together.
// 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,
@@ -96,69 +41,55 @@ func (s *Store) CreateUserWithinLimits(
}
func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *UserLimits) error {
tx, err := s.db.Begin()
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()
// Check uniqueness within transaction
exists, err := s.userExists(tx, record.Username, record.Email)
if err != nil {
return err
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 {
var total, permanent int
if err := tx.QueryRow(`SELECT COUNT(*),
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END)
FROM users`).Scan(&total, &permanent); err != nil {
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 record.AccountType == "permanent" && permanent >= limits.PermanentSlots {
return ErrPermanentCapacity
}
if total >= limits.MaxUsers {
result, err := tx.Exec(`DELETE FROM users WHERE user_id = (
SELECT user_id FROM users
WHERE account_type = 'temp'
ORDER BY created_at ASC
LIMIT 1
)`)
if err != nil {
return fmt.Errorf("evict oldest temporary user: %w", err)
}
deleted, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("inspect temporary user eviction: %w", err)
}
if deleted != 1 {
return ErrUserCapacity
}
return ErrUserCapacity
}
}
// Insert user
query := `INSERT INTO users (
user_id, username, email, password_hash, account_type, created_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`
_, err = tx.Exec(query,
record.UserID, record.Username, record.Email,
record.PasswordHash, record.AccountType, record.CreatedAt, record.ExpiresAt,
)
if err != nil {
return err
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 session.UserID != record.UserID {
return errors.New("initial session user does not match new user")
}
if _, err := tx.Exec(
`INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
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)
@@ -168,78 +99,74 @@ func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *Us
if err := tx.Commit(); err != nil {
return err
}
slog.Debug("storage user created",
"user_id", record.UserID,
"account_type", record.AccountType,
"initial_session", session != nil,
)
slog.Debug("storage user created", "user_id", record.UserID, "initial_session", session != nil)
return nil
}
// DeleteUserByID removes a user by ID (synchronous, for replacement logic)
func (s *Store) DeleteUserByID(userID string) error {
query := `DELETE FROM users WHERE user_id = ?`
_, err := s.db.Exec(query, userID)
if err == nil {
slog.Debug("storage user deleted", "user_id", userID)
// 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
}
// PromoteToPermament upgrades a temp user to permanent
func (s *Store) PromoteToPermanent(userID string) error {
query := `UPDATE users SET account_type = 'permanent', expires_at = NULL WHERE user_id = ?`
_, err := s.db.Exec(query, userID)
return err
}
// userExists verifies username/email uniqueness within a transaction
func (s *Store) userExists(tx *sql.Tx, username, email string) (bool, error) {
var count int
query := `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE`
args := []any{username}
if email != "" {
query = `SELECT COUNT(*) FROM users
WHERE username = ? COLLATE NOCASE
OR (email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '')`
args = append(args, email)
// 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
}
err := tx.QueryRow(query, args...).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
slog.Debug("storage user deleted", "user_id", userID)
return nil
}
// UpdateUserPassword updates user password hash
func (s *Store) UpdateUserPassword(userID string, passwordHash string) error {
query := `UPDATE users SET password_hash = ? WHERE user_id = ?`
_, err := s.db.Exec(query, passwordHash, userID)
return err
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 user email
func (s *Store) UpdateUserEmail(userID string, email string) error {
query := `UPDATE users SET email = ? WHERE user_id = ?`
_, err := s.db.Exec(query, email, userID)
return err
// 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 string, username string) error {
query := `UPDATE users SET username = ? WHERE user_id = ?`
_, err := s.db.Exec(query, username, userID)
return err
func (s *Store) UpdateUserUsername(userID, username string) error {
return s.execUserUpdate(`UPDATE users SET username = lower($2) WHERE user_id = $1`,
userID, username)
}
// GetAllUsers retrieves all users
func (s *Store) GetAllUsers() ([]UserRecord, error) {
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users ORDER BY created_at DESC`
// 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
}
rows, err := s.db.Query(query)
// 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
}
@@ -248,91 +175,57 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) {
var users []UserRecord
for rows.Next() {
var user UserRecord
var email sql.NullString
err := rows.Scan(
&user.UserID, &user.Username, &email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
if err := scanUser(rows, &user); err != nil {
return nil, err
}
user.Email = email.String
users = append(users, user)
}
return users, rows.Err()
}
// UpdateUserLastLoginSync updates user last login time
func (s *Store) UpdateUserLastLoginSync(userID string, loginTime time.Time) error {
query := `UPDATE users SET last_login_at = ? WHERE user_id = ?`
_, err := s.db.Exec(query, loginTime, userID)
if err != nil {
return fmt.Errorf("failed to update last login for user %s: %w", userID, err)
}
return nil
}
// GetUserByUsername retrieves user by username with case-insensitive matching
// GetUserByUsername retrieves a user by case-insensitive username.
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
var user UserRecord
var email sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users WHERE username = ? COLLATE NOCASE`
err := s.db.QueryRow(query, username).Scan(
&user.UserID, &user.Username, &email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
user.Email = email.String
return &user, nil
return s.getUser(`username = lower($1)`, username)
}
// GetUserByEmail retrieves user by email with case-insensitive matching
// GetUserByEmail retrieves a user by case-insensitive email.
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
var user UserRecord
var emailNull sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != ''`
err := s.db.QueryRow(query, email).Scan(
&user.UserID, &user.Username, &emailNull,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
if email == "" {
return nil, sql.ErrNoRows
}
user.Email = emailNull.String
return &user, nil
return s.getUser(`email = lower($1)`, email)
}
// GetUserByID retrieves user by unique user ID
func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
var user UserRecord
var email sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users WHERE user_id = ?`
if !validUUID(userID) {
return nil, sql.ErrNoRows
}
return s.getUser(`user_id = $1`, userID)
}
err := s.db.QueryRow(query, userID).Scan(
&user.UserID, &user.Username, &email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
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
}
user.Email = email.String
return &user, nil
}
// DeleteUser removes a user synchronously. Account operations are consistency
// sensitive and should not be reported successful before SQLite commits them.
func (s *Store) DeleteUser(userID string) error {
return s.DeleteUserByID(userID)
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
}