package server import ( "bytes" "net/http" "net/url" "strings" "galaxy/backend/internal/adminconsole" "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 logger *zap.Logger } // NewAdminConsoleHandlers constructs the console handler set. A nil renderer // falls back to the embedded default templates; a nil csrf falls back to a // fresh per-process random key; a nil logger falls back to zap.NewNop. 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(renderer *adminconsole.Renderer, csrf *adminconsole.CSRF, logger *zap.Logger) *AdminConsoleHandlers { if logger == nil { logger = zap.NewNop() } if renderer == nil { renderer = adminconsole.MustNewRenderer() } 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))), logger: logger.Named("http.admin.console"), } } // Dashboard renders the console landing page (GET /_gm and GET /_gm/). func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc { return func(c *gin.Context) { h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", nil) } } // 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()) } // 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) }