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

7.6 KB · 247 lines · Go Raw History
  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}