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
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:
@@ -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()))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user