feat(admin-console): Stage 1 — pipe + skeleton behind the gateway
Tests · Go / test (push) Successful in 2m0s
Tests · Go / test (push) Successful in 2m0s
Add the server-rendered operator console at /_gm, exposed publicly through the gateway behind the existing admin_accounts Basic Auth. Backend: - new internal/adminconsole package (html/template Renderer, stateless HMAC CSRF signer, embedded stylesheet) - /_gm route group reusing basicauth.Middleware(admin.Service) + a CSRF guard (per-operator token + same-origin check); dashboard landing page - BACKEND_ADMIN_CONSOLE_CSRF_KEY config (per-process random fallback) Gateway: - new "admin" public route class (per-IP rate limit, body + GET/HEAD/POST method limits) classifying /_gm traffic - reverse proxy to the backend /_gm surface, preserving Host and relaying the backend 401 Basic Auth challenge; 502 when the backend is unreachable - GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_* config dev-deploy: - Caddy routes /_gm/* to the gateway - bootstrap admin + stable CSRF key; enable Prometheus /metrics exporters on backend and gateway (forward-compat for a future Prometheus/Grafana stack) Docs: ARCHITECTURE 14.1/16, FUNCTIONAL 10.2.1 (+ru mirror), backend and gateway READMEs, new backend/docs/admin-console.md. Tests: renderer + CSRF unit tests; backend router auth/render/asset/CSRF; gateway classifier, proxy forwarding/Host/401/405/413/429/502.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
package adminconsole
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed assets
|
||||
var assetsFS embed.FS
|
||||
|
||||
// Renderer holds the parsed admin console templates. It composes one template
|
||||
// set per content page, each combining the shared layout (defining the page
|
||||
// chrome and the "layout" entry template) with that page's "content" block, so
|
||||
// rendering a page is a single ExecuteTemplate call against the "layout" name.
|
||||
type Renderer struct {
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
// PageData is the view model passed to every admin console page. Title is the
|
||||
// document title; Username is the authenticated operator; CSRFToken is the
|
||||
// per-operator token embedded into state-changing forms; ActiveNav marks the
|
||||
// highlighted navigation entry; Data carries the page-specific payload.
|
||||
type PageData struct {
|
||||
Title string
|
||||
Username string
|
||||
CSRFToken string
|
||||
ActiveNav string
|
||||
Data any
|
||||
}
|
||||
|
||||
// NewRenderer parses the embedded layout and every content page under
|
||||
// templates/pages, returning a Renderer ready to serve them. It fails when a
|
||||
// template cannot be parsed.
|
||||
func NewRenderer() (*Renderer, error) {
|
||||
base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admin console layout: %w", err)
|
||||
}
|
||||
|
||||
pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enumerate admin console pages: %w", err)
|
||||
}
|
||||
if len(pageFiles) == 0 {
|
||||
return nil, fmt.Errorf("admin console: no page templates found under templates/pages")
|
||||
}
|
||||
|
||||
pages := make(map[string]*template.Template, len(pageFiles))
|
||||
for _, file := range pageFiles {
|
||||
name := strings.TrimSuffix(path.Base(file), ".gohtml")
|
||||
clone, err := base.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err)
|
||||
}
|
||||
if _, err := clone.ParseFS(templatesFS, file); err != nil {
|
||||
return nil, fmt.Errorf("parse admin console page %q: %w", name, err)
|
||||
}
|
||||
pages[name] = clone
|
||||
}
|
||||
|
||||
return &Renderer{pages: pages}, nil
|
||||
}
|
||||
|
||||
// MustNewRenderer is like NewRenderer but panics on error. The templates are
|
||||
// embedded at build time, so a parse failure is a programmer error rather than
|
||||
// a runtime condition.
|
||||
func MustNewRenderer() *Renderer {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
// Render writes the named page, wrapped in the shared layout, to w using data.
|
||||
// It returns an error when page is unknown or template execution fails; the
|
||||
// page is rendered into an intermediate buffer first so a mid-render failure
|
||||
// never emits a partial document to w.
|
||||
func (r *Renderer) Render(w io.Writer, page string, data PageData) error {
|
||||
tmpl, ok := r.pages[page]
|
||||
if !ok {
|
||||
return fmt.Errorf("admin console: unknown page %q", page)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||
return fmt.Errorf("render admin console page %q: %w", page, err)
|
||||
}
|
||||
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
// Assets returns the embedded static asset tree rooted at the assets directory,
|
||||
// suitable for serving under `/_gm/assets/`.
|
||||
func Assets() (fs.FS, error) {
|
||||
return fs.Sub(assetsFS, "assets")
|
||||
}
|
||||
Reference in New Issue
Block a user