94534ad0f2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s
The games-list status filter offered only active/finished; add 'open' (auto-match games awaiting an opponent) to the subnav and accept it in normalizeGameStatus. Render test covers the new filter link.
712 lines
25 KiB
Go
712 lines
25 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"html/template"
|
|
"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: template.URL(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: 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),
|
|
})
|
|
}
|
|
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, 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")
|
|
}
|
|
|
|
// fmtTimePtr formats an optional timestamp for display, or "" when nil.
|
|
func fmtTimePtr(t *time.Time) string {
|
|
if t == nil {
|
|
return ""
|
|
}
|
|
return fmtTime(*t)
|
|
}
|