2207ac6132
Add an in-memory SendLimiter enforcing a one-per-minute cooldown and a five-per-rolling-hour cap per recipient address, checked before provisioning or sending in RequestCode, RequestLoginCode and RequestLinkCode. It guards against email bombing and protects the relay quota. The limiter is injected in main (nil in tests, so the domain suite is unaffected); ErrTooManyRequests maps to HTTP 429.
287 lines
13 KiB
Go
287 lines
13 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/accountmerge"
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/feedback"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/lobby"
|
|
"scrabble/backend/internal/session"
|
|
"scrabble/backend/internal/social"
|
|
)
|
|
|
|
// registerRoutes wires the 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.
|
|
func (s *Server) registerRoutes() {
|
|
if s.sessions != nil && s.accounts != nil {
|
|
in := s.internal
|
|
in.POST("/sessions/telegram", s.handleTelegramAuth)
|
|
in.POST("/sessions/vk", s.handleVKAuth)
|
|
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)
|
|
// Out-of-app push routing for the platform side-service: the
|
|
// gateway resolves a recipient's Telegram chat + language + in-app-only flag
|
|
// before delivering an out-of-app notification.
|
|
in.POST("/push-target", s.handlePushTarget)
|
|
}
|
|
if s.accounts != nil {
|
|
// Moderated-chat write eligibility for the Telegram bot: resolve a Telegram
|
|
// identity (the bot's join-time query) or an account id (a chat-access-changed
|
|
// event) to whether the user may write in the discussion chat. It needs only the
|
|
// account store, not the session service, so it registers independently.
|
|
s.internal.POST("/chat-access", s.handleChatAccess)
|
|
}
|
|
if s.ratewatch != nil {
|
|
// The gateway's periodic rate-limiter rejection summary: feeds the
|
|
// admin console's throttled view and the high-rate auto-flag.
|
|
s.internal.POST("/ratelimit/report", s.handleRateLimitReport)
|
|
}
|
|
if s.banview != nil {
|
|
// The gateway's periodic active-ban sync: feeds the admin console's
|
|
// active-bans panel and returns the operator's pending unbans.
|
|
s.internal.POST("/bans/sync", s.handleBanSync)
|
|
}
|
|
u := s.user
|
|
if s.accounts != nil {
|
|
u.GET("/profile", s.handleProfile)
|
|
u.PUT("/profile", s.handleUpdateProfile)
|
|
u.GET("/stats", s.handleStats)
|
|
// Exempt from the suspension gate (see requireNotSuspended): the one endpoint a blocked
|
|
// client may still reach, to fetch the block's expiry and reason for the blocked screen.
|
|
u.GET("/block-status", s.handleBlockStatus)
|
|
}
|
|
if s.links != nil {
|
|
// Account linking & merge. The request step always mails a code;
|
|
// a required merge is revealed only after the code is verified, and the
|
|
// irreversible merge is an explicit second step.
|
|
u.POST("/link/email/request", s.handleLinkEmailRequest)
|
|
u.POST("/link/email/confirm", s.handleLinkEmailConfirm)
|
|
u.POST("/link/email/merge", s.handleLinkEmailMerge)
|
|
u.POST("/link/telegram", s.handleLinkTelegram)
|
|
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
|
|
}
|
|
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)
|
|
u.GET("/games/:id/gcg", s.handleExportGCG)
|
|
u.GET("/games/:id/export-url", s.handleExportURL)
|
|
u.GET("/games/:id/draft", s.handleGetDraft)
|
|
u.PUT("/games/:id/draft", s.handleSaveDraft)
|
|
u.POST("/games/:id/hide", s.handleHideGame)
|
|
// Raw dictionary download for the client-side local move preview, keyed by
|
|
// the game's pinned (variant, version); immutable, so cached hard.
|
|
u.GET("/dict/:variant/:version", s.handleDictBytes)
|
|
// The signed finished-game export download — the only public data route:
|
|
// the URL's HMAC + expiry are the whole grant (export.go), because the
|
|
// platforms' native download calls carry no credentials.
|
|
s.public.GET("/dl/:id/:kind", s.handleExportDownload)
|
|
}
|
|
if s.feedback != nil {
|
|
u.POST("/feedback", s.handleFeedbackSubmit)
|
|
u.GET("/feedback", s.handleFeedbackState)
|
|
u.GET("/feedback/unread", s.handleFeedbackUnread)
|
|
}
|
|
if s.matchmaker != nil {
|
|
u.POST("/lobby/enqueue", s.handleEnqueue)
|
|
}
|
|
if s.invitations != nil {
|
|
u.GET("/invitations", s.handleListInvitations)
|
|
u.POST("/invitations", s.handleCreateInvitation)
|
|
u.POST("/invitations/:id/accept", s.handleAcceptInvitation)
|
|
u.POST("/invitations/:id/decline", s.handleDeclineInvitation)
|
|
u.DELETE("/invitations/:id", s.handleCancelInvitation)
|
|
}
|
|
if s.social != nil {
|
|
u.POST("/games/:id/chat", s.handleChatPost)
|
|
u.GET("/games/:id/chat", s.handleChatList)
|
|
u.POST("/games/:id/chat/read", s.handleChatRead)
|
|
u.POST("/games/:id/nudge", s.handleNudge)
|
|
u.GET("/friends", s.handleListFriends)
|
|
u.GET("/friends/incoming", s.handleIncomingRequests)
|
|
u.GET("/friends/outgoing", s.handleOutgoingRequests)
|
|
u.POST("/friends/request", s.handleFriendRequest)
|
|
u.POST("/friends/respond", s.handleFriendRespond)
|
|
u.POST("/friends/cancel", s.handleFriendCancel)
|
|
u.DELETE("/friends/:id", s.handleUnfriend)
|
|
u.POST("/friends/code", s.handleIssueFriendCode)
|
|
u.POST("/friends/code/redeem", s.handleRedeemFriendCode)
|
|
u.GET("/blocks", s.handleListBlocks)
|
|
u.POST("/blocks", s.handleBlock)
|
|
u.DELETE("/blocks/:id", s.handleUnblock)
|
|
}
|
|
}
|
|
|
|
// 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 != "" {
|
|
first, _, _ := strings.Cut(xff, ",")
|
|
return strings.TrimSpace(first)
|
|
}
|
|
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):
|
|
return http.StatusConflict, "not_your_turn"
|
|
case errors.Is(err, social.ErrNudgeOnOwnTurn):
|
|
return http.StatusConflict, "nudge_own_turn"
|
|
case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive):
|
|
return http.StatusConflict, "game_finished"
|
|
case errors.Is(err, social.ErrGameVsAI):
|
|
return http.StatusConflict, "ai_game"
|
|
case errors.Is(err, game.ErrNoOpponentYet):
|
|
return http.StatusConflict, "no_opponent_yet"
|
|
case errors.Is(err, game.ErrGameActive):
|
|
return http.StatusConflict, "game_active"
|
|
case errors.Is(err, game.ErrGameLimitReached):
|
|
return http.StatusConflict, "game_limit_reached"
|
|
case errors.Is(err, account.ErrInvalidProfile):
|
|
return http.StatusBadRequest, "invalid_profile"
|
|
case errors.Is(err, account.ErrAlreadyConfirmed):
|
|
return http.StatusConflict, "already_confirmed"
|
|
case errors.Is(err, lobby.ErrInvalidInvitation):
|
|
return http.StatusBadRequest, "invalid_invitation"
|
|
case errors.Is(err, lobby.ErrInvitationBlocked):
|
|
return http.StatusForbidden, "invitation_blocked"
|
|
case errors.Is(err, lobby.ErrInvitationNotFound):
|
|
return http.StatusNotFound, "invitation_not_found"
|
|
case errors.Is(err, lobby.ErrInvitationNotPending):
|
|
return http.StatusConflict, "invitation_not_pending"
|
|
case errors.Is(err, lobby.ErrInvitationExpired):
|
|
return http.StatusConflict, "invitation_expired"
|
|
case errors.Is(err, lobby.ErrNotInvited):
|
|
return http.StatusForbidden, "not_invited"
|
|
case errors.Is(err, lobby.ErrAlreadyResponded):
|
|
return http.StatusConflict, "already_responded"
|
|
case errors.Is(err, lobby.ErrNotInviter):
|
|
return http.StatusForbidden, "not_inviter"
|
|
case errors.Is(err, game.ErrInvalidConfig):
|
|
return http.StatusBadRequest, "invalid_config"
|
|
case errors.Is(err, game.ErrNoHintAvailable):
|
|
// No legal move for the rack — distinct from a budget/disabled hint so the UI
|
|
// can say "no options" (and the service spends nothing in this case).
|
|
return http.StatusConflict, "no_hint_available"
|
|
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
|
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), errors.Is(err, account.ErrIdentityTaken):
|
|
return http.StatusConflict, "email_taken"
|
|
case errors.Is(err, accountmerge.ErrActiveGameConflict):
|
|
return http.StatusConflict, "merge_active_game_conflict"
|
|
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, account.ErrTooManyRequests):
|
|
return http.StatusTooManyRequests, "too_many_requests"
|
|
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):
|
|
return http.StatusUnprocessableEntity, "chat_rejected"
|
|
case errors.Is(err, social.ErrNudgeTooSoon):
|
|
// A too-frequent nudge is a distinct, non-content rejection — the UI must say
|
|
// "don't rush the player so often", not the chat content-rejection message.
|
|
return http.StatusConflict, "nudge_too_soon"
|
|
case errors.Is(err, social.ErrChatNotYourTurn):
|
|
return http.StatusConflict, "chat_not_your_turn"
|
|
case errors.Is(err, social.ErrChatAlreadySentThisTurn):
|
|
return http.StatusConflict, "chat_already_sent"
|
|
case errors.Is(err, social.ErrSelfRelation):
|
|
return http.StatusBadRequest, "self_relation"
|
|
case errors.Is(err, social.ErrRequestExists):
|
|
return http.StatusConflict, "request_exists"
|
|
case errors.Is(err, social.ErrRequestBlocked):
|
|
return http.StatusForbidden, "request_blocked"
|
|
case errors.Is(err, social.ErrRequestNotFound):
|
|
return http.StatusNotFound, "request_not_found"
|
|
case errors.Is(err, social.ErrNoSharedGame):
|
|
return http.StatusForbidden, "no_shared_game"
|
|
case errors.Is(err, social.ErrRequestDeclined):
|
|
return http.StatusConflict, "request_declined"
|
|
case errors.Is(err, social.ErrFriendCodeInvalid):
|
|
return http.StatusUnprocessableEntity, "friend_code_invalid"
|
|
case errors.Is(err, feedback.ErrGuestForbidden):
|
|
return http.StatusForbidden, "feedback_guest_forbidden"
|
|
case errors.Is(err, feedback.ErrBanned):
|
|
return http.StatusForbidden, "feedback_banned"
|
|
case errors.Is(err, feedback.ErrPendingReview):
|
|
return http.StatusConflict, "feedback_pending"
|
|
case errors.Is(err, feedback.ErrEmptyMessage):
|
|
return http.StatusUnprocessableEntity, "feedback_empty"
|
|
case errors.Is(err, feedback.ErrMessageTooLong):
|
|
return http.StatusUnprocessableEntity, "feedback_too_long"
|
|
case errors.Is(err, feedback.ErrAttachmentTooLarge):
|
|
return http.StatusUnprocessableEntity, "feedback_attachment_too_large"
|
|
case errors.Is(err, feedback.ErrAttachmentType):
|
|
return http.StatusUnprocessableEntity, "feedback_attachment_type"
|
|
default:
|
|
return http.StatusInternalServerError, "internal"
|
|
}
|
|
}
|