// 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, `

`+msg+`

The evidence index lists every fragment in this build.

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