b918217497
Unlink: POST /user/link/unlink (telegram|vk) via account.RemoveIdentity, refusing
the last identity; email is never unlinked. New fbs LinkUnlinkRequest + gateway
link.unlink op, returning the refreshed profile.
Change-email: purposeChange confirm-codes (RequestChangeCode/ConfirmChange) that
atomically replace the account's confirmed email (account.replaceEmailIdentity);
a new address owned by another account is refused without disclosure, never merged.
The one-tap deeplink handles purposeChange too. Reuses the LinkEmail* fbs tables;
gateway link.email.change.{request,confirm} ops + backendclient methods; branded
ru/en change-email copy.
296 lines
13 KiB
Go
296 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/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"
|
|
}
|
|
}
|