v0.9.0 user and session management improvement, xterm.js addons
This commit is contained in:
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/storage/game.go
|
||||
package storage
|
||||
|
||||
import (
|
||||
|
||||
@ -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,
|
||||
|
||||
92
internal/server/storage/session.go
Normal file
92
internal/server/storage/session.go
Normal 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
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/storage/storage.go
|
||||
package storage
|
||||
|
||||
import (
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user