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
1// isaacbythewood.com: server-rendered html/template with a Vite-built frontend,
2// one static binary, no database and no third party Go dependency.
3package main
4
5import (
6 "context"
7 "embed"
8 "flag"
9 "fmt"
10 "io"
11 "io/fs"
12 "log/slog"
13 "net/http"
14 "os"
15 "path/filepath"
16 "strings"
17 "time"
18
19 "isaacbythewood.com/web"
20)
21
22// Templates are source and ship in the binary unconditionally. The Vite bundle
23// is build output, so it ships only in a release build, see assets_embed.go.
24//
25//go:embed templates
26var templateFS embed.FS
27
28const listenAddr = ":8000"
29
30// 'unsafe-inline' in script-src is there for the analytics collector loader,
31// which is a literal inline script. No 'unsafe-eval' anywhere.
32func csp() string {
33 return strings.Join([]string{
34 "default-src 'self'",
35 "script-src 'self' 'unsafe-inline' https://analytics.bythewood.me",
36 "style-src 'self' 'unsafe-inline'",
37 "img-src 'self' data: blob:",
38 "font-src 'self' data:",
39 "connect-src 'self' https://analytics.bythewood.me",
40 "manifest-src 'self'",
41 "base-uri 'self'",
42 "form-action 'self'",
43 "frame-ancestors 'self'",
44 }, "; ")
45}
46
47func main() {
48 web.SetupLogging()
49
50 // The container HEALTHCHECK runs this: a FROM scratch image has no shell
51 // for a check to call, so the binary probes itself.
52 healthcheck := flag.Bool("healthcheck", false, "probe a running server on this host and exit")
53 flag.Parse()
54
55 if *healthcheck {
56 if err := web.HealthCheck("http://127.0.0.1:8000/healthz", 3*time.Second); err != nil {
57 slog.Info(fmt.Sprintf("healthcheck: %v", err))
58 os.Exit(1)
59 }
60 return
61 }
62
63 // Below the healthcheck branch, so a HEALTHCHECK invocation does not start
64 // a queue it will never flush.
65 shipper := web.ShipLogs("isaacbythewood", web.HTTPSink())
66 defer shipper.Close()
67
68 dist := distFS()
69
70 assets, err := web.LoadAssets(dist)
71 if err != nil {
72 slog.Error("startup failed", slog.Any("err", err))
73 os.Exit(1)
74 }
75
76 templates, err := fs.Sub(templateFS, "templates")
77 if err != nil {
78 slog.Error("startup failed", slog.Any("err", err))
79 os.Exit(1)
80 }
81
82 renderer, err := web.NewRenderer(
83 templates,
84 nil,
85 []string{"base.html"},
86 []string{"index.html", "about.html", "code.html", "art.html", "contact.html", "notfound.html"},
87 )
88 if err != nil {
89 slog.Error("startup failed", slog.Any("err", err))
90 os.Exit(1)
91 }
92
93 commits := NewCommitCache()
94 ctx, cancel := context.WithCancel(context.Background())
95 defer cancel()
96
97 // Live cards only. An archived repo does not change, so polling it spends
98 // the rate limit re-reading the same commit.
99 targets := make([]CommitTarget, 0, len(sites)+len(projects))
100 for _, live := range sites {
101 targets = append(targets, live.CommitTarget())
102 }
103 for _, project := range projects {
104 if !project.Archived {
105 targets = append(targets, CommitTarget{Key: project.Slug, Repo: project.Slug})
106 }
107 }
108 commits.Start(ctx, targets)
109
110 latest := NewLatestCache(blogLatestSources)
111 latest.Start(ctx)
112
113 s := &site{
114 renderer: renderer,
115 commits: commits,
116 latest: latest,
117 script: assets.Script("index.js"),
118 styles: assets.Styles("index.js"),
119 }
120
121 mux := http.NewServeMux()
122 mux.HandleFunc("GET /", s.home)
123 mux.HandleFunc("GET /about", s.about)
124 mux.HandleFunc("GET /code", s.code)
125 mux.HandleFunc("GET /art", s.art)
126 mux.HandleFunc("GET /contact", s.contact)
127
128 mux.HandleFunc("GET /robots.txt", s.robots)
129 mux.HandleFunc("GET /sitemap.xml", s.sitemap)
130 mux.HandleFunc("GET /manifest.json", s.manifest)
131
132 mux.Handle("GET /favicon.ico", rootAsset(dist, "favicon.ico"))
133 mux.Handle("GET /favicon.svg", rootAsset(dist, "favicon.svg"))
134
135 // Not logged, or a line per probe would bury the real traffic.
136 mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
137 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
138 // EdgeCache fills in the site policy whenever a handler sets no
139 // Cache-Control of its own, so saying nothing here means the edge
140 // answers a liveness check out of cache long after this process has
141 // stopped serving.
142 w.Header().Set("Cache-Control", "no-store")
143 _, _ = w.Write([]byte("ok\n"))
144 })
145
146 mux.Handle("GET /static/", web.Static(dist, assets))
147
148 // Next.js image optimiser URLs, still arriving from search indexes.
149 s.nextImages = newNextImageIndex(dist)
150 mux.HandleFunc("GET /_next/image", s.nextImage)
151
152 handler := web.Chain(mux,
153 web.Recovered,
154 web.Logged,
155 web.SecurityHeaders(csp()),
156 // No s-maxage. Per RFC 9111 it carries proxy-revalidate semantics, so
157 // Cloudflare reads it as "never serve stale without asking first" and
158 // disables stale-while-revalidate and stale-if-error both.
159 web.EdgeCache("public, max-age=300, "+
160 "stale-while-revalidate=86400, stale-if-error=604800"),
161 )
162
163 slog.Info(fmt.Sprintf("isaacbythewood.com serving %s (staging=%t)", baseURL, Staging))
164 if err := web.Serve(listenAddr, handler); err != nil {
165 slog.Error("startup failed", slog.Any("err", err))
166 os.Exit(1)
167 }
168}
169
170func rootAsset(dist fs.FS, name string) http.Handler {
171 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172 f, err := dist.Open(name)
173 if err != nil {
174 http.NotFound(w, r)
175 return
176 }
177 defer f.Close()
178
179 info, err := f.Stat()
180 if err != nil {
181 http.NotFound(w, r)
182 return
183 }
184 seeker, ok := f.(io.ReadSeeker)
185 if !ok {
186 http.NotFound(w, r)
187 return
188 }
189
190 // Unhashed, so a short cache rather than an immutable one.
191 w.Header().Set("Cache-Control", "public, max-age=3600")
192 http.ServeContent(w, r, filepath.Base(name), info.ModTime(), seeker)
193 })
194}