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// blog.bythewood.me: a Markdown blog with no database. Posts are files under
2// content/posts, parsed once at startup and served from memory.
3package main
4
5import (
6 "embed"
7 "flag"
8 "fmt"
9 "io/fs"
10 "log/slog"
11 "net/http"
12 "os"
13 "strings"
14 "time"
15
16 "blog.bythewood.me/web"
17)
18
19// Templates are source, so they always ship inside the binary.
20//
21//go:embed templates
22var templateFS embed.FS
23
24// The posts too, which is what makes the release binary the whole blog. `all:`
25// keeps dot-prefixed files under content/images, which embed would skip.
26//
27//go:embed all:content
28var contentFS embed.FS
29
30const listenAddr = ":8000"
31
32// Paths resolve against the working directory in dev, and are absolute in the
33// image.
34func dir(env, fallback string) string {
35 if v := os.Getenv(env); v != "" {
36 return v
37 }
38 return fallback
39}
40
41// 'unsafe-inline' is needed in script-src for the inline analytics snippet and
42// in style-src for Bootstrap's style attributes. Neither can drop it without a
43// markup change.
44func csp() string {
45 return strings.Join([]string{
46 "default-src 'self'",
47 "script-src 'self' 'unsafe-inline' https://analytics.bythewood.me",
48 "style-src 'self' 'unsafe-inline'",
49 "img-src 'self' data:",
50 "font-src 'self'",
51 "connect-src 'self' https://analytics.bythewood.me",
52 "base-uri 'self'",
53 "form-action 'self'",
54 "frame-ancestors 'self'",
55 }, "; ")
56}
57
58func main() {
59 web.SetupLogging()
60
61 // The build-time modes live on the site binary so the post loader and the
62 // Typst walker have one copy.
63 pdfsOut := flag.String("pdfs", "", "compile every post to a PDF in this directory, then exit")
64 ogOut := flag.String("og", "", "compile every social card to a PNG in this directory")
65 typstRoot := flag.String("typst-root", ".", "directory Typst resolves absolute paths against")
66 typstFonts := flag.String("typst-fonts", "", "directory of extra font files for Typst")
67 // The container HEALTHCHECK runs this: a FROM scratch image has no shell
68 // for a check to call, so the binary probes itself.
69 healthcheck := flag.Bool("healthcheck", false, "probe a running server on this host and exit")
70 flag.Parse()
71
72 if *healthcheck {
73 if err := web.HealthCheck("http://127.0.0.1:8000/healthz", 3*time.Second); err != nil {
74 slog.Info(fmt.Sprintf("healthcheck: %v", err))
75 os.Exit(1)
76 }
77 return
78 }
79
80 // Tees stdout records to logging.bythewood.me; see web/shipper.go. It goes
81 // after the healthcheck branch so a HEALTHCHECK does not start a queue it
82 // will never flush.
83 shipper := web.ShipLogs("blog", web.HTTPSink())
84 defer shipper.Close()
85
86 content, err := fs.Sub(contentFS, "content")
87 if err != nil {
88 slog.Error("startup failed", slog.Any("err", err))
89 os.Exit(1)
90 }
91
92 lib, err := LoadLibrary(content)
93 if err != nil {
94 slog.Error(fmt.Sprintf("load posts: %v", err))
95 os.Exit(1)
96 }
97 slog.Info(fmt.Sprintf("loaded %d posts", len(lib.All())))
98
99 // Either mode ends the process: this binary serves the site or compiles
100 // assets, never both.
101 if *pdfsOut != "" || *ogOut != "" {
102 if *pdfsOut != "" {
103 if err := GeneratePDFs(lib, *typstRoot, *typstFonts, *pdfsOut); err != nil {
104 slog.Error(fmt.Sprintf("generate pdfs: %v", err))
105 os.Exit(1)
106 }
107 }
108 if *ogOut != "" {
109 if err := GenerateOGCards(lib, *typstRoot, *typstFonts, *ogOut); err != nil {
110 slog.Error(fmt.Sprintf("generate og cards: %v", err))
111 os.Exit(1)
112 }
113 }
114 return
115 }
116
117 dist := distFS()
118
119 assets, err := web.LoadAssets(dist)
120 if err != nil {
121 slog.Error("startup failed", slog.Any("err", err))
122 os.Exit(1)
123 }
124
125 templates, err := fs.Sub(templateFS, "templates")
126 if err != nil {
127 slog.Error("startup failed", slog.Any("err", err))
128 os.Exit(1)
129 }
130
131 renderer, err := web.NewRenderer(
132 templates,
133 templateFuncs,
134 []string{"base.html", "partials.html"},
135 []string{"home.html", "blog.html", "post.html", "search.html", "notfound.html"},
136 )
137 if err != nil {
138 slog.Error("startup failed", slog.Any("err", err))
139 os.Exit(1)
140 }
141
142 s := &site{
143 renderer: renderer,
144 lib: lib,
145 content: content,
146 pdfs: pdfsFS(),
147 og: ogFS(),
148 script: assets.Script("index.js"),
149 styles: assets.Styles("index.js"),
150 }
151
152 mux := http.NewServeMux()
153
154 mux.HandleFunc("GET /{$}", s.home)
155 mux.HandleFunc("GET /blog/{$}", s.blogIndex)
156 mux.HandleFunc("GET /blog/tag/{tag}/{$}", s.blogByTag)
157 mux.HandleFunc("GET /blog/year/{year}/{$}", s.blogByYear)
158
159 mux.HandleFunc("GET /posts/{slug}/{$}", s.post)
160 mux.HandleFunc("GET /posts/{slug}/pdf/{$}", s.postPDF)
161 mux.HandleFunc("GET /posts/{slug}/md/{$}", s.postMarkdown)
162
163 // The export forms are one {slug}/{format} pattern because "/blog/{slug}/pdf/"
164 // and "/blog/tag/{tag}/" overlap with neither more specific, which the mux
165 // panics on at registration.
166 mux.HandleFunc("GET /blog/{slug}/{$}", s.redirectPost)
167 mux.HandleFunc("GET /blog/{slug}/{format}/{$}", s.redirectPostFormat)
168
169 // Every route above ends in a slash; these cover the slashless forms.
170 mux.HandleFunc("GET /blog", redirectSlash)
171 mux.HandleFunc("GET /search", redirectSlash)
172
173 mux.HandleFunc("GET /search/{$}", s.search)
174 mux.HandleFunc("GET /search/live/{$}", s.searchLive)
175
176 // Read by isaacbythewood.com's home page for its promo slot.
177 mux.HandleFunc("GET /latest.json", s.latestJSON)
178
179 // Cards are compiled at build time, and change only with a post's title
180 // or tags.
181 mux.Handle("GET /og/", http.StripPrefix("/og/",
182 cacheControl("public, max-age=86400", http.FileServer(http.FS(s.og)))))
183 mux.HandleFunc("GET /favicon.ico", favicon)
184 mux.HandleFunc("GET /favicon.svg", favicon)
185 mux.HandleFunc("GET /robots.txt", robots)
186 mux.HandleFunc("GET /sitemap.xml", s.sitemap)
187 mux.HandleFunc("GET "+feedPath, s.feed)
188 // The two paths readers guess at.
189 mux.HandleFunc("GET /feed", redirectFeed)
190 mux.HandleFunc("GET /rss.xml", redirectFeed)
191
192 mux.Handle("GET /static/", web.Static(dist, assets))
193
194 // Post images keep their real filenames, so replacing one has to become
195 // visible without a hash change; no immutable year here.
196 contentImages, err := fs.Sub(content, "images")
197 if err != nil {
198 slog.Error("startup failed", slog.Any("err", err))
199 os.Exit(1)
200 }
201 images := http.StripPrefix("/content/images/",
202 cacheControl("public, max-age=86400",
203 http.FileServer(http.FS(contentImages))))
204 mux.Handle("GET /content/images/", images)
205
206 // Wagtail rendition URLs, still arriving from feeds and search indexes.
207 s.mediaIdx = newMediaIndex(contentImages)
208 mux.HandleFunc("GET /media/", s.media)
209
210 // Not logged: a line per probe would bury real traffic.
211 mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
212 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
213 // EdgeCache fills in the site policy whenever a handler sets no
214 // Cache-Control of its own, so saying nothing here means the edge
215 // answers a liveness check out of cache long after this process has
216 // stopped serving.
217 w.Header().Set("Cache-Control", "no-store")
218 _, _ = w.Write([]byte("ok\n"))
219 })
220
221 // "/" with no {$} is Go's catch-all.
222 mux.HandleFunc("GET /", s.notFound)
223
224 handler := web.Chain(mux,
225 web.Recovered,
226 web.Logged,
227 web.SecurityHeaders(csp()),
228 // No s-maxage: per RFC 9111 it carries proxy-revalidate semantics, so
229 // Cloudflare disables stale-while-revalidate and stale-if-error both.
230 web.EdgeCache("public, max-age=300, "+
231 "stale-while-revalidate=86400, stale-if-error=604800"),
232 )
233
234 slog.Info(fmt.Sprintf("blog.bythewood.me serving %s (staging=%t)", baseURL, Staging))
235 if err := web.Serve(listenAddr, handler); err != nil {
236 slog.Error("startup failed", slog.Any("err", err))
237 os.Exit(1)
238 }
239}
240
241func cacheControl(value string, next http.Handler) http.Handler {
242 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
243 w.Header().Set("Cache-Control", value)
244 next.ServeHTTP(w, r)
245 })
246}