// 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/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/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) } // 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) // 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, 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) 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