orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "bytes"
5 "embed"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "strings"
10 texttemplate "text/template"
11)
12
13// text/template, not html/template: HTML escaping would turn a Typst "#" or a
14// Markdown "*" into an entity. typstMD and typstStr do the escaping that matters.
15//
16//go:embed reports
17var reportFS embed.FS
18
19var reportFuncs = texttemplate.FuncMap{
20 "typstMD": typstMD,
21 "typstStr": typstStr,
22 "pct": pct,
23 "add": func(a, b int) int { return a + b },
24}
25
26var reportTemplates = texttemplate.Must(
27 texttemplate.New("reports").Funcs(reportFuncs).ParseFS(reportFS, "reports/*"))
28
29// typstRoot is the directory Typst resolves absolute paths against, and the
30// boundary of what a compile is allowed to read.
31func typstRoot() string { return dir("SITE_ROOT", ".") }
32
33func (s *site) renderReport(w http.ResponseWriter, r *http.Request, format, propertyName string, data PageData) {
34 name := "report.typ"
35 if format == "md" {
36 name = "report.md"
37 }
38
39 var buf bytes.Buffer
40 if err := reportTemplates.ExecuteTemplate(&buf, name, data); err != nil {
41 slog.Info(fmt.Sprintf("render %s: %v", name, err))
42 http.Error(w, "report error", http.StatusInternalServerError)
43 return
44 }
45
46 filename := asciiFilename(propertyName)
47
48 if format == "md" {
49 w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
50 w.Header().Set("Content-Disposition", `inline; filename="`+filename+`.md"`)
51 _, _ = buf.WriteTo(w)
52 return
53 }
54
55 pdf, err := s.typst.Render(r.Context(), typstRoot(), buf.String())
56 if err != nil {
57 // A missing binary is the normal state of a dev checkout.
58 if err == ErrTypstMissing {
59 slog.Info(fmt.Sprintf("pdf report: %v (install typst, or use ?report=md)", err))
60 http.Error(w, "pdf export unavailable", http.StatusServiceUnavailable)
61 return
62 }
63 slog.Info(fmt.Sprintf("pdf report: %v", err))
64 http.Error(w, "report error", http.StatusInternalServerError)
65 return
66 }
67
68 w.Header().Set("Content-Type", "application/pdf")
69 w.Header().Set("Content-Disposition", `inline; filename="`+filename+`.pdf"`)
70 _, _ = w.Write(pdf)
71}
72
73// asciiFilename reduces a name to bytes a Content-Disposition header can carry
74// literally; Go will send an unencoded non-ASCII header that clients ignore.
75func asciiFilename(name string) string {
76 var b strings.Builder
77 b.Grow(len(name))
78 for _, r := range name {
79 switch {
80 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
81 b.WriteRune(r)
82 case r == ' ', r == '.', r == '-', r == '_':
83 b.WriteRune(r)
84 default:
85 b.WriteByte('_')
86 }
87 }
88 // A leading or trailing dot makes a hidden or extensionless file.
89 out := strings.Trim(strings.TrimSpace(b.String()), ".")
90 if out == "" {
91 return "report"
92 }
93 return out
94}