v0.9.0 user and session management improvement, xterm.js addons

This commit is contained in:
2026-02-07 15:37:52 -05:00
parent 0a85cc88bb
commit 820ad7eb27
62 changed files with 875 additions and 446 deletions

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/storage/game.go
package storage
import (

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/storage/schema.go
package storage
import "time"
@ -9,10 +8,20 @@ type UserRecord struct {
Username string `db:"username"`
Email string `db:"email"`
PasswordHash string `db:"password_hash"`
AccountType string `db:"account_type"` // "permanent" or "temp"
CreatedAt time.Time `db:"created_at"`
ExpiresAt *time.Time `db:"expires_at"` // nil for permanent
LastLoginAt *time.Time `db:"last_login_at"`
}
// SessionRecord represents an active user session
type SessionRecord struct {
SessionID string `db:"session_id"`
UserID string `db:"user_id"`
CreatedAt time.Time `db:"created_at"`
ExpiresAt time.Time `db:"expires_at"`
}
// GameRecord represents a row in the games table
type GameRecord struct {
GameID string `db:"game_id"`
@ -35,7 +44,7 @@ type MoveRecord struct {
MoveNumber int `db:"move_number"`
MoveUCI string `db:"move_uci"`
FENAfterMove string `db:"fen_after_move"`
PlayerColor string `db:"player_color"` // "w" or "b"
PlayerColor string `db:"player_color"`
MoveTimeUTC time.Time `db:"move_time_utc"`
}
@ -46,14 +55,29 @@ CREATE TABLE IF NOT EXISTS users (
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
email TEXT COLLATE NOCASE,
password_hash TEXT NOT NULL,
account_type TEXT NOT NULL DEFAULT 'temp' CHECK(account_type IN ('permanent', 'temp')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME,
last_login_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_account_type ON users(account_type);
CREATE INDEX IF NOT EXISTS idx_users_expires_at ON users(expires_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email) WHERE email IS NOT NULL AND email != '';
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS games (
game_id TEXT PRIMARY KEY,
initial_fen TEXT NOT NULL,

View File

@ -0,0 +1,92 @@
package storage
import (
"fmt"
"time"
)
// CreateSession creates or replaces the session for a user (single session per user)
func (s *Store) CreateSession(record SessionRecord) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Delete any existing session for this user
deleteQuery := `DELETE FROM sessions WHERE user_id = ?`
if _, err := tx.Exec(deleteQuery, record.UserID); err != nil {
return fmt.Errorf("failed to delete existing session: %w", err)
}
// Insert new session
insertQuery := `INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`
if _, err := tx.Exec(insertQuery, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil {
return fmt.Errorf("failed to create session: %w", err)
}
return tx.Commit()
}
// GetSession retrieves a session by ID
func (s *Store) GetSession(sessionID string) (*SessionRecord, error) {
var session SessionRecord
query := `SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE session_id = ?`
err := s.db.QueryRow(query, sessionID).Scan(
&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt,
)
if err != nil {
return nil, err
}
return &session, nil
}
// GetSessionByUserID retrieves the active session for a user
func (s *Store) GetSessionByUserID(userID string) (*SessionRecord, error) {
var session SessionRecord
query := `SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE user_id = ?`
err := s.db.QueryRow(query, userID).Scan(
&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt,
)
if err != nil {
return nil, err
}
return &session, nil
}
// DeleteSession removes a session
func (s *Store) DeleteSession(sessionID string) error {
query := `DELETE FROM sessions WHERE session_id = ?`
_, err := s.db.Exec(query, sessionID)
return err
}
// DeleteSessionByUserID removes all sessions for a user
func (s *Store) DeleteSessionByUserID(userID string) error {
query := `DELETE FROM sessions WHERE user_id = ?`
_, err := s.db.Exec(query, userID)
return err
}
// DeleteExpiredSessions removes expired sessions
func (s *Store) DeleteExpiredSessions() (int64, error) {
query := `DELETE FROM sessions WHERE expires_at < ?`
result, err := s.db.Exec(query, time.Now().UTC())
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// IsSessionValid checks if a session exists and is not expired
func (s *Store) IsSessionValid(sessionID string) (bool, error) {
var count int
query := `SELECT COUNT(*) FROM sessions WHERE session_id = ? AND expires_at > ?`
err := s.db.QueryRow(query, sessionID, time.Now().UTC()).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/storage/storage.go
package storage
import (

View File

@ -1,4 +1,3 @@
// FILE: lixenwraith/chess/internal/server/storage/user.go
package storage
import (
@ -8,6 +7,64 @@ import (
"time"
)
// UserLimits defines registration constraints
type UserLimits struct {
MaxUsers int
PermanentSlots int
TempTTL time.Duration
}
// DefaultUserLimits returns default POC limits
func DefaultUserLimits() UserLimits {
return UserLimits{
MaxUsers: 100,
PermanentSlots: 10,
TempTTL: 24 * time.Hour,
}
}
// GetUserCounts returns current user counts by type
func (s *Store) GetUserCounts() (total, permanent, temp int, err error) {
query := `SELECT
COUNT(*) as total,
SUM(CASE WHEN account_type = 'permanent' THEN 1 ELSE 0 END) as permanent,
SUM(CASE WHEN account_type = 'temp' THEN 1 ELSE 0 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
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, &user.Email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
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 < ?`
result, err := s.db.Exec(query, time.Now().UTC())
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// CreateUser creates user with transaction isolation to prevent race conditions
func (s *Store) CreateUser(record UserRecord) error {
tx, err := s.db.Begin()
@ -27,12 +84,12 @@ func (s *Store) CreateUser(record UserRecord) error {
// Insert user
query := `INSERT INTO users (
user_id, username, email, password_hash, created_at
) VALUES (?, ?, ?, ?, ?)`
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.CreatedAt,
record.PasswordHash, record.AccountType, record.CreatedAt, record.ExpiresAt,
)
if err != nil {
return err
@ -41,6 +98,20 @@ func (s *Store) CreateUser(record UserRecord) error {
return tx.Commit()
}
// 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)
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
@ -82,7 +153,7 @@ func (s *Store) UpdateUserUsername(userID string, username string) error {
// GetAllUsers retrieves all users
func (s *Store) GetAllUsers() ([]UserRecord, error) {
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users ORDER BY created_at DESC`
rows, err := s.db.Query(query)
@ -96,7 +167,8 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) {
var user UserRecord
err := rows.Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
@ -110,24 +182,23 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) {
// 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
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
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, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
@ -138,12 +209,13 @@ func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
// GetUserByEmail retrieves user by email with case-insensitive matching
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users WHERE email = ? COLLATE NOCASE`
err := s.db.QueryRow(query, email).Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
@ -154,12 +226,13 @@ func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
// GetUserByID retrieves user by unique user ID
func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
var user UserRecord
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users WHERE user_id = ?`
err := s.db.QueryRow(query, userID).Scan(
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
@ -167,7 +240,7 @@ func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
return &user, nil
}
// DeleteUser removes a user from the database
// DeleteUser removes a user from the database (async)
func (s *Store) DeleteUser(userID string) error {
if !s.healthStatus.Load() {
return nil