- One base layout (layout.html) with the header, the nav from Registry.Nav()
(D-038), a content slot and the CSRF token; every page renders inside it and
a module supplies content only
- Embedded same-origin assets (app.css · app.js · webauthn.js · sw.js ·
manifest.webmanifest · icons) served by the reserved `static` workflow
- webauthn.js drives the three ceremonies from data-* attributes — no inline
script, so the CSP `script-src 'self'` holds (AC-3)
- manifest start_url "/" (D-011); the service worker never caches an
authenticated response or /{GUID} (A5)
186 lines
5.5 KiB
Go
186 lines
5.5 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"html/template"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
//go:embed layout.html
|
|
var layoutHTML string
|
|
|
|
// layout is the ONE base layout (SPEC-009 §4). A parse failure of a constant is
|
|
// impossible; a non-nil error here would be a programming fault.
|
|
var layout = template.Must(template.New("layout").Parse(layoutHTML))
|
|
|
|
// NavLister is the shell's navigation seam (SPEC-008 §7 / SPEC-009 §4): the
|
|
// names of the ENABLED modules, in registration order. A disabled module's
|
|
// entry is absent (D-038). *module.Registry satisfies it. Declaring the seam
|
|
// here — rather than importing the registry — keeps this package a leaf.
|
|
type NavLister interface {
|
|
Nav() []string
|
|
}
|
|
|
|
var (
|
|
navMu sync.RWMutex
|
|
navSrc NavLister
|
|
)
|
|
|
|
// SetNav installs the process-wide navigation source. The entrypoint calls it
|
|
// once at startup with the runtime module registry — the same single, explicit
|
|
// wiring point as build-time discovery (D-028): the directory set is the source
|
|
// of truth, and the shell reads the resulting nav. Until it is set (and in
|
|
// tests) pages render without a nav.
|
|
func SetNav(l NavLister) {
|
|
navMu.Lock()
|
|
navSrc = l
|
|
navMu.Unlock()
|
|
}
|
|
|
|
// NavSource returns the installed nav source (nil when unset).
|
|
func NavSource() NavLister {
|
|
navMu.RLock()
|
|
defer navMu.RUnlock()
|
|
return navSrc
|
|
}
|
|
|
|
// Markup is page body HTML a caller has ALREADY escaped. The shell escapes
|
|
// everything it interpolates except this type, so the conversion at the call
|
|
// site is deliberate and greppable: escape first, then convert.
|
|
type Markup = template.HTML
|
|
|
|
// Attrs is an escaped attribute set for an element — the WebAuthn client's
|
|
// data-* contract lives here. Every value interpolated into it MUST be escaped
|
|
// by the caller.
|
|
type Attrs = template.HTMLAttr
|
|
|
|
// NavItem is one rendered navigation entry.
|
|
type NavItem struct { // Label is the human label.
|
|
Label string
|
|
// Href is the target — /{GUID}/{module} inside a GUID context.
|
|
Href string
|
|
// Active marks the current page (aria-current).
|
|
Active bool
|
|
}
|
|
|
|
// Page is one page rendered inside the shell.
|
|
type Page struct {
|
|
// Title is the document title (defaults to AppName).
|
|
Title string
|
|
// GUID is the identity riding the path. The nav renders only in a GUID
|
|
// context; identity stays in the URL (D-001).
|
|
GUID string
|
|
// Guest marks an anonymous screen (enroll · login · setup · access): no nav
|
|
// — there is no identity yet.
|
|
Guest bool
|
|
// Active is the PARAM2 module to mark as the current nav entry ("" none).
|
|
Active string
|
|
// Flash is a notice or error banner ("" for none).
|
|
Flash string
|
|
// FlashKind is "error" for an error banner; anything else is a notice.
|
|
FlashKind string
|
|
// CSRF is the token every form — and the WebAuthn client — submits. It is
|
|
// rendered once as <meta name="csrf-token">.
|
|
CSRF string
|
|
// Content is the page body: HTML the caller has ALREADY escaped.
|
|
Content template.HTML
|
|
// MainAttrs are extra attributes for the <main> element — the WebAuthn
|
|
// ceremony's data-* contract. The caller MUST escape them.
|
|
MainAttrs template.HTMLAttr
|
|
}
|
|
|
|
// layoutData is the layout's input (the unexported shape of a Page).
|
|
type layoutData struct {
|
|
AppName string
|
|
Title string
|
|
CSRF string
|
|
Flash string
|
|
FlashKind string
|
|
Nav []NavItem
|
|
HomeHref string
|
|
StaticPath string
|
|
Content template.HTML
|
|
MainAttrs template.HTMLAttr
|
|
}
|
|
|
|
// Render writes a page inside the shell. The caller is responsible for
|
|
// Cache-Control (the enroll/login/ceremony screens set `no-store` — F-2); the
|
|
// shell sets the content type and the status.
|
|
//
|
|
// The page is rendered to a buffer first: a template fault never leaves a
|
|
// half-written body behind a 200.
|
|
func Render(w http.ResponseWriter, status int, p Page) error {
|
|
data := layoutData{
|
|
AppName: AppName,
|
|
Title: strings.TrimSpace(p.Title),
|
|
CSRF: p.CSRF,
|
|
Flash: p.Flash,
|
|
FlashKind: flashKind(p.FlashKind),
|
|
StaticPath: StaticPath,
|
|
Content: p.Content,
|
|
MainAttrs: p.MainAttrs,
|
|
}
|
|
if data.Title == "" {
|
|
data.Title = AppName
|
|
}
|
|
if p.Guest || strings.TrimSpace(p.GUID) == "" {
|
|
data.HomeHref = "/"
|
|
} else {
|
|
data.HomeHref = "/" + p.GUID + "/home"
|
|
data.Nav = navItems(p.GUID, p.Active)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := layout.Execute(&buf, data); err != nil {
|
|
return err
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, err := w.Write(buf.Bytes())
|
|
return err
|
|
}
|
|
|
|
// flashKind normalises a banner kind for the class attribute.
|
|
func flashKind(kind string) string {
|
|
if strings.EqualFold(strings.TrimSpace(kind), "error") {
|
|
return "error"
|
|
}
|
|
return "notice"
|
|
}
|
|
|
|
// navItems builds the nav from the installed NavLister (SPEC-008 §7: the nav is
|
|
// driven by the registry, so a disabled module's entry is absent — D-038).
|
|
func navItems(guid, active string) []NavItem {
|
|
l := NavSource()
|
|
if l == nil || guid == "" {
|
|
return nil
|
|
}
|
|
names := l.Nav()
|
|
items := make([]NavItem, 0, len(names))
|
|
for _, name := range names {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
items = append(items, NavItem{
|
|
Label: ModuleLabel(name),
|
|
Href: "/" + guid + "/" + name,
|
|
Active: name == active,
|
|
})
|
|
}
|
|
return items
|
|
}
|
|
|
|
// ModuleLabel renders a module name as a nav label: the first letter upcased
|
|
// and separators read as spaces ("home" -> "Home", "time_off" -> "Time off").
|
|
func ModuleLabel(name string) string {
|
|
s := strings.TrimSpace(name)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
s = strings.NewReplacer("-", " ", "_", " ").Replace(s)
|
|
return strings.ToUpper(s[:1]) + s[1:]
|
|
}
|