v0.9.0 user and session management improvement, xterm.js addons
This commit is contained in:
27
Makefile
27
Makefile
@ -21,6 +21,14 @@ WASM_DIR := web/chess-client-wasm
|
||||
WASM_BINARY := $(WASM_DIR)/chess-client.wasm
|
||||
WASM_EXEC_JS := $(WASM_DIR)/wasm_exec.js
|
||||
WASM_EXEC_SRC := $(GOROOT)/lib/wasm/wasm_exec.js
|
||||
WASM_LIB_DIR := $(WASM_DIR)/lib
|
||||
|
||||
# xterm.js versions (5.5.0 compatible)
|
||||
XTERM_VERSION := 5.5.0
|
||||
XTERM_FIT_VERSION := 0.10.0
|
||||
XTERM_WEBGL_VERSION := 0.18.0
|
||||
XTERM_LINKS_VERSION := 0.11.0
|
||||
XTERM_UNICODE_VERSION := 0.8.0
|
||||
|
||||
# Default target
|
||||
.PHONY: all
|
||||
@ -61,15 +69,20 @@ wasm: $(WASM_DIR)
|
||||
@echo "Built WASM client: $(WASM_BINARY)"
|
||||
@echo "Size: $$(du -h $(WASM_BINARY) | cut -f1)"
|
||||
|
||||
# Download xterm.js dependencies
|
||||
# Download xterm.js and all addons
|
||||
.PHONY: wasm-deps
|
||||
wasm-deps: $(WASM_DIR)
|
||||
@echo "Downloading xterm.js 5.5.0..."
|
||||
@mkdir -p $(WASM_DIR)/lib
|
||||
@cd $(WASM_DIR)/lib && \
|
||||
curl -sO https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js && \
|
||||
curl -sO https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css
|
||||
@echo "xterm.js 5.5.0 downloaded to $(WASM_DIR)/lib/"
|
||||
@echo "Downloading xterm.js $(XTERM_VERSION) and addons..."
|
||||
@mkdir -p $(WASM_LIB_DIR)
|
||||
@cd $(WASM_LIB_DIR) && \
|
||||
curl -sO https://cdn.jsdelivr.net/npm/@xterm/xterm@$(XTERM_VERSION)/lib/xterm.min.js && \
|
||||
curl -sO https://cdn.jsdelivr.net/npm/@xterm/xterm@$(XTERM_VERSION)/css/xterm.min.css && \
|
||||
curl -sO https://cdn.jsdelivr.net/npm/@xterm/addon-fit@$(XTERM_FIT_VERSION)/lib/addon-fit.min.js && \
|
||||
curl -sO https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@$(XTERM_WEBGL_VERSION)/lib/addon-webgl.min.js && \
|
||||
curl -sO https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@$(XTERM_LINKS_VERSION)/lib/addon-web-links.min.js && \
|
||||
curl -sO https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@$(XTERM_UNICODE_VERSION)/lib/addon-unicode11.min.js
|
||||
@echo "Downloaded to $(WASM_LIB_DIR)/"
|
||||
@ls -la $(WASM_LIB_DIR)/
|
||||
|
||||
# Build WASM with dependencies
|
||||
.PHONY: wasm-full
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/cmd/chess-client-cli/exit_native.go
|
||||
//go:build !js && !wasm
|
||||
|
||||
package main
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/cmd/chess-client-cli/exit_wasm.go
|
||||
//go:build js && wasm
|
||||
|
||||
package main
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/cmd/chess-client-cli/main.go
|
||||
// Package main implements an interactive cli debugging client for the chess server API.
|
||||
package main
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/cmd/chess-server/cli/cli.go
|
||||
package cli
|
||||
|
||||
import (
|
||||
@ -168,9 +167,10 @@ func runUserAdd(args []string) error {
|
||||
path := fs.String("path", "", "Database file path (required)")
|
||||
username := fs.String("username", "", "Username (required)")
|
||||
email := fs.String("email", "", "Email address (optional)")
|
||||
password := fs.String("password", "", "Password (use this or -hash, not both)")
|
||||
hash := fs.String("hash", "", "Password hash (use this or -password, not both)")
|
||||
password := fs.String("password", "", "Password (optional, will prompt if not provided)")
|
||||
hash := fs.String("hash", "", "Pre-computed password hash (optional)")
|
||||
interactive := fs.Bool("interactive", false, "Interactive password prompt")
|
||||
temp := fs.Bool("temp", false, "Create as temporary user (24h TTL, default: permanent)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
@ -244,12 +244,23 @@ func runUserAdd(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine account type (CLI default = permanent)
|
||||
accountType := "permanent"
|
||||
var expiresAt *time.Time
|
||||
if *temp {
|
||||
accountType = "temp"
|
||||
expiry := time.Now().UTC().Add(24 * time.Hour)
|
||||
expiresAt = &expiry
|
||||
}
|
||||
|
||||
record := storage.UserRecord{
|
||||
UserID: userID,
|
||||
Username: strings.ToLower(*username),
|
||||
Email: strings.ToLower(*email),
|
||||
PasswordHash: passwordHash,
|
||||
AccountType: accountType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err := store.CreateUser(record); err != nil {
|
||||
@ -534,23 +545,29 @@ func runUserList(args []string) error {
|
||||
|
||||
// Print results in tabular format
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "User ID\tUsername\tEmail\tCreated\tLast Login")
|
||||
fmt.Fprintln(w, strings.Repeat("-", 100))
|
||||
fmt.Fprintln(w, "User ID\tUsername\tType\tEmail\tCreated\tExpires\tLast Login")
|
||||
fmt.Fprintln(w, strings.Repeat("-", 120))
|
||||
|
||||
for _, u := range users {
|
||||
lastLogin := "never"
|
||||
if u.LastLoginAt != nil {
|
||||
lastLogin = u.LastLoginAt.Format("2006-01-02 15:04:05")
|
||||
lastLogin = u.LastLoginAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
email := u.Email
|
||||
if email == "" {
|
||||
email = "(none)"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
expires := "never"
|
||||
if u.ExpiresAt != nil {
|
||||
expires = u.ExpiresAt.Format("2006-01-02 15:04")
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
u.UserID[:8]+"...",
|
||||
u.Username,
|
||||
u.AccountType,
|
||||
email,
|
||||
u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
u.CreatedAt.Format("2006-01-02 15:04"),
|
||||
expires,
|
||||
lastLogin,
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/cmd/chess-server/main.go
|
||||
// Package main implements the chess server application with RESTful API,
|
||||
// user authentication, and optional web UI serving capabilities.
|
||||
package main
|
||||
@ -103,6 +102,10 @@ func main() {
|
||||
// 2. Initialize the Service with optional storage and auth
|
||||
svc := service.New(store, jwtSecret)
|
||||
|
||||
// Start cleanup job for expired users/sessions
|
||||
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
|
||||
go svc.RunCleanupJob(cleanupCtx, service.CleanupJobInterval)
|
||||
|
||||
// 3. Initialize the Processor (Orchestrator), injecting the service
|
||||
proc, err := processor.New(svc)
|
||||
if err != nil {
|
||||
@ -178,6 +181,8 @@ func main() {
|
||||
log.Printf("Processor close error: %v", err)
|
||||
}
|
||||
|
||||
cleanupCancel() // Stop cleanup job
|
||||
|
||||
// Shutdown service first (includes wait registry cleanup)
|
||||
if err = svc.Shutdown(gracefulShutdownTimeout); err != nil {
|
||||
log.Printf("Service shutdown error: %v", err)
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/cmd/chess-server/pid.go
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
26
go.mod
26
go.mod
@ -3,32 +3,32 @@ module chess
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/gofiber/fiber/v2 v2.52.9
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/gofiber/fiber/v2 v2.52.11
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
golang.org/x/term v0.37.0
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
golang.org/x/term v0.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.5.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.3 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.68.0 // indirect
|
||||
golang.org/x/crypto v0.44.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
)
|
||||
|
||||
26
go.sum
26
go.sum
@ -4,10 +4,14 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
|
||||
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@ -16,14 +20,22 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
|
||||
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226 h1:c7wfyZGdy6RkM/b6mIazoYrAS+3qDL7d9M1CFm2e1VA=
|
||||
@ -36,6 +48,8 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@ -44,20 +58,32 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
|
||||
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
||||
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
|
||||
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
|
||||
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/api/client.go
|
||||
package api
|
||||
|
||||
import (
|
||||
@ -226,6 +225,10 @@ func (c *Client) Login(identifier, password string) (*AuthResponse, error) {
|
||||
return &resp, err
|
||||
}
|
||||
|
||||
func (c *Client) Logout() error {
|
||||
return c.doRequest("POST", "/api/v1/auth/logout", nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) GetCurrentUser() (*UserResponse, error) {
|
||||
var resp UserResponse
|
||||
err := c.doRequest("GET", "/api/v1/auth/me", nil, &resp)
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/client/api/types.go
|
||||
package api
|
||||
|
||||
import "time"
|
||||
@ -71,9 +70,11 @@ type BoardResponse struct {
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Token string `json:"token"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/client/command/auth.go
|
||||
package command
|
||||
|
||||
import (
|
||||
@ -119,10 +118,20 @@ func loginHandler(s *session.Session, args []string) error {
|
||||
}
|
||||
|
||||
func logoutHandler(s *session.Session, args []string) error {
|
||||
c := s.GetClient().(*api.Client)
|
||||
|
||||
// Call server to invalidate session if authenticated
|
||||
if s.GetAuthToken() != "" {
|
||||
if err := c.Logout(); err != nil {
|
||||
// Log but don't fail - clear local state anyway
|
||||
display.Println(display.Yellow, "Server logout failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Clear local state
|
||||
s.SetAuthToken("")
|
||||
s.SetCurrentUser("")
|
||||
s.SetUsername("")
|
||||
c := s.GetClient().(*api.Client)
|
||||
c.SetToken("")
|
||||
|
||||
display.Println(display.Green, "Logged out")
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/client/command/debug.go
|
||||
package command
|
||||
|
||||
import (
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/client/command/game.go
|
||||
package command
|
||||
|
||||
import (
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/client/command/pass_native.go
|
||||
//go:build !js && !wasm
|
||||
|
||||
package command
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/client/command/pass_wasm.go
|
||||
//go:build js && wasm
|
||||
|
||||
package command
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/client/command/registry.go
|
||||
package command
|
||||
|
||||
import (
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/client/display/board.go
|
||||
package display
|
||||
|
||||
import (
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/client/display/colors.go
|
||||
package display
|
||||
|
||||
import (
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/client/display/format.go
|
||||
package display
|
||||
|
||||
import (
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/client/session/session.go
|
||||
package session
|
||||
|
||||
import (
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/board/board.go
|
||||
package board
|
||||
|
||||
import (
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/core/api.go
|
||||
package core
|
||||
|
||||
// Request types
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/core/error.go
|
||||
package core
|
||||
|
||||
// Error codes
|
||||
@ -12,4 +11,6 @@ const (
|
||||
ErrInvalidRequest = "INVALID_REQUEST"
|
||||
ErrInvalidFEN = "INVALID_FEN"
|
||||
ErrInternalError = "INTERNAL_ERROR"
|
||||
ErrResourceLimit = "RESOURCE_LIMIT"
|
||||
ErrUnauthorized = "UNAUTHORIZED"
|
||||
)
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/core/player.go
|
||||
package core
|
||||
|
||||
import (
|
||||
@ -19,6 +18,7 @@ type Player struct {
|
||||
Type PlayerType `json:"type"`
|
||||
Level int `json:"level,omitempty"` // Only for computer
|
||||
SearchTime int `json:"searchTime,omitempty"` // Only for computer
|
||||
ClaimedBy string `json:"claimedBy,omitempty"` // UserID that claimed this slot
|
||||
}
|
||||
|
||||
// PlayerConfig for API requests and configuration
|
||||
@ -28,7 +28,7 @@ type PlayerConfig struct {
|
||||
SearchTime int `json:"searchTime,omitempty" validate:"omitempty,min=100,max=10000"` // Processor sets the min value
|
||||
}
|
||||
|
||||
// PlayersResponse for API responses - now contains full Player structs
|
||||
// PlayersResponse for API responses
|
||||
type PlayersResponse struct {
|
||||
White *Player `json:"white"`
|
||||
Black *Player `json:"black"`
|
||||
@ -50,10 +50,20 @@ func NewPlayer(config PlayerConfig, color Color) *Player {
|
||||
return player
|
||||
}
|
||||
|
||||
// IsClaimed returns true if this slot has been claimed by a user
|
||||
func (p *Player) IsClaimed() bool {
|
||||
return p.ClaimedBy != ""
|
||||
}
|
||||
|
||||
// CanBeClaimed returns true if this slot can be claimed
|
||||
func (p *Player) CanBeClaimed() bool {
|
||||
return p.Type == PlayerHuman && !p.IsClaimed()
|
||||
}
|
||||
|
||||
type Color byte
|
||||
|
||||
const (
|
||||
ColorWhite = iota + 1
|
||||
ColorWhite Color = iota + 1
|
||||
ColorBlack
|
||||
)
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/core/state.go
|
||||
package core
|
||||
|
||||
type State int
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/engine/engine.go
|
||||
package engine
|
||||
|
||||
import (
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/game/game.go
|
||||
package game
|
||||
|
||||
import (
|
||||
@ -149,4 +148,47 @@ func (g *Game) InitialFEN() string {
|
||||
return g.snapshots[0].FEN
|
||||
}
|
||||
return board.StartingFEN
|
||||
}
|
||||
|
||||
// ClaimSlot claims a player slot for a user
|
||||
// Caller must hold the lock
|
||||
func (g *Game) ClaimSlot(color core.Color, userID string) error {
|
||||
player := g.players[color]
|
||||
if player == nil {
|
||||
return fmt.Errorf("invalid color")
|
||||
}
|
||||
|
||||
if player.Type != core.PlayerHuman {
|
||||
return fmt.Errorf("cannot claim computer slot")
|
||||
}
|
||||
|
||||
if player.ClaimedBy != "" && player.ClaimedBy != userID {
|
||||
return fmt.Errorf("slot already claimed by another user")
|
||||
}
|
||||
|
||||
player.ClaimedBy = userID
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSlotOwner returns the userID that claimed the slot, empty if unclaimed
|
||||
// Caller must hold the lock
|
||||
func (g *Game) GetSlotOwner(color core.Color) string {
|
||||
player := g.players[color]
|
||||
if player == nil {
|
||||
return ""
|
||||
}
|
||||
return player.ClaimedBy
|
||||
}
|
||||
|
||||
// IsSlotClaimedBy checks if a specific user owns the slot
|
||||
func (g *Game) IsSlotClaimedBy(color core.Color, userID string) bool {
|
||||
return g.GetSlotOwner(color) == userID
|
||||
}
|
||||
|
||||
// HasComputerPlayer returns true if at least one player is computer
|
||||
func (g *Game) HasComputerPlayer() bool {
|
||||
white := g.players[core.ColorWhite]
|
||||
black := g.players[core.ColorBlack]
|
||||
return (white != nil && white.Type == core.PlayerComputer) ||
|
||||
(black != nil && black.Type == core.PlayerComputer)
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/http/auth.go
|
||||
package http
|
||||
|
||||
import (
|
||||
@ -9,6 +8,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
@ -90,8 +90,8 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
|
||||
req.Email = strings.ToLower(req.Email)
|
||||
}
|
||||
|
||||
// Create user
|
||||
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password)
|
||||
// Create user (temp by default via API)
|
||||
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password, false)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{
|
||||
@ -100,14 +100,30 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
// Create session for new user
|
||||
sessionID, err := h.svc.CreateUserSession(user.UserID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to create session",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := h.svc.GenerateUserToken(user.UserID)
|
||||
token, err := h.svc.GenerateUserToken(user.UserID, sessionID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to generate token",
|
||||
@ -120,7 +136,7 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
|
||||
UserID: user.UserID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
ExpiresAt: time.Now().Add(service.SessionTTL),
|
||||
})
|
||||
}
|
||||
|
||||
@ -173,18 +189,17 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
|
||||
// Normalize identifier for case-insensitive lookup
|
||||
req.Identifier = strings.ToLower(req.Identifier)
|
||||
|
||||
// Authenticate user
|
||||
user, err := h.svc.AuthenticateUser(req.Identifier, req.Password)
|
||||
// Authenticate user and create session (invalidates previous session)
|
||||
user, sessionID, err := h.svc.AuthenticateUser(req.Identifier, req.Password)
|
||||
if err != nil {
|
||||
// Always return same error to prevent user enumeration
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid credentials",
|
||||
Code: core.ErrInvalidRequest,
|
||||
})
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := h.svc.GenerateUserToken(user.UserID)
|
||||
// Generate JWT token with session ID
|
||||
token, err := h.svc.GenerateUserToken(user.UserID, sessionID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to generate token",
|
||||
@ -192,10 +207,6 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Update last login
|
||||
// TODO: for now, non-blocking if login time update fails, log/block in the future
|
||||
_ = h.svc.UpdateLastLogin(user.UserID)
|
||||
|
||||
return c.JSON(AuthResponse{
|
||||
Token: token,
|
||||
UserID: user.UserID,
|
||||
@ -229,4 +240,25 @@ func (h *HTTPHandler) GetCurrentUserHandler(c *fiber.Ctx) error {
|
||||
Email: user.Email,
|
||||
CreatedAt: user.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// LogoutHandler invalidates the current session
|
||||
func (h *HTTPHandler) LogoutHandler(c *fiber.Ctx) error {
|
||||
// Extract session ID from token claims
|
||||
sessionID, ok := c.Locals("sessionID").(string)
|
||||
if !ok || sessionID == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "no active session",
|
||||
Code: core.ErrInvalidRequest,
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.svc.InvalidateSession(sessionID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to logout",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"message": "logged out"})
|
||||
}
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/http/handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
@ -100,6 +99,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
|
||||
// Current user (requires auth)
|
||||
auth.Get("/me", AuthRequired(validateToken), h.GetCurrentUserHandler)
|
||||
|
||||
// Logout
|
||||
auth.Post("/logout", AuthRequired(validateToken), h.LogoutHandler)
|
||||
|
||||
// Game routes with standard rate limiting
|
||||
maxReq := rateLimitRate
|
||||
if devMode {
|
||||
@ -137,7 +139,7 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
|
||||
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
||||
api.Get("/games/:gameId", h.GetGame)
|
||||
api.Delete("/games/:gameId", h.DeleteGame)
|
||||
api.Post("/games/:gameId/moves", h.MakeMove)
|
||||
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
|
||||
api.Post("/games/:gameId/undo", h.UndoMove)
|
||||
api.Get("/games/:gameId/board", h.GetBoard)
|
||||
|
||||
@ -370,7 +372,6 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
@ -379,7 +380,6 @@ func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
@ -388,7 +388,6 @@ func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
@ -399,15 +398,21 @@ func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
var req core.MoveRequest
|
||||
req = *(validatedBody.(*core.MoveRequest))
|
||||
|
||||
// Create command and execute
|
||||
// Get authenticated user ID if present
|
||||
userID, _ := c.Locals("userID").(string)
|
||||
|
||||
cmd := processor.NewMakeMoveCommand(gameID, req)
|
||||
cmd.UserID = userID // Pass user context for authorization
|
||||
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response with correct status code
|
||||
if !resp.Success {
|
||||
statusCode := fiber.StatusBadRequest
|
||||
if resp.Error.Code == core.ErrGameNotFound {
|
||||
switch resp.Error.Code {
|
||||
case core.ErrGameNotFound:
|
||||
statusCode = fiber.StatusNotFound
|
||||
case core.ErrUnauthorized:
|
||||
statusCode = fiber.StatusForbidden
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/http/middleware.go
|
||||
package http
|
||||
|
||||
import (
|
||||
@ -23,7 +22,7 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
userID, _, err := validateToken(token)
|
||||
userID, claims, err := validateToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid or expired token",
|
||||
@ -32,6 +31,9 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
|
||||
}
|
||||
|
||||
c.Locals("userID", userID)
|
||||
if sessionID, ok := claims["session_id"].(string); ok {
|
||||
c.Locals("sessionID", sessionID)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
@ -44,11 +46,13 @@ func OptionalAuth(validateToken TokenValidator) fiber.Handler {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
userID, _, err := validateToken(token)
|
||||
userID, claims, err := validateToken(token)
|
||||
if err == nil {
|
||||
c.Locals("userID", userID)
|
||||
if sessionID, ok := claims["session_id"].(string); ok {
|
||||
c.Locals("sessionID", sessionID)
|
||||
}
|
||||
}
|
||||
// Continue regardless of token validity
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/http/handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/processor/command.go
|
||||
package processor
|
||||
|
||||
import (
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/processor/processor.go
|
||||
|
||||
package processor
|
||||
|
||||
import (
|
||||
@ -131,6 +129,15 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
|
||||
args.Black.SearchTime = minSearchTime
|
||||
}
|
||||
|
||||
// Check computer game limit
|
||||
hasComputer := args.White.Type == core.PlayerComputer || args.Black.Type == core.PlayerComputer
|
||||
if hasComputer && !p.svc.CanCreateComputerGame() {
|
||||
return p.errorResponse(
|
||||
fmt.Sprintf("computer game limit reached (%d/%d)", p.svc.GetComputerGameCount(), service.MaxComputerGames),
|
||||
core.ErrResourceLimit,
|
||||
)
|
||||
}
|
||||
|
||||
// Generate game ID
|
||||
gameID := p.svc.GenerateGameID()
|
||||
|
||||
@ -163,12 +170,17 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
|
||||
whitePlayer := core.NewPlayer(args.White, core.ColorWhite)
|
||||
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
|
||||
|
||||
// Override player IDs for authenticated human players
|
||||
if args.White.Type == core.PlayerHuman && cmd.UserID != "" {
|
||||
whitePlayer.ID = cmd.UserID
|
||||
}
|
||||
if args.Black.Type == core.PlayerHuman && cmd.UserID != "" {
|
||||
blackPlayer.ID = cmd.UserID
|
||||
// FIX: Only assign authenticated user to ONE human slot
|
||||
// If both are human, authenticated user gets white; black remains unclaimed
|
||||
if cmd.UserID != "" {
|
||||
if args.White.Type == core.PlayerHuman {
|
||||
whitePlayer.ID = cmd.UserID
|
||||
whitePlayer.ClaimedBy = cmd.UserID
|
||||
} else if args.Black.Type == core.PlayerHuman {
|
||||
// Only claim black if white is not human (i.e., H vs C scenario)
|
||||
blackPlayer.ID = cmd.UserID
|
||||
blackPlayer.ClaimedBy = cmd.UserID
|
||||
}
|
||||
}
|
||||
|
||||
// Create game in service with fully-formed players
|
||||
@ -252,7 +264,7 @@ func (p *Processor) handleGetGame(cmd Command) ProcessorResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// handleMakeMove processes human moves
|
||||
// handleMakeMove processes human moves with authorization
|
||||
func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
args, ok := cmd.Args.(core.MoveRequest)
|
||||
if !ok {
|
||||
@ -278,21 +290,22 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
return p.errorResponse("game is in invalid state", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Handle empty move string - trigger computer move
|
||||
currentColor := g.NextTurnColor()
|
||||
currentPlayer := g.NextPlayer()
|
||||
|
||||
// Handle computer move trigger
|
||||
if strings.TrimSpace(args.Move) == "cccc" {
|
||||
if g.NextPlayer().Type != core.PlayerComputer {
|
||||
if currentPlayer.Type != core.PlayerComputer {
|
||||
return p.errorResponse("not computer player's turn", core.ErrNotHumanTurn)
|
||||
}
|
||||
|
||||
// Set state to pending and trigger computer move
|
||||
p.svc.UpdateGameState(cmd.GameID, core.StatePending)
|
||||
p.triggerComputerMove(cmd.GameID, g)
|
||||
|
||||
// Re-fetch for updated state
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
response.LastMove = &core.MoveInfo{
|
||||
PlayerColor: g.NextTurnColor().String(),
|
||||
PlayerColor: currentColor.String(),
|
||||
}
|
||||
|
||||
return ProcessorResponse{
|
||||
@ -302,11 +315,32 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle human move
|
||||
if g.NextPlayer().Type != core.PlayerHuman {
|
||||
// Human move - validate authorization
|
||||
if currentPlayer.Type != core.PlayerHuman {
|
||||
return p.errorResponse("not human player's turn", core.ErrNotHumanTurn)
|
||||
}
|
||||
|
||||
// Authorization: first-move-claims-slot model
|
||||
slotOwner := g.GetSlotOwner(currentColor)
|
||||
|
||||
if slotOwner == "" {
|
||||
// Slot unclaimed - claim it with this move
|
||||
if cmd.UserID != "" {
|
||||
if err := p.svc.ClaimGameSlot(cmd.GameID, currentColor, cmd.UserID); err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("failed to claim slot: %v", err), core.ErrInternalError)
|
||||
}
|
||||
}
|
||||
// Anonymous users can also claim by making a move (slot remains "unclaimed" but move proceeds)
|
||||
} else if cmd.UserID != "" && slotOwner != cmd.UserID {
|
||||
// Slot claimed by different user
|
||||
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
|
||||
}
|
||||
// If slotOwner == cmd.UserID, authorized to proceed
|
||||
// If slotOwner != "" && cmd.UserID == "", anonymous trying to move claimed slot - block
|
||||
if slotOwner != "" && cmd.UserID == "" {
|
||||
return p.errorResponse("slot claimed - authentication required", core.ErrUnauthorized)
|
||||
}
|
||||
|
||||
// Normalize and validate move format
|
||||
move := strings.ToLower(strings.TrimSpace(args.Move))
|
||||
if !p.isMoveSafe(move) {
|
||||
@ -314,7 +348,6 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
}
|
||||
|
||||
currentFEN := g.CurrentFEN()
|
||||
currentColor := g.NextTurnColor()
|
||||
|
||||
// Validate move with engine
|
||||
p.mu.Lock()
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/processor/queue.go
|
||||
package processor
|
||||
|
||||
import (
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/webserver/web/app.js
|
||||
// Game state management
|
||||
let gameState = {
|
||||
gameId: null,
|
||||
@ -681,6 +680,14 @@ function handleApiError(action, error, response = null) {
|
||||
statusMessage = 'Invalid Request';
|
||||
}
|
||||
break;
|
||||
case 403:
|
||||
serverStatus = 'healthy';
|
||||
if (action === 'move' || action === 'trigger computer move') {
|
||||
statusMessage = 'Slot Claimed';
|
||||
} else {
|
||||
statusMessage = 'Not Authorized';
|
||||
}
|
||||
break;
|
||||
case 404:
|
||||
serverStatus = 'healthy'; // Server is fine, game doesn't exist
|
||||
statusMessage = 'Game Not Found';
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
<!-- FILE: lixenwraith/chess/internal/server/webserver/web/index.html -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
/* FILE: lixenwraith/chess/internal/server/webserver/web/style.css */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
// FILE: lixenwraith/chess/internal/server/webserver/server.go
|
||||
package webserver
|
||||
|
||||
import (
|
||||
|
||||
@ -11,8 +11,9 @@ This directory contains comprehensive test suites for the Chess API server, cove
|
||||
- Compiled `chessd` binary in accessible path
|
||||
|
||||
## Running the test server
|
||||
From repo root
|
||||
```bash
|
||||
./run-test-server.sh
|
||||
test/run-test-server.sh
|
||||
```
|
||||
|
||||
Pass binary path as first argument of the script if it's not placed in current directory `./chessd`.
|
||||
@ -143,4 +144,4 @@ test/test-longpoll.sh
|
||||
2. **Multiple Waiters**: 3 clients wait, all receive notification
|
||||
3. **Timeout**: Verify 25-second timeout with valid response
|
||||
4. **Skip Wait**: Immediate return when moveCount outdated
|
||||
5. **Disconnection**: Proper cleanup on client disconnect
|
||||
5. **Disconnection**: Proper cleanup on client disconnect
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# FILE: lixenwraith/chess/test/run-test-server.sh
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# FILE: lixenwraith/chess/test/test-api.sh
|
||||
|
||||
# Chess API Robustness Test Suite
|
||||
# Tests the refactored chess API with security hardening
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# FILE: lixenwraith/chess/test/test-db.sh
|
||||
|
||||
# Database & Authentication API Integration Test Suite
|
||||
# Tests user operations, authentication, and persistence via HTTP API
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
#!/bin/bash
|
||||
# FILE: lixenwraith/chess/test/test-longpoll.sh
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@ -3,14 +3,18 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Chess Client Terminal</title>
|
||||
<link rel="icon" href="data:,"> <!-- Prevents favicon request -->
|
||||
<link rel="stylesheet" href="lib/xterm.css">
|
||||
<link rel="icon" href="data:,"> <!-- Prevents favicon request -->
|
||||
<link rel="stylesheet" href="lib/xterm.min.css">
|
||||
<link rel="stylesheet" href="terminal.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="terminal"></div>
|
||||
<script src="lib/xterm.min.js"></script>
|
||||
<script src="lib/addon-fit.min.js"></script>
|
||||
<script src="lib/addon-webgl.min.js"></script>
|
||||
<script src="lib/addon-web-links.min.js"></script>
|
||||
<script src="lib/addon-unicode11.min.js"></script>
|
||||
<script src="wasm_exec.js"></script>
|
||||
<script src="terminal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>>
|
||||
8
web/chess-client-wasm/lib/addon-fit.min.js
vendored
Normal file
8
web/chess-client-wasm/lib/addon-fit.min.js
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
8
web/chess-client-wasm/lib/addon-unicode11.min.js
vendored
Normal file
8
web/chess-client-wasm/lib/addon-unicode11.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
web/chess-client-wasm/lib/addon-web-links.min.js
vendored
Normal file
8
web/chess-client-wasm/lib/addon-web-links.min.js
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
|
||||
//# sourceMappingURL=addon-web-links.js.map
|
||||
8
web/chess-client-wasm/lib/addon-webgl.min.js
vendored
Normal file
8
web/chess-client-wasm/lib/addon-webgl.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -1,218 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-scroll-area {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
8
web/chess-client-wasm/lib/xterm.min.css
vendored
Normal file
8
web/chess-client-wasm/lib/xterm.min.css
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Minified by jsDelivr using clean-css v5.3.3.
|
||||
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:0}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm .xterm-cursor-pointer,.xterm.xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:transparent}.xterm .xterm-accessibility-tree{user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative}
|
||||
/*# sourceMappingURL=/sm/97377c0c258e109358121823f5790146c714989366481f90e554c42277efb500.map */
|
||||
@ -3,15 +3,24 @@
|
||||
--host-surface: #1a1b26;
|
||||
}
|
||||
|
||||
body {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--host-bg);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
background: var(--host-bg);
|
||||
}
|
||||
|
||||
#terminal {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@ -1,20 +1,41 @@
|
||||
// FILE: terminal.js
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
fontSize: 14,
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: '#1a1b26',
|
||||
foreground: '#a9b1d6',
|
||||
cursor: '#a9b1d6',
|
||||
selection: 'rgba(169, 177, 214, 0.3)'
|
||||
},
|
||||
cols: 120,
|
||||
rows: 40
|
||||
}
|
||||
});
|
||||
|
||||
// Load addons
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
const webglAddon = new WebglAddon.WebglAddon();
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
const unicode11Addon = new Unicode11Addon.Unicode11Addon();
|
||||
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(webLinksAddon);
|
||||
term.loadAddon(unicode11Addon);
|
||||
term.unicode.activeVersion = '11';
|
||||
|
||||
term.open(document.getElementById('terminal'));
|
||||
|
||||
// WebGL addon must load after open()
|
||||
try {
|
||||
term.loadAddon(webglAddon);
|
||||
webglAddon.onContextLoss(() => {
|
||||
webglAddon.dispose();
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('WebGL addon failed, using canvas renderer:', e);
|
||||
}
|
||||
|
||||
fitAddon.fit();
|
||||
term.focus();
|
||||
|
||||
let inputBuffer = '';
|
||||
@ -51,17 +72,15 @@ term.onData(data => {
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
// FIXED: Override GLOBAL fs, not go.fs
|
||||
if (!globalThis.fs) {
|
||||
globalThis.fs = {};
|
||||
}
|
||||
|
||||
// Store original methods if they exist
|
||||
const originalWrite = globalThis.fs.write;
|
||||
const originalRead = globalThis.fs.read;
|
||||
|
||||
globalThis.fs.write = function(fd, buf, offset, length, position, callback) {
|
||||
if (fd === 1 || fd === 2) { // stdout/stderr
|
||||
if (fd === 1 || fd === 2) {
|
||||
const text = decoder.decode(buf.slice(offset, offset + length));
|
||||
term.write(text);
|
||||
callback(null, length);
|
||||
@ -73,7 +92,7 @@ globalThis.fs.write = function(fd, buf, offset, length, position, callback) {
|
||||
};
|
||||
|
||||
globalThis.fs.read = function(fd, buf, offset, length, position, callback) {
|
||||
if (fd === 0) { // stdin
|
||||
if (fd === 0) {
|
||||
const promise = new Promise(resolve => {
|
||||
inputResolver = resolve;
|
||||
});
|
||||
@ -91,7 +110,6 @@ globalThis.fs.read = function(fd, buf, offset, length, position, callback) {
|
||||
}
|
||||
};
|
||||
|
||||
// Create Go runtime AFTER fs override
|
||||
const go = new Go();
|
||||
|
||||
WebAssembly.instantiateStreaming(fetch('chess-client.wasm'), go.importObject)
|
||||
@ -103,8 +121,10 @@ WebAssembly.instantiateStreaming(fetch('chess-client.wasm'), go.importObject)
|
||||
console.error('WASM load error:', err);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const cols = Math.floor(window.innerWidth / 8);
|
||||
const rows = Math.floor(window.innerHeight / 17);
|
||||
term.resize(cols, rows);
|
||||
});
|
||||
// Resize handling with debounce for performance
|
||||
let resizeTimeout;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => fitAddon.fit(), 16);
|
||||
});
|
||||
resizeObserver.observe(document.getElementById('terminal'));
|
||||
Reference in New Issue
Block a user