| 1 |
|
// Package pages is the shared page-template machinery for the custom services |
| 2 |
|
// of a self-hosted SourceHut instance (compare, dolt, cover, bench, tokens). |
| 3 |
|
// |
| 4 |
|
// Every one of those services grew the same web/templates.go: discover the page |
| 5 |
|
// templates next to a shared layout, parse one template set per page, refuse a |
| 6 |
|
// page that defines no "content", execute into a buffer and only then write the |
| 7 |
|
// response, and render one error page for every status the surface refuses |
| 8 |
|
// with. The copies drifted in the ways copies do — one kept a hand-maintained |
| 9 |
|
// list of page names instead of reading the directory, one wrote the template |
| 10 |
|
// error into the response body, one skipped the content check entirely — and |
| 11 |
|
// each drift is a different way to serve a viewer a page that is silently |
| 12 |
|
// wrong. This package is the one copy, and it keeps the two refusals the |
| 13 |
|
// donors argued for at length: |
| 14 |
|
// |
| 15 |
|
// - A page that defines no "content" is a startup error. Executed, it would |
| 16 |
|
// render the chrome around an empty hole and answer 200, and a blank page |
| 17 |
|
// is the one failure a viewer cannot report usefully. This is also why the |
| 18 |
|
// layout must invoke the hole with {{template "content" .}} and never with |
| 19 |
|
// {{block "content" .}}: `block` defines the name it invokes, which would |
| 20 |
|
// hand every page an empty default at once and silently disarm the check. |
| 21 |
|
// |
| 22 |
|
// - A render goes into a buffer first. html/template writes as it evaluates, |
| 23 |
|
// so executing straight into the ResponseWriter commits the status line and |
| 24 |
|
// however many kilobytes of chrome were already produced before reaching |
| 25 |
|
// the expression that fails. Buffering costs one page of memory and turns |
| 26 |
|
// that into a clean 500. |
| 27 |
|
// |
| 28 |
|
// Usage, at startup: |
| 29 |
|
// |
| 30 |
|
// set, err := pages.Load(tmplFS, pages.Options{Funcs: myHelpers}) |
| 31 |
|
// |
| 32 |
|
// and in a handler: |
| 33 |
|
// |
| 34 |
|
// if err := s.pages.Render(w, http.StatusOK, "index", vd); err != nil { |
| 35 |
|
// slog.ErrorContext(r.Context(), "render failed", |
| 36 |
|
// "method", r.Method, "path", r.URL.Path, scribe.Err(err)) |
| 37 |
|
// } |
| 38 |
|
// |
| 39 |
|
// Render answers the response itself in every case, so a returned error is for |
| 40 |
|
// the log and never for a second answer — see Render. |
| 41 |
|
// |
| 42 |
|
// What stays in the services: the mapping from their own domain sentinels onto |
| 43 |
|
// HTTP statuses (each service's `fail`). Two surfaces of one service must agree |
| 44 |
|
// about which object exists, and that agreement is a property of that service's |
| 45 |
|
// domain, not of this package. |
| 46 |
|
package pages |
| 47 |
|
|
| 48 |
|
import ( |
| 49 |
|
"bytes" |
| 50 |
|
"errors" |
| 51 |
|
"fmt" |
| 52 |
|
"html/template" |
| 53 |
|
"io/fs" |
| 54 |
|
"maps" |
| 55 |
|
"net/http" |
| 56 |
|
"strings" |
| 57 |
|
|
| 58 |
|
"sourcecraft.dev/bigbes/sr-ht-ecore/chrome" |
| 59 |
|
) |
| 60 |
|
|
| 61 |
|
// Defaults for Options, and the file extension a page or partial is recognised |
| 62 |
|
// by. The extension is not configurable: every service in this family writes |
| 63 |
|
// .html, and a second spelling would only make the discovery rule harder to |
| 64 |
|
// read than the list it replaces. |
| 65 |
|
const ( |
| 66 |
|
DefaultDir = "templates" |
| 67 |
|
DefaultLayout = "layout.html" |
| 68 |
|
DefaultPartialPrefix = "_" |
| 69 |
|
DefaultContentBlock = "content" |
| 70 |
|
|
| 71 |
|
pageExt = ".html" |
| 72 |
|
) |
| 73 |
|
|
| 74 |
|
// ErrNoContent is returned by Load for a page template that defines no content |
| 75 |
|
// block. It is a sentinel rather than a bare string because it is the one Load |
| 76 |
|
// failure that is a template-authoring mistake and not a build or packaging |
| 77 |
|
// one, and a service that wants to say so in its startup message needs to tell |
| 78 |
|
// them apart. |
| 79 |
|
var ErrNoContent = errors.New("pages: no content block") |
| 80 |
|
|
| 81 |
|
// ErrUnknownPage is returned by Render when the name is not in the set. It is |
| 82 |
|
// always a bug in the calling package — the set is built from the files that |
| 83 |
|
// exist — so it is worth recognising in a log filter. |
| 84 |
|
var ErrUnknownPage = errors.New("pages: unknown page") |
| 85 |
|
|
| 86 |
|
// internalServerError is the body of every 500 this package writes itself. It |
| 87 |
|
// says nothing, deliberately: the error it stands for names templates, fields |
| 88 |
|
// and payload types, and the dolt donor published exactly that string to the |
| 89 |
|
// browser. |
| 90 |
|
const internalServerError = "internal server error" |
| 91 |
|
|
| 92 |
|
// Options configures Load. The zero value is the layout of every service in |
| 93 |
|
// this family: templates/layout.html, partials prefixed with '_', a "content" |
| 94 |
|
// block, and the shared chrome helpers. |
| 95 |
|
type Options struct { |
| 96 |
|
// Dir is the directory inside the FS holding the layout, the partials and |
| 97 |
|
// the pages. Defaults to DefaultDir. |
| 98 |
|
Dir string |
| 99 |
|
|
| 100 |
|
// Layout is the outer chrome every page is executed through, by file name. |
| 101 |
|
// Defaults to DefaultLayout. |
| 102 |
|
Layout string |
| 103 |
|
|
| 104 |
|
// PartialPrefix marks the files in Dir that are fragments rather than |
| 105 |
|
// pages. Defaults to DefaultPartialPrefix. |
| 106 |
|
// |
| 107 |
|
// A partial is parsed into *every* page's set, not only into the pages that |
| 108 |
|
// invoke it today: a partial known only to the pages that used it on the |
| 109 |
|
// day it was written is a lookup failure on the page that needs it next. |
| 110 |
|
PartialPrefix string |
| 111 |
|
|
| 112 |
|
// ContentBlock is the one block a page must define. Defaults to |
| 113 |
|
// DefaultContentBlock. |
| 114 |
|
ContentBlock string |
| 115 |
|
|
| 116 |
|
// Funcs are the service's own template helpers. They are merged over |
| 117 |
|
// chrome.Funcs, in that order, so the shared partials always find the |
| 118 |
|
// helpers they were written against and a service can still shadow one |
| 119 |
|
// deliberately rather than by accident of map ordering. |
| 120 |
|
Funcs template.FuncMap |
| 121 |
|
} |
| 122 |
|
|
| 123 |
|
// withDefaults fills the unset fields. It works on a copy: Load must not |
| 124 |
|
// rewrite the caller's struct, which is usually a literal at a call site that |
| 125 |
|
// documents what the service actually chose. |
| 126 |
13 |
func (o Options) withDefaults() Options { |
| 127 |
13 |
if o.Dir == "" { |
| 128 |
13 |
o.Dir = DefaultDir |
| 129 |
13 |
} |
| 130 |
13 |
if o.Layout == "" { |
| 131 |
13 |
o.Layout = DefaultLayout |
| 132 |
13 |
} |
| 133 |
13 |
if o.PartialPrefix == "" { |
| 134 |
13 |
o.PartialPrefix = DefaultPartialPrefix |
| 135 |
13 |
} |
| 136 |
13 |
if o.ContentBlock == "" { |
| 137 |
13 |
o.ContentBlock = DefaultContentBlock |
| 138 |
13 |
} |
| 139 |
13 |
return o |
| 140 |
|
} |
| 141 |
|
|
| 142 |
|
// funcs is the merged helper map, chrome's first and the service's on top. |
| 143 |
13 |
func (o Options) funcs() template.FuncMap { |
| 144 |
13 |
m := chrome.Funcs() |
| 145 |
13 |
maps.Copy(m, o.Funcs) |
| 146 |
13 |
return m |
| 147 |
13 |
} |
| 148 |
|
|
| 149 |
|
// A Set maps a page name — the template file's name without its extension — to |
| 150 |
|
// the template set that renders it: the layout, the shared chrome partials, the |
| 151 |
|
// shipped error partial, every local partial, and that one page's content. |
| 152 |
|
// |
| 153 |
|
// Every page gets its own set rather than all of them sharing one, because each |
| 154 |
|
// defines "content" and a shared set would let the last one parsed win. |
| 155 |
|
type Set map[string]*template.Template |
| 156 |
|
|
| 157 |
|
// Load parses one template set per content page found in the FS. |
| 158 |
|
// |
| 159 |
|
// Pages are discovered from the directory rather than listed in a slice, so |
| 160 |
|
// adding templates/whatever.html is the whole registration of a page. The |
| 161 |
|
// alternative — the static list the compare donor kept — is one more edit to |
| 162 |
|
// forget, and forgetting it yields a page that 500s with "unknown template" |
| 163 |
|
// while the file sits right there in the tree. |
| 164 |
|
// |
| 165 |
|
// A page that defines no content block is refused here, at startup, for the |
| 166 |
|
// reason given in the package doc. |
| 167 |
|
// |
| 168 |
|
// The set always has an "error" page: the one this package ships (error.html), |
| 169 |
|
// unless the FS carries an error.html of its own, which then wins whole. Either |
| 170 |
|
// way the "srht-error" partial is parsed into every set, so a service that |
| 171 |
|
// wants its own error page around the standard body can invoke it rather than |
| 172 |
|
// copy it. |
| 173 |
|
// |
| 174 |
|
// It takes the FS rather than an embed.FS so a test can hand it a bad tree: the |
| 175 |
|
// refusals above are the whole point of this function and none of them is |
| 176 |
|
// reachable through a service's own embedded templates, which are exactly the |
| 177 |
|
// files it ships and is expected to keep valid. |
| 178 |
13 |
func Load(fsys fs.FS, opts Options) (Set, error) { |
| 179 |
13 |
opts = opts.withDefaults() |
| 180 |
13 |
funcs := opts.funcs() |
| 181 |
13 |
|
| 182 |
13 |
entries, err := fs.ReadDir(fsys, opts.Dir) |
| 183 |
13 |
if err != nil { |
| 184 |
0 |
return nil, fmt.Errorf("pages: read %s: %w", opts.Dir, err) |
| 185 |
0 |
} |
| 186 |
|
|
| 187 |
13 |
var pageNames, partials []string |
| 188 |
13 |
layoutFound := false |
| 189 |
51 |
for _, e := range entries { |
| 190 |
51 |
name := e.Name() |
| 191 |
51 |
switch { |
| 192 |
0 |
case e.IsDir() || !strings.HasSuffix(name, pageExt): |
| 193 |
0 |
continue |
| 194 |
12 |
case name == opts.Layout: |
| 195 |
12 |
layoutFound = true |
| 196 |
13 |
case strings.HasPrefix(name, opts.PartialPrefix): |
| 197 |
13 |
partials = append(partials, opts.Dir+"/"+name) |
| 198 |
26 |
default: |
| 199 |
26 |
pageNames = append(pageNames, name) |
| 200 |
|
} |
| 201 |
|
} |
| 202 |
13 |
if !layoutFound { |
| 203 |
1 |
// Reported here rather than left to ParseFS, whose message for a pattern |
| 204 |
1 |
// that matches nothing does not mention that the missing file is the |
| 205 |
1 |
// layout every page is executed through. |
| 206 |
1 |
return nil, fmt.Errorf("pages: no layout %s in %s", opts.Layout, opts.Dir) |
| 207 |
1 |
} |
| 208 |
12 |
if len(pageNames) == 0 { |
| 209 |
1 |
// Only reachable if an embed pattern stops matching, which is a build |
| 210 |
1 |
// change and not a runtime condition; it is checked because the symptom |
| 211 |
1 |
// otherwise is a daemon that starts happily and 500s on every route. |
| 212 |
1 |
return nil, fmt.Errorf("pages: no page templates in %s", opts.Dir) |
| 213 |
1 |
} |
| 214 |
|
|
| 215 |
11 |
set := make(Set, len(pageNames)+1) |
| 216 |
22 |
for _, page := range pageNames { |
| 217 |
22 |
defines, err := opts.definesContent(fsys, page, funcs) |
| 218 |
22 |
if err != nil { |
| 219 |
0 |
return nil, err |
| 220 |
0 |
} |
| 221 |
22 |
if !defines { |
| 222 |
1 |
return nil, fmt.Errorf("pages: template %s defines no %q block: %w", |
| 223 |
1 |
page, opts.ContentBlock, ErrNoContent) |
| 224 |
1 |
} |
| 225 |
|
|
| 226 |
21 |
t, err := opts.base(funcs) |
| 227 |
21 |
if err != nil { |
| 228 |
0 |
return nil, err |
| 229 |
0 |
} |
| 230 |
|
// The layout first and the page last, so that a page's content wins over |
| 231 |
|
// any default the layout may carry for it: text/template keeps the last |
| 232 |
|
// definition of a name it parses. |
| 233 |
21 |
files := append([]string{opts.Dir + "/" + opts.Layout}, partials...) |
| 234 |
21 |
files = append(files, opts.Dir+"/"+page) |
| 235 |
21 |
if _, err := t.ParseFS(fsys, files...); err != nil { |
| 236 |
0 |
return nil, fmt.Errorf("pages: parse template %s: %w", page, err) |
| 237 |
0 |
} |
| 238 |
21 |
set[strings.TrimSuffix(page, pageExt)] = t |
| 239 |
|
} |
| 240 |
|
|
| 241 |
10 |
if _, ok := set[ErrorPage]; !ok { |
| 242 |
9 |
t, err := opts.base(funcs) |
| 243 |
9 |
if err != nil { |
| 244 |
0 |
return nil, err |
| 245 |
0 |
} |
| 246 |
9 |
files := append([]string{opts.Dir + "/" + opts.Layout}, partials...) |
| 247 |
9 |
if _, err := t.ParseFS(fsys, files...); err != nil { |
| 248 |
0 |
return nil, fmt.Errorf("pages: parse the layout for the error page: %w", err) |
| 249 |
0 |
} |
| 250 |
9 |
if _, err := t.ParseFS(sharedFS, sharedDir+"/"+errorPageFile); err != nil { |
| 251 |
0 |
return nil, fmt.Errorf("pages: parse the shipped error page: %w", err) |
| 252 |
0 |
} |
| 253 |
9 |
set[ErrorPage] = t |
| 254 |
|
} |
| 255 |
10 |
return set, nil |
| 256 |
|
} |
| 257 |
|
|
| 258 |
|
// base is the empty set every page starts from: the funcs, the shared chrome |
| 259 |
|
// partials, and this package's own. |
| 260 |
|
// |
| 261 |
|
// The chrome partials go in through chrome.Attach rather than |
| 262 |
|
// chrome.MustAttach: a parse failure in somebody else's module is still a |
| 263 |
|
// startup error the daemon should report with a sentence naming what it was |
| 264 |
|
// doing, and Load already returns an error for everything else that can go |
| 265 |
|
// wrong here. |
| 266 |
30 |
func (o Options) base(funcs template.FuncMap) (*template.Template, error) { |
| 267 |
30 |
// Attach first, the service's funcs second: Attach installs chrome's own |
| 268 |
30 |
// helpers so its partials can parse, and layering the service's map on top |
| 269 |
30 |
// afterwards is what lets a service shadow one of them deliberately. |
| 270 |
30 |
t, err := chrome.Attach(template.New(o.Layout)) |
| 271 |
30 |
if err != nil { |
| 272 |
0 |
return nil, fmt.Errorf("pages: attach the shared chrome partials: %w", err) |
| 273 |
0 |
} |
| 274 |
30 |
t = t.Funcs(funcs) |
| 275 |
30 |
if _, err := t.ParseFS(sharedFS, sharedDir+"/"+errorPartialFile); err != nil { |
| 276 |
0 |
return nil, fmt.Errorf("pages: parse the shared error partial: %w", err) |
| 277 |
0 |
} |
| 278 |
30 |
return t, nil |
| 279 |
|
} |
| 280 |
|
|
| 281 |
|
// definesContent reports whether a page's own file defines the content block. |
| 282 |
|
// |
| 283 |
|
// It parses the page alone, without the layout and without the partials, and |
| 284 |
|
// that separate parse is the whole of what this function is for. The obvious |
| 285 |
|
// check — looking "content" up in the assembled set — only works while |
| 286 |
|
// layout.html spells its hole {{template "content" .}} and its neighbours are |
| 287 |
|
// {{block "head" .}} and {{block "scripts" .}}: `block` *defines* the name it |
| 288 |
|
// invokes, so the day somebody makes the three consistent — a tidying edit no |
| 289 |
|
// reviewer would question — Lookup starts finding the layout's own empty |
| 290 |
|
// default on every page and the guard silently stops guarding. What comes back |
| 291 |
|
// then is the failure it exists to prevent: the chrome around an empty hole, |
| 292 |
|
// answered 200. Parsed on its own a page has only what it defines itself, and |
| 293 |
|
// no edit to the layout can reach that. |
| 294 |
|
// |
| 295 |
|
// The cost is one extra parse per page, once, at startup. |
| 296 |
22 |
func (o Options) definesContent(fsys fs.FS, page string, funcs template.FuncMap) (bool, error) { |
| 297 |
22 |
t := template.New(page).Funcs(funcs) |
| 298 |
22 |
if _, err := t.ParseFS(fsys, o.Dir+"/"+page); err != nil { |
| 299 |
0 |
return false, fmt.Errorf("pages: parse template %s: %w", page, err) |
| 300 |
0 |
} |
| 301 |
22 |
return t.Lookup(o.ContentBlock) != nil, nil |
| 302 |
|
} |
| 303 |
|
|
| 304 |
|
// Render executes a page and writes it. |
| 305 |
|
// |
| 306 |
|
// The execution goes into a buffer first, and that is the whole point of this |
| 307 |
|
// function: a template that fails halfway has otherwise already written a |
| 308 |
|
// partial page under a 200 that cannot be taken back, and a viewer cannot tell |
| 309 |
|
// half a document from a page that is genuinely that short. On some of these |
| 310 |
|
// surfaces the missing half is the one carrying a secret that will never be |
| 311 |
|
// shown again. |
| 312 |
|
// |
| 313 |
|
// Render answers the response in every case, and the contract that follows from |
| 314 |
|
// that is the important half of this comment: **a returned error means the |
| 315 |
|
// response has already been answered**, so the caller must log it and nothing |
| 316 |
|
// else. Handing it back to a `fail` that renders an error page would either |
| 317 |
|
// write a second response over a committed one, or — when the failure is in the |
| 318 |
|
// error page itself — recurse until the stack runs out. It is returned rather |
| 319 |
|
// than logged here because this package has no opinion about the caller's |
| 320 |
|
// logger, and the callers have three between them. |
| 321 |
|
// |
| 322 |
|
// What is answered on failure is a bare 500 carrying a fixed string. The dolt |
| 323 |
|
// donor wrote "template render error: "+err.Error() into the body instead, |
| 324 |
|
// which publishes template names, field paths and whatever the payload's |
| 325 |
|
// String method produces to whoever asked for the page. |
| 326 |
8 |
func (s Set) Render(w http.ResponseWriter, status int, name string, data any) error { |
| 327 |
8 |
t, ok := s[name] |
| 328 |
8 |
if !ok { |
| 329 |
1 |
// A page name that is not a template is a bug in the calling package — |
| 330 |
1 |
// the set is built from the files that exist — so it is answered as the |
| 331 |
1 |
// 500 it is. |
| 332 |
1 |
http.Error(w, internalServerError, http.StatusInternalServerError) |
| 333 |
1 |
return fmt.Errorf("pages: page %q: %w", name, ErrUnknownPage) |
| 334 |
1 |
} |
| 335 |
|
|
| 336 |
|
// t.Execute and not ExecuteTemplate(layout): Load names every set after the |
| 337 |
|
// layout it parses, so t *is* the layout, and naming it again here would be |
| 338 |
|
// a second place for Options.Layout to be spelled. |
| 339 |
7 |
var buf bytes.Buffer |
| 340 |
7 |
if err := t.Execute(&buf, data); err != nil { |
| 341 |
1 |
http.Error(w, internalServerError, http.StatusInternalServerError) |
| 342 |
1 |
return fmt.Errorf("pages: execute template %q: %w", name, err) |
| 343 |
1 |
} |
| 344 |
|
|
| 345 |
6 |
w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 346 |
6 |
w.WriteHeader(status) |
| 347 |
6 |
if _, err := buf.WriteTo(w); err != nil { |
| 348 |
0 |
// The viewer hung up mid-response. Nothing left to answer with and the |
| 349 |
0 |
// read is already done, so this is a log line — which is what every |
| 350 |
0 |
// error out of here is. |
| 351 |
0 |
return fmt.Errorf("pages: write the %d page: %w", status, err) |
| 352 |
0 |
} |
| 353 |
6 |
return nil |
| 354 |
|
} |