Files
scrabble-game/backend/internal/server/handlers_admin_console.go
T
Ilia Denisov 8881214213 R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
2026-06-10 16:56:03 +02:00

711 lines
25 KiB
Go

package server
import (
"bytes"
"encoding/csv"
"fmt"
"net/http"
"net/url"
"path/filepath"
"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/engine"
"scrabble/backend/internal/game"
"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.GET("/throttled", s.consoleThrottled)
gm.GET("/games", s.consoleGames)
gm.GET("/games/:id", s.consoleGameDetail)
gm.GET("/complaints", s.consoleComplaints)
gm.GET("/complaints/:id", s.consoleComplaintDetail)
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
gm.GET("/messages", s.consoleMessages)
gm.GET("/messages.csv", s.consoleMessagesCSV)
gm.GET("/dictionary", s.consoleDictionary)
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
gm.GET("/broadcast", s.consoleBroadcast)
gm.POST("/broadcast", s.consolePostBroadcast)
}
// 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()}
view.Accounts, _ = s.accounts.CountAccounts(ctx)
view.Games, _ = s.games.CountGames(ctx, "")
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
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",
NameMask: c.Query("name"),
ExternalIDMask: c.Query("ext"),
}
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 strings.TrimSpace(filter.NameMask) != "" {
q.Set("name", filter.NameMask)
}
if strings.TrimSpace(filter.ExternalIDMask) != "" {
q.Set("ext", filter.ExternalIDMask)
}
view := adminconsole.UsersView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
Robots: filter.Robots, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
FilterQuery: q.Encode(),
}
ids := make([]uuid.UUID, 0, len(items))
for _, it := range items {
kind := "registered"
if it.IsRobot {
kind = "robot"
} else if 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,
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"),
}
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)
}
view := adminconsole.MessagesView{
Pager: adminconsole.NewPager(page, adminPageSize, total),
NameMask: filter.NameMask,
ExtMask: filter.ExtMask,
FilterQuery: 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),
})
}
s.renderConsole(c, "messages", "messages", "Messages", 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"),
}
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"})
for _, m := range items {
// 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(),
})
}
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,
PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, 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}
}
}
if ids, err := s.accounts.Identities(ctx, id); err == nil {
for _, idn := range ids {
view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
}
}
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
view.TelegramID = tg
}
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)
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
// 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")
language := trimForm(c, "language")
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, language)
if err != nil {
s.consoleError(c, err)
return
}
body := "message delivered"
if !delivered {
body = "not delivered (the user may not have started that 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),
}
// 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)
}
}
}
}
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()}
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)
}
// consoleReloadDictionary hot-loads a dictionary version from its subdirectory.
func (s *Server) consoleReloadDictionary(c *gin.Context) {
version := trimForm(c, "version")
if version == "" {
s.renderConsoleMessage(c, "Reload failed", "a version is required", "/_gm/dictionary")
return
}
dir := filepath.Join(s.dictDir, version)
loaded, err := s.registry.LoadAvailable(dir, version)
if err != nil {
s.consoleError(c, err)
return
}
if len(loaded) == 0 {
s.renderConsoleMessage(c, "Nothing loaded", "no dictionary files found in "+dir, "/_gm/dictionary")
return
}
names := make([]string, len(loaded))
for i, v := range loaded {
names[i] = v.String()
}
s.renderConsoleMessage(c, "Reloaded", fmt.Sprintf("loaded %v as version %q", names, version), "/_gm/dictionary")
}
// 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")
language := trimForm(c, "language")
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, language)
if err != nil {
s.consoleError(c, err)
return
}
body := "posted to the game channel"
if !delivered {
body = "not delivered (that 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)
}
}
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)
}
// 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())
}
// 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)}
}
// 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:
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")
}
// fmtTimePtr formats an optional timestamp for display, or "" when nil.
func fmtTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return fmtTime(*t)
}