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.9 KB · 102 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	"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}