repos
/ orchard main

orchard

mirror

Every 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

2.6 KB · 95 lines · Go Raw History
 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}