~bigbes/sr-ht-ecore

ref: e8a9672594733218fd773bab9e9216ae1c2db6c2 sr-ht-ecore/pages/pages.go -rw-r--r-- 14.7 KiB
e8a96725 — Eugene Blikh bearer: mint the internal authorization through internalauth 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
// 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 {
//		slog.ErrorContext(r.Context(), "render failed",
//			"method", r.Method, "path", r.URL.Path, scribe.Err(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) {
	// Attach first, the service's funcs second: Attach installs chrome's own
	// helpers so its partials can parse, and layering the service's map on top
	// afterwards is what lets a service shadow one of them deliberately.
	t, err := chrome.Attach(template.New(o.Layout))
	if err != nil {
		return nil, fmt.Errorf("pages: attach the shared chrome partials: %w", err)
	}
	t = t.Funcs(funcs)
	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
}