Files
khosra/internal/web/web.go
T
bdeshiandClaude Opus 5 9349c54d2e let a feature own a route, and serve the site's own files at exact paths
Addresses like /.well-known/security.txt are fixed by somebody else's spec.
None is a bundle, none belongs under /static/, and core had no way to serve one.

This is the trigger the extension registry has been held for, in those words:
ADR-0042 called core's generic derived-file route "the seam to revisit when a
second feature wants output of its own", and state.md's counter note said to
build the registry "when a feature wants a route". Raw passthrough is that
feature, so the seam is built rather than worked around.

Only Routes, not the seven-field Extension struct extensions.md describes. Five
of the other six fields have no implementor and building them would be the
speculation rule 6 forbids. It also kept the change inside the core budget,
which had 65 lines left: the seam is ~30 core lines and the feature's own code
lands in internal/ext/, where there is room. Core is 2965/3000.

A feature returns map[string]http.Handler; core mounts each as an exact pattern
and learns nothing about who owns it. A path core already answers is skipped
with a warning, not overridden — http.ServeMux panics on a duplicate pattern, so
a site shipping root/robots.txt would otherwise take the server down at startup.
Verified: server alive, engine keeps /robots.txt, warning logged, zero panics.

Templating is opt-in by filename. A .tmpl suffix is stripped from the URL and
the file is rendered with text/template — never html/template, which would turn
an ampersand in a contact address into & and a JSON quote into ". Opt-in
by name rather than by sniffing the type, because a key or a signature may
contain anything and a pass choosing for itself which files to rewrite would
eventually eat one. The data is the site's own declarations and nothing more,
which is the point: a security.txt naming its canonical URL should not repeat
what site.yaml already says.

Headers come from root/_headers.yaml, exact paths only. Globs are a second-use
feature and the concrete need is a handful of .well-known names. The manifest is
not served, by the leading-underscore rule that already means "not addressable"
everywhere else — no special case was added for it. A manifest that will not
parse is logged and ignored; the files still serve.

Found while counting: the Extensions row read 4 while five packages existed.
notation landed in ADR-0061/0062 and was never counted, though the prose beside
the number already named all five. Corrected to 6. That is the latent item about
counters having no mechanical check, demonstrating itself.

Not done, and logged as latent: khosra check cannot report a root/ file
shadowing an engine path, because verify.sh fails a feature that imports a
sibling and the reserved paths live in passthrough. The startup warning fires on
every boot, which is louder than a check finding.

Evidence against the demo with a fresh binary: /pubkey answers with its declared
text/plain despite having no extension; /.well-known/security.txt answers with
Canonical filled from site.yaml's base, plus the declared CORS header;
/humans.txt gets a derived type; /_headers.yaml is 404; / and a bundle page are
untouched. Eight unit tests cover layout, absence, interpolation, non-escaping,
declared and derived headers, a broken template, and a broken manifest.

24 files, +514/-46. Extensions 4 (miscounted) → 6. Routing cases unmoved: exact
paths are mux entries, not resolver cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:48:59 +06:00

249 lines
9.9 KiB
Go

// 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"
)
// Snapshot is the content and the theme that were current together.
//
// One value rather than two, because a page assembled from a new theme and the previous index is a page that
// never existed on disk. A rebuild makes both and swaps them in one store (ADR-0077).
type Snapshot struct {
Site *content.Site
Theme *render.Renderer
}
// Current returns the snapshot as it is right now.
//
// A function rather than a pointer, so a background poller can swap what it returns and a request still sees
// one coherent pair instead of one being rebuilt underneath it (ADR-0022).
type Current func() *Snapshot
// Fixed is a Current for a site that never changes, which is every caller that does not watch for changes.
func Fixed(site *content.Site, theme *render.Renderer) Current {
snap := &Snapshot{Site: site, Theme: theme}
return func() *Snapshot { return snap }
}
// Handler serves a site.
//
// One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
// Handler builds the mux. routes are exact URL paths a feature owns, keyed by path — the one part of the
// extension contract that is earned, because a feature finally wants a route (ADR-0081, the seam ADR-0042
// named). Core learns only that some paths belong to somebody else; which files answer them, and what they
// contain, is the feature's business — the same division `/derived/` already uses.
func Handler(current Current, siteFS, derivedFS fs.FS, settings content.Settings, routes map[string]http.Handler) http.Handler {
mux := http.NewServeMux()
// Registered first so a later duplicate is caught rather than panicking, and so core's own answers are
// the ones that cannot be taken over.
reserved := map[string]bool{"/": true, robotsPath: true, sitemapPath: true}
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
now := current()
serve(w, req, now.Site, now.Theme, 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, current().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)))
}
// A feature's routes go on last. A path core already answers is skipped, not overridden: http.ServeMux
// panics on a duplicate pattern, so without this a site shipping root/robots.txt would take the server
// down at startup rather than lose a race it was never told about. `khosra check` reports the shadow.
for pattern, handler := range routes {
if reserved[pattern] || strings.HasPrefix(pattern, "/static/") || strings.HasPrefix(pattern, content.DerivedPrefix) {
slog.Warn("a passthrough path is already answered by the engine and is not served",
"path", pattern)
continue
}
mux.Handle("GET "+pattern, handler)
}
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 {
// An empty key is the site root, which lists everything. Anything with a slash in it is a bundle path that
// missed, not a section.
if 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.extras {
if !serveExtras(w, req, site, r, siteFS, res) {
http.NotFound(w, req)
}
return
}
if res.tag != "" {
if !serveTags(w, req, site, r, res) {
http.NotFound(w, req)
}
return
}
serveBundle(w, req, site, r, siteFS, res)
}
// serveBundle answers a request that named a bundle, a section listing, or a file inside a bundle.
//
// Split from serve when that function crossed the length warning: serve decides *what kind* of thing was asked
// for, this one answers the commonest kind.
func serveBundle(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer,
siteFS fs.FS, res resolution) {
// 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)
}