From f82091a9bd592e4dec405ccbddbefa62c77a2f65b8e1ae0206c9fc7413838656 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Sat, 28 Feb 2026 01:37:40 -0500 Subject: [PATCH] v0.9.3 db and web fixes for deployment --- cmd/chess-server/main.go | 6 +++- internal/server/http/auth.go | 18 ++++++------ internal/server/http/handler.go | 9 +++--- internal/server/processor/queue.go | 5 ++-- internal/server/service/user.go | 18 ++++++++---- internal/server/service/waiter.go | 5 ++-- internal/server/storage/user.go | 28 +++++++++++++------ .../server/webserver/chess-client-web/app.js | 24 +++++++++------- 8 files changed, 72 insertions(+), 41 deletions(-) diff --git a/cmd/chess-server/main.go b/cmd/chess-server/main.go index 6cb72ac..974091c 100644 --- a/cmd/chess-server/main.go +++ b/cmd/chess-server/main.go @@ -75,6 +75,9 @@ func main() { if err != nil { log.Fatalf("Failed to initialize storage: %v", err) } + if err := store.InitDB(); err != nil { + log.Fatalf("Failed to initialize schema: %v", err) + } defer func() { if err := store.Close(); err != nil { log.Printf("Warning: failed to close storage cleanly: %v", err) @@ -189,4 +192,5 @@ func main() { } log.Println("Servers exited") -} \ No newline at end of file +} + diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go index 1232415..6d579c2 100644 --- a/internal/server/http/auth.go +++ b/internal/server/http/auth.go @@ -1,6 +1,7 @@ package http import ( + "errors" "fmt" "regexp" "strings" @@ -93,6 +94,13 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error { // Create user (temp by default via API) user, err := h.svc.CreateUser(req.Username, req.Email, req.Password, false) if err != nil { + if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrPermanentSlotsFull) { + return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ + Error: "registration temporarily unavailable", + Code: core.ErrResourceLimit, + Details: err.Error(), + }) + } if strings.Contains(err.Error(), "already exists") { return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{ Error: "user already exists", @@ -100,13 +108,6 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error { Details: "username or email already taken", }) } - if strings.Contains(err.Error(), "limit") || strings.Contains(err.Error(), "capacity") { - return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{ - Error: "registration temporarily unavailable", - Code: core.ErrResourceLimit, - Details: err.Error(), - }) - } return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{ Error: "failed to create user", Code: core.ErrInternalError, @@ -261,4 +262,5 @@ func (h *HTTPHandler) LogoutHandler(c *fiber.Ctx) error { } return c.JSON(fiber.Map{"message": "logged out"}) -} \ No newline at end of file +} + diff --git a/internal/server/http/handler.go b/internal/server/http/handler.go index 1a71391..2fd4d15 100644 --- a/internal/server/http/handler.go +++ b/internal/server/http/handler.go @@ -36,9 +36,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) // Initialize Fiber app app := fiber.New(fiber.Config{ ErrorHandler: customErrorHandler, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 30 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 35 * time.Second, + IdleTimeout: 60 * time.Second, }) // Global middleware (order matters) @@ -517,4 +517,5 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error { } return c.JSON(resp.Data) -} \ No newline at end of file +} + diff --git a/internal/server/processor/queue.go b/internal/server/processor/queue.go index 13be5f3..60dd199 100644 --- a/internal/server/processor/queue.go +++ b/internal/server/processor/queue.go @@ -90,7 +90,7 @@ func (q *EngineQueue) worker(id int) { // Send result if receiver still listening select { case task.Response <- result: - case <-time.After(100 * time.Millisecond): + case <-time.After(15 * time.Millisecond): // Receiver abandoned, discard result } @@ -205,4 +205,5 @@ func (q *EngineQueue) Shutdown(timeout time.Duration) error { case <-time.After(timeout): return fmt.Errorf("shutdown timeout exceeded") } -} \ No newline at end of file +} + diff --git a/internal/server/service/user.go b/internal/server/service/user.go index 627aab5..0433e50 100644 --- a/internal/server/service/user.go +++ b/internal/server/service/user.go @@ -1,6 +1,7 @@ package service import ( + "errors" "fmt" "strings" "time" @@ -11,6 +12,12 @@ import ( "github.com/lixenwraith/auth" ) +var ( + ErrStorageDisabled = errors.New("storage disabled") + ErrAtCapacity = errors.New("at capacity") + ErrPermanentSlotsFull = errors.New("permanent slots full") +) + // User represents a registered user account type User struct { UserID string @@ -24,13 +31,13 @@ type User struct { // 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") + return nil, ErrStorageDisabled } // Check registration limits total, permCount, _, err := s.store.GetUserCounts() if err != nil { - return nil, fmt.Errorf("failed to check user limits: %w", err) + return nil, fmt.Errorf("failed to get user count: %w", err) } // Determine account type @@ -39,7 +46,7 @@ func (s *Service) CreateUser(username, email, password string, permanent bool) ( if permanent { if permCount >= PermanentSlots { - return nil, fmt.Errorf("permanent user slots full (%d/%d)", permCount, PermanentSlots) + return nil, fmt.Errorf("%w (%d/%d)", ErrPermanentSlotsFull, permCount, PermanentSlots) } accountType = "permanent" } else { @@ -50,7 +57,7 @@ func (s *Service) CreateUser(username, email, password string, permanent bool) ( // 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) + return nil, fmt.Errorf("%w: %v", ErrAtCapacity, err) } } @@ -275,4 +282,5 @@ func (s *Service) CreateUserSession(userID string) (string, error) { } return sessionID, nil -} \ No newline at end of file +} + diff --git a/internal/server/service/waiter.go b/internal/server/service/waiter.go index 432b274..6eac4f0 100644 --- a/internal/server/service/waiter.go +++ b/internal/server/service/waiter.go @@ -9,7 +9,7 @@ import ( const ( // WaitTimeout is the maximum time a client can wait for notifications - WaitTimeout = 25 * time.Second + WaitTimeout = 30 * time.Second // WaitChannelBuffer size for notification channels WaitChannelBuffer = 1 @@ -174,4 +174,5 @@ func (w *WaitRegistry) removeWaiter(gameID string, req *WaitRequest) { // Stop timer if still running req.Timer.Stop() -} \ No newline at end of file +} + diff --git a/internal/server/storage/user.go b/internal/server/storage/user.go index a9ac4fb..803c040 100644 --- a/internal/server/storage/user.go +++ b/internal/server/storage/user.go @@ -26,9 +26,9 @@ func DefaultUserLimits() UserLimits { // 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 + COUNT(*) as total, + COALESCE(SUM(CASE WHEN account_type = 'permanent' THEN 1 ELSE 0 END), 0) as permanent, + COALESCE(SUM(CASE WHEN account_type = 'temp' THEN 1 ELSE 0 END), 0) as temp FROM users` err = s.db.QueryRow(query).Scan(&total, &permanent, &temp) @@ -38,6 +38,7 @@ func (s *Store) GetUserCounts() (total, permanent, temp int, err error) { // 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' @@ -45,13 +46,14 @@ func (s *Store) GetOldestTempUser() (*UserRecord, error) { LIMIT 1` err := s.db.QueryRow(query).Scan( - &user.UserID, &user.Username, &user.Email, + &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 } @@ -165,14 +167,16 @@ 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, &user.Email, + &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 users = append(users, user) } @@ -192,51 +196,57 @@ func (s *Store) UpdateUserLastLoginSync(userID string, loginTime time.Time) erro // GetUserByUsername retrieves user by username with case-insensitive matching 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, &user.Email, + &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 } // GetUserByEmail retrieves user by email with case-insensitive matching 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` err := s.db.QueryRow(query, email).Scan( - &user.UserID, &user.Username, &user.Email, + &user.UserID, &user.Username, &emailNull, &user.PasswordHash, &user.AccountType, &user.CreatedAt, &user.ExpiresAt, &user.LastLoginAt, ) if err != nil { return nil, err } + user.Email = emailNull.String return &user, nil } // 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 = ?` err := s.db.QueryRow(query, userID).Scan( - &user.UserID, &user.Username, &user.Email, + &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 } @@ -257,4 +267,4 @@ func (s *Store) DeleteUser(userID string) error { log.Printf("Storage write queue full, dropping user deletion") return nil } -} \ No newline at end of file +} diff --git a/internal/server/webserver/chess-client-web/app.js b/internal/server/webserver/chess-client-web/app.js index 1ec2611..46095c5 100644 --- a/internal/server/webserver/chess-client-web/app.js +++ b/internal/server/webserver/chess-client-web/app.js @@ -155,6 +155,15 @@ function switchAuthTab(tab) { document.getElementById('register-form').style.display = tab === 'register' ? 'block' : 'none'; } +// Shared helper: safely parse error response regardless of Content-Type +async function parseErrorResponse(response) { + try { + return await response.json(); + } catch { + return { error: `Server error (${response.status})`, details: null }; + } +} + async function handleLogin() { const identifier = document.getElementById('login-identifier').value.trim(); const password = document.getElementById('login-password').value; @@ -175,8 +184,8 @@ async function handleLogin() { }); if (!response.ok) { - const err = await response.json(); - flashErrorMessage(err.error || 'Login failed', 3000); + const err = await parseErrorResponse(response); + flashErrorMessage(err.details || err.error || 'Login failed', 3000); return; } @@ -197,22 +206,17 @@ async function handleLogin() { async function handleRegister() { const username = document.getElementById('register-username').value.trim(); const email = document.getElementById('register-email').value.trim(); - const password = document.getElementById('register-password').value; // intentionally not trimmed for passwords + const password = document.getElementById('register-password').value; if (!username || !password) { flashErrorMessage('Username and password required'); return; } - if (password.length < 8) { flashErrorMessage('Password min 8 chars'); return; } - - // Match server-side requirement: at least one letter AND one digit - const hasLetter = /[a-zA-Z]/.test(password); - const hasNumber = /[0-9]/.test(password); - if (!hasLetter || !hasNumber) { + if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) { flashErrorMessage('Password needs a letter and number'); return; } @@ -231,7 +235,7 @@ async function handleRegister() { }); if (!response.ok) { - const err = await response.json(); + const err = await parseErrorResponse(response); flashErrorMessage(err.details || err.error || 'Registration failed', 3000); return; }