Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts)
Server-rendered admin console in the backend at /_gm (internal/adminconsole), fronted on the gateway's public listener by Basic-Auth + a verbatim reverse proxy (mounted on the edge mux below the h2c wrap). A same-origin check guards its POSTs; no operator identity is tracked. This supersedes the Stage 6 gateway-fronts- /api/v1/admin model: GATEWAY_ADMIN_ADDR and the backend /api/v1/admin ping are dropped and gateway/internal/admin is repurposed to the verbatim proxy. - Complaints: migration 00008 (+ jetgen) adds disposition/resolution_note/ resolved_at/applied_in_version + the deferred status CHECK; resolution feeds a query-derived pending dictionary-change pipeline (marked applied after a reload). - Dictionary hot-reload: per-version subdir BACKEND_DICT_DIR/<version>/ via the new Registry.LoadAvailable; engine.OpenWithVersions restores resident versions on restart. Partially addresses TODO-2. - Broadcasts: a backend Telegram-connector client (internal/connector, BACKEND_CONNECTOR_ADDR) for SendToUser / SendToGameChannel (discharges the Stage 9 forward-note). - Admin reads: account.ListAccounts/CountAccounts/Identities and game.ListGames/CountGames/GameByID/ListComplaints/GetComplaint/CountComplaints/ ResolveComplaint/DictionaryChanges/MarkChangesApplied. - Tests: adminconsole render, engine reload, same-origin guard, gateway verbatim proxy + h2c console mount, inttest complaint pipeline + list/count + /_gm console. - Docs: PLAN (Stage 10 done + refinements + TODO-2), ARCHITECTURE §1/§5/§6/§12/§13, FUNCTIONAL (+_ru), TESTING, backend/gateway READMEs.
This commit is contained in:
@@ -87,7 +87,6 @@ func (s *Server) registerRoutes() {
|
||||
u.POST("/blocks", s.handleBlock)
|
||||
u.DELETE("/blocks/:id", s.handleUnblock)
|
||||
}
|
||||
s.admin.GET("/ping", s.handleAdminPing)
|
||||
}
|
||||
|
||||
// userID returns the authenticated account id stored by RequireUserID. The user
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// The /api/v1/admin/* endpoints are reached through the gateway's Basic-Auth
|
||||
// reverse proxy (docs/ARCHITECTURE.md §12). The backend trusts the gateway to
|
||||
// have authenticated the operator; the admin surface itself (complaint review,
|
||||
// dictionary versions) lands in Stage 10. handleAdminPing is the proxy target that
|
||||
// proves the path end to end until then.
|
||||
func (s *Server) handleAdminPing(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
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,
|
||||
HintBalance: acc.HintBalance, CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest,
|
||||
ConnectorEnabled: s.connector != nil,
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -43,13 +43,6 @@ func do(t *testing.T, s *Server, method, path, body string, headers map[string]s
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestAdminPingOK(t *testing.T) {
|
||||
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/admin/ping", "", nil)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"status":"ok"`) {
|
||||
t.Fatalf("admin ping = %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileRequiresUserID(t *testing.T) {
|
||||
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/user/profile", "", nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -39,3 +40,40 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
|
||||
id, ok := ctx.Value(userIDContextKey).(uuid.UUID)
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// requireSameOrigin guards the admin console's state-changing requests: it rejects
|
||||
// a non-safe request whose Origin (or, failing that, Referer) host does not match
|
||||
// the request Host. The gateway authenticates the operator with Basic-Auth in front
|
||||
// of /_gm; this same-origin check is the console's CSRF defence, stopping a
|
||||
// cross-site form POST from riding the cached credential. Safe methods pass through.
|
||||
func requireSameOrigin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
switch c.Request.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
if !sameOrigin(c.Request) {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// sameOrigin reports whether the request's Origin (or, failing that, Referer) host
|
||||
// matches the request Host. A state-changing request carrying neither header is
|
||||
// rejected.
|
||||
func sameOrigin(r *http.Request) bool {
|
||||
for _, h := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} {
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(h)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return u.Host == r.Host
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestSameOriginGuard checks the admin console's CSRF defence: safe methods pass,
|
||||
// a state-changing request needs an Origin/Referer host matching the request Host.
|
||||
func TestSameOriginGuard(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
e := gin.New()
|
||||
g := e.Group("/_gm")
|
||||
g.Use(requireSameOrigin())
|
||||
g.POST("/act", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
g.GET("/page", func(c *gin.Context) { c.Status(http.StatusOK) })
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
origin string
|
||||
referer string
|
||||
want int
|
||||
}{
|
||||
{"get is safe", http.MethodGet, "/_gm/page", "", "", http.StatusOK},
|
||||
{"post without origin rejected", http.MethodPost, "/_gm/act", "", "", http.StatusForbidden},
|
||||
{"post matching origin ok", http.MethodPost, "/_gm/act", "http://example.com", "", http.StatusOK},
|
||||
{"post foreign origin rejected", http.MethodPost, "/_gm/act", "http://evil.test", "", http.StatusForbidden},
|
||||
{"post matching referer ok", http.MethodPost, "/_gm/act", "", "http://example.com/_gm/x", http.StatusOK},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, "http://example.com"+tc.path, nil)
|
||||
if tc.origin != "" {
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
}
|
||||
if tc.referer != "" {
|
||||
req.Header.Set("Referer", tc.referer)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
if rec.Code != tc.want {
|
||||
t.Errorf("status = %d, want %d", rec.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/adminconsole"
|
||||
"scrabble/backend/internal/connector"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/session"
|
||||
@@ -55,6 +58,15 @@ type Deps struct {
|
||||
Matchmaker *lobby.Matchmaker
|
||||
Invitations *lobby.InvitationService
|
||||
Emails *account.EmailService
|
||||
// Registry holds the resident dictionaries; the admin console (Stage 10) reads
|
||||
// its versions and hot-reloads new ones. DictDir is the dictionary directory a
|
||||
// reload reads a version subdirectory from. A nil Registry disables the console.
|
||||
Registry *engine.Registry
|
||||
DictDir string
|
||||
// Connector is the backend's Telegram connector client for operator broadcasts;
|
||||
// nil when BACKEND_CONNECTOR_ADDR is unset (broadcasts show a "not configured"
|
||||
// notice).
|
||||
Connector *connector.Client
|
||||
}
|
||||
|
||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||
@@ -73,11 +85,14 @@ type Server struct {
|
||||
matchmaker *lobby.Matchmaker
|
||||
invitations *lobby.InvitationService
|
||||
emails *account.EmailService
|
||||
registry *engine.Registry
|
||||
dictDir string
|
||||
connector *connector.Client
|
||||
console *adminconsole.Renderer
|
||||
|
||||
public *gin.RouterGroup
|
||||
user *gin.RouterGroup
|
||||
internal *gin.RouterGroup
|
||||
admin *gin.RouterGroup
|
||||
}
|
||||
|
||||
// New returns a Server that will listen on addr. It installs the recovery and
|
||||
@@ -109,11 +124,15 @@ func New(addr string, deps Deps) *Server {
|
||||
matchmaker: deps.Matchmaker,
|
||||
invitations: deps.Invitations,
|
||||
emails: deps.Emails,
|
||||
registry: deps.Registry,
|
||||
dictDir: deps.DictDir,
|
||||
connector: deps.Connector,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
}
|
||||
s.registerProbes(engine)
|
||||
s.registerAPIGroups(engine)
|
||||
s.registerRoutes()
|
||||
s.registerConsole(engine)
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -153,7 +172,6 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) {
|
||||
s.user = v1.Group("/user")
|
||||
s.user.Use(RequireUserID())
|
||||
s.internal = v1.Group("/internal")
|
||||
s.admin = v1.Group("/admin")
|
||||
}
|
||||
|
||||
// PublicGroup returns the unauthenticated public route group.
|
||||
@@ -165,9 +183,6 @@ func (s *Server) UserGroup() *gin.RouterGroup { return s.user }
|
||||
// InternalGroup returns the gateway-facing internal route group.
|
||||
func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal }
|
||||
|
||||
// AdminGroup returns the admin route group (authenticated at the gateway).
|
||||
func (s *Server) AdminGroup() *gin.RouterGroup { return s.admin }
|
||||
|
||||
// Social returns the social domain service for the handlers added in Stage 6.
|
||||
func (s *Server) Social() *social.Service { return s.social }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user