2c465c01d2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
A browser has no signed VK Mini App launch params, so linking VK on the web uses
VK ID's raw OAuth 2.1 flow (PKCE, no @vkid/sdk): the SPA redirects to VK's hosted
login and returns with an authorization code, which the gateway exchanges
server-side (confidential, under the VK "Web" app's protected key) for the trusted
vk user id — then the existing link/merge machinery attaches or merges it.
- fbs LinkVKRequest{code, device_id, code_verifier}; codec + TS bindings.
- backend link.Service ConfirmVK/MergeVK/attachVK (KindVK, mirror Telegram),
handleLinkVK[Merge], routes /user/link/vk[/merge], backendclient LinkVK[Merge].
- gateway internal/vkid confidential code exchange (id.vk.com/oauth2/auth);
transcode link.vk.confirm/merge (registered only when configured) + config
GATEWAY_VK_ID_{APP_ID,CLIENT_SECRET,REDIRECT_URL} + main wiring.
- UI lib/vkid (PKCE authorize redirect + callback), Profile "Link VK" control,
boot callback handling; a merge re-authorizes for a fresh code (VK codes are
single-use). Web-only (a redirect strands a Mini App webview).
- Deploy: VITE_VK_APP_ID + VITE_VK_ID_REDIRECT_URL build args + gateway env,
ci.yaml/prod-deploy TEST_/PROD_ vars, compose/Dockerfile/.env.example/README.
- Tests: vkid exchange unit (string/number user_id, id_token fallback, errors),
transcode link.vk, backend ConfirmVK/MergeVK inttest, codec encodeLinkVK.
- Docs: ARCHITECTURE §4, FUNCTIONAL(+ru), gateway README.
333 lines
10 KiB
Go
333 lines
10 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.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
|
|
}
|