mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-09 13:15:33 +00:00
328 lines
13 KiB
Go
328 lines
13 KiB
Go
// 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"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"github.com/jleight/meshtender/internal/config"
|
|
"github.com/jleight/meshtender/internal/identity"
|
|
"github.com/jleight/meshtender/internal/store"
|
|
)
|
|
|
|
//go:embed templates/base.html templates/icons.html templates/org_tabs.html templates/repeater_tabs.html templates/org_public.html templates/org_config.html templates/org_repeaters.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"}
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
// UserInfoFunc reports the signed-in user's display name and admin flag for the
|
|
// page chrome. 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, ok bool)
|
|
|
|
// Deps are the shared dependencies every surface needs.
|
|
type Deps struct {
|
|
Store *store.Store
|
|
Identity *identity.Service
|
|
Cfg *config.Config
|
|
UserInfo UserInfoFunc
|
|
LookupTXT func(name string) ([]string, error)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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}, 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)
|
|
}
|
|
|
|
// 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) 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,
|
|
}
|
|
|
|
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/org_tabs.html", "templates/repeater_tabs.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}
|
|
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).
|
|
func (rn *Renderer) Render(w http.ResponseWriter, r *http.Request, 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)
|
|
// 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"
|
|
}
|
|
if rn.userInfo != nil {
|
|
if name, canAdmin, ok := rn.userInfo(r.Context()); ok {
|
|
data["UserName"] = name
|
|
data["CanAdmin"] = canAdmin
|
|
}
|
|
}
|
|
// Per-request CSP nonce for inline <script nonce="{{.Nonce}}"> blocks.
|
|
data["Nonce"] = NonceFromContext(r.Context())
|
|
t, ok := rn.pages[page]
|
|
if !ok {
|
|
http.Error(w, "unknown page: "+page, 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")
|
|
_, _ = 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(limitBody) // cap request bodies before any handler reads them
|
|
r.Use(middleware.Recoverer)
|
|
}
|
|
|
|
// 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/confirm sockets.
|
|
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).
|
|
func (e *Env) SharedRoutes(r chi.Router) {
|
|
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
staticSub, _ := fs.Sub(staticFS, "static")
|
|
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|