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 "time"
12)
13
14// Report templates are text: html/template would escape a Typst "#" or a
15// Markdown "*" into an entity. typstMD and typstStr do the escaping instead.
16//
17//go:embed reports
18var reportFS embed.FS
19
20var reportFuncs = texttemplate.FuncMap{
21 "typstMD": typstMD,
22 "typstStr": typstStr,
23 "upper": strings.ToUpper,
24 "naturaltime": naturalTime,
25 "num": formatNum,
26 "msSavings": msSavings,
27 "urlPath": urlPath,
28 "pct": pct,
29 "pct1": pct1,
30 "add": func(a, b int) int { return a + b },
31}
32
33var reportTemplates = texttemplate.Must(
34 texttemplate.New("reports").Funcs(reportFuncs).ParseFS(reportFS, "reports/*"))
35
36// typstRoot is the directory Typst resolves absolute paths against, bounding
37// what a compile is allowed to read.
38func typstRoot() string { return dir("SITE_ROOT", ".") }
39
40func (s *site) renderReport(w http.ResponseWriter, r *http.Request, format, propertyName string, data PageData) {
41 name := "report.typ"
42 if format == "md" {
43 name = "report.md"
44 }
45
46 var buf bytes.Buffer
47 if err := reportTemplates.ExecuteTemplate(&buf, name, data); err != nil {
48 slog.Info(fmt.Sprintf("render %s: %v", name, err))
49 http.Error(w, "report error", http.StatusInternalServerError)
50 return
51 }
52
53 filename := asciiFilename(propertyName) + "-" + time.Now().Format("2006-01-02")
54
55 if format == "md" {
56 w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
57 w.Header().Set("Content-Disposition", `inline; filename="`+filename+`.md"`)
58 _, _ = buf.WriteTo(w)
59 return
60 }
61
62 pdf, err := s.typst.Render(r.Context(), typstRoot(), buf.String())
63 if err != nil {
64 // A missing binary is the normal state of a dev checkout, not a fault.
65 if err == ErrTypstMissing {
66 slog.Info(fmt.Sprintf("pdf report: %v (install typst, or use ?report=md)", err))
67 http.Error(w, "pdf export unavailable", http.StatusServiceUnavailable)
68 return
69 }
70 slog.Info(fmt.Sprintf("pdf report: %v", err))
71 http.Error(w, "report error", http.StatusInternalServerError)
72 return
73 }
74
75 w.Header().Set("Content-Type", "application/pdf")
76 w.Header().Set("Content-Disposition", `inline; filename="`+filename+`.pdf"`)
77 _, _ = w.Write(pdf)
78}
79
80// asciiFilename reduces a property name to bytes a Content-Disposition value can
81// carry literally; Go will send a non-ASCII one the client silently ignores.
82func asciiFilename(name string) string {
83 var b strings.Builder
84 b.Grow(len(name))
85 for _, r := range name {
86 switch {
87 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
88 b.WriteRune(r)
89 case r == ' ', r == '.', r == '-', r == '_':
90 b.WriteRune(r)
91 default:
92 b.WriteByte('_')
93 }
94 }
95 // A leading or trailing dot makes a hidden or extensionless file.
96 out := strings.Trim(strings.TrimSpace(b.String()), ".")
97 if out == "" {
98 return "report"
99 }
100 return out
101}