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/service/game.go
|
||||
package service
|
||||
|
||||
import (
|
||||
@ -21,6 +20,15 @@ func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, i
|
||||
return fmt.Errorf("game %s already exists", id)
|
||||
}
|
||||
|
||||
// Check computer game limit
|
||||
hasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
|
||||
if hasComputer {
|
||||
if s.computerGames.Load() >= MaxComputerGames {
|
||||
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
|
||||
}
|
||||
s.computerGames.Add(1)
|
||||
}
|
||||
|
||||
// Store game with provided players
|
||||
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
||||
|
||||
@ -186,16 +194,22 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGame removes a game from memory
|
||||
// DeleteGame removes a game from the service
|
||||
func (s *Service) DeleteGame(gameID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.games[gameID]; !ok {
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
// Notify and remove all waiters before deletion
|
||||
// Decrement computer game count if applicable
|
||||
if g.HasComputerPlayer() {
|
||||
s.computerGames.Add(-1)
|
||||
}
|
||||
|
||||
// Remove from wait registry
|
||||
s.waiter.RemoveGame(gameID)
|
||||
|
||||
delete(s.games, gameID)
|
||||
|
||||
@ -1,24 +1,35 @@
|
||||
// FILE: lixenwraith/chess/internal/server/service/service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"chess/internal/server/core"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/game"
|
||||
"chess/internal/server/storage"
|
||||
)
|
||||
|
||||
// Service is a pure state manager for chess games with optional persistence
|
||||
const (
|
||||
MaxComputerGames = 10
|
||||
MaxUsers = 100
|
||||
PermanentSlots = 10
|
||||
TempUserTTL = 24 * time.Hour
|
||||
SessionTTL = 7 * 24 * time.Hour
|
||||
CleanupJobInterval = 1 * time.Hour
|
||||
)
|
||||
|
||||
// Service coordinates game state, user management, and storage
|
||||
type Service struct {
|
||||
games map[string]*game.Game
|
||||
mu sync.RWMutex
|
||||
store *storage.Store // nil if persistence disabled
|
||||
jwtSecret []byte
|
||||
waiter *WaitRegistry // Long-polling notification registry
|
||||
games map[string]*game.Game
|
||||
mu sync.RWMutex
|
||||
store *storage.Store
|
||||
jwtSecret []byte
|
||||
waiter *WaitRegistry
|
||||
computerGames atomic.Int32 // Active games with computer players
|
||||
}
|
||||
|
||||
// New creates a new service instance with optional storage
|
||||
@ -47,12 +58,56 @@ func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context
|
||||
return s.waiter.RegisterWait(gameID, moveCount, ctx)
|
||||
}
|
||||
|
||||
// CanCreateComputerGame checks if a new computer game can be created
|
||||
func (s *Service) CanCreateComputerGame() bool {
|
||||
return s.computerGames.Load() < MaxComputerGames
|
||||
}
|
||||
|
||||
// IncrementComputerGames increments the computer game counter
|
||||
func (s *Service) IncrementComputerGames() {
|
||||
s.computerGames.Add(1)
|
||||
}
|
||||
|
||||
// DecrementComputerGames decrements the computer game counter
|
||||
func (s *Service) DecrementComputerGames() {
|
||||
s.computerGames.Add(-1)
|
||||
}
|
||||
|
||||
// GetComputerGameCount returns current computer game count
|
||||
func (s *Service) GetComputerGameCount() int32 {
|
||||
return s.computerGames.Load()
|
||||
}
|
||||
|
||||
// ClaimGameSlot claims a player slot for a user
|
||||
func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
return g.ClaimSlot(color, userID)
|
||||
}
|
||||
|
||||
// GetSlotOwner returns the user who claimed a slot
|
||||
func (s *Service) GetSlotOwner(gameID string, color core.Color) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
return g.GetSlotOwner(color), nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the service
|
||||
func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
// Collect all errors
|
||||
var errs []error
|
||||
|
||||
// Shutdown wait registry
|
||||
if err := s.waiter.Shutdown(timeout); err != nil {
|
||||
errs = append(errs, fmt.Errorf("wait registry: %w", err))
|
||||
}
|
||||
@ -60,10 +115,8 @@ func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Clear all games
|
||||
s.games = make(map[string]*game.Game)
|
||||
|
||||
// Close storage if enabled
|
||||
if s.store != nil {
|
||||
if err := s.store.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("storage: %w", err))
|
||||
@ -71,4 +124,40 @@ func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// RunCleanupJob runs periodic cleanup of expired users and sessions
|
||||
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.cleanupExpired()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) cleanupExpired() {
|
||||
if s.store == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Cleanup expired temp users
|
||||
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
|
||||
// Log but don't fail
|
||||
fmt.Printf("cleanup: failed to delete expired users: %v\n", err)
|
||||
} else if deleted > 0 {
|
||||
fmt.Printf("cleanup: deleted %d expired temp users\n", deleted)
|
||||
}
|
||||
|
||||
// Cleanup expired sessions
|
||||
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
|
||||
fmt.Printf("cleanup: failed to delete expired sessions: %v\n", err)
|
||||
} else if deleted > 0 {
|
||||
fmt.Printf("cleanup: deleted %d expired sessions\n", deleted)
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/service/user.go
|
||||
package service
|
||||
|
||||
import (
|
||||
@ -14,25 +13,54 @@ import (
|
||||
|
||||
// User represents a registered user account
|
||||
type User struct {
|
||||
UserID string
|
||||
Username string
|
||||
Email string
|
||||
CreatedAt time.Time
|
||||
UserID string
|
||||
Username string
|
||||
Email string
|
||||
AccountType string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// CreateUser creates new user with transactional consistency
|
||||
func (s *Service) CreateUser(username, email, password string) (*User, error) {
|
||||
// CreateUser creates new user with registration limits enforcement
|
||||
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) {
|
||||
if s.store == nil {
|
||||
return nil, fmt.Errorf("storage disabled")
|
||||
}
|
||||
|
||||
// Check registration limits
|
||||
total, permCount, _, err := s.store.GetUserCounts()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check user limits: %w", err)
|
||||
}
|
||||
|
||||
// Determine account type
|
||||
accountType := "temp"
|
||||
var expiresAt *time.Time
|
||||
|
||||
if permanent {
|
||||
if permCount >= PermanentSlots {
|
||||
return nil, fmt.Errorf("permanent user slots full (%d/%d)", permCount, PermanentSlots)
|
||||
}
|
||||
accountType = "permanent"
|
||||
} else {
|
||||
expiry := time.Now().UTC().Add(TempUserTTL)
|
||||
expiresAt = &expiry
|
||||
}
|
||||
|
||||
// Handle capacity - remove oldest temp user if at max
|
||||
if total >= MaxUsers {
|
||||
if err := s.removeOldestTempUser(); err != nil {
|
||||
return nil, fmt.Errorf("at capacity and cannot make room: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Generate guaranteed unique user ID with proper collision handling
|
||||
// Generate unique user ID
|
||||
userID, err := s.generateUniqueUserID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate unique ID: %w", err)
|
||||
@ -40,19 +68,22 @@ func (s *Service) CreateUser(username, email, password string) (*User, error) {
|
||||
|
||||
// Create user record
|
||||
user := &User{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
AccountType: accountType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
// Use transactional storage method
|
||||
record := storage.UserRecord{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
Username: strings.ToLower(username),
|
||||
Email: strings.ToLower(email),
|
||||
PasswordHash: passwordHash,
|
||||
AccountType: accountType,
|
||||
CreatedAt: user.CreatedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err = s.store.CreateUser(record); err != nil {
|
||||
@ -62,11 +93,28 @@ func (s *Service) CreateUser(username, email, password string) (*User, error) {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// AuthenticateUser verifies user credentials and returns user information
|
||||
// AuthenticateUser verifies user credentials and returns user information
|
||||
func (s *Service) AuthenticateUser(identifier, password string) (*User, error) {
|
||||
// removeOldestTempUser removes the oldest temporary user to make room
|
||||
func (s *Service) removeOldestTempUser() error {
|
||||
oldest, err := s.store.GetOldestTempUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no temp users to remove: %w", err)
|
||||
}
|
||||
|
||||
// Delete their session first
|
||||
_ = s.store.DeleteSessionByUserID(oldest.UserID)
|
||||
|
||||
// Delete the user
|
||||
if err := s.store.DeleteUserByID(oldest.UserID); err != nil {
|
||||
return fmt.Errorf("failed to remove oldest user: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthenticateUser verifies credentials and creates a new session
|
||||
func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) {
|
||||
if s.store == nil {
|
||||
return nil, fmt.Errorf("storage disabled")
|
||||
return nil, "", fmt.Errorf("storage disabled")
|
||||
}
|
||||
|
||||
var userRecord *storage.UserRecord
|
||||
@ -80,36 +128,62 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, error) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Always hash to prevent timing attacks
|
||||
auth.HashPassword(password)
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
auth.HashPassword(password) // Timing attack prevention
|
||||
return nil, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := auth.VerifyPassword(password, userRecord.PasswordHash); err != nil {
|
||||
return nil, fmt.Errorf("invalid credentials")
|
||||
return nil, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
return &User{
|
||||
// Check if temp user expired
|
||||
if userRecord.AccountType == "temp" && userRecord.ExpiresAt != nil {
|
||||
if time.Now().UTC().After(*userRecord.ExpiresAt) {
|
||||
return nil, "", fmt.Errorf("account expired")
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session (invalidates any existing session)
|
||||
sessionID := uuid.New().String()
|
||||
sessionRecord := storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
}, nil
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(SessionTTL),
|
||||
}
|
||||
|
||||
if err := s.store.CreateSession(sessionRecord); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
// Update last login
|
||||
_ = s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC())
|
||||
|
||||
return &User{
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
AccountType: userRecord.AccountType,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
ExpiresAt: userRecord.ExpiresAt,
|
||||
}, sessionID, nil
|
||||
}
|
||||
|
||||
// UpdateLastLogin updates the last login timestamp for a user
|
||||
func (s *Service) UpdateLastLogin(userID string) error {
|
||||
// ValidateSession checks if a session is valid
|
||||
func (s *Service) ValidateSession(sessionID string) (bool, error) {
|
||||
if s.store == nil {
|
||||
return false, fmt.Errorf("storage disabled")
|
||||
}
|
||||
return s.store.IsSessionValid(sessionID)
|
||||
}
|
||||
|
||||
// InvalidateSession removes a session (logout)
|
||||
func (s *Service) InvalidateSession(sessionID string) error {
|
||||
if s.store == nil {
|
||||
return fmt.Errorf("storage disabled")
|
||||
}
|
||||
|
||||
err := s.store.UpdateUserLastLoginSync(userID, time.Now().UTC())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update last login time for user %s: %w\n", userID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.store.DeleteSession(sessionID)
|
||||
}
|
||||
|
||||
// GetUserByID retrieves user information by user ID
|
||||
@ -124,31 +198,47 @@ func (s *Service) GetUserByID(userID string) (*User, error) {
|
||||
}
|
||||
|
||||
return &User{
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
AccountType: userRecord.AccountType,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
ExpiresAt: userRecord.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateUserToken creates a JWT token for the specified user
|
||||
func (s *Service) GenerateUserToken(userID string) (string, error) {
|
||||
// GenerateUserToken creates a JWT token for the specified user with session ID
|
||||
func (s *Service) GenerateUserToken(userID, sessionID string) (string, error) {
|
||||
user, err := s.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
claims := map[string]any{
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"session_id": sessionID,
|
||||
}
|
||||
|
||||
return auth.GenerateHS256Token(s.jwtSecret, userID, claims, 7*24*time.Hour)
|
||||
return auth.GenerateHS256Token(s.jwtSecret, userID, claims, SessionTTL)
|
||||
}
|
||||
|
||||
// ValidateToken verifies JWT token and returns user ID with claims
|
||||
// ValidateToken verifies JWT token and session validity
|
||||
func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
|
||||
return auth.ValidateHS256Token(s.jwtSecret, token)
|
||||
userID, claims, err := auth.ValidateHS256Token(s.jwtSecret, token)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Validate session is still active
|
||||
if sessionID, ok := claims["session_id"].(string); ok && s.store != nil {
|
||||
valid, err := s.store.IsSessionValid(sessionID)
|
||||
if err != nil || !valid {
|
||||
return "", nil, fmt.Errorf("session invalidated")
|
||||
}
|
||||
}
|
||||
|
||||
return userID, claims, nil
|
||||
}
|
||||
|
||||
// generateUniqueUserID creates a unique user ID with collision detection
|
||||
@ -157,19 +247,32 @@ func (s *Service) generateUniqueUserID() (string, error) {
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
id := uuid.New().String()
|
||||
|
||||
// Check for collision
|
||||
if _, err := s.store.GetUserByID(id); err != nil {
|
||||
// Error means not found, ID is unique
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Collision detected, try again
|
||||
if i == maxAttempts-1 {
|
||||
// After max attempts, fail and don't risk collision
|
||||
return "", fmt.Errorf("failed to generate unique ID after %d attempts", maxAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to generate unique user ID")
|
||||
}
|
||||
|
||||
// CreateUserSession creates a session for a user without re-authenticating
|
||||
// Used after registration to avoid redundant password hashing
|
||||
func (s *Service) CreateUserSession(userID string) (string, error) {
|
||||
if s.store == nil {
|
||||
return "", fmt.Errorf("storage disabled")
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
sessionRecord := storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(SessionTTL),
|
||||
}
|
||||
|
||||
if err := s.store.CreateSession(sessionRecord); err != nil {
|
||||
return "", fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/service/waiter.go
|
||||
package service
|
||||
|
||||
import (
|
||||
|
||||
Reference in New Issue
Block a user