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)
606 lines
18 KiB
Go
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
|
|
}
|