Files
MeshTender/internal/web/env.go
T

530 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package web is the shared HTTP foundation: template rendering, common
// middleware/helpers, and the host dispatcher that the marketing/auth/core
// surface packages build on. It deliberately does NOT import the surface
// packages (or internal/auth), so those can import web without a cycle.
package web
import (
"bytes"
"context"
"embed"
"html/template"
"io/fs"
"log/slog"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/MeshTender/MeshTender/internal/buildinfo"
"github.com/MeshTender/MeshTender/internal/config"
"github.com/MeshTender/MeshTender/internal/identity"
"github.com/MeshTender/MeshTender/internal/store"
)
//go:embed templates/base.html templates/icons.html templates/brand.html templates/org_tabs.html templates/repeater_tabs.html templates/command_grid.html templates/org_access.html templates/org_public.html templates/org_config.html templates/org_repeaters.html templates/error.html
var sharedTemplatesFS embed.FS
// sharedPages are full content pages (not just layout partials) that more than
// one surface renders. They're composed onto the base layout for every surface,
// so the root host (anonymous) and the app host (signed-in) can render the same
// public org page without duplicating the template.
var sharedPages = []string{"templates/org_public.html", "templates/org_config.html", "templates/org_repeaters.html", "templates/error.html"}
//go:embed static/*
var staticFS embed.FS
// UserInfoFunc reports the signed-in user's display name, admin flag, and
// preferred IANA time zone (empty = auto-detect) for the page chrome and
// timestamp localization. ok is false when no user is signed in. Injected by the
// assembler (which wires it from the auth service + store) so web stays auth-free.
type UserInfoFunc func(ctx context.Context) (name string, canAdmin bool, tz string, ok bool)
// Deps are the shared dependencies every surface needs.
type Deps struct {
Store *store.Store
Identity *identity.Service
Cfg *config.Config
UserInfo UserInfoFunc
// CSP collects browser violation reports. One collector is shared by all three
// surfaces (violations happen on every host, and they aggregate into one table),
// so it's built once by the assembler and passed in here. Nil disables the
// endpoint and the reporting directives entirely.
CSP *CSPCollector
LookupTXT func(name string) ([]string, error)
// Build is what this binary was built from, reported by /version and the
// admin build page. Assembled once by the assembler (hashing the executable
// is not free) and shared, so every surface reports the same thing.
Build buildinfo.Info
}
// Env is the shared environment a surface's Handlers embeds. It carries the
// store/identity/config, the surface's renderer, and the DNS lookup.
type Env struct {
Store *store.Store
Identity *identity.Service
Cfg *config.Config
Renderer *Renderer
// LookupTXT resolves DNS TXT records; injectable so domain verification is
// testable. Defaults to net.LookupTXT.
LookupTXT func(name string) ([]string, error)
// Build is what this binary was built from (see Deps.Build).
Build buildinfo.Info
// csp is the shared violation-report collector, or nil when reporting is off.
// Unexported: surfaces only need it to exist, not to reach into it.
csp *CSPCollector
}
// NewEnv builds a surface environment from shared Deps plus that surface's own
// page templates (composed onto the shared base layout).
func NewEnv(d Deps, surfaceTemplates fs.FS) (*Env, error) {
r, err := NewRenderer(d.Cfg, surfaceTemplates)
if err != nil {
return nil, err
}
r.userInfo = d.UserInfo
lookup := d.LookupTXT
if lookup == nil {
lookup = net.LookupTXT
}
return &Env{Store: d.Store, Identity: d.Identity, Cfg: d.Cfg, Renderer: r, LookupTXT: lookup, Build: d.Build, csp: d.CSP}, nil
}
// Render delegates to the shared renderer (convenience for handlers via Env).
func (e *Env) Render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
e.Renderer.Render(w, r, page, data)
}
// ServerError logs an internal failure (keyed by request ID, so a report can be
// traced to its cause) and renders the branded 500 page with userMsg as the
// message. Use it at every handler site that currently drops an err on the floor:
// the visitor still sees only userMsg, but the real cause is recoverable from the
// logs. For expected client errors (4xx) keep using http.Error directly — those
// aren't server faults and shouldn't log at error level.
func (e *Env) ServerError(w http.ResponseWriter, r *http.Request, userMsg string, err error) {
LogError(r, userMsg, err)
e.ErrorPage(w, r, http.StatusInternalServerError, "Something went wrong", userMsg)
}
// NotFound renders the branded 404 page. It matches http.HandlerFunc so it works
// both as a chi NotFound handler (unrouted paths) and at handler call sites where
// a requested resource doesn't exist.
func (e *Env) NotFound(w http.ResponseWriter, r *http.Request) {
e.ErrorPage(w, r, http.StatusNotFound, "Page not found",
"We couldn't find that page. It may have moved, or the link may be wrong.")
}
// ErrorPage renders the shared branded error page (error.html) with the given
// status, on the surface's default layout so the chrome matches the host.
func (e *Env) ErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string) {
e.Renderer.render(w, r, status, "error.html", map[string]any{
"Status": status, "Title": title, "Message": message,
})
}
// LogError emits a structured error log for a failed request, keyed by request
// ID so it can be correlated with a user report. It writes no response — use it
// for failures that surface to the client through another channel (a WebSocket
// status frame, a redirect flash) or that have no response at all. Extra
// slog key/value pairs can be appended.
func LogError(r *http.Request, msg string, err error, args ...any) {
base := []any{
"method", r.Method,
"path", r.URL.Path,
"request_id", middleware.GetReqID(r.Context()),
"err", err,
}
slog.Error(msg, append(base, args...)...)
}
// LogAudit records a security-relevant action that SUCCEEDED, keyed by request ID like
// LogError. It exists so audit lines don't have to borrow LogError, which logs at error
// level and would file every successful action as a fault — polluting error alerting and
// burying real failures.
func LogAudit(r *http.Request, msg string, args ...any) {
base := []any{
"method", r.Method,
"path", r.URL.Path,
"request_id", middleware.GetReqID(r.Context()),
}
slog.Info(msg, append(base, args...)...)
}
// Origin builds an absolute scheme://host[:port] for a sibling surface, reusing
// the port the request arrived on (one binary serves all hosts on one port).
func (e *Env) Origin(r *http.Request, host string) string {
return originFor(e.Cfg, r, host)
}
// RedirectAfterLogout lands a signed-out visitor on the public root host. Shared
// by every host's POST /logout so sign-out ends in the same place regardless of
// which surface it was triggered from.
func (e *Env) RedirectAfterLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, e.Origin(r, e.Cfg.RootHost)+"/", http.StatusSeeOther) //nolint:gosec // G710: config-pinned origin
}
func originFor(cfg *config.Config, r *http.Request, host string) string {
scheme := "http"
if cfg.Secure {
scheme = "https"
}
port := ""
if _, p, err := net.SplitHostPort(r.Host); err == nil && p != "" {
port = ":" + p
}
return scheme + "://" + host + port
}
// Renderer composes content pages onto the shared base layout and executes them,
// injecting the cross-host URLs and current-user info every page's chrome needs.
type Renderer struct {
cfg *config.Config
pages map[string]*template.Template
userInfo UserInfoFunc
// defaultLayout is the layout used when a render specifies none. Empty means
// "base" (the app chrome); the marketing surface sets "rootbase".
defaultLayout string
}
// SetDefaultLayout sets the layout used for renders that don't specify one. The
// marketing surface calls this with "rootbase" so every root page gets the
// public topbar without each handler passing a Layout key.
func (e *Env) SetDefaultLayout(name string) { e.Renderer.defaultLayout = name }
// NewRenderer parses the shared base layout (base.html + icons.html + brand.html,
// which holds the reserved brand mark — see TRADEMARKS.md) and composes
// each of the surface's own *.html pages onto it. Each page redefines the
// content/title/header blocks, so every page gets its own cloned template set.
// templateFuncs are helpers available to every page template. mhz/khz present
// the Hz-canonical radio values in the human-readable units the region presets
// use (MHz for frequency, kHz for bandwidth), formatted without trailing zeros.
var templateFuncs = template.FuncMap{
"mhz": func(hz int64) string { return strconv.FormatFloat(float64(hz)/1e6, 'f', -1, 64) },
"khz": func(hz int64) string { return strconv.FormatFloat(float64(hz)/1e3, 'f', -1, 64) },
// markdown renders user-authored markdown (e.g. an org description) to
// sanitized HTML. Wrap the output in a `.markdown` container for spacing.
"markdown": Markdown,
// markdowntext flattens that same markdown to plain text for compact teasers.
"markdowntext": MarkdownText,
// ts renders an instant as a <time> element for consistent, locale-aware
// display. See TimeElement.
"ts": TimeElement,
// asset maps a logical static path ("/static/ui.js") to its content-hashed,
// immutably-cacheable URL. Use it for every /static/ reference in templates.
"asset": func(l string) string { return assets().URL(l) },
}
// tsFallbackLayouts maps a display kind to the Go layout used for the server-side
// fallback text (rendered in UTC, labeled, for no-JS / crawlers). ui.js rewrites
// the element into the viewer's locale and zone at load time.
var tsFallbackLayouts = map[string]string{
"date": "Jan 2, 2006",
"datetime": "Jan 2, 2006, 15:04 UTC",
"time": "15:04 UTC",
"time-seconds": "15:04:05 UTC",
}
// TimeElement renders an instant as
//
// <time datetime="2006-01-02T15:04:05Z" data-fmt="datetime">Jan 2, 2006, 15:04 UTC</time>
//
// The machine-readable datetime is always a full RFC3339 UTC timestamp; kind
// (date|datetime|time|time-seconds) selects which parts ui.js shows once it
// localizes the element. A zero time renders nothing (call sites guard nil).
//
// The result is template.HTML because it's server-controlled markup: the only
// interpolated values are a formatted timestamp and a fixed, validated kind — no
// user data — so autoescaping is unnecessary here.
func TimeElement(t time.Time, kind string) template.HTML {
if t.IsZero() {
return ""
}
layout, ok := tsFallbackLayouts[kind]
if !ok {
kind, layout = "datetime", tsFallbackLayouts["datetime"]
}
utc := t.UTC()
var b strings.Builder
b.WriteString(`<time datetime="`)
b.WriteString(utc.Format(time.RFC3339))
b.WriteString(`" data-fmt="`)
b.WriteString(kind)
b.WriteString(`">`)
b.WriteString(utc.Format(layout))
b.WriteString(`</time>`)
return template.HTML(b.String()) //nolint:gosec // G203: server-controlled timestamp + fixed enum, no user data
}
func NewRenderer(cfg *config.Config, surfaceTemplates fs.FS) (*Renderer, error) {
base, err := template.New("").Funcs(templateFuncs).ParseFS(sharedTemplatesFS, "templates/base.html", "templates/icons.html", "templates/brand.html", "templates/org_tabs.html", "templates/repeater_tabs.html", "templates/command_grid.html", "templates/org_access.html")
if err != nil {
return nil, err
}
all, err := fs.Glob(surfaceTemplates, "templates/*.html")
if err != nil {
return nil, err
}
// The shared layout/partials are composed in above; skip them if they happen
// to live in the surface's template dir.
shared := map[string]bool{"templates/base.html": true, "templates/icons.html": true, "templates/brand.html": true}
pages := map[string]*template.Template{}
// Compose the cross-surface pages first, then the surface's own pages (a
// surface page of the same name would override, but none should collide).
for _, p := range sharedPages {
clone, err := base.Clone()
if err != nil {
return nil, err
}
if _, err := clone.ParseFS(sharedTemplatesFS, p); err != nil {
return nil, err
}
pages[strings.TrimPrefix(p, "templates/")] = clone
}
for _, p := range all {
if shared[p] {
continue
}
clone, err := base.Clone()
if err != nil {
return nil, err
}
if _, err := clone.ParseFS(surfaceTemplates, p); err != nil {
return nil, err
}
pages[strings.TrimPrefix(p, "templates/")] = clone
}
return &Renderer{cfg: cfg, pages: pages}, nil
}
// Pages returns the composed page templates keyed by file name. Intended for
// startup/composition tests.
func (rn *Renderer) Pages() map[string]*template.Template { return rn.pages }
// Render executes a content page within its layout (default "base"; pages can
// opt into another via the "Layout" data key), with a 200 status.
func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, page string, data map[string]any) {
rn.render(w, r, http.StatusOK, page, data)
}
// render is Render with an explicit status code (error pages pass 404/500/…).
func (rn *Renderer) render(w http.ResponseWriter, r *http.Request, status int, page string, data map[string]any) {
if data == nil {
data = map[string]any{}
}
// Absolute origins for cross-host links: sessions are host-scoped, so a link
// from one surface to a sibling must be absolute.
data["AppURL"] = originFor(rn.cfg, r, rn.cfg.PrimaryHost)
data["AuthURL"] = originFor(rn.cfg, r, rn.cfg.AuthHost)
data["RootURL"] = originFor(rn.cfg, r, rn.cfg.RootHost)
// SourceURL backs the footer's source link, which is how every page satisfies
// the AGPL section 13 obligation to offer this server's source to the people
// using it. Injected here rather than per-handler so no page can omit it.
data["SourceURL"] = SourceURL
// LogoutURL: sign-out is a POST that revokes the login row, so it must target a
// host that owns a /logout endpoint AND holds this browser's session. The app
// host, auth host, and custom org domains all do (relative "/logout", a
// same-host POST). The root host is strictly side-effect-free GET (see
// docs/auth-cross-host.md), so it has no logout of its own — the template hides
// the control there and the user signs out from the app dashboard instead.
if HostWithoutPort(r.Host) != rn.cfg.RootHost {
data["LogoutURL"] = "/logout"
}
// UserTZ is the viewer's saved IANA zone (empty = auto-detect); the base
// layout exposes it as <html data-tz> so ui.js localizes <time> elements.
data["UserTZ"] = ""
if rn.userInfo != nil {
if name, canAdmin, tz, ok := rn.userInfo(r.Context()); ok {
data["UserName"] = name
data["CanAdmin"] = canAdmin
data["UserTZ"] = tz
}
}
// Per-request CSP nonce for inline <script nonce="{{.Nonce}}"> blocks.
data["Nonce"] = NonceFromContext(r.Context())
t, ok := rn.pages[page]
if !ok {
slog.Error("render: unknown page",
"page", page, "request_id", middleware.GetReqID(r.Context()))
http.Error(w, "Something went wrong.", http.StatusInternalServerError)
return
}
layout, _ := data["Layout"].(string)
if layout == "" {
layout = rn.defaultLayout
}
if layout == "" {
layout = "base"
}
// Render into a buffer first: executing straight to w commits a 200 and a
// partial body the moment the template writes anything, so a mid-render
// failure would both leak the error text (template/query internals) and
// corrupt the page. Buffering lets us fail cleanly with a generic 500 and
// log the real cause server-side, keyed by request ID.
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, layout, data); err != nil {
slog.Error("template render failed",
"page", page, "layout", layout,
"request_id", middleware.GetReqID(r.Context()), "err", err)
http.Error(w, "Something went wrong.", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = buf.WriteTo(w)
}
// --- shared HTTP helpers (used across surfaces) ---
// CommonMiddleware applies the generic middleware every surface needs. Session
// loading is added by each surface (it owns the auth service), so web stays
// auth-free. chi requires all Use() calls before any route is registered.
func (e *Env) CommonMiddleware(r chi.Router) {
r.Use(middleware.RequestID)
r.Use(CaptureRemoteAddr) // preserve the true TCP peer before we resolve
r.Use(e.resolveClientIP) // trusted-proxy-aware X-Forwarded-For resolution
r.Use(e.securityHeaders) // CSP (+ per-request script nonce) and hardening headers
r.Use(blockCrossSiteWrites) // CSRF second layer, before any handler reads the body
r.Use(limitBody) // cap request bodies before any handler reads them
r.Use(compressHTML) // gzip the server-rendered pages
r.Use(middleware.Recoverer)
}
// compressibleTypes is deliberately just text/html — the server-rendered pages,
// which is where the entire win is (a page is 1530 KB of markup that shrinks
// ~80%). Everything else is left alone on purpose:
//
// - Static CSS/JS/SVG are already pre-compressed once at startup with brotli and
// gzip at their best levels (see assets.go), and served with their own
// Content-Encoding — which chi's compressor detects and skips. Listing those
// types here would only add a redundant on-the-fly path for the rare identity
// fallback.
// - application/json is skipped because ServeJSONCached computes a *strong* ETag
// over the uncompressed bytes; a strong validator is supposed to be unique per
// representation, and encodings are separate representations. The one JSON
// endpoint is a small list of map points, so there's little to gain. If it ever
// grows, compress it at the handler and weaken the ETag there.
//
// Note on BREACH: compressing a response that mixes a secret with
// attacker-influenced text can leak the secret through response sizes. No secret is
// rendered into an HTML body today — the auth handoff code travels in a redirect's
// Location header, not the page. If a CSRF token is ever embedded in forms (audit
// S1), revisit this: the usual mitigation is per-response length masking.
var compressibleTypes = []string{"text/html"}
// compressHTML gzips server-rendered HTML. Placed after the header middleware so
// those headers are set before the body is written, and outside Recoverer so a
// recovered panic's plain-text 500 passes through uncompressed. Responses that
// already carry a Content-Encoding (the pre-compressed static assets) are skipped
// by the compressor, and it adds Vary: Accept-Encoding so the cacheable public
// pages can't be served a gzip body to a client that didn't ask for one.
//
// Level 5 rather than best: on a 30 KB page the last few levels buy a couple of
// percent for meaningfully more CPU per request. Brotli isn't offered here — chi
// ships gzip/deflate, and on-the-fly brotli at a competitive quality costs more
// than it returns for bodies this size. The static assets, compressed once at
// startup, do get brotli.
var compressHTML = middleware.Compress(5, compressibleTypes...)
// maxRequestBody caps the request body every surface will read. The app has no
// file uploads — the largest bodies are form posts and small JSON (serial-setup
// command lists, markdown docs) — so 1 MiB is generous while stopping a client
// from streaming an arbitrarily large body into memory before per-field limits
// can apply. WebSocket upgrades carry no request body, so this doesn't affect
// the console socket.
const maxRequestBody = 1 << 20 // 1 MiB
// limitBody caps r.Body so an oversized request fails fast — a read past the
// limit errors and the handler surfaces its generic 400/500 — instead of
// buffering unbounded data.
func limitBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
next.ServeHTTP(w, r)
})
}
// SharedRoutes registers endpoints every surface needs (health, static assets, CSP
// violation reports).
//
// The report endpoint belongs here specifically because every surface registers
// these: a violation on the auth host must be reportable to the auth host, so the
// delivery stays same-origin. It's also registered ahead of the session middleware
// by every caller, which matters — a browser posting reports must not mint a session
// row per report.
func (e *Env) SharedRoutes(r chi.Router) {
r.Get("/healthz", e.healthz)
// Resolve the manifest here, at registration, rather than inside the
// handler. Registration happens during server startup, so the assets are
// compressed before the first request exactly as they were when the
// manifest was a package-level var — while a test binary that never
// registers routes skips the work entirely. See the comment on `assets`.
m := assets()
r.Handle("/static/*", http.StripPrefix("/static/", http.HandlerFunc(m.serveHTTP)))
if e.csp != nil {
r.Post(CSPReportPath, e.csp.handleReport)
}
}
// healthz is a readiness probe: it pings the database (briefly) so a broken pool
// or unreachable DB fails the check — letting a load balancer route away or an
// orchestrator restart the instance — rather than reporting healthy while every
// request 500s.
func (e *Env) healthz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := e.Store.Pool().Ping(ctx); err != nil {
LogError(r, "healthz: db ping failed", err)
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte("ok"))
}
// HostWithoutPort strips a trailing :port from a request Host, if present.
func HostWithoutPort(host string) string {
if h, _, err := net.SplitHostPort(host); err == nil {
return h
}
return host
}
// RedirectFlash 303-redirects to path with a single flash query param (key=msg,
// escaped), picking ? or & as the separator.
func RedirectFlash(w http.ResponseWriter, r *http.Request, path, key, msg string) {
sep := "?"
if strings.Contains(path, "?") {
sep = "&"
}
http.Redirect(w, r, path+sep+key+"="+url.QueryEscape(msg), http.StatusSeeOther)
}
// RedirectErr is RedirectFlash with the conventional "error" key.
func RedirectErr(w http.ResponseWriter, r *http.Request, path, msg string) {
RedirectFlash(w, r, path, "error", msg)
}
// Dispatcher routes by hostname across the three surfaces: the auth host, the
// root (public discovery) host, and — for everything else, including custom org
// domains — the app host. AuthHost and RootHost are always configured (see
// config.Load); the WWWHost redirect is optional.
func Dispatcher(cfg *config.Config, authH, rootH, appH http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := HostWithoutPort(r.Host)
switch {
case strings.EqualFold(host, cfg.AuthHost):
authH.ServeHTTP(w, r)
case strings.EqualFold(host, cfg.RootHost):
rootH.ServeHTTP(w, r)
case cfg.WWWHost != "" && strings.EqualFold(host, cfg.WWWHost):
http.Redirect(w, r, originFor(cfg, r, cfg.RootHost)+r.URL.RequestURI(), http.StatusMovedPermanently) //nolint:gosec // G710: local path or config-pinned origin
default:
appH.ServeHTTP(w, r)
}
})
}