feat(admin): ручная блокировка пользователей в админке #61
@@ -30,6 +30,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
|
|||||||
| MW3 | Graceful replay degradation: a game whose journalled move became illegal under MW2 is closed as a draw (`end_reason='aborted'`) on open instead of erroring, with an impersonal organizer note in the history + GCG (migration `00002`) | owner ad-hoc | **done** |
|
| MW3 | Graceful replay degradation: a game whose journalled move became illegal under MW2 is closed as a draw (`end_reason='aborted'`) on open instead of erroring, with an impersonal organizer note in the history + GCG (migration `00002`) | owner ad-hoc | **done** |
|
||||||
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 s) | owner ad-hoc | **done** |
|
| OW | Open auto-match: enter the game at once and wait inside it (robot after 90–180 s) | owner ad-hoc | **done** |
|
||||||
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
|
| DA | Dictionary admin: online release-archive upload → word-diff preview → install/activate; versioned dict volume; active version persisted in DB; resident label = release tag | owner ad-hoc | **done** |
|
||||||
|
| AB | Manual account block (admin suspension): permanent/temporary with an editable en+ru reason picklist; a block forfeits the player's active games + cancels their open ones; a backend gate refuses a blocked account with **403 `account_blocked`**; the UI shows a terminal blocked screen and stops all push/poll; manual unblock; temporary blocks self-expire (migration `00003`) | owner ad-hoc | **done** |
|
||||||
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||||
|
|
||||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||||
|
|||||||
@@ -99,12 +99,15 @@ type Identity struct {
|
|||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
metrics *accountMetrics
|
metrics *accountMetrics
|
||||||
|
// suspensions caches each account's current manual block, read by the suspension gate on
|
||||||
|
// every authenticated request and invalidated on Suspend/LiftSuspension. See suspension.go.
|
||||||
|
suspensions *suspensionCache
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStore constructs a Store wrapping db. Metrics default to a no-op meter until
|
// NewStore constructs a Store wrapping db. Metrics default to a no-op meter until
|
||||||
// SetMetrics installs the real one during startup wiring.
|
// SetMetrics installs the real one during startup wiring.
|
||||||
func NewStore(db *sql.DB) *Store {
|
func NewStore(db *sql.DB) *Store {
|
||||||
return &Store{db: db, metrics: defaultAccountMetrics()}
|
return &Store{db: db, metrics: defaultAccountMetrics(), suspensions: newSuspensionCache()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
|
// ProvisionByIdentity returns the account bound to (kind, externalID), creating
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
"github.com/go-jet/jet/v2/qrm"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/model"
|
||||||
|
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Suspension is an account's currently-in-force manual block, the operator's hard counterpart
|
||||||
|
// to the soft, reversible FlaggedHighRateAt marker. It is named "suspension" to stay distinct
|
||||||
|
// from the peer-to-peer blocks in internal/social (one player muting another); the vocabulary
|
||||||
|
// the player sees is "blocked". BlockedUntil is nil for a permanent block and the expiry
|
||||||
|
// instant for a temporary one. ReasonEn and ReasonRu are the reason-text snapshot taken at
|
||||||
|
// block time (both empty when no reason was cited), so editing or deleting the picklist entry
|
||||||
|
// never changes what an already-blocked player is shown.
|
||||||
|
type Suspension struct {
|
||||||
|
AccountID uuid.UUID
|
||||||
|
BlockedAt time.Time
|
||||||
|
BlockedUntil *time.Time
|
||||||
|
ReasonEn string
|
||||||
|
ReasonRu string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanent reports whether the suspension has no expiry.
|
||||||
|
func (s Suspension) Permanent() bool { return s.BlockedUntil == nil }
|
||||||
|
|
||||||
|
// LocalizedReason returns the reason text in the given language ("ru" selects Russian, anything
|
||||||
|
// else English), or empty when no reason was cited. The two snapshots are always both set or
|
||||||
|
// both empty, since a reason is chosen from the en+ru picklist.
|
||||||
|
func (s Suspension) LocalizedReason(language string) string {
|
||||||
|
if language == "ru" {
|
||||||
|
return s.ReasonRu
|
||||||
|
}
|
||||||
|
return s.ReasonEn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reason is one entry of the operator-editable suspension-reason picklist, carrying the English
|
||||||
|
// and Russian text shown to a blocked player in their language.
|
||||||
|
type Reason struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
TextEn string
|
||||||
|
TextRu string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspend records a new manual block on the account: permanent when until is nil, otherwise in
|
||||||
|
// force until that instant. reasonEn and reasonRu are the optional reason-text snapshot (both
|
||||||
|
// empty for no reason) and reasonID is the loose picklist link (nil when none, nulled later if
|
||||||
|
// that entry is deleted). It returns the persisted Suspension. Suspending an already-blocked
|
||||||
|
// account simply appends another block; CurrentSuspension always reflects the strongest.
|
||||||
|
func (s *Store) Suspend(ctx context.Context, accountID uuid.UUID, until *time.Time, reasonEn, reasonRu string, reasonID *uuid.UUID) (Suspension, error) {
|
||||||
|
suspensionID, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return Suspension{}, fmt.Errorf("account: new suspension id: %w", err)
|
||||||
|
}
|
||||||
|
stmt := table.AccountSuspensions.INSERT(
|
||||||
|
table.AccountSuspensions.SuspensionID,
|
||||||
|
table.AccountSuspensions.AccountID,
|
||||||
|
table.AccountSuspensions.BlockedUntil,
|
||||||
|
table.AccountSuspensions.ReasonEn,
|
||||||
|
table.AccountSuspensions.ReasonRu,
|
||||||
|
table.AccountSuspensions.ReasonID,
|
||||||
|
).VALUES(
|
||||||
|
suspensionID,
|
||||||
|
accountID,
|
||||||
|
nullableTimestamp(until),
|
||||||
|
nullableString(reasonEn),
|
||||||
|
nullableString(reasonRu),
|
||||||
|
nullableUUID(reasonID),
|
||||||
|
).RETURNING(table.AccountSuspensions.AllColumns)
|
||||||
|
|
||||||
|
var row model.AccountSuspensions
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
return Suspension{}, fmt.Errorf("account: suspend %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
s.invalidateSuspension(accountID)
|
||||||
|
return modelToSuspension(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiftSuspension lifts every in-force block on the account (the operator's manual unblock),
|
||||||
|
// stamping lifted_at on each. It is a no-op when the account is not currently blocked. Lifting
|
||||||
|
// does not un-resign games already forfeited at block time — those stay lost.
|
||||||
|
func (s *Store) LiftSuspension(ctx context.Context, accountID uuid.UUID) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
stmt := table.AccountSuspensions.
|
||||||
|
UPDATE(table.AccountSuspensions.LiftedAt).
|
||||||
|
SET(postgres.TimestampzT(now)).
|
||||||
|
WHERE(
|
||||||
|
table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(activeSuspensionPredicate(now)),
|
||||||
|
)
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return fmt.Errorf("account: lift suspension %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
s.invalidateSuspension(accountID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentSuspension returns the account's in-force block and true, or false when the account is
|
||||||
|
// not blocked. When several blocks overlap it returns the strongest — a permanent one first,
|
||||||
|
// otherwise the latest-expiring — which is what the player's blocked screen shows. The gate
|
||||||
|
// middleware calls it on every authenticated request, served by account_suspensions_account_idx.
|
||||||
|
func (s *Store) CurrentSuspension(ctx context.Context, accountID uuid.UUID) (Suspension, bool, error) {
|
||||||
|
// A store with no database (the zero-value store used by the routing unit tests) has no
|
||||||
|
// suspensions; report not-blocked rather than dereferencing a nil pool. A real pool that
|
||||||
|
// errors still surfaces the error to the gate, which fails closed.
|
||||||
|
if s.db == nil {
|
||||||
|
return Suspension{}, false, nil
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if s.suspensions != nil {
|
||||||
|
if e, ok := s.suspensions.get(accountID); ok {
|
||||||
|
if !e.found {
|
||||||
|
return Suspension{}, false, nil // cached: not blocked
|
||||||
|
}
|
||||||
|
if suspensionActiveAt(e.susp, now) {
|
||||||
|
return e.susp, true, nil // cached block still in force
|
||||||
|
}
|
||||||
|
// The cached block has lapsed since it was cached; refresh from the database below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
susp, found, err := s.queryCurrentSuspension(ctx, accountID, now)
|
||||||
|
if err != nil {
|
||||||
|
return Suspension{}, false, err
|
||||||
|
}
|
||||||
|
if s.suspensions != nil {
|
||||||
|
s.suspensions.put(accountID, suspensionCacheEntry{susp: susp, found: found})
|
||||||
|
}
|
||||||
|
return susp, found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryCurrentSuspension reads the account's strongest in-force block straight from the database
|
||||||
|
// (no cache), as of now. It backs CurrentSuspension on a cache miss or a lapsed entry.
|
||||||
|
func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID, now time.Time) (Suspension, bool, error) {
|
||||||
|
stmt := postgres.SELECT(table.AccountSuspensions.AllColumns).
|
||||||
|
FROM(table.AccountSuspensions).
|
||||||
|
WHERE(
|
||||||
|
table.AccountSuspensions.AccountID.EQ(postgres.UUID(accountID)).
|
||||||
|
AND(activeSuspensionPredicate(now)),
|
||||||
|
).
|
||||||
|
ORDER_BY(table.AccountSuspensions.BlockedUntil.DESC().NULLS_FIRST()).
|
||||||
|
LIMIT(1)
|
||||||
|
|
||||||
|
var row model.AccountSuspensions
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return Suspension{}, false, nil
|
||||||
|
}
|
||||||
|
return Suspension{}, false, fmt.Errorf("account: current suspension %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return modelToSuspension(row), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it.
|
||||||
|
// Called after Suspend and LiftSuspension.
|
||||||
|
func (s *Store) invalidateSuspension(accountID uuid.UUID) {
|
||||||
|
if s.suspensions != nil {
|
||||||
|
s.suspensions.invalidate(accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspensionActiveAt reports whether a suspension is in force at now: permanent, or not yet
|
||||||
|
// expired. The lifted check is implicit — only non-lifted blocks are ever cached or returned.
|
||||||
|
func suspensionActiveAt(susp Suspension, now time.Time) bool {
|
||||||
|
return susp.BlockedUntil == nil || susp.BlockedUntil.After(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspensionCache is the gate's write-through cache of each account's current block, keyed by
|
||||||
|
// account id. The suspension gate reads it on every authenticated request; Suspend and
|
||||||
|
// LiftSuspension invalidate the account's entry. An entry holds the strongest active block at
|
||||||
|
// query time (or a not-blocked marker), re-evaluated against the wall clock on read, so a
|
||||||
|
// temporary block lapses without an explicit invalidation. It is single-instance, matching the
|
||||||
|
// deployment (one shared Store); a multi-instance deployment would need a shared cache.
|
||||||
|
type suspensionCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
m map[uuid.UUID]suspensionCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// suspensionCacheEntry is a cached lookup: the strongest active block when found, else a
|
||||||
|
// not-blocked marker (found=false).
|
||||||
|
type suspensionCacheEntry struct {
|
||||||
|
susp Suspension
|
||||||
|
found bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSuspensionCache() *suspensionCache {
|
||||||
|
return &suspensionCache{m: make(map[uuid.UUID]suspensionCacheEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *suspensionCache) get(id uuid.UUID) (suspensionCacheEntry, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
e, ok := c.m[id]
|
||||||
|
return e, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *suspensionCache) put(id uuid.UUID, e suspensionCacheEntry) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.m[id] = e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *suspensionCache) invalidate(id uuid.UUID) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
delete(c.m, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeSuspensionPredicate matches the rows of a block that is in force at now: not lifted and
|
||||||
|
// either permanent or not yet expired.
|
||||||
|
func activeSuspensionPredicate(now time.Time) postgres.BoolExpression {
|
||||||
|
return table.AccountSuspensions.LiftedAt.IS_NULL().
|
||||||
|
AND(
|
||||||
|
table.AccountSuspensions.BlockedUntil.IS_NULL().
|
||||||
|
OR(table.AccountSuspensions.BlockedUntil.GT(postgres.TimestampzT(now))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReasons returns the suspension-reason picklist, oldest first.
|
||||||
|
func (s *Store) ListReasons(ctx context.Context) ([]Reason, error) {
|
||||||
|
stmt := postgres.SELECT(table.SuspensionReasons.AllColumns).
|
||||||
|
FROM(table.SuspensionReasons).
|
||||||
|
ORDER_BY(table.SuspensionReasons.CreatedAt.ASC())
|
||||||
|
var rows []model.SuspensionReasons
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("account: list reasons: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Reason, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, modelToReason(r))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReason loads one picklist entry, or ErrNotFound when it is absent.
|
||||||
|
func (s *Store) GetReason(ctx context.Context, id uuid.UUID) (Reason, error) {
|
||||||
|
stmt := postgres.SELECT(table.SuspensionReasons.AllColumns).
|
||||||
|
FROM(table.SuspensionReasons).
|
||||||
|
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))).
|
||||||
|
LIMIT(1)
|
||||||
|
var row model.SuspensionReasons
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return Reason{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Reason{}, fmt.Errorf("account: get reason %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return modelToReason(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateReason inserts a new picklist entry with the given English and Russian text.
|
||||||
|
func (s *Store) CreateReason(ctx context.Context, textEn, textRu string) (Reason, error) {
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return Reason{}, fmt.Errorf("account: new reason id: %w", err)
|
||||||
|
}
|
||||||
|
stmt := table.SuspensionReasons.INSERT(
|
||||||
|
table.SuspensionReasons.ReasonID,
|
||||||
|
table.SuspensionReasons.TextEn,
|
||||||
|
table.SuspensionReasons.TextRu,
|
||||||
|
).VALUES(id, textEn, textRu).
|
||||||
|
RETURNING(table.SuspensionReasons.AllColumns)
|
||||||
|
var row model.SuspensionReasons
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
return Reason{}, fmt.Errorf("account: create reason: %w", err)
|
||||||
|
}
|
||||||
|
return modelToReason(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateReason rewrites a picklist entry's English and Russian text, returning ErrNotFound when
|
||||||
|
// it is absent. Existing suspensions keep their text snapshot, so the change only affects future
|
||||||
|
// blocks.
|
||||||
|
func (s *Store) UpdateReason(ctx context.Context, id uuid.UUID, textEn, textRu string) (Reason, error) {
|
||||||
|
stmt := table.SuspensionReasons.
|
||||||
|
UPDATE(table.SuspensionReasons.TextEn, table.SuspensionReasons.TextRu, table.SuspensionReasons.UpdatedAt).
|
||||||
|
SET(postgres.String(textEn), postgres.String(textRu), postgres.TimestampzT(time.Now().UTC())).
|
||||||
|
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id))).
|
||||||
|
RETURNING(table.SuspensionReasons.AllColumns)
|
||||||
|
var row model.SuspensionReasons
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return Reason{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Reason{}, fmt.Errorf("account: update reason %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return modelToReason(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteReason removes a picklist entry. It is a hard delete: the reason_id link on past
|
||||||
|
// suspensions is nulled by the foreign key, but their text snapshot is untouched.
|
||||||
|
func (s *Store) DeleteReason(ctx context.Context, id uuid.UUID) error {
|
||||||
|
stmt := table.SuspensionReasons.
|
||||||
|
DELETE().
|
||||||
|
WHERE(table.SuspensionReasons.ReasonID.EQ(postgres.UUID(id)))
|
||||||
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
|
return fmt.Errorf("account: delete reason %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelToSuspension projects a generated row into the public Suspension struct.
|
||||||
|
func modelToSuspension(row model.AccountSuspensions) Suspension {
|
||||||
|
s := Suspension{AccountID: row.AccountID, BlockedAt: row.BlockedAt, BlockedUntil: row.BlockedUntil}
|
||||||
|
if row.ReasonEn != nil {
|
||||||
|
s.ReasonEn = *row.ReasonEn
|
||||||
|
}
|
||||||
|
if row.ReasonRu != nil {
|
||||||
|
s.ReasonRu = *row.ReasonRu
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelToReason projects a generated row into the public Reason struct.
|
||||||
|
func modelToReason(row model.SuspensionReasons) Reason {
|
||||||
|
return Reason{ID: row.ReasonID, TextEn: row.TextEn, TextRu: row.TextRu, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableString renders an empty string as SQL NULL, otherwise the string literal.
|
||||||
|
func nullableString(v string) postgres.Expression {
|
||||||
|
if v == "" {
|
||||||
|
return postgres.NULL
|
||||||
|
}
|
||||||
|
return postgres.String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableTimestamp renders a nil time as SQL NULL, otherwise the UTC timestamp literal.
|
||||||
|
func nullableTimestamp(v *time.Time) postgres.Expression {
|
||||||
|
if v == nil {
|
||||||
|
return postgres.NULL
|
||||||
|
}
|
||||||
|
return postgres.TimestampzT(v.UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullableUUID renders a nil id as SQL NULL, otherwise the uuid literal.
|
||||||
|
func nullableUUID(v *uuid.UUID) postgres.Expression {
|
||||||
|
if v == nil {
|
||||||
|
return postgres.NULL
|
||||||
|
}
|
||||||
|
return postgres.UUID(*v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSuspensionActiveAt covers the wall-clock re-evaluation the cache relies on: a permanent
|
||||||
|
// block is always active, a future-dated one is active, a past-dated one is not.
|
||||||
|
func TestSuspensionActiveAt(t *testing.T) {
|
||||||
|
now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
future := now.Add(time.Hour)
|
||||||
|
past := now.Add(-time.Hour)
|
||||||
|
|
||||||
|
if !suspensionActiveAt(Suspension{BlockedUntil: nil}, now) {
|
||||||
|
t.Error("a permanent block must be active")
|
||||||
|
}
|
||||||
|
if !suspensionActiveAt(Suspension{BlockedUntil: &future}, now) {
|
||||||
|
t.Error("a future-dated block must be active")
|
||||||
|
}
|
||||||
|
if suspensionActiveAt(Suspension{BlockedUntil: &past}, now) {
|
||||||
|
t.Error("a past-dated block must be inactive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSuspensionCache covers the cache primitives: a miss on an empty cache, a hit after put for
|
||||||
|
// both the not-blocked and blocked markers, and a miss after invalidate.
|
||||||
|
func TestSuspensionCache(t *testing.T) {
|
||||||
|
c := newSuspensionCache()
|
||||||
|
id := uuid.New()
|
||||||
|
|
||||||
|
if _, ok := c.get(id); ok {
|
||||||
|
t.Fatal("empty cache must miss")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.put(id, suspensionCacheEntry{found: false})
|
||||||
|
if e, ok := c.get(id); !ok || e.found {
|
||||||
|
t.Fatalf("cached not-blocked entry = (%+v, ok %v), want hit with found=false", e, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.put(id, suspensionCacheEntry{susp: Suspension{AccountID: id}, found: true})
|
||||||
|
if e, ok := c.get(id); !ok || !e.found {
|
||||||
|
t.Fatalf("cached blocked entry = (%+v, ok %v), want hit with found=true", e, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.invalidate(id)
|
||||||
|
if _, ok := c.get(id); ok {
|
||||||
|
t.Fatal("invalidated entry must miss")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
|
||||||
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
|
||||||
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
|
||||||
|
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
|
||||||
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
|
||||||
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
|
||||||
<a href="/_gm/grafana/">Grafana ↗</a>
|
<a href="/_gm/grafana/">Grafana ↗</a>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Suspension reasons</h1>
|
||||||
|
{{with .Data}}
|
||||||
|
<p class="note">Reasons offered when blocking a user; each is shown to the blocked player in their own language. Editing or deleting a reason does not change a reason already shown to a currently-blocked player (blocks snapshot the text).</p>
|
||||||
|
<section class="panel"><h2>Add reason</h2>
|
||||||
|
<form class="form col" method="post" action="/_gm/reasons">
|
||||||
|
<label>English <input type="text" name="text_en" required></label>
|
||||||
|
<label>Russian <input type="text" name="text_ru" required></label>
|
||||||
|
<div><button type="submit">Add</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Existing reasons</h2>
|
||||||
|
{{range .Items}}
|
||||||
|
<div class="row">
|
||||||
|
<form class="form" method="post" action="/_gm/reasons/{{.ID}}/update">
|
||||||
|
<input type="text" name="text_en" value="{{.TextEn}}" aria-label="English" required>
|
||||||
|
<input type="text" name="text_ru" value="{{.TextRu}}" aria-label="Russian" required>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
<form class="form" method="post" action="/_gm/reasons/{{.ID}}/delete">
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{else}}<p class="note">no reasons yet</p>{{end}}
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -34,6 +34,35 @@
|
|||||||
{{else}}<p class="note">no statistics</p>{{end}}
|
{{else}}<p class="note">no statistics</p>{{end}}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="panel"><h2>Account block</h2>
|
||||||
|
{{$uid := .ID}}
|
||||||
|
{{with .Suspension}}{{if .Blocked}}
|
||||||
|
<ul class="kv">
|
||||||
|
<li><b>Status</b> <span class="warn">{{if .Permanent}}blocked (permanent){{else}}blocked until {{.Until}} (UTC){{end}}</span></li>
|
||||||
|
{{if .BlockedAt}}<li><b>Since</b> {{.BlockedAt}}</li>{{end}}
|
||||||
|
{{if .ReasonEn}}<li><b>Reason</b> {{.ReasonEn}} / {{.ReasonRu}}</li>{{end}}
|
||||||
|
</ul>
|
||||||
|
<form class="form" method="post" action="/_gm/users/{{$uid}}/unblock">
|
||||||
|
<button type="submit">Unblock</button>
|
||||||
|
</form>
|
||||||
|
{{else}}<p class="note">not blocked</p>{{end}}{{end}}
|
||||||
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/block">
|
||||||
|
<label>Duration <select name="duration">
|
||||||
|
<option value="permanent">permanent</option>
|
||||||
|
<option value="1d">1 day</option>
|
||||||
|
<option value="3d">3 days</option>
|
||||||
|
<option value="1w">1 week</option>
|
||||||
|
<option value="1m">1 month</option>
|
||||||
|
<option value="custom">custom date (UTC)</option>
|
||||||
|
</select></label>
|
||||||
|
<label>Custom until (UTC; used when duration is "custom") <input type="datetime-local" name="until"></label>
|
||||||
|
<label>Reason <select name="reason">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{{range .Reasons}}<option value="{{.ID}}">{{.TextEn}}</option>{{end}}
|
||||||
|
</select></label>
|
||||||
|
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
{{if .MoveChart}}
|
{{if .MoveChart}}
|
||||||
<section class="panel"><h2>Move timing</h2>
|
<section class="panel"><h2>Move timing</h2>
|
||||||
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
|
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
|
||||||
|
|||||||
@@ -135,6 +135,29 @@ type UserDetailView struct {
|
|||||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||||
// time (min/mean/max), empty when the account has no timed move.
|
// time (min/mean/max), empty when the account has no timed move.
|
||||||
MoveChart template.HTML
|
MoveChart template.HTML
|
||||||
|
// Suspension is the account's current manual-block state, shown with the block/unblock
|
||||||
|
// form; Reasons is the operator-editable reason picklist offered in the block form.
|
||||||
|
Suspension SuspensionView
|
||||||
|
Reasons []ReasonOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuspensionView is an account's current manual-block state shown on the user card: whether it
|
||||||
|
// is blocked, whether the block is permanent, the pre-formatted expiry (empty when permanent),
|
||||||
|
// when it was applied, and the reason snapshot in both languages (empty when none was cited).
|
||||||
|
type SuspensionView struct {
|
||||||
|
Blocked bool
|
||||||
|
Permanent bool
|
||||||
|
Until string
|
||||||
|
BlockedAt string
|
||||||
|
ReasonEn string
|
||||||
|
ReasonRu string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReasonOption is one suspension-reason picklist entry offered in the block form's dropdown.
|
||||||
|
type ReasonOption struct {
|
||||||
|
ID string
|
||||||
|
TextEn string
|
||||||
|
TextRu string
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatsRow is an account's lifetime statistics.
|
// StatsRow is an account's lifetime statistics.
|
||||||
@@ -317,6 +340,19 @@ type FlaggedAccountRow struct {
|
|||||||
FlaggedAt string
|
FlaggedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReasonsView is the suspension-reason picklist management page: every editable reason entry.
|
||||||
|
type ReasonsView struct {
|
||||||
|
Items []ReasonRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReasonRow is one editable suspension-reason entry, with its English and Russian text.
|
||||||
|
type ReasonRow struct {
|
||||||
|
ID string
|
||||||
|
TextEn string
|
||||||
|
TextRu string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
// MessageView is the result page shown after a POST action.
|
// MessageView is the result page shown after a POST action.
|
||||||
type MessageView struct {
|
type MessageView struct {
|
||||||
Heading string
|
Heading string
|
||||||
|
|||||||
@@ -378,6 +378,68 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
|
|||||||
return MoveResult{Move: rec, Game: post}, nil
|
return MoveResult{Move: rec, Game: post}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForfeitAllForAccount resigns every game the account is currently playing and cancels every game
|
||||||
|
// it has opened but no opponent has joined, returning how many it acted on. It is the game-side
|
||||||
|
// effect of an admin block: the blocked player instantly loses each live game (the opponent
|
||||||
|
// winning, exactly as a manual resignation) and leaves matchmaking so nobody joins a doomed game.
|
||||||
|
// It reuses the per-game resignation path (its lock, commit and live events), so it is safe to
|
||||||
|
// call while play continues and is idempotent — an already-resigned or finished game is skipped.
|
||||||
|
// A per-game failure is logged and skipped so one bad game does not strand the rest; the
|
||||||
|
// turn-timeout sweeper remains the backstop for anything missed in a race.
|
||||||
|
func (svc *Service) ForfeitAllForAccount(ctx context.Context, accountID uuid.UUID) (int, error) {
|
||||||
|
games, err := svc.store.ListGamesForAccount(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
count := 0
|
||||||
|
for _, g := range games {
|
||||||
|
if g.Status != StatusActive && g.Status != StatusOpen {
|
||||||
|
continue // a finished game holds no turn to forfeit
|
||||||
|
}
|
||||||
|
acted, err := svc.forfeitOne(ctx, g.ID, accountID)
|
||||||
|
if err != nil {
|
||||||
|
svc.log.Warn("forfeit on block", zap.String("game", g.ID.String()), zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if acted {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// forfeitOne resigns one game on behalf of accountID, or cancels it when it is an open auto-match
|
||||||
|
// game still awaiting an opponent. It reports whether it changed the game. A game that has already
|
||||||
|
// finished, or in which the account no longer holds a seat, is a benign no-op.
|
||||||
|
func (svc *Service) forfeitOne(ctx context.Context, gameID, accountID uuid.UUID) (bool, error) {
|
||||||
|
_, err := svc.Resign(ctx, gameID, accountID)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return true, nil
|
||||||
|
case errors.Is(err, ErrNoOpponentYet):
|
||||||
|
// An open game with no opponent cannot be resigned (there is no one to award the win), so
|
||||||
|
// delete it to clear it from the matchmaking pool. If it filled in the race window it is
|
||||||
|
// now active, so resign the seat instead.
|
||||||
|
deleted, derr := svc.store.DeleteOpenGame(ctx, gameID)
|
||||||
|
if derr != nil {
|
||||||
|
return false, derr
|
||||||
|
}
|
||||||
|
if deleted {
|
||||||
|
svc.cache.remove(gameID)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if _, err := svc.Resign(ctx, gameID, accountID); err != nil &&
|
||||||
|
!errors.Is(err, ErrFinished) && !errors.Is(err, ErrNoOpponentYet) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
case errors.Is(err, ErrFinished), errors.Is(err, ErrNotAPlayer), errors.Is(err, ErrNotFound):
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
|
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
|
||||||
// indices to concrete letters before delegating to the letter-based play, exchange and
|
// indices to concrete letters before delegating to the letter-based play, exchange and
|
||||||
// word-check methods, keeping a single domain path shared with the robot.
|
// word-check methods, keeping a single domain path shared with the robot.
|
||||||
|
|||||||
@@ -354,6 +354,26 @@ func (s *Store) SharedGameExists(ctx context.Context, a, b uuid.UUID) (bool, err
|
|||||||
return len(rows) > 0, nil
|
return len(rows) > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteOpenGame removes a game only while it is still open (an auto-match game awaiting an
|
||||||
|
// opponent), reporting whether a row was deleted. The starter's lone seat and any draft cascade
|
||||||
|
// away with it. It no-ops (false) once the game has filled and become active, so a caller can
|
||||||
|
// fall back to resigning the now-active seat. Used to clear a blocked player from matchmaking.
|
||||||
|
func (s *Store) DeleteOpenGame(ctx context.Context, gameID uuid.UUID) (bool, error) {
|
||||||
|
stmt := table.Games.DELETE().WHERE(
|
||||||
|
table.Games.GameID.EQ(postgres.UUID(gameID)).
|
||||||
|
AND(table.Games.Status.EQ(postgres.String(StatusOpen))),
|
||||||
|
)
|
||||||
|
res, err := stmt.ExecContext(ctx, s.db)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("game: delete open game %s: %w", gameID, err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("game: delete open game rows %s: %w", gameID, err)
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListGamesForAccount loads every game the account is seated in (active and
|
// ListGamesForAccount loads every game the account is seated in (active and
|
||||||
// finished), newest first, each joined with its ordered seats. It backs the lobby's
|
// finished), newest first, each joined with its ordered seats. It backs the lobby's
|
||||||
// "my games" lists.
|
// "my games" lists.
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestForfeitAllResignsActiveGame checks the game-side effect of a block on a two-player game:
|
||||||
|
// ForfeitAllForAccount resigns the blocked player, finishing the game with the opponent winning.
|
||||||
|
func TestForfeitAllResignsActiveGame(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newGameService()
|
||||||
|
gid, seats := newGameWithSeats(t, 2) // seats[0] = blocked, seats[1] = opponent
|
||||||
|
|
||||||
|
n, err := svc.ForfeitAllForAccount(ctx, seats[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("forfeit all: %v", err)
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
t.Fatalf("forfeit count = %d, want at least 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := svc.GameByID(ctx, gid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get game: %v", err)
|
||||||
|
}
|
||||||
|
if g.Status != game.StatusFinished {
|
||||||
|
t.Errorf("status = %q, want finished", g.Status)
|
||||||
|
}
|
||||||
|
if w := seatWinner(g, seats[0]); w {
|
||||||
|
t.Error("the blocked player must not be the winner")
|
||||||
|
}
|
||||||
|
if w := seatWinner(g, seats[1]); !w {
|
||||||
|
t.Error("the opponent must win when the blocked player forfeits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForfeitAllCancelsOpenGame checks that a block deletes the blocked player's open auto-match
|
||||||
|
// game (no opponent to resign against), clearing it from the matchmaking pool.
|
||||||
|
func TestForfeitAllCancelsOpenGame(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
clearOpenGames(t)
|
||||||
|
svc := newGameService()
|
||||||
|
starter := provisionAccount(t)
|
||||||
|
g := openGame(t, svc, starter, evenOpeningSeed(t))
|
||||||
|
|
||||||
|
n, err := svc.ForfeitAllForAccount(ctx, starter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("forfeit all: %v", err)
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
t.Fatalf("forfeit count = %d, want at least 1", n)
|
||||||
|
}
|
||||||
|
if c := gameRowCount(t, g.ID); c != 0 {
|
||||||
|
t.Errorf("open game rows after forfeit = %d, want 0 (deleted)", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForfeitAllResignsSeatInMultiplayerGame checks that in a 3-player game the blocked player's
|
||||||
|
// seat is resigned while the game continues for the remaining players.
|
||||||
|
func TestForfeitAllResignsSeatInMultiplayerGame(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newGameService()
|
||||||
|
gid, seats := newGameWithSeats(t, 3)
|
||||||
|
|
||||||
|
n, err := svc.ForfeitAllForAccount(ctx, seats[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("forfeit all: %v", err)
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
t.Fatalf("forfeit count = %d, want at least 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := svc.GameByID(ctx, gid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get game: %v", err)
|
||||||
|
}
|
||||||
|
if g.Status != game.StatusActive {
|
||||||
|
t.Errorf("status = %q, want still active (two seats remain)", g.Status)
|
||||||
|
}
|
||||||
|
if c := resignMoveCount(t, gid, 0); c != 1 {
|
||||||
|
t.Errorf("resign moves for seat 0 = %d, want 1", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seatWinner reports the is_winner flag for the given account's seat in g.
|
||||||
|
func seatWinner(g game.Game, accountID uuid.UUID) bool {
|
||||||
|
for _, s := range g.Seats {
|
||||||
|
if s.AccountID == accountID {
|
||||||
|
return s.IsWinner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// gameRowCount counts the rows in the games table for gameID (0 once it is deleted).
|
||||||
|
func gameRowCount(t *testing.T, gameID uuid.UUID) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := testDB.QueryRowContext(context.Background(),
|
||||||
|
`SELECT count(*) FROM backend.games WHERE game_id = $1`, gameID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count game rows: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// resignMoveCount counts the resign moves journalled for a seat in a game.
|
||||||
|
func resignMoveCount(t *testing.T, gameID uuid.UUID, seat int) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := testDB.QueryRowContext(context.Background(),
|
||||||
|
`SELECT count(*) FROM backend.game_moves WHERE game_id = $1 AND action = 'resign' AND seat = $2`,
|
||||||
|
gameID, seat).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count resign moves: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/game"
|
||||||
|
"scrabble/backend/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConsoleBlockAndUnblock drives the admin console block flow through HTTP: blocking a user
|
||||||
|
// records the suspension and forfeits their active game, the user card renders both branches,
|
||||||
|
// and unblocking lifts it.
|
||||||
|
func TestConsoleBlockAndUnblock(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
accounts := account.NewStore(testDB)
|
||||||
|
games := newGameService()
|
||||||
|
srv := consoleServer(t, accounts, games)
|
||||||
|
|
||||||
|
gid, seats := newGameWithSeats(t, 2)
|
||||||
|
blocked := seats[0]
|
||||||
|
|
||||||
|
// The card renders the not-blocked branch before any block.
|
||||||
|
if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("user card before block = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block permanently via the console.
|
||||||
|
if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/block", url.Values{"duration": {"permanent"}}); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("console block = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || !ok {
|
||||||
|
t.Fatalf("after console block: blocked=%v err=%v, want blocked", ok, err)
|
||||||
|
}
|
||||||
|
if g, err := games.GameByID(ctx, gid); err != nil || g.Status != game.StatusFinished {
|
||||||
|
t.Fatalf("game after block: status=%q err=%v, want finished", g.Status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The card renders the blocked branch.
|
||||||
|
if rec := consoleGet(t, srv, "/_gm/users/"+blocked.String()); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("user card after block = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unblock via the console.
|
||||||
|
if rec := consolePost(t, srv, "/_gm/users/"+blocked.String()+"/unblock", url.Values{}); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("console unblock = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if _, ok, err := accounts.CurrentSuspension(ctx, blocked); err != nil || ok {
|
||||||
|
t.Fatalf("after console unblock: blocked=%v err=%v, want not blocked", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleReasonsCRUD drives reason creation through the console and checks the page renders.
|
||||||
|
func TestConsoleReasonsCRUD(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
accounts := account.NewStore(testDB)
|
||||||
|
srv := consoleServer(t, accounts, newGameService())
|
||||||
|
|
||||||
|
if rec := consoleGet(t, srv, "/_gm/reasons"); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("reasons page = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
form := url.Values{"text_en": {"Console reason"}, "text_ru": {"Причина из консоли"}}
|
||||||
|
if rec := consolePost(t, srv, "/_gm/reasons", form); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("console create reason = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
reasons, err := accounts.ListReasons(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list reasons: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, r := range reasons {
|
||||||
|
if r.TextEn == "Console reason" && r.TextRu == "Причина из консоли" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("reason created via the console was not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleServer assembles a server with the admin console mounted (it needs accounts, games and a
|
||||||
|
// registry).
|
||||||
|
func consoleServer(t *testing.T, accounts *account.Store, games *game.Service) *server.Server {
|
||||||
|
t.Helper()
|
||||||
|
return server.New(":0", server.Deps{
|
||||||
|
Logger: zaptest.NewLogger(t),
|
||||||
|
DB: testDB,
|
||||||
|
Accounts: accounts,
|
||||||
|
Games: games,
|
||||||
|
Registry: testRegistry,
|
||||||
|
DictDir: dictDir(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleGet issues a console GET (a safe method, so the same-origin guard passes).
|
||||||
|
func consoleGet(t *testing.T, srv *server.Server, path string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "http://example.com"+path, nil))
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// consolePost issues a same-origin form POST to the console.
|
||||||
|
func consolePost(t *testing.T, srv *server.Server, path string, form url.Values) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "http://example.com"+path, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Origin", "http://example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// blockStatusBody mirrors the backend's /block-status JSON for the test.
|
||||||
|
type blockStatusBody struct {
|
||||||
|
Blocked bool `json:"blocked"`
|
||||||
|
Permanent bool `json:"permanent"`
|
||||||
|
Until string `json:"until"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSuspensionGate drives the gate end-to-end through the assembled HTTP server: a blocked
|
||||||
|
// account is refused on a normal user route with 403 + account_blocked, the block-status probe
|
||||||
|
// stays reachable and resolves the reason to the account's language, and an unblock restores
|
||||||
|
// access.
|
||||||
|
func TestSuspensionGate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
accounts := account.NewStore(testDB)
|
||||||
|
srv := server.New(":0", server.Deps{
|
||||||
|
Logger: zaptest.NewLogger(t),
|
||||||
|
DB: testDB,
|
||||||
|
Accounts: accounts,
|
||||||
|
})
|
||||||
|
|
||||||
|
acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
id := acc.ID
|
||||||
|
|
||||||
|
// Not blocked: a normal route passes and block-status reports false.
|
||||||
|
if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("profile before block = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if bs := blockStatus(t, srv, id); bs.Blocked {
|
||||||
|
t.Fatal("fresh account should not be blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block permanently with a reason.
|
||||||
|
if _, err := accounts.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A normal route is now refused with the stable code.
|
||||||
|
rec := userGet(t, srv, "/api/v1/user/profile", id)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("profile while blocked = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if code := errorCode(t, rec); code != "account_blocked" {
|
||||||
|
t.Fatalf("error code = %q, want account_blocked", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The block-status probe stays reachable and resolves the reason to the account's language.
|
||||||
|
bs := blockStatus(t, srv, id)
|
||||||
|
if !bs.Blocked || !bs.Permanent {
|
||||||
|
t.Fatalf("block-status = %+v, want blocked+permanent", bs)
|
||||||
|
}
|
||||||
|
if bs.Reason != "Спам" {
|
||||||
|
t.Errorf("reason = %q, want the Russian snapshot Спам", bs.Reason)
|
||||||
|
}
|
||||||
|
if bs.Until != "" {
|
||||||
|
t.Errorf("until = %q, want empty for a permanent block", bs.Until)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unblock restores access.
|
||||||
|
if err := accounts.LiftSuspension(ctx, id); err != nil {
|
||||||
|
t.Fatalf("lift: %v", err)
|
||||||
|
}
|
||||||
|
if rec := userGet(t, srv, "/api/v1/user/profile", id); rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("profile after unblock = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSuspensionGateTemporaryUntil checks a temporary block reports its expiry through
|
||||||
|
// block-status as a future RFC3339 instant and not permanent.
|
||||||
|
func TestSuspensionGateTemporaryUntil(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
accounts := account.NewStore(testDB)
|
||||||
|
srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
|
||||||
|
id := provisionAccount(t)
|
||||||
|
|
||||||
|
until := time.Now().Add(24 * time.Hour)
|
||||||
|
if _, err := accounts.Suspend(ctx, id, &until, "", "", nil); err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
bs := blockStatus(t, srv, id)
|
||||||
|
if !bs.Blocked || bs.Permanent {
|
||||||
|
t.Fatalf("block-status = %+v, want blocked, not permanent", bs)
|
||||||
|
}
|
||||||
|
parsed, err := time.Parse(time.RFC3339, bs.Until)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("until %q is not RFC3339: %v", bs.Until, err)
|
||||||
|
}
|
||||||
|
if !parsed.After(time.Now()) {
|
||||||
|
t.Errorf("until = %v, want a future instant", parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// userGet issues an authenticated GET (X-User-ID) against the assembled server.
|
||||||
|
func userGet(t *testing.T, srv *server.Server, path string, id uuid.UUID) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
req.Header.Set("X-User-ID", id.String())
|
||||||
|
srv.Handler().ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockStatus fetches and decodes the /block-status payload, asserting a 200.
|
||||||
|
func blockStatus(t *testing.T, srv *server.Server, id uuid.UUID) blockStatusBody {
|
||||||
|
t.Helper()
|
||||||
|
rec := userGet(t, srv, "/api/v1/user/block-status", id)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("block-status status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
var b blockStatusBody
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil {
|
||||||
|
t.Fatalf("decode block-status: %v", err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorCode extracts the stable code from the backend error envelope.
|
||||||
|
func errorCode(t *testing.T, rec *httptest.ResponseRecorder) string {
|
||||||
|
t.Helper()
|
||||||
|
var e struct {
|
||||||
|
Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
||||||
|
t.Fatalf("decode error envelope: %v", err)
|
||||||
|
}
|
||||||
|
return e.Error.Code
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSuspensionRoundTrip covers a permanent block: a fresh account is not blocked, Suspend with
|
||||||
|
// a reason makes CurrentSuspension report it (permanent, with the en/ru snapshot resolved per
|
||||||
|
// language), and LiftSuspension reverses it.
|
||||||
|
func TestSuspensionRoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
id := provisionAccount(t)
|
||||||
|
|
||||||
|
if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok {
|
||||||
|
t.Fatalf("fresh CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Suspend(ctx, id, nil, "Spam", "Спам", nil); err != nil {
|
||||||
|
t.Fatalf("suspend: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
susp, ok, err := store.CurrentSuspension(ctx, id)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("CurrentSuspension after suspend = (ok %v, err %v), want (true, nil)", ok, err)
|
||||||
|
}
|
||||||
|
if !susp.Permanent() {
|
||||||
|
t.Errorf("Permanent() = false, want true for a nil-until block")
|
||||||
|
}
|
||||||
|
if got := susp.LocalizedReason("en"); got != "Spam" {
|
||||||
|
t.Errorf("LocalizedReason(en) = %q, want Spam", got)
|
||||||
|
}
|
||||||
|
if got := susp.LocalizedReason("ru"); got != "Спам" {
|
||||||
|
t.Errorf("LocalizedReason(ru) = %q, want Спам", got)
|
||||||
|
}
|
||||||
|
if got := susp.LocalizedReason("fr"); got != "Spam" {
|
||||||
|
t.Errorf("LocalizedReason(fr) = %q, want the English fallback Spam", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.LiftSuspension(ctx, id); err != nil {
|
||||||
|
t.Fatalf("lift: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok {
|
||||||
|
t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTemporarySuspensionExpiry checks the until boundary: a future expiry is in force (and not
|
||||||
|
// permanent), while a past expiry is already not in force.
|
||||||
|
func TestTemporarySuspensionExpiry(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
|
||||||
|
future := provisionAccount(t)
|
||||||
|
until := time.Now().Add(time.Hour)
|
||||||
|
if _, err := store.Suspend(ctx, future, &until, "", "", nil); err != nil {
|
||||||
|
t.Fatalf("suspend future: %v", err)
|
||||||
|
}
|
||||||
|
susp, ok, err := store.CurrentSuspension(ctx, future)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("future CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err)
|
||||||
|
}
|
||||||
|
if susp.Permanent() {
|
||||||
|
t.Error("Permanent() = true, want false for a dated block")
|
||||||
|
}
|
||||||
|
if susp.BlockedUntil == nil || !susp.BlockedUntil.After(time.Now()) {
|
||||||
|
t.Errorf("BlockedUntil = %v, want a future instant", susp.BlockedUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
past := provisionAccount(t)
|
||||||
|
expired := time.Now().Add(-time.Hour)
|
||||||
|
if _, err := store.Suspend(ctx, past, &expired, "", "", nil); err != nil {
|
||||||
|
t.Fatalf("suspend past: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok, err := store.CurrentSuspension(ctx, past); err != nil || ok {
|
||||||
|
t.Fatalf("expired CurrentSuspension = (ok %v, err %v), want (false, nil)", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSuspensionStrongestWins checks that with overlapping blocks CurrentSuspension returns the
|
||||||
|
// strongest — a permanent block outranks any dated one — and that LiftSuspension clears them all.
|
||||||
|
func TestSuspensionStrongestWins(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
id := provisionAccount(t)
|
||||||
|
|
||||||
|
soon := time.Now().Add(time.Hour)
|
||||||
|
if _, err := store.Suspend(ctx, id, &soon, "", "", nil); err != nil {
|
||||||
|
t.Fatalf("suspend dated: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Suspend(ctx, id, nil, "", "", nil); err != nil {
|
||||||
|
t.Fatalf("suspend permanent: %v", err)
|
||||||
|
}
|
||||||
|
later := time.Now().Add(2 * time.Hour)
|
||||||
|
if _, err := store.Suspend(ctx, id, &later, "", "", nil); err != nil {
|
||||||
|
t.Fatalf("suspend dated-later: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
susp, ok, err := store.CurrentSuspension(ctx, id)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err)
|
||||||
|
}
|
||||||
|
if !susp.Permanent() {
|
||||||
|
t.Error("strongest block should be the permanent one")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.LiftSuspension(ctx, id); err != nil {
|
||||||
|
t.Fatalf("lift: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok, err := store.CurrentSuspension(ctx, id); err != nil || ok {
|
||||||
|
t.Fatalf("CurrentSuspension after lift = (ok %v, err %v), want (false, nil)", ok, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReasonsCRUD covers the operator-editable picklist: create, list, get, update, delete, and
|
||||||
|
// the ErrNotFound paths.
|
||||||
|
func TestReasonsCRUD(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
|
||||||
|
created, err := store.CreateReason(ctx, "Cheating", "Читерство")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create reason: %v", err)
|
||||||
|
}
|
||||||
|
if created.ID == uuid.Nil || created.TextEn != "Cheating" || created.TextRu != "Читерство" {
|
||||||
|
t.Fatalf("created reason = %+v, want populated en/ru and id", created)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := store.GetReason(ctx, created.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get reason: %v", err)
|
||||||
|
}
|
||||||
|
if got.TextEn != "Cheating" {
|
||||||
|
t.Errorf("get TextEn = %q, want Cheating", got.TextEn)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := store.ListReasons(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list reasons: %v", err)
|
||||||
|
}
|
||||||
|
if !containsReason(list, created.ID) {
|
||||||
|
t.Error("created reason missing from ListReasons")
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := store.UpdateReason(ctx, created.ID, "Abuse", "Оскорбления")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update reason: %v", err)
|
||||||
|
}
|
||||||
|
if updated.TextEn != "Abuse" || updated.TextRu != "Оскорбления" {
|
||||||
|
t.Errorf("updated reason = %+v, want Abuse/Оскорбления", updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.DeleteReason(ctx, created.ID); err != nil {
|
||||||
|
t.Fatalf("delete reason: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.GetReason(ctx, created.ID); !errors.Is(err, account.ErrNotFound) {
|
||||||
|
t.Errorf("get deleted reason = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if _, err := store.UpdateReason(ctx, uuid.New(), "x", "y"); !errors.Is(err, account.ErrNotFound) {
|
||||||
|
t.Errorf("update missing reason = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSuspensionReasonSnapshotSurvivesDelete checks that a block keeps the reason text it was
|
||||||
|
// stamped with even after the picklist entry is deleted (the FK nulls reason_id, the snapshot
|
||||||
|
// stays).
|
||||||
|
func TestSuspensionReasonSnapshotSurvivesDelete(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
id := provisionAccount(t)
|
||||||
|
|
||||||
|
reason, err := store.CreateReason(ctx, "Toxicity", "Токсичность")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create reason: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Suspend(ctx, id, nil, reason.TextEn, reason.TextRu, &reason.ID); err != nil {
|
||||||
|
t.Fatalf("suspend with reason: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.DeleteReason(ctx, reason.ID); err != nil {
|
||||||
|
t.Fatalf("delete reason: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
susp, ok, err := store.CurrentSuspension(ctx, id)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("CurrentSuspension = (ok %v, err %v), want (true, nil)", ok, err)
|
||||||
|
}
|
||||||
|
if susp.LocalizedReason("en") != "Toxicity" || susp.LocalizedReason("ru") != "Токсичность" {
|
||||||
|
t.Errorf("reason snapshot lost after delete: en=%q ru=%q", susp.LocalizedReason("en"), susp.LocalizedReason("ru"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsReason reports whether list holds a reason with the given id.
|
||||||
|
func containsReason(list []account.Reason, id uuid.UUID) bool {
|
||||||
|
for _, r := range list {
|
||||||
|
if r.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AccountSuspensions struct {
|
||||||
|
SuspensionID uuid.UUID `sql:"primary_key"`
|
||||||
|
AccountID uuid.UUID
|
||||||
|
BlockedAt time.Time
|
||||||
|
BlockedUntil *time.Time
|
||||||
|
ReasonEn *string
|
||||||
|
ReasonRu *string
|
||||||
|
ReasonID *uuid.UUID
|
||||||
|
LiftedAt *time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SuspensionReasons struct {
|
||||||
|
ReasonID uuid.UUID `sql:"primary_key"`
|
||||||
|
TextEn string
|
||||||
|
TextRu string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var AccountSuspensions = newAccountSuspensionsTable("backend", "account_suspensions", "")
|
||||||
|
|
||||||
|
type accountSuspensionsTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
SuspensionID postgres.ColumnString
|
||||||
|
AccountID postgres.ColumnString
|
||||||
|
BlockedAt postgres.ColumnTimestampz
|
||||||
|
BlockedUntil postgres.ColumnTimestampz
|
||||||
|
ReasonEn postgres.ColumnString
|
||||||
|
ReasonRu postgres.ColumnString
|
||||||
|
ReasonID postgres.ColumnString
|
||||||
|
LiftedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccountSuspensionsTable struct {
|
||||||
|
accountSuspensionsTable
|
||||||
|
|
||||||
|
EXCLUDED accountSuspensionsTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new AccountSuspensionsTable with assigned alias
|
||||||
|
func (a AccountSuspensionsTable) AS(alias string) *AccountSuspensionsTable {
|
||||||
|
return newAccountSuspensionsTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new AccountSuspensionsTable with assigned schema name
|
||||||
|
func (a AccountSuspensionsTable) FromSchema(schemaName string) *AccountSuspensionsTable {
|
||||||
|
return newAccountSuspensionsTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new AccountSuspensionsTable with assigned table prefix
|
||||||
|
func (a AccountSuspensionsTable) WithPrefix(prefix string) *AccountSuspensionsTable {
|
||||||
|
return newAccountSuspensionsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new AccountSuspensionsTable with assigned table suffix
|
||||||
|
func (a AccountSuspensionsTable) WithSuffix(suffix string) *AccountSuspensionsTable {
|
||||||
|
return newAccountSuspensionsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccountSuspensionsTable(schemaName, tableName, alias string) *AccountSuspensionsTable {
|
||||||
|
return &AccountSuspensionsTable{
|
||||||
|
accountSuspensionsTable: newAccountSuspensionsTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newAccountSuspensionsTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccountSuspensionsTableImpl(schemaName, tableName, alias string) accountSuspensionsTable {
|
||||||
|
var (
|
||||||
|
SuspensionIDColumn = postgres.StringColumn("suspension_id")
|
||||||
|
AccountIDColumn = postgres.StringColumn("account_id")
|
||||||
|
BlockedAtColumn = postgres.TimestampzColumn("blocked_at")
|
||||||
|
BlockedUntilColumn = postgres.TimestampzColumn("blocked_until")
|
||||||
|
ReasonEnColumn = postgres.StringColumn("reason_en")
|
||||||
|
ReasonRuColumn = postgres.StringColumn("reason_ru")
|
||||||
|
ReasonIDColumn = postgres.StringColumn("reason_id")
|
||||||
|
LiftedAtColumn = postgres.TimestampzColumn("lifted_at")
|
||||||
|
allColumns = postgres.ColumnList{SuspensionIDColumn, AccountIDColumn, BlockedAtColumn, BlockedUntilColumn, ReasonEnColumn, ReasonRuColumn, ReasonIDColumn, LiftedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{AccountIDColumn, BlockedAtColumn, BlockedUntilColumn, ReasonEnColumn, ReasonRuColumn, ReasonIDColumn, LiftedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{BlockedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return accountSuspensionsTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
SuspensionID: SuspensionIDColumn,
|
||||||
|
AccountID: AccountIDColumn,
|
||||||
|
BlockedAt: BlockedAtColumn,
|
||||||
|
BlockedUntil: BlockedUntilColumn,
|
||||||
|
ReasonEn: ReasonEnColumn,
|
||||||
|
ReasonRu: ReasonRuColumn,
|
||||||
|
ReasonID: ReasonIDColumn,
|
||||||
|
LiftedAt: LiftedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// Code generated by go-jet DO NOT EDIT.
|
||||||
|
//
|
||||||
|
// WARNING: Changes to this file may cause incorrect behavior
|
||||||
|
// and will be lost if the code is regenerated
|
||||||
|
//
|
||||||
|
|
||||||
|
package table
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-jet/jet/v2/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
var SuspensionReasons = newSuspensionReasonsTable("backend", "suspension_reasons", "")
|
||||||
|
|
||||||
|
type suspensionReasonsTable struct {
|
||||||
|
postgres.Table
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
ReasonID postgres.ColumnString
|
||||||
|
TextEn postgres.ColumnString
|
||||||
|
TextRu postgres.ColumnString
|
||||||
|
CreatedAt postgres.ColumnTimestampz
|
||||||
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
|
||||||
|
AllColumns postgres.ColumnList
|
||||||
|
MutableColumns postgres.ColumnList
|
||||||
|
DefaultColumns postgres.ColumnList
|
||||||
|
}
|
||||||
|
|
||||||
|
type SuspensionReasonsTable struct {
|
||||||
|
suspensionReasonsTable
|
||||||
|
|
||||||
|
EXCLUDED suspensionReasonsTable
|
||||||
|
}
|
||||||
|
|
||||||
|
// AS creates new SuspensionReasonsTable with assigned alias
|
||||||
|
func (a SuspensionReasonsTable) AS(alias string) *SuspensionReasonsTable {
|
||||||
|
return newSuspensionReasonsTable(a.SchemaName(), a.TableName(), alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema creates new SuspensionReasonsTable with assigned schema name
|
||||||
|
func (a SuspensionReasonsTable) FromSchema(schemaName string) *SuspensionReasonsTable {
|
||||||
|
return newSuspensionReasonsTable(schemaName, a.TableName(), a.Alias())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPrefix creates new SuspensionReasonsTable with assigned table prefix
|
||||||
|
func (a SuspensionReasonsTable) WithPrefix(prefix string) *SuspensionReasonsTable {
|
||||||
|
return newSuspensionReasonsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSuffix creates new SuspensionReasonsTable with assigned table suffix
|
||||||
|
func (a SuspensionReasonsTable) WithSuffix(suffix string) *SuspensionReasonsTable {
|
||||||
|
return newSuspensionReasonsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSuspensionReasonsTable(schemaName, tableName, alias string) *SuspensionReasonsTable {
|
||||||
|
return &SuspensionReasonsTable{
|
||||||
|
suspensionReasonsTable: newSuspensionReasonsTableImpl(schemaName, tableName, alias),
|
||||||
|
EXCLUDED: newSuspensionReasonsTableImpl("", "excluded", ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSuspensionReasonsTableImpl(schemaName, tableName, alias string) suspensionReasonsTable {
|
||||||
|
var (
|
||||||
|
ReasonIDColumn = postgres.StringColumn("reason_id")
|
||||||
|
TextEnColumn = postgres.StringColumn("text_en")
|
||||||
|
TextRuColumn = postgres.StringColumn("text_ru")
|
||||||
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
|
allColumns = postgres.ColumnList{ReasonIDColumn, TextEnColumn, TextRuColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
mutableColumns = postgres.ColumnList{TextEnColumn, TextRuColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn}
|
||||||
|
)
|
||||||
|
|
||||||
|
return suspensionReasonsTable{
|
||||||
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
|
//Columns
|
||||||
|
ReasonID: ReasonIDColumn,
|
||||||
|
TextEn: TextEnColumn,
|
||||||
|
TextRu: TextRuColumn,
|
||||||
|
CreatedAt: CreatedAtColumn,
|
||||||
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
|
||||||
|
AllColumns: allColumns,
|
||||||
|
MutableColumns: mutableColumns,
|
||||||
|
DefaultColumns: defaultColumns,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ package table
|
|||||||
// this method only once at the beginning of the program.
|
// this method only once at the beginning of the program.
|
||||||
func UseSchema(schema string) {
|
func UseSchema(schema string) {
|
||||||
AccountStats = AccountStats.FromSchema(schema)
|
AccountStats = AccountStats.FromSchema(schema)
|
||||||
|
AccountSuspensions = AccountSuspensions.FromSchema(schema)
|
||||||
Accounts = Accounts.FromSchema(schema)
|
Accounts = Accounts.FromSchema(schema)
|
||||||
Blocks = Blocks.FromSchema(schema)
|
Blocks = Blocks.FromSchema(schema)
|
||||||
ChatMessages = ChatMessages.FromSchema(schema)
|
ChatMessages = ChatMessages.FromSchema(schema)
|
||||||
@@ -28,4 +29,5 @@ func UseSchema(schema string) {
|
|||||||
Games = Games.FromSchema(schema)
|
Games = Games.FromSchema(schema)
|
||||||
Identities = Identities.FromSchema(schema)
|
Identities = Identities.FromSchema(schema)
|
||||||
Sessions = Sessions.FromSchema(schema)
|
Sessions = Sessions.FromSchema(schema)
|
||||||
|
SuspensionReasons = SuspensionReasons.FromSchema(schema)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Manual account blocking ("suspension"), the operator's hard counterpart to the soft,
|
||||||
|
-- reversible accounts.flagged_high_rate_at marker. Named "suspension" throughout the schema
|
||||||
|
-- and Go to stay distinct from the peer-to-peer `blocks` table (one player muting another);
|
||||||
|
-- the wire/UI vocabulary the player sees is "blocked". A suspension forces the player to
|
||||||
|
-- forfeit every active game and replaces their whole UI with a terminal "blocked" screen until
|
||||||
|
-- it is lifted or (for a temporary one) expires. See docs/ARCHITECTURE.md §12.
|
||||||
|
SET search_path = backend, pg_catalog;
|
||||||
|
|
||||||
|
-- The operator-editable reason picklist, one row per reason with its English and Russian text.
|
||||||
|
-- A suspension snapshots the chosen text (account_suspensions.reason_en/ru), so editing or
|
||||||
|
-- deleting a reason here never changes or breaks a reason already shown to a blocked player.
|
||||||
|
CREATE TABLE suspension_reasons (
|
||||||
|
reason_id uuid PRIMARY KEY,
|
||||||
|
text_en text NOT NULL,
|
||||||
|
text_ru text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The block history: one row per block. blocked_until is NULL for a permanent block and the
|
||||||
|
-- expiry instant for a temporary one; lifted_at is stamped when an operator unblocks early.
|
||||||
|
-- reason_en/reason_ru are the text snapshot taken at block time (NULL when no reason was
|
||||||
|
-- cited); reason_id keeps a loose link to the picklist entry for later analytics and is nulled
|
||||||
|
-- if that entry is deleted (the snapshot remains the source of truth shown to the player). An
|
||||||
|
-- account is currently blocked when its newest row has lifted_at IS NULL AND (blocked_until IS
|
||||||
|
-- NULL OR blocked_until > now()).
|
||||||
|
CREATE TABLE account_suspensions (
|
||||||
|
suspension_id uuid PRIMARY KEY,
|
||||||
|
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||||
|
blocked_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
blocked_until timestamptz,
|
||||||
|
reason_en text,
|
||||||
|
reason_ru text,
|
||||||
|
reason_id uuid REFERENCES suspension_reasons (reason_id) ON DELETE SET NULL,
|
||||||
|
lifted_at timestamptz
|
||||||
|
);
|
||||||
|
-- The enforcement gate looks up the newest suspension for an account on every authenticated
|
||||||
|
-- request; this index serves that "latest by account" probe.
|
||||||
|
CREATE INDEX account_suspensions_account_idx ON account_suspensions (account_id, blocked_at DESC);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
SET search_path = backend, pg_catalog;
|
||||||
|
DROP TABLE IF EXISTS account_suspensions;
|
||||||
|
DROP TABLE IF EXISTS suspension_reasons;
|
||||||
@@ -46,6 +46,9 @@ func (s *Server) registerRoutes() {
|
|||||||
u.GET("/profile", s.handleProfile)
|
u.GET("/profile", s.handleProfile)
|
||||||
u.PUT("/profile", s.handleUpdateProfile)
|
u.PUT("/profile", s.handleUpdateProfile)
|
||||||
u.GET("/stats", s.handleStats)
|
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 {
|
if s.links != nil {
|
||||||
// Account linking & merge. The request step always mails a code;
|
// 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))
|
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.
|
// handleStats returns the caller's lifetime statistics.
|
||||||
func (s *Server) handleStats(c *gin.Context) {
|
func (s *Server) handleStats(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.GET("/users/:id", s.consoleUserDetail)
|
gm.GET("/users/:id", s.consoleUserDetail)
|
||||||
gm.POST("/users/:id/message", s.consoleUserMessage)
|
gm.POST("/users/:id/message", s.consoleUserMessage)
|
||||||
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
|
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("/throttled", s.consoleThrottled)
|
||||||
gm.GET("/games", s.consoleGames)
|
gm.GET("/games", s.consoleGames)
|
||||||
gm.GET("/games/:id", s.consoleGameDetail)
|
gm.GET("/games/:id", s.consoleGameDetail)
|
||||||
@@ -291,6 +297,20 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
view.MoveChart = adminconsole.MoveDurationChart(cps)
|
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)
|
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())
|
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.
|
// variantVersions builds the per-variant resident-version summary from the registry.
|
||||||
func (s *Server) variantVersions() []adminconsole.VariantVersions {
|
func (s *Server) variantVersions() []adminconsole.VariantVersions {
|
||||||
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
|
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
|
||||||
|
|||||||
@@ -77,3 +77,40 @@ func sameOrigin(r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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.public = v1.Group("/public")
|
||||||
s.user = v1.Group("/user")
|
s.user = v1.Group("/user")
|
||||||
s.user.Use(RequireUserID())
|
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")
|
s.internal = v1.Group("/internal")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -700,6 +700,7 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
|||||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||||
|
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
|
||||||
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
|
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
|
||||||
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
|
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
|
||||||
|
|
||||||
@@ -707,6 +708,25 @@ This is an explicit, accepted MVP risk: compromise of the gateway↔backend
|
|||||||
network segment defeats backend authentication. Mitigated by network isolation;
|
network segment defeats backend authentication. Mitigated by network isolation;
|
||||||
mutual auth is a future hardening step.
|
mutual auth is a future hardening step.
|
||||||
|
|
||||||
|
**Manual account block (suspension).** Beyond the soft, reversible high-rate flag (§11, never a
|
||||||
|
gate), an operator can hard-block an account from the admin console — permanently or until a
|
||||||
|
date, with an optional reason chosen from an editable en+ru picklist. A block is a row in
|
||||||
|
`account_suspensions` (the chosen reason's text is **snapshotted**, so editing or deleting a
|
||||||
|
picklist entry never changes what an already-blocked player is shown); it is named *suspension*
|
||||||
|
to stay distinct from the peer-to-peer `blocks` table. Enforcement is a backend middleware gate
|
||||||
|
after `X-User-ID`: every `/api/v1/user/*` route except the block-status probe refuses a blocked
|
||||||
|
account with **HTTP 403 + code `account_blocked`**, which threads through the gateway unchanged as
|
||||||
|
the Execute `result_code`, so the UI detects the block from *any* call and replaces every screen
|
||||||
|
with a terminal "blocked" screen, stopping all push/poll. The one exempt route,
|
||||||
|
`GET /api/v1/user/block-status`, returns the expiry and the reason resolved to the account's
|
||||||
|
language so the blocked client can render the message. Sessions are **not** revoked on block (a
|
||||||
|
revoked token would fail session resolution at the gateway *before* the gate, sending the UI to
|
||||||
|
login instead of the blocked screen). A block instantly **forfeits** every active game the player
|
||||||
|
is in (the opponent wins, exactly as a resignation — the engine resigns off-turn) and cancels
|
||||||
|
their open matchmaking games; a temporary block lapses automatically once its expiry passes (no
|
||||||
|
sweeper — the gate recomputes against `now`). No operator identity is recorded (shared
|
||||||
|
Basic-Auth).
|
||||||
|
|
||||||
**Short numeric codes** (email confirm-codes and friend codes) are stored
|
**Short numeric codes** (email confirm-codes and friend codes) are stored
|
||||||
only as SHA-256 hashes and are short-lived and single-use. The unauthenticated
|
only as SHA-256 hashes and are short-lived and single-use. The unauthenticated
|
||||||
email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem**
|
email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem**
|
||||||
|
|||||||
@@ -208,3 +208,15 @@ account sustaining rejections past a tunable threshold is flagged automatically
|
|||||||
the marker is reversible, shown as a badge in the user list and on the user card, and
|
the marker is reversible, shown as a badge in the user list and on the user card, and
|
||||||
**never blocks play**; the operator reviews and clears it from the user card. There is
|
**never blocks play**; the operator reviews and clears it from the user card. There is
|
||||||
no automatic ban.
|
no automatic ban.
|
||||||
|
|
||||||
|
The console also lets an operator **manually block** an account — the hard counterpart to the
|
||||||
|
soft high-rate flag. From the user card the operator blocks the account **permanently** or
|
||||||
|
**until a date** (presets: 1 day / 3 days / 1 week / 1 month, or a custom date in UTC), optionally
|
||||||
|
citing a **reason** chosen from an editable **en+ru picklist** managed on a **Reasons** page; the
|
||||||
|
blocked player sees the reason in their own language. Blocking instantly **forfeits** the player's
|
||||||
|
active games (each opponent wins, as if the player resigned) and removes them from matchmaking. A
|
||||||
|
blocked player — on opening the app or on any action — is shown a terminal screen: *"Your account
|
||||||
|
is blocked"* (permanent) or *"…blocked until <date, time>"* (temporary, in their timezone),
|
||||||
|
plus the reason when one was given, and the app stops all background traffic with the server. A
|
||||||
|
temporary block lifts itself when it expires; the operator can also **unblock** from the user card
|
||||||
|
at any time (games already lost stay lost).
|
||||||
|
|||||||
@@ -213,3 +213,16 @@ Telegram-identity) или **отправить пост в игровой кан
|
|||||||
автоматически — маркер обратим, виден бейджем в списке пользователей и на карточке
|
автоматически — маркер обратим, виден бейджем в списке пользователей и на карточке
|
||||||
аккаунта и **никогда не блокирует игру**; оператор рассматривает и снимает его с
|
аккаунта и **никогда не блокирует игру**; оператор рассматривает и снимает его с
|
||||||
карточки пользователя. Автоматического бана нет.
|
карточки пользователя. Автоматического бана нет.
|
||||||
|
|
||||||
|
Консоль также позволяет оператору **вручную заблокировать** аккаунт — жёсткий аналог мягкого
|
||||||
|
high-rate флага. С карточки пользователя оператор блокирует аккаунт **навсегда** или **до даты**
|
||||||
|
(пресеты: 1 день / 3 дня / 1 неделя / 1 месяц либо произвольная дата в UTC), при желании указав
|
||||||
|
**причину** из редактируемого **списка en+ru**, который ведётся на странице **Reasons**;
|
||||||
|
заблокированный игрок видит причину на своём языке. Блокировка мгновенно засчитывает **поражение**
|
||||||
|
во всех активных партиях игрока (каждый соперник побеждает, как при сдаче) и убирает его из
|
||||||
|
матчмейкинга. Заблокированный игрок — при открытии приложения или любом действии — видит
|
||||||
|
терминальный экран: *«Ваша учётная запись заблокирована»* (постоянная) или *«…заблокирована до
|
||||||
|
<дата, время>»* (временная, в его часовом поясе), плюс причину, если она была указана, и
|
||||||
|
приложение прекращает любое фоновое общение с сервером. Временная блокировка снимается сама по
|
||||||
|
истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент
|
||||||
|
(уже проигранные партии не возвращаются).
|
||||||
|
|||||||
@@ -224,6 +224,25 @@ func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BlockStatusResp is the caller's current manual-block state. Until is an RFC3339 UTC instant for
|
||||||
|
// a temporary block, empty for a permanent one or when not blocked; Reason is resolved to the
|
||||||
|
// account's language, empty when none was cited.
|
||||||
|
type BlockStatusResp struct {
|
||||||
|
Blocked bool `json:"blocked"`
|
||||||
|
Permanent bool `json:"permanent"`
|
||||||
|
Until string `json:"until"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlockStatus fetches the caller's current block. It is the one user endpoint a blocked account
|
||||||
|
// can still reach (the backend's suspension gate exempts it), so the UI can render the blocked
|
||||||
|
// screen.
|
||||||
|
func (c *Client) BlockStatus(ctx context.Context, userID string) (BlockStatusResp, error) {
|
||||||
|
var out BlockStatusResp
|
||||||
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/block-status", userID, "", nil, &out)
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
// SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet
|
// SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet
|
||||||
// index.
|
// index.
|
||||||
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (MoveResultResp, error) {
|
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID string, tiles []PlayTileJSON) (MoveResultResp, error) {
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
return b.FinishedBytes()
|
return b.FinishedBytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// encodeBlockStatus builds a BlockStatus payload.
|
||||||
|
func encodeBlockStatus(s backendclient.BlockStatusResp) []byte {
|
||||||
|
b := flatbuffers.NewBuilder(128)
|
||||||
|
until := b.CreateString(s.Until)
|
||||||
|
reason := b.CreateString(s.Reason)
|
||||||
|
fb.BlockStatusStart(b)
|
||||||
|
fb.BlockStatusAddBlocked(b, s.Blocked)
|
||||||
|
fb.BlockStatusAddPermanent(b, s.Permanent)
|
||||||
|
fb.BlockStatusAddUntil(b, until)
|
||||||
|
fb.BlockStatusAddReason(b, reason)
|
||||||
|
b.Finish(fb.BlockStatusEnd(b))
|
||||||
|
return b.FinishedBytes()
|
||||||
|
}
|
||||||
|
|
||||||
// encodeLinkResult builds a LinkResult payload. A switched-session token
|
// encodeLinkResult builds a LinkResult payload. A switched-session token
|
||||||
// (a guest initiator whose durable counterpart won) is carried as a nested Session
|
// (a guest initiator whose durable counterpart won) is carried as a nested Session
|
||||||
// for the client to adopt; it is omitted otherwise. supportedLangs is the variant
|
// for the client to adopt; it is omitted otherwise. supportedLangs is the variant
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
MsgAuthEmailReq = "auth.email.request"
|
MsgAuthEmailReq = "auth.email.request"
|
||||||
MsgAuthEmailLogin = "auth.email.login"
|
MsgAuthEmailLogin = "auth.email.login"
|
||||||
MsgProfileGet = "profile.get"
|
MsgProfileGet = "profile.get"
|
||||||
|
MsgBlockStatus = "account.block_status"
|
||||||
MsgGameSubmitPlay = "game.submit_play"
|
MsgGameSubmitPlay = "game.submit_play"
|
||||||
MsgGameState = "game.state"
|
MsgGameState = "game.state"
|
||||||
MsgLobbyEnqueue = "lobby.enqueue"
|
MsgLobbyEnqueue = "lobby.enqueue"
|
||||||
@@ -95,6 +96,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
|
|||||||
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
|
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
|
||||||
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend, defaultLanguages), Email: true}
|
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend, defaultLanguages), Email: true}
|
||||||
r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true}
|
r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true}
|
||||||
|
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
|
||||||
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
||||||
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
|
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
|
||||||
r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true}
|
r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true}
|
||||||
@@ -199,6 +201,16 @@ func profileHandler(backend *backendclient.Client) Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func blockStatusHandler(backend *backendclient.Client) Handler {
|
||||||
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
|
bs, err := backend.BlockStatus(ctx, req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return encodeBlockStatus(bs), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func submitPlayHandler(backend *backendclient.Client) Handler {
|
func submitPlayHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0)
|
in := fb.GetRootAsSubmitPlayRequest(req.Payload, 0)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package transcode_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/transcode"
|
||||||
|
fb "scrabble/pkg/fbs/scrabblefb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBlockStatusRoundTrip checks the account.block_status op forwards to the backend block-status
|
||||||
|
// endpoint and encodes its JSON into the BlockStatus FlatBuffer.
|
||||||
|
func TestBlockStatusRoundTrip(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/user/block-status" {
|
||||||
|
t.Errorf("path = %q, want /api/v1/user/block-status", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"blocked":true,"permanent":false,"until":"2026-07-01T12:00:00Z","reason":"Спам"}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, ok := reg.Lookup(transcode.MsgBlockStatus)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("account.block_status not registered")
|
||||||
|
}
|
||||||
|
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handler: %v", err)
|
||||||
|
}
|
||||||
|
bs := fb.GetRootAsBlockStatus(payload, 0)
|
||||||
|
if !bs.Blocked() || bs.Permanent() {
|
||||||
|
t.Fatalf("blocked=%v permanent=%v, want true/false", bs.Blocked(), bs.Permanent())
|
||||||
|
}
|
||||||
|
if string(bs.Until()) != "2026-07-01T12:00:00Z" {
|
||||||
|
t.Errorf("until = %q, want the forwarded instant", bs.Until())
|
||||||
|
}
|
||||||
|
if string(bs.Reason()) != "Спам" {
|
||||||
|
t.Errorf("reason = %q, want Спам", bs.Reason())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBlockedBackendSurfacesDomainCode checks that a backend 403 with code account_blocked (the
|
||||||
|
// suspension gate) surfaces as a domain code, which the Execute layer turns into the envelope
|
||||||
|
// result_code the UI keys off.
|
||||||
|
func TestBlockedBackendSurfacesDomainCode(t *testing.T) {
|
||||||
|
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
_, _ = w.Write([]byte(`{"error":{"code":"account_blocked","message":"account is blocked"}}`))
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reg := transcode.NewRegistry(backend, nil)
|
||||||
|
op, _ := reg.Lookup(transcode.MsgProfileGet) // any gated op hits the same gate
|
||||||
|
_, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error from a blocked backend response")
|
||||||
|
}
|
||||||
|
code, ok := transcode.DomainCode(err)
|
||||||
|
if !ok || code != "account_blocked" {
|
||||||
|
t.Fatalf("DomainCode = (%q, %v), want (account_blocked, true)", code, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,6 +150,18 @@ table Profile {
|
|||||||
notifications_in_app_only:bool = true;
|
notifications_in_app_only:bool = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
||||||
|
// returns the "account_blocked" result code, then replaces every screen with the terminal blocked
|
||||||
|
// screen and stops all push/poll. permanent is false for a temporary block; until is an RFC3339
|
||||||
|
// UTC instant for a temporary block and empty for a permanent one; reason is the operator-set text
|
||||||
|
// resolved to the account's language, empty when none was cited.
|
||||||
|
table BlockStatus {
|
||||||
|
blocked:bool;
|
||||||
|
permanent:bool;
|
||||||
|
until:string;
|
||||||
|
reason:string;
|
||||||
|
}
|
||||||
|
|
||||||
// --- game (authenticated) ---
|
// --- game (authenticated) ---
|
||||||
|
|
||||||
// SubmitPlayRequest places tiles on the player's turn; the backend infers the play's
|
// SubmitPlayRequest places tiles on the player's turn; the backend infers the play's
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package scrabblefb
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BlockStatus struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsBlockStatus(buf []byte, offset flatbuffers.UOffsetT) *BlockStatus {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &BlockStatus{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishBlockStatusBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsBlockStatus(buf []byte, offset flatbuffers.UOffsetT) *BlockStatus {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &BlockStatus{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedBlockStatusBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BlockStatus) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BlockStatus) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BlockStatus) Blocked() bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BlockStatus) MutateBlocked(n bool) bool {
|
||||||
|
return rcv._tab.MutateBoolSlot(4, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BlockStatus) Permanent() bool {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BlockStatus) MutatePermanent(n bool) bool {
|
||||||
|
return rcv._tab.MutateBoolSlot(6, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BlockStatus) Until() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BlockStatus) Reason() []byte {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BlockStatusStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(4)
|
||||||
|
}
|
||||||
|
func BlockStatusAddBlocked(builder *flatbuffers.Builder, blocked bool) {
|
||||||
|
builder.PrependBoolSlot(0, blocked, false)
|
||||||
|
}
|
||||||
|
func BlockStatusAddPermanent(builder *flatbuffers.Builder, permanent bool) {
|
||||||
|
builder.PrependBoolSlot(1, permanent, false)
|
||||||
|
}
|
||||||
|
func BlockStatusAddUntil(builder *flatbuffers.Builder, until flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(until), 0)
|
||||||
|
}
|
||||||
|
func BlockStatusAddReason(builder *flatbuffers.Builder, reason flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(reason), 0)
|
||||||
|
}
|
||||||
|
func BlockStatusEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { expect, test } from './fixtures';
|
||||||
|
|
||||||
|
// The terminal blocked screen replaces every screen. The mock never blocks of its own accord, so
|
||||||
|
// the e2e drives it through the mock-mode __block seam (lib/app.svelte.ts), mirroring what
|
||||||
|
// enterBlocked does when any call returns the account_blocked code.
|
||||||
|
|
||||||
|
test('a temporary block shows the dated message and reason, replacing the lobby', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
await expect(page.getByText('Your turn')).toBeVisible(); // reached the lobby
|
||||||
|
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(window as unknown as { __block: (b: unknown) => void }).__block({
|
||||||
|
permanent: false,
|
||||||
|
until: '2026-07-01T12:00:00Z',
|
||||||
|
reason: 'Спам',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const blocked = page.locator('.blocked');
|
||||||
|
await expect(blocked).toBeVisible();
|
||||||
|
await expect(blocked).toContainText('2026'); // the expiry rendered in the user's locale/timezone
|
||||||
|
await expect(blocked).toContainText('Спам'); // the operator reason
|
||||||
|
// The lobby is gone: the blocked screen overlays every screen, stopping its push/poll.
|
||||||
|
await expect(page.getByText('Your turn')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a permanent block shows the permanent message with no date', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
|
await expect(page.getByText('Your turn')).toBeVisible();
|
||||||
|
|
||||||
|
await page.evaluate(() =>
|
||||||
|
(window as unknown as { __block: (b: unknown) => void }).__block({ permanent: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const blocked = page.locator('.blocked');
|
||||||
|
await expect(blocked).toBeVisible();
|
||||||
|
await expect(blocked).not.toContainText('2026');
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
import Stats from './screens/Stats.svelte';
|
import Stats from './screens/Stats.svelte';
|
||||||
import Game from './game/Game.svelte';
|
import Game from './game/Game.svelte';
|
||||||
import CommsHub from './game/CommsHub.svelte';
|
import CommsHub from './game/CommsHub.svelte';
|
||||||
|
import Blocked from './screens/Blocked.svelte';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
@@ -68,6 +69,8 @@
|
|||||||
|
|
||||||
{#if !app.ready}
|
{#if !app.ready}
|
||||||
<div class="splash">{t('common.loading')}</div>
|
<div class="splash">{t('common.loading')}</div>
|
||||||
|
{:else if app.blocked}
|
||||||
|
<Blocked />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="router">
|
<div class="router">
|
||||||
{#key routeKey}
|
{#key routeKey}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export { AccountRef } from './scrabblefb/account-ref.js';
|
|||||||
export { Ack } from './scrabblefb/ack.js';
|
export { Ack } from './scrabblefb/ack.js';
|
||||||
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
export { AlphabetEntry } from './scrabblefb/alphabet-entry.js';
|
||||||
export { BlockList } from './scrabblefb/block-list.js';
|
export { BlockList } from './scrabblefb/block-list.js';
|
||||||
|
export { BlockStatus } from './scrabblefb/block-status.js';
|
||||||
export { ChatList } from './scrabblefb/chat-list.js';
|
export { ChatList } from './scrabblefb/chat-list.js';
|
||||||
export { ChatMessage } from './scrabblefb/chat-message.js';
|
export { ChatMessage } from './scrabblefb/chat-message.js';
|
||||||
export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
|
export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// automatically generated by the FlatBuffers compiler, do not modify
|
||||||
|
|
||||||
|
import * as flatbuffers from 'flatbuffers';
|
||||||
|
|
||||||
|
export class BlockStatus {
|
||||||
|
bb: flatbuffers.ByteBuffer|null = null;
|
||||||
|
bb_pos = 0;
|
||||||
|
__init(i:number, bb:flatbuffers.ByteBuffer):BlockStatus {
|
||||||
|
this.bb_pos = i;
|
||||||
|
this.bb = bb;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getRootAsBlockStatus(bb:flatbuffers.ByteBuffer, obj?:BlockStatus):BlockStatus {
|
||||||
|
return (obj || new BlockStatus()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSizePrefixedRootAsBlockStatus(bb:flatbuffers.ByteBuffer, obj?:BlockStatus):BlockStatus {
|
||||||
|
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||||
|
return (obj || new BlockStatus()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked():boolean {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||||
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
permanent():boolean {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||||
|
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
until():string|null
|
||||||
|
until(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
until(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reason():string|null
|
||||||
|
reason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||||
|
reason(optionalEncoding?:any):string|Uint8Array|null {
|
||||||
|
const offset = this.bb!.__offset(this.bb_pos, 10);
|
||||||
|
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static startBlockStatus(builder:flatbuffers.Builder) {
|
||||||
|
builder.startObject(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addBlocked(builder:flatbuffers.Builder, blocked:boolean) {
|
||||||
|
builder.addFieldInt8(0, +blocked, +false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addPermanent(builder:flatbuffers.Builder, permanent:boolean) {
|
||||||
|
builder.addFieldInt8(1, +permanent, +false);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addUntil(builder:flatbuffers.Builder, untilOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(2, untilOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static addReason(builder:flatbuffers.Builder, reasonOffset:flatbuffers.Offset) {
|
||||||
|
builder.addFieldOffset(3, reasonOffset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static endBlockStatus(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||||
|
const offset = builder.endObject();
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
static createBlockStatus(builder:flatbuffers.Builder, blocked:boolean, permanent:boolean, untilOffset:flatbuffers.Offset, reasonOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||||
|
BlockStatus.startBlockStatus(builder);
|
||||||
|
BlockStatus.addBlocked(builder, blocked);
|
||||||
|
BlockStatus.addPermanent(builder, permanent);
|
||||||
|
BlockStatus.addUntil(builder, untilOffset);
|
||||||
|
BlockStatus.addReason(builder, reasonOffset);
|
||||||
|
return BlockStatus.endBlockStatus(builder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
// gateway calls funnel through here so errors map to one user-facing toast and an
|
// gateway calls funnel through here so errors map to one user-facing toast and an
|
||||||
// expired session logs out.
|
// expired session logs out.
|
||||||
|
|
||||||
import type { LinkResult, Profile, PushEvent, Session } from './model';
|
import type { BlockStatus, LinkResult, Profile, PushEvent, Session } from './model';
|
||||||
import { gateway } from './gateway';
|
import { gateway } from './gateway';
|
||||||
import { GatewayError } from './client';
|
import { GatewayError } from './client';
|
||||||
import { navigate, router } from './router.svelte';
|
import { navigate, router } from './router.svelte';
|
||||||
@@ -43,6 +43,9 @@ export const app = $state<{
|
|||||||
streamAlive: boolean;
|
streamAlive: boolean;
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
|
/** The caller's active manual block, or null. When set, App.svelte replaces every screen with
|
||||||
|
* the terminal blocked screen and all push/poll is stopped. */
|
||||||
|
blocked: BlockStatus | null;
|
||||||
toast: Toast | null;
|
toast: Toast | null;
|
||||||
lastEvent: PushEvent | null;
|
lastEvent: PushEvent | null;
|
||||||
theme: ThemePref;
|
theme: ThemePref;
|
||||||
@@ -65,6 +68,7 @@ export const app = $state<{
|
|||||||
streamAlive: false,
|
streamAlive: false,
|
||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
|
blocked: null,
|
||||||
toast: null,
|
toast: null,
|
||||||
lastEvent: null,
|
lastEvent: null,
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
@@ -140,11 +144,39 @@ export function handleError(err: unknown): void {
|
|||||||
void logout();
|
void logout();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (code === 'account_blocked') {
|
||||||
|
void enterBlocked();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isConnectionCode(code) || !connection.online) return;
|
if (isConnectionCode(code) || !connection.online) return;
|
||||||
telegramHaptic('error');
|
telegramHaptic('error');
|
||||||
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* enterBlocked switches the app to the terminal blocked screen and stops all push/poll. It is
|
||||||
|
* triggered whenever any call reports the "account_blocked" code. It flips the screen
|
||||||
|
* synchronously (a generic placeholder), then fetches the block's expiry and reason — the one
|
||||||
|
* endpoint a blocked client may still reach — to render the precise message. If that fetch reports
|
||||||
|
* the block was already lifted (a race), it recovers: clears the blocked screen and reopens the
|
||||||
|
* stream.
|
||||||
|
*/
|
||||||
|
export async function enterBlocked(): Promise<void> {
|
||||||
|
closeStream(); // stop the live stream; the blocked screen makes no further calls
|
||||||
|
app.blocked ??= { blocked: true, permanent: true, until: '', reason: '' };
|
||||||
|
try {
|
||||||
|
const s = await gateway.blockStatus();
|
||||||
|
if (s.blocked) {
|
||||||
|
app.blocked = s;
|
||||||
|
} else {
|
||||||
|
app.blocked = null;
|
||||||
|
if (app.session) openStream();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the placeholder blocked screen even if the details cannot be fetched.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* viewingGame reports whether the game board for the given game id is the current route. That screen
|
* viewingGame reports whether the game board for the given game id is the current route. That screen
|
||||||
* maintains its own cache from live events, so the global stream handler must not also advance it (a
|
* maintains its own cache from live events, so the global stream handler must not also advance it (a
|
||||||
@@ -284,6 +316,8 @@ async function adoptSession(s: Session): Promise<void> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
}
|
}
|
||||||
|
// A blocked account stays on the blocked screen: no live stream, no notification poll.
|
||||||
|
if (app.blocked) return;
|
||||||
openStream();
|
openStream();
|
||||||
void refreshNotifications();
|
void refreshNotifications();
|
||||||
}
|
}
|
||||||
@@ -392,7 +426,8 @@ export async function bootstrap(): Promise<void> {
|
|||||||
telegramDisableVerticalSwipes();
|
telegramDisableVerticalSwipes();
|
||||||
try {
|
try {
|
||||||
await adoptSession(await gateway.authTelegram(launch.initData));
|
await adoptSession(await gateway.authTelegram(launch.initData));
|
||||||
await routeStartParam(launch.startParam);
|
// A blocked account skips deep-link routing — the blocked screen overlays every route.
|
||||||
|
if (!app.blocked) await routeStartParam(launch.startParam);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -568,4 +603,10 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
|
|||||||
drop: closeStream,
|
drop: closeStream,
|
||||||
restore: openStream,
|
restore: openStream,
|
||||||
};
|
};
|
||||||
|
// Drive the terminal blocked screen from the e2e (the mock never blocks of its own accord). It
|
||||||
|
// mirrors enterBlocked: flip the screen and stop the live stream.
|
||||||
|
(window as unknown as { __block?: (b?: Partial<BlockStatus>) => void }).__block = (b) => {
|
||||||
|
closeStream();
|
||||||
|
app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatBlockUntil } from './blocked';
|
||||||
|
|
||||||
|
describe('formatBlockUntil', () => {
|
||||||
|
const instant = '2026-07-01T12:00:00Z';
|
||||||
|
|
||||||
|
it('formats a valid instant in the given timezone', () => {
|
||||||
|
const out = formatBlockUntil(instant, 'en', 'UTC');
|
||||||
|
expect(out).not.toBe(instant); // it was localized, not echoed
|
||||||
|
expect(out).toContain('2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shifts the rendered time by timezone', () => {
|
||||||
|
const utc = formatBlockUntil(instant, 'en', 'UTC');
|
||||||
|
const ny = formatBlockUntil(instant, 'en', 'America/New_York');
|
||||||
|
expect(ny).not.toBe(utc); // 12:00 UTC renders as a different wall-clock in New York
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the raw string for an unparseable instant', () => {
|
||||||
|
expect(formatBlockUntil('not-a-date', 'en', 'UTC')).toBe('not-a-date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw on an invalid timezone, still rendering the date', () => {
|
||||||
|
const out = formatBlockUntil(instant, 'en', 'Not/AZone');
|
||||||
|
expect(out).toContain('2026');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Pure helpers for the terminal blocked screen, kept out of the .svelte component so they are
|
||||||
|
// unit-testable in the node vitest env.
|
||||||
|
|
||||||
|
import type { Locale } from './i18n/index.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* formatBlockUntil renders a temporary block's RFC3339 UTC expiry in the user's locale and
|
||||||
|
* timezone, e.g. "1 Jul 2026, 15:00". It falls back to UTC formatting when the timezone is
|
||||||
|
* unknown/invalid, and to the raw string when the instant cannot be parsed at all.
|
||||||
|
*/
|
||||||
|
export function formatBlockUntil(until: string, locale: Locale, timeZone: string): string {
|
||||||
|
const d = new Date(until);
|
||||||
|
if (Number.isNaN(d.getTime())) return until;
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
timeZone: timeZone || undefined,
|
||||||
|
}).format(d);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d);
|
||||||
|
} catch {
|
||||||
|
return until;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccountRef,
|
AccountRef,
|
||||||
|
BlockStatus,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
EvalResult,
|
EvalResult,
|
||||||
FriendCode,
|
FriendCode,
|
||||||
@@ -60,6 +61,9 @@ export interface GatewayClient {
|
|||||||
|
|
||||||
// --- profile / lists ---
|
// --- profile / lists ---
|
||||||
profileGet(): Promise<Profile>;
|
profileGet(): Promise<Profile>;
|
||||||
|
/** The caller's current manual block. Exempt from the backend's suspension gate, so a blocked
|
||||||
|
* client can still fetch it to render the blocked screen. */
|
||||||
|
blockStatus(): Promise<BlockStatus>;
|
||||||
gamesList(): Promise<GameList>;
|
gamesList(): Promise<GameList>;
|
||||||
|
|
||||||
// --- lobby ---
|
// --- lobby ---
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import * as fb from '../gen/fbs/scrabblefb';
|
import * as fb from '../gen/fbs/scrabblefb';
|
||||||
import { BLANK_INDEX, setAlphabet } from './alphabet';
|
import { BLANK_INDEX, setAlphabet } from './alphabet';
|
||||||
import {
|
import {
|
||||||
|
decodeBlockStatus,
|
||||||
decodeDraftView,
|
decodeDraftView,
|
||||||
decodeEvent,
|
decodeEvent,
|
||||||
decodeFriendList,
|
decodeFriendList,
|
||||||
@@ -37,6 +38,38 @@ describe('codec', () => {
|
|||||||
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
|
expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decodes a temporary BlockStatus with reason', () => {
|
||||||
|
const b = new Builder(64);
|
||||||
|
const until = b.createString('2026-07-01T12:00:00Z');
|
||||||
|
const reason = b.createString('Спам');
|
||||||
|
fb.BlockStatus.startBlockStatus(b);
|
||||||
|
fb.BlockStatus.addBlocked(b, true);
|
||||||
|
fb.BlockStatus.addPermanent(b, false);
|
||||||
|
fb.BlockStatus.addUntil(b, until);
|
||||||
|
fb.BlockStatus.addReason(b, reason);
|
||||||
|
b.finish(fb.BlockStatus.endBlockStatus(b));
|
||||||
|
expect(decodeBlockStatus(b.asUint8Array())).toEqual({
|
||||||
|
blocked: true,
|
||||||
|
permanent: false,
|
||||||
|
until: '2026-07-01T12:00:00Z',
|
||||||
|
reason: 'Спам',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes a permanent BlockStatus with empty until/reason', () => {
|
||||||
|
const b = new Builder(32);
|
||||||
|
fb.BlockStatus.startBlockStatus(b);
|
||||||
|
fb.BlockStatus.addBlocked(b, true);
|
||||||
|
fb.BlockStatus.addPermanent(b, true);
|
||||||
|
b.finish(fb.BlockStatus.endBlockStatus(b));
|
||||||
|
expect(decodeBlockStatus(b.asUint8Array())).toEqual({
|
||||||
|
blocked: true,
|
||||||
|
permanent: true,
|
||||||
|
until: '',
|
||||||
|
reason: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('encodes a SubmitPlayRequest with alphabet indices', () => {
|
it('encodes a SubmitPlayRequest with alphabet indices', () => {
|
||||||
setAlphabet('scrabble_en', [
|
setAlphabet('scrabble_en', [
|
||||||
{ index: 0, letter: 'a', value: 1 },
|
{ index: 0, letter: 'a', value: 1 },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } f
|
|||||||
import type { PlacedTile } from './client';
|
import type { PlacedTile } from './client';
|
||||||
import type {
|
import type {
|
||||||
AccountRef,
|
AccountRef,
|
||||||
|
BlockStatus,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
EvalResult,
|
EvalResult,
|
||||||
FriendCode,
|
FriendCode,
|
||||||
@@ -314,6 +315,16 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function decodeBlockStatus(buf: Uint8Array): BlockStatus {
|
||||||
|
const b = fb.BlockStatus.getRootAsBlockStatus(new ByteBuffer(buf));
|
||||||
|
return {
|
||||||
|
blocked: b.blocked(),
|
||||||
|
permanent: b.permanent(),
|
||||||
|
until: s(b.until()),
|
||||||
|
reason: s(b.reason()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// decodeStateViewTable projects a StateView table (a root or one nested in an event) to the
|
// decodeStateViewTable projects a StateView table (a root or one nested in an event) to the
|
||||||
// model. It caches the alphabet when present (a per-variant cache miss) and decodes the index
|
// model. It caches the alphabet when present (a per-variant cache miss) and decodes the index
|
||||||
// rack to display letters with it.
|
// rack to display letters with it.
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export const en = {
|
|||||||
'app.title': 'Scrabble',
|
'app.title': 'Scrabble',
|
||||||
'connection.connecting': 'Connecting…',
|
'connection.connecting': 'Connecting…',
|
||||||
|
|
||||||
|
'blocked.title': 'Account blocked',
|
||||||
|
'blocked.permanent': 'Your account is blocked.',
|
||||||
|
'blocked.temporary': 'Your account is blocked until {until}.',
|
||||||
|
'blocked.reason': 'Reason:',
|
||||||
|
|
||||||
'common.back': 'Back',
|
'common.back': 'Back',
|
||||||
'common.cancel': 'Cancel',
|
'common.cancel': 'Cancel',
|
||||||
'common.ok': 'OK',
|
'common.ok': 'OK',
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'app.title': 'Scrabble',
|
'app.title': 'Scrabble',
|
||||||
'connection.connecting': 'Подключение…',
|
'connection.connecting': 'Подключение…',
|
||||||
|
|
||||||
|
'blocked.title': 'Учётная запись заблокирована',
|
||||||
|
'blocked.permanent': 'Ваша учётная запись заблокирована.',
|
||||||
|
'blocked.temporary': 'Ваша учётная запись заблокирована до {until}.',
|
||||||
|
'blocked.reason': 'Причина:',
|
||||||
|
|
||||||
'common.back': 'Назад',
|
'common.back': 'Назад',
|
||||||
'common.cancel': 'Отмена',
|
'common.cancel': 'Отмена',
|
||||||
'common.ok': 'ОК',
|
'common.ok': 'ОК',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
import { GatewayError } from '../client';
|
import { GatewayError } from '../client';
|
||||||
import type {
|
import type {
|
||||||
AccountRef,
|
AccountRef,
|
||||||
|
BlockStatus,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
EvalResult,
|
EvalResult,
|
||||||
FriendCode,
|
FriendCode,
|
||||||
@@ -139,6 +140,10 @@ export class MockGateway implements GatewayClient {
|
|||||||
async profileGet(): Promise<Profile> {
|
async profileGet(): Promise<Profile> {
|
||||||
return { ...this.profile };
|
return { ...this.profile };
|
||||||
}
|
}
|
||||||
|
async blockStatus(): Promise<BlockStatus> {
|
||||||
|
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
|
||||||
|
return { blocked: false, permanent: false, until: '', reason: '' };
|
||||||
|
}
|
||||||
async gamesList(): Promise<GameList> {
|
async gamesList(): Promise<GameList> {
|
||||||
return { games: [...this.games.values()].map((g) => structuredClone(g.view)) };
|
return { games: [...this.games.values()].map((g) => structuredClone(g.view)) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,18 @@ export interface Profile {
|
|||||||
notificationsInAppOnly: boolean;
|
notificationsInAppOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** BlockStatus is the caller's current manual block, fetched after any call reports
|
||||||
|
* the "account_blocked" code. When blocked the app shows the terminal blocked screen
|
||||||
|
* and stops all push/poll. */
|
||||||
|
export interface BlockStatus {
|
||||||
|
blocked: boolean;
|
||||||
|
permanent: boolean;
|
||||||
|
/** RFC3339 UTC instant for a temporary block; "" for a permanent one or when not blocked. */
|
||||||
|
until: string;
|
||||||
|
/** Operator reason resolved to the account's language; "" when none was cited. */
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** The full editable profile sent to profileUpdate (overwrites every field). */
|
/** The full editable profile sent to profileUpdate (overwrites every field). */
|
||||||
export interface ProfileUpdate {
|
export interface ProfileUpdate {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async profileGet() {
|
async profileGet() {
|
||||||
return codec.decodeProfile(await exec('profile.get', codec.empty()));
|
return codec.decodeProfile(await exec('profile.get', codec.empty()));
|
||||||
},
|
},
|
||||||
|
async blockStatus() {
|
||||||
|
return codec.decodeBlockStatus(await exec('account.block_status', codec.empty()));
|
||||||
|
},
|
||||||
async gamesList() {
|
async gamesList() {
|
||||||
return codec.decodeGameList(await exec('games.list', codec.empty()));
|
return codec.decodeGameList(await exec('games.list', codec.empty()));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The terminal blocked screen. While app.blocked is set, App.svelte renders only this and all
|
||||||
|
// push/poll is stopped; the screen itself makes no network calls. The message is permanent or
|
||||||
|
// dated (the expiry rendered in the user's locale + timezone), with the optional operator reason.
|
||||||
|
import { app } from '../lib/app.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
import { formatBlockUntil } from '../lib/blocked';
|
||||||
|
|
||||||
|
const until = $derived(
|
||||||
|
app.blocked?.until ? formatBlockUntil(app.blocked.until, app.locale, app.profile?.timeZone ?? '') : '',
|
||||||
|
);
|
||||||
|
const message = $derived(until ? t('blocked.temporary', { until }) : t('blocked.permanent'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="blocked">
|
||||||
|
<div class="card">
|
||||||
|
<h1>{t('blocked.title')}</h1>
|
||||||
|
<p class="msg">{message}</p>
|
||||||
|
{#if app.blocked?.reason}
|
||||||
|
<p class="reason"><span class="label">{t('blocked.reason')}</span> {app.blocked.reason}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.blocked {
|
||||||
|
height: 100%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
max-width: 28rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.msg {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.reason {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user