65c194264c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m0s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Mirror the Telegram Mini App wrapper for VK: the SPA loads at a new /vk/ entry, authenticates from VK's signed launch parameters, and provisions a 'vk' platform identity — the minimum to run the game in VK test mode. - Gateway verifies the launch signature in-process (internal/vkauth: HMAC-SHA256 over the sorted vk_* params under GATEWAY_VK_APP_SECRET, base64url) — a pure offline check, no side-service. New auth.vk op (gated on the secret), backendclient.VKAuth, /vk/ SPA mount. - Backend: KindVK + ProvisionVK/vkSeed, /sessions/vk handler, identity kind widened to include 'vk' (migration 00005, expand-contract). - UI: src/lib/vk.ts (VK Bridge, lazy-imported), bootVK + the /vk/ boot dispatch, encodeVKLogin + authVK across transport/client/mock. VK omits the name from the signed params, so the client reads it via VKWebAppGetUserInfo as an unsigned display seed. - Deploy: /vk in the edge Caddyfile, GATEWAY_VK_APP_SECRET wired through compose + .env.example + CI (TEST_) + prod-deploy (PROD_). - Admin console: surface the VK user id (link to the VK profile) next to the Telegram id on the user card. - Docs: ARCHITECTURE §12/§13, FUNCTIONAL (+ _ru), gateway README; VK integration reference under .claude/. Signature algorithm verified against dev.vk.com plus independent Node/Python references and a %2C edge-case vector.
1222 lines
44 KiB
Go
1222 lines
44 KiB
Go
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/ratewatch"
|
|
"scrabble/backend/internal/robot"
|
|
"scrabble/backend/internal/social"
|
|
)
|
|
|
|
// adminPageSize is the page size of the admin console's paginated lists.
|
|
const adminPageSize = 50
|
|
|
|
// maxHintGrant caps a single operator hint grant. Grants are additive and can never lower a
|
|
// wallet, so a fat-fingered grant cannot be undone through this form; the cap bounds one mistake.
|
|
const maxHintGrant = 100
|
|
|
|
// 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/grant-hints", s.consoleGrantHints)
|
|
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.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)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
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"),
|
|
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,
|
|
PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, HintGrantMax: maxHintGrant,
|
|
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 {
|
|
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 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)
|
|
}
|
|
}
|
|
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
// 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())
|
|
}
|
|
|
|
// consoleGrantHints adds hints to a user's wallet. The grant is additive (raise-only): it tops a
|
|
// player up and can never lower what they already hold, so blocking a reduction is inherent rather
|
|
// than a separate guard. A single grant is bounded by maxHintGrant.
|
|
func (s *Server) consoleGrantHints(c *gin.Context) {
|
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
|
if !ok {
|
|
return
|
|
}
|
|
back := "/_gm/users/" + id.String()
|
|
n, err := strconv.Atoi(trimForm(c, "amount"))
|
|
if err != nil || n < 1 || n > maxHintGrant {
|
|
s.renderConsoleMessage(c, "Invalid amount", fmt.Sprintf("enter a whole number of hints to add, between 1 and %d", maxHintGrant), back)
|
|
return
|
|
}
|
|
balance, err := s.accounts.GrantHints(c.Request.Context(), id, n)
|
|
if err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
// A non-empty hint wallet removes the banner: nudge an open client to re-check.
|
|
s.publishBannerChange(id)
|
|
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), 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}
|
|
}
|
|
|
|
// 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)
|
|
}
|