Compare commits

..

2 Commits

Author SHA256 Message Date
99b37b5456 v0.9.1 web ui fixes and improvements 2026-02-25 10:22:24 -05:00
f9630dea3b v0.9.1 web ui fixes and improvements 2026-02-25 10:19:52 -05:00
12 changed files with 61 additions and 119 deletions

View File

@ -30,8 +30,8 @@ func runClient() (restart bool) {
}()
s := &session.Session{
APIBaseURL: defaultAPIBase,
Client: api.New(defaultAPIBase),
APIBaseURL: "http://localhost:8080",
Client: api.New("http://localhost:8080"),
Verbose: false,
}
@ -133,4 +133,3 @@ func buildPrompt(s *session.Session) string {
return display.Prompt(b.String())
}

View File

@ -1,5 +0,0 @@
//go:build !js
package main
const defaultAPIBase = "http://localhost:8080"

View File

@ -1,11 +0,0 @@
//go:build js && wasm
package main
import "syscall/js"
// Derive base URL from the page's own origin at runtime
// When served via nginx at domain.com, origin = "https://domain.com"
// and the chess API proxy lives at /chess — so BaseURL = "https://comain.com/chess".
// Works correctly for any deployment domain without rebuilding
var defaultAPIBase = js.Global().Get("location").Get("origin").String() + "/chess"

View File

@ -75,9 +75,6 @@ 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)
@ -193,4 +190,3 @@ func main() {
log.Println("Servers exited")
}

2
go.mod
View File

@ -1,6 +1,6 @@
module chess
go 1.26.0
go 1.25.4
require (
github.com/go-playground/validator/v10 v10.30.1

View File

@ -1,7 +1,6 @@
package http
import (
"errors"
"fmt"
"regexp"
"strings"
@ -94,13 +93,6 @@ 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",
@ -108,6 +100,13 @@ 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,
@ -263,4 +262,3 @@ func (h *HTTPHandler) LogoutHandler(c *fiber.Ctx) error {
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
app := fiber.New(fiber.Config{
ErrorHandler: customErrorHandler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 35 * time.Second,
IdleTimeout: 60 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
})
// Global middleware (order matters)
@ -518,4 +518,3 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
return c.JSON(resp.Data)
}

View File

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

View File

@ -1,7 +1,6 @@
package service
import (
"errors"
"fmt"
"strings"
"time"
@ -12,12 +11,6 @@ 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
@ -31,13 +24,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, ErrStorageDisabled
return nil, fmt.Errorf("storage disabled")
}
// Check registration limits
total, permCount, _, err := s.store.GetUserCounts()
if err != nil {
return nil, fmt.Errorf("failed to get user count: %w", err)
return nil, fmt.Errorf("failed to check user limits: %w", err)
}
// Determine account type
@ -46,7 +39,7 @@ func (s *Service) CreateUser(username, email, password string, permanent bool) (
if permanent {
if permCount >= PermanentSlots {
return nil, fmt.Errorf("%w (%d/%d)", ErrPermanentSlotsFull, permCount, PermanentSlots)
return nil, fmt.Errorf("permanent user slots full (%d/%d)", permCount, PermanentSlots)
}
accountType = "permanent"
} else {
@ -57,7 +50,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("%w: %v", ErrAtCapacity, err)
return nil, fmt.Errorf("at capacity and cannot make room: %w", err)
}
}
@ -283,4 +276,3 @@ func (s *Service) CreateUserSession(userID string) (string, error) {
return sessionID, nil
}

View File

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

View File

@ -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,
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
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)
@ -38,7 +38,6 @@ 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'
@ -46,14 +45,13 @@ func (s *Store) GetOldestTempUser() (*UserRecord, error) {
LIMIT 1`
err := s.db.QueryRow(query).Scan(
&user.UserID, &user.Username, &email,
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
user.Email = email.String
return &user, nil
}
@ -167,16 +165,14 @@ 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, &email,
&user.UserID, &user.Username, &user.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)
}
@ -196,57 +192,51 @@ 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, &email,
&user.UserID, &user.Username, &user.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, &emailNull,
&user.UserID, &user.Username, &user.Email,
&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, &email,
&user.UserID, &user.Username, &user.Email,
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
&user.ExpiresAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
user.Email = email.String
return &user, nil
}

View File

@ -28,9 +28,6 @@ document.addEventListener('DOMContentLoaded', async () => {
const config = await getConfig();
gameState.apiUrl = config.apiUrl;
// Check for existing session on load
restoreAuthSession();
document.getElementById('new-game-btn').addEventListener('click', showNewGameModal);
document.getElementById('undo-btn').addEventListener('click', undoMoves);
document.getElementById('start-game-btn').addEventListener('click', startNewGame);
@ -58,6 +55,8 @@ document.getElementById('register-submit-btn').addEventListener('click', handleR
document.getElementById('auth-cancel-btn').addEventListener('click', hideAuthModal);
document.getElementById('auth-cancel-btn-2').addEventListener('click', hideAuthModal);
// Check for existing session on load
restoreAuthSession();
// Auth functions
function restoreAuthSession() {
@ -118,8 +117,6 @@ function handleAuthClick() {
function showAuthModal() {
document.getElementById('auth-modal-overlay').classList.add('show');
document.getElementById('login-identifier').focus();
// Remove first to prevent duplicate registrations
document.removeEventListener('keydown', handleAuthModalKeydown);
document.addEventListener('keydown', handleAuthModalKeydown);
}
@ -155,15 +152,6 @@ 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;
@ -173,9 +161,6 @@ async function handleLogin() {
return;
}
const submitBtn = document.getElementById('login-submit-btn');
submitBtn.disabled = true;
try {
const response = await fetch(`${gameState.apiUrl}/api/v1/auth/login`, {
method: 'POST',
@ -184,8 +169,8 @@ async function handleLogin() {
});
if (!response.ok) {
const err = await parseErrorResponse(response);
flashErrorMessage(err.details || err.error || 'Login failed', 3000);
const err = await response.json();
flashErrorMessage(err.error || 'Login failed');
return;
}
@ -198,8 +183,6 @@ async function handleLogin() {
hideAuthModal();
} catch (error) {
flashErrorMessage('Connection failed');
} finally {
submitBtn.disabled = false;
}
}
@ -212,17 +195,11 @@ async function handleRegister() {
flashErrorMessage('Username and password required');
return;
}
if (password.length < 8) {
flashErrorMessage('Password min 8 chars');
return;
}
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
flashErrorMessage('Password needs a letter and number');
return;
}
const submitBtn = document.getElementById('register-submit-btn');
submitBtn.disabled = true;
try {
const body = { username, password };
@ -235,8 +212,8 @@ async function handleRegister() {
});
if (!response.ok) {
const err = await parseErrorResponse(response);
flashErrorMessage(err.details || err.error || 'Registration failed', 3000);
const err = await response.json();
flashErrorMessage(err.details || err.error || 'Registration failed');
return;
}
@ -249,8 +226,6 @@ async function handleRegister() {
hideAuthModal();
} catch (error) {
flashErrorMessage('Connection failed');
} finally {
submitBtn.disabled = false;
}
}
@ -277,7 +252,17 @@ function authFetch(url, options = {}) {
}
async function getConfig() {
return { apiUrl: '/chess' };
try {
const response = await fetch('/config');
const contentType = response.headers.get('content-type') || '';
if (!response.ok || !contentType.includes('application/json')) {
throw new Error(`unexpected response: ${response.status} ${contentType}`);
}
return await response.json();
} catch (error) {
console.error('Failed to get config:', error);
return { apiUrl: 'http://localhost:8080' };
}
}
function startHealthCheck() {
@ -987,17 +972,18 @@ function handleApiError(action, error, response = null) {
};
}
function flashErrorMessage(message, duration = 1500) {
function flashErrorMessage(message) {
const overlay = document.getElementById('error-flash-overlay');
const messageEl = document.getElementById('error-flash-message');
// Set message text
messageEl.textContent = message;
// Show overlay
overlay.classList.add('show');
// Clear any pending timeout to avoid premature hide on rapid calls
if (overlay._flashTimeout) clearTimeout(overlay._flashTimeout);
overlay._flashTimeout = setTimeout(() => {
// Auto-hide after animation completes
setTimeout(() => {
overlay.classList.remove('show');
overlay._flashTimeout = null;
}, duration);
}, 1500);
}