A pages/error.go => pages/error.go +130 -0
@@ 0,0 1,130 @@
+package pages
+
+import (
+ "embed"
+ "net/http"
+)
+
+// sharedFS holds this package's own templates: the error partial parsed into
+// every set, and the error page used by a service that ships none.
+//
+//go:embed templates
+var sharedFS embed.FS
+
+const (
+ sharedDir = "templates"
+
+ // errorPartialFile defines ErrorPartial and nothing else, so that it can be
+ // parsed into every page set without bringing a content block with it.
+ errorPartialFile = "error.tmpl"
+
+ // errorPageFile is the default error page: a content block that is one
+ // invocation of ErrorPartial.
+ errorPageFile = "error.html"
+)
+
+// ErrorPage is the name Load registers the error page under, and the name to
+// pass Render for it. It is a constant because a service's `fail` names it on
+// every arm of its switch.
+const ErrorPage = "error"
+
+// ErrorPartial is the shared error body: the status line, the message and the
+// way back. Its dot is an ErrorData — not a view struct — so a service that
+// wants its own error.html around it invokes it as
+// {{template "srht-error" .Data}}.
+const ErrorPartial = "srht-error"
+
+// The messages of the error pages a surface produces on its own. They are
+// constants, and shared ones, for the reason the donors gave: the visibility
+// rules of these services require "somebody else's private thing" and "no such
+// thing" to be indistinguishable, and two 404s that differed in their prose
+// would rebuild the distinction the status code was chosen to erase.
+//
+// A 400 has no constant deliberately. It is the one class that describes
+// something the viewer just typed — "1y is not a duration", "grants are not
+// lower case" — and an error page that replaced that with a house phrase would
+// send them back to the form with nothing to change.
+const (
+ NotFoundMessage = "There is nothing here."
+ UnauthorizedMessage = "You need to be logged in to do that."
+ ForbiddenMessage = "You may not do that."
+ MethodMessage = "That is not something you can do to this page."
+ InternalMessage = "Something went wrong on our side. It has been logged."
+ UnavailableMessage = "Something we depend on is not answering. Try again in a moment."
+)
+
+// Message is the standard message for a status, or "" for a status that has
+// none — a 400 above all, whose message is the caller's own text.
+func Message(status int) string {
+ switch status {
+ case http.StatusUnauthorized:
+ return UnauthorizedMessage
+ case http.StatusForbidden:
+ return ForbiddenMessage
+ case http.StatusNotFound:
+ return NotFoundMessage
+ case http.StatusMethodNotAllowed:
+ return MethodMessage
+ case http.StatusInternalServerError:
+ return InternalMessage
+ case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
+ return UnavailableMessage
+ default:
+ return ""
+ }
+}
+
+// A Link is a href and the text that carries it.
+type Link struct {
+ Href string
+ Text string
+}
+
+// DefaultBack is the way back off an error page for a service that names none:
+// its own root, which every one of these services answers with a landing page.
+var DefaultBack = Link{Href: "/", Text: "Back to the start"}
+
+// ErrorData is the payload of the error page. A service puts it where its view
+// struct keeps page payload — the field the layout's content sees as .Data in
+// all of the donors:
+//
+// vd := s.view(r, http.StatusText(status))
+// vd.Data = pages.Error(status, message).BackTo("/tokens", "Back to your tokens")
+// err := s.pages.Render(w, status, pages.ErrorPage, vd)
+type ErrorData struct {
+ Status int
+ StatusText string
+
+ // Message is what the viewer can act on. It is never the raw error from
+ // below: those name tables, queries and paths. It goes through
+ // html/template, which escapes it — which matters, because the messages
+ // that are not constants quote what the caller typed.
+ Message string
+
+ // Back is the way off this page. Empty renders no link at all rather than a
+ // link to nowhere, which is what a hand-built ErrorData would otherwise get.
+ Back Link
+}
+
+// Error builds the payload for a status. An empty message takes the standard
+// one for that status (Message), so a caller that has nothing of its own to add
+// says nothing rather than inventing a phrase.
+func Error(status int, message string) ErrorData {
+ if message == "" {
+ message = Message(status)
+ }
+ return ErrorData{
+ Status: status,
+ StatusText: http.StatusText(status),
+ Message: message,
+ Back: DefaultBack,
+ }
+}
+
+// BackTo replaces the way back, for the services whose landing page is not "/"
+// or whose word for it is not "back to the start". It returns a copy, so it
+// chains off Error.
+func (d ErrorData) BackTo(href, text string) ErrorData {
+ d.Back = Link{Href: href, Text: text}
+ return d
+}
A pages/pages.go => pages/pages.go +349 -0
@@ 0,0 1,349 @@
+// Package pages is the shared page-template machinery for the custom services
+// of a self-hosted SourceHut instance (compare, dolt, cover, bench, tokens).
+//
+// Every one of those services grew the same web/templates.go: discover the page
+// templates next to a shared layout, parse one template set per page, refuse a
+// page that defines no "content", execute into a buffer and only then write the
+// response, and render one error page for every status the surface refuses
+// with. The copies drifted in the ways copies do — one kept a hand-maintained
+// list of page names instead of reading the directory, one wrote the template
+// error into the response body, one skipped the content check entirely — and
+// each drift is a different way to serve a viewer a page that is silently
+// wrong. This package is the one copy, and it keeps the two refusals the
+// donors argued for at length:
+//
+// - A page that defines no "content" is a startup error. Executed, it would
+// render the chrome around an empty hole and answer 200, and a blank page
+// is the one failure a viewer cannot report usefully. This is also why the
+// layout must invoke the hole with {{template "content" .}} and never with
+// {{block "content" .}}: `block` defines the name it invokes, which would
+// hand every page an empty default at once and silently disarm the check.
+//
+// - A render goes into a buffer first. html/template writes as it evaluates,
+// so executing straight into the ResponseWriter commits the status line and
+// however many kilobytes of chrome were already produced before reaching
+// the expression that fails. Buffering costs one page of memory and turns
+// that into a clean 500.
+//
+// Usage, at startup:
+//
+// set, err := pages.Load(tmplFS, pages.Options{Funcs: myHelpers})
+//
+// and in a handler:
+//
+// if err := s.pages.Render(w, http.StatusOK, "index", vd); err != nil {
+// log.Printf("web: %s %s: %v", r.Method, r.URL.Path, err)
+// }
+//
+// Render answers the response itself in every case, so a returned error is for
+// the log and never for a second answer — see Render.
+//
+// What stays in the services: the mapping from their own domain sentinels onto
+// HTTP statuses (each service's `fail`). Two surfaces of one service must agree
+// about which object exists, and that agreement is a property of that service's
+// domain, not of this package.
+package pages
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "maps"
+ "net/http"
+ "strings"
+
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
+)
+
+// Defaults for Options, and the file extension a page or partial is recognised
+// by. The extension is not configurable: every service in this family writes
+// .html, and a second spelling would only make the discovery rule harder to
+// read than the list it replaces.
+const (
+ DefaultDir = "templates"
+ DefaultLayout = "layout.html"
+ DefaultPartialPrefix = "_"
+ DefaultContentBlock = "content"
+
+ pageExt = ".html"
+)
+
+// ErrNoContent is returned by Load for a page template that defines no content
+// block. It is a sentinel rather than a bare string because it is the one Load
+// failure that is a template-authoring mistake and not a build or packaging
+// one, and a service that wants to say so in its startup message needs to tell
+// them apart.
+var ErrNoContent = errors.New("pages: no content block")
+
+// ErrUnknownPage is returned by Render when the name is not in the set. It is
+// always a bug in the calling package — the set is built from the files that
+// exist — so it is worth recognising in a log filter.
+var ErrUnknownPage = errors.New("pages: unknown page")
+
+// internalServerError is the body of every 500 this package writes itself. It
+// says nothing, deliberately: the error it stands for names templates, fields
+// and payload types, and the dolt donor published exactly that string to the
+// browser.
+const internalServerError = "internal server error"
+
+// Options configures Load. The zero value is the layout of every service in
+// this family: templates/layout.html, partials prefixed with '_', a "content"
+// block, and the shared chrome helpers.
+type Options struct {
+ // Dir is the directory inside the FS holding the layout, the partials and
+ // the pages. Defaults to DefaultDir.
+ Dir string
+
+ // Layout is the outer chrome every page is executed through, by file name.
+ // Defaults to DefaultLayout.
+ Layout string
+
+ // PartialPrefix marks the files in Dir that are fragments rather than
+ // pages. Defaults to DefaultPartialPrefix.
+ //
+ // A partial is parsed into *every* page's set, not only into the pages that
+ // invoke it today: a partial known only to the pages that used it on the
+ // day it was written is a lookup failure on the page that needs it next.
+ PartialPrefix string
+
+ // ContentBlock is the one block a page must define. Defaults to
+ // DefaultContentBlock.
+ ContentBlock string
+
+ // Funcs are the service's own template helpers. They are merged over
+ // chrome.Funcs, in that order, so the shared partials always find the
+ // helpers they were written against and a service can still shadow one
+ // deliberately rather than by accident of map ordering.
+ Funcs template.FuncMap
+}
+
+// withDefaults fills the unset fields. It works on a copy: Load must not
+// rewrite the caller's struct, which is usually a literal at a call site that
+// documents what the service actually chose.
+func (o Options) withDefaults() Options {
+ if o.Dir == "" {
+ o.Dir = DefaultDir
+ }
+ if o.Layout == "" {
+ o.Layout = DefaultLayout
+ }
+ if o.PartialPrefix == "" {
+ o.PartialPrefix = DefaultPartialPrefix
+ }
+ if o.ContentBlock == "" {
+ o.ContentBlock = DefaultContentBlock
+ }
+ return o
+}
+
+// funcs is the merged helper map, chrome's first and the service's on top.
+func (o Options) funcs() template.FuncMap {
+ m := chrome.Funcs()
+ maps.Copy(m, o.Funcs)
+ return m
+}
+
+// A Set maps a page name — the template file's name without its extension — to
+// the template set that renders it: the layout, the shared chrome partials, the
+// shipped error partial, every local partial, and that one page's content.
+//
+// Every page gets its own set rather than all of them sharing one, because each
+// defines "content" and a shared set would let the last one parsed win.
+type Set map[string]*template.Template
+
+// Load parses one template set per content page found in the FS.
+//
+// Pages are discovered from the directory rather than listed in a slice, so
+// adding templates/whatever.html is the whole registration of a page. The
+// alternative — the static list the compare donor kept — is one more edit to
+// forget, and forgetting it yields a page that 500s with "unknown template"
+// while the file sits right there in the tree.
+//
+// A page that defines no content block is refused here, at startup, for the
+// reason given in the package doc.
+//
+// The set always has an "error" page: the one this package ships (error.html),
+// unless the FS carries an error.html of its own, which then wins whole. Either
+// way the "srht-error" partial is parsed into every set, so a service that
+// wants its own error page around the standard body can invoke it rather than
+// copy it.
+//
+// It takes the FS rather than an embed.FS so a test can hand it a bad tree: the
+// refusals above are the whole point of this function and none of them is
+// reachable through a service's own embedded templates, which are exactly the
+// files it ships and is expected to keep valid.
+func Load(fsys fs.FS, opts Options) (Set, error) {
+ opts = opts.withDefaults()
+ funcs := opts.funcs()
+
+ entries, err := fs.ReadDir(fsys, opts.Dir)
+ if err != nil {
+ return nil, fmt.Errorf("pages: read %s: %w", opts.Dir, err)
+ }
+
+ var pageNames, partials []string
+ layoutFound := false
+ for _, e := range entries {
+ name := e.Name()
+ switch {
+ case e.IsDir() || !strings.HasSuffix(name, pageExt):
+ continue
+ case name == opts.Layout:
+ layoutFound = true
+ case strings.HasPrefix(name, opts.PartialPrefix):
+ partials = append(partials, opts.Dir+"/"+name)
+ default:
+ pageNames = append(pageNames, name)
+ }
+ }
+ if !layoutFound {
+ // Reported here rather than left to ParseFS, whose message for a pattern
+ // that matches nothing does not mention that the missing file is the
+ // layout every page is executed through.
+ return nil, fmt.Errorf("pages: no layout %s in %s", opts.Layout, opts.Dir)
+ }
+ if len(pageNames) == 0 {
+ // Only reachable if an embed pattern stops matching, which is a build
+ // change and not a runtime condition; it is checked because the symptom
+ // otherwise is a daemon that starts happily and 500s on every route.
+ return nil, fmt.Errorf("pages: no page templates in %s", opts.Dir)
+ }
+
+ set := make(Set, len(pageNames)+1)
+ for _, page := range pageNames {
+ defines, err := opts.definesContent(fsys, page, funcs)
+ if err != nil {
+ return nil, err
+ }
+ if !defines {
+ return nil, fmt.Errorf("pages: template %s defines no %q block: %w",
+ page, opts.ContentBlock, ErrNoContent)
+ }
+
+ t, err := opts.base(funcs)
+ if err != nil {
+ return nil, err
+ }
+ // The layout first and the page last, so that a page's content wins over
+ // any default the layout may carry for it: text/template keeps the last
+ // definition of a name it parses.
+ files := append([]string{opts.Dir + "/" + opts.Layout}, partials...)
+ files = append(files, opts.Dir+"/"+page)
+ if _, err := t.ParseFS(fsys, files...); err != nil {
+ return nil, fmt.Errorf("pages: parse template %s: %w", page, err)
+ }
+ set[strings.TrimSuffix(page, pageExt)] = t
+ }
+
+ if _, ok := set[ErrorPage]; !ok {
+ t, err := opts.base(funcs)
+ if err != nil {
+ return nil, err
+ }
+ files := append([]string{opts.Dir + "/" + opts.Layout}, partials...)
+ if _, err := t.ParseFS(fsys, files...); err != nil {
+ return nil, fmt.Errorf("pages: parse the layout for the error page: %w", err)
+ }
+ if _, err := t.ParseFS(sharedFS, sharedDir+"/"+errorPageFile); err != nil {
+ return nil, fmt.Errorf("pages: parse the shipped error page: %w", err)
+ }
+ set[ErrorPage] = t
+ }
+ return set, nil
+}
+
+// base is the empty set every page starts from: the funcs, the shared chrome
+// partials, and this package's own.
+//
+// The chrome partials go in through chrome.Attach rather than
+// chrome.MustAttach: a parse failure in somebody else's module is still a
+// startup error the daemon should report with a sentence naming what it was
+// doing, and Load already returns an error for everything else that can go
+// wrong here.
+func (o Options) base(funcs template.FuncMap) (*template.Template, error) {
+ t, err := chrome.Attach(template.New(o.Layout).Funcs(funcs))
+ if err != nil {
+ return nil, fmt.Errorf("pages: attach the shared chrome partials: %w", err)
+ }
+ if _, err := t.ParseFS(sharedFS, sharedDir+"/"+errorPartialFile); err != nil {
+ return nil, fmt.Errorf("pages: parse the shared error partial: %w", err)
+ }
+ return t, nil
+}
+
+// definesContent reports whether a page's own file defines the content block.
+//
+// It parses the page alone, without the layout and without the partials, and
+// that separate parse is the whole of what this function is for. The obvious
+// check — looking "content" up in the assembled set — only works while
+// layout.html spells its hole {{template "content" .}} and its neighbours are
+// {{block "head" .}} and {{block "scripts" .}}: `block` *defines* the name it
+// invokes, so the day somebody makes the three consistent — a tidying edit no
+// reviewer would question — Lookup starts finding the layout's own empty
+// default on every page and the guard silently stops guarding. What comes back
+// then is the failure it exists to prevent: the chrome around an empty hole,
+// answered 200. Parsed on its own a page has only what it defines itself, and
+// no edit to the layout can reach that.
+//
+// The cost is one extra parse per page, once, at startup.
+func (o Options) definesContent(fsys fs.FS, page string, funcs template.FuncMap) (bool, error) {
+ t := template.New(page).Funcs(funcs)
+ if _, err := t.ParseFS(fsys, o.Dir+"/"+page); err != nil {
+ return false, fmt.Errorf("pages: parse template %s: %w", page, err)
+ }
+ return t.Lookup(o.ContentBlock) != nil, nil
+}
+
+// Render executes a page and writes it.
+//
+// The execution goes into a buffer first, and that is the whole point of this
+// function: a template that fails halfway has otherwise already written a
+// partial page under a 200 that cannot be taken back, and a viewer cannot tell
+// half a document from a page that is genuinely that short. On some of these
+// surfaces the missing half is the one carrying a secret that will never be
+// shown again.
+//
+// Render answers the response in every case, and the contract that follows from
+// that is the important half of this comment: **a returned error means the
+// response has already been answered**, so the caller must log it and nothing
+// else. Handing it back to a `fail` that renders an error page would either
+// write a second response over a committed one, or — when the failure is in the
+// error page itself — recurse until the stack runs out. It is returned rather
+// than logged here because this package has no opinion about the caller's
+// logger, and the callers have three between them.
+//
+// What is answered on failure is a bare 500 carrying a fixed string. The dolt
+// donor wrote "template render error: "+err.Error() into the body instead,
+// which publishes template names, field paths and whatever the payload's
+// String method produces to whoever asked for the page.
+func (s Set) Render(w http.ResponseWriter, status int, name string, data any) error {
+ t, ok := s[name]
+ if !ok {
+ // A page name that is not a template is a bug in the calling package —
+ // the set is built from the files that exist — so it is answered as the
+ // 500 it is.
+ http.Error(w, internalServerError, http.StatusInternalServerError)
+ return fmt.Errorf("pages: page %q: %w", name, ErrUnknownPage)
+ }
+
+ // t.Execute and not ExecuteTemplate(layout): Load names every set after the
+ // layout it parses, so t *is* the layout, and naming it again here would be
+ // a second place for Options.Layout to be spelled.
+ var buf bytes.Buffer
+ if err := t.Execute(&buf, data); err != nil {
+ http.Error(w, internalServerError, http.StatusInternalServerError)
+ return fmt.Errorf("pages: execute template %q: %w", name, err)
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if _, err := buf.WriteTo(w); err != nil {
+ // The viewer hung up mid-response. Nothing left to answer with and the
+ // read is already done, so this is a log line — which is what every
+ // error out of here is.
+ return fmt.Errorf("pages: write the %d page: %w", status, err)
+ }
+ return nil
+}
A pages/pages_test.go => pages/pages_test.go +251 -0
@@ 0,0 1,251 @@
+package pages
+
+import (
+ "errors"
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "testing/fstest"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// view is the dot every test renders through: the shape the donors' view
+// structs have — chrome fields (here only a title) plus the page's payload
+// under .Data, which is what the shipped error page reads.
+type view struct {
+ Title string
+ Data any
+}
+
+// chromeMark is written by the layout before it reaches the content hole, so a
+// test can tell "nothing was written" from "half a page was written".
+const chromeMark = "CHROME-MARK"
+
+// boomer fails at execution and only there: html/template reports an error
+// returned by a method as a render error, which is the mid-render failure the
+// buffering exists for. Its message names something a viewer must never see.
+type boomer struct{}
+
+func (boomer) Boom() (string, error) { return "", errors.New("kaboom /etc/srht/config.ini") }
+
+// testFS is a service's template tree: a layout, a page, a partial, and a page
+// that fails halfway through rendering.
+func testFS() fstest.MapFS {
+ return fstest.MapFS{
+ "templates/layout.html": &fstest.MapFile{Data: []byte(
+ `<!doctype html><title>{{.Title}}</title><body>` + chromeMark +
+ `{{template "content" .}}</body>`)},
+ "templates/index.html": &fstest.MapFile{Data: []byte(
+ `{{define "content"}}<p>index {{template "_row" .Data}}</p>{{end}}`)},
+ "templates/_row.html": &fstest.MapFile{Data: []byte(
+ `{{define "_row"}}<i>{{.}}</i>{{end}}`)},
+ "templates/boom.html": &fstest.MapFile{Data: []byte(
+ `{{define "content"}}<p>{{.Data.Boom}}</p>{{end}}`)},
+ }
+}
+
+func TestLoadDiscoversPagesPartialsAndTheShippedErrorPage(t *testing.T) {
+ set, err := Load(testFS(), Options{})
+ require.NoError(t, err)
+
+ // The pages, plus the error page this package ships. The layout and the
+ // partial are not pages and must not be renderable by name.
+ assert.ElementsMatch(t, []string{"index", "boom", ErrorPage}, keys(set))
+}
+
+func TestLoadParsesEveryPartialIntoEveryPage(t *testing.T) {
+ set, err := Load(testFS(), Options{})
+ require.NoError(t, err)
+
+ // A partial is parsed into every set, not only into the page that invokes
+ // it today — including the error page, which invokes none of them.
+ for name, tmpl := range set {
+ assert.NotNil(t, tmpl.Lookup("_row"), "local partial missing from %q", name)
+ assert.NotNil(t, tmpl.Lookup(ErrorPartial), "error partial missing from %q", name)
+ assert.NotNil(t, tmpl.Lookup("srht-nav"), "chrome partial missing from %q", name)
+ }
+}
+
+func TestLoadRefusesAPageThatDefinesNoContent(t *testing.T) {
+ fsys := testFS()
+ // A page that renders nothing into the hole: executed, it would answer 200
+ // with the chrome around an empty document.
+ fsys["templates/blank.html"] = &fstest.MapFile{Data: []byte(`<p>forgot the define</p>`)}
+
+ _, err := Load(fsys, Options{})
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrNoContent)
+ assert.Contains(t, err.Error(), "blank.html")
+}
+
+func TestLoadRefusesATreeWithoutALayout(t *testing.T) {
+ fsys := testFS()
+ delete(fsys, "templates/layout.html")
+
+ _, err := Load(fsys, Options{})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no layout")
+}
+
+func TestLoadRefusesATreeWithNoPages(t *testing.T) {
+ fsys := fstest.MapFS{
+ "templates/layout.html": &fstest.MapFile{Data: []byte(`{{template "content" .}}`)},
+ "templates/_row.html": &fstest.MapFile{Data: []byte(`{{define "_row"}}{{end}}`)},
+ }
+
+ _, err := Load(fsys, Options{})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no page templates")
+}
+
+func TestLoadMergesTheServiceFuncsOverChromes(t *testing.T) {
+ fsys := testFS()
+ fsys["templates/index.html"] = &fstest.MapFile{Data: []byte(
+ `{{define "content"}}{{shortsha "0123456789"}}|{{shout "hi"}}{{end}}`)}
+
+ set, err := Load(fsys, Options{Funcs: template.FuncMap{
+ "shout": func(s string) string { return s + "!" },
+ "shortsha": func(string) string { return "SHADOWED" },
+ }})
+ require.NoError(t, err)
+
+ rec := httptest.NewRecorder()
+ require.NoError(t, set.Render(rec, http.StatusOK, "index", view{}))
+ assert.Contains(t, rec.Body.String(), "SHADOWED|hi!")
+}
+
+func TestRenderWritesTheStatusAndTheBufferedPage(t *testing.T) {
+ set, err := Load(testFS(), Options{})
+ require.NoError(t, err)
+
+ rec := httptest.NewRecorder()
+ require.NoError(t, set.Render(rec, http.StatusCreated, "index", view{Title: "T", Data: "payload"}))
+
+ assert.Equal(t, http.StatusCreated, rec.Code)
+ assert.Equal(t, "text/html; charset=utf-8", rec.Header().Get("Content-Type"))
+ body := rec.Body.String()
+ assert.Contains(t, body, "<title>T</title>")
+ assert.Contains(t, body, chromeMark)
+ assert.Contains(t, body, "<i>payload</i>")
+}
+
+func TestRenderFailureLeavesNoPartialPageAndNoTemplateText(t *testing.T) {
+ set, err := Load(testFS(), Options{})
+ require.NoError(t, err)
+
+ rec := httptest.NewRecorder()
+ err = set.Render(rec, http.StatusOK, "boom", view{Title: "T", Data: boomer{}})
+
+ // The caller gets the error to log, naming the page and carrying the cause.
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), `"boom"`)
+ assert.Contains(t, err.Error(), "kaboom")
+
+ // The viewer gets a clean 500 and nothing else: not the status the handler
+ // asked for, not the chrome the template had already produced when it
+ // failed, and above all not the template's own error text — which is what
+ // the dolt donor wrote into the body.
+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
+ assert.Equal(t, internalServerError+"\n", rec.Body.String())
+ assert.NotContains(t, rec.Body.String(), chromeMark)
+ assert.NotContains(t, rec.Body.String(), "kaboom")
+ assert.NotContains(t, rec.Body.String(), "config.ini")
+}
+
+func TestRenderRefusesAnUnknownPage(t *testing.T) {
+ set, err := Load(testFS(), Options{})
+ require.NoError(t, err)
+
+ rec := httptest.NewRecorder()
+ err = set.Render(rec, http.StatusOK, "nope", view{})
+
+ require.Error(t, err)
+ assert.ErrorIs(t, err, ErrUnknownPage)
+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
+ assert.Equal(t, internalServerError+"\n", rec.Body.String())
+}
+
+func TestErrorPageRendersTheStatusTheMessageAndTheWayBack(t *testing.T) {
+ set, err := Load(testFS(), Options{})
+ require.NoError(t, err)
+
+ data := Error(http.StatusNotFound, "").BackTo("/tokens", "Back to your tokens")
+ rec := httptest.NewRecorder()
+ require.NoError(t, set.Render(rec, data.Status, ErrorPage,
+ view{Title: data.StatusText, Data: data}))
+
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+ body := rec.Body.String()
+ assert.Contains(t, body, chromeMark, "the error page is rendered inside the chrome")
+ assert.Contains(t, body, "404")
+ assert.Contains(t, body, "Not Found")
+ assert.Contains(t, body, NotFoundMessage)
+ assert.Contains(t, body, `<a href="/tokens">Back to your tokens</a>`)
+}
+
+func TestErrorPageEscapesTheMessage(t *testing.T) {
+ set, err := Load(testFS(), Options{})
+ require.NoError(t, err)
+
+ // A 400's message quotes what the caller typed, so it is the one that can
+ // carry markup.
+ data := Error(http.StatusBadRequest, `<script>alert(1)</script> is not a duration`)
+ rec := httptest.NewRecorder()
+ require.NoError(t, set.Render(rec, data.Status, ErrorPage, view{Data: data}))
+
+ body := rec.Body.String()
+ assert.NotContains(t, body, "<script>")
+ assert.Contains(t, body, "<script>")
+}
+
+func TestErrorPageOmitsAWayBackItWasNotGiven(t *testing.T) {
+ set, err := Load(testFS(), Options{})
+ require.NoError(t, err)
+
+ rec := httptest.NewRecorder()
+ require.NoError(t, set.Render(rec, http.StatusForbidden, ErrorPage,
+ view{Data: ErrorData{Status: 403, StatusText: "Forbidden", Message: ForbiddenMessage}}))
+
+ assert.NotContains(t, rec.Body.String(), "<a href=", "no link rather than a link to nowhere")
+}
+
+func TestAServiceErrorPageWinsOverTheShippedOne(t *testing.T) {
+ fsys := testFS()
+ fsys["templates/error.html"] = &fstest.MapFile{Data: []byte(
+ `{{define "content"}}<div class="own">{{template "srht-error" .Data}}</div>{{end}}`)}
+
+ set, err := Load(fsys, Options{})
+ require.NoError(t, err)
+
+ rec := httptest.NewRecorder()
+ data := Error(http.StatusServiceUnavailable, "")
+ require.NoError(t, set.Render(rec, data.Status, ErrorPage, view{Data: data}))
+
+ body := rec.Body.String()
+ assert.Contains(t, body, `<div class="own">`, "the service's own error page is used")
+ assert.Contains(t, body, UnavailableMessage, "and it can still invoke the shared body")
+}
+
+func TestErrorTakesTheStandardMessageForItsStatus(t *testing.T) {
+ assert.Equal(t, NotFoundMessage, Error(http.StatusNotFound, "").Message)
+ assert.Equal(t, InternalMessage, Error(http.StatusInternalServerError, "").Message)
+ assert.Equal(t, "Not Found", Error(http.StatusNotFound, "").StatusText)
+ assert.Equal(t, DefaultBack, Error(http.StatusNotFound, "").Back)
+
+ // A caller's own message is never replaced, and a status with no standard
+ // message keeps the empty one rather than inventing a phrase.
+ assert.Equal(t, "1y is not a duration", Error(http.StatusBadRequest, "1y is not a duration").Message)
+ assert.Empty(t, Error(http.StatusBadRequest, "").Message)
+ assert.Empty(t, Message(http.StatusTeapot))
+}
+
+func keys(s Set) []string {
+ out := make([]string, 0, len(s))
+ for name := range s {
+ out = append(out, name)
+ }
+ return out
+}
A pages/templates/error.html => pages/templates/error.html +6 -0
@@ 0,0 1,6 @@
+{{/*
+ The default error page, registered as "error" for every service that ships no
+ error.html of its own. It is one invocation of the shared body, and it reads
+ .Data because that is where all of the donors' view structs keep page payload.
+*/}}
+{{define "content"}}{{template "srht-error" .Data}}{{end}}
A pages/templates/error.tmpl => pages/templates/error.tmpl +19 -0
@@ 0,0 1,19 @@
+{{/*
+ The shared error body, for every status a surface refuses with. Its dot is a
+ pages.ErrorData and not a view struct, so a service that wants its own
+ error.html can wrap this rather than copy it:
+
+ {{define "content"}}{{template "srht-error" .Data}}{{end}}
+
+ The message is escaped here — it is a constant on most arms, but the ones
+ that are not quote what the caller typed.
+*/}}
+{{define "srht-error" -}}
+<div class="row">
+ <div class="col-md-8">
+ <h2>{{.Status}} — {{.StatusText}}</h2>
+ {{if .Message}}<p>{{.Message}}</p>{{end}}
+ {{with .Back.Href}}<p><a href="{{.}}">{{$.Back.Text}}</a></p>{{end}}
+ </div>
+</div>
+{{- end}}