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// status.bythewood.me is single-operator uptime monitoring. An in-process
2// scheduler probes every tracked URL, audits it with Lighthouse and crawls it
3// for SEO; the handlers mostly render what the scheduler wrote.
4package main
5
6import (
7 "context"
8 "database/sql"
9 "embed"
10 "flag"
11 "fmt"
12 "io/fs"
13 "log/slog"
14 "net/http"
15 "os"
16 "os/signal"
17 "strings"
18 "syscall"
19 "time"
20
21 "github.com/google/uuid"
22 "status.bythewood.me/web"
23)
24
25// Templates are source and ship in the binary unconditionally; the Vite bundle
26// is build output and ships only in a release build.
27//
28//go:embed templates
29var templateFS embed.FS
30
31const listenAddr = ":8000"
32
33func dir(env, fallback string) string {
34 if v := os.Getenv(env); v != "" {
35 return v
36 }
37 return fallback
38}
39
40// script-src needs 'unsafe-inline' because the dashboard ships its chart data as
41// inline <script type="application/json"> blocks, and style-src carries it for
42// Bootstrap's inline style attributes.
43func csp() string {
44 return strings.Join([]string{
45 "default-src 'self'",
46 "script-src 'self' 'unsafe-inline' https://analytics.bythewood.me",
47 "style-src 'self' 'unsafe-inline'",
48 "img-src 'self' data:",
49 "font-src 'self'",
50 "connect-src 'self' https://analytics.bythewood.me",
51 "base-uri 'self'",
52 "form-action 'self'",
53 "frame-ancestors 'self'",
54 }, "; ")
55}
56
57// site is everything the handlers share.
58type site struct {
59 renderer *web.Renderer
60 db *sql.DB
61 dist fs.FS
62 assets *web.Assets
63 typst *Typst
64
65 auth *web.Authenticator
66
67 baseScript string
68 baseStyles []string
69 pagesScript string
70 pagesStyles []string
71 propsScript string
72 propsStyles []string
73}
74
75// The template sets, shared with the tests, which parse them for real. A page
76// listed here with no file behind it parses at boot and not at build, so
77// nothing but doing it catches a template that was deleted and left listed.
78var (
79 layoutTemplates = []string{"base.html", "partials.html"}
80 pageTemplates = []string{
81 "home.html",
82 "properties.html", "property.html", "notfound.html",
83 }
84)
85
86func main() {
87 web.SetupLogging()
88
89 previewKind := flag.String("preview-alert", "",
90 "print the ntfy notification for 'down' or 'recovery' and exit")
91 // The container HEALTHCHECK runs this, since a scratch image has no shell.
92 healthcheck := flag.Bool("healthcheck", false, "probe a running server on this host and exit")
93 flag.Parse()
94
95 if *healthcheck {
96 if err := web.HealthCheck("http://127.0.0.1:8000/healthz", 3*time.Second); err != nil {
97 slog.Info(fmt.Sprintf("healthcheck: %v", err))
98 os.Exit(1)
99 }
100 return
101 }
102
103 // Tees onto the stdout handler rather than replacing it. Kept after the
104 // healthcheck branch, so a HEALTHCHECK never starts a queue it cannot flush.
105 shipper := web.ShipLogs("status", web.HTTPSink())
106 defer shipper.Close()
107
108 if *previewKind != "" {
109 if err := previewAlert(*previewKind); err != nil {
110 slog.Error("startup failed", slog.Any("err", err))
111 os.Exit(1)
112 }
113 return
114 }
115
116 dataDir := dir("SITE_DATA", "data")
117 db, err := openDB(dataDir + "/db.sqlite3")
118 if err != nil {
119 slog.Error("startup failed", slog.Any("err", err))
120 os.Exit(1)
121 }
122 defer db.Close()
123
124 dist := distFS()
125
126 assets, err := web.LoadAssets(dist)
127 if err != nil {
128 slog.Error("startup failed", slog.Any("err", err))
129 os.Exit(1)
130 }
131
132 templates, err := fs.Sub(templateFS, "templates")
133 if err != nil {
134 slog.Error("startup failed", slog.Any("err", err))
135 os.Exit(1)
136 }
137
138 renderer, err := web.NewRenderer(templates, templateFuncs, layoutTemplates, pageTemplates)
139 if err != nil {
140 slog.Error("startup failed", slog.Any("err", err))
141 os.Exit(1)
142 }
143
144 s := &site{
145 renderer: renderer,
146 db: db,
147 dist: dist,
148 assets: assets,
149 typst: NewTypst(),
150 auth: web.NewAuthenticator(),
151 baseScript: assets.Script("static_src/base/index.js"),
152 baseStyles: assets.Styles("static_src/base/index.js"),
153 pagesScript: assets.Script("static_src/pages/index.js"),
154 pagesStyles: assets.Styles("static_src/pages/index.js"),
155 propsScript: assets.Script("static_src/properties/index.js"),
156 propsStyles: assets.Styles("static_src/properties/index.js"),
157 }
158
159 // The scheduler stops with the process, so a deploy does not kill a crawl
160 // halfway and leave the row wedged for the watchdog to find.
161 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
162 defer stop()
163
164 scheduler := NewScheduler(db, NewNotifier(), dir("SITE_ROOT", "."))
165 if err := scheduler.ResetOnBoot(ctx); err != nil {
166 slog.Error(fmt.Sprintf("reset wedged states: %v", err))
167 os.Exit(1)
168 }
169 go scheduler.Run(ctx)
170
171 mux := http.NewServeMux()
172
173 mux.HandleFunc("GET /{$}", s.home)
174
175 // Signing in happens on auth.bythewood.me. This stays so an old bookmark
176 // and the "access dashboard" button both land somewhere useful.
177 mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
178 http.Redirect(w, r, web.LoginURL(r), http.StatusSeeOther)
179 })
180
181 mux.HandleFunc("GET /properties", s.auth.RequireAuth(s.properties))
182 mux.HandleFunc("POST /properties", s.auth.RequireAuth(s.propertyCreate))
183 mux.HandleFunc("POST /properties/{id}/delete", s.auth.RequireAuth(s.propertyDelete))
184 mux.HandleFunc("POST /properties/{id}/public", s.auth.RequireAuthJSON(s.propertyPublic))
185
186 // Reachable without a session for a public property; the handler does that
187 // check itself, because it needs the property row to know.
188 mux.HandleFunc("GET /properties/{id}/status", s.propertyStatus)
189 // Every property in one answer, for chat.bythewood.me's tools. Behind the
190 // session, since it does not filter out the private ones.
191 mux.HandleFunc("GET /api/properties", s.auth.RequireAuthJSON(s.apiProperties))
192 mux.HandleFunc("POST /properties/{id}/recrawl", s.auth.RequireAuthJSON(s.propertyRecrawl))
193 mux.HandleFunc("POST /properties/{id}/rerun-lighthouse", s.auth.RequireAuthJSON(s.propertyRerunLighthouse))
194
195 // A wrong method on an existing route should be 405 with Allow. The mux only
196 // does that when nothing else matches, and "GET /" below always matches.
197 for path, allow := range map[string]string{
198 "/properties/{id}/delete": "POST",
199 "/properties/{id}/public": "POST",
200 "/properties/{id}/recrawl": "POST",
201 "/properties/{id}/rerun-lighthouse": "POST",
202 } {
203 mux.HandleFunc("GET "+path, methodNotAllowed(allow))
204 }
205
206 mux.HandleFunc("GET /favicon.ico", favicon)
207 mux.HandleFunc("GET /robots.txt", robots)
208 mux.HandleFunc("GET /sitemap.xml", sitemap)
209
210 mux.Handle("GET /static/", web.Static(dist, assets))
211
212 mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
213 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
214 // EdgeCache fills in the site policy whenever a handler sets no
215 // Cache-Control of its own, so saying nothing here means the edge
216 // answers a liveness check out of cache long after this process has
217 // stopped serving.
218 w.Header().Set("Cache-Control", "no-store")
219 _, _ = w.Write([]byte("ok\n"))
220 })
221
222 // Registered as "/" rather than "/{id}" so it cannot shadow /login and
223 // /properties; "/{id}" would also claim /nonsense-that-should-404.
224 mux.HandleFunc("GET /", s.dashboardOrNotFound)
225
226 handler := web.Chain(mux,
227 web.Recovered,
228 web.Logged,
229 web.SecurityHeaders(csp()),
230 )
231
232 slog.Info(fmt.Sprintf("status serving %s (staging=%t)", baseURL, Staging))
233 if err := web.Serve(listenAddr, handler); err != nil {
234 slog.Error("startup failed", slog.Any("err", err))
235 os.Exit(1)
236 }
237}
238
239func methodNotAllowed(allow string) http.HandlerFunc {
240 return func(w http.ResponseWriter, r *http.Request) {
241 w.Header().Set("Allow", allow)
242 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
243 }
244}
245
246// dashboardOrNotFound routes a bare "/<uuid>" to a dashboard and everything
247// else to a 404.
248func (s *site) dashboardOrNotFound(w http.ResponseWriter, r *http.Request) {
249 slug := strings.Trim(r.URL.Path, "/")
250 if id, err := uuid.Parse(slug); err == nil && !strings.Contains(slug, "/") {
251 s.dashboard(w, r, id)
252 return
253 }
254 s.notFound(w, r)
255}
256
257func (s *site) notFound(w http.ResponseWriter, r *http.Request) {
258 data := s.page(r, "404", "That page does not exist.")
259 s.renderer.Render(w, http.StatusNotFound, "notfound.html", data)
260}
261
262// page builds the half of PageData every template needs.
263func (s *site) page(r *http.Request, title, description string) PageData {
264 return PageData{
265 Title: title,
266 Description: description,
267 Path: r.URL.Path,
268 Canonical: baseURL + r.URL.Path,
269 Staging: Staging,
270 Analytics: !Staging,
271 AnalyticsID: analyticsID,
272 Authenticated: s.auth.Authenticated(r),
273 Year: time.Now().Year(),
274 BaseURL: baseURL,
275 SourceURL: sourceURL,
276 OGImage: baseURL + "/static/og/card.png",
277 JSONLD: pageGraph(title, description, baseURL+r.URL.Path),
278 SiteName: siteName,
279 AuthorName: authorName,
280 Script: s.baseScript,
281 Styles: s.baseStyles,
282 }
283}