27916bbe61
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.
108 lines
3.2 KiB
Go
108 lines
3.2 KiB
Go
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")
|
|
}
|