v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
5d2be4abb2
commit
d0326b059e
66 files changed
+4016
-2433
No files matched your search
@@ -116,4 +116,4 @@ func (b *Board) GetPieceAt(square string) byte {
|
||||
file := square[0] - 'a'
|
||||
rank := '8' - square[1]
|
||||
return b.squares[rank][file]
|
||||
}
|
||||
}
|
||||
@@ -69,10 +69,12 @@ type HistoryMove struct {
|
||||
}
|
||||
|
||||
// GameSummary is intentionally sufficient for a client-side game picker; the
|
||||
// full move list remains on the per-game history endpoint.
|
||||
// full move list remains on the per-game history endpoint. FinalFEN is the
|
||||
// position after the last stored move (the initial FEN when there is none).
|
||||
type GameSummary struct {
|
||||
GameID string `json:"gameId"`
|
||||
InitialFEN string `json:"initialFen"`
|
||||
FinalFEN string `json:"finalFen"`
|
||||
Result string `json:"result,omitempty"`
|
||||
StartTimeUTC time.Time `json:"startTimeUtc"`
|
||||
EndTimeUTC *time.Time `json:"endTimeUtc,omitempty"`
|
||||
|
||||
@@ -19,6 +19,7 @@ type Player struct {
|
||||
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
|
||||
Name string `json:"name,omitempty"` // Claimant's username when the claim was stored (history only)
|
||||
}
|
||||
|
||||
// PlayerConfig for API requests and configuration
|
||||
@@ -82,4 +83,4 @@ func OppositeColor(c Color) Color {
|
||||
return ColorBlack
|
||||
}
|
||||
return ColorWhite
|
||||
}
|
||||
}
|
||||
@@ -284,4 +284,3 @@ func (u *UCI) Close() error {
|
||||
u.killLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,11 +26,12 @@ type MoveResult struct {
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
snapshots []Snapshot
|
||||
players map[core.Color]*core.Player
|
||||
state core.State
|
||||
lastResult *MoveResult
|
||||
endTimeUTC *time.Time
|
||||
snapshots []Snapshot
|
||||
players map[core.Color]*core.Player
|
||||
state core.State
|
||||
lastResult *MoveResult
|
||||
endTimeUTC *time.Time
|
||||
lastActivity time.Time // last create, move, undo, reconfiguration, or state change
|
||||
}
|
||||
|
||||
// View is an immutable copy of the state needed by processors and transports.
|
||||
@@ -133,10 +134,6 @@ func (g *Game) SetLastResult(result *MoveResult) {
|
||||
g.lastResult = ©
|
||||
}
|
||||
|
||||
func (g *Game) LastResult() *MoveResult {
|
||||
return g.lastResult
|
||||
}
|
||||
|
||||
// CurrentSnapshot returns the latest game snapshot
|
||||
func (g *Game) CurrentSnapshot() Snapshot {
|
||||
return g.snapshots[len(g.snapshots)-1]
|
||||
@@ -217,11 +214,8 @@ func (g *Game) State() core.State {
|
||||
return g.state
|
||||
}
|
||||
|
||||
func (g *Game) SetState(s core.State) {
|
||||
g.SetStateAt(s, time.Now().UTC())
|
||||
}
|
||||
|
||||
func (g *Game) SetStateAt(s core.State, at time.Time) {
|
||||
g.Touch(at)
|
||||
if s.IsTerminal() {
|
||||
if !g.state.IsTerminal() || g.endTimeUTC == nil {
|
||||
ended := at.UTC()
|
||||
@@ -233,6 +227,28 @@ func (g *Game) SetStateAt(s core.State, at time.Time) {
|
||||
g.state = s
|
||||
}
|
||||
|
||||
// Touch records activity at the given time; earlier times are ignored.
|
||||
func (g *Game) Touch(at time.Time) {
|
||||
if at.After(g.lastActivity) {
|
||||
g.lastActivity = at
|
||||
}
|
||||
}
|
||||
|
||||
// LastActivity returns the latest time passed to Touch.
|
||||
func (g *Game) LastActivity() time.Time {
|
||||
return g.lastActivity
|
||||
}
|
||||
|
||||
// IsClaimed reports whether a registered user claimed either slot.
|
||||
func (g *Game) IsClaimed() bool {
|
||||
for _, player := range g.players {
|
||||
if player != nil && player.ClaimedBy != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *Game) EndTimeUTC() *time.Time {
|
||||
if g.endTimeUTC == nil {
|
||||
return nil
|
||||
@@ -278,11 +294,6 @@ func (g *Game) GetSlotOwner(color core.Color) string {
|
||||
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]
|
||||
|
||||
@@ -91,22 +91,22 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
|
||||
req.Email = strings.ToLower(req.Email)
|
||||
}
|
||||
|
||||
// Create the user and initial session atomically (temp by default via API).
|
||||
user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password, false)
|
||||
// Create the user and initial session atomically.
|
||||
user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrPermanentSlotsFull) {
|
||||
if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrAuthBusy) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "registration temporarily unavailable",
|
||||
Code: core.ErrResourceLimit,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
if errors.Is(err, service.ErrUserExists) {
|
||||
return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{
|
||||
Error: "user already exists",
|
||||
Code: core.ErrInvalidRequest,
|
||||
@@ -194,6 +194,11 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
if errors.Is(err, service.ErrAuthBusy) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication temporarily unavailable", Code: core.ErrResourceLimit,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid credentials",
|
||||
Code: core.ErrInvalidRequest,
|
||||
@@ -214,7 +219,7 @@ func (h *HTTPHandler) LoginHandler(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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
@@ -30,21 +29,58 @@ func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandle
|
||||
return &HTTPHandler{proc: proc, svc: svc}
|
||||
}
|
||||
|
||||
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRequests bool) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(proc, svc)
|
||||
// Options configures the API application.
|
||||
type Options struct {
|
||||
DevMode bool
|
||||
LogRequests bool
|
||||
// TrustedProxies lists reverse-proxy IPs or CIDRs whose ProxyHeader value
|
||||
// is the client address. Empty means the TCP peer is the client.
|
||||
TrustedProxies []string
|
||||
// ProxyHeader names a single-address header the proxy overwrites, such as
|
||||
// X-Real-IP (nginx: proxy_set_header X-Real-IP $remote_addr). Avoid
|
||||
// X-Forwarded-For: its first entry is supplied by the client.
|
||||
ProxyHeader string
|
||||
}
|
||||
|
||||
// Initialize Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
// appConfig returns the Fiber configuration. Client-IP resolution only honors
|
||||
// ProxyHeader on connections from a trusted proxy, so rate-limit keys cannot be
|
||||
// chosen by a client that reaches the API directly.
|
||||
func appConfig(opts Options) fiber.Config {
|
||||
config := fiber.Config{
|
||||
ErrorHandler: customErrorHandler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 35 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
})
|
||||
}
|
||||
if len(opts.TrustedProxies) > 0 && opts.ProxyHeader != "" {
|
||||
config.EnableTrustedProxyCheck = true
|
||||
config.TrustedProxies = opts.TrustedProxies
|
||||
config.ProxyHeader = opts.ProxyHeader
|
||||
config.EnableIPValidation = true
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// clientIP is the rate-limit key: the proxy-reported address from a trusted
|
||||
// proxy, otherwise the TCP peer.
|
||||
func clientIP(c *fiber.Ctx) string {
|
||||
if ip := c.IP(); ip != "" {
|
||||
return ip
|
||||
}
|
||||
return c.Context().RemoteIP().String()
|
||||
}
|
||||
|
||||
func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(proc, svc)
|
||||
devMode := opts.DevMode
|
||||
|
||||
// Initialize Fiber app
|
||||
app := fiber.New(appConfig(opts))
|
||||
|
||||
// Global middleware (order matters)
|
||||
app.Use(recover.New())
|
||||
if logRequests {
|
||||
if opts.LogRequests {
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} HTTP ${status} ${method} ${path} ${latency}\n",
|
||||
TimeFormat: time.RFC3339,
|
||||
@@ -68,11 +104,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
|
||||
|
||||
// Register: 5 req/min per IP
|
||||
auth.Post("/register", limiter.New(limiter.Config{
|
||||
Max: 5,
|
||||
Expiration: 1 * time.Minute,
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
return c.IP()
|
||||
},
|
||||
Max: 5,
|
||||
Expiration: 1 * time.Minute,
|
||||
KeyGenerator: clientIP,
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
@@ -84,11 +118,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
|
||||
|
||||
// Login: 10 req/min per IP
|
||||
auth.Post("/login", limiter.New(limiter.Config{
|
||||
Max: 10,
|
||||
Expiration: 1 * time.Minute,
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
return c.IP()
|
||||
},
|
||||
Max: 10,
|
||||
Expiration: 1 * time.Minute,
|
||||
KeyGenerator: clientIP,
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
@@ -113,17 +145,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
|
||||
maxReq = rateLimitRate * 2
|
||||
}
|
||||
api.Use(limiter.New(limiter.Config{
|
||||
Max: maxReq,
|
||||
Expiration: 1 * time.Second,
|
||||
KeyGenerator: func(c *fiber.Ctx) string {
|
||||
if xff := c.Get("X-Forwarded-For"); xff != "" {
|
||||
if idx := strings.Index(xff, ","); idx != -1 {
|
||||
return strings.TrimSpace(xff[:idx])
|
||||
}
|
||||
return xff
|
||||
}
|
||||
return c.IP()
|
||||
},
|
||||
Max: maxReq,
|
||||
Expiration: 1 * time.Second,
|
||||
KeyGenerator: clientIP,
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestClientIPTrustsProxyHeaderOnlyFromTrustedPeers(t *testing.T) {
|
||||
// app.Test connections originate from 0.0.0.0.
|
||||
for name, test := range map[string]struct {
|
||||
opts Options
|
||||
want string
|
||||
}{
|
||||
"no proxy configured": {Options{ProxyHeader: "X-Real-IP"}, "0.0.0.0"},
|
||||
"untrusted peer": {Options{TrustedProxies: []string{"10.0.0.1"}, ProxyHeader: "X-Real-IP"}, "0.0.0.0"},
|
||||
"trusted peer": {Options{TrustedProxies: []string{"0.0.0.0/32"}, ProxyHeader: "X-Real-IP"}, "203.0.113.9"},
|
||||
"trusted, header only": {Options{TrustedProxies: []string{"0.0.0.0"}, ProxyHeader: "X-Real-IP"}, "203.0.113.9"},
|
||||
} {
|
||||
app := fiber.New(appConfig(test.opts))
|
||||
app.Get("/ip", func(c *fiber.Ctx) error { return c.SendString(clientIP(c)) })
|
||||
request := httptest.NewRequest("GET", "/ip", nil)
|
||||
request.Header.Set("X-Real-IP", "203.0.113.9")
|
||||
request.Header.Set("X-Forwarded-For", "198.51.100.1") // never consulted
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if string(body) != test.want {
|
||||
t.Errorf("%s: client IP = %q, want %q", name, body, test.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A trusted proxy that omits the header must not collapse clients into one key.
|
||||
app := fiber.New(appConfig(Options{TrustedProxies: []string{"0.0.0.0"}, ProxyHeader: "X-Real-IP"}))
|
||||
app.Get("/ip", func(c *fiber.Ctx) error { return c.SendString(clientIP(c)) })
|
||||
response, err := app.Test(httptest.NewRequest("GET", "/ip", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body, _ := io.ReadAll(response.Body); string(body) != "0.0.0.0" {
|
||||
t.Errorf("missing proxy header: client IP = %q, want peer address", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidUUIDRequiresCanonicalForm(t *testing.T) {
|
||||
for value, want := range map[string]bool{
|
||||
"5579b47e-4d3b-4eb3-abbd-846fe94cb955": true,
|
||||
"{5579b47e-4d3b-4eb3-abbd-846fe94cb955}": false,
|
||||
"urn:uuid:5579b47e-4d3b-4eb3-abbd-846fe94cb955": false,
|
||||
"5579b47e4d3b4eb3abbd846fe94cb955": false,
|
||||
"not-a-uuid": false,
|
||||
} {
|
||||
if got := isValidUUID(value); got != want {
|
||||
t.Errorf("isValidUUID(%q) = %v, want %v", value, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@ package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
// TokenValidator validates JWT tokens
|
||||
@@ -16,69 +16,59 @@ type TokenValidator func(token string) (userID string, claims map[string]any, er
|
||||
// AuthRequired enforces JWT authentication for protected endpoints
|
||||
func AuthRequired(validateToken TokenValidator) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
token := extractBearerToken(c.Get("Authorization"))
|
||||
if token == "" {
|
||||
header := c.Get(fiber.HeaderAuthorization)
|
||||
if header == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "missing authorization token",
|
||||
Code: core.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
|
||||
userID, claims, err := validateToken(token)
|
||||
token, err := auth.ParseBearerToken(header)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid or expired token",
|
||||
Code: core.ErrUnauthorized,
|
||||
})
|
||||
return invalidToken(c)
|
||||
}
|
||||
|
||||
c.Locals("userID", userID)
|
||||
if sessionID, ok := claims["session_id"].(string); ok {
|
||||
c.Locals("sessionID", sessionID)
|
||||
}
|
||||
return c.Next()
|
||||
return authenticate(c, validateToken, token)
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth permits an absent token but rejects an invalid token instead of
|
||||
// silently downgrading an intended authenticated request to anonymous access.
|
||||
// OptionalAuth permits an absent Authorization header but rejects a malformed
|
||||
// or invalid one instead of silently downgrading an intended authenticated
|
||||
// request to anonymous access.
|
||||
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
token := extractBearerToken(c.Get("Authorization"))
|
||||
if token == "" {
|
||||
header := c.Get(fiber.HeaderAuthorization)
|
||||
if header == "" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
userID, claims, err := validateToken(token)
|
||||
token, err := auth.ParseBearerToken(header)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid or expired token", Code: core.ErrUnauthorized,
|
||||
return invalidToken(c)
|
||||
}
|
||||
return authenticate(c, validateToken, token)
|
||||
}
|
||||
}
|
||||
|
||||
func authenticate(c *fiber.Ctx, validateToken TokenValidator, token string) error {
|
||||
userID, claims, err := validateToken(token)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
|
||||
c.Locals("userID", userID)
|
||||
if sessionID, ok := claims["session_id"].(string); ok {
|
||||
c.Locals("sessionID", sessionID)
|
||||
}
|
||||
return c.Next()
|
||||
return invalidToken(c)
|
||||
}
|
||||
|
||||
c.Locals("userID", userID)
|
||||
if sessionID, ok := claims["session_id"].(string); ok {
|
||||
c.Locals("sessionID", sessionID)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// extractBearerToken extracts JWT token from Authorization header
|
||||
func extractBearerToken(header string) string {
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(header, prefix)
|
||||
func invalidToken(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid or expired token", Code: core.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
@@ -58,3 +58,36 @@ func TestAuthMiddlewareReportsStorageUnavailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareParsesBearerHeaderStrictly(t *testing.T) {
|
||||
validate := func(token string) (string, map[string]any, error) {
|
||||
if token != "good.token.value" {
|
||||
return "", nil, errors.New("invalid token")
|
||||
}
|
||||
return "user", map[string]any{"session_id": "session"}, nil
|
||||
}
|
||||
for _, middleware := range []func(TokenValidator) fiber.Handler{AuthRequired, OptionalAuth} {
|
||||
app := fiber.New()
|
||||
app.Get("/route", middleware(validate), func(c *fiber.Ctx) error {
|
||||
return c.SendString(c.Locals("userID").(string))
|
||||
})
|
||||
for header, want := range map[string]int{
|
||||
"Bearer good.token.value": fiber.StatusOK,
|
||||
"bearer good.token.value": fiber.StatusOK, // RFC 6750 scheme is case-insensitive
|
||||
"Basic dXNlcjpwYXNz": fiber.StatusUnauthorized,
|
||||
"Bearer good token": fiber.StatusUnauthorized,
|
||||
"Bearer ": fiber.StatusUnauthorized,
|
||||
"good.token.value": fiber.StatusUnauthorized,
|
||||
} {
|
||||
request := httptest.NewRequest("GET", "/route", nil)
|
||||
request.Header.Set("Authorization", header)
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != want {
|
||||
t.Errorf("header %q: status = %d, want %d", header, response.StatusCode, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,13 @@ func validationMiddleware(c *fiber.Ctx) error {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// isValidUUID accepts only the canonical 36-character form the server issues.
|
||||
// uuid.Parse also accepts braced, URN, and unhyphenated forms, which never
|
||||
// match an in-memory game and are rejected by the database's uuid input.
|
||||
func isValidUUID(s string) bool {
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
_, err := uuid.Parse(s)
|
||||
return err == nil
|
||||
}
|
||||
}
|
||||
@@ -83,4 +83,4 @@ func NewGetBoardCommand(gameID string) Command {
|
||||
Type: CmdGetBoard,
|
||||
GameID: gameID,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -531,25 +531,6 @@ func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
|
||||
})
|
||||
}
|
||||
|
||||
// determineGameEndState centralized function to determine game end state based on engine evaluation
|
||||
func (p *Processor) determineGameEndState(lastMoveBy core.Color, searchResult *engine.SearchResult) core.State {
|
||||
// No legal moves detected
|
||||
if searchResult.BestMove == "" || searchResult.BestMove == "(none)" {
|
||||
if searchResult.IsMate {
|
||||
// It's a checkmate - the side that just moved wins
|
||||
if lastMoveBy == core.ColorWhite {
|
||||
return core.StateWhiteWins
|
||||
}
|
||||
return core.StateBlackWins
|
||||
}
|
||||
// Stalemate - no legal moves but not in check
|
||||
return core.StateStalemate
|
||||
}
|
||||
|
||||
// Game continues
|
||||
return core.StateOngoing
|
||||
}
|
||||
|
||||
// classifyCurrentLocked classifies whatever position is loaded in the
|
||||
// validation engine. Caller holds p.mu, immediately after a SetPosition.
|
||||
func (p *Processor) classifyCurrentLocked() (fen string, state core.State, err error) {
|
||||
@@ -585,26 +566,6 @@ func (p *Processor) classifyLocked(fen string) (string, core.State, error) {
|
||||
return p.classifyCurrentLocked()
|
||||
}
|
||||
|
||||
// checkGameEnd: retry once (second attempt runs on a respawned process), then
|
||||
// fail SAFE to StateStuck. Leaving a possibly-terminal position Ongoing is the
|
||||
// original bug class; Stuck is now recoverable via undo (see handleUndoMove).
|
||||
func (p *Processor) checkGameEnd(gameID, fen string) {
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
p.mu.Lock()
|
||||
_, state, err := p.classifyLocked(fen)
|
||||
p.mu.Unlock()
|
||||
if err == nil {
|
||||
if state != core.StateOngoing {
|
||||
p.svc.UpdateGameState(gameID, state)
|
||||
}
|
||||
return
|
||||
}
|
||||
slog.Warn("game end-state check failed",
|
||||
"game_id", gameID, "attempt", attempt+1, "error", err)
|
||||
}
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
}
|
||||
|
||||
// buildGameResponse constructs standard game response
|
||||
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
|
||||
resp := core.GameResponse{
|
||||
|
||||
@@ -127,6 +127,7 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
|
||||
}
|
||||
|
||||
g.UpdatePlayers(whitePlayer, blackPlayer)
|
||||
g.Touch(time.Now().UTC())
|
||||
if oldHasComputer != newHasComputer {
|
||||
if newHasComputer {
|
||||
s.computerGames.Add(1)
|
||||
@@ -302,20 +303,6 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLastMoveResult stores metadata about the last move
|
||||
func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
|
||||
g.SetLastResult(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UndoMoves removes the specified number of moves from game history
|
||||
func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
s.mu.Lock()
|
||||
@@ -334,6 +321,7 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
if err := g.UndoMoves(count); err != nil {
|
||||
return err
|
||||
}
|
||||
g.Touch(time.Now().UTC())
|
||||
|
||||
// Notify waiting clients about the undo
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), g.State())
|
||||
|
||||
@@ -2,21 +2,24 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/game"
|
||||
"chess/internal/server/storage"
|
||||
"chess/internal/server/storage/pgtest"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID, userID := uuid.NewString(), uuid.NewString()
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -24,53 +27,54 @@ func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T)
|
||||
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
|
||||
commit := MoveCommit{
|
||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||
ActorUserID: "user-1", MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
|
||||
ActorUserID: userID, MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
|
||||
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
|
||||
}
|
||||
if err := svc.ApplyMoveWithState("game-1", commit); err != nil {
|
||||
if err := svc.ApplyMoveWithState(gameID, commit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := svc.GetGameView("game-1")
|
||||
view, err := svc.GetGameView(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.WhitePlayer.ClaimedBy != "user-1" || view.State != core.StateWhiteWins {
|
||||
if view.WhitePlayer.ClaimedBy != userID || view.State != core.StateWhiteWins {
|
||||
t.Fatalf("in-memory move did not settle atomically: %+v", view)
|
||||
}
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
history, err := svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ClaimedBy != "user-1" || history.Result != "white_wins" || len(history.Moves) != 1 {
|
||||
if history.Players.White.ClaimedBy != userID || history.Result != "white_wins" || len(history.Moves) != 1 {
|
||||
t.Fatalf("history did not settle atomically: %+v", history)
|
||||
}
|
||||
|
||||
if err := svc.ApplyMoveWithState("game-1", commit); !errors.Is(err, ErrGameChanged) {
|
||||
if err := svc.ApplyMoveWithState(gameID, commit); !errors.Is(err, ErrGameChanged) {
|
||||
t.Fatalf("stale move error = %v, want ErrGameChanged", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoClearsDurableTerminalResult(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID := uuid.NewString()
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.ApplyMoveWithState("game-1", MoveCommit{
|
||||
if err := svc.ApplyMoveWithState(gameID, MoveCommit{
|
||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.UndoMoves("game-1", 1); err != nil {
|
||||
if err := svc.UndoMoves(gameID, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
history, err := svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -81,12 +85,13 @@ func TestUndoClearsDurableTerminalResult(t *testing.T) {
|
||||
|
||||
func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID, userID := uuid.NewString(), uuid.NewString()
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
white.ID = "user-1"
|
||||
white.ClaimedBy = "user-1"
|
||||
white.ID = userID
|
||||
white.ClaimedBy = userID
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
gameID, white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -95,15 +100,15 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
|
||||
replacementBlack := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack,
|
||||
)
|
||||
if err := svc.UpdatePlayers("game-1", replacementWhite, replacementBlack); err != nil {
|
||||
if err := svc.UpdatePlayers(gameID, replacementWhite, replacementBlack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
history, err := svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ID != "user-1" || history.Players.White.ClaimedBy != "user-1" {
|
||||
if history.Players.White.ID != userID || history.Players.White.ClaimedBy != userID {
|
||||
t.Fatalf("white ownership was replaced: %+v", history.Players.White)
|
||||
}
|
||||
if history.Players.Black.Type != core.PlayerComputer || history.Players.Black.Level != 12 {
|
||||
@@ -113,48 +118,101 @@ func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.
|
||||
computerWhite := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite,
|
||||
)
|
||||
if err := svc.UpdatePlayers("game-1", computerWhite, replacementBlack); err != nil {
|
||||
if err := svc.UpdatePlayers(gameID, computerWhite, replacementBlack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err = svc.GetGameHistory("game-1")
|
||||
history, err = svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ClaimedBy != "user-1" {
|
||||
if history.Players.White.ClaimedBy != userID {
|
||||
t.Fatalf("player-type change discarded historical ownership: %+v", history.Players.White)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
gameID := uuid.NewString()
|
||||
svc.SetFinishedGameTTL(time.Nanosecond)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "terminal", core.ColorWhite, core.StateStalemate,
|
||||
gameID, white, black, "terminal", core.ColorWhite, core.StateStalemate,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc.cleanupFinishedGames(time.Now().UTC().Add(time.Second))
|
||||
if _, err := svc.GetGameView("game-1"); !errors.Is(err, ErrGameNotFound) {
|
||||
svc.cleanupGames(time.Now().UTC().Add(time.Second))
|
||||
if _, err := svc.GetGameView(gameID); !errors.Is(err, ErrGameNotFound) {
|
||||
t.Fatalf("terminal game remains in memory: %v", err)
|
||||
}
|
||||
if history, err := svc.GetGameHistory("game-1"); err != nil || history.Result != "stalemate" {
|
||||
if history, err := svc.GetGameHistory(gameID); err != nil || history.Result != "stalemate" {
|
||||
t.Fatalf("durable history was removed: history=%+v err=%v", history, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupRemovesIdleAnonymousGamesButKeepsClaimedOnes(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
svc.SetFinishedGameTTL(0)
|
||||
user, _, err := svc.RegisterUser("alice", "", "Password1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
anonymous, claimed := uuid.NewString(), uuid.NewString()
|
||||
for gameID, claimant := range map[string]string{anonymous: "", claimed: user.UserID} {
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
if claimant != "" {
|
||||
white.ID, white.ClaimedBy = claimant, claimant
|
||||
}
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(gameID, white, black, "initial", core.ColorWhite, core.StateOngoing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
history, err := svc.GetGameHistory(claimed)
|
||||
if err != nil || history.Players.White.Name != "alice" {
|
||||
t.Fatalf("claimed history name = %+v, %v", history, err)
|
||||
}
|
||||
|
||||
// Within the retention window both games stay loaded and stored.
|
||||
svc.cleanupGames(time.Now().UTC().Add(time.Hour))
|
||||
for _, gameID := range []string{anonymous, claimed} {
|
||||
if _, err := svc.GetGameView(gameID); err != nil {
|
||||
t.Fatalf("game %s evicted early: %v", gameID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Past it, the anonymous game leaves memory and the database; the
|
||||
// registered user's game is untouched.
|
||||
svc.cleanupGames(time.Now().UTC().Add(AnonymousGameTTL + time.Hour))
|
||||
if _, err := svc.GetGameView(anonymous); !errors.Is(err, ErrGameNotFound) {
|
||||
t.Fatalf("idle anonymous game still loaded: %v", err)
|
||||
}
|
||||
if _, err := svc.GetGameHistory(anonymous); !errors.Is(err, ErrGameNotFound) {
|
||||
t.Fatalf("idle anonymous game still stored: %v", err)
|
||||
}
|
||||
if _, err := svc.GetGameView(claimed); err != nil {
|
||||
t.Fatalf("claimed game evicted: %v", err)
|
||||
}
|
||||
if _, err := svc.GetGameHistory(claimed); err != nil {
|
||||
t.Fatalf("claimed game deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newPersistentTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
store, err := storage.NewStore(filepath.Join(t.TempDir(), "chess.db"), false)
|
||||
store, err := storage.NewStore(pgtest.DSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.InitDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(store, []byte("test-secret-test-secret-test-secret"))
|
||||
svc, err := New(store, testJWTSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := svc.Shutdown(time.Second); err != nil {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
|
||||
@@ -82,6 +82,7 @@ func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameList
|
||||
StartTimeUTC: record.StartTimeUTC,
|
||||
EndTimeUTC: record.EndTimeUTC,
|
||||
MoveCount: record.MoveCount,
|
||||
FinalFEN: record.FinalFEN,
|
||||
Players: playersResponse(record.GameRecord),
|
||||
})
|
||||
}
|
||||
@@ -100,10 +101,12 @@ func playersResponse(record storage.GameRecord) core.PlayersResponse {
|
||||
White: &core.Player{
|
||||
ID: record.WhitePlayerID, Color: core.ColorWhite, Type: core.PlayerType(record.WhiteType),
|
||||
Level: record.WhiteLevel, SearchTime: record.WhiteSearchTime, ClaimedBy: record.WhiteClaimedBy,
|
||||
Name: record.WhiteName,
|
||||
},
|
||||
Black: &core.Player{
|
||||
ID: record.BlackPlayerID, Color: core.ColorBlack, Type: core.PlayerType(record.BlackType),
|
||||
Level: record.BlackLevel, SearchTime: record.BlackSearchTime, ClaimedBy: record.BlackClaimedBy,
|
||||
Name: record.BlackName,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"chess/internal/server/core"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,41 +9,62 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/game"
|
||||
"chess/internal/server/storage"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxComputerGames = 10
|
||||
MaxUsers = 100
|
||||
PermanentSlots = 10
|
||||
TempUserTTL = 24 * time.Hour
|
||||
DefaultMaxUsers = 100
|
||||
SessionTTL = 7 * 24 * time.Hour
|
||||
CleanupJobInterval = 1 * time.Hour
|
||||
FinishedGameTTL = 1 * time.Hour
|
||||
// AnonymousGameTTL is how long a game no registered user claimed survives
|
||||
// after its last activity, in memory and in the database.
|
||||
AnonymousGameTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Service coordinates game state, user management, and storage
|
||||
type Service struct {
|
||||
games map[string]*game.Game
|
||||
mu sync.RWMutex
|
||||
userMu sync.Mutex
|
||||
store *storage.Store
|
||||
jwtSecret []byte
|
||||
jwt *auth.JWT
|
||||
kdf chan struct{} // Argon2id concurrency slots
|
||||
waiter *WaitRegistry
|
||||
computerGames atomic.Int32 // Active games with computer players
|
||||
finishedTTL time.Duration
|
||||
anonymousTTL time.Duration
|
||||
maxUsers atomic.Int64
|
||||
}
|
||||
|
||||
// New creates a new service instance with optional storage
|
||||
func New(store *storage.Store, jwtSecret []byte) *Service {
|
||||
return &Service{
|
||||
games: make(map[string]*game.Game),
|
||||
store: store,
|
||||
jwtSecret: jwtSecret,
|
||||
waiter: NewWaitRegistry(),
|
||||
finishedTTL: FinishedGameTTL,
|
||||
// New creates a service with optional storage. jwtSecret signs session tokens
|
||||
// and must hold at least 32 bytes of key material.
|
||||
func New(store *storage.Store, jwtSecret []byte) (*Service, error) {
|
||||
manager, err := auth.NewJWT(jwtSecret,
|
||||
auth.WithIssuer(JWTIssuer),
|
||||
auth.WithAudience([]string{JWTAudience}),
|
||||
auth.WithTokenLifetime(SessionTTL),
|
||||
// Tokens are minted and verified by this process: no clock skew.
|
||||
auth.WithLeeway(0),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configure JWT: %w", err)
|
||||
}
|
||||
s := &Service{
|
||||
games: make(map[string]*game.Game),
|
||||
store: store,
|
||||
jwt: manager,
|
||||
kdf: make(chan struct{}, MaxConcurrentKDF),
|
||||
waiter: NewWaitRegistry(),
|
||||
finishedTTL: FinishedGameTTL,
|
||||
anonymousTTL: AnonymousGameTTL,
|
||||
}
|
||||
s.maxUsers.Store(DefaultMaxUsers)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// SetFinishedGameTTL configures how long terminal games remain in memory.
|
||||
@@ -56,6 +76,22 @@ func (s *Service) SetFinishedGameTTL(ttl time.Duration) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetAnonymousGameTTL configures how long games without a registered player
|
||||
// survive after their last activity. Such games are unloaded from memory and
|
||||
// deleted from the database once idle this long. A non-positive duration
|
||||
// keeps them indefinitely.
|
||||
func (s *Service) SetAnonymousGameTTL(ttl time.Duration) {
|
||||
s.mu.Lock()
|
||||
s.anonymousTTL = ttl
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetMaxUsers caps the number of accounts public registration may create;
|
||||
// zero removes the cap. Accounts created through the CLI are not limited.
|
||||
func (s *Service) SetMaxUsers(limit int) {
|
||||
s.maxUsers.Store(int64(max(limit, 0)))
|
||||
}
|
||||
|
||||
// GetStorageHealth returns the storage component status
|
||||
func (s *Service) GetStorageHealth() string {
|
||||
if s.store == nil {
|
||||
@@ -84,60 +120,11 @@ 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 {
|
||||
if userID == "" {
|
||||
return errors.New("claimant user ID is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
if err := g.ClaimSlot(color, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.store != nil {
|
||||
if err := s.store.RecordSlotClaim(gameID, color.String(), userID); err != nil {
|
||||
slog.Error("failed to queue slot claim persistence",
|
||||
"game_id", gameID, "color", color.String(), "error", err)
|
||||
}
|
||||
}
|
||||
slog.Debug("game slot claimed", "game_id", gameID, "color", color.String(), "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var errs []error
|
||||
@@ -159,7 +146,8 @@ func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// RunCleanupJob runs periodic cleanup of expired users and sessions
|
||||
// RunCleanupJob periodically removes expired sessions, unloads idle games, and
|
||||
// deletes anonymous games past their retention.
|
||||
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||
s.cleanupExpired()
|
||||
ticker := time.NewTicker(interval)
|
||||
@@ -176,49 +164,62 @@ func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||
}
|
||||
|
||||
func (s *Service) cleanupExpired() {
|
||||
now := time.Now().UTC()
|
||||
if s.store != nil {
|
||||
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
|
||||
slog.Error("cleanup failed to delete expired users", "error", err)
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired temporary users", "count", deleted)
|
||||
}
|
||||
|
||||
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
|
||||
slog.Error("cleanup failed to delete expired sessions", "error", err)
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired sessions", "count", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
s.cleanupFinishedGames(time.Now().UTC())
|
||||
s.cleanupGames(now)
|
||||
}
|
||||
|
||||
func (s *Service) cleanupFinishedGames(now time.Time) {
|
||||
// cleanupGames unloads terminal games after the finished-game TTL and
|
||||
// unclaimed games idle past the anonymous TTL, then queues deletion of
|
||||
// anonymous games idle past that TTL. The set of games still loaded is taken
|
||||
// under the same lock as the eviction and excluded from deletion, so no live
|
||||
// game can lose its row while writes for it are still possible.
|
||||
func (s *Service) cleanupGames(now time.Time) {
|
||||
s.mu.Lock()
|
||||
if s.finishedTTL <= 0 {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
cutoff := now.Add(-s.finishedTTL)
|
||||
removed := make([]string, 0)
|
||||
finishedCutoff := now.Add(-s.finishedTTL)
|
||||
anonymousTTL := s.anonymousTTL
|
||||
anonymousCutoff := now.Add(-anonymousTTL)
|
||||
var finished, idle []string
|
||||
live := make([]string, 0, len(s.games))
|
||||
for gameID, g := range s.games {
|
||||
ended := g.EndTimeUTC()
|
||||
if !g.State().IsTerminal() || ended == nil || ended.After(cutoff) {
|
||||
switch {
|
||||
case s.finishedTTL > 0 && g.State().IsTerminal() && ended != nil && ended.Before(finishedCutoff):
|
||||
finished = append(finished, gameID)
|
||||
case anonymousTTL > 0 && !g.IsClaimed() && g.State() != core.StatePending &&
|
||||
g.LastActivity().Before(anonymousCutoff):
|
||||
idle = append(idle, gameID)
|
||||
default:
|
||||
live = append(live, gameID)
|
||||
continue
|
||||
}
|
||||
if g.HasComputerPlayer() {
|
||||
s.computerGames.Add(-1)
|
||||
}
|
||||
delete(s.games, gameID)
|
||||
removed = append(removed, gameID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, gameID := range removed {
|
||||
for _, gameID := range append(finished, idle...) {
|
||||
s.waiter.RemoveGame(gameID)
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
if len(finished) > 0 {
|
||||
slog.Info("cleanup evicted terminal games from memory",
|
||||
"count", len(removed), "retention", s.finishedTTL)
|
||||
"count", len(finished), "retention", s.finishedTTL)
|
||||
}
|
||||
if len(idle) > 0 {
|
||||
slog.Info("cleanup evicted idle anonymous games from memory",
|
||||
"count", len(idle), "retention", anonymousTTL)
|
||||
}
|
||||
if s.store != nil && anonymousTTL > 0 {
|
||||
if err := s.store.DeleteAnonymousGames(anonymousCutoff, live); err != nil {
|
||||
slog.Error("cleanup failed to queue anonymous game deletion", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
-189
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/storage"
|
||||
@@ -18,122 +19,129 @@ var (
|
||||
ErrStorageDisabled = errors.New("storage disabled")
|
||||
ErrStorageUnavailable = errors.New("storage unavailable")
|
||||
ErrAtCapacity = errors.New("at capacity")
|
||||
ErrPermanentSlotsFull = errors.New("permanent slots full")
|
||||
ErrUserExists = errors.New("username or email already exists")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrAuthBusy = errors.New("authentication capacity exhausted")
|
||||
)
|
||||
|
||||
const (
|
||||
// JWTIssuer and JWTAudience scope chess tokens so a token minted by another
|
||||
// service sharing a key is rejected.
|
||||
JWTIssuer = "chess-server"
|
||||
JWTAudience = "chess-api"
|
||||
|
||||
// MaxConcurrentKDF bounds simultaneous Argon2id derivations (64 MiB each at
|
||||
// the auth defaults), capping password-hashing memory near 256 MiB.
|
||||
MaxConcurrentKDF = 4
|
||||
kdfWaitTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// dummyPasswordHash is verified for unknown identifiers so a login for a
|
||||
// missing account costs the same Argon2id work as one with a wrong password.
|
||||
var dummyPasswordHash = sync.OnceValues(func() (string, error) {
|
||||
return auth.HashPassword(uuid.NewString())
|
||||
})
|
||||
|
||||
// User represents a registered user account
|
||||
type User struct {
|
||||
UserID string
|
||||
Username string
|
||||
Email string
|
||||
AccountType string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
UserID string
|
||||
Username string
|
||||
Email string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateUser creates new user with registration limits enforcement
|
||||
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) {
|
||||
user, _, err := s.createUser(username, email, password, permanent, false)
|
||||
return user, err
|
||||
// acquireKDF reserves one Argon2id slot, waiting at most kdfWaitTimeout.
|
||||
func (s *Service) acquireKDF() (release func(), err error) {
|
||||
timer := time.NewTimer(kdfWaitTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case s.kdf <- struct{}{}:
|
||||
return func() { <-s.kdf }, nil
|
||||
case <-timer.C:
|
||||
return nil, ErrAuthBusy
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterUser creates the account and its initial session in one SQLite
|
||||
func (s *Service) hashPassword(password string) (string, error) {
|
||||
release, err := s.acquireKDF()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer release()
|
||||
return auth.HashPassword(password)
|
||||
}
|
||||
|
||||
func (s *Service) verifyPassword(password, hash string) error {
|
||||
release, err := s.acquireKDF()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
return auth.VerifyPassword(password, hash)
|
||||
}
|
||||
|
||||
// RegisterUser creates the account and its initial session in one database
|
||||
// transaction, so a successful registration always returns a usable account.
|
||||
func (s *Service) RegisterUser(username, email, password string, permanent bool) (*User, string, error) {
|
||||
return s.createUser(username, email, password, permanent, true)
|
||||
}
|
||||
|
||||
func (s *Service) createUser(
|
||||
username, email, password string,
|
||||
permanent, withSession bool,
|
||||
) (*User, string, error) {
|
||||
s.userMu.Lock()
|
||||
defer s.userMu.Unlock()
|
||||
|
||||
// Registered accounts are identical to CLI-created ones and never expire.
|
||||
func (s *Service) RegisterUser(username, email, password string) (*User, string, error) {
|
||||
if s.store == nil {
|
||||
return nil, "", ErrStorageDisabled
|
||||
}
|
||||
|
||||
// Determine account type
|
||||
accountType := "temp"
|
||||
var expiresAt *time.Time
|
||||
|
||||
if permanent {
|
||||
accountType = "permanent"
|
||||
} else {
|
||||
expiry := time.Now().UTC().Add(TempUserTTL)
|
||||
expiresAt = &expiry
|
||||
}
|
||||
|
||||
// Hash password
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
// Hash before touching storage; account creation itself is serialized by
|
||||
// the store, so concurrent registrations only contend for KDF slots.
|
||||
passwordHash, err := s.hashPassword(password)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAuthBusy) {
|
||||
return nil, "", err
|
||||
}
|
||||
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Generate unique user ID
|
||||
userID, err := s.generateUniqueUserID()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to generate unique ID: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Create user record
|
||||
// A random UUIDv4 needs no existence probe; the primary key rejects the
|
||||
// negligible collision case.
|
||||
userID := uuid.NewString()
|
||||
user := &User{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
AccountType: accountType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: expiresAt,
|
||||
UserID: userID,
|
||||
Username: strings.ToLower(username),
|
||||
Email: strings.ToLower(email),
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
record := storage.UserRecord{
|
||||
UserID: userID,
|
||||
Username: strings.ToLower(username),
|
||||
Email: strings.ToLower(email),
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
PasswordHash: passwordHash,
|
||||
AccountType: accountType,
|
||||
CreatedAt: user.CreatedAt,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: now,
|
||||
}
|
||||
|
||||
var sessionID string
|
||||
var session *storage.SessionRecord
|
||||
if withSession {
|
||||
sessionID = uuid.New().String()
|
||||
session = &storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: user.CreatedAt,
|
||||
ExpiresAt: user.CreatedAt.Add(SessionTTL),
|
||||
}
|
||||
}
|
||||
limits := storage.UserLimits{
|
||||
MaxUsers: MaxUsers,
|
||||
PermanentSlots: PermanentSlots,
|
||||
sessionID := uuid.NewString()
|
||||
session := &storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(SessionTTL),
|
||||
}
|
||||
limits := storage.UserLimits{MaxUsers: int(s.maxUsers.Load())}
|
||||
if err = s.store.CreateUserWithinLimits(record, session, limits); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrUserAlreadyExists):
|
||||
return nil, "", fmt.Errorf("username or email already exists: %w", err)
|
||||
case errors.Is(err, storage.ErrPermanentCapacity):
|
||||
return nil, "", fmt.Errorf("%w (%d maximum)", ErrPermanentSlotsFull, PermanentSlots)
|
||||
return nil, "", ErrUserExists
|
||||
case errors.Is(err, storage.ErrUserCapacity):
|
||||
return nil, "", fmt.Errorf("%w: no temporary account can be replaced", ErrAtCapacity)
|
||||
return nil, "", fmt.Errorf("%w: registration is limited to %d accounts", ErrAtCapacity, limits.MaxUsers)
|
||||
default:
|
||||
return nil, "", fmt.Errorf("%w: create user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
}
|
||||
slog.Debug("user created",
|
||||
"user_id", userID,
|
||||
"account_type", accountType,
|
||||
"initial_session", withSession,
|
||||
)
|
||||
slog.Debug("user created", "user_id", userID)
|
||||
|
||||
return user, sessionID, nil
|
||||
}
|
||||
|
||||
// AuthenticateUser verifies credentials and creates a new session
|
||||
// AuthenticateUser verifies credentials and creates a new session. Unknown
|
||||
// identifiers and wrong passwords both return ErrInvalidCredentials after the
|
||||
// same Argon2id work.
|
||||
func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) {
|
||||
if s.store == nil {
|
||||
return nil, "", ErrStorageDisabled
|
||||
@@ -141,75 +149,49 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
|
||||
|
||||
var userRecord *storage.UserRecord
|
||||
var err error
|
||||
|
||||
// Check if identifier looks like email
|
||||
if strings.Contains(identifier, "@") {
|
||||
userRecord, err = s.store.GetUserByEmail(identifier)
|
||||
} else {
|
||||
userRecord, err = s.store.GetUserByUsername(identifier)
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
auth.HashPassword(password) // Timing attack prevention
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
|
||||
hash := ""
|
||||
if userRecord != nil {
|
||||
hash = userRecord.PasswordHash
|
||||
} else if hash, err = dummyPasswordHash(); err != nil {
|
||||
return nil, "", fmt.Errorf("prepare credential check: %w", err)
|
||||
}
|
||||
if err := s.verifyPassword(password, hash); err != nil || userRecord == nil {
|
||||
if errors.Is(err, ErrAuthBusy) {
|
||||
return nil, "", err
|
||||
}
|
||||
return nil, "", fmt.Errorf("invalid credentials")
|
||||
return nil, "", ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := auth.VerifyPassword(password, userRecord.PasswordHash); err != nil {
|
||||
return nil, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
// 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{
|
||||
now := time.Now().UTC()
|
||||
// Replaces any existing session and records the login time.
|
||||
sessionID := uuid.NewString()
|
||||
if err := s.store.CreateSession(storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userRecord.UserID,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(SessionTTL),
|
||||
}
|
||||
|
||||
if err := s.store.CreateSession(sessionRecord); err != nil {
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(SessionTTL),
|
||||
}); err != nil {
|
||||
return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
slog.Debug("user authenticated", "user_id", userRecord.UserID)
|
||||
|
||||
// Update last login
|
||||
if err := s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC()); err != nil {
|
||||
slog.Warn("failed to record user login time", "user_id", userRecord.UserID, "error", err)
|
||||
}
|
||||
|
||||
return &User{
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
AccountType: userRecord.AccountType,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
ExpiresAt: userRecord.ExpiresAt,
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
}, sessionID, nil
|
||||
}
|
||||
|
||||
// ValidateSession checks if a session is valid
|
||||
func (s *Service) ValidateSession(sessionID string) (bool, error) {
|
||||
if s.store == nil {
|
||||
return false, ErrStorageDisabled
|
||||
}
|
||||
valid, err := s.store.IsSessionValid(sessionID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: validate session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// InvalidateSession removes a session (logout)
|
||||
func (s *Service) InvalidateSession(sessionID string) error {
|
||||
if s.store == nil {
|
||||
@@ -236,34 +218,23 @@ func (s *Service) GetUserByID(userID string) (*User, error) {
|
||||
}
|
||||
|
||||
return &User{
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
AccountType: userRecord.AccountType,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
ExpiresAt: userRecord.ExpiresAt,
|
||||
UserID: userRecord.UserID,
|
||||
Username: userRecord.Username,
|
||||
Email: userRecord.Email,
|
||||
CreatedAt: userRecord.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateUserToken creates a JWT token for the specified user with session ID
|
||||
// GenerateUserToken signs a scoped JWT bound to the persisted session. The
|
||||
// token carries only the subject and session ID: JWT payloads are readable by
|
||||
// the holder, so profile data such as email stays behind /auth/me.
|
||||
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,
|
||||
"session_id": sessionID,
|
||||
}
|
||||
|
||||
return auth.GenerateHS256Token(s.jwtSecret, userID, claims, SessionTTL)
|
||||
return s.jwt.GenerateToken(userID, map[string]any{"session_id": sessionID})
|
||||
}
|
||||
|
||||
// ValidateToken verifies JWT token and session validity
|
||||
func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
|
||||
userID, claims, err := auth.ValidateHS256Token(s.jwtSecret, token)
|
||||
userID, claims, err := s.jwt.ValidateToken(token)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -285,43 +256,3 @@ func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
|
||||
|
||||
return userID, claims, nil
|
||||
}
|
||||
|
||||
// generateUniqueUserID creates a unique user ID with collision detection
|
||||
func (s *Service) generateUniqueUserID() (string, error) {
|
||||
const maxAttempts = 10
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
id := uuid.New().String()
|
||||
if _, err := s.store.GetUserByID(id); errors.Is(err, sql.ErrNoRows) {
|
||||
return id, nil
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("%w: check generated user ID: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to generate unique user ID")
|
||||
}
|
||||
|
||||
// CreateUserSession creates a session for a trusted internal caller without
|
||||
// re-authenticating. Public registration uses RegisterUser so account and
|
||||
// initial session creation remain atomic.
|
||||
func (s *Service) CreateUserSession(userID string) (string, error) {
|
||||
if s.store == nil {
|
||||
return "", ErrStorageDisabled
|
||||
}
|
||||
|
||||
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("%w: create session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
slog.Debug("user session created", "user_id", userID)
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
|
||||
var testJWTSecret = []byte("test-secret-test-secret-test-secret")
|
||||
|
||||
func TestValidateTokenRequiresScopedTokenBoundToSession(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
user, sessionID, err := svc.RegisterUser("alice", "", "Password1", false)
|
||||
user, sessionID, err := svc.RegisterUser("alice", "", "Password1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -17,26 +24,47 @@ func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotUserID, _, err := svc.ValidateToken(validToken); err != nil || gotUserID != user.UserID {
|
||||
t.Fatalf("valid token rejected: user=%q err=%v", gotUserID, err)
|
||||
gotUserID, claims, err := svc.ValidateToken(validToken)
|
||||
if err != nil || gotUserID != user.UserID || claims["session_id"] != sessionID {
|
||||
t.Fatalf("valid token rejected: user=%q claims=%v err=%v", gotUserID, claims, err)
|
||||
}
|
||||
|
||||
missingSession, err := auth.GenerateHS256Token(svc.jwtSecret, user.UserID, nil, SessionTTL)
|
||||
scoped, err := auth.NewJWT(testJWTSecret,
|
||||
auth.WithIssuer(JWTIssuer), auth.WithAudience([]string{JWTAudience}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(missingSession); err == nil {
|
||||
t.Fatal("token without a persisted session was accepted")
|
||||
}
|
||||
|
||||
wrongSubject, err := auth.GenerateHS256Token(svc.jwtSecret, "other-user", map[string]any{
|
||||
"session_id": sessionID,
|
||||
}, SessionTTL)
|
||||
unscoped, err := auth.NewJWT(testJWTSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(wrongSubject); err == nil {
|
||||
t.Fatal("session was accepted for a different JWT subject")
|
||||
foreignAudience, err := auth.NewJWT(testJWTSecret,
|
||||
auth.WithIssuer(JWTIssuer), auth.WithAudience([]string{"other-service"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, mint := range map[string]func() (string, error){
|
||||
"missing session": func() (string, error) { return scoped.GenerateToken(user.UserID, nil) },
|
||||
"wrong subject": func() (string, error) {
|
||||
return scoped.GenerateToken(uuid.NewString(), map[string]any{"session_id": sessionID})
|
||||
},
|
||||
"malformed subject": func() (string, error) {
|
||||
return scoped.GenerateToken("other-user", map[string]any{"session_id": sessionID})
|
||||
},
|
||||
"no issuer or audience": func() (string, error) {
|
||||
return unscoped.GenerateToken(user.UserID, map[string]any{"session_id": sessionID})
|
||||
},
|
||||
"foreign audience": func() (string, error) {
|
||||
return foreignAudience.GenerateToken(user.UserID, map[string]any{"session_id": sessionID})
|
||||
},
|
||||
} {
|
||||
token, err := mint()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(token); err == nil {
|
||||
t.Errorf("%s: token accepted", name)
|
||||
}
|
||||
}
|
||||
|
||||
if err := svc.InvalidateSession(sessionID); err != nil {
|
||||
@@ -46,3 +74,75 @@ func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
|
||||
t.Fatal("invalidated session was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateUserFailuresAreIndistinguishable(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
if _, _, err := svc.RegisterUser("alice", "alice@example.com", "Password1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.RegisterUser("ALICE", "", "Password1"); !errors.Is(err, ErrUserExists) {
|
||||
t.Fatalf("duplicate registration = %v, want ErrUserExists", err)
|
||||
}
|
||||
|
||||
user, sessionID, err := svc.AuthenticateUser("alice@example.com", "Password1")
|
||||
if err != nil || sessionID == "" || user.Username != "alice" {
|
||||
t.Fatalf("login by email = %+v, %q, %v", user, sessionID, err)
|
||||
}
|
||||
|
||||
for name, attempt := range map[string][2]string{
|
||||
"unknown user": {"nobody", "Password1"},
|
||||
"unknown email": {"nobody@example.com", "Password1"},
|
||||
"wrong password": {"alice", "Password2"},
|
||||
} {
|
||||
if _, _, err := svc.AuthenticateUser(attempt[0], attempt[1]); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Errorf("%s: error = %v, want ErrInvalidCredentials", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationCapIsConfigurable(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
svc.SetMaxUsers(1)
|
||||
if _, _, err := svc.RegisterUser("alice", "", "Password1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.RegisterUser("bob", "", "Password1"); !errors.Is(err, ErrAtCapacity) {
|
||||
t.Fatalf("registration past the cap = %v, want ErrAtCapacity", err)
|
||||
}
|
||||
svc.SetMaxUsers(0)
|
||||
if _, _, err := svc.RegisterUser("bob", "", "Password1"); err != nil {
|
||||
t.Fatalf("registration without a cap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHashingIsBounded(t *testing.T) {
|
||||
svc, err := New(nil, testJWTSecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var releases []func()
|
||||
for range MaxConcurrentKDF {
|
||||
release, err := svc.acquireKDF()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releases = append(releases, release)
|
||||
}
|
||||
started := time.Now()
|
||||
if _, err := svc.hashPassword("Password1"); !errors.Is(err, ErrAuthBusy) {
|
||||
t.Fatalf("saturated KDF = %v, want ErrAuthBusy", err)
|
||||
}
|
||||
if waited := time.Since(started); waited < kdfWaitTimeout {
|
||||
t.Fatalf("gave up after %v, want %v", waited, kdfWaitTimeout)
|
||||
}
|
||||
releases[0]()
|
||||
if _, err := svc.hashPassword("Password1"); err != nil {
|
||||
t.Fatalf("hash after release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsShortJWTSecret(t *testing.T) {
|
||||
if _, err := New((*storage.Store)(nil), make([]byte, 31)); err == nil {
|
||||
t.Fatal("31-byte JWT secret accepted")
|
||||
}
|
||||
}
|
||||
+204
-214
@@ -6,17 +6,29 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const gameSelectColumns = `
|
||||
g.game_id, g.initial_fen,
|
||||
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by,
|
||||
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by,
|
||||
g.white_player_id, g.white_type, g.white_level, g.white_search_time, g.white_claimed_by, g.white_name,
|
||||
g.black_player_id, g.black_type, g.black_level, g.black_search_time, g.black_claimed_by, g.black_name,
|
||||
g.result, g.start_time_utc, g.end_time_utc`
|
||||
|
||||
// claimantName snapshots a claimant's username inside the writing
|
||||
// transaction; NULL when the slot is unclaimed or the account is gone.
|
||||
func claimantName(param string) string {
|
||||
return "(SELECT u.username FROM users u WHERE u.user_id = " + param + ")"
|
||||
}
|
||||
|
||||
// MaxUserGamesPage bounds QueryGamesForUser; callers request one extra row to
|
||||
// detect a following page.
|
||||
const MaxUserGamesPage = 101
|
||||
|
||||
// RecordNewGame asynchronously records a new game. Terminal custom-FEN games
|
||||
// include their result in this insert rather than relying on a second write.
|
||||
// Claimed slots also record the claimant's current username.
|
||||
func (s *Store) RecordNewGame(record GameRecord) error {
|
||||
if record.GameID == "" || record.InitialFEN == "" || record.WhitePlayerID == "" || record.BlackPlayerID == "" {
|
||||
return errors.New("game ID, initial FEN, and player IDs are required")
|
||||
@@ -25,15 +37,16 @@ func (s *Store) RecordNewGame(record GameRecord) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.enqueue("record_game", record.GameID, func(tx *sql.Tx) error {
|
||||
const query = `INSERT INTO games (
|
||||
return s.enqueue("record_game", record.GameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
query := `INSERT INTO games (
|
||||
game_id, initial_fen,
|
||||
white_player_id, white_type, white_level, white_search_time, white_claimed_by,
|
||||
black_player_id, black_type, black_level, black_search_time, black_claimed_by,
|
||||
white_player_id, white_type, white_level, white_search_time, white_claimed_by, white_name,
|
||||
black_player_id, black_type, black_level, black_search_time, black_claimed_by, black_name,
|
||||
start_time_utc, result, end_time_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, ` + claimantName("$7") + `,
|
||||
$8, $9, $10, $11, $12, ` + claimantName("$12") + `, $13, $14, $15)`
|
||||
|
||||
_, err := tx.Exec(query,
|
||||
_, err := tx.ExecContext(ctx, query,
|
||||
record.GameID, record.InitialFEN,
|
||||
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
|
||||
nullableString(record.WhiteClaimedBy),
|
||||
@@ -65,11 +78,11 @@ func (s *Store) RecordMove(record MovePersistence) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.enqueue("record_move", record.Move.GameID, func(tx *sql.Tx) error {
|
||||
return s.enqueue("record_move", record.Move.GameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
const insertMove = `INSERT INTO moves (
|
||||
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
if _, err := tx.Exec(insertMove,
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)`
|
||||
if _, err := tx.ExecContext(ctx, insertMove,
|
||||
record.Move.GameID,
|
||||
record.Move.MoveNumber,
|
||||
record.Move.MoveUCI,
|
||||
@@ -80,33 +93,35 @@ func (s *Store) RecordMove(record MovePersistence) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if record.ClaimedBy == "" && record.Result == "" {
|
||||
return nil
|
||||
}
|
||||
// One UPDATE applies the optional claim and optional result. A claim may
|
||||
// only fill an empty slot or repeat the same claimant.
|
||||
claimColumn, nameColumn := "white_claimed_by", "white_name"
|
||||
if record.ClaimColor == "b" {
|
||||
claimColumn, nameColumn = "black_claimed_by", "black_name"
|
||||
}
|
||||
var set []string
|
||||
args := []any{record.Move.GameID}
|
||||
where := "game_id = $1"
|
||||
if record.ClaimedBy != "" {
|
||||
column := "white_claimed_by"
|
||||
if record.ClaimColor == "b" {
|
||||
column = "black_claimed_by"
|
||||
}
|
||||
query := `UPDATE games SET ` + column + ` = ?
|
||||
WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)`
|
||||
result, err := tx.Exec(query, record.ClaimedBy, record.Move.GameID, record.ClaimedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireOneGame(result, record.Move.GameID); err != nil {
|
||||
return err
|
||||
}
|
||||
args = append(args, record.ClaimedBy)
|
||||
param := fmt.Sprintf("$%d", len(args))
|
||||
set = append(set, fmt.Sprintf("%s = %s, %s = COALESCE(%s, %s)",
|
||||
claimColumn, param, nameColumn, nameColumn, claimantName(param)))
|
||||
where += fmt.Sprintf(" AND (%s IS NULL OR %s = %s)", claimColumn, claimColumn, param)
|
||||
}
|
||||
|
||||
if record.Result != "" {
|
||||
result, err := tx.Exec(
|
||||
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`,
|
||||
record.Result, record.EndTimeUTC, record.Move.GameID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(result, record.Move.GameID)
|
||||
args = append(args, record.Result, record.EndTimeUTC)
|
||||
set = append(set, fmt.Sprintf("result = $%d, end_time_utc = $%d", len(args)-1, len(args)))
|
||||
}
|
||||
return nil
|
||||
result, err := tx.ExecContext(ctx,
|
||||
"UPDATE games SET "+strings.Join(set, ", ")+" WHERE "+where, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(result, record.Move.GameID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,56 +137,10 @@ func (s *Store) RecordGameResult(gameID, result string, at time.Time) error {
|
||||
if at.IsZero() {
|
||||
return errors.New("game result time is required")
|
||||
}
|
||||
return s.enqueue("record_game_result", gameID, func(tx *sql.Tx) error {
|
||||
res, err := tx.Exec(
|
||||
`UPDATE games SET result = ?, end_time_utc = ? WHERE game_id = ?`,
|
||||
result, at.UTC(), gameID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// RecordSlotClaim persists a claim made independently from a move.
|
||||
func (s *Store) RecordSlotClaim(gameID, color, userID string) error {
|
||||
if gameID == "" || userID == "" {
|
||||
return errors.New("game ID and claimant are required")
|
||||
}
|
||||
if color != "w" && color != "b" {
|
||||
return fmt.Errorf("invalid claim color %q", color)
|
||||
}
|
||||
column := "white_claimed_by"
|
||||
if color == "b" {
|
||||
column = "black_claimed_by"
|
||||
}
|
||||
return s.enqueue("record_slot_claim", gameID, func(tx *sql.Tx) error {
|
||||
query := `UPDATE games SET ` + column + ` = ?
|
||||
WHERE game_id = ? AND (` + column + ` IS NULL OR ` + column + ` = '' OR ` + column + ` = ?)`
|
||||
res, err := tx.Exec(query, userID, gameID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// RecordPlayers keeps persisted player configuration aligned with in-memory
|
||||
// configuration changes.
|
||||
func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
|
||||
if gameID == "" || white.PlayerID == "" || black.PlayerID == "" {
|
||||
return errors.New("game ID and player IDs are required")
|
||||
}
|
||||
return s.enqueue("record_players", gameID, func(tx *sql.Tx) error {
|
||||
const query = `UPDATE games SET
|
||||
white_player_id = ?, white_type = ?, white_level = ?, white_search_time = ?, white_claimed_by = ?,
|
||||
black_player_id = ?, black_type = ?, black_level = ?, black_search_time = ?, black_claimed_by = ?
|
||||
WHERE game_id = ?`
|
||||
res, err := tx.Exec(query,
|
||||
white.PlayerID, white.Type, white.Level, white.SearchTime, nullableString(white.ClaimedBy),
|
||||
black.PlayerID, black.Type, black.Level, black.SearchTime, nullableString(black.ClaimedBy),
|
||||
gameID,
|
||||
return s.enqueue("record_game_result", gameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE games SET result = $2, end_time_utc = $3 WHERE game_id = $1`,
|
||||
gameID, result, at.UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -189,21 +158,44 @@ type PlayerRecord struct {
|
||||
ClaimedBy string
|
||||
}
|
||||
|
||||
// RecordPlayers keeps persisted player configuration aligned with in-memory
|
||||
// configuration changes. Claims are carried over unchanged by the service, so
|
||||
// their name snapshots are left as they are.
|
||||
func (s *Store) RecordPlayers(gameID string, white, black PlayerRecord) error {
|
||||
if gameID == "" || white.PlayerID == "" || black.PlayerID == "" {
|
||||
return errors.New("game ID and player IDs are required")
|
||||
}
|
||||
return s.enqueue("record_players", gameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
const query = `UPDATE games SET
|
||||
white_player_id = $2, white_type = $3, white_level = $4, white_search_time = $5, white_claimed_by = $6,
|
||||
black_player_id = $7, black_type = $8, black_level = $9, black_search_time = $10, black_claimed_by = $11
|
||||
WHERE game_id = $1`
|
||||
res, err := tx.ExecContext(ctx, query, gameID,
|
||||
white.PlayerID, white.Type, white.Level, white.SearchTime, nullableString(white.ClaimedBy),
|
||||
black.PlayerID, black.Type, black.Level, black.SearchTime, nullableString(black.ClaimedBy),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return requireOneGame(res, gameID)
|
||||
})
|
||||
}
|
||||
|
||||
// RewindGame atomically removes undone moves and clears a previously terminal
|
||||
// result so replay readers never observe an ongoing line with a stale outcome.
|
||||
func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
|
||||
if gameID == "" || afterMoveNumber < 0 {
|
||||
return errors.New("game ID and a non-negative move number are required")
|
||||
}
|
||||
return s.enqueue("rewind_game", gameID, func(tx *sql.Tx) error {
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM moves WHERE game_id = ? AND move_number > ?`,
|
||||
return s.enqueue("rewind_game", gameID, func(ctx context.Context, tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM moves WHERE game_id = $1 AND move_number > $2`,
|
||||
gameID, afterMoveNumber,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(
|
||||
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = ?`,
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE games SET result = NULL, end_time_utc = NULL WHERE game_id = $1`,
|
||||
gameID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -213,28 +205,66 @@ func (s *Store) RewindGame(gameID string, afterMoveNumber int) error {
|
||||
})
|
||||
}
|
||||
|
||||
// QueryGames retrieves games with optional filtering. A player filter matches
|
||||
// both creation-time player IDs and claims made after game creation.
|
||||
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
// DeleteAnonymousGames queues removal of games that no registered user
|
||||
// claimed and whose last activity (start, last move, or end) precedes cutoff.
|
||||
// Games still loaded in memory are listed in live and always kept, so a queued
|
||||
// or future write can never target a deleted row. Moves cascade.
|
||||
func (s *Store) DeleteAnonymousGames(cutoff time.Time, live []string) error {
|
||||
if cutoff.IsZero() {
|
||||
return errors.New("anonymous game cutoff is required")
|
||||
}
|
||||
started := time.Now()
|
||||
query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE 1=1`
|
||||
var args []any
|
||||
if live == nil {
|
||||
live = []string{}
|
||||
}
|
||||
return s.enqueue("delete_anonymous_games", "", func(ctx context.Context, tx *sql.Tx) error {
|
||||
result, err := tx.ExecContext(ctx, `DELETE FROM games g
|
||||
WHERE g.white_claimed_by IS NULL AND g.black_claimed_by IS NULL
|
||||
AND g.start_time_utc < $1
|
||||
AND (g.end_time_utc IS NULL OR g.end_time_utc < $1)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM moves m
|
||||
WHERE m.game_id = g.game_id AND m.move_time_utc >= $1
|
||||
)
|
||||
AND NOT (g.game_id = ANY($2::uuid[]))`,
|
||||
cutoff.UTC(), live)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deleted, err := result.RowsAffected(); err == nil && deleted > 0 {
|
||||
slog.Info("storage anonymous games deleted", "count", deleted, "inactive_before", cutoff.UTC())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// QueryGames is the administrative game lookup. Either filter may be empty or
|
||||
// "*" for all; a player filter matches creation-time player IDs and claims.
|
||||
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||
query := `SELECT ` + gameSelectColumns + ` FROM games g WHERE true`
|
||||
var args []any
|
||||
if gameID != "" && gameID != "*" {
|
||||
query += " AND g.game_id = ?"
|
||||
if !validUUID(gameID) {
|
||||
return nil, fmt.Errorf("invalid game ID %q", gameID)
|
||||
}
|
||||
args = append(args, gameID)
|
||||
query += fmt.Sprintf(" AND g.game_id = $%d", len(args))
|
||||
}
|
||||
if playerID != "" && playerID != "*" {
|
||||
query += ` AND (g.white_player_id = ? OR g.black_player_id = ?
|
||||
OR g.white_claimed_by = ? OR g.black_claimed_by = ?)`
|
||||
args = append(args, playerID, playerID, playerID, playerID)
|
||||
if !validUUID(playerID) {
|
||||
return nil, fmt.Errorf("invalid player ID %q", playerID)
|
||||
}
|
||||
args = append(args, playerID)
|
||||
query += fmt.Sprintf(` AND $%d IN (g.white_player_id, g.black_player_id,
|
||||
g.white_claimed_by, g.black_claimed_by)`, len(args))
|
||||
}
|
||||
query += " ORDER BY g.start_time_utc DESC, g.game_id DESC"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query games: %w", err)
|
||||
}
|
||||
@@ -251,111 +281,95 @@ func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate games: %w", err)
|
||||
}
|
||||
slog.Debug("storage games queried", "count", len(games), "duration", time.Since(started))
|
||||
return games, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetGameRecord(gameID string) (*GameRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return getGameRecord(s.db, gameID)
|
||||
}
|
||||
|
||||
type gameQueryer interface {
|
||||
Query(query string, args ...any) (*sql.Rows, error)
|
||||
QueryRow(query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
func getGameRecord(queryer gameQueryer, gameID string) (*GameRecord, error) {
|
||||
var record GameRecord
|
||||
row := queryer.QueryRow(`SELECT `+gameSelectColumns+` FROM games g WHERE g.game_id = ?`, gameID)
|
||||
if err := scanGame(row, &record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// GetMovesForGame returns the complete, undo-consistent replay line.
|
||||
func (s *Store) GetMovesForGame(gameID string) ([]MoveRecord, error) {
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return getMovesForGame(s.db, gameID)
|
||||
}
|
||||
|
||||
func getMovesForGame(queryer gameQueryer, gameID string) ([]MoveRecord, error) {
|
||||
const query = `SELECT move_id, game_id, move_number, move_uci,
|
||||
fen_after_move, player_color, move_time_utc
|
||||
FROM moves WHERE game_id = ? ORDER BY move_number ASC`
|
||||
rows, err := queryer.Query(query, gameID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query game moves: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
moves := make([]MoveRecord, 0)
|
||||
for rows.Next() {
|
||||
var move MoveRecord
|
||||
if err := rows.Scan(
|
||||
&move.MoveID, &move.GameID, &move.MoveNumber, &move.MoveUCI,
|
||||
&move.FENAfterMove, &move.PlayerColor, &move.MoveTimeUTC,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan game move: %w", err)
|
||||
}
|
||||
moves = append(moves, move)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate game moves: %w", err)
|
||||
}
|
||||
return moves, nil
|
||||
}
|
||||
|
||||
// GetGameHistory uses one write barrier and one read transaction for a
|
||||
// consistent game-and-moves snapshot.
|
||||
// GetGameHistory returns the game row and its ordered moves after every
|
||||
// previously accepted write has committed. Both reads share one REPEATABLE READ
|
||||
// snapshot, so a concurrent move or rewind cannot produce a mixed history.
|
||||
func (s *Store) GetGameHistory(gameID string) (*GameRecord, []MoveRecord, error) {
|
||||
if !validUUID(gameID) {
|
||||
return nil, nil, sql.ErrNoRows
|
||||
}
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
started := time.Now()
|
||||
tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("begin game history read: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
record, err := getGameRecord(tx, gameID)
|
||||
if err != nil {
|
||||
var record GameRecord
|
||||
row := tx.QueryRowContext(ctx, `SELECT `+gameSelectColumns+` FROM games g WHERE g.game_id = $1`, gameID)
|
||||
if err := scanGame(row, &record); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
moves, err := getMovesForGame(tx, gameID)
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `SELECT game_id, move_number, move_uci,
|
||||
fen_after_move, player_color, move_time_utc
|
||||
FROM moves WHERE game_id = $1 ORDER BY move_number`, gameID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, fmt.Errorf("query game moves: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
moves := make([]MoveRecord, 0)
|
||||
for rows.Next() {
|
||||
var move MoveRecord
|
||||
if err := rows.Scan(
|
||||
&move.GameID, &move.MoveNumber, &move.MoveUCI,
|
||||
&move.FENAfterMove, &move.PlayerColor, &move.MoveTimeUTC,
|
||||
); err != nil {
|
||||
return nil, nil, fmt.Errorf("scan game move: %w", err)
|
||||
}
|
||||
move.MoveTimeUTC = move.MoveTimeUTC.UTC()
|
||||
moves = append(moves, move)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("iterate game moves: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, nil, fmt.Errorf("finish game history read: %w", err)
|
||||
}
|
||||
slog.Debug("storage game history queried",
|
||||
"game_id", gameID, "move_count", len(moves), "duration", time.Since(started))
|
||||
return record, moves, nil
|
||||
return &record, moves, nil
|
||||
}
|
||||
|
||||
// QueryGamesForUser returns a page of games claimed by userID in either color,
|
||||
// newest first. The last move is read through a LATERAL probe of the moves
|
||||
// primary key, so the summary costs one index lookup per game rather than a
|
||||
// move count over the whole line.
|
||||
func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSummaryRecord, error) {
|
||||
if userID == "" || limit < 1 || limit > 101 || offset < 0 {
|
||||
return nil, errors.New("user ID, limit from 1 to 101, and non-negative offset are required")
|
||||
if userID == "" || limit < 1 || limit > MaxUserGamesPage || offset < 0 {
|
||||
return nil, fmt.Errorf("user ID, limit from 1 to %d, and non-negative offset are required",
|
||||
MaxUserGamesPage)
|
||||
}
|
||||
if !validUUID(userID) {
|
||||
return []GameSummaryRecord{}, nil
|
||||
}
|
||||
if err := s.flushBeforeRead(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
started := time.Now()
|
||||
query := `SELECT ` + gameSelectColumns + `,
|
||||
(SELECT COUNT(*) FROM moves m WHERE m.game_id = g.game_id) AS move_count
|
||||
const query = `SELECT ` + gameSelectColumns + `,
|
||||
COALESCE(last.move_number, 0), COALESCE(last.fen_after_move, g.initial_fen)
|
||||
FROM games g
|
||||
WHERE g.white_player_id = ? OR g.black_player_id = ?
|
||||
OR g.white_claimed_by = ? OR g.black_claimed_by = ?
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT m.move_number, m.fen_after_move FROM moves m
|
||||
WHERE m.game_id = g.game_id
|
||||
ORDER BY m.move_number DESC
|
||||
LIMIT 1
|
||||
) last ON true
|
||||
WHERE g.white_claimed_by = $1 OR g.black_claimed_by = $1
|
||||
ORDER BY g.start_time_utc DESC, g.game_id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
rows, err := s.db.Query(query, userID, userID, userID, userID, limit, offset)
|
||||
LIMIT $2 OFFSET $3`
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
rows, err := s.db.QueryContext(ctx, query, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query user games: %w", err)
|
||||
}
|
||||
@@ -364,7 +378,7 @@ func (s *Store) QueryGamesForUser(userID string, limit, offset int) ([]GameSumma
|
||||
games := make([]GameSummaryRecord, 0)
|
||||
for rows.Next() {
|
||||
var summary GameSummaryRecord
|
||||
if err := scanGameSummary(rows, &summary); err != nil {
|
||||
if err := scanGame(rows, &summary.GameRecord, &summary.MoveCount, &summary.FinalFEN); err != nil {
|
||||
return nil, fmt.Errorf("scan user game: %w", err)
|
||||
}
|
||||
games = append(games, summary)
|
||||
@@ -386,45 +400,28 @@ type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanGame(scanner rowScanner, record *GameRecord) error {
|
||||
var whiteClaimed, blackClaimed, result sql.NullString
|
||||
// scanGame scans gameSelectColumns followed by any extra destinations.
|
||||
func scanGame(scanner rowScanner, record *GameRecord, extra ...any) error {
|
||||
var whiteClaimed, whiteName, blackClaimed, blackName, result sql.NullString
|
||||
var endTime sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
dest := []any{
|
||||
&record.GameID, &record.InitialFEN,
|
||||
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime, &whiteClaimed,
|
||||
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime, &blackClaimed,
|
||||
&record.WhitePlayerID, &record.WhiteType, &record.WhiteLevel, &record.WhiteSearchTime,
|
||||
&whiteClaimed, &whiteName,
|
||||
&record.BlackPlayerID, &record.BlackType, &record.BlackLevel, &record.BlackSearchTime,
|
||||
&blackClaimed, &blackName,
|
||||
&result, &record.StartTimeUTC, &endTime,
|
||||
); err != nil {
|
||||
}
|
||||
if err := scanner.Scan(append(dest, extra...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
record.WhiteClaimedBy = whiteClaimed.String
|
||||
record.WhiteName = whiteName.String
|
||||
record.BlackClaimedBy = blackClaimed.String
|
||||
record.BlackName = blackName.String
|
||||
record.Result = result.String
|
||||
if endTime.Valid {
|
||||
ended := endTime.Time
|
||||
record.EndTimeUTC = &ended
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanGameSummary(scanner rowScanner, summary *GameSummaryRecord) error {
|
||||
var whiteClaimed, blackClaimed, result sql.NullString
|
||||
var endTime sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
&summary.GameID, &summary.InitialFEN,
|
||||
&summary.WhitePlayerID, &summary.WhiteType, &summary.WhiteLevel, &summary.WhiteSearchTime, &whiteClaimed,
|
||||
&summary.BlackPlayerID, &summary.BlackType, &summary.BlackLevel, &summary.BlackSearchTime, &blackClaimed,
|
||||
&result, &summary.StartTimeUTC, &endTime, &summary.MoveCount,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
summary.WhiteClaimedBy = whiteClaimed.String
|
||||
summary.BlackClaimedBy = blackClaimed.String
|
||||
summary.Result = result.String
|
||||
if endTime.Valid {
|
||||
ended := endTime.Time
|
||||
summary.EndTimeUTC = &ended
|
||||
}
|
||||
record.StartTimeUTC = record.StartTimeUTC.UTC()
|
||||
record.EndTimeUTC = utcPointer(endTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -439,13 +436,6 @@ func requireOneGame(result sql.Result, gameID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isValidResult(result string) bool {
|
||||
switch result {
|
||||
case "white_wins", "black_wins", "draw", "stalemate":
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package pgtest provisions isolated PostgreSQL schemas for tests.
|
||||
//
|
||||
// Set CHESS_TEST_DSN to a database the test role may create schemas in, e.g.
|
||||
//
|
||||
// CHESS_TEST_DSN='postgres://chess_test:secret@127.0.0.1:5432/chess_test?sslmode=disable'
|
||||
//
|
||||
// Tests that need a database are skipped when it is unset.
|
||||
package pgtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// EnvDSN names the environment variable holding the base test DSN.
|
||||
const EnvDSN = "CHESS_TEST_DSN"
|
||||
|
||||
// DSN creates an empty schema, registers its removal as test cleanup, and
|
||||
// returns the base DSN with search_path pinned to that schema.
|
||||
func DSN(t testing.TB) string {
|
||||
t.Helper()
|
||||
base := os.Getenv(EnvDSN)
|
||||
if base == "" {
|
||||
t.Skipf("%s is not set; skipping PostgreSQL test", EnvDSN)
|
||||
}
|
||||
|
||||
suffix := make([]byte, 6)
|
||||
rand.Read(suffix)
|
||||
schema := "chess_test_" + hex.EncodeToString(suffix)
|
||||
|
||||
admin, err := sql.Open("pgx", base)
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "CREATE SCHEMA "+schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create test schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
defer admin.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
|
||||
t.Errorf("drop test schema %s: %v", schema, err)
|
||||
}
|
||||
})
|
||||
return WithParam(base, "search_path", schema)
|
||||
}
|
||||
|
||||
// WithParam adds a runtime parameter to a URL or keyword/value DSN.
|
||||
func WithParam(dsn, key, value string) string {
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
u, err := url.Parse(dsn)
|
||||
if err == nil {
|
||||
query := u.Query()
|
||||
query.Set(key, value)
|
||||
u.RawQuery = query.Encode()
|
||||
return u.String()
|
||||
}
|
||||
}
|
||||
return dsn + " " + key + "=" + value
|
||||
}
|
||||
@@ -2,59 +2,62 @@ package storage
|
||||
|
||||
import "time"
|
||||
|
||||
// UserRecord represents a user account in the database
|
||||
// UserRecord represents a user account in the database. Accounts created by
|
||||
// public registration and by the CLI are identical and do not expire.
|
||||
type UserRecord struct {
|
||||
UserID string `db:"user_id"`
|
||||
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"`
|
||||
UserID string
|
||||
Username string
|
||||
Email string // empty when the account has no email (stored as NULL)
|
||||
PasswordHash string
|
||||
CreatedAt time.Time
|
||||
LastLoginAt *time.Time
|
||||
}
|
||||
|
||||
// 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"`
|
||||
SessionID string
|
||||
UserID string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// GameRecord represents a row in the games table
|
||||
type GameRecord struct {
|
||||
GameID string `db:"game_id"`
|
||||
InitialFEN string `db:"initial_fen"`
|
||||
WhitePlayerID string `db:"white_player_id"`
|
||||
WhiteType int `db:"white_type"`
|
||||
WhiteLevel int `db:"white_level"`
|
||||
WhiteSearchTime int `db:"white_search_time"`
|
||||
WhiteClaimedBy string `db:"white_claimed_by"`
|
||||
BlackPlayerID string `db:"black_player_id"`
|
||||
BlackType int `db:"black_type"`
|
||||
BlackLevel int `db:"black_level"`
|
||||
BlackSearchTime int `db:"black_search_time"`
|
||||
BlackClaimedBy string `db:"black_claimed_by"`
|
||||
Result string `db:"result"`
|
||||
StartTimeUTC time.Time `db:"start_time_utc"`
|
||||
EndTimeUTC *time.Time `db:"end_time_utc"`
|
||||
GameID string
|
||||
InitialFEN string
|
||||
WhitePlayerID string
|
||||
WhiteType int
|
||||
WhiteLevel int
|
||||
WhiteSearchTime int
|
||||
WhiteClaimedBy string
|
||||
WhiteName string // claimant's username when the claim was recorded
|
||||
BlackPlayerID string
|
||||
BlackType int
|
||||
BlackLevel int
|
||||
BlackSearchTime int
|
||||
BlackClaimedBy string
|
||||
BlackName string
|
||||
Result string
|
||||
StartTimeUTC time.Time
|
||||
EndTimeUTC *time.Time
|
||||
}
|
||||
|
||||
// GameSummaryRecord is a game row plus its replay extent. MoveCount and
|
||||
// FinalFEN come from the last move, so a picker needs no move-list read.
|
||||
type GameSummaryRecord struct {
|
||||
GameRecord
|
||||
MoveCount int `db:"move_count"`
|
||||
MoveCount int
|
||||
FinalFEN string
|
||||
}
|
||||
|
||||
// MoveRecord represents a row in the moves table
|
||||
type MoveRecord struct {
|
||||
MoveID int64 `db:"move_id"`
|
||||
GameID string `db:"game_id"`
|
||||
MoveNumber int `db:"move_number"`
|
||||
MoveUCI string `db:"move_uci"`
|
||||
FENAfterMove string `db:"fen_after_move"`
|
||||
PlayerColor string `db:"player_color"`
|
||||
MoveTimeUTC time.Time `db:"move_time_utc"`
|
||||
GameID string
|
||||
MoveNumber int
|
||||
MoveUCI string
|
||||
FENAfterMove string
|
||||
PlayerColor string
|
||||
MoveTimeUTC time.Time
|
||||
}
|
||||
|
||||
// MovePersistence groups changes caused by one accepted move so the move,
|
||||
@@ -67,72 +70,98 @@ type MovePersistence struct {
|
||||
EndTimeUTC *time.Time
|
||||
}
|
||||
|
||||
// Schema defines tables only. Indexes are applied after legacy column
|
||||
// migrations so upgrading an older games table never references a missing
|
||||
// column.
|
||||
const Schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
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
|
||||
// schemaVersion is the newest schema this binary understands. Migrations are
|
||||
// applied in order inside one transaction; PostgreSQL DDL is transactional, so
|
||||
// a failed upgrade leaves the previous version intact.
|
||||
const schemaVersion = 1
|
||||
|
||||
// migrations[v-1] upgrades the schema from version v-1 to v. Never edit a
|
||||
// released migration; append a new one.
|
||||
var migrations = []string{
|
||||
// v1: initial PostgreSQL schema.
|
||||
//
|
||||
// Identifiers are uuid (16 bytes, validated on input). Usernames and emails
|
||||
// are stored lowercase, so plain unique constraints give case-insensitive
|
||||
// uniqueness without citext or a nondeterministic collation. Game-to-user
|
||||
// association is by claim: every authenticated human slot records its
|
||||
// claimant, and claims survive player reconfiguration. The claimant's
|
||||
// username is copied into the game when the claim is written, so replays
|
||||
// and PGN keep the name after a rename or account deletion. Games with no
|
||||
// claim belong to anonymous players and are purged after inactivity.
|
||||
`
|
||||
CREATE TABLE schema_version (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
version integer NOT NULL CHECK (version > 0),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
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 TABLE users (
|
||||
user_id uuid PRIMARY KEY,
|
||||
username text NOT NULL,
|
||||
email text,
|
||||
password_hash text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_login_at timestamptz,
|
||||
CONSTRAINT users_username_key UNIQUE (username),
|
||||
CONSTRAINT users_email_key UNIQUE (email),
|
||||
CONSTRAINT users_username_check
|
||||
CHECK (username = lower(username) AND char_length(username) BETWEEN 1 AND 64),
|
||||
CONSTRAINT users_email_check
|
||||
CHECK (email = lower(email) AND char_length(email) BETWEEN 3 AND 254)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
game_id TEXT PRIMARY KEY,
|
||||
initial_fen TEXT NOT NULL,
|
||||
white_player_id TEXT NOT NULL,
|
||||
white_type INTEGER NOT NULL,
|
||||
white_level INTEGER NOT NULL DEFAULT 0,
|
||||
white_search_time INTEGER NOT NULL DEFAULT 1000,
|
||||
black_player_id TEXT NOT NULL,
|
||||
black_type INTEGER NOT NULL,
|
||||
black_level INTEGER NOT NULL DEFAULT 0,
|
||||
black_search_time INTEGER NOT NULL DEFAULT 1000,
|
||||
start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
result TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate')),
|
||||
end_time_utc DATETIME,
|
||||
white_claimed_by TEXT,
|
||||
black_claimed_by TEXT
|
||||
CREATE TABLE sessions (
|
||||
session_id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users (user_id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
CONSTRAINT sessions_user_id_key UNIQUE (user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS moves (
|
||||
move_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id TEXT NOT NULL,
|
||||
move_number INTEGER NOT NULL,
|
||||
move_uci TEXT NOT NULL,
|
||||
fen_after_move TEXT NOT NULL,
|
||||
player_color TEXT NOT NULL CHECK(player_color IN ('w', 'b')),
|
||||
move_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
UNIQUE(game_id, move_number)
|
||||
CREATE TABLE games (
|
||||
game_id uuid PRIMARY KEY,
|
||||
initial_fen text NOT NULL CHECK (initial_fen <> ''),
|
||||
white_player_id uuid NOT NULL,
|
||||
white_type smallint NOT NULL CHECK (white_type IN (1, 2)),
|
||||
white_level smallint NOT NULL DEFAULT 0 CHECK (white_level BETWEEN 0 AND 20),
|
||||
white_search_time integer NOT NULL DEFAULT 0 CHECK (white_search_time >= 0),
|
||||
white_claimed_by uuid,
|
||||
white_name text CHECK (char_length(white_name) BETWEEN 1 AND 64),
|
||||
black_player_id uuid NOT NULL,
|
||||
black_type smallint NOT NULL CHECK (black_type IN (1, 2)),
|
||||
black_level smallint NOT NULL DEFAULT 0 CHECK (black_level BETWEEN 0 AND 20),
|
||||
black_search_time integer NOT NULL DEFAULT 0 CHECK (black_search_time >= 0),
|
||||
black_claimed_by uuid,
|
||||
black_name text CHECK (char_length(black_name) BETWEEN 1 AND 64),
|
||||
start_time_utc timestamptz NOT NULL DEFAULT now(),
|
||||
result text CHECK (result IN ('white_wins', 'black_wins', 'draw', 'stalemate')),
|
||||
end_time_utc timestamptz,
|
||||
CONSTRAINT games_result_end_time_check CHECK ((result IS NULL) = (end_time_utc IS NULL))
|
||||
);
|
||||
`
|
||||
|
||||
const Indexes = `
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
|
||||
ON users(email) WHERE email IS NOT NULL AND email != '';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_temp_created_at
|
||||
ON users(created_at) WHERE account_type = 'temp';
|
||||
CREATE INDEX IF NOT EXISTS idx_users_temp_expires_at
|
||||
ON users(expires_at) WHERE account_type = 'temp' AND expires_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_white_player ON games(white_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_black_player ON games(black_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_white_claimed ON games(white_claimed_by)
|
||||
-- Moves are numbered 1..n without gaps; the primary key serves ordered replay
|
||||
-- reads and the last-move probe used by game listings.
|
||||
CREATE TABLE moves (
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
move_number integer NOT NULL CHECK (move_number > 0),
|
||||
move_uci text NOT NULL CHECK (move_uci ~ '^[a-h][1-8][a-h][1-8][qrbn]?$'),
|
||||
fen_after_move text NOT NULL CHECK (fen_after_move <> ''),
|
||||
player_color text NOT NULL CHECK (player_color IN ('w', 'b')),
|
||||
move_time_utc timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (game_id, move_number)
|
||||
);
|
||||
|
||||
CREATE INDEX sessions_expires_at_idx ON sessions (expires_at);
|
||||
-- Candidates for the anonymous-game purge: a game that started after the
|
||||
-- cutoff cannot have been idle since it.
|
||||
CREATE INDEX games_anonymous_start_idx ON games (start_time_utc)
|
||||
WHERE white_claimed_by IS NULL AND black_claimed_by IS NULL;
|
||||
CREATE INDEX games_white_claimed_idx ON games (white_claimed_by, start_time_utc DESC, game_id DESC)
|
||||
WHERE white_claimed_by IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_games_black_claimed ON games(black_claimed_by)
|
||||
CREATE INDEX games_black_claimed_idx ON games (black_claimed_by, start_time_utc DESC, game_id DESC)
|
||||
WHERE black_claimed_by IS NOT NULL;
|
||||
`
|
||||
`,
|
||||
}
|
||||
|
||||
// ownedTables lists every table created by migrations, children first.
|
||||
var ownedTables = []string{"moves", "games", "sessions", "users", "schema_version"}
|
||||
@@ -1,20 +1,30 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateSession creates or replaces the session for a user (single session per user)
|
||||
// CreateSession creates or replaces the user's single session and records the
|
||||
// login time in the same statement, so a login is one round trip.
|
||||
func (s *Store) CreateSession(record SessionRecord) error {
|
||||
const query = `INSERT INTO sessions (session_id, user_id, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
created_at = excluded.created_at,
|
||||
expires_at = excluded.expires_at`
|
||||
if _, err := s.db.Exec(query, record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt); err != nil {
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
const query = `WITH session AS (
|
||||
INSERT INTO sessions (session_id, user_id, created_at, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
session_id = excluded.session_id,
|
||||
created_at = excluded.created_at,
|
||||
expires_at = excluded.expires_at
|
||||
RETURNING user_id
|
||||
)
|
||||
UPDATE users SET last_login_at = $3 WHERE user_id = (SELECT user_id FROM session)`
|
||||
if _, err := s.db.ExecContext(ctx, query,
|
||||
record.SessionID, record.UserID, record.CreatedAt, record.ExpiresAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
slog.Debug("storage session created", "user_id", record.UserID, "expires_at", record.ExpiresAt)
|
||||
@@ -23,56 +33,44 @@ func (s *Store) CreateSession(record SessionRecord) error {
|
||||
|
||||
// GetSession retrieves a session by ID
|
||||
func (s *Store) GetSession(sessionID string) (*SessionRecord, error) {
|
||||
if !validUUID(sessionID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
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,
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT session_id, user_id, created_at, expires_at FROM sessions WHERE session_id = $1`,
|
||||
sessionID,
|
||||
).Scan(&session.SessionID, &session.UserID, &session.CreatedAt, &session.ExpiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session.CreatedAt = session.CreatedAt.UTC()
|
||||
session.ExpiresAt = session.ExpiresAt.UTC()
|
||||
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
|
||||
// DeleteSession removes a session (logout). Deleting an absent session is not
|
||||
// an error.
|
||||
func (s *Store) DeleteSession(sessionID string) error {
|
||||
query := `DELETE FROM sessions WHERE session_id = ?`
|
||||
_, err := s.db.Exec(query, sessionID)
|
||||
if err == nil {
|
||||
slog.Debug("storage session deleted")
|
||||
if !validUUID(sessionID) {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
if err == nil {
|
||||
slog.Debug("storage user sessions deleted", "user_id", userID)
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE session_id = $1`, sessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
slog.Debug("storage session deleted")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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())
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < $1`, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -83,29 +81,20 @@ func (s *Store) DeleteExpiredSessions() (int64, error) {
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
// IsSessionValid checks if a session exists and is not expired
|
||||
func (s *Store) IsSessionValid(sessionID string) (bool, error) {
|
||||
var valid bool
|
||||
const query = `SELECT EXISTS(
|
||||
SELECT 1 FROM sessions WHERE session_id = ? AND expires_at > ?
|
||||
)`
|
||||
err := s.db.QueryRow(query, sessionID, time.Now().UTC()).Scan(&valid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// IsSessionValidForUser verifies both expiry and the binding between a JWT
|
||||
// subject and its persisted session. Checking only the session ID would allow
|
||||
// a malformed server-issued token to authenticate as the wrong subject.
|
||||
func (s *Store) IsSessionValidForUser(sessionID, userID string) (bool, error) {
|
||||
if !validUUID(sessionID) || !validUUID(userID) {
|
||||
return false, nil
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
var valid bool
|
||||
const query = `SELECT EXISTS(
|
||||
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(
|
||||
SELECT 1 FROM sessions
|
||||
WHERE session_id = ? AND user_id = ? AND expires_at > ?
|
||||
)`
|
||||
err := s.db.QueryRow(query, sessionID, userID, time.Now().UTC()).Scan(&valid)
|
||||
WHERE session_id = $1 AND user_id = $2 AND expires_at > $3
|
||||
)`, sessionID, userID, time.Now().UTC()).Scan(&valid)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
+256
-173
@@ -3,25 +3,43 @@ package storage
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
const (
|
||||
writeQueueCapacity = 1000
|
||||
flushTimeout = 5 * time.Second
|
||||
schemaVersion = 2
|
||||
)
|
||||
queryTimeout = 5 * time.Second
|
||||
writeTimeout = 10 * time.Second
|
||||
migrationTimeout = time.Minute
|
||||
|
||||
var memoryStoreCounter atomic.Uint64
|
||||
// A transient failure (PostgreSQL restart, dropped connection) is retried
|
||||
// before storage degrades. Backoff doubles from writeRetryBase, bounding
|
||||
// the stall of the ordered writer to roughly three seconds.
|
||||
writeAttempts = 6
|
||||
writeRetryBase = 100 * time.Millisecond
|
||||
|
||||
maxOpenConns = 10
|
||||
maxIdleConns = 5
|
||||
|
||||
// schemaLockKey serializes migrations between the server and the CLI.
|
||||
// Advisory locks are scoped to the current database.
|
||||
schemaLockKey int64 = 0x6368657373 // "chess"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrStorageDegraded = errors.New("storage is degraded")
|
||||
@@ -32,14 +50,14 @@ var (
|
||||
type writeRequest struct {
|
||||
operation string
|
||||
gameID string
|
||||
run func(*sql.Tx) error
|
||||
run func(context.Context, *sql.Tx) error
|
||||
barrier chan error
|
||||
}
|
||||
|
||||
// Store handles SQLite database operations with async writes for games and sync writes for auth
|
||||
// Store handles PostgreSQL operations with ordered async writes for gameplay
|
||||
// and synchronous writes for accounts and sessions.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
writeChan chan writeRequest
|
||||
healthStatus atomic.Bool
|
||||
writeFailed atomic.Bool
|
||||
@@ -52,68 +70,55 @@ type Store struct {
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// NewStore creates a new storage instance with async writer
|
||||
func NewStore(dataSourceName string, devMode bool) (*Store, error) {
|
||||
dsn := sqliteDSN(dataSourceName)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
// NewStore connects to PostgreSQL and starts the async writer. dsn is a
|
||||
// libpq-style keyword/value string or postgres:// URL; unset fields fall back
|
||||
// to the standard PG* environment variables and ~/.pgpass. The schema is
|
||||
// resolved through search_path, so the role's default or a search_path DSN
|
||||
// parameter selects it.
|
||||
func NewStore(dsn string) (*Store, error) {
|
||||
config, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
// Parse errors may echo the DSN, which can contain a password.
|
||||
return nil, errors.New("invalid database connection string")
|
||||
}
|
||||
if _, ok := config.RuntimeParams["application_name"]; !ok {
|
||||
config.RuntimeParams["application_name"] = "chess-server"
|
||||
}
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
db := stdlib.OpenDB(*config)
|
||||
db.SetMaxOpenConns(maxOpenConns)
|
||||
db.SetMaxIdleConns(maxIdleConns)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), queryTimeout)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// SQLite benefits from a small pool. WAL and busy_timeout are configured in
|
||||
// the DSN for every connection, unlike connection-local PRAGMA calls.
|
||||
db.SetMaxOpenConns(8)
|
||||
db.SetMaxIdleConns(4)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
writerCtx, writerCancel := context.WithCancel(context.Background())
|
||||
s := &Store{
|
||||
db: db,
|
||||
path: dataSourceName,
|
||||
writeChan: make(chan writeRequest, writeQueueCapacity),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
ctx: writerCtx,
|
||||
cancel: writerCancel,
|
||||
}
|
||||
|
||||
// Initialize health as true
|
||||
s.healthStatus.Store(true)
|
||||
|
||||
// Start async writer
|
||||
s.wg.Add(1)
|
||||
go s.writerLoop()
|
||||
slog.Debug("storage opened",
|
||||
"path", dataSourceName,
|
||||
"dev_mode", devMode,
|
||||
"host", config.Host,
|
||||
"database", config.Database,
|
||||
"user", config.User,
|
||||
"write_queue_capacity", writeQueueCapacity,
|
||||
"max_open_connections", 8,
|
||||
"max_open_connections", maxOpenConns,
|
||||
)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func sqliteDSN(dataSourceName string) string {
|
||||
if dataSourceName == ":memory:" {
|
||||
// Each Store needs a private shared-cache database: shared cache keeps
|
||||
// that Store's pooled connections on one database, while the unique name
|
||||
// prevents independent in-memory stores from leaking into each other.
|
||||
dataSourceName = fmt.Sprintf(
|
||||
"file:chess-memory-%d?mode=memory&cache=shared",
|
||||
memoryStoreCounter.Add(1),
|
||||
)
|
||||
}
|
||||
separator := "?"
|
||||
if strings.Contains(dataSourceName, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
return dataSourceName + separator +
|
||||
"_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL"
|
||||
}
|
||||
|
||||
// IsHealthy returns true if the storage is operational
|
||||
func (s *Store) IsHealthy() bool {
|
||||
return s.healthStatus.Load()
|
||||
@@ -176,49 +181,63 @@ func (s *Store) handleWrite(req writeRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// executeWrite runs a transactional write operation
|
||||
// executeWrite runs one logical mutation as one transaction. A transient error
|
||||
// raised before COMMIT is retried: the transaction was rolled back, so a retry
|
||||
// cannot duplicate it, and the single writer preserves ordering while it waits.
|
||||
// A COMMIT error has an unknown outcome and is never retried. Any unrecovered
|
||||
// failure degrades storage permanently, because skipping one write would leave
|
||||
// later writes applied to an incomplete history.
|
||||
func (s *Store) executeWrite(req writeRequest) error {
|
||||
started := time.Now()
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: failed to begin transaction",
|
||||
"operation", req.operation, "game_id", req.gameID, "error", err)
|
||||
return err
|
||||
var err error
|
||||
for attempt := 1; ; attempt++ {
|
||||
var committing bool
|
||||
committing, err = s.runWriteTx(req.run)
|
||||
if err == nil {
|
||||
slog.Debug("storage write committed",
|
||||
"operation", req.operation,
|
||||
"game_id", req.gameID,
|
||||
"attempts", attempt,
|
||||
"duration", time.Since(started),
|
||||
"queue_depth", len(s.writeChan),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if committing || attempt >= writeAttempts || !isTransient(err) {
|
||||
break
|
||||
}
|
||||
backoff := writeRetryBase << (attempt - 1)
|
||||
slog.Warn("storage write failed transiently; retrying",
|
||||
"operation", req.operation, "game_id", req.gameID,
|
||||
"attempt", attempt, "backoff", backoff, "error", err)
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
|
||||
if err := req.run(tx); err != nil {
|
||||
rollbackErr := tx.Rollback()
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: write operation failed",
|
||||
"operation", req.operation,
|
||||
"game_id", req.gameID,
|
||||
"error", err,
|
||||
"rollback_error", rollbackErr,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: failed to commit",
|
||||
"operation", req.operation, "game_id", req.gameID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("storage write committed",
|
||||
"operation", req.operation,
|
||||
"game_id", req.gameID,
|
||||
"duration", time.Since(started),
|
||||
"queue_depth", len(s.writeChan),
|
||||
)
|
||||
return nil
|
||||
s.writeFailed.Store(true)
|
||||
s.healthStatus.Store(false)
|
||||
slog.Error("storage degraded: write operation failed",
|
||||
"operation", req.operation, "game_id", req.gameID, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) enqueue(operation, gameID string, fn func(*sql.Tx) error) error {
|
||||
func (s *Store) runWriteTx(run func(context.Context, *sql.Tx) error) (committing bool, err error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
||||
defer cancel()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
if err := run(ctx, tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return true, fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *Store) enqueue(operation, gameID string, fn func(context.Context, *sql.Tx) error) error {
|
||||
s.enqueueMu.RLock()
|
||||
defer s.enqueueMu.RUnlock()
|
||||
|
||||
@@ -285,7 +304,7 @@ func (s *Store) flushBeforeRead() error {
|
||||
return s.Flush(ctx)
|
||||
}
|
||||
|
||||
// Close gracefully closes the database connection
|
||||
// Close drains accepted writes and closes the connection pool.
|
||||
func (s *Store) Close() error {
|
||||
s.closeOnce.Do(func() {
|
||||
s.enqueueMu.Lock()
|
||||
@@ -301,125 +320,189 @@ func (s *Store) Close() error {
|
||||
return s.closeErr
|
||||
}
|
||||
|
||||
// InitDB creates the database schema
|
||||
// InitDB applies pending migrations. It holds a transaction-scoped advisory
|
||||
// lock so concurrent migrators serialize, and issues no DDL when the schema is
|
||||
// current: a runtime role limited to DML can start the server once an owner
|
||||
// role has migrated the schema.
|
||||
func (s *Store) InitDB() error {
|
||||
started := time.Now()
|
||||
tx, err := s.db.Begin()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), migrationTimeout)
|
||||
defer cancel()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var currentVersion int
|
||||
if err := tx.QueryRow("PRAGMA user_version").Scan(¤tVersion); err != nil {
|
||||
return fmt.Errorf("failed to read schema version: %w", err)
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, schemaLockKey); err != nil {
|
||||
return fmt.Errorf("failed to lock schema: %w", err)
|
||||
}
|
||||
if currentVersion > schemaVersion {
|
||||
return fmt.Errorf(
|
||||
"database schema version %d is newer than supported version %d",
|
||||
currentVersion,
|
||||
schemaVersion,
|
||||
)
|
||||
current, err := readSchemaVersion(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current > schemaVersion {
|
||||
return fmt.Errorf("database schema version %d is newer than supported version %d",
|
||||
current, schemaVersion)
|
||||
}
|
||||
if current == schemaVersion {
|
||||
slog.Debug("storage schema current", "version", current)
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(Schema); err != nil {
|
||||
return fmt.Errorf("failed to create schema: %w", err)
|
||||
}
|
||||
|
||||
columns := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{"result", "TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate'))"},
|
||||
{"end_time_utc", "DATETIME"},
|
||||
{"white_claimed_by", "TEXT"},
|
||||
{"black_claimed_by", "TEXT"},
|
||||
}
|
||||
for _, column := range columns {
|
||||
if err := ensureColumn(tx, "games", column.name, column.definition); err != nil {
|
||||
return err
|
||||
for version := current + 1; version <= schemaVersion; version++ {
|
||||
if _, err := tx.ExecContext(ctx, migrations[version-1]); err != nil {
|
||||
return fmt.Errorf("failed to apply schema version %d: %w", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
// These indexes duplicate UNIQUE constraints or are superseded by targeted
|
||||
// partial/composite indexes. Drop them during upgrades as well as omitting
|
||||
// them from new databases.
|
||||
for _, name := range []string{
|
||||
"idx_users_username",
|
||||
"idx_users_email",
|
||||
"idx_users_account_type",
|
||||
"idx_users_expires_at",
|
||||
"idx_sessions_user_id",
|
||||
"idx_moves_game_id",
|
||||
"idx_games_finished_end_time",
|
||||
} {
|
||||
if _, err := tx.Exec("DROP INDEX IF EXISTS " + name); err != nil {
|
||||
return fmt.Errorf("failed to remove redundant index %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(Indexes); err != nil {
|
||||
return fmt.Errorf("failed to create indexes: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", schemaVersion)); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO schema_version (version) VALUES ($1)
|
||||
ON CONFLICT (singleton) DO UPDATE SET version = excluded.version, updated_at = now()`,
|
||||
schemaVersion,
|
||||
); err != nil {
|
||||
return fmt.Errorf("failed to record schema version: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit schema: %w", err)
|
||||
}
|
||||
slog.Debug("storage schema ready", "version", schemaVersion, "duration", time.Since(started))
|
||||
slog.Info("storage schema migrated", "from", current, "to", schemaVersion,
|
||||
"duration", time.Since(started))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureColumn(tx *sql.Tx, table, column, definition string) error {
|
||||
rows, err := tx.Query("PRAGMA table_info(" + table + ")")
|
||||
// SchemaVersion reports the version recorded in the database; 0 means no
|
||||
// schema has been created in the current search_path.
|
||||
func (s *Store) SchemaVersion() (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), queryTimeout)
|
||||
defer cancel()
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
return readSchemaVersion(ctx, tx)
|
||||
}
|
||||
|
||||
found := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, columnType string
|
||||
var notNull, primaryKey int
|
||||
var defaultValue any
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("failed to inspect %s column: %w", table, err)
|
||||
}
|
||||
if name == column {
|
||||
found = true
|
||||
func readSchemaVersion(ctx context.Context, tx *sql.Tx) (int, error) {
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT to_regclass('schema_version') IS NOT NULL`,
|
||||
).Scan(&exists); err != nil {
|
||||
return 0, fmt.Errorf("failed to inspect schema: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return 0, nil
|
||||
}
|
||||
var version int
|
||||
err := tx.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&version)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read schema version: %w", err)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// DropSchema removes every chess table from the current search_path. It is
|
||||
// destructive and requires ownership of the tables.
|
||||
func (s *Store) DropSchema() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), migrationTimeout)
|
||||
defer cancel()
|
||||
// RESTRICT (the default) refuses to drop objects that others depend on,
|
||||
// such as an administrator's reporting view.
|
||||
_, err := s.db.ExecContext(ctx, `DROP TABLE IF EXISTS `+strings.Join(ownedTables, ", "))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop schema: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// opContext bounds a synchronous operation so a stalled database cannot hold a
|
||||
// request handler indefinitely.
|
||||
func opContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), queryTimeout)
|
||||
}
|
||||
|
||||
// isTransient reports errors after which the same transaction may succeed on a
|
||||
// fresh connection.
|
||||
func isTransient(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "40001", // serialization_failure
|
||||
"40P01", // deadlock_detected
|
||||
"57P01", // admin_shutdown
|
||||
"57P02", // crash_shutdown
|
||||
"57P03": // cannot_connect_now
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(pgErr.Code, "08") // connection_exception class
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close %s schema rows: %w", table, err)
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return false
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
|
||||
var connectErr *pgconn.ConnectError
|
||||
var netErr net.Error
|
||||
return errors.As(err, &connectErr) ||
|
||||
errors.As(err, &netErr) ||
|
||||
errors.Is(err, driver.ErrBadConn) ||
|
||||
errors.Is(err, io.EOF) ||
|
||||
errors.Is(err, io.ErrUnexpectedEOF) ||
|
||||
pgconn.SafeToRetry(err)
|
||||
}
|
||||
|
||||
// uniqueViolation returns the violated constraint name for SQLSTATE 23505.
|
||||
func uniqueViolation(err error) (string, bool) {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return pgErr.ConstraintName, true
|
||||
}
|
||||
if found {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// validUUID accepts the canonical 36-character form. Lookups treat anything
|
||||
// else as not found instead of sending PostgreSQL a value that fails to cast.
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
_, err := uuid.Parse(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func nullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := tx.Exec("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition); err != nil {
|
||||
return fmt.Errorf("failed to add %s.%s: %w", table, column, err)
|
||||
}
|
||||
slog.Debug("storage schema column added", "table", table, "column", column)
|
||||
return nil
|
||||
return value
|
||||
}
|
||||
|
||||
// DeleteDB removes the database file
|
||||
func (s *Store) DeleteDB() error {
|
||||
// Close connection first
|
||||
if err := s.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close database: %w", err)
|
||||
func utcPointer(value sql.NullTime) *time.Time {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
t := value.Time.UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
// ☣ DESTRUCTIVE: Removes database file
|
||||
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to delete database file: %w", err)
|
||||
// ErrSchemaMismatch reports a database whose schema is absent or at a version
|
||||
// other than the one this binary supports.
|
||||
var ErrSchemaMismatch = errors.New("database schema version mismatch")
|
||||
|
||||
// CheckSchema verifies that the schema is current without changing it.
|
||||
func (s *Store) CheckSchema() error {
|
||||
version, err := s.SchemaVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case version == 0:
|
||||
return fmt.Errorf("%w: schema not initialized (run 'chess-server db init')", ErrSchemaMismatch)
|
||||
case version != schemaVersion:
|
||||
return fmt.Errorf("%w: database has version %d, binary supports %d",
|
||||
ErrSchemaMismatch, version, schemaVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+122
-229
@@ -5,76 +5,22 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserAlreadyExists = errors.New("username or email already exists")
|
||||
ErrUserCapacity = errors.New("user capacity reached")
|
||||
ErrPermanentCapacity = errors.New("permanent user capacity reached")
|
||||
)
|
||||
|
||||
// UserLimits defines registration constraints
|
||||
// userCreateLockKey serializes account creation across processes (server and
|
||||
// CLI) so the capacity check cannot be raced past.
|
||||
const userCreateLockKey int64 = 0x6368657375 // "chesu"
|
||||
|
||||
const userSelectColumns = `user_id, username, email, password_hash, created_at, last_login_at`
|
||||
|
||||
// UserLimits defines public-registration constraints.
|
||||
type UserLimits struct {
|
||||
MaxUsers int
|
||||
PermanentSlots int
|
||||
}
|
||||
|
||||
// DefaultUserLimits returns default POC limits
|
||||
func DefaultUserLimits() UserLimits {
|
||||
return UserLimits{
|
||||
MaxUsers: 100,
|
||||
PermanentSlots: 10,
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserCounts returns current user counts by type
|
||||
func (s *Store) GetUserCounts() (total, permanent, temp int, err error) {
|
||||
query := `SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END) as permanent,
|
||||
COUNT(CASE WHEN account_type = 'temp' THEN 1 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
|
||||
var email sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users
|
||||
WHERE account_type = 'temp'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`
|
||||
|
||||
err := s.db.QueryRow(query).Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// DeleteExpiredTempUsers removes temporary users past their expiry
|
||||
func (s *Store) DeleteExpiredTempUsers() (int64, error) {
|
||||
query := `DELETE FROM users
|
||||
WHERE account_type = 'temp' AND expires_at IS NOT NULL AND expires_at < ?`
|
||||
result, err := s.db.Exec(query, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err == nil && deleted > 0 {
|
||||
slog.Debug("storage expired temporary users deleted", "count", deleted)
|
||||
}
|
||||
return deleted, err
|
||||
MaxUsers int // total accounts at which registration closes
|
||||
}
|
||||
|
||||
// CreateUser creates an administratively managed user without applying the
|
||||
@@ -83,10 +29,9 @@ func (s *Store) CreateUser(record UserRecord) error {
|
||||
return s.createUser(record, nil, nil)
|
||||
}
|
||||
|
||||
// CreateUserWithinLimits atomically applies registration limits, evicts the
|
||||
// oldest temporary account when required, creates the user, and optionally
|
||||
// creates its initial session. No account is evicted on a duplicate request,
|
||||
// and a session failure rolls back the user and eviction together.
|
||||
// CreateUserWithinLimits atomically applies the registration limit, creates
|
||||
// the user, and optionally creates its initial session. A session failure
|
||||
// rolls back the account.
|
||||
func (s *Store) CreateUserWithinLimits(
|
||||
record UserRecord,
|
||||
session *SessionRecord,
|
||||
@@ -96,69 +41,55 @@ func (s *Store) CreateUserWithinLimits(
|
||||
}
|
||||
|
||||
func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *UserLimits) error {
|
||||
tx, err := s.db.Begin()
|
||||
if session != nil && session.UserID != record.UserID {
|
||||
return errors.New("initial session user does not match new user")
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Check uniqueness within transaction
|
||||
exists, err := s.userExists(tx, record.Username, record.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, userCreateLockKey); err != nil {
|
||||
return fmt.Errorf("lock user creation: %w", err)
|
||||
}
|
||||
|
||||
// Report a duplicate as such even when registration is full. The advisory
|
||||
// lock orders this check with concurrent creations; the unique constraints
|
||||
// still catch a race with renames.
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(
|
||||
SELECT 1 FROM users WHERE username = lower($1) OR email = lower($2)
|
||||
)`, record.Username, nullableString(record.Email)).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("check user uniqueness: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return ErrUserAlreadyExists
|
||||
}
|
||||
|
||||
if limits != nil {
|
||||
var total, permanent int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*),
|
||||
COUNT(CASE WHEN account_type = 'permanent' THEN 1 END)
|
||||
FROM users`).Scan(&total, &permanent); err != nil {
|
||||
if limits != nil && limits.MaxUsers > 0 {
|
||||
var total int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&total); err != nil {
|
||||
return fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
if record.AccountType == "permanent" && permanent >= limits.PermanentSlots {
|
||||
return ErrPermanentCapacity
|
||||
}
|
||||
if total >= limits.MaxUsers {
|
||||
result, err := tx.Exec(`DELETE FROM users WHERE user_id = (
|
||||
SELECT user_id FROM users
|
||||
WHERE account_type = 'temp'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("evict oldest temporary user: %w", err)
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect temporary user eviction: %w", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
return ErrUserCapacity
|
||||
}
|
||||
return ErrUserCapacity
|
||||
}
|
||||
}
|
||||
|
||||
// Insert user
|
||||
query := `INSERT INTO users (
|
||||
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.AccountType, record.CreatedAt, record.ExpiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO users (
|
||||
user_id, username, email, password_hash, created_at
|
||||
) VALUES ($1, lower($2), lower($3), $4, $5)`,
|
||||
record.UserID, record.Username, nullableString(record.Email),
|
||||
record.PasswordHash, record.CreatedAt,
|
||||
); err != nil {
|
||||
return mapUserConflict(err)
|
||||
}
|
||||
if session != nil {
|
||||
if session.UserID != record.UserID {
|
||||
return errors.New("initial session user does not match new user")
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO sessions (session_id, user_id, created_at, expires_at) VALUES ($1, $2, $3, $4)`,
|
||||
session.SessionID, session.UserID, session.CreatedAt, session.ExpiresAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create initial session: %w", err)
|
||||
@@ -168,78 +99,74 @@ func (s *Store) createUser(record UserRecord, session *SessionRecord, limits *Us
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Debug("storage user created",
|
||||
"user_id", record.UserID,
|
||||
"account_type", record.AccountType,
|
||||
"initial_session", session != nil,
|
||||
)
|
||||
slog.Debug("storage user created", "user_id", record.UserID, "initial_session", session != nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err == nil {
|
||||
slog.Debug("storage user deleted", "user_id", userID)
|
||||
// mapUserConflict converts a username/email uniqueness violation, including one
|
||||
// lost to a concurrent writer, into ErrUserAlreadyExists.
|
||||
func mapUserConflict(err error) error {
|
||||
if constraint, ok := uniqueViolation(err); ok &&
|
||||
(constraint == "users_username_key" || constraint == "users_email_key") {
|
||||
return ErrUserAlreadyExists
|
||||
}
|
||||
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
|
||||
query := `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE`
|
||||
args := []any{username}
|
||||
|
||||
if email != "" {
|
||||
query = `SELECT COUNT(*) FROM users
|
||||
WHERE username = ? COLLATE NOCASE
|
||||
OR (email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '')`
|
||||
args = append(args, email)
|
||||
// DeleteUser removes a user synchronously; sessions cascade, while games keep
|
||||
// the claim ID and name snapshot. It returns sql.ErrNoRows when no such user
|
||||
// exists.
|
||||
func (s *Store) DeleteUser(userID string) error {
|
||||
if err := s.execUserUpdate(`DELETE FROM users WHERE user_id = $1`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := tx.QueryRow(query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
slog.Debug("storage user deleted", "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUserPassword updates user password hash
|
||||
func (s *Store) UpdateUserPassword(userID string, passwordHash string) error {
|
||||
query := `UPDATE users SET password_hash = ? WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, passwordHash, userID)
|
||||
return err
|
||||
func (s *Store) UpdateUserPassword(userID, passwordHash string) error {
|
||||
return s.execUserUpdate(`UPDATE users SET password_hash = $2 WHERE user_id = $1`,
|
||||
userID, passwordHash)
|
||||
}
|
||||
|
||||
// UpdateUserEmail updates user email
|
||||
func (s *Store) UpdateUserEmail(userID string, email string) error {
|
||||
query := `UPDATE users SET email = ? WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, email, userID)
|
||||
return err
|
||||
// UpdateUserEmail updates the email; an empty value removes it.
|
||||
func (s *Store) UpdateUserEmail(userID, email string) error {
|
||||
return s.execUserUpdate(`UPDATE users SET email = lower($2) WHERE user_id = $1`,
|
||||
userID, nullableString(email))
|
||||
}
|
||||
|
||||
// UpdateUserUsername updates username
|
||||
func (s *Store) UpdateUserUsername(userID string, username string) error {
|
||||
query := `UPDATE users SET username = ? WHERE user_id = ?`
|
||||
_, err := s.db.Exec(query, username, userID)
|
||||
return err
|
||||
func (s *Store) UpdateUserUsername(userID, username string) error {
|
||||
return s.execUserUpdate(`UPDATE users SET username = lower($2) WHERE user_id = $1`,
|
||||
userID, username)
|
||||
}
|
||||
|
||||
// GetAllUsers retrieves all users
|
||||
func (s *Store) GetAllUsers() ([]UserRecord, error) {
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users ORDER BY created_at DESC`
|
||||
// execUserUpdate runs a single-row statement keyed by user_id ($1).
|
||||
func (s *Store) execUserUpdate(query, userID string, args ...any) error {
|
||||
if !validUUID(userID) {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
result, err := s.db.ExecContext(ctx, query, append([]any{userID}, args...)...)
|
||||
if err != nil {
|
||||
return mapUserConflict(err)
|
||||
}
|
||||
if rows, err := result.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(query)
|
||||
// GetAllUsers retrieves all users, newest first
|
||||
func (s *Store) GetAllUsers() ([]UserRecord, error) {
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+userSelectColumns+` FROM users ORDER BY created_at DESC, user_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -248,91 +175,57 @@ func (s *Store) GetAllUsers() ([]UserRecord, error) {
|
||||
var users []UserRecord
|
||||
for rows.Next() {
|
||||
var user UserRecord
|
||||
var email sql.NullString
|
||||
err := rows.Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err := scanUser(rows, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// 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
|
||||
// GetUserByUsername retrieves a user by case-insensitive username.
|
||||
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var email sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users WHERE username = ? COLLATE NOCASE`
|
||||
|
||||
err := s.db.QueryRow(query, username).Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
return &user, nil
|
||||
return s.getUser(`username = lower($1)`, username)
|
||||
}
|
||||
|
||||
// GetUserByEmail retrieves user by email with case-insensitive matching
|
||||
// GetUserByEmail retrieves a user by case-insensitive email.
|
||||
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var emailNull sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != ''`
|
||||
|
||||
err := s.db.QueryRow(query, email).Scan(
|
||||
&user.UserID, &user.Username, &emailNull,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if email == "" {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
user.Email = emailNull.String
|
||||
return &user, nil
|
||||
return s.getUser(`email = lower($1)`, email)
|
||||
}
|
||||
|
||||
// GetUserByID retrieves user by unique user ID
|
||||
func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
|
||||
var user UserRecord
|
||||
var email sql.NullString
|
||||
query := `SELECT user_id, username, email, password_hash, account_type, created_at, expires_at, last_login_at
|
||||
FROM users WHERE user_id = ?`
|
||||
if !validUUID(userID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return s.getUser(`user_id = $1`, userID)
|
||||
}
|
||||
|
||||
err := s.db.QueryRow(query, userID).Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.AccountType, &user.CreatedAt,
|
||||
&user.ExpiresAt, &user.LastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
func (s *Store) getUser(predicate string, arg any) (*UserRecord, error) {
|
||||
ctx, cancel := opContext()
|
||||
defer cancel()
|
||||
var user UserRecord
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+userSelectColumns+` FROM users WHERE `+predicate, arg)
|
||||
if err := scanUser(row, &user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.Email = email.String
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user synchronously. Account operations are consistency
|
||||
// sensitive and should not be reported successful before SQLite commits them.
|
||||
func (s *Store) DeleteUser(userID string) error {
|
||||
return s.DeleteUserByID(userID)
|
||||
func scanUser(scanner rowScanner, user *UserRecord) error {
|
||||
var email sql.NullString
|
||||
var lastLoginAt sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
&user.UserID, &user.Username, &email,
|
||||
&user.PasswordHash, &user.CreatedAt, &lastLoginAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
user.Email = email.String
|
||||
user.CreatedAt = user.CreatedAt.UTC()
|
||||
user.LastLoginAt = utcPointer(lastLoginAt)
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user