Go · 17479 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package render owns the html/template loading and rendering pipeline.
4 // S02 ships the helper set that the rest of the project will rely on
5 // (safeHTML, relativeTime, pluralize, pathJoin, octicon, csrfToken).
6 // S25 will broaden this with the markdown pipeline.
7 package render
8
9 import (
10 "bytes"
11 "fmt"
12 "html/template"
13 "io"
14 "io/fs"
15 "net/http"
16 "path"
17 "reflect"
18 "sort"
19 "strings"
20 "text/template/parse"
21 "time"
22
23 "github.com/tenseleyFlow/shithub/internal/web/middleware"
24 )
25
26 // Renderer holds parsed templates indexed by page name.
27 type Renderer struct {
28 pages map[string]*template.Template
29 octicon OcticonResolver
30 }
31
32 // OcticonResolver returns the inline SVG markup for a named octicon. The
33 // implementation is provided by the caller; for S02 we ship a tiny built-in
34 // set; later sprints can plug in the full Primer octicon catalog.
35 type OcticonResolver func(name string) (template.HTML, bool)
36
37 // Options configures a renderer.
38 type Options struct {
39 Octicons OcticonResolver
40 }
41
42 // New parses every page template under tmplFS.
43 //
44 // Naming contract — read this before adding files to internal/web/templates/:
45 //
46 // - **Pages** are .html files whose basename does NOT begin with an
47 // underscore. A page at `repo/tree.html` is registered under the
48 // lookup name `repo/tree`. Render that name from a handler.
49 // - **Partials** are .html files whose basename begins with an
50 // underscore (`_layout.html`, `profile/_tabs.html`). Partials are
51 // parsed into *every* page so the `{{ define "name" }}` blocks
52 // they declare are resolvable from any page template.
53 //
54 // Both pages and partials are picked up recursively. Earlier versions
55 // of this loader walked only the root for partials, which caused a
56 // page that referenced a subdir partial (`profile/_tabs.html`'s
57 // `{{ define "tabs" }}`) to render blank — html/template silently
58 // ignored the missing-template ref at exec time. We now also validate
59 // that every `{{ template "name" }}` action in every parsed page
60 // resolves; an undefined ref fails loud at startup with the offending
61 // page + the missing name.
62 func New(tmplFS fs.FS, opts Options) (*Renderer, error) {
63 var (
64 partialPaths []string
65 pagePaths []string
66 )
67 if err := fs.WalkDir(tmplFS, ".", func(p string, d fs.DirEntry, walkErr error) error {
68 if walkErr != nil {
69 return walkErr
70 }
71 if d.IsDir() || !strings.HasSuffix(p, ".html") {
72 return nil
73 }
74 if strings.HasPrefix(path.Base(p), "_") {
75 partialPaths = append(partialPaths, p)
76 } else {
77 pagePaths = append(pagePaths, p)
78 }
79 return nil
80 }); err != nil {
81 return nil, fmt.Errorf("walk templates: %w", err)
82 }
83 sort.Strings(partialPaths)
84 sort.Strings(pagePaths)
85
86 r := &Renderer{
87 pages: make(map[string]*template.Template, len(pagePaths)),
88 octicon: opts.Octicons,
89 }
90
91 parsePage := func(displayName, primary string) error {
92 t := template.New(path.Base(primary)).Funcs(funcMap(r.octicon))
93 all := append([]string{}, partialPaths...)
94 all = append(all, primary)
95 parsed, err := t.ParseFS(tmplFS, all...)
96 if err != nil {
97 return fmt.Errorf("parse %s: %w", displayName, err)
98 }
99 if missing := undefinedTemplateRefs(parsed); len(missing) > 0 {
100 return fmt.Errorf("page %q references undefined template(s): %s", displayName, strings.Join(missing, ", "))
101 }
102 r.pages[displayName] = parsed
103 return nil
104 }
105
106 for _, page := range pagePaths {
107 if err := parsePage(strings.TrimSuffix(page, ".html"), page); err != nil {
108 return nil, err
109 }
110 }
111 return r, nil
112 }
113
114 // undefinedTemplateRefs returns the names of every `{{ template "name" }}`
115 // action in any parsed sub-template that does not resolve to a defined
116 // template within `t`. Empty slice means every reference is satisfied.
117 //
118 // The standard library does not validate this at parse time — html/template
119 // happily parses a page with a dangling `{{ template "missing" }}` and
120 // silently emits nothing at exec time. This helper closes that hole.
121 func undefinedTemplateRefs(t *template.Template) []string {
122 defined := map[string]bool{}
123 for _, child := range t.Templates() {
124 defined[child.Name()] = true
125 }
126 seen := map[string]bool{}
127 var missing []string
128 for _, child := range t.Templates() {
129 if child.Tree == nil {
130 continue
131 }
132 walkTemplateRefs(child.Tree.Root, func(name string) {
133 if defined[name] || seen[name] {
134 return
135 }
136 seen[name] = true
137 missing = append(missing, name)
138 })
139 }
140 sort.Strings(missing)
141 return missing
142 }
143
144 func walkTemplateRefs(n parse.Node, visit func(name string)) {
145 if n == nil {
146 return
147 }
148 switch x := n.(type) {
149 case *parse.ListNode:
150 if x == nil {
151 return
152 }
153 for _, c := range x.Nodes {
154 walkTemplateRefs(c, visit)
155 }
156 case *parse.IfNode:
157 walkTemplateRefs(x.List, visit)
158 walkTemplateRefs(x.ElseList, visit)
159 case *parse.RangeNode:
160 walkTemplateRefs(x.List, visit)
161 walkTemplateRefs(x.ElseList, visit)
162 case *parse.WithNode:
163 walkTemplateRefs(x.List, visit)
164 walkTemplateRefs(x.ElseList, visit)
165 case *parse.TemplateNode:
166 visit(x.Name)
167 }
168 }
169
170 // Render writes the named page to w using data as the template root context.
171 //
172 // Prefer RenderPage when a *http.Request is in scope — it auto-injects the
173 // viewer (current logged-in user) into map data so partials like _nav.html
174 // can branch on .Viewer without every handler remembering to thread it.
175 //
176 // When w is an http.ResponseWriter, Render sets Content-Type to
177 // `text/html; charset=utf-8` *before* the first body byte. This is
178 // load-bearing: a handler that calls WriteHeader(non-200) without
179 // pre-setting Content-Type otherwise produces a 4xx/5xx response with
180 // no Content-Type, which the browser renders as raw text. Setting it
181 // here makes that class of bug structurally impossible.
182 func (r *Renderer) Render(w io.Writer, name string, data any) error {
183 t, ok := r.pages[name]
184 if !ok {
185 return fmt.Errorf("render: unknown page %q", name)
186 }
187 var buf bytes.Buffer
188 if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
189 return fmt.Errorf("execute %s: %w", name, err)
190 }
191 if rw, ok := w.(http.ResponseWriter); ok {
192 // Header().Set is a no-op once headers have been committed
193 // (e.g. an upstream WriteHeader call). That's the right
194 // behaviour: we don't try to retroactively fix a header
195 // stream that's already on the wire — the caller has to set
196 // Content-Type before WriteHeader in those cases.
197 if rw.Header().Get("Content-Type") == "" {
198 rw.Header().Set("Content-Type", "text/html; charset=utf-8")
199 }
200 }
201 _, err := w.Write(buf.Bytes())
202 return err
203 }
204
205 // RenderFragment executes only a page template's "page" definition. Use it
206 // for HTML fragments returned to JavaScript or htmx; full browser pages should
207 // continue to call Render/RenderPage so nav, footer, and document chrome stay
208 // consistent.
209 func (r *Renderer) RenderFragment(w io.Writer, name string, data any) error {
210 t, ok := r.pages[name]
211 if !ok {
212 return fmt.Errorf("render: unknown page %q", name)
213 }
214 var buf bytes.Buffer
215 if err := t.ExecuteTemplate(&buf, "page", data); err != nil {
216 return fmt.Errorf("execute fragment %s: %w", name, err)
217 }
218 if rw, ok := w.(http.ResponseWriter); ok {
219 if rw.Header().Get("Content-Type") == "" {
220 rw.Header().Set("Content-Type", "text/html; charset=utf-8")
221 }
222 }
223 _, err := w.Write(buf.Bytes())
224 return err
225 }
226
227 // RenderPage is the request-aware Render: when data is a map[string]any, it
228 // injects "Viewer" (from middleware.CurrentUserFromContext) and "CSRFToken"
229 // (the per-request token) if the caller hasn't set them. The nav partial's
230 // sign-out form uses the token, so every layout-rendered page needs it.
231 // Typed-struct callers must include those fields themselves — we don't
232 // reflect-mutate to avoid surprising aliasing.
233 func (r *Renderer) RenderPage(w io.Writer, req *http.Request, name string, data any) error {
234 if m, ok := data.(map[string]any); ok {
235 if _, present := m["Viewer"]; !present {
236 m["Viewer"] = middleware.CurrentUserFromContext(req.Context())
237 }
238 if _, present := m["CSRFToken"]; !present {
239 m["CSRFToken"] = middleware.CSRFTokenForRequest(req)
240 }
241 data = m
242 }
243 return r.Render(w, name, data)
244 }
245
246 // HTTPError writes an error page with the appropriate status code. If the
247 // named error template doesn't exist a plain-text fallback is written.
248 func (r *Renderer) HTTPError(w http.ResponseWriter, req *http.Request, status int, message string) {
249 pageName := errorPageFor(status)
250 w.Header().Set("Content-Type", "text/html; charset=utf-8")
251 w.Header().Set("Cache-Control", "no-store")
252 w.WriteHeader(status)
253
254 data := struct {
255 Title string
256 Status int
257 StatusText string
258 Message string
259 RequestID string
260 }{
261 Title: fmt.Sprintf("%d %s", status, http.StatusText(status)),
262 Status: status,
263 StatusText: http.StatusText(status),
264 Message: message,
265 RequestID: middleware.RequestIDFromContext(req.Context()),
266 }
267 if err := r.Render(w, pageName, data); err != nil {
268 _, _ = fmt.Fprintf(w, "%d %s\n%s\n(request_id=%s)\n",
269 status, http.StatusText(status), message, data.RequestID)
270 }
271 }
272
273 func errorPageFor(status int) string {
274 switch status {
275 case http.StatusForbidden:
276 return "errors/403"
277 case http.StatusNotFound:
278 return "errors/404"
279 case http.StatusTooManyRequests:
280 return "errors/429"
281 default:
282 return "errors/500"
283 }
284 }
285
286 func funcMap(octicon OcticonResolver) template.FuncMap {
287 return template.FuncMap{
288 // safeHTML embeds trusted HTML directly. Callers MUST ensure the
289 // input is server-controlled — never user input. S25's markdown
290 // pipeline supplies the canonical helper for user content.
291 "safeHTML": func(s string) template.HTML {
292 return template.HTML(s) //nolint:gosec // trusted-input only
293 },
294 // relativeTime renders a "2 hours ago" / "yesterday" / "Mar 5"
295 // style label. Used wherever timestamps appear in UI.
296 "relativeTime": relativeTime,
297 // pluralize picks the singular or plural form based on count.
298 "pluralize": func(count int, one, many string) string {
299 if count == 1 {
300 return one
301 }
302 return many
303 },
304 // pathJoin builds URL paths with a single leading slash.
305 "pathJoin": func(parts ...string) string {
306 joined := path.Join(parts...)
307 if !strings.HasPrefix(joined, "/") {
308 return "/" + joined
309 }
310 return joined
311 },
312 // octicon resolves a named octicon to inline SVG. Returns empty
313 // HTML if the icon isn't registered (the caller's template stays
314 // valid but renders nothing — better than a build-time crash).
315 "octicon": func(name string) template.HTML {
316 if octicon == nil {
317 return ""
318 }
319 if html, ok := octicon(name); ok {
320 return html
321 }
322 return ""
323 },
324 // flag reads an optional boolean-ish field from map or struct
325 // template data. Layout-level feature toggles use this so pages
326 // backed by typed structs don't fail when the toggle is absent.
327 "flag": dataFlag,
328 // stringField reads an optional string-ish field from map or
329 // struct template data. Shared document metadata uses this so
330 // typed page-data structs don't all have to define every optional
331 // SEO/social field.
332 "stringField": dataString,
333 // jsField returns only server-marked template.JS values. It is
334 // intentionally stricter than stringField so JSON-LD can be
335 // emitted from trusted marshaled data without giving templates a
336 // generic raw-JS escape hatch for arbitrary strings.
337 "jsField": dataJS,
338 // csrfToken pulls the per-request token from the request context.
339 // Templates use this in <input type="hidden" name="csrf_token">.
340 "csrfToken": middleware.CSRFTokenForRequest,
341 // dict builds a map for partial-template includes that need
342 // multiple named values (idiomatic Go template trick).
343 // add / sub are tiny integer helpers used by pagination
344 // templates (next/prev page links). Templates can't do
345 // arithmetic, so the helpers earn their keep here.
346 "add": func(a, b int) int { return a + b },
347 "sub": func(a, b int) int { return a - b },
348 "dict": func(values ...any) (map[string]any, error) {
349 if len(values)%2 != 0 {
350 return nil, fmt.Errorf("dict: odd number of args")
351 }
352 m := make(map[string]any, len(values)/2)
353 for i := 0; i < len(values); i += 2 {
354 key, ok := values[i].(string)
355 if !ok {
356 return nil, fmt.Errorf("dict: non-string key at %d", i)
357 }
358 m[key] = values[i+1]
359 }
360 return m, nil
361 },
362 // verifiedReasonMessage maps a sigverify.Reason string to a
363 // short user-facing explanation. Used by the _verified_badge
364 // partial's popover. Accepts the Reason as a string so the
365 // template doesn't need a type assertion.
366 "verifiedReasonMessage": verifiedReasonMessage,
367 }
368 }
369
370 // verifiedReasonMessage returns the popover body text for each
371 // non-valid verification reason. "unsigned" never reaches the
372 // popover (the badge isn't rendered at all in that case) so it's
373 // omitted. The strings are user-facing copy; keep them short and
374 // concrete.
375 func verifiedReasonMessage(reason any) string {
376 r, ok := reason.(string)
377 if !ok {
378 // The template might pass a typed sigverify.Reason; Stringer
379 // would surface the underlying string. Try fmt-style fallback.
380 r = fmt.Sprintf("%s", reason)
381 }
382 switch r {
383 case "unknown_key":
384 return "This commit was signed but the public key isn't registered with shithub."
385 case "bad_email":
386 return "The commit signature doesn't match any of the registered user's emails."
387 case "unverified_email":
388 return "The commit signature matches an email the user hasn't verified."
389 case "malformed_signature":
390 return "The commit's signature couldn't be parsed."
391 case "invalid":
392 return "The commit's signature failed cryptographic verification."
393 case "expired_key":
394 return "The signing key was expired when this commit was made."
395 case "not_signing_key":
396 return "The key that signed this commit isn't authorized for signing."
397 default:
398 return "This commit's signature couldn't be verified."
399 }
400 }
401
402 func dataFlag(data any, name string) bool {
403 field, ok := dataField(data, name)
404 if !ok {
405 return false
406 }
407 return truthyValue(field)
408 }
409
410 func dataString(data any, name string) string {
411 field, ok := dataField(data, name)
412 if !ok {
413 return ""
414 }
415 for field.Kind() == reflect.Pointer || field.Kind() == reflect.Interface {
416 if field.IsNil() {
417 return ""
418 }
419 field = field.Elem()
420 }
421 switch field.Kind() {
422 case reflect.String:
423 return field.String()
424 default:
425 if field.CanInterface() {
426 return fmt.Sprint(field.Interface())
427 }
428 return ""
429 }
430 }
431
432 func dataJS(data any, name string) template.JS {
433 field, ok := dataField(data, name)
434 if !ok {
435 return ""
436 }
437 if field.CanInterface() {
438 switch v := field.Interface().(type) {
439 case template.JS:
440 return v
441 case *template.JS:
442 if v != nil {
443 return *v
444 }
445 }
446 }
447 for field.Kind() == reflect.Pointer || field.Kind() == reflect.Interface {
448 if field.IsNil() {
449 return ""
450 }
451 field = field.Elem()
452 if field.CanInterface() {
453 if v, ok := field.Interface().(template.JS); ok {
454 return v
455 }
456 }
457 }
458 return ""
459 }
460
461 func dataField(data any, name string) (reflect.Value, bool) {
462 v := reflect.ValueOf(data)
463 if !v.IsValid() {
464 return reflect.Value{}, false
465 }
466 for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
467 if v.IsNil() {
468 return reflect.Value{}, false
469 }
470 v = v.Elem()
471 }
472 switch v.Kind() {
473 case reflect.Map:
474 if v.Type().Key().Kind() != reflect.String {
475 return reflect.Value{}, false
476 }
477 field := v.MapIndex(reflect.ValueOf(name))
478 if !field.IsValid() {
479 return reflect.Value{}, false
480 }
481 return field, true
482 case reflect.Struct:
483 field := v.FieldByName(name)
484 if !field.IsValid() || !field.CanInterface() {
485 return reflect.Value{}, false
486 }
487 return field, true
488 default:
489 return reflect.Value{}, false
490 }
491 }
492
493 func truthyValue(v reflect.Value) bool {
494 if !v.IsValid() {
495 return false
496 }
497 for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
498 if v.IsNil() {
499 return false
500 }
501 v = v.Elem()
502 }
503 switch v.Kind() {
504 case reflect.Bool:
505 return v.Bool()
506 case reflect.String:
507 return v.String() != ""
508 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
509 return v.Int() != 0
510 case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
511 return v.Uint() != 0
512 default:
513 return !v.IsZero()
514 }
515 }
516
517 // relativeTime returns a human-readable relative-time string. The intent is
518 // to read naturally; absolute precision below the level of "minutes" isn't
519 // useful for UI labels.
520 func relativeTime(t time.Time) string {
521 if t.IsZero() {
522 return ""
523 }
524 d := time.Since(t)
525 switch {
526 case d < 0:
527 // Future timestamps are uncommon; render as absolute.
528 return t.UTC().Format("Jan 2, 2006")
529 case d < time.Minute:
530 return "just now"
531 case d < time.Hour:
532 m := int(d / time.Minute)
533 return fmt.Sprintf("%d minute%s ago", m, plural(m))
534 case d < 24*time.Hour:
535 h := int(d / time.Hour)
536 return fmt.Sprintf("%d hour%s ago", h, plural(h))
537 case d < 7*24*time.Hour:
538 days := int(d / (24 * time.Hour))
539 if days == 1 {
540 return "yesterday"
541 }
542 return fmt.Sprintf("%d days ago", days)
543 case d < 30*24*time.Hour:
544 w := int(d / (7 * 24 * time.Hour))
545 return fmt.Sprintf("%d week%s ago", w, plural(w))
546 case d < 365*24*time.Hour:
547 mo := int(d / (30 * 24 * time.Hour))
548 return fmt.Sprintf("%d month%s ago", mo, plural(mo))
549 default:
550 return t.UTC().Format("Jan 2, 2006")
551 }
552 }
553
554 func plural(n int) string {
555 if n == 1 {
556 return ""
557 }
558 return "s"
559 }
560