87a272166b
Tests · Go / test (push) Successful in 1m59s
Add the operator-management page over *admin.Service (no new business logic).
- GET/POST /_gm/operators list + create operator
- POST /_gm/operators/{user}/disable|enable toggle access
- POST /_gm/operators/{user}/reset-password set a new password
Console depends on an OperatorAdmin interface (satisfied by *admin.Service) so
the page renders in tests without a database. Create POST is mounted on the
collection path; per-row disable/enable/reset are guarded by the CSRF middleware
and redirect back. Passwords are never logged.
Tests: list render, create (+ username/password assertions), username-taken
conflict, disable/enable, reset (+ password assertion), missing-password 400,
bad-CSRF 403, and unavailable 503.
Docs: backend/docs/admin-console.md page inventory extended.
227 lines
7.5 KiB
Go
227 lines
7.5 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
|
|
operators OperatorAdmin
|
|
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
|
|
Operators OperatorAdmin
|
|
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,
|
|
operators: deps.Operators,
|
|
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)
|
|
}
|