v0.9.3 db and web fixes for deployment

This commit is contained in:
2026-02-28 01:37:40 -05:00
parent 16fc069a8f
commit f82091a9bd
8 changed files with 72 additions and 41 deletions

View File

@ -75,6 +75,9 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("Failed to initialize storage: %v", err) log.Fatalf("Failed to initialize storage: %v", err)
} }
if err := store.InitDB(); err != nil {
log.Fatalf("Failed to initialize schema: %v", err)
}
defer func() { defer func() {
if err := store.Close(); err != nil { if err := store.Close(); err != nil {
log.Printf("Warning: failed to close storage cleanly: %v", err) log.Printf("Warning: failed to close storage cleanly: %v", err)
@ -190,3 +193,4 @@ func main() {
log.Println("Servers exited") log.Println("Servers exited")
} }

View File

@ -1,6 +1,7 @@
package http package http
import ( import (
"errors"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@ -93,6 +94,13 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
// Create user (temp by default via API) // Create user (temp by default via API)
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password, false) user, err := h.svc.CreateUser(req.Username, req.Email, req.Password, false)
if err != nil { 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") { if strings.Contains(err.Error(), "already exists") {
return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{ return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{
Error: "user already exists", Error: "user already exists",
@ -100,13 +108,6 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
Details: "username or email already taken", 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{ return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
Error: "failed to create user", Error: "failed to create user",
Code: core.ErrInternalError, Code: core.ErrInternalError,
@ -262,3 +263,4 @@ func (h *HTTPHandler) LogoutHandler(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "logged out"}) return c.JSON(fiber.Map{"message": "logged out"})
} }

View File

@ -36,9 +36,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
// Initialize Fiber app // Initialize Fiber app
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
ErrorHandler: customErrorHandler, ErrorHandler: customErrorHandler,
ReadTimeout: 10 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: 35 * time.Second,
IdleTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second,
}) })
// Global middleware (order matters) // Global middleware (order matters)
@ -518,3 +518,4 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
return c.JSON(resp.Data) return c.JSON(resp.Data)
} }

View File

@ -90,7 +90,7 @@ func (q *EngineQueue) worker(id int) {
// Send result if receiver still listening // Send result if receiver still listening
select { select {
case task.Response <- result: case task.Response <- result:
case <-time.After(100 * time.Millisecond): case <-time.After(15 * time.Millisecond):
// Receiver abandoned, discard result // Receiver abandoned, discard result
} }
@ -206,3 +206,4 @@ func (q *EngineQueue) Shutdown(timeout time.Duration) error {
return fmt.Errorf("shutdown timeout exceeded") return fmt.Errorf("shutdown timeout exceeded")
} }
} }

View File

@ -1,6 +1,7 @@
package service package service
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@ -11,6 +12,12 @@ import (
"github.com/lixenwraith/auth" "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 // User represents a registered user account
type User struct { type User struct {
UserID string UserID string
@ -24,13 +31,13 @@ type User struct {
// CreateUser creates new user with registration limits enforcement // CreateUser creates new user with registration limits enforcement
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) { func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) {
if s.store == nil { if s.store == nil {
return nil, fmt.Errorf("storage disabled") return nil, ErrStorageDisabled
} }
// Check registration limits // Check registration limits
total, permCount, _, err := s.store.GetUserCounts() total, permCount, _, err := s.store.GetUserCounts()
if err != nil { 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 // Determine account type
@ -39,7 +46,7 @@ func (s *Service) CreateUser(username, email, password string, permanent bool) (
if permanent { if permanent {
if permCount >= PermanentSlots { 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" accountType = "permanent"
} else { } else {
@ -50,7 +57,7 @@ func (s *Service) CreateUser(username, email, password string, permanent bool) (
// Handle capacity - remove oldest temp user if at max // Handle capacity - remove oldest temp user if at max
if total >= MaxUsers { if total >= MaxUsers {
if err := s.removeOldestTempUser(); err != nil { 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)
} }
} }
@ -276,3 +283,4 @@ func (s *Service) CreateUserSession(userID string) (string, error) {
return sessionID, nil return sessionID, nil
} }

View File

@ -9,7 +9,7 @@ import (
const ( const (
// WaitTimeout is the maximum time a client can wait for notifications // 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 size for notification channels
WaitChannelBuffer = 1 WaitChannelBuffer = 1
@ -175,3 +175,4 @@ func (w *WaitRegistry) removeWaiter(gameID string, req *WaitRequest) {
// Stop timer if still running // Stop timer if still running
req.Timer.Stop() req.Timer.Stop()
} }

View File

@ -27,8 +27,8 @@ func DefaultUserLimits() UserLimits {
func (s *Store) GetUserCounts() (total, permanent, temp int, err error) { func (s *Store) GetUserCounts() (total, permanent, temp int, err error) {
query := `SELECT query := `SELECT
COUNT(*) as total, COUNT(*) as total,
SUM(CASE WHEN account_type = 'permanent' THEN 1 ELSE 0 END) as permanent, COALESCE(SUM(CASE WHEN account_type = 'permanent' THEN 1 ELSE 0 END), 0) as permanent,
SUM(CASE WHEN account_type = 'temp' THEN 1 ELSE 0 END) as temp COALESCE(SUM(CASE WHEN account_type = 'temp' THEN 1 ELSE 0 END), 0) as temp
FROM users` FROM users`
err = s.db.QueryRow(query).Scan(&total, &permanent, &temp) 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 // GetOldestTempUser returns the oldest temporary user for replacement
func (s *Store) GetOldestTempUser() (*UserRecord, error) { func (s *Store) GetOldestTempUser() (*UserRecord, error) {
var user UserRecord var user UserRecord
var email sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
FROM users FROM users
WHERE account_type = 'temp' WHERE account_type = 'temp'
@ -45,13 +46,14 @@ func (s *Store) GetOldestTempUser() (*UserRecord, error) {
LIMIT 1` LIMIT 1`
err := s.db.QueryRow(query).Scan( err := s.db.QueryRow(query).Scan(
&user.UserID, &user.Username, &user.Email, &user.UserID, &user.Username, &email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt, &user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt, &user.ExpiresAt, &user.LastLoginAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
user.Email = email.String
return &user, nil return &user, nil
} }
@ -165,14 +167,16 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) {
var users []UserRecord var users []UserRecord
for rows.Next() { for rows.Next() {
var user UserRecord var user UserRecord
var email sql.NullString
err := rows.Scan( err := rows.Scan(
&user.UserID, &user.Username, &user.Email, &user.UserID, &user.Username, &email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt, &user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt, &user.ExpiresAt, &user.LastLoginAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
user.Email = email.String
users = append(users, user) 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 // GetUserByUsername retrieves user by username with case-insensitive matching
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) { func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
var user UserRecord var user UserRecord
var email sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_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` FROM users WHERE username = ? COLLATE NOCASE`
err := s.db.QueryRow(query, username).Scan( 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.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt, &user.ExpiresAt, &user.LastLoginAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
user.Email = email.String
return &user, nil return &user, nil
} }
// GetUserByEmail retrieves user by email with case-insensitive matching // GetUserByEmail retrieves user by email with case-insensitive matching
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) { func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
var user UserRecord var user UserRecord
var emailNull sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_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` FROM users WHERE email = ? COLLATE NOCASE`
err := s.db.QueryRow(query, email).Scan( 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.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt, &user.ExpiresAt, &user.LastLoginAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
user.Email = emailNull.String
return &user, nil return &user, nil
} }
// GetUserByID retrieves user by unique user ID // GetUserByID retrieves user by unique user ID
func (s *Store) GetUserByID(userID string) (*UserRecord, error) { func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
var user UserRecord var user UserRecord
var email sql.NullString
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_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 = ?` FROM users WHERE user_id = ?`
err := s.db.QueryRow(query, userID).Scan( 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.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt, &user.ExpiresAt, &user.LastLoginAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
user.Email = email.String
return &user, nil return &user, nil
} }

View File

@ -155,6 +155,15 @@ function switchAuthTab(tab) {
document.getElementById('register-form').style.display = tab === 'register' ? 'block' : 'none'; 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() { async function handleLogin() {
const identifier = document.getElementById('login-identifier').value.trim(); const identifier = document.getElementById('login-identifier').value.trim();
const password = document.getElementById('login-password').value; const password = document.getElementById('login-password').value;
@ -175,8 +184,8 @@ async function handleLogin() {
}); });
if (!response.ok) { if (!response.ok) {
const err = await response.json(); const err = await parseErrorResponse(response);
flashErrorMessage(err.error || 'Login failed', 3000); flashErrorMessage(err.details || err.error || 'Login failed', 3000);
return; return;
} }
@ -197,22 +206,17 @@ async function handleLogin() {
async function handleRegister() { async function handleRegister() {
const username = document.getElementById('register-username').value.trim(); const username = document.getElementById('register-username').value.trim();
const email = document.getElementById('register-email').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) { if (!username || !password) {
flashErrorMessage('Username and password required'); flashErrorMessage('Username and password required');
return; return;
} }
if (password.length < 8) { if (password.length < 8) {
flashErrorMessage('Password min 8 chars'); flashErrorMessage('Password min 8 chars');
return; return;
} }
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
// 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) {
flashErrorMessage('Password needs a letter and number'); flashErrorMessage('Password needs a letter and number');
return; return;
} }
@ -231,7 +235,7 @@ async function handleRegister() {
}); });
if (!response.ok) { if (!response.ok) {
const err = await response.json(); const err = await parseErrorResponse(response);
flashErrorMessage(err.details || err.error || 'Registration failed', 3000); flashErrorMessage(err.details || err.error || 'Registration failed', 3000);
return; return;
} }