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/email/confirm-link", s.handleEmailConfirmLink) 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) u.POST("/link/unlink", s.handleUnlink) // Change the account's confirmed email: mail a code to the new address, then // atomically switch on confirm (a new address owned by another account is // refused without disclosure, never merged). u.POST("/link/email/change/request", s.handleChangeEmailRequest) u.POST("/link/email/change/confirm", s.handleChangeEmailConfirm) } 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, account.ErrLastIdentity): return http.StatusConflict, "last_identity" 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" } }