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 (R3). 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 (R3). 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) }