// 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.