Files
galaxy-game/backend/internal/server/handlers_admin_console.go
T
Ilia Denisov ecfb2d3351
Tests · Go / test (push) Successful in 1m58s
feat(admin-console): Stage 4 — games & runtimes domain
Add the games, runtime, and engine-version pages over the existing lobby,
runtime, and engine-version services (no new business logic).

- GET/POST /_gm/games                         list + create public game
- GET      /_gm/games/{id}                    detail incl. runtime snapshot
- POST     /_gm/games/{id}/force-start|stop    game state actions
- POST     /_gm/games/{id}/ban-member          ban a member (uuid + reason)
- POST     /_gm/games/{id}/runtime/restart|patch|force-next-turn
- GET/POST /_gm/engine-versions               registry + register
- POST     /_gm/engine-versions/{ver}/disable disable a version

Console depends on GameAdmin / RuntimeAdmin / EngineVersionAdmin interfaces
(satisfied by the concrete services) so the pages render in tests without a
database. Collection-mutating POSTs are mounted on the collection path to avoid
a static-vs-param route conflict in gin. Writes flow through the CSRF guard and
redirect back; the create form parses datetime-local as UTC.

Tests: list/detail (with and without a runtime), create (visibility/owner/time
assertions), force-start (+ bad-CSRF), ban-member (+ bad uuid), runtime patch
(+ missing version), engine-version list/register/disable, and unavailable.

Docs: backend/docs/admin-console.md page inventory extended.
2026-05-31 20:25:28 +02:00

224 lines
7.4 KiB
Go

package server
import (
"bytes"
"net/http"
"net/url"
"strings"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/opsstatus"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// AdminConsoleHandlers renders the server-side operator console mounted under
// the `/_gm` route group. It wraps the framework-agnostic
// adminconsole.Renderer and CSRF signer with the gin glue: the per-page
// handlers, the embedded static-asset handler, and the CSRF guard middleware
// applied to state-changing requests. Authentication is provided by the shared
// admin Basic Auth middleware mounted on the group, so this type assumes the
// caller has already been verified.
type AdminConsoleHandlers struct {
renderer *adminconsole.Renderer
csrf *adminconsole.CSRF
assets http.Handler
monitor opsstatus.Reader
ready func() bool
users UserAdmin
games GameAdmin
runtime RuntimeAdmin
engineVersions EngineVersionAdmin
logger *zap.Logger
}
// AdminConsoleDeps bundles the collaborators for the operator console. Every
// field is optional: a nil Renderer or CSRF falls back to the embedded default
// templates and a per-process random key; a nil Monitor renders the dashboard
// without the monitoring panels; a nil Ready reports backend readiness as not
// ready; a nil Logger falls back to zap.NewNop.
type AdminConsoleDeps struct {
Renderer *adminconsole.Renderer
CSRF *adminconsole.CSRF
Monitor opsstatus.Reader
Ready func() bool
Users UserAdmin
Games GameAdmin
Runtime RuntimeAdmin
EngineVersions EngineVersionAdmin
Logger *zap.Logger
}
// NewAdminConsoleHandlers constructs the console handler set from deps. It
// panics only on conditions that are unrecoverable at startup (template parse
// failure or crypto/rand failure), both of which indicate a broken build or
// host rather than a runtime input.
func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
renderer := deps.Renderer
if renderer == nil {
renderer = adminconsole.MustNewRenderer()
}
csrf := deps.CSRF
if csrf == nil {
generated, err := adminconsole.NewRandomCSRF()
if err != nil {
panic(err)
}
csrf = generated
}
assetsFS, err := adminconsole.Assets()
if err != nil {
panic(err)
}
return &AdminConsoleHandlers{
renderer: renderer,
csrf: csrf,
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
monitor: deps.Monitor,
ready: deps.Ready,
users: deps.Users,
games: deps.Games,
runtime: deps.Runtime,
engineVersions: deps.EngineVersions,
logger: logger.Named("http.admin.console"),
}
}
// Dashboard renders the console landing page (GET /_gm and GET /_gm/),
// including the monitoring panels when an ops-status reader is wired.
func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc {
return func(c *gin.Context) {
data := adminconsole.DashboardData{}
if h.ready != nil {
data.BackendReady = h.ready()
}
if h.monitor != nil {
data.MonitorAvailable = true
snapshot := h.monitor.Collect(c.Request.Context())
data.PostgresHealthy = snapshot.PostgresHealthy
data.Runtimes = toViewCounts(snapshot.Runtimes)
data.MailDeliveries = toViewCounts(snapshot.MailDeliveries)
data.NotificationRoutes = toViewCounts(snapshot.NotificationRoutes)
data.NotificationMalformed = snapshot.NotificationMalformed
data.Errors = snapshot.Errors
}
h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", data)
}
}
// toViewCounts maps ops-status counts to the console's view-layer counts.
func toViewCounts(in []opsstatus.StatusCount) []adminconsole.StatusCount {
if len(in) == 0 {
return nil
}
out := make([]adminconsole.StatusCount, len(in))
for i, sc := range in {
out[i] = adminconsole.StatusCount{Status: sc.Status, Count: sc.Count}
}
return out
}
// Asset serves the embedded console static assets under `/_gm/assets/`.
func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc {
return gin.WrapH(h.assets)
}
// RequireCSRF returns middleware guarding state-changing requests against
// cross-site request forgery. Safe methods pass through untouched. For unsafe
// methods it requires both a same-origin Origin/Referer header (when the
// browser sends one) and a valid per-operator token in the `_csrf` form field;
// either check failing yields 403.
func (h *AdminConsoleHandlers) RequireCSRF() gin.HandlerFunc {
return func(c *gin.Context) {
if isSafeHTTPMethod(c.Request.Method) {
c.Next()
return
}
if !sameOriginRequest(c.Request) {
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "cross-origin request rejected")
return
}
username, _ := basicauth.UsernameFromContext(c.Request.Context())
if !h.csrf.Verify(username, c.PostForm("_csrf")) {
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "invalid or missing CSRF token")
return
}
c.Next()
}
}
// render composes the data common to every console page (operator name, CSRF
// token, active navigation entry) and writes the named page. It renders into an
// intermediate buffer so a template failure surfaces as a clean 500 without
// emitting a partial document.
func (h *AdminConsoleHandlers) render(c *gin.Context, status int, page, activeNav, title string, data any) {
username, _ := basicauth.UsernameFromContext(c.Request.Context())
var buf bytes.Buffer
err := h.renderer.Render(&buf, page, adminconsole.PageData{
Title: title,
Username: username,
CSRFToken: h.csrf.Token(username),
ActiveNav: activeNav,
Data: data,
})
if err != nil {
h.logger.Error("render admin console page", zap.String("page", page), zap.Error(err))
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "failed to render page")
return
}
c.Data(status, "text/html; charset=utf-8", buf.Bytes())
}
// renderMessage renders the generic message page (not-found, validation, or
// operation-failure notices). class selects the CSS styling and backHref, when
// non-empty, adds a back link.
func (h *AdminConsoleHandlers) renderMessage(c *gin.Context, status int, activeNav, title, message, class, backHref string) {
h.render(c, status, "message", activeNav, title, adminconsole.MessageData{
Message: message,
Class: class,
BackHref: backHref,
})
}
// isSafeHTTPMethod reports whether method is a read-only HTTP method that the
// CSRF guard may let through without a token.
func isSafeHTTPMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
default:
return false
}
}
// sameOriginRequest reports whether the request's Origin (or, failing that,
// Referer) names the same host as the request itself. A request that carries
// neither header is treated as same-origin, leaving the CSRF token as the sole
// guard; a malformed or cross-host value is rejected. This relies on the
// gateway reverse proxy preserving the inbound Host header.
func sameOriginRequest(r *http.Request) bool {
source := r.Header.Get("Origin")
if source == "" {
source = r.Header.Get("Referer")
}
if source == "" {
return true
}
parsed, err := url.Parse(source)
if err != nil || parsed.Host == "" {
return false
}
return strings.EqualFold(parsed.Host, r.Host)
}