da25eac070
When a guest links an identity (email/TG/VK) that already belongs to a durable account, the guest-primary rule already made the durable account the survivor and switched the session — but the client still showed an irreversible 'merge two accounts?' confirmation, which is nonsense from the user's side (they are simply signing into their account). The confirm step now merges inline for a GUEST initiator and returns the completed merge (the switched token); a durable initiator still gets the explicit confirmation (consolidating two real accounts is consequential). The active-game guard still refuses, surfaced as the clear error.merge_active_game_conflict message rather than swallowed. Also fixes the merged-away device-local games: their human/host seat was recorded under the retired account id, so after the switch the lobby and game header could not identify 'me' and showed every seat as an opponent (the game still played — turn logic is seat-index based). applyLinkResult now re-points local game seats from the retired id to the survivor (repointLocalGameSeats). Docs: FUNCTIONAL.md (+_ru).
339 lines
11 KiB
Go
339 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/link"
|
|
)
|
|
|
|
// The /api/v1/user/link handlers drive account linking & merge. The
|
|
// request step always mails a code (no pre-send "taken" signal, so a probe cannot
|
|
// enumerate registered emails); confirm reveals a required merge only after the
|
|
// code is verified; merge performs the irreversible consolidation behind an
|
|
// explicit step. A merge into a guest initiator's durable counterpart switches the
|
|
// active session — the new token rides back in the result for the client to adopt.
|
|
|
|
// linkEmailRequestBody starts a link/merge by mailing a code to email.
|
|
type linkEmailRequestBody struct {
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
// linkEmailConfirmBody carries the email and its confirm code.
|
|
type linkEmailConfirmBody struct {
|
|
Email string `json:"email"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// linkTelegramBody carries a gateway-validated Telegram identity.
|
|
type linkTelegramBody struct {
|
|
ExternalID string `json:"external_id"`
|
|
}
|
|
|
|
// linkVKBody carries a gateway-validated VK identity (the trusted vk user id the
|
|
// gateway resolved from the VK ID code exchange).
|
|
type linkVKBody struct {
|
|
ExternalID string `json:"external_id"`
|
|
}
|
|
|
|
// linkResultResponse is the unified result of a confirm or merge step. Status is
|
|
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
|
|
// account — the secondary_* fields summarise it for the irreversible confirmation),
|
|
// or "merged" (done; token is non-empty when the active account switched).
|
|
type linkResultResponse struct {
|
|
Status string `json:"status"`
|
|
SecondaryUserID string `json:"secondary_user_id,omitempty"`
|
|
SecondaryName string `json:"secondary_display_name,omitempty"`
|
|
SecondaryGames int `json:"secondary_games"`
|
|
SecondaryFriends int `json:"secondary_friends"`
|
|
Token string `json:"token,omitempty"`
|
|
Profile *profileResponse `json:"profile,omitempty"`
|
|
}
|
|
|
|
// handleLinkEmailRequest mails a confirm-code to email for a later link or merge.
|
|
func (s *Server) handleLinkEmailRequest(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req linkEmailRequestBody
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
if err := s.links.RequestEmail(c.Request.Context(), uid, req.Email); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
|
}
|
|
|
|
// unlinkBody carries the provider kind to detach.
|
|
type unlinkBody struct {
|
|
Kind string `json:"kind"`
|
|
}
|
|
|
|
// handleUnlink detaches a platform identity (telegram or vk) from the caller's
|
|
// account, refusing to remove the last identity (ErrLastIdentity). Email is never
|
|
// unlinked — it is replaced through the change-email flow — so an email kind is
|
|
// rejected. It returns the refreshed profile so the client updates its controls.
|
|
func (s *Server) handleUnlink(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req unlinkBody
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
if req.Kind != account.KindTelegram && req.Kind != account.KindVK {
|
|
abortBadRequest(c, "only telegram or vk can be unlinked")
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
if err := s.accounts.RemoveIdentity(ctx, uid, req.Kind); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
acc, err := s.accounts.GetByID(ctx, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
r := s.profileResponse(ctx, acc)
|
|
c.JSON(http.StatusOK, linkResultResponse{Status: "unlinked", Profile: &r})
|
|
}
|
|
|
|
// handleChangeEmailRequest mails a confirm-code to a new address for an authenticated
|
|
// email change. Like the link request it never signals "taken" up front — a conflict is
|
|
// only revealed (non-disclosingly) at confirm.
|
|
func (s *Server) handleChangeEmailRequest(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req linkEmailRequestBody
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
if err := s.emails.RequestChangeCode(c.Request.Context(), uid, req.Email); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, okResponse{OK: true})
|
|
}
|
|
|
|
// handleChangeEmailConfirm verifies the code and atomically switches the account to the
|
|
// new address, returning the refreshed profile. A new address confirmed by another
|
|
// account is refused (ErrEmailTaken → the non-disclosing message), never merged.
|
|
func (s *Server) handleChangeEmailConfirm(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req linkEmailConfirmBody
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
acc, err := s.emails.ConfirmChange(ctx, uid, req.Email, req.Code)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
r := s.profileResponse(ctx, acc)
|
|
c.JSON(http.StatusOK, linkResultResponse{Status: "changed", Profile: &r})
|
|
}
|
|
|
|
// handleLinkEmailConfirm verifies the code and binds a free email or reports a
|
|
// required merge.
|
|
func (s *Server) handleLinkEmailConfirm(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req linkEmailConfirmBody
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
res, err := s.links.ConfirmEmail(c.Request.Context(), uid, req.Email, req.Code)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
|
|
}
|
|
|
|
// handleLinkEmailMerge re-verifies the code and performs the merge.
|
|
func (s *Server) handleLinkEmailMerge(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req linkEmailConfirmBody
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
abortBadRequest(c, "invalid request body")
|
|
return
|
|
}
|
|
res, err := s.links.MergeEmail(c.Request.Context(), uid, req.Email, req.Code)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
|
}
|
|
|
|
// handleLinkTelegram attaches a gateway-validated Telegram identity to the caller
|
|
// or reports a required merge.
|
|
func (s *Server) handleLinkTelegram(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req linkTelegramBody
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
|
abortBadRequest(c, "missing external_id")
|
|
return
|
|
}
|
|
res, err := s.links.ConfirmTelegram(c.Request.Context(), uid, req.ExternalID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
|
|
}
|
|
|
|
// handleLinkTelegramMerge merges the account owning a gateway-validated Telegram
|
|
// identity into the caller's.
|
|
func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req linkTelegramBody
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
|
abortBadRequest(c, "missing external_id")
|
|
return
|
|
}
|
|
res, err := s.links.MergeTelegram(c.Request.Context(), uid, req.ExternalID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
|
}
|
|
|
|
// handleLinkVK attaches a gateway-validated VK identity to the caller or reports a
|
|
// required merge.
|
|
func (s *Server) handleLinkVK(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req linkVKBody
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
|
abortBadRequest(c, "missing external_id")
|
|
return
|
|
}
|
|
res, err := s.links.ConfirmVK(c.Request.Context(), uid, req.ExternalID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
|
|
}
|
|
|
|
// handleLinkVKMerge merges the account owning a gateway-validated VK identity into
|
|
// the caller's.
|
|
func (s *Server) handleLinkVKMerge(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
abortBadRequest(c, "missing identity")
|
|
return
|
|
}
|
|
var req linkVKBody
|
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
|
abortBadRequest(c, "missing external_id")
|
|
return
|
|
}
|
|
res, err := s.links.MergeVK(c.Request.Context(), uid, req.ExternalID)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
|
|
}
|
|
|
|
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
|
|
// or a completed link (the active account's refreshed profile).
|
|
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
|
ctx := c.Request.Context()
|
|
if res.Merged {
|
|
// A guest initiator's merge ran inline (the guest-primary rule; no confirmation step),
|
|
// so render the completed merge — the client adopts the switched session and lands on
|
|
// the surviving durable account as if it simply logged in.
|
|
return s.mergeResultResponse(c, res.Merge)
|
|
}
|
|
if res.MergeRequired {
|
|
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
|
|
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
|
|
out.SecondaryName = acc.DisplayName
|
|
}
|
|
out.SecondaryGames, out.SecondaryFriends = s.secondaryCounts(ctx, res.SecondaryID)
|
|
return out
|
|
}
|
|
return linkResultResponse{Status: "linked", Profile: s.profileFor(ctx, activeID)}
|
|
}
|
|
|
|
// mergeResultResponse renders a completed merge: the surviving account's profile
|
|
// plus a switched-session token when the active account changed.
|
|
func (s *Server) mergeResultResponse(c *gin.Context, res link.MergeResult) linkResultResponse {
|
|
return linkResultResponse{
|
|
Status: "merged",
|
|
Token: res.SwitchedToken,
|
|
Profile: s.profileFor(c.Request.Context(), res.PrimaryID),
|
|
}
|
|
}
|
|
|
|
// profileFor loads an account's profile DTO, or nil when it cannot be read.
|
|
func (s *Server) profileFor(ctx context.Context, id uuid.UUID) *profileResponse {
|
|
acc, err := s.accounts.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
p := s.profileResponse(ctx, acc)
|
|
return &p
|
|
}
|
|
|
|
// secondaryCounts summarises the to-be-retired account for the merge confirmation.
|
|
func (s *Server) secondaryCounts(ctx context.Context, id uuid.UUID) (games, friends int) {
|
|
if s.games != nil {
|
|
if gs, err := s.games.ListForAccount(ctx, id); err == nil {
|
|
games = len(gs)
|
|
}
|
|
}
|
|
if s.social != nil {
|
|
if fs, err := s.social.ListFriends(ctx, id); err == nil {
|
|
friends = len(fs)
|
|
}
|
|
}
|
|
return games, friends
|
|
}
|