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) }