Every figure and gallery shipped so far emitted links a browser could not fetch: a relative src resolves under the page's URL, and nothing answered there. Found by fetching the pages' own links rather than by reading their markup — the evidence runs had been checking that the right src appeared, never that it worked. A directory bundle's files are now served under its URL. The bundle is looked up first and the file is read only from the directory that bundle owns, never from a path assembled out of the request: ADR-0024 requires that no route serve bundle bytes by path alone, since every byte inside a bundle inherits its publish status. When drafts arrive at queue 19 the filter belongs beside that lookup and nowhere else, which is why the ordering is written down in the comment. A single-file bundle owns nothing: its neighbours belong to the section, and its slash-terminated URL has nothing beneath it. An author with assets writes a directory bundle, now stated in content-model.md. A .md inside a bundle directory is never an asset — it is a bundle with its own URL or a fragment that was never addressable, and serving either raw would publish source. http.ServeFileFS handles content type, conditional requests and ranges, none of which is worth reimplementing here.
180 lines
6.6 KiB
Go
180 lines
6.6 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"
|
|
)
|
|
|
|
// 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, settings content.Settings) http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
|
|
serve(w, req, site, r, siteFS)
|
|
})
|
|
// 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)))
|
|
}
|
|
}
|
|
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) {
|
|
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.
|
|
// 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)
|
|
}
|