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

9.9 KB · 359 lines · Go Raw History
  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}