65689b903f
backend: REST handlers for pass/exchange/resign/hint/evaluate/check_word/complaint/history/chat-list/nudge + new game.ListForAccount (my games) + seat display_name resolution pkg/fbs: GameActionRequest/ExchangeRequest/EvalRequest/EvalResult/CheckWordRequest/WordCheckResult/ComplaintRequest/HintResult/History/GameList/ChatList + SeatView.display_name; committed Go regenerated (flatc 23.5.26) gateway: 11 new transcode ops + backendclient methods + FB encoders ui: edge TS codegen (flatc --ts + protoc-gen-es, committed), FlatBuffers<->model codec, real connect-web transport (binary, bearer auth, Subscribe). prod bundle ~69KB gzip JS
145 lines
5.6 KiB
Go
145 lines
5.6 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/lobby"
|
|
"scrabble/backend/internal/session"
|
|
"scrabble/backend/internal/social"
|
|
)
|
|
|
|
// registerRoutes wires the Stage 6 REST handlers onto the /api/v1 groups. The
|
|
// internal group is gateway-only (the gateway authenticates and forwards); the
|
|
// user group requires X-User-ID; the admin group is reached through the gateway's
|
|
// Basic-Auth proxy. This is the representative vertical slice — further domain
|
|
// operations follow the same pattern (PLAN.md Stage 6).
|
|
func (s *Server) registerRoutes() {
|
|
if s.sessions != nil && s.accounts != nil {
|
|
in := s.internal
|
|
in.POST("/sessions/telegram", s.handleTelegramAuth)
|
|
in.POST("/sessions/guest", s.handleGuestAuth)
|
|
in.POST("/sessions/email/request", s.handleEmailRequest)
|
|
in.POST("/sessions/email/login", s.handleEmailLogin)
|
|
in.POST("/sessions/resolve", s.handleResolveSession)
|
|
in.POST("/sessions/revoke", s.handleRevokeSession)
|
|
}
|
|
u := s.user
|
|
if s.accounts != nil {
|
|
u.GET("/profile", s.handleProfile)
|
|
}
|
|
if s.games != nil {
|
|
u.GET("/games", s.handleListGames)
|
|
u.POST("/games/:id/play", s.handleSubmitPlay)
|
|
u.GET("/games/:id/state", s.handleGameState)
|
|
u.POST("/games/:id/pass", s.handlePass)
|
|
u.POST("/games/:id/exchange", s.handleExchange)
|
|
u.POST("/games/:id/resign", s.handleResign)
|
|
u.POST("/games/:id/hint", s.handleHint)
|
|
u.POST("/games/:id/evaluate", s.handleEvaluate)
|
|
u.GET("/games/:id/check_word", s.handleCheckWord)
|
|
u.POST("/games/:id/complaint", s.handleComplaint)
|
|
u.GET("/games/:id/history", s.handleHistory)
|
|
}
|
|
if s.matchmaker != nil {
|
|
u.POST("/lobby/enqueue", s.handleEnqueue)
|
|
u.GET("/lobby/poll", s.handlePoll)
|
|
}
|
|
if s.social != nil {
|
|
u.POST("/games/:id/chat", s.handleChatPost)
|
|
u.GET("/games/:id/chat", s.handleChatList)
|
|
u.POST("/games/:id/nudge", s.handleNudge)
|
|
}
|
|
s.admin.GET("/ping", s.handleAdminPing)
|
|
}
|
|
|
|
// userID returns the authenticated account id stored by RequireUserID. The user
|
|
// group always runs that middleware, so absence is a programming error.
|
|
func userID(c *gin.Context) (uuid.UUID, bool) {
|
|
return UserIDFromContext(c.Request.Context())
|
|
}
|
|
|
|
// gameIDParam parses the :id path parameter as a game UUID.
|
|
func gameIDParam(c *gin.Context) (uuid.UUID, bool) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return uuid.UUID{}, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// clientIP returns the originating client IP the gateway forwarded in
|
|
// X-Forwarded-For (the first hop), falling back to the direct peer.
|
|
func clientIP(c *gin.Context) string {
|
|
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
|
|
if i := strings.IndexByte(xff, ','); i >= 0 {
|
|
return strings.TrimSpace(xff[:i])
|
|
}
|
|
return strings.TrimSpace(xff)
|
|
}
|
|
return c.ClientIP()
|
|
}
|
|
|
|
// abortBadRequest rejects a malformed request body or parameter.
|
|
func abortBadRequest(c *gin.Context, msg string) {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "bad_request", Message: msg}})
|
|
}
|
|
|
|
// abortErr maps a domain error to its HTTP status and a stable code. Server-side
|
|
// (5xx) errors are logged with the real cause and reported generically.
|
|
func (s *Server) abortErr(c *gin.Context, err error) {
|
|
status, code := statusForError(err)
|
|
msg := err.Error()
|
|
if status >= http.StatusInternalServerError {
|
|
s.log.Error("request failed", zap.String("path", c.FullPath()), zap.Error(err))
|
|
msg = "internal error"
|
|
}
|
|
c.AbortWithStatusJSON(status, errorResponse{Error: errorBody{Code: code, Message: msg}})
|
|
}
|
|
|
|
// statusForError maps a known domain sentinel to an HTTP status and code,
|
|
// defaulting to 500/internal for anything unrecognised.
|
|
func statusForError(err error) (int, string) {
|
|
switch {
|
|
case errors.Is(err, game.ErrNotFound), errors.Is(err, account.ErrNotFound):
|
|
return http.StatusNotFound, "not_found"
|
|
case errors.Is(err, game.ErrNotAPlayer), errors.Is(err, social.ErrNotParticipant):
|
|
return http.StatusForbidden, "not_a_player"
|
|
case errors.Is(err, game.ErrNotYourTurn), errors.Is(err, social.ErrNudgeOnOwnTurn):
|
|
return http.StatusConflict, "not_your_turn"
|
|
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
|
|
return http.StatusConflict, "game_finished"
|
|
case errors.Is(err, lobby.ErrAlreadyQueued):
|
|
return http.StatusConflict, "already_queued"
|
|
case errors.Is(err, game.ErrInvalidConfig):
|
|
return http.StatusBadRequest, "invalid_config"
|
|
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft), errors.Is(err, game.ErrNoHintAvailable):
|
|
return http.StatusConflict, "hint_unavailable"
|
|
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
|
return http.StatusUnprocessableEntity, "illegal_play"
|
|
case errors.Is(err, account.ErrEmailTaken):
|
|
return http.StatusConflict, "email_taken"
|
|
case errors.Is(err, account.ErrInvalidEmail):
|
|
return http.StatusBadRequest, "invalid_email"
|
|
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
|
errors.Is(err, account.ErrNoPendingCode), errors.Is(err, account.ErrTooManyAttempts):
|
|
return http.StatusUnauthorized, "code_invalid"
|
|
case errors.Is(err, session.ErrNotFound):
|
|
return http.StatusUnauthorized, "session_invalid"
|
|
case errors.Is(err, social.ErrChatBlocked), errors.Is(err, social.ErrMessageTooLong),
|
|
errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent),
|
|
errors.Is(err, social.ErrNudgeTooSoon):
|
|
return http.StatusUnprocessableEntity, "chat_rejected"
|
|
default:
|
|
return http.StatusInternalServerError, "internal"
|
|
}
|
|
}
|