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

5.4 KB · 195 lines · Go Raw History
  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}