462 lines
16 KiB
Go
462 lines
16 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"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"
|
|
)
|
|
|
|
// 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.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("/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)
|
|
total, _ := s.accounts.CountAccounts(ctx)
|
|
accs, err := s.accounts.ListAccounts(ctx, adminPageSize, (page-1)*adminPageSize)
|
|
if err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)}
|
|
for _, a := range accs {
|
|
kind := "registered"
|
|
if a.IsGuest {
|
|
kind = "guest"
|
|
}
|
|
view.Items = append(view.Items, adminconsole.UserRow{
|
|
ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind,
|
|
Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt),
|
|
})
|
|
}
|
|
s.renderConsole(c, "users", "users", "Users", view)
|
|
}
|
|
|
|
// 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 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))
|
|
}
|
|
}
|
|
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")
|
|
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),
|
|
}
|
|
for _, seat := range g.Seats {
|
|
row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner}
|
|
if acc, err := s.accounts.GetByID(ctx, seat.AccountID); err == nil {
|
|
row.DisplayName = acc.DisplayName
|
|
}
|
|
view.Seats = append(view.Seats, row)
|
|
}
|
|
s.renderConsole(c, "game_detail", "games", "Game", view)
|
|
}
|
|
|
|
// 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")
|
|
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 (no game channel configured on the connector)"
|
|
}
|
|
s.renderConsoleMessage(c, "Broadcast", body, "/_gm/broadcast")
|
|
}
|
|
}
|
|
|
|
// variantVersions builds the per-variant resident-version summary from the registry.
|
|
func (s *Server) variantVersions() []adminconsole.VariantVersions {
|
|
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
|
|
for _, v := range engine.Variants() {
|
|
vv := adminconsole.VariantVersions{Variant: v.String(), Versions: s.registry.Versions(v)}
|
|
if latest, _, err := s.registry.Latest(v); err == nil {
|
|
vv.Latest = latest
|
|
}
|
|
out = append(out, vv)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renderConsole renders a console page into a buffer, then writes it; a render
|
|
// failure is logged and reported as 500 without emitting a partial document.
|
|
func (s *Server) renderConsole(c *gin.Context, page, nav, title string, data any) {
|
|
var buf bytes.Buffer
|
|
if err := s.console.Render(&buf, page, adminconsole.PageData{Title: title, ActiveNav: nav, Data: data}); err != nil {
|
|
s.log.Error("admin console render", zap.String("page", page), zap.Error(err))
|
|
c.String(http.StatusInternalServerError, "render error")
|
|
return
|
|
}
|
|
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
|
}
|
|
|
|
// renderConsoleMessage renders the post-action result page with a back link.
|
|
func (s *Server) renderConsoleMessage(c *gin.Context, heading, body, back string) {
|
|
s.renderConsole(c, "message", "", heading, adminconsole.MessageView{Heading: heading, Body: body, Back: back})
|
|
}
|
|
|
|
// consoleError logs an unexpected console error and renders it on the message page.
|
|
// The console is operator-only (gateway Basic-Auth), so the message is shown as-is.
|
|
func (s *Server) consoleError(c *gin.Context, err error) {
|
|
s.log.Error("admin console", zap.String("path", c.FullPath()), zap.Error(err))
|
|
s.renderConsole(c, "message", "", "Error", adminconsole.MessageView{Heading: "Error", Body: err.Error(), Back: "/_gm/"})
|
|
}
|
|
|
|
// consoleUUID parses the :id path parameter, rendering an invalid-id message and
|
|
// returning false when it is not a UUID.
|
|
func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
s.renderConsoleMessage(c, "Invalid id", "the id in the URL is not valid", back)
|
|
return uuid.UUID{}, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// gameRow projects a game summary into its console row.
|
|
func gameRow(g game.Game) adminconsole.GameRow {
|
|
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt)}
|
|
}
|
|
|
|
// trimForm returns the trimmed value of a posted form field.
|
|
func trimForm(c *gin.Context, name string) string {
|
|
return strings.TrimSpace(c.PostForm(name))
|
|
}
|
|
|
|
// consolePage parses the 1-based ?page query parameter, defaulting to 1.
|
|
func consolePage(c *gin.Context) int {
|
|
if n, err := strconv.Atoi(c.Query("page")); err == nil && n > 1 {
|
|
return n
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// normalizeGameStatus keeps only a recognised game status filter, else "" (all).
|
|
func normalizeGameStatus(s string) string {
|
|
switch s {
|
|
case game.StatusActive, game.StatusFinished:
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// normalizeComplaintStatus keeps only a recognised complaint status filter, else
|
|
// "" (all).
|
|
func normalizeComplaintStatus(s string) string {
|
|
switch s {
|
|
case game.StatusComplaintOpen, game.StatusComplaintResolved:
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// fmtTime formats a timestamp for display, or "" when zero.
|
|
func fmtTime(t time.Time) string {
|
|
if t.IsZero() {
|
|
return ""
|
|
}
|
|
return t.UTC().Format("2006-01-02 15:04")
|
|
}
|
|
|
|
// fmtTimePtr formats an optional timestamp for display, or "" when nil.
|
|
func fmtTimePtr(t *time.Time) string {
|
|
if t == nil {
|
|
return ""
|
|
}
|
|
return fmtTime(*t)
|
|
}
|