// Package web maps requests to bundles and writes bytes. It knows content and render, and exposes // neither to them. package web import ( "io/fs" "log/slog" "net/http" "strings" "khosra/internal/content" "khosra/internal/render" ) // Handler serves a site. // // One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve. func Handler(site *content.Site, r *render.Renderer, siteFS fs.FS) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) { serve(w, req, site, r) }) if siteFS != nil { mux.Handle("GET /static/", http.StripPrefix("/static/", noListing(staticFS(siteFS)))) } return mux } // staticFS serves the site root's static/ directory verbatim. It keeps the os.Root guarantee, because // the fs.FS it is given is the one rooted there (ADR-0031). func staticFS(siteFS fs.FS) http.Handler { sub, err := fs.Sub(siteFS, "static") if err != nil { return http.NotFoundHandler() } return http.FileServerFS(sub) } // noListing refuses directory paths, so static/ never answers with an index of its own contents. func noListing(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { if req.URL.Path == "" || strings.HasSuffix(req.URL.Path, "/") { http.NotFound(w, req) return } h.ServeHTTP(w, req) }) } // serveListing answers a section index, reporting whether it handled the request. // // A section is not a bundle, so this runs only after the bundle lookup misses. A page number past the // end is a 404 rather than an empty page, because an empty page is a URL that means nothing. func serveListing(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool { if res.key == "" || strings.Contains(res.key, "/") { return false } items := site.Run(content.Query{Section: res.key, Lang: res.lang}) if len(items) == 0 { return false } if res.page > 1 && (res.page-1)*content.PerPage >= len(items) { return false } out, err := r.Listing(res.key, res.lang, items, res.page) if err != nil { slog.Error("listing failed", "section", res.key, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return true } write(w, out, res.key) return true } // serveTags answers a tag listing, grouped by section so a busy term stays readable (ADR-0018). func serveTags(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer, res resolution) bool { items := site.Run(content.Query{Section: res.key, Tag: res.tag, Lang: res.lang}) if len(items) == 0 { return false } if res.page > 1 && (res.page-1)*content.PerPage >= len(items) { return false } // Redirect only once the listing is known to exist, the same rule bundles follow: a canonical URL for // nothing would confirm what is not there. if res.redirect != "" { http.Redirect(w, req, res.redirect, http.StatusMovedPermanently) return true } out, err := r.Tag(res.key, res.tag, res.lang, items, res.page) if err != nil { slog.Error("tag listing failed", "tag", res.tag, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return true } write(w, out, res.tag) return true } // write sends a rendered page, logging a failed write rather than pretending it succeeded. func write(w http.ResponseWriter, out []byte, what string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if _, err := w.Write(out); err != nil { slog.Warn("write failed", "what", what, "err", err) } } // serve resolves one request and writes its bundle. func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer) { res, ok := resolve(req.URL.Path, site) if !ok { http.NotFound(w, req) return } if res.tag != "" { if !serveTags(w, req, site, r, res) { http.NotFound(w, req) } return } // A redirect target only exists for a path that resolves, so check the bundle before sending one: // otherwise a nonexistent page answers 301 and confirms nothing. b, served, found := site.Lookup(res.key, res.lang) if res.redirect != "" && (found || res.key == "") { http.Redirect(w, req, res.redirect, http.StatusMovedPermanently) return } if !found { // An alias is a promise that an old URL keeps working, so it answers a permanent redirect to the // canonical one — and only for an alias that exists, so nothing can be probed by 301. if canonical, isAlias := site.Alias(res.key); isAlias { http.Redirect(w, req, content.URL(canonical, res.lang), http.StatusMovedPermanently) return } if serveListing(w, req, site, r, res) { return } http.NotFound(w, req) return } // A bundle nested under another, or holding others, is part of a series; anything else renders with no // sequence at all (ADR-0033). var seq *content.Sequence if series, inSeries := site.Sequence(b.Key, served); inSeries { seq = &series } out, err := r.Bundle(b, served, site.Variants(res.key), seq) if err != nil { // A render failure degrades: log it and say nothing more to the client than that it failed // (conventions.md). It must never leak a template or filesystem detail. slog.Error("render failed", "key", b.Key, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } write(w, out, b.Key) }