NextMyDay/internal/core/web/shell.go
Claus Lohmar dd865d4f27 feat(web): add the web shell and the PWA (SPEC-009)
- 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)
2026-10-14 10:53:57 +00:00

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:]
}