Files
scrabble-game/backend/internal/server/handlers_admin_console.go
T
Ilia Denisov e3c2e80a0a
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
feat(payments): report income to «Мой налог»
The direct rail runs on НПД, where the provider neither files with the tax
service nor issues a receipt — so nobody was doing it. This registers each
rouble purchase, annuls its receipt on a refund, and hands the buyer the
receipt by email.

Two properties of the (unofficial) lknpd API shape the design. Registering an
income takes no idempotency key, so an error does not mean nothing happened:
the service name is frozen before the call and carries a marker from the tail
of the order id, and after a failure the taxpayer's income list is searched
for that exact name. Found means filed; not found halts the queue for a human,
because declaring an income twice is as wrong as not declaring it. And faults
are classified rather than logged: a token is renewed silently, a throttle
backs off, an outage retries, but three unfixable rejections take the rail out
of service — a changed format must not become thousands of requests overnight.

The console button and the worker share one RunBatch. Automatic mode is armed
from the console, not from configuration, so the operator can watch a run go
through by hand first. A daily watchdog runs whether or not it is armed, since
the case it exists for is the export being off. An idle queue issues no call at
all — not even an authentication.

No payment path changed: the purchase letter rides the existing payment-event
outbox on its own cursor, the receipt and annulment letters ride the export
row. Decisions D53-D60.
2026-07-28 15:40:36 +02:00

1404 lines
50 KiB
Go

package server
import (
"bytes"
"encoding/csv"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/dictadmin"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/social"
)
// adminPageSize is the page size of the admin console's paginated lists.
const adminPageSize = 50
// registerConsole mounts the server-rendered admin console under /_gm. The gateway
// puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the
// backend trusts the gateway (as for all of /api) and adds only a same-origin guard
// on the state-changing POSTs (docs/ARCHITECTURE.md §12). The console reads the
// account, game and dictionary surfaces, so it mounts only when those are wired.
func (s *Server) registerConsole(router *gin.Engine) {
if s.accounts == nil || s.games == nil || s.registry == nil {
return
}
s.console = adminconsole.MustNewRenderer()
assets, err := adminconsole.Assets()
if err != nil {
panic(err)
}
gm := router.Group("/_gm")
gm.Use(requireSameOrigin())
gm.StaticFS("/assets", http.FS(assets))
gm.GET("/", s.consoleDashboard)
gm.GET("/users", s.consoleUsers)
gm.GET("/users/:id", s.consoleUserDetail)
gm.POST("/users/:id/message", s.consoleUserMessage)
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
gm.POST("/users/:id/block", s.consoleBlockUser)
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
gm.POST("/users/:id/grant", s.consoleGrant)
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
gm.POST("/users/:id/refund", s.consoleRefund)
gm.POST("/users/:id/purchase-override", s.consoleSetPurchaseOverride)
gm.POST("/users/:id/delete", s.consoleDeleteUser)
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.POST("/bans/unban", s.consoleUnban)
gm.GET("/games", s.consoleGames)
gm.GET("/games/:id", s.consoleGameDetail)
if s.gamelimits != nil {
gm.GET("/limits", s.consoleLimits)
gm.POST("/limits", s.consoleUpdateLimits)
}
gm.GET("/complaints", s.consoleComplaints)
gm.GET("/complaints/:id", s.consoleComplaintDetail)
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
if s.feedback != nil {
gm.GET("/feedback", s.consoleFeedback)
gm.GET("/feedback/:id", s.consoleFeedbackDetail)
gm.GET("/feedback/:id/attachment", s.consoleFeedbackAttachment)
gm.POST("/feedback/:id/read", s.consoleFeedbackRead)
gm.POST("/feedback/:id/reply", s.consoleFeedbackReply)
gm.POST("/feedback/:id/archive", s.consoleFeedbackArchive)
gm.POST("/feedback/:id/delete", s.consoleFeedbackDelete)
gm.POST("/feedback/:id/delete-all", s.consoleFeedbackDeleteAll)
}
gm.GET("/messages", s.consoleMessages)
gm.GET("/messages.csv", s.consoleMessagesCSV)
gm.GET("/messages/:id", s.consoleChatMessageDetail)
gm.GET("/dictionary", s.consoleDictionary)
gm.POST("/dictionary/upload", s.consoleUploadDictionary)
gm.POST("/dictionary/install", s.consoleInstallDictionary)
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
gm.GET("/broadcast", s.consoleBroadcast)
gm.POST("/broadcast", s.consolePostBroadcast)
if s.ads != nil {
gm.GET("/banners", s.consoleBanners)
gm.POST("/banners", s.consoleCreateBanner)
gm.GET("/banners/:id", s.consoleBannerDetail)
gm.POST("/banners/:id", s.consoleUpdateBanner)
gm.POST("/banners/:id/delete", s.consoleDeleteBanner)
gm.POST("/banners/:id/messages", s.consoleAddBannerMessage)
gm.POST("/banners/:id/messages/:mid", s.consoleEditBannerMessage)
gm.POST("/banners/:id/messages/:mid/delete", s.consoleDeleteBannerMessage)
gm.POST("/banners/:id/messages/:mid/move", s.consoleMoveBannerMessage)
gm.GET("/banner-settings", s.consoleBannerSettings)
gm.POST("/banner-settings", s.consoleUpdateBannerSettings)
}
if s.payments != nil {
gm.GET("/catalog", s.consoleCatalog)
gm.POST("/catalog", s.consoleCreateProduct)
gm.POST("/catalog/reward", s.consoleSetReward)
gm.POST("/catalog/rail-status", s.consoleSetRailStatus)
gm.GET("/catalog/:id", s.consoleCatalogDetail)
gm.POST("/catalog/:id", s.consoleUpdateProduct)
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
gm.GET("/ledger", s.consoleLedger)
gm.GET("/ledger.csv", s.consoleLedgerExport)
}
// The professional-income tax export, registered only when an encryption key is configured —
// without one there is nowhere safe to keep the tax cabinet's refresh token, so the whole rail
// stays dormant rather than half-present.
s.registerMyNalogConsole(gm)
}
// consoleDashboard renders the landing page: the top-line counts and the resident
// dictionary versions.
func (s *Server) consoleDashboard(c *gin.Context) {
ctx := c.Request.Context()
view := adminconsole.DashboardView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()}
// The Users card counts people only: robots are pool infrastructure, not registered
// users, so an empty filter (Robots: false) excludes them.
view.Accounts, _ = s.accounts.CountUsers(ctx, account.UserFilter{})
view.Games, _ = s.games.CountGames(ctx, "")
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
if s.feedback != nil {
view.OpenFeedback, _ = s.feedback.CountUnread(ctx)
}
if changes, err := s.games.DictionaryChanges(ctx); err == nil {
view.PendingChanges = len(changes)
}
s.renderConsole(c, "dashboard", "dashboard", "Dashboard", view)
}
// consoleUsers renders the paginated account list.
func (s *Server) consoleUsers(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
filter := account.UserFilter{
Robots: c.Query("kind") == "robots",
Deleted: c.Query("kind") == "deleted",
NameMask: c.Query("name"),
ExternalIDMask: c.Query("ext"),
EmailExact: c.Query("email"),
}
total, _ := s.accounts.CountUsers(ctx, filter)
items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
q := url.Values{}
if filter.Robots {
q.Set("kind", "robots")
}
if filter.Deleted {
q.Set("kind", "deleted")
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExternalIDMask) != "" {
q.Set("ext", filter.ExternalIDMask)
}
if strings.TrimSpace(filter.EmailExact) != "" {
q.Set("email", filter.EmailExact)
}
view := adminconsole.UsersView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
Robots: filter.Robots, Deleted: filter.Deleted, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
EmailExact: filter.EmailExact,
FilterQuery: template.URL(q.Encode()),
}
ids := make([]uuid.UUID, 0, len(items))
for _, it := range items {
kind := "registered"
switch {
case it.IsRobot:
kind = "robot"
case it.IsDeleted:
kind = "deleted"
case it.IsGuest:
kind = "guest"
}
view.Items = append(view.Items, adminconsole.UserRow{
ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
Language: it.PreferredLanguage, Guest: it.IsGuest, Deleted: it.IsDeleted,
FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
})
ids = append(ids, it.ID)
}
if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil {
for i := range view.Items {
if st, ok := stats[ids[i]]; ok && st.Moves > 0 {
view.Items[i].HasMoveStats = true
view.Items[i].MoveMin = adminconsole.FormatDuration(st.MinSecs)
view.Items[i].MoveAvg = adminconsole.FormatDuration(st.AvgSecs)
view.Items[i].MoveMax = adminconsole.FormatDuration(st.MaxSecs)
}
}
}
s.renderConsole(c, "users", "users", "Users", view)
}
// consoleMessages renders the paginated chat-message moderation list, optionally pinned to
// one game (?game=) or sender (?user=) and filtered by sender glob masks (?name / ?ext).
func (s *Server) consoleMessages(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := social.AdminMessageFilter{
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
UnreadOnly: c.Query("unread") == "1",
}
total, _ := s.social.AdminCountMessages(ctx, filter)
items, err := s.social.AdminListMessages(ctx, filter, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
q := url.Values{}
if filter.GameID != uuid.Nil {
q.Set("game", filter.GameID.String())
}
if filter.SenderID != uuid.Nil {
q.Set("user", filter.SenderID.String())
}
if strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExtMask) != "" {
q.Set("ext", filter.ExtMask)
}
if filter.UnreadOnly {
q.Set("unread", "1")
}
view := adminconsole.MessagesView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
NameMask: filter.NameMask,
ExtMask: filter.ExtMask,
UnreadOnly: filter.UnreadOnly,
FilterQuery: template.URL(q.Encode()),
}
if filter.GameID != uuid.Nil {
view.GameID = filter.GameID.String()
}
if filter.SenderID != uuid.Nil {
view.UserID = filter.SenderID.String()
}
for _, m := range items {
view.Items = append(view.Items, adminconsole.MessageRow{
ID: m.ID.String(), SenderID: m.SenderID.String(), SenderName: m.SenderName,
Source: m.Source, IP: m.SenderIP, Body: m.Body,
GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt), Unread: m.UnreadSeats != 0,
})
}
s.renderConsole(c, "messages", "messages", "Messages", view)
}
// consoleChatMessageDetail renders one chat message with the per-seat read breakdown of
// its game (who has read it, who has not).
func (s *Server) consoleChatMessageDetail(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/messages")
if !ok {
return
}
card, err := s.social.AdminMessageDetail(c.Request.Context(), id)
if err != nil {
if errors.Is(err, social.ErrMessageNotFound) {
s.renderConsoleMessage(c, "Not found", "no such message", "/_gm/messages")
return
}
s.consoleError(c, err)
return
}
view := adminconsole.ChatMessageDetailView{
ID: card.ID.String(), GameID: card.GameID.String(), SenderID: card.SenderID.String(),
SenderName: card.SenderName, Source: card.Source, Kind: card.Kind, Body: card.Body,
IP: card.SenderIP, CreatedAt: fmtTime(card.CreatedAt), Unread: card.UnreadSeats != 0,
}
for _, st := range card.Seats {
view.Seats = append(view.Seats, adminconsole.ChatSeatStatusRow{
Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: st.DisplayName, Role: st.Role,
})
}
s.renderConsole(c, "chatmessage", "messages", "Message", view)
}
// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small).
const adminMessagesExportCap = 100000
// consoleMessagesCSV exports the whole filtered chat-message list (ignoring pagination) as a
// CSV download, for offline moderation review.
func (s *Server) consoleMessagesCSV(c *gin.Context) {
ctx := c.Request.Context()
gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
filter := social.AdminMessageFilter{
GameID: gameID,
SenderID: userID,
NameMask: c.Query("name"),
ExtMask: c.Query("ext"),
UnreadOnly: c.Query("unread") == "1",
}
items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0)
if err != nil {
s.consoleError(c, err)
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="messages.csv"`)
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id", "read"})
for _, m := range items {
read := "read"
if m.UnreadSeats != 0 {
read = "unread"
}
// The sender name and message body are user-controlled; defuse spreadsheet formula
// injection so a moderator opening the export can't trigger a formula.
_ = w.Write([]string{
fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(), read,
})
}
w.Flush()
}
// csvSafe defuses CSV/spreadsheet formula injection: a value a spreadsheet would treat as a
// formula (a leading =, +, -, @, tab or CR) is prefixed with a single quote so it renders as
// plain text on open.
func csvSafe(s string) string {
if s == "" {
return s
}
switch s[0] {
case '=', '+', '-', '@', '\t', '\r':
return "'" + s
}
return s
}
// consoleUserDetail renders one account with its stats, identities and games.
func (s *Server) consoleUserDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
acc, err := s.accounts.GetByID(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.UserDetailView{
ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage,
TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly,
CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
}
if acc.MergedInto != uuid.Nil {
view.MergedInto = acc.MergedInto.String()
}
if !acc.FlaggedHighRateAt.IsZero() {
view.FlaggedHighRateAt = fmtTime(acc.FlaggedHighRateAt)
}
if view.HasStats {
if st, err := s.accounts.GetStats(ctx, id); err == nil {
view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints, Moves: st.Moves, HintsUsed: st.HintsUsed}
}
}
if ids, err := s.accounts.Identities(ctx, id); err == nil {
for _, idn := range ids {
if idn.Kind == account.KindEmail {
view.HasEmail = true
}
view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
}
}
if info, err := s.accounts.DeletionInfo(ctx, id); err == nil {
if info.LastLoginAt != nil {
view.LastLoginAt = fmtTime(*info.LastLoginAt)
}
view.LastLoginIP = info.LastLoginIP
if info.DeletedAt != nil {
view.Deleted = true
view.DeletedAt = fmtTime(*info.DeletedAt)
view.DeletedName = info.DeletedDisplayName
}
}
if rets, err := s.accounts.RetainedIdentities(ctx, id); err == nil {
for _, r := range rets {
view.Retained = append(view.Retained, adminconsole.RetainedRow{
Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
Confirmed: r.Confirmed, LinkedAt: fmtTime(r.LinkedAt), DetachedAt: fmtTime(r.DetachedAt),
})
}
}
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
view.TelegramID = tg
}
if vk, err := s.accounts.IdentityExternalID(ctx, id, account.KindVK); err == nil {
view.VKID = vk
}
if games, err := s.games.ListForAccount(ctx, id); err == nil {
for _, g := range games {
view.Games = append(view.Games, gameRow(g))
}
}
if pts, err := s.games.MoveDurationByOrdinal(ctx, id); err == nil && len(pts) > 0 {
cps := make([]adminconsole.ChartPoint, len(pts))
for i, p := range pts {
cps[i] = adminconsole.ChartPoint{Ordinal: p.Ordinal, Min: p.MinSecs, Max: p.MaxSecs, Avg: p.AvgSecs}
}
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})
}
}
if roles, err := s.accounts.ListRoles(ctx, id); err == nil {
view.Roles = roles
}
view.KnownRoles = account.KnownRoles
if s.social != nil {
if rels, err := s.social.AdminBlocksBy(ctx, id); err == nil {
view.Blocks = relationRows(rels)
}
if rels, err := s.social.AdminBlockedBy(ctx, id); err == nil {
view.BlockedBy = relationRows(rels)
}
if rels, err := s.social.AdminFriends(ctx, id); err == nil {
view.Friends = relationRows(rels)
}
}
if s.payments != nil {
if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
view.Finance = financeView(id, stmt)
} else {
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
}
view.Grant = s.grantForm(ctx)
if ov, oerr := s.payments.PurchaseOverrideFor(ctx, id); oerr == nil {
view.PurchaseOverride = overrideName(ov)
}
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
// financeView projects an account's payments statement into the user-card finance panel: where the
// account stands (balances, benefits, lifetime money and chip totals), not what happened when. The
// operations themselves are the ledger section's job, which the panel links to pre-filtered to this
// account — one place renders them, with filters and paging, instead of two.
func financeView(id uuid.UUID, stmt payments.Statement) adminconsole.FinanceView {
fv := adminconsole.FinanceView{Present: true, Abuse: stmt.Risk.Abuse, Loss: stmt.Risk.LossChips}
for _, sg := range stmt.Segments {
fv.Segments = append(fv.Segments, adminconsole.SegmentRow{Source: string(sg.Source), Chips: sg.Chips})
}
for _, b := range stmt.Benefits {
row := adminconsole.BenefitRow{Origin: string(b.Origin), Hints: b.Hints, Forever: b.AdsForever}
if !b.AdsPaidUntil.IsZero() {
row.AdsUntil = fmtTime(b.AdsPaidUntil)
}
fv.Benefits = append(fv.Benefits, row)
}
// The lifetime totals are folded from the history the statement already carries, so the summary
// costs no extra query. Money is kept per currency: the rails settle in roubles, Votes and Stars,
// and one sum across them would mean nothing.
paid, refunded := map[payments.Currency]int64{}, map[payments.Currency]int64{}
for _, e := range stmt.Ledger {
if e.ChipsDelta > 0 {
fv.ChipsBought += e.ChipsDelta
} else if e.Kind == "spend" {
fv.ChipsSpent -= e.ChipsDelta
}
m := payments.MoneyFromLedgerSnapshot(e.Snapshot)
if m.Currency() == "" {
continue
}
switch e.Kind {
case "fund":
paid[m.Currency()] += m.Minor()
case "refund":
refunded[m.Currency()] += m.Minor()
}
}
fv.Paid = fmtMoneyTotals(paid)
fv.Refunded = fmtMoneyTotals(refunded)
fv.LedgerQuery = template.URL(url.Values{"user": {id.String()}}.Encode())
return fv
}
// fmtMoneyTotals renders per-currency minor-unit sums as display strings, in a stable order so the
// panel does not reshuffle between reloads (map iteration is random).
func fmtMoneyTotals(totals map[payments.Currency]int64) []string {
currencies := make([]string, 0, len(totals))
for c := range totals {
currencies = append(currencies, string(c))
}
sort.Strings(currencies)
out := make([]string, 0, len(currencies))
for _, c := range currencies {
m, err := payments.MoneyFromMinor(totals[payments.Currency(c)], payments.Currency(c))
if err != nil {
continue
}
out = append(out, fmtMoney(m))
}
return out
}
// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
func overrideName(ov payments.PurchaseOverride) string {
switch ov {
case payments.OverrideAllow:
return "allow"
case payments.OverrideDeny:
return "deny"
default:
return "default"
}
}
// consoleSetPurchaseOverride sets or clears an account's per-account purchase override (allow / deny
// / default) from the user card. "allow" bypasses only the operational rail switch, never the
// security gates; "default" clears the override (deletes the row).
func (s *Server) consoleSetPurchaseOverride(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not configured", back)
return
}
var ov payments.PurchaseOverride
switch c.PostForm("override") {
case "allow":
ov = payments.OverrideAllow
case "deny":
ov = payments.OverrideDeny
default:
ov = payments.OverrideDefault
}
if err := s.payments.SetPurchaseOverride(c.Request.Context(), id, ov); err != nil {
s.renderConsoleMessage(c, "Update failed", err.Error(), back)
return
}
s.renderConsoleMessage(c, "Saved", "the purchase override was updated", back)
}
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
// user card renders.
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
out := make([]adminconsole.RelationRow, 0, len(rels))
for _, r := range rels {
out = append(out, adminconsole.RelationRow{AccountID: r.AccountID.String(), DisplayName: r.DisplayName, Date: fmtTime(r.At)})
}
return out
}
// consoleUserMessage sends an operator Telegram message to one user.
func (s *Server) consoleUserMessage(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
text := trimForm(c, "text")
switch {
case text == "":
s.renderConsoleMessage(c, "Nothing sent", "the message was empty", back)
case s.connector == nil:
s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", back)
default:
ext, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram)
if err != nil {
s.renderConsoleMessage(c, "No Telegram", "this account has no Telegram identity", back)
return
}
delivered, err := s.connector.SendToUser(ctx, ext, text)
if err != nil {
s.consoleError(c, err)
return
}
body := "message delivered"
if !delivered {
body = "not delivered (the user may not have started the bot)"
}
s.renderConsoleMessage(c, "Sent", body, back)
}
}
// consoleGames renders the paginated games list, optionally filtered by status.
func (s *Server) consoleGames(c *gin.Context) {
ctx := c.Request.Context()
status := normalizeGameStatus(c.Query("status"))
page := consolePage(c)
total, _ := s.games.CountGames(ctx, status)
games, err := s.games.ListGames(ctx, status, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.GamesView{Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total)}
for _, g := range games {
view.Items = append(view.Items, gameRow(g))
}
s.renderConsole(c, "games", "games", "Games", view)
}
// consoleGameDetail renders one game with its seats (display names resolved).
func (s *Server) consoleGameDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/games")
if !ok {
return
}
g, err := s.games.GameByID(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.GameDetailView{
ID: g.ID.String(), Variant: g.Variant.String(), DictVersion: g.DictVersion,
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
}
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
// anchors the robot's sleep window for the next-move ETA.
oppTZ := ""
for _, seat := range g.Seats {
row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner}
acc, accErr := s.accounts.GetByID(ctx, seat.AccountID)
if accErr == nil {
row.DisplayName = acc.DisplayName
}
if isRobot, _ := s.accounts.IsRobot(ctx, seat.AccountID); isRobot {
row.IsRobot = true
view.HasRobot = true
} else if accErr == nil {
oppTZ = acc.TimeZone
}
view.Seats = append(view.Seats, row)
}
// For each robot seat, surface the game's deterministic play-to-win intent and — while
// it is that robot's turn — the scheduled next-move ETA, both derived from the bag seed.
if view.HasRobot {
view.RobotTargetPct = robot.PlayToWinTargetPercent
if seed, turnStartedAt, schedErr := s.games.RobotSchedule(ctx, g.ID); schedErr == nil {
now := time.Now().UTC()
for i := range view.Seats {
if !view.Seats[i].IsRobot {
continue
}
if robot.PlayToWin(seed) {
view.Seats[i].RobotIntent = "play to win"
} else {
view.Seats[i].RobotIntent = "play to lose"
}
if g.Status == game.StatusActive && g.ToMove == view.Seats[i].Seat {
view.Seats[i].NextMove = robotETA(robot.NextMoveAt(seed, g.MoveCount, turnStartedAt, oppTZ), now)
}
}
}
}
// First-move draw (docs/ARCHITECTURE.md §6) and the step-by-step replay timeline. Both are
// best-effort: a game that predates the draw or cannot be replayed still renders its
// summary and seats.
names := make(map[string]string, len(view.Seats))
for _, st := range view.Seats {
names[st.AccountID] = st.DisplayName
}
if draws, derr := s.games.SetupDraws(ctx, g.ID); derr == nil {
for _, d := range draws {
row := adminconsole.SetupDrawRow{Round: d.Round, Letter: strings.ToUpper(d.Letter), Blank: d.Blank, Rank: d.Rank}
if d.Account == uuid.Nil {
row.Name = "(opponent)"
} else {
row.Name = names[d.Account.String()]
row.AccountID = d.Account.String()
}
view.SetupDraws = append(view.SetupDraws, row)
}
}
if len(view.Seats) > 0 {
view.FirstMover = view.Seats[0].DisplayName
}
if tl, terr := s.games.ReplayTimeline(ctx, g.ID); terr == nil {
if rj, jerr := buildReplayJSON(g.Variant, tl, view.Seats); jerr == nil {
view.ReplayJSON = rj
view.HasReplay = len(tl.Steps) > 0
}
}
s.renderConsole(c, "game_detail", "games", "Game", view)
}
// robotETA formats a robot's scheduled next-move instant as an absolute UTC time plus a
// relative estimate, e.g. "≈ 14:37 UTC (in ~7 min)"; a past instant reads "(due now)".
func robotETA(at, now time.Time) string {
mins := int(at.Sub(now).Round(time.Minute).Minutes())
rel := fmt.Sprintf("in ~%d min", mins)
if mins <= 0 {
rel = "due now"
}
return fmt.Sprintf("≈ %s UTC (%s)", at.UTC().Format("15:04"), rel)
}
// consoleComplaints renders the paginated complaint review queue.
func (s *Server) consoleComplaints(c *gin.Context) {
ctx := c.Request.Context()
status := normalizeComplaintStatus(c.Query("status"))
page := consolePage(c)
total, _ := s.games.CountComplaints(ctx, status)
rows, err := s.games.ListComplaints(ctx, status, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.ComplaintsView{Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total)}
for _, cp := range rows {
view.Items = append(view.Items, adminconsole.ComplaintRow{
ID: cp.ID.String(), Word: cp.Word, Variant: cp.Variant.String(), WasValid: cp.WasValid,
Status: cp.Status, Disposition: cp.Disposition, CreatedAt: fmtTime(cp.CreatedAt),
})
}
s.renderConsole(c, "complaints", "complaints", "Complaints", view)
}
// consoleComplaintDetail renders one complaint with its resolution form.
func (s *Server) consoleComplaintDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/complaints")
if !ok {
return
}
cp, err := s.games.GetComplaint(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsole(c, "complaint_detail", "complaints", "Complaint", adminconsole.ComplaintDetailView{
ID: cp.ID.String(), Word: cp.Word, Variant: cp.Variant.String(), DictVersion: cp.DictVersion,
WasValid: cp.WasValid, Note: cp.Note, Status: cp.Status, Disposition: cp.Disposition,
ResolutionNote: cp.ResolutionNote, CreatedAt: fmtTime(cp.CreatedAt), ResolvedAt: fmtTimePtr(cp.ResolvedAt),
GameID: cp.GameID.String(), Resolved: cp.Status == game.StatusComplaintResolved,
})
}
// consoleResolveComplaint resolves a complaint with the chosen disposition.
func (s *Server) consoleResolveComplaint(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/complaints")
if !ok {
return
}
disposition := c.PostForm("disposition")
if _, err := s.games.ResolveComplaint(ctx, id, disposition, trimForm(c, "note")); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Resolved", "complaint resolved as "+disposition, "/_gm/complaints/"+id.String())
}
// consoleDictionary renders the resident versions and the pending wordlist changes.
func (s *Server) consoleDictionary(c *gin.Context) {
view := adminconsole.DictionaryView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()}
if changes, err := s.games.DictionaryChanges(c.Request.Context()); err == nil {
for _, ch := range changes {
action := "remove"
if ch.Add {
action = "add"
}
view.Changes = append(view.Changes, adminconsole.DictChangeRow{Variant: ch.Variant.String(), Word: ch.Word, Action: action, ResolvedAt: fmtTime(ch.ResolvedAt)})
}
}
s.renderConsole(c, "dictionary", "dictionary", "Dictionary", view)
}
// previewSampleCap bounds how many added/removed words the update preview lists per
// variant; the full totals are always shown.
const previewSampleCap = 200
// largeRemovalThreshold flags a removal large enough that the operator should pause
// before confirming: a rebuilt dictionary normally drops a handful of words, not
// thousands.
const largeRemovalThreshold = 1000
// consoleUploadDictionary accepts a scrabble-dawg release archive, stages it under
// the dictionary directory and renders the per-variant word diff against the active
// dictionary. The operator confirms it with consoleInstallDictionary. The archive
// is bounded, its file name fixes the version, and a version already resident is
// rejected (versions are immutable).
func (s *Server) consoleUploadDictionary(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, dictadmin.MaxArchiveBytes)
file, header, err := c.Request.FormFile("archive")
if err != nil {
s.renderConsoleMessage(c, "Upload failed", "choose a scrabble-dawg-vX.Y.Z.tar.gz archive (max 64 MiB)", "/_gm/dictionary")
return
}
defer func() { _ = file.Close() }()
version, err := dictadmin.ParseVersionFromName(header.Filename)
if err != nil {
s.renderConsoleMessage(c, "Upload failed", "the file name must be scrabble-dawg-vX.Y.Z.tar.gz", "/_gm/dictionary")
return
}
if s.versionResident(version) {
s.renderConsoleMessage(c, "Already resident", fmt.Sprintf("version %s is already loaded; versions are immutable", version), "/_gm/dictionary")
return
}
mgr := dictadmin.New(s.dictDir)
token, variants, err := mgr.Stage(file)
if err != nil {
s.consoleError(c, err)
return
}
if len(variants) != len(engine.Variants()) {
mgr.Discard(token)
s.renderConsoleMessage(c, "Incomplete archive",
fmt.Sprintf("the archive carries %d of %d variants; a release must contain all of them", len(variants), len(engine.Variants())),
"/_gm/dictionary")
return
}
staged, err := mgr.StagedDir(token)
if err != nil {
s.consoleError(c, err)
return
}
rows, err := s.diffStaged(staged, variants)
if err != nil {
mgr.Discard(token)
s.consoleError(c, err)
return
}
s.renderConsole(c, "dictionary_preview", "dictionary", "Dictionary update", adminconsole.DictionaryPreviewView{
Version: version,
Token: token,
ActiveVersion: s.games.ActiveVersion(),
Variants: rows,
})
}
// consoleInstallDictionary promotes a staged upload into its version directory,
// loads it into the registry and makes it the active version new games pin. The
// version is taken from the (editable) form field, re-validated and checked for
// immutability.
func (s *Server) consoleInstallDictionary(c *gin.Context) {
token := trimForm(c, "token")
version := trimForm(c, "version")
if !dictadmin.ValidVersion(version) {
s.renderConsoleMessage(c, "Install failed", "the version must look like vX.Y.Z", "/_gm/dictionary")
return
}
if s.versionResident(version) {
s.renderConsoleMessage(c, "Already resident", fmt.Sprintf("version %s is already loaded; versions are immutable", version), "/_gm/dictionary")
return
}
mgr := dictadmin.New(s.dictDir)
dest, err := mgr.Install(token, version)
if err != nil {
s.consoleError(c, err)
return
}
loaded, err := s.registry.LoadAvailable(dest, version)
if err != nil {
s.consoleError(c, err)
return
}
if len(loaded) != len(engine.Variants()) {
s.renderConsoleMessage(c, "Install incomplete",
fmt.Sprintf("loaded %d of %d variants from %s", len(loaded), len(engine.Variants()), version), "/_gm/dictionary")
return
}
if err := s.games.SetActiveVersion(c.Request.Context(), version); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Updated",
fmt.Sprintf("dictionary %s installed and activated; new games now use it, games in progress keep their version", version),
"/_gm/dictionary")
}
// diffStaged computes the word diff of each staged variant against the active
// dictionary, projected into the preview rows.
func (s *Server) diffStaged(staged string, variants []engine.Variant) ([]adminconsole.VariantDiffRow, error) {
active := s.games.ActiveVersion()
rows := make([]adminconsole.VariantDiffRow, 0, len(variants))
for _, v := range variants {
newFinder, err := engine.OpenFinder(staged, v)
if err != nil {
return nil, err
}
oldFinder, err := s.registry.Finder(v, active)
if err != nil {
_ = newFinder.Close()
return nil, fmt.Errorf("active dictionary %s/%s not resident: %w", v, active, err)
}
diff, err := engine.DiffWords(v, oldFinder, newFinder)
_ = newFinder.Close()
if err != nil {
return nil, err
}
rows = append(rows, diffRow(v, diff))
}
return rows, nil
}
// diffRow projects a variant's word diff into its preview row, capping the sample
// lists and flagging a large removal.
func diffRow(v engine.Variant, d engine.WordDiff) adminconsole.VariantDiffRow {
added, addedTrunc := sampleWords(d.Added)
removed, removedTrunc := sampleWords(d.Removed)
return adminconsole.VariantDiffRow{
Variant: v.String(),
AddedCount: len(d.Added),
RemovedCount: len(d.Removed),
AddedSample: added,
RemovedSample: removed,
AddedTruncated: addedTrunc,
RemovedTruncated: removedTrunc,
LargeRemoval: len(d.Removed) >= largeRemovalThreshold,
}
}
// sampleWords returns at most previewSampleCap words and whether the list was cut.
func sampleWords(words []string) ([]string, bool) {
if len(words) <= previewSampleCap {
return words, false
}
return words[:previewSampleCap], true
}
// versionResident reports whether version is loaded for any variant (covering the
// flat seed and every uploaded subdirectory), backing the immutability guard.
func (s *Server) versionResident(version string) bool {
for _, v := range engine.Variants() {
if _, err := s.registry.Finder(v, version); err == nil {
return true
}
}
return false
}
// consoleApplyChanges marks a variant's pending accepted changes applied in a
// reloaded version.
func (s *Server) consoleApplyChanges(c *gin.Context) {
variant, err := engine.ParseVariant(c.PostForm("variant"))
if err != nil {
s.renderConsoleMessage(c, "Apply failed", "unknown variant", "/_gm/dictionary")
return
}
version := trimForm(c, "version")
if version == "" {
s.renderConsoleMessage(c, "Apply failed", "a version is required", "/_gm/dictionary")
return
}
n, err := s.games.MarkChangesApplied(c.Request.Context(), variant, version)
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Applied", fmt.Sprintf("marked %d change(s) applied in %q", n, version), "/_gm/dictionary")
}
// consoleBroadcast renders the operator-broadcast form.
func (s *Server) consoleBroadcast(c *gin.Context) {
s.renderConsole(c, "broadcast", "broadcast", "Broadcast", adminconsole.BroadcastView{ConnectorEnabled: s.connector != nil})
}
// consolePostBroadcast posts an operator message to the connector's game channel.
func (s *Server) consolePostBroadcast(c *gin.Context) {
text := trimForm(c, "text")
switch {
case text == "":
s.renderConsoleMessage(c, "Nothing sent", "the message was empty", "/_gm/broadcast")
case s.connector == nil:
s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", "/_gm/broadcast")
default:
delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text)
if err != nil {
s.consoleError(c, err)
return
}
body := "posted to the game channel"
if !delivered {
body = "not delivered (the bot has no game channel configured)"
}
s.renderConsoleMessage(c, "Broadcast", body, "/_gm/broadcast")
}
}
// consoleThrottled renders the rate-limit observability page: the recent
// gateway-reported throttle episodes (in-memory, reset on a backend restart)
// and the accounts currently carrying the soft high-rate flag.
func (s *Server) consoleThrottled(c *gin.Context) {
ctx := c.Request.Context()
var view adminconsole.ThrottledView
if s.ratewatch != nil {
cfg := s.ratewatch.Config()
view.FlagThreshold = cfg.FlagThreshold
view.FlagWindow = cfg.FlagWindow.String()
for _, ep := range s.ratewatch.Recent() {
row := adminconsole.ThrottleEpisodeRow{
Class: ep.Class, Key: ep.Key, Rejected: ep.Rejected,
FirstSeen: fmtTime(ep.FirstSeen), LastSeen: fmtTime(ep.LastSeen),
}
if ep.Class == ratewatch.ClassUser {
if id, err := uuid.Parse(ep.Key); err == nil {
row.UserID = id.String()
}
}
view.Episodes = append(view.Episodes, row)
}
}
if s.banview != nil {
for _, b := range s.banview.Recent() {
view.Bans = append(view.Bans, adminconsole.BanRow{
IP: b.IP, Reason: b.Reason, Since: fmtTime(b.Since), Expires: fmtTime(b.Expires),
})
}
}
flagged, err := s.accounts.ListFlaggedHighRate(ctx)
if err != nil {
s.consoleError(c, err)
return
}
for _, fa := range flagged {
view.Flagged = append(view.Flagged, adminconsole.FlaggedAccountRow{
ID: fa.ID.String(), DisplayName: fa.DisplayName, FlaggedAt: fmtTime(fa.FlaggedHighRateAt),
})
}
s.renderConsole(c, "throttled", "throttled", "Throttled", view)
}
// consoleUnban lifts a temporary IP ban — the operator's manual override. The
// gateway applies it on its next active-ban sync, so the ban clears within the
// sync interval rather than immediately.
func (s *Server) consoleUnban(c *gin.Context) {
ip := trimForm(c, "ip")
if ip == "" {
s.renderConsoleMessage(c, "Invalid", "an IP address is required", "/_gm/throttled")
return
}
if s.banview != nil {
s.banview.RequestUnban(ip)
}
s.renderConsoleMessage(c, "Unban requested", fmt.Sprintf("%s will be unbanned on the next gateway sync", ip), "/_gm/throttled")
}
// consoleClearHighRateFlag clears the soft high-rate marker — the operator's
// reversible review action.
func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
if err := s.accounts.ClearHighRateFlag(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
}
// consoleRemoveEmail deletes the account's bound email identity (and any pending
// confirmations), freeing the address. It refuses to remove the account's only
// identity, which would leave it unreachable.
func (s *Server) consoleRemoveEmail(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
switch err := s.accounts.RemoveEmailIdentity(c.Request.Context(), id); {
case errors.Is(err, account.ErrLastIdentity):
s.renderConsoleMessage(c, "Can't remove", "the email is this account's only identity — removing it would leave the account unreachable", back)
case err != nil:
s.consoleError(c, err)
default:
s.renderConsoleMessage(c, "Removed", "the email identity was erased and the address freed", back)
}
}
// consoleDeleteUser deletes an account from the console — the operator-initiated equivalent
// of the in-app deletion (legal retention, not erasure): the account is tombstoned, its
// credentials journalled + freed, its live surfaces anonymised, and its sessions revoked.
func (s *Server) consoleDeleteUser(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if err := s.deleteAccount(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "the account was deleted: its credentials were journalled and freed, its data anonymised, and its sessions revoked", back)
}
// 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
}
// Re-evaluate the player's moderated-chat write access: a block mutes them in
// the discussion chat if they are currently in it.
s.publishChatAccessChange(id)
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
}
// Re-evaluate the player's moderated-chat write access: an unblock restores it
// (unless they are still chat-muted) for a member currently in the chat.
s.publishChatAccessChange(id)
s.renderConsoleMessage(c, "Unblocked", "the block was lifted; lost games are not restored", "/_gm/users/"+id.String())
}
// parseSuspendUntil maps the block form's duration choice to an expiry instant, returning nil for
// a permanent block. A custom choice parses the datetime-local value as UTC and must be in the
// future. It reports false for an unrecognised choice or an invalid/past custom date.
func parseSuspendUntil(choice, custom string) (*time.Time, bool) {
now := time.Now().UTC()
switch choice {
case "permanent":
return nil, true
case "1d":
t := now.AddDate(0, 0, 1)
return &t, true
case "3d":
t := now.AddDate(0, 0, 3)
return &t, true
case "1w":
t := now.AddDate(0, 0, 7)
return &t, true
case "1m":
t := now.AddDate(0, 1, 0)
return &t, true
case "custom":
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, custom); err == nil {
if !t.After(now) {
return nil, false
}
return &t, true
}
}
return nil, false
default:
return nil, false
}
}
// consoleReasons renders the operator-editable suspension-reason picklist.
func (s *Server) consoleReasons(c *gin.Context) {
reasons, err := s.accounts.ListReasons(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
var view adminconsole.ReasonsView
for _, r := range reasons {
view.Items = append(view.Items, adminconsole.ReasonRow{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu, CreatedAt: fmtTime(r.CreatedAt)})
}
s.renderConsole(c, "reasons", "reasons", "Reasons", view)
}
// consoleCreateReason adds a suspension-reason picklist entry; both languages are required.
func (s *Server) consoleCreateReason(c *gin.Context) {
en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
if en == "" || ru == "" {
s.renderConsoleMessage(c, "Nothing added", "both English and Russian text are required", "/_gm/reasons")
return
}
if _, err := s.accounts.CreateReason(c.Request.Context(), en, ru); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Added", "reason added", "/_gm/reasons")
}
// consoleUpdateReason rewrites a reason's English and Russian text. Existing blocks keep their
// snapshot, so the change only affects future blocks.
func (s *Server) consoleUpdateReason(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/reasons")
if !ok {
return
}
en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
if en == "" || ru == "" {
s.renderConsoleMessage(c, "Not changed", "both English and Russian text are required", "/_gm/reasons")
return
}
if _, err := s.accounts.UpdateReason(c.Request.Context(), id, en, ru); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Updated", "reason updated", "/_gm/reasons")
}
// consoleDeleteReason removes a reason from the picklist. Past blocks keep their text snapshot.
func (s *Server) consoleDeleteReason(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/reasons")
if !ok {
return
}
if err := s.accounts.DeleteReason(c.Request.Context(), id); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "reason deleted", "/_gm/reasons")
}
// variantVersions builds the per-variant resident-version summary from the registry.
func (s *Server) variantVersions() []adminconsole.VariantVersions {
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
for _, v := range engine.Variants() {
vv := adminconsole.VariantVersions{Variant: v.String(), Versions: s.registry.Versions(v)}
if latest, _, err := s.registry.Latest(v); err == nil {
vv.Latest = latest
}
out = append(out, vv)
}
return out
}
// renderConsole renders a console page into a buffer, then writes it; a render
// failure is logged and reported as 500 without emitting a partial document.
func (s *Server) renderConsole(c *gin.Context, page, nav, title string, data any) {
var buf bytes.Buffer
if err := s.console.Render(&buf, page, adminconsole.PageData{Title: title, ActiveNav: nav, Data: data}); err != nil {
s.log.Error("admin console render", zap.String("page", page), zap.Error(err))
c.String(http.StatusInternalServerError, "render error")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
// renderConsoleMessage renders the post-action result page with a back link.
func (s *Server) renderConsoleMessage(c *gin.Context, heading, body, back string) {
s.renderConsole(c, "message", "", heading, adminconsole.MessageView{Heading: heading, Body: body, Back: back})
}
// consoleError logs an unexpected console error and renders it on the message page.
// The console is operator-only (gateway Basic-Auth), so the message is shown as-is.
func (s *Server) consoleError(c *gin.Context, err error) {
s.log.Error("admin console", zap.String("path", c.FullPath()), zap.Error(err))
s.renderConsole(c, "message", "", "Error", adminconsole.MessageView{Heading: "Error", Body: err.Error(), Back: "/_gm/"})
}
// consoleUUID parses the :id path parameter, rendering an invalid-id message and
// returning false when it is not a UUID.
func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
s.renderConsoleMessage(c, "Invalid id", "the id in the URL is not valid", back)
return uuid.UUID{}, false
}
return id, true
}
// gameRow projects a game summary into its console row.
func gameRow(g game.Game) adminconsole.GameRow {
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI, Kind: g.Kind.String()}
}
// trimForm returns the trimmed value of a posted form field.
func trimForm(c *gin.Context, name string) string {
return strings.TrimSpace(c.PostForm(name))
}
// consolePage parses the 1-based ?page query parameter, defaulting to 1.
func consolePage(c *gin.Context) int {
if n, err := strconv.Atoi(c.Query("page")); err == nil && n > 1 {
return n
}
return 1
}
// normalizeGameStatus keeps only a recognised game status filter, else "" (all).
func normalizeGameStatus(s string) string {
switch s {
case game.StatusActive, game.StatusFinished, game.StatusOpen:
return s
}
return ""
}
// normalizeComplaintStatus keeps only a recognised complaint status filter, else
// "" (all).
func normalizeComplaintStatus(s string) string {
switch s {
case game.StatusComplaintOpen, game.StatusComplaintResolved:
return s
}
return ""
}
// fmtTime formats a timestamp for display, or "" when zero.
func fmtTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02 15:04")
}
// fmtTimeIn formats a timestamp in the given zone — a "±HH:MM" offset or an IANA name, resolved
// by account.ResolveZone (falling back to UTC when empty or unknown) — or "" when zero. Used to
// show a time in a user's local zone beside UTC; the offset form is what the profile editor and
// the feedback browser-tz snapshot store, so it must not go through time.LoadLocation alone.
func fmtTimeIn(t time.Time, tz string) string {
if t.IsZero() {
return ""
}
return t.In(account.ResolveZone(tz)).Format("2006-01-02 15:04")
}
// fmtTimePtr formats an optional timestamp for display, or "" when nil.
func fmtTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return fmtTime(*t)
}