v0.7.0 cli client with readline added, directory structure updated
This commit is contained in:
232
internal/server/http/auth.go
Normal file
232
internal/server/http/auth.go
Normal file
@ -0,0 +1,232 @@
|
||||
// FILE: lixenwraith/chess/internal/server/http/auth.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"chess/internal/server/core"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{1,40}$`)
|
||||
|
||||
// RegisterRequest defines the user registration payload
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" validate:"required,min=1,max=40"`
|
||||
Email string `json:"email" validate:"omitempty,max=255"`
|
||||
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||
}
|
||||
|
||||
// LoginRequest defines the authentication payload
|
||||
type LoginRequest struct {
|
||||
Identifier string `json:"identifier" validate:"required"` // username or email
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
// AuthResponse contains JWT token and user information
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
// UserResponse contains current user information
|
||||
type UserResponse struct {
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// RegisterHandler creates a new user account
|
||||
func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
|
||||
var req RegisterRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
if !usernameRegex.MatchString(req.Username) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid username format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "username must be 1-40 characters, alphanumeric and underscore only",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate email format if provided
|
||||
if req.Email != "" && !emailRegex.MatchString(req.Email) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid email format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "email must be a valid email address",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if err := validatePassword(req.Password); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "weak password",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Normalize for case-insensitive storage
|
||||
req.Username = strings.ToLower(req.Username)
|
||||
if req.Email != "" {
|
||||
req.Email = strings.ToLower(req.Email)
|
||||
}
|
||||
|
||||
// Create user
|
||||
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{
|
||||
Error: "user already exists",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "username or email already taken",
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to create user",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := h.svc.GenerateUserToken(user.UserID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to generate token",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(AuthResponse{
|
||||
Token: token,
|
||||
UserID: user.UserID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
// validatePassword checks password strength requirements
|
||||
func validatePassword(password string) error {
|
||||
const (
|
||||
minPasswordLength = 8
|
||||
maxPasswordLength = 128
|
||||
)
|
||||
if len(password) < minPasswordLength {
|
||||
return fmt.Errorf("password must be at least 8 characters")
|
||||
}
|
||||
if len(password) > maxPasswordLength {
|
||||
return fmt.Errorf("password must not exceed 128 characters")
|
||||
}
|
||||
|
||||
// Check for at least one letter and one number
|
||||
hasLetter := false
|
||||
hasNumber := false
|
||||
for _, r := range password {
|
||||
switch {
|
||||
case unicode.IsLetter(r):
|
||||
hasLetter = true
|
||||
case unicode.IsNumber(r):
|
||||
hasNumber = true
|
||||
}
|
||||
if hasLetter && hasNumber {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasLetter || !hasNumber {
|
||||
return fmt.Errorf("password must contain at least one letter and one number")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoginHandler authenticates user and returns JWT token
|
||||
func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
|
||||
var req LoginRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Normalize identifier for case-insensitive lookup
|
||||
req.Identifier = strings.ToLower(req.Identifier)
|
||||
|
||||
// Authenticate user
|
||||
user, err := h.svc.AuthenticateUser(req.Identifier, req.Password)
|
||||
if err != nil {
|
||||
// Always return same error to prevent user enumeration
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid credentials",
|
||||
Code: core.ErrInvalidRequest,
|
||||
})
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := h.svc.GenerateUserToken(user.UserID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to generate token",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Update last login
|
||||
// TODO: for now, non-blocking if login time update fails, log/block in the future
|
||||
_ = h.svc.UpdateLastLogin(user.UserID)
|
||||
|
||||
return c.JSON(AuthResponse{
|
||||
Token: token,
|
||||
UserID: user.UserID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCurrentUserHandler returns authenticated user information
|
||||
func (h *HTTPHandler) GetCurrentUserHandler(c *fiber.Ctx) error {
|
||||
userID, ok := c.Locals("userID").(string)
|
||||
if !ok || userID == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "unauthorized",
|
||||
Code: core.ErrInvalidRequest,
|
||||
})
|
||||
}
|
||||
|
||||
user, err := h.svc.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
|
||||
Error: "user not found",
|
||||
Code: core.ErrInvalidRequest,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(UserResponse{
|
||||
UserID: user.UserID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
CreatedAt: user.CreatedAt,
|
||||
})
|
||||
}
|
||||
515
internal/server/http/handler.go
Normal file
515
internal/server/http/handler.go
Normal file
@ -0,0 +1,515 @@
|
||||
// FILE: lixenwraith/chess/internal/server/http/handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/processor"
|
||||
"chess/internal/server/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/recover"
|
||||
)
|
||||
|
||||
const rateLimitRate = 10 // req/sec
|
||||
|
||||
// HTTPHandler handles HTTP requests and routes them to the processor
|
||||
type HTTPHandler struct {
|
||||
proc *processor.Processor
|
||||
svc *service.Service
|
||||
}
|
||||
|
||||
func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandler {
|
||||
return &HTTPHandler{proc: proc, svc: svc}
|
||||
}
|
||||
|
||||
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(proc, svc)
|
||||
|
||||
// Initialize Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
ErrorHandler: customErrorHandler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
})
|
||||
|
||||
// Global middleware (order matters)
|
||||
app.Use(recover.New())
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} ${status} ${method} ${path} ${latency}\n",
|
||||
}))
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
||||
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
|
||||
}))
|
||||
|
||||
// Health check (no rate limit)
|
||||
app.Get("/health", h.Health)
|
||||
|
||||
// API v1 routes
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
// Auth routes with specific rate limiting
|
||||
auth := api.Group("/auth")
|
||||
|
||||
// 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()
|
||||
},
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
Code: core.ErrRateLimitExceeded,
|
||||
Details: "5 registrations per minute allowed",
|
||||
})
|
||||
},
|
||||
}), h.RegisterHandler)
|
||||
|
||||
// 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()
|
||||
},
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
Code: core.ErrRateLimitExceeded,
|
||||
Details: "10 login attempts per minute allowed",
|
||||
})
|
||||
},
|
||||
}), h.LoginHandler)
|
||||
|
||||
// Create token validator closure
|
||||
validateToken := svc.ValidateToken
|
||||
|
||||
// Current user (requires auth)
|
||||
auth.Get("/me", AuthRequired(validateToken), h.GetCurrentUserHandler)
|
||||
|
||||
// Game routes with standard rate limiting
|
||||
maxReq := rateLimitRate
|
||||
if devMode {
|
||||
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()
|
||||
},
|
||||
LimitReached: func(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||
Error: "rate limit exceeded",
|
||||
Code: core.ErrRateLimitExceeded,
|
||||
Details: fmt.Sprintf("%d requests per second allowed", maxReq),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
// Content-Type validation for POST and PUT requests
|
||||
api.Use(contentTypeValidator)
|
||||
|
||||
// Middleware validation for sanitization
|
||||
api.Use(validationMiddleware)
|
||||
|
||||
// Register game routes with auth middleware
|
||||
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
|
||||
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
||||
api.Get("/games/:gameId", h.GetGame)
|
||||
api.Delete("/games/:gameId", h.DeleteGame)
|
||||
api.Post("/games/:gameId/moves", h.MakeMove)
|
||||
api.Post("/games/:gameId/undo", h.UndoMove)
|
||||
api.Get("/games/:gameId/board", h.GetBoard)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// contentTypeValidator ensures POST and PUT requests have application/json
|
||||
func contentTypeValidator(c *fiber.Ctx) error {
|
||||
method := c.Method()
|
||||
if method == fiber.MethodPost || method == fiber.MethodPut {
|
||||
contentType := c.Get("Content-Type")
|
||||
if contentType != "application/json" && contentType != "" {
|
||||
return c.Status(fiber.StatusUnsupportedMediaType).JSON(core.ErrorResponse{
|
||||
Error: "unsupported media type",
|
||||
Code: core.ErrInvalidContent,
|
||||
Details: "Content-Type must be application/json",
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// customErrorHandler provides consistent error responses
|
||||
func customErrorHandler(c *fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
response := core.ErrorResponse{
|
||||
Error: "internal server error",
|
||||
Code: core.ErrInternalError,
|
||||
}
|
||||
|
||||
// Check if it's a Fiber error
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
response.Error = e.Message
|
||||
|
||||
// Map HTTP status to error codes
|
||||
switch code {
|
||||
case fiber.StatusNotFound:
|
||||
response.Code = core.ErrGameNotFound
|
||||
case fiber.StatusBadRequest:
|
||||
response.Code = core.ErrInvalidRequest
|
||||
case fiber.StatusTooManyRequests:
|
||||
response.Code = core.ErrRateLimitExceeded
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(code).JSON(response)
|
||||
}
|
||||
|
||||
// Health check endpoint with storage status
|
||||
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "healthy",
|
||||
"time": time.Now().Unix(),
|
||||
"storage": h.svc.GetStorageHealth(),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateGame creates a new game with specified player types
|
||||
func (h *HTTPHandler) CreateGame(c *fiber.Ctx) error {
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.CreateGameRequest
|
||||
req = *(validatedBody.(*core.CreateGameRequest))
|
||||
|
||||
// Retrieve authenticated user ID if available
|
||||
userID, _ := c.Locals("userID").(string)
|
||||
|
||||
// Generate game ID via service with optional user context
|
||||
cmd := processor.NewCreateGameCommand(req)
|
||||
cmd.UserID = userID // Add user ID to command if authenticated
|
||||
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(resp.Data)
|
||||
}
|
||||
|
||||
// ConfigurePlayers updates player configuration mid-game
|
||||
func (h *HTTPHandler) ConfigurePlayers(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.ConfigurePlayersRequest
|
||||
req = *(validatedBody.(*core.ConfigurePlayersRequest))
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewConfigurePlayersCommand(gameID, req)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
statusCode := fiber.StatusBadRequest
|
||||
if resp.Error.Code == core.ErrGameNotFound {
|
||||
statusCode = fiber.StatusNotFound
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// GetGame retrieves current game state
|
||||
func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Check for long-polling parameters
|
||||
waitStr := c.Query("wait", "false")
|
||||
moveCountStr := c.Query("moveCount", "-1")
|
||||
|
||||
// Non-wait path - existing behavior
|
||||
if waitStr != "true" {
|
||||
// Create command and execute
|
||||
cmd := processor.NewGetGameCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// Long-polling path
|
||||
moveCount, err := strconv.Atoi(moveCountStr)
|
||||
if err != nil {
|
||||
moveCount = -1
|
||||
}
|
||||
|
||||
// First check if game exists and get current state
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
|
||||
Error: "game not found",
|
||||
Code: core.ErrGameNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
currentMoveCount := len(g.Moves())
|
||||
|
||||
// If move count already different, return immediately
|
||||
if moveCount != currentMoveCount {
|
||||
cmd := processor.NewGetGameCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
}
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// Register wait with service
|
||||
ctx := c.Context()
|
||||
notify := h.svc.RegisterWait(gameID, moveCount, ctx)
|
||||
|
||||
// Wait for notification, timeout, or client disconnect
|
||||
select {
|
||||
case <-notify:
|
||||
// State changed or timeout, get fresh game state
|
||||
cmd := processor.NewGetGameCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Game might have been deleted
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
|
||||
case <-ctx.Done():
|
||||
// Client disconnected
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MakeMove submits a move
|
||||
func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.MoveRequest
|
||||
req = *(validatedBody.(*core.MoveRequest))
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewMakeMoveCommand(gameID, req)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response with correct status code
|
||||
if !resp.Success {
|
||||
statusCode := fiber.StatusBadRequest
|
||||
if resp.Error.Code == core.ErrGameNotFound {
|
||||
statusCode = fiber.StatusNotFound
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// UndoMove undoes one or more moves
|
||||
func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure middleware validation ran
|
||||
validated, ok := c.Locals("validated").(bool)
|
||||
if !ok || !validated {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation bypass detected",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Retrieve validated parsed body
|
||||
validatedBody := c.Locals("validatedBody")
|
||||
if validatedBody == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "validation data missing",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
var req core.UndoRequest
|
||||
req = *(validatedBody.(*core.UndoRequest))
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewUndoMoveCommand(gameID, req)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
statusCode := fiber.StatusBadRequest
|
||||
if resp.Error.Code == core.ErrGameNotFound {
|
||||
statusCode = fiber.StatusNotFound
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// DeleteGame ends and cleans up a game
|
||||
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewDeleteGameCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetBoard returns ASCII representation of the board
|
||||
func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
// Validate UUID format
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
// Create command and execute
|
||||
cmd := processor.NewGetBoardCommand(gameID)
|
||||
resp := h.proc.Execute(cmd)
|
||||
|
||||
// Return appropriate HTTP response
|
||||
if !resp.Success {
|
||||
return c.Status(fiber.StatusNotFound).JSON(resp.Error)
|
||||
}
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
62
internal/server/http/middleware.go
Normal file
62
internal/server/http/middleware.go
Normal file
@ -0,0 +1,62 @@
|
||||
// FILE: lixenwraith/chess/internal/server/http/middleware.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"chess/internal/server/core"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// TokenValidator validates JWT tokens
|
||||
type TokenValidator func(token string) (userID string, claims map[string]any, err error)
|
||||
|
||||
// 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 == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "missing authorization token",
|
||||
Code: core.ErrInvalidRequest,
|
||||
})
|
||||
}
|
||||
|
||||
userID, _, err := validateToken(token)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid or expired token",
|
||||
Code: core.ErrInvalidRequest,
|
||||
})
|
||||
}
|
||||
|
||||
c.Locals("userID", userID)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth validates JWT if present but allows anonymous access
|
||||
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
token := extractBearerToken(c.Get("Authorization"))
|
||||
if token == "" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
userID, _, err := validateToken(token)
|
||||
if err == nil {
|
||||
c.Locals("userID", userID)
|
||||
}
|
||||
// Continue regardless of token validity
|
||||
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)
|
||||
}
|
||||
102
internal/server/http/validator.go
Normal file
102
internal/server/http/validator.go
Normal file
@ -0,0 +1,102 @@
|
||||
// FILE: lixenwraith/chess/internal/server/http/handler.go
|
||||
package http
|
||||
|
||||
import (
|
||||
"chess/internal/server/core"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Add validator instance near top of file
|
||||
var validate = validator.New()
|
||||
|
||||
// Add custom validation middleware function
|
||||
func validationMiddleware(c *fiber.Ctx) error {
|
||||
// Skip validation for GET, DELETE, OPTIONS
|
||||
method := c.Method()
|
||||
if method == fiber.MethodGet || method == fiber.MethodDelete || method == fiber.MethodOptions {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Determine request type based on path
|
||||
path := c.Path()
|
||||
var requestType interface{}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/games") && method == fiber.MethodPost:
|
||||
requestType = &core.CreateGameRequest{}
|
||||
case strings.HasSuffix(path, "/players") && method == fiber.MethodPut:
|
||||
requestType = &core.ConfigurePlayersRequest{}
|
||||
case strings.HasSuffix(path, "/moves") && method == fiber.MethodPost:
|
||||
requestType = &core.MoveRequest{}
|
||||
case strings.HasSuffix(path, "/undo") && method == fiber.MethodPost:
|
||||
requestType = &core.UndoRequest{}
|
||||
default:
|
||||
return c.Next() // No validation for unknown endpoints
|
||||
}
|
||||
|
||||
// Parse body
|
||||
if err := c.BodyParser(requestType); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid request body",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate
|
||||
if errs := validate.Struct(requestType); errs != nil {
|
||||
var details strings.Builder
|
||||
for _, err := range errs.(validator.ValidationErrors) {
|
||||
if details.Len() > 0 {
|
||||
details.WriteString("; ")
|
||||
}
|
||||
switch err.Tag() {
|
||||
case "required":
|
||||
details.WriteString(fmt.Sprintf("%s is required", err.Field()))
|
||||
case "oneof":
|
||||
details.WriteString(fmt.Sprintf("%s must be one of [%s]", err.Field(), err.Param()))
|
||||
case "min":
|
||||
if err.Type().Kind() == reflect.String {
|
||||
details.WriteString(fmt.Sprintf("%s must be at least %s characters", err.Field(), err.Param()))
|
||||
} else {
|
||||
details.WriteString(fmt.Sprintf("%s must be at least %s", err.Field(), err.Param()))
|
||||
}
|
||||
case "max":
|
||||
if err.Type().Kind() == reflect.String {
|
||||
details.WriteString(fmt.Sprintf("%s must be at most %s characters", err.Field(), err.Param()))
|
||||
} else {
|
||||
details.WriteString(fmt.Sprintf("%s must be at most %s", err.Field(), err.Param()))
|
||||
}
|
||||
case "omitempty": // Skip, a control tag that doesn't error
|
||||
continue
|
||||
case "dive": // Skip, panics on wrong type, no error handling since current code does not call validator on slice or map
|
||||
continue
|
||||
default:
|
||||
details.WriteString(fmt.Sprintf("%s failed %s validation", err.Field(), err.Tag()))
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "validation failed",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Details: details.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// Store validated body for handler use
|
||||
c.Locals("validatedBody", requestType)
|
||||
c.Locals("validated", true)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
func isValidUUID(s string) bool {
|
||||
_, err := uuid.Parse(s)
|
||||
return err == nil
|
||||
}
|
||||
Reference in New Issue
Block a user