feat(admin): manual account blocking (suspensions)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s

Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint.

Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
This commit is contained in:
Ilia Denisov
2026-06-14 21:55:59 +02:00
parent 9d85090075
commit d1ba666495
48 changed files with 2206 additions and 2 deletions
+3
View File
@@ -46,6 +46,9 @@ func (s *Server) registerRoutes() {
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;
@@ -86,6 +86,48 @@ func (s *Server) handleUpdateProfile(c *gin.Context) {
c.JSON(http.StatusOK, profileResponseFor(acc))
}
// blockStatusResponse reports the caller's current block to the client. Until is an RFC3339 UTC
// instant for a temporary block and empty for a permanent one (or when not blocked); Reason is
// the operator-set reason resolved to the account's language, empty when none was cited. It is
// the one payload a blocked client can still fetch, behind the suspension gate's exemption.
type blockStatusResponse struct {
Blocked bool `json:"blocked"`
Permanent bool `json:"permanent"`
Until string `json:"until,omitempty"`
Reason string `json:"reason,omitempty"`
}
// handleBlockStatus reports whether the caller is blocked and, if so, the block's expiry and the
// reason resolved to the account's language. The suspension gate exempts this route so a blocked
// client can render the terminal blocked screen.
func (s *Server) handleBlockStatus(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
susp, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
resp := blockStatusResponse{Blocked: blocked}
if blocked {
resp.Permanent = susp.Permanent()
if susp.BlockedUntil != nil {
resp.Until = susp.BlockedUntil.UTC().Format(time.RFC3339)
}
// Resolve the reason snapshot to the account's interface language; fall back to English
// if the account row cannot be read for some reason.
lang := "en"
if acc, err := s.accounts.GetByID(c.Request.Context(), uid); err == nil {
lang = acc.PreferredLanguage
}
resp.Reason = susp.LocalizedReason(lang)
}
c.JSON(http.StatusOK, resp)
}
// handleStats returns the caller's lifetime statistics.
func (s *Server) handleStats(c *gin.Context) {
uid, ok := userID(c)
@@ -51,6 +51,12 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/users/:id", s.consoleUserDetail)
gm.POST("/users/:id/message", s.consoleUserMessage)
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
gm.POST("/users/:id/block", s.consoleBlockUser)
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
gm.POST("/reasons/:id/update", s.consoleUpdateReason)
gm.POST("/reasons/:id/delete", s.consoleDeleteReason)
gm.GET("/throttled", s.consoleThrottled)
gm.GET("/games", s.consoleGames)
gm.GET("/games/:id", s.consoleGameDetail)
@@ -291,6 +297,20 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
}
view.MoveChart = adminconsole.MoveDurationChart(cps)
}
if susp, blocked, err := s.accounts.CurrentSuspension(ctx, id); err == nil && blocked {
view.Suspension = adminconsole.SuspensionView{
Blocked: true, Permanent: susp.Permanent(), BlockedAt: fmtTime(susp.BlockedAt),
ReasonEn: susp.ReasonEn, ReasonRu: susp.ReasonRu,
}
if susp.BlockedUntil != nil {
view.Suspension.Until = fmtTime(*susp.BlockedUntil)
}
}
if reasons, err := s.accounts.ListReasons(ctx); err == nil {
for _, r := range reasons {
view.Reasons = append(view.Reasons, adminconsole.ReasonOption{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu})
}
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
@@ -754,6 +774,157 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
}
// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
// games, removing them from matchmaking. The block takes effect on the player's next request.
func (s *Server) consoleBlockUser(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
until, ok := parseSuspendUntil(trimForm(c, "duration"), trimForm(c, "until"))
if !ok {
s.renderConsoleMessage(c, "Invalid duration", "pick a preset or a future custom date/time (UTC)", back)
return
}
var reasonEn, reasonRu string
var reasonID *uuid.UUID
if rid := trimForm(c, "reason"); rid != "" {
parsed, err := uuid.Parse(rid)
if err != nil {
s.renderConsoleMessage(c, "Invalid reason", "the selected reason is not valid", back)
return
}
reason, err := s.accounts.GetReason(ctx, parsed)
if err != nil {
s.consoleError(c, err)
return
}
reasonEn, reasonRu, reasonID = reason.TextEn, reason.TextRu, &reason.ID
}
if _, err := s.accounts.Suspend(ctx, id, until, reasonEn, reasonRu, reasonID); err != nil {
s.consoleError(c, err)
return
}
forfeited, err := s.games.ForfeitAllForAccount(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Blocked", fmt.Sprintf("account blocked; %d game(s) forfeited", forfeited), back)
}
// consoleUnblockUser lifts an account's block (temporary or permanent). Games already lost at
// block time are not restored.
func (s *Server) consoleUnblockUser(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
if err := s.accounts.LiftSuspension(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Unblocked", "the block was lifted; lost games are not restored", "/_gm/users/"+id.String())
}
// parseSuspendUntil maps the block form's duration choice to an expiry instant, returning nil for
// a permanent block. A custom choice parses the datetime-local value as UTC and must be in the
// future. It reports false for an unrecognised choice or an invalid/past custom date.
func parseSuspendUntil(choice, custom string) (*time.Time, bool) {
now := time.Now().UTC()
switch choice {
case "permanent":
return nil, true
case "1d":
t := now.AddDate(0, 0, 1)
return &t, true
case "3d":
t := now.AddDate(0, 0, 3)
return &t, true
case "1w":
t := now.AddDate(0, 0, 7)
return &t, true
case "1m":
t := now.AddDate(0, 1, 0)
return &t, true
case "custom":
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, custom); err == nil {
if !t.After(now) {
return nil, false
}
return &t, true
}
}
return nil, false
default:
return nil, false
}
}
// consoleReasons renders the operator-editable suspension-reason picklist.
func (s *Server) consoleReasons(c *gin.Context) {
reasons, err := s.accounts.ListReasons(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
var view adminconsole.ReasonsView
for _, r := range reasons {
view.Items = append(view.Items, adminconsole.ReasonRow{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu, CreatedAt: fmtTime(r.CreatedAt)})
}
s.renderConsole(c, "reasons", "reasons", "Reasons", view)
}
// consoleCreateReason adds a suspension-reason picklist entry; both languages are required.
func (s *Server) consoleCreateReason(c *gin.Context) {
en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
if en == "" || ru == "" {
s.renderConsoleMessage(c, "Nothing added", "both English and Russian text are required", "/_gm/reasons")
return
}
if _, err := s.accounts.CreateReason(c.Request.Context(), en, ru); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Added", "reason added", "/_gm/reasons")
}
// consoleUpdateReason rewrites a reason's English and Russian text. Existing blocks keep their
// snapshot, so the change only affects future blocks.
func (s *Server) consoleUpdateReason(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/reasons")
if !ok {
return
}
en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
if en == "" || ru == "" {
s.renderConsoleMessage(c, "Not changed", "both English and Russian text are required", "/_gm/reasons")
return
}
if _, err := s.accounts.UpdateReason(c.Request.Context(), id, en, ru); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Updated", "reason updated", "/_gm/reasons")
}
// consoleDeleteReason removes a reason from the picklist. Past blocks keep their text snapshot.
func (s *Server) consoleDeleteReason(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/reasons")
if !ok {
return
}
if err := s.accounts.DeleteReason(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "reason deleted", "/_gm/reasons")
}
// variantVersions builds the per-variant resident-version summary from the registry.
func (s *Server) variantVersions() []adminconsole.VariantVersions {
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
+37
View File
@@ -77,3 +77,40 @@ func sameOrigin(r *http.Request) bool {
}
return false
}
// codeAccountBlocked is the stable error code the suspension gate returns for a blocked account.
// It threads through the gateway unchanged as the Execute result_code, so the UI can detect the
// block from any call and switch to the terminal blocked screen.
const codeAccountBlocked = "account_blocked"
// blockStatusPath is the one /api/v1/user route exempt from the suspension gate: a blocked client
// must still reach it to fetch the block's expiry and reason for the blocked screen.
const blockStatusPath = "/api/v1/user/block-status"
// requireNotSuspended returns middleware that rejects a blocked account's requests with 403 and
// code "account_blocked", so the UI can detect an active block from any call. The block-status
// probe is exempt. It is a no-op when the account store is not wired. It runs after RequireUserID
// (which has already placed the account id in the context).
func (s *Server) requireNotSuspended() gin.HandlerFunc {
return func(c *gin.Context) {
if s.accounts == nil || c.FullPath() == blockStatusPath {
c.Next()
return
}
id, ok := userID(c)
if !ok {
c.Next() // RequireUserID runs first and has already rejected a missing id
return
}
_, blocked, err := s.accounts.CurrentSuspension(c.Request.Context(), id)
if err != nil {
s.abortErr(c, err)
return
}
if blocked {
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: codeAccountBlocked, Message: "account is blocked"}})
return
}
c.Next()
}
}
+3
View File
@@ -185,6 +185,9 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) {
s.public = v1.Group("/public")
s.user = v1.Group("/user")
s.user.Use(RequireUserID())
// The suspension gate runs after identity is established: a blocked account is refused on
// every user route (except the block-status probe) so the UI can show the blocked screen.
s.user.Use(s.requireNotSuspended())
s.internal = v1.Group("/internal")
}