Files
ai-resume/internal/server/server.go
Sam Rolfe d793ab0871 search: make the navigation form work from every page (AUD-151)
The nav form carried hx-get="/search" hx-target="#search-results", but
#search-results exists only on /search. On every other page htmx found no
target and silently discarded the response: a search box that looks like it
works and does nothing.

- SearchForm is now a plain GET with no htmx attributes (navigation and the
  landing page), so it navigates to /search?q=… from any page and with
  JavaScript disabled.
- SearchFormLive keeps the in-place swap on the search page, where the target
  does exist. For HX-Request the server now returns the block's *contents*
  (SearchResultsBody), so the fragment no longer nests a second element with
  id="search-results" inside the live region.
- Empty query shows a prompt instead of an empty list. A zero-match query says
  "Nothing found" and offers the six areas plus the evidence index.
- The result count is role="status" inside the existing aria-live container, so
  a screen reader reads "4 results for …" rather than a run of titles.

Tests (internal/server, internal/site):
- the nav form carries no htmx attribute on nine pages, and no built page
  carries an hx-target whose element is missing (walks all of dist/);
- search submitted from /, /edge/ and /platform/ lands on a page with results
  with no HX-Request header, i.e. the JavaScript-disabled path;
- empty query, zero match and the announced count;
- a content change followed by a build is visible in dist/search.db. That is
  the deploy path: deploy/deploy.sh runs `make dist` before it stages a
  release, and search.Build removes and rewrites the index every build.

DoD: search from home, /edge and /platform lands on results; works with
JavaScript disabled; zero-match query shows a message; content change followed
by a deploy is reflected in search; no htmx request targets a missing element.

(cherry picked from commit a56c795768e1e9b10204aae04c3612d7a96775a7)
2026-10-28 15:55:34 +11:00

606 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 contents of
// the results block, which the search page's own form swaps into #search-results;
// 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" {
// The element that holds #search-results stays where it is, so the
// fragment carries the contents and not the wrapper.
c = templates.SearchResultsBody(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
}