package server import ( "bytes" "encoding/csv" "errors" "fmt" "html/template" "net/http" "net/url" "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/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.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.csv", s.consoleLedgerExport) } } // 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(stmt) } else { s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err)) } view.Grant = s.grantForm(ctx) } s.renderConsole(c, "user_detail", "users", acc.DisplayName, view) } // financeView projects an account's payments statement into the user-card finance panel, with the // benefit expiry and ledger times pre-formatted for the logic-free template. func financeView(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) } for _, e := range stmt.Ledger { fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{ Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta, Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Snapshot: e.Snapshot, At: fmtTime(e.CreatedAt), }) } return fv } // 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) }