// 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, derivedFS fs.FS, settings content.Settings) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) { serve(w, req, site, r, siteFS, settings) }) // Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases: no // bundle can own them, since a key always sits under a section. mux.HandleFunc("GET "+robotsPath, func(w http.ResponseWriter, req *http.Request) { serveRobots(w, req, siteFS, settings.Base) }) mux.HandleFunc("GET "+sitemapPath, func(w http.ResponseWriter, req *http.Request) { serveSitemap(w, req, site, settings.Base) }) if siteFS != nil { if sub, err := fs.Sub(siteFS, "static"); err == nil { mux.Handle("GET /static/", http.StripPrefix("/static/", serveStatic(sub))) } } // Generated files, served as opaque names. Core knows only that a directory of them exists: which files // are there, and what they are derived from, is the feature's business (ADR-0042). if derivedFS != nil { mux.Handle("GET "+content.DerivedPrefix, http.StripPrefix(content.DerivedPrefix, serveStatic(derivedFS))) } return mux } // serveStatic 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). // // Anything it cannot serve answers 404: a directory, a missing file, or a name the root refuses because it // resolves outside. A listing would expose the tree, and an error page for a refused symlink would confirm // the path is there — the same reason a hidden bundle answers 404 rather than 403 (ADR-0024). func serveStatic(sub fs.FS) http.Handler { files := http.FileServerFS(sub) return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { info, err := fs.Stat(sub, strings.TrimPrefix(req.URL.Path, "/")) if err != nil || info.IsDir() { http.NotFound(w, req) return } files.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) { writeAs(w, "text/html; charset=utf-8", out, what) } // writeAs sends bytes with the type they actually are. // // Separate from write because headers are only sent with the first byte, so a handler that set its own type // before calling write would have had it silently replaced by HTML — which is how a sitemap ends up served // as a web page. func writeAs(w http.ResponseWriter, contentType string, out []byte, what string) { w.Header().Set("Content-Type", contentType) 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, siteFS fs.FS, settings content.Settings) { res, ok := resolve(req.URL.Path, site) if !ok { http.NotFound(w, req) return } if res.feed { if !serveFeed(w, req, site, res, settings) { 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. // A request path is a route: a slug may have moved a bundle there, and moved another away (ADR-0035). key, live := site.KeyFor(res.key) b, served, found := site.Lookup(key, res.lang) found = found && live 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(site.RouteOf(canonical), res.lang), http.StatusMovedPermanently) return } // A file inside a bundle's directory: how a relative src in a body resolves (ADR-0024). if serveAsset(w, req, site, siteFS, res) { 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(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) }