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 "encoding/json"
6 "html/template"
7 "io/fs"
8 "math/rand/v2"
9 "net/http"
10 "net/url"
11 "path"
12 "strings"
13 "time"
14
15 "blog.bythewood.me/web"
16)
17
18// Crumb is one link in the breadcrumb bar. An empty URL is the current page,
19// rendered as plain text.
20type Crumb struct {
21 Title string
22 URL string
23}
24
25// PageData is everything base.html and one page template need; the fields for
26// pages other than the one rendering are zero.
27type PageData struct {
28 Title string
29 Description string
30 Canonical string
31 Staging bool
32 Analytics bool
33 AnalyticsID string
34 Year int
35
36 Script string
37 Styles []string
38
39 NavTags []TagEntry
40 ActiveTag string
41 Breadcrumbs []Crumb
42
43 // Social meta is opt in; the home and 404 pages do without it.
44 ShowSocial bool
45 OGImage string
46
47 // The site graph by default, overridden with the article graph on a post.
48 JSONLD template.JS
49
50 FooterProjects []FooterLink
51 FooterLinks []FooterLink
52 SourceURL string
53
54 // Search box contents, so a query survives the round trip.
55 Query string
56
57 // Page specific; a nil slice renders nothing.
58 Latest *Post
59 Posts []*Post
60 ExtraPosts []*Post
61 RandomPosts []*Post
62 Post *Post
63 Related []*Post
64 Tags []TagEntry
65 Years []string
66 ActiveYear string
67 Heading string
68 Kicker string
69 NoResults bool
70}
71
72type site struct {
73 renderer *web.Renderer
74 lib *Library
75 // Filesystems rather than paths: in a release build these live inside the
76 // executable and there is no path to hand out.
77 content fs.FS
78 pdfs fs.FS
79 og fs.FS
80 // Resolves Wagtail era /media/ URLs onto the files that serve them now.
81 mediaIdx *mediaIndex
82 script string
83 styles []string
84}
85
86// page builds the shared half of PageData.
87func (s *site) page(r *http.Request, title, description string) PageData {
88 _, tags, _ := s.lib.Published()
89 return PageData{
90 Title: title,
91 Description: description,
92 Canonical: baseURL + r.URL.Path,
93 Staging: Staging,
94 Analytics: !Staging,
95 AnalyticsID: analyticsID,
96 Year: time.Now().Year(),
97 Script: s.script,
98 Styles: s.styles,
99 NavTags: tags,
100 FooterProjects: footerProjects,
101 FooterLinks: footerLinks,
102 SourceURL: sourceURL,
103 OGImage: baseURL + "/og/" + ogSiteCard + ".png",
104 JSONLD: siteGraph(),
105 }
106}
107
108func (s *site) home(w http.ResponseWriter, r *http.Request) {
109 published, _, _ := s.lib.Published()
110
111 data := s.page(r, siteName,
112 "Writing about webdev, infrastructure, security, and tooling by Isaac Bythewood, "+
113 "a Senior Solutions Architect in Elkin, NC.")
114 data.ShowSocial = true
115 data.Heading = siteName
116
117 if len(published) > 0 {
118 data.Latest = published[0]
119 data.RandomPosts = pickRandom(published[1:], 3)
120 }
121
122 s.renderer.Render(w, http.StatusOK, "home.html", data)
123}
124
125func (s *site) blogIndex(w http.ResponseWriter, r *http.Request) {
126 published, tags, years := s.lib.Published()
127
128 data := s.page(r, "Blog",
129 "Posts on webdev, coding, security, and sysadmin by Isaac Bythewood.")
130 data.ShowSocial = true
131 data.Heading = "Blog"
132 data.Breadcrumbs = []Crumb{{Title: "Home", URL: "/"}, {Title: "Blog"}}
133 data.Posts = published
134 data.Tags = tags
135 data.Years = years
136
137 s.renderer.Render(w, http.StatusOK, "blog.html", data)
138}
139
140func (s *site) blogByTag(w http.ResponseWriter, r *http.Request) {
141 tag, err := url.PathUnescape(r.PathValue("tag"))
142 if err != nil {
143 s.notFound(w, r)
144 return
145 }
146
147 published, tags, years := s.lib.Published()
148 matched, others := byTag(published, tag)
149 if len(matched) == 0 {
150 s.notFound(w, r)
151 return
152 }
153
154 display := titleCase(tag)
155 data := s.page(r, "Blog - Tag - "+display,
156 "Posts on webdev, coding, security, and sysadmin by Isaac Bythewood. "+
157 "Currently filtered by tag "+display+".")
158 data.ShowSocial = true
159 data.Heading = "Blog"
160 data.Kicker = display
161 data.Breadcrumbs = []Crumb{{Title: "Home", URL: "/"}, {Title: "Blog", URL: "/blog/"}, {Title: display}}
162 data.Posts = matched
163 data.Tags = tags
164 data.Years = years
165 data.ActiveTag = tag
166
167 // A thin filter page offers a few posts from outside the filter.
168 if len(matched) < 5 {
169 data.ExtraPosts = take(others, 4)
170 }
171
172 s.renderer.Render(w, http.StatusOK, "blog.html", data)
173}
174
175func (s *site) blogByYear(w http.ResponseWriter, r *http.Request) {
176 year := r.PathValue("year")
177
178 published, tags, years := s.lib.Published()
179 matched, others := byYear(published, year)
180 if len(matched) == 0 {
181 s.notFound(w, r)
182 return
183 }
184
185 data := s.page(r, "Blog - Year - "+year,
186 "Posts on webdev, coding, security, and sysadmin by Isaac Bythewood. "+
187 "Currently filtered by year "+year+".")
188 data.ShowSocial = true
189 data.Heading = "Blog"
190 data.Kicker = year
191 data.Breadcrumbs = []Crumb{{Title: "Home", URL: "/"}, {Title: "Blog", URL: "/blog/"}, {Title: year}}
192 data.Posts = matched
193 data.Tags = tags
194 data.Years = years
195 data.ActiveYear = year
196
197 if len(matched) < 5 {
198 data.ExtraPosts = take(others, 4)
199 }
200
201 s.renderer.Render(w, http.StatusOK, "blog.html", data)
202}
203
204func (s *site) post(w http.ResponseWriter, r *http.Request) {
205 post, ok := s.lookup(r)
206 if !ok {
207 s.notFound(w, r)
208 return
209 }
210
211 published, _, _ := s.lib.Published()
212
213 data := s.page(r, post.Title, post.Description)
214 data.ShowSocial = true
215 data.OGImage = post.OGImage()
216 data.JSONLD = postGraph(post)
217 data.Breadcrumbs = []Crumb{{Title: "Home", URL: "/"}, {Title: "Blog", URL: "/blog/"}, {Title: post.Title}}
218 data.Post = post
219 data.Related = related(post, published, 3)
220
221 s.renderer.Render(w, http.StatusOK, "post.html", data)
222}
223
224// postPDF serves the PDF built during the image build. It is a handler and not
225// a static mount so an unpublished post stays a 404.
226func (s *site) postPDF(w http.ResponseWriter, r *http.Request) {
227 post, ok := s.lookup(r)
228 if !ok {
229 s.notFound(w, r)
230 return
231 }
232
233 raw, err := fs.ReadFile(s.pdfs, post.Slug+".pdf")
234 if err != nil {
235 // Reachable when the build skipped PDF generation, the normal state
236 // of a checkout without typst.
237 http.Error(w, "pdf not available", http.StatusNotFound)
238 return
239 }
240
241 w.Header().Set("Content-Type", "application/pdf")
242 w.Header().Set("Content-Disposition", `inline; filename="`+post.Slug+`.pdf"`)
243 w.Header().Set("Cache-Control", "public, max-age=3600")
244 // A zero modtime sends no Last-Modified, since an embedded file has no
245 // meaningful timestamp. ServeContent still serves ranges.
246 http.ServeContent(w, r, post.Slug+".pdf", time.Time{}, bytes.NewReader(raw))
247}
248
249// postMarkdown hands back the source file.
250func (s *site) postMarkdown(w http.ResponseWriter, r *http.Request) {
251 post, ok := s.lookup(r)
252 if !ok {
253 s.notFound(w, r)
254 return
255 }
256
257 raw, err := fs.ReadFile(s.content, path.Join("posts", post.Filename))
258 if err != nil {
259 http.Error(w, "not found", http.StatusNotFound)
260 return
261 }
262
263 w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
264 w.Header().Set("Content-Disposition", `inline; filename="`+post.Slug+`.md"`)
265 w.Header().Set("Cache-Control", "public, max-age=3600")
266 _, _ = w.Write(raw)
267}
268
269func (s *site) lookup(r *http.Request) (*Post, bool) {
270 slug, err := url.PathUnescape(r.PathValue("slug"))
271 if err != nil {
272 return nil, false
273 }
274 return s.lib.Lookup(slug)
275}
276
277func (s *site) notFound(w http.ResponseWriter, r *http.Request) {
278 data := s.page(r, "404", "That means the page you are looking for doesn't exist.")
279 s.renderer.Render(w, http.StatusNotFound, "notfound.html", data)
280}
281
282// redirectPost keeps the old /blog/<slug>/ URLs alive.
283func (s *site) redirectPost(w http.ResponseWriter, r *http.Request) {
284 http.Redirect(w, r, "/posts/"+r.PathValue("slug")+"/", http.StatusMovedPermanently)
285}
286
287// redirectPostFormat handles the old export URLs. Only pdf and md exist, so
288// anything else 404s here rather than redirecting to a 404.
289func (s *site) redirectPostFormat(w http.ResponseWriter, r *http.Request) {
290 format := r.PathValue("format")
291 if format != "pdf" && format != "md" {
292 s.notFound(w, r)
293 return
294 }
295 http.Redirect(w, r, "/posts/"+r.PathValue("slug")+"/"+format+"/", http.StatusMovedPermanently)
296}
297
298// redirectSlash sends /blog to /blog/, so the slashless form is not a 404.
299func redirectSlash(w http.ResponseWriter, r *http.Request) {
300 http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
301}
302
303func take(posts []*Post, n int) []*Post {
304 if len(posts) > n {
305 return posts[:n]
306 }
307 return posts
308}
309
310// pickRandom returns n posts chosen without replacement, leaving the caller's
311// slice alone.
312func pickRandom(posts []*Post, n int) []*Post {
313 shuffled := make([]*Post, len(posts))
314 copy(shuffled, posts)
315 rand.Shuffle(len(shuffled), func(i, j int) {
316 shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
317 })
318 return take(shuffled, n)
319}
320
321// urlPathEscape encodes a tag or year for a path segment. url.PathEscape leaves
322// "/" alone, which would let a tag containing one invent a route.
323func urlPathEscape(s string) string {
324 return strings.ReplaceAll(url.PathEscape(s), "/", "%2F")
325}
326
327// latestJSON publishes the newest post for isaacbythewood.com's promo slot. The
328// shape is hand-written so a new field on Post does not become a public API by
329// accident, and there is no CORS because the only consumer fetches server side.
330func (s *site) latestJSON(w http.ResponseWriter, r *http.Request) {
331 published, _, _ := s.lib.Published()
332
333 w.Header().Set("Content-Type", "application/json; charset=utf-8")
334 // Short, so a new post reaches the other site without a restart.
335 w.Header().Set("Cache-Control", "public, max-age=300")
336
337 if len(published) == 0 {
338 _, _ = w.Write([]byte("{}\n"))
339 return
340 }
341
342 p := published[0]
343 enc := json.NewEncoder(w)
344 // The consumer renders these through html/template, which escapes for that
345 // context already; escaping here only corrupts an ampersand in a title.
346 enc.SetEscapeHTML(false)
347 _ = enc.Encode(struct {
348 Title string `json:"title"`
349 Description string `json:"description"`
350 URL string `json:"url"`
351 Date string `json:"date"`
352 }{
353 Title: p.Title,
354 Description: p.Description,
355 URL: baseURL + p.URL(),
356 Date: p.PublishDate,
357 })
358}