Files
ai-resume/internal/server/server.go
Sam Rolfe 84fc585c91 Phase 2: Mode A static site — build, serve, search, stage panel
One Go binary serves a pre-rendered site. No Node, no bundler, no node_modules:
htmx and the SSE extension are vendored files, Markdown and code highlighting
run at build time (goldmark + chroma), and the server only writes files.

Implements docs/phase-2-static-site.md:

  2.1 service skeleton   cmd/build (content -> dist), cmd/server (dist -> HTTP),
                         env-configured, 127.0.0.1:8096, /healthz with separate
                         static / ai / search readiness, --health-check probe,
                         deploy/compose.yml + deploy/Dockerfile at 120m
  2.2 rendering          templ components (Layout, Nav, PillarCard, Stage,
                         EvidenceTable, CodeBlock, Timeline, Footer);
                         `make generate` is a build step and is byte-stable
  2.3 navigation         six-area rail, <details> disclosure under 900px,
                         breadcrumbs, aria-current, L1/L2/L3 with artefact pages
  2.4 stage panel        internal/stage owns the contract; RouteDriver (fragment
                         swap) and SSEDriver (event stream) differ only in the
                         Binding, never in layout. fragment ids live in the
                         contract so route files, links and streamed frames agree
  2.5 search             SQLite FTS5 over titles and body, rebuilt by the build;
                         one handler returns a page without HTMX, a fragment with
                         it. Visitors' FTS operators are never honoured
  2.6 quality floor      print stylesheet, JS-off navigation, focus states,
                         semantic landmarks, dark default; security headers

Definition of Done evidence (see issue AUD-142):
  A1  all pages 200 in a network namespace with no external route
  one binary serves the site; make dist writes 45 pages, 86 fragments, 40 docs
  templ generate is deterministic (md5 identical across runs)
  6 L1 + 6 L2 pages, artefact pages reachable in <= 3 clicks
  FTS5 returns sensible hits for zigbee / signage / routing (tested)
  Lighthouse 100 performance and 100 accessibility, mobile and desktop
  first byte well inside 100ms over loopback (test)
  panel renders from a route driver and re-points at the stream untouched
  print legible: /edge/ renders to a 3-page PDF with the print stylesheet

Also fixes found while finishing the build:
  - reject "." / ".." path segments before ServeMux redirects them into a file
    outside the asset directory (/static/../build.json now 404, was 307->200)
  - make test is the real gate: go test ./... plus the validator self-test
2026-10-26 20:42:52 +11:00

603 lines
18 KiB
Go

// Package server serves the pre-rendered Mode A site out of a build directory.
//
// Everything a visitor reads is a file the build wrote: the server renders a
// page only for a keyword search that carries a query, and for the streamed
// panel demonstration. Nothing here calls a model, opens a socket to another
// service or reads the content tree, so stopping Qdrant, OmniRoute or any query
// service cannot change what a visitor sees (acceptance test A1).
//
// The two pieces that are not files are the two things the static build cannot
// pre-render:
//
// GET /search?q=… keyword search over the FTS5 index
// GET /api/stage/stream the stage panel bound to an event stream
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/search"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/site"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/stage"
"gitea.lab.audasmedia.com.au/sam/ai-resume/internal/web/templates"
)
// Default configuration. The address is loopback on purpose: Caddy is the only
// public listener (AGENTS.md §5).
const (
DefaultAddr = "127.0.0.1:8096"
DefaultDist = "dist"
DefaultStreamDelay = 350 * time.Millisecond
)
// Cache policies. Pages are rebuilt on deploy and must not be stale across a
// release, so HTML is revalidated. Unhashed assets get an hour at the origin;
// Phase 3's Caddy block sets the long immutable cache for the public edge.
const (
pageCache = "no-cache"
assetCache = "public, max-age=3600"
fragCache = "public, max-age=300"
)
// Config is what the server needs to start. Every field has a usable default so
// that the same binary runs under systemd, compose and `make dev`.
type Config struct {
// Addr is the listen address. Default 127.0.0.1:8096.
Addr string
// DistDir is a directory written by cmd/build. Default "dist".
DistDir string
// StreamDelay paces the streamed panel demonstration. Default 350ms.
StreamDelay time.Duration
// Logger receives one line per request. Default: JSON to stdout.
Logger *slog.Logger
}
func (c *Config) defaults() {
if c.Addr == "" {
c.Addr = DefaultAddr
}
if c.DistDir == "" {
c.DistDir = DefaultDist
}
if c.StreamDelay <= 0 {
c.StreamDelay = DefaultStreamDelay
}
if c.Logger == nil {
c.Logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
}
}
// Server serves one build directory.
type Server struct {
cfg Config
dist string
shell site.Shell
log *slog.Logger
// built is nil when the directory has no readable build.json; the site is
// still served, but /healthz reports static as not ready.
built *site.BuildInfo
builtErr error
// manifest is nil when the build has no stage fragments. Only the streamed
// demonstration needs it.
manifest *stage.Manifest
manifestErr error
// index is nil when the FTS5 index could not be opened. Search then says so
// and the rest of the site is unaffected.
index *search.Index
indexErr error
handler http.Handler
}
// New opens a build directory. It does not fail when the directory is
// incomplete: it starts, serves whatever is there and lets /healthz report why
// it is not ready, which is what a deploy script needs to see.
func New(cfg Config) (*Server, error) {
cfg.defaults()
abs, err := filepath.Abs(cfg.DistDir)
if err != nil {
return nil, err
}
cfg.DistDir = abs
st, err := os.Stat(abs)
if err != nil {
return nil, fmt.Errorf("build directory %s: %w", abs, err)
}
if !st.IsDir() {
return nil, fmt.Errorf("build directory %s is not a directory", abs)
}
s := &Server{
cfg: cfg,
dist: abs,
shell: site.NewShell(),
log: cfg.Logger,
}
s.built, s.builtErr = site.LoadBuildInfo(abs)
if s.built == nil && s.builtErr == nil {
s.builtErr = errors.New("build.json is missing")
}
if s.builtErr != nil {
s.log.Warn("build metadata unavailable", "dist", abs, "error", s.builtErr)
}
if raw, err := os.ReadFile(filepath.Join(abs, filepath.FromSlash(site.ManifestFile))); err != nil {
s.manifestErr = err
s.log.Warn("stage manifest unavailable", "dist", abs, "error", err)
} else if m, err := stage.LoadManifest(raw); err != nil {
s.manifestErr = err
s.log.Warn("stage manifest is not readable", "dist", abs, "error", err)
} else {
s.manifest = m
}
s.index, s.indexErr = search.Open(filepath.Join(abs, site.IndexFile))
if s.indexErr != nil {
s.log.Warn("search index unavailable", "dist", abs, "error", s.indexErr)
}
s.handler = s.routes()
return s, nil
}
// Handler is the whole site behind one http.Handler.
func (s *Server) Handler() http.Handler { return s.handler }
// Close releases the search index handle.
func (s *Server) Close() error {
if s.index != nil {
return s.index.Close()
}
return nil
}
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
// Liveness and readiness. The AI field is separate and deliberately false:
// Mode A never needs a model (docs/phase-2-static-site.md §2.1).
mux.HandleFunc("GET /healthz", s.handleHealth)
// The two things that are not pre-rendered files.
mux.HandleFunc("GET /search", s.handleSearch)
mux.HandleFunc("GET /api/stage/stream", s.handleStream)
// Route driver: one pre-rendered fragment per click.
mux.HandleFunc("GET /stage/{id}", s.handleFragment)
// Assets the build copied out of the repository.
mux.Handle("GET /static/", s.assetHandler("static"))
mux.Handle("GET /assets/", s.assetHandler("assets"))
// Every other path is a page. "GET /" is the fallback pattern.
mux.HandleFunc("GET /", s.handlePage)
return s.middleware(s.rejectDotSegments(mux))
}
// rejectDotSegments refuses a request whose path has a "." or ".." segment
// before http.ServeMux cleans it and answers with a redirect. Without this,
// "/static/../build.json" is redirected to "/build.json" and served from outside
// the asset directory. The check runs on URL.Path, which is already percent-
// decoded, so an encoded "%2e%2e" is caught as well.
func (s *Server) rejectDotSegments(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if hasDotSegment(r.URL.Path) {
s.notFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// hasDotSegment reports whether any segment of p is "." or "..".
func hasDotSegment(p string) bool {
for _, seg := range strings.Split(p, "/") {
if seg == "." || seg == ".." {
return true
}
}
return false
}
// middleware adds the security headers, one log line per request, and a panic
// guard that keeps one bad request from taking the process down.
func (s *Server) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// The policy mirrors the Caddy block in docs/phase-3-deploy.md, so the
// browser sees the same rules whether it reaches the origin or the edge.
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("X-Frame-Options", "DENY")
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
rec := &statusWriter{ResponseWriter: w}
started := time.Now()
defer func() {
if p := recover(); p != nil {
s.log.Error("request panic", "method", r.Method, "path", r.URL.Path, "panic", fmt.Sprint(p))
if rec.status == 0 {
http.Error(rec, "internal error", http.StatusInternalServerError)
}
}
s.log.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.statusOf(),
"bytes", rec.bytes,
"duration_ms", time.Since(started).Milliseconds(),
)
}()
next.ServeHTTP(rec, r)
})
}
// handleHealth reports static and AI readiness separately. Static readiness is
// the only thing that decides the status code, because static readiness is what
// a deploy needs; the AI field says plainly that Mode A does not use one.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
indexReady := s.index != nil
documents := 0
if indexReady {
if n, err := s.index.Count(); err == nil {
documents = n
}
}
if documents == 0 && s.built != nil {
documents = s.built.Indexed
}
static := struct {
Ready bool `json:"ready"`
Pages int `json:"pages,omitempty"`
Built string `json:"built,omitempty"`
Error string `json:"error,omitempty"`
}{Ready: s.staticReady()}
if s.built != nil {
static.Pages = s.built.Pages
static.Built = s.built.Built.Format(time.RFC3339)
}
if !static.Ready && s.builtErr != nil {
static.Error = s.builtErr.Error()
} else if !static.Ready {
static.Error = "index.html is missing from the build directory"
}
body := struct {
Status string `json:"status"`
Static any `json:"static"`
AI struct {
Ready bool `json:"ready"`
Reason string `json:"reason"`
} `json:"ai"`
Search struct {
Ready bool `json:"ready"`
Documents int `json:"documents,omitempty"`
Error string `json:"error,omitempty"`
} `json:"search"`
}{Static: static}
body.AI.Ready = false
body.AI.Reason = "Mode A serves pre-rendered pages and calls no model; Mode B reports its own readiness in Phase 5."
body.Search.Ready = indexReady
body.Search.Documents = documents
if s.indexErr != nil {
body.Search.Error = s.indexErr.Error()
}
code := http.StatusOK
body.Status = "ok"
if !static.Ready {
code = http.StatusServiceUnavailable
body.Status = "static-unavailable"
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(code)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(body)
}
// staticReady is true when the build actually produced a landing page.
func (s *Server) staticReady() bool {
if s.built == nil {
return false
}
st, err := os.Stat(filepath.Join(s.dist, "index.html"))
return err == nil && !st.IsDir()
}
// handlePage serves a pre-rendered page by URL. Directory URLs map to
// index.html so the same tree works under any plain file server.
func (s *Server) handlePage(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
file, ok := s.resolvePage(rel)
if !ok {
s.notFound(w, r)
return
}
if !s.serveFile(w, r, file, "text/html; charset=utf-8", pageCache) {
s.notFound(w, r)
}
}
// resolvePage maps a cleaned URL path onto a file inside the build directory.
// A file that is not there is not an error: the caller serves the 404 page.
func (s *Server) resolvePage(rel string) (string, bool) {
candidates := make([]string, 0, 3)
switch {
case rel == "" || strings.HasSuffix(rel, "/"):
candidates = append(candidates, rel+"index.html")
case path.Ext(rel) == "":
candidates = append(candidates, rel, rel+"/index.html", rel+".html")
default:
candidates = append(candidates, rel)
}
for _, c := range candidates {
full := filepath.Join(s.dist, filepath.FromSlash(c))
if !within(s.dist, full) {
continue
}
if st, err := os.Stat(full); err == nil && !st.IsDir() {
return full, true
}
}
return "", false
}
// handleFragment serves the route driver's payload: one pre-rendered stage
// fragment, selected by the evidence table's hx-get. It is the same bytes the
// stream driver sends as an event.
func (s *Server) handleFragment(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !validFragmentID(id) {
s.fragmentNotFound(w, "That fragment is not part of this build.")
return
}
file := filepath.Join(s.dist, "stage", id+".html")
if !within(s.dist, file) {
s.fragmentNotFound(w, "That fragment is not part of this build.")
return
}
if _, err := os.Stat(file); err != nil {
s.fragmentNotFound(w, "That fragment is not part of this build.")
return
}
if !s.serveFile(w, r, file, "text/html; charset=utf-8", fragCache) {
s.fragmentNotFound(w, "That fragment could not be read.")
}
}
// validFragmentID keeps ids to the alphabet the build produces, so a request
// can never build a path of its own.
func validFragmentID(id string) bool {
if id == "" || len(id) > 128 {
return false
}
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
default:
return false
}
}
return true
}
// fragmentNotFound returns a small HTML fragment, because the panel swaps the
// response into the page and a full document would nest inside it.
func (s *Server) fragmentNotFound(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, `<div class="stage-empty"><p class="stage-empty-title">`+msg+`</p><p class="stage-hint">The <a href="/evidence/">evidence index</a> lists every fragment in this build.</p></div>`)
}
// handleSearch runs a keyword search. With HTMX it returns just the results
// block; without JavaScript the same handler returns the whole page, so the form
// works in both cases.
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
failed := false
indexed := 0
if s.built != nil {
indexed = s.built.Indexed
}
var results []search.Result
if s.index != nil {
if n, err := s.index.Count(); err == nil {
indexed = n
}
}
if q != "" {
if s.index == nil {
failed = true
} else {
var err error
results, err = s.index.Query(q, 20)
if err != nil {
failed = true
s.log.Warn("search failed", "query_length", len(q), "error", err)
}
}
}
view := s.shell.SearchView(indexed, q, results, failed)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Vary", "HX-Request")
var buf bytes.Buffer
var c interface {
Render(context.Context, io.Writer) error
} = templates.SearchPage(view)
if r.Header.Get("HX-Request") == "true" {
c = templates.SearchResults(view)
}
if err := c.Render(r.Context(), &buf); err != nil {
s.log.Error("render search", "error", err)
http.Error(w, "search is unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(buf.Bytes())
}
}
// notFound serves the pre-rendered 404 page with a 404 status.
func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
file := filepath.Join(s.dist, "404.html")
if raw, err := os.ReadFile(file); err == nil && within(s.dist, file) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNotFound)
if r.Method != http.MethodHead {
_, _ = w.Write(raw)
}
return
}
http.Error(w, "not found", http.StatusNotFound)
}
// assetHandler serves a directory of build output (/static/, /assets/).
func (s *Server) assetHandler(dir string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
prefix := "/" + dir + "/"
rel := strings.TrimPrefix(path.Clean("/"+strings.TrimPrefix(r.URL.Path, prefix)), "/")
if rel == "" || strings.HasPrefix(rel, "..") {
s.notFound(w, r)
return
}
file := filepath.Join(s.dist, dir, filepath.FromSlash(rel))
if !within(filepath.Join(s.dist, dir), file) {
s.notFound(w, r)
return
}
ctype := contentTypeFor(file)
if !s.serveFile(w, r, file, ctype, assetCache) {
s.notFound(w, r)
}
})
}
// serveFile writes one file, with Range and HEAD support. It returns false when
// the file is missing or unreadable, so the caller can choose the error page.
func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, file, ctype, cache string) bool {
f, err := os.Open(file)
if err != nil {
return false
}
defer f.Close()
st, err := f.Stat()
if err != nil || st.IsDir() {
return false
}
h := w.Header()
h.Set("Content-Type", ctype)
h.Set("Cache-Control", cache)
h.Set("Content-Length", strconv.FormatInt(st.Size(), 10))
http.ServeContent(w, r, st.Name(), st.ModTime(), f)
return true
}
// within reports whether target is inside root, after cleaning both. Every path
// built from a request goes through here before it reaches the filesystem.
func within(root, target string) bool {
rel, err := filepath.Rel(root, target)
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel)
}
// contentTypeFor is the small set of types this site publishes.
func contentTypeFor(file string) string {
switch strings.ToLower(filepath.Ext(file)) {
case ".css":
return "text/css; charset=utf-8"
case ".js":
return "text/javascript; charset=utf-8"
case ".svg":
return "image/svg+xml"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".woff2":
return "font/woff2"
case ".json":
return "application/json; charset=utf-8"
case ".html":
return "text/html; charset=utf-8"
}
return "application/octet-stream"
}
// statusWriter records the status and size for the request log, and forwards
// Flush so an event stream is not buffered by the middleware.
type statusWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *statusWriter) WriteHeader(code int) {
if w.status == 0 {
w.status = code
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
func (w *statusWriter) statusOf() int {
if w.status == 0 {
return http.StatusOK
}
return w.status
}