e9f836db87
Tests · Go / test (push) Successful in 9s
Tests · Integration / integration (push) Successful in 10s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Service-agnostic refinement of the owner's idea: the sign-in service returns a set of supported game languages with the user identity, and the lobby gates the New Game variant choice by it (en -> English; ru -> Russian + Эрудит). - Connector hosts two bots in one container (one per service language, each its own token + game channel; the same telegram_id spans both). ValidateInitData tries each token and returns the validating bot's service_language + supported_languages. Per-language config (TELEGRAM_BOT_TOKEN_EN/_RU, channels). - supported_languages rides the Session (fbs, session-scoped, not persisted); the UI offers only the matching variants on New Game — gating only the START of a new game (auto-match + friend invite), not accept/open/play; backend does not enforce. - service_language persisted (accounts.service_language, migration 00010, written every login, last-login-wins) and routes the user-facing Notify push back through the right bot (push-target coalesces with preferred_language). - Admin SendToUser/SendToGameChannel gain an operator-chosen language selector in the console (unrelated to ValidateInitData). - Non-Telegram logins carry the gateway default set (GATEWAY_DEFAULT_SUPPORTED_LANGUAGES, all variants). Wire (committed regen): ValidateInitDataResponse +service_language +supported_languages; Session +supported_languages; SendToUser/SendToGameChannel +language. Docs (ARCHITECTURE/FUNCTIONAL/_ru/READMEs) + PLAN updated; stage marked done.
205 lines
6.9 KiB
Go
205 lines
6.9 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/account"
|
|
)
|
|
|
|
// The /api/v1/internal/sessions/* endpoints are gateway-only: the gateway has
|
|
// already validated the originating credential (Telegram initData, an email
|
|
// code, or a guest bootstrap) and forwards the result here to provision the
|
|
// account and mint the opaque session. The backend trusts the gateway on this
|
|
// segment (docs/ARCHITECTURE.md §12).
|
|
|
|
// telegramAuthRequest carries the identity the connector extracted from a
|
|
// validated initData payload. Username, FirstName and LanguageCode seed a
|
|
// brand-new account's display name and language (first contact only).
|
|
// ServiceLanguage is the validating bot's language tag (en/ru); it is recorded on
|
|
// every login (the bot the user last came through) and routes their out-of-app push.
|
|
type telegramAuthRequest struct {
|
|
ExternalID string `json:"external_id"`
|
|
Username string `json:"username"`
|
|
FirstName string `json:"first_name"`
|
|
LanguageCode string `json:"language_code"`
|
|
ServiceLanguage string `json:"service_language"`
|
|
}
|
|
|
|
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
|
// identity and mints a session for it, seeding a new account's display name and
|
|
// language from the supplied Telegram fields and recording the validating bot's
|
|
// service language (updated every login) so out-of-app push routes to that bot.
|
|
func (s *Server) handleTelegramAuth(c *gin.Context) {
|
|
var req telegramAuthRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
|
abortBadRequest(c, "external_id is required")
|
|
return
|
|
}
|
|
acc, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
if err := s.accounts.SetServiceLanguage(c.Request.Context(), acc.ID, req.ServiceLanguage); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.mintSession(c, acc)
|
|
}
|
|
|
|
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
|
type pushTargetRequest struct {
|
|
UserID string `json:"user_id"`
|
|
}
|
|
|
|
// pushTargetResponse carries what the gateway needs to route an out-of-app push:
|
|
// the recipient's Telegram external_id (empty when they have no Telegram
|
|
// identity, e.g. a guest or email-only account), the language that both selects the
|
|
// delivering bot and renders the message (the account's service language, the bot
|
|
// it last signed in through, falling back to its preferred language), and whether
|
|
// they confined notifications to the in-app stream.
|
|
type pushTargetResponse struct {
|
|
ExternalID string `json:"external_id"`
|
|
Language string `json:"language"`
|
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
|
}
|
|
|
|
// handlePushTarget resolves a user id to the data the gateway needs to deliver an
|
|
// out-of-app Telegram notification — the gateway-only internal counterpart of the
|
|
// in-app push stream. A user with no Telegram identity yields an empty external_id,
|
|
// which the gateway treats as "no out-of-app channel".
|
|
func (s *Server) handlePushTarget(c *gin.Context) {
|
|
var req pushTargetRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.UserID == "" {
|
|
abortBadRequest(c, "user_id is required")
|
|
return
|
|
}
|
|
uid, err := uuid.Parse(req.UserID)
|
|
if err != nil {
|
|
abortBadRequest(c, "user_id must be a uuid")
|
|
return
|
|
}
|
|
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
ext, err := s.accounts.IdentityExternalID(c.Request.Context(), uid, account.KindTelegram)
|
|
if err != nil && !errors.Is(err, account.ErrNotFound) {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
// Route by the bot the user last signed in through; fall back to the interface
|
|
// language for an account that has never come through a tagged bot.
|
|
language := acc.ServiceLanguage
|
|
if language == "" {
|
|
language = acc.PreferredLanguage
|
|
}
|
|
c.JSON(http.StatusOK, pushTargetResponse{
|
|
ExternalID: ext,
|
|
Language: language,
|
|
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
|
})
|
|
}
|
|
|
|
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session.
|
|
func (s *Server) handleGuestAuth(c *gin.Context) {
|
|
acc, err := s.accounts.ProvisionGuest(c.Request.Context())
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.mintSession(c, acc)
|
|
}
|
|
|
|
// emailRequest is an email-login code request.
|
|
type emailRequest struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// handleEmailRequest issues a login confirm-code to the email. It always reports
|
|
// success once the address is well-formed, so the response does not reveal
|
|
// whether an account already exists.
|
|
func (s *Server) handleEmailRequest(c *gin.Context) {
|
|
var req emailRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.Email == "" {
|
|
abortBadRequest(c, "email is required")
|
|
return
|
|
}
|
|
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
|
}
|
|
|
|
// emailLoginRequest verifies an email login code.
|
|
type emailLoginRequest struct {
|
|
Email string `json:"email"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// handleEmailLogin verifies the code and mints a session for the owning account.
|
|
func (s *Server) handleEmailLogin(c *gin.Context) {
|
|
var req emailLoginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.Email == "" || req.Code == "" {
|
|
abortBadRequest(c, "email and code are required")
|
|
return
|
|
}
|
|
acc, err := s.emails.LoginWithCode(c.Request.Context(), req.Email, req.Code)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
s.mintSession(c, acc)
|
|
}
|
|
|
|
// tokenRequest carries an opaque session token.
|
|
type tokenRequest struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// handleResolveSession resolves a token to its account id. The gateway calls it
|
|
// on a session-cache miss.
|
|
func (s *Server) handleResolveSession(c *gin.Context) {
|
|
var req tokenRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
|
|
abortBadRequest(c, "token is required")
|
|
return
|
|
}
|
|
sess, err := s.sessions.Resolve(c.Request.Context(), req.Token)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resolveResponse{UserID: sess.AccountID.String()})
|
|
}
|
|
|
|
// handleRevokeSession revokes the session for a token (idempotent).
|
|
func (s *Server) handleRevokeSession(c *gin.Context) {
|
|
var req tokenRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
|
|
abortBadRequest(c, "token is required")
|
|
return
|
|
}
|
|
if err := s.sessions.Revoke(c.Request.Context(), req.Token); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
|
}
|
|
|
|
// mintSession creates a session for acc and writes the credential response.
|
|
func (s *Server) mintSession(c *gin.Context, acc account.Account) {
|
|
token, _, err := s.sessions.Create(c.Request.Context(), acc.ID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, sessionResponseFor(token, acc))
|
|
}
|