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