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// analytics.bythewood.me: self-hosted, single-operator website analytics. An
2// embedded collector script writes events into SQLite, and a dashboard renders
3// them as metric tiles, charts, a world map and downloadable reports.
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 "strings"
17 "time"
18
19 "analytics.bythewood.me/web"
20 "github.com/google/uuid"
21)
22
23// Templates are source and always ship in the binary; the Vite bundle and the
24// topojson are build output and only ship in a release build.
25//
26//go:embed templates
27var templateFS embed.FS
28
29const listenAddr = ":8000"
30
31func dir(env, fallback string) string {
32 if v := os.Getenv(env); v != "" {
33 return v
34 }
35 return fallback
36}
37
38// script-src needs 'unsafe-inline' for the inline self-tracking snippet, and
39// style-src for Bootstrap's style attributes. Neither can drop it without a
40// markup change.
41func csp() string {
42 return strings.Join([]string{
43 "default-src 'self'",
44 "script-src 'self' 'unsafe-inline'",
45 "style-src 'self' 'unsafe-inline'",
46 "img-src 'self' data:",
47 "font-src 'self'",
48 "connect-src 'self'",
49 "base-uri 'self'",
50 "form-action 'self'",
51 "frame-ancestors 'self'",
52 }, "; ")
53}
54
55// site is everything the handlers share.
56type site struct {
57 renderer *web.Renderer
58 db *sql.DB
59 dist fs.FS
60 assets *web.Assets
61 geoip *GeoIP
62 ua *UAParser
63 typst *Typst
64
65 auth *web.Authenticator
66 baseScript string
67 baseStyles []string
68 pagesScript string
69 pagesStyles []string
70 propsScript string
71 propsStyles []string
72}
73
74// The template sets, shared with the tests, which parse them for real. A page
75// listed here with no file behind it parses at boot and not at build, so
76// nothing but doing it catches a template that was deleted and left listed.
77var (
78 layoutTemplates = []string{"base.html", "partials.html"}
79 pageTemplates = []string{
80 "home.html", "documentation.html",
81 "properties.html", "property.html", "notfound.html",
82 }
83)
84
85func main() {
86 web.SetupLogging()
87
88 seed := flag.Bool("seed", false, "fill a Seed Test property with realistic fake events, then exit")
89 seedSessions := flag.Int("seed-sessions", 500, "sessions to generate in -seed mode")
90 seedDays := flag.Int("seed-days", 60, "days to spread -seed sessions over")
91 // The container HEALTHCHECK runs this: a FROM scratch image has no shell
92 // for a check to call, so the binary probes itself.
93 healthcheck := flag.Bool("healthcheck", false, "probe a running server on this host and exit")
94 flag.Parse()
95
96 if *healthcheck {
97 if err := web.HealthCheck("http://127.0.0.1:8000/healthz", 3*time.Second); err != nil {
98 slog.Info(fmt.Sprintf("healthcheck: %v", err))
99 os.Exit(1)
100 }
101 return
102 }
103
104 // Tees stdout records to logging.bythewood.me; see web/shipper.go. It goes
105 // after the healthcheck branch so a HEALTHCHECK does not start a queue it
106 // will never flush.
107 shipper := web.ShipLogs("analytics", web.HTTPSink())
108 defer shipper.Close()
109
110 dataDir := dir("SITE_DATA", "data")
111 db, err := openDB(dataDir + "/db.sqlite3")
112 if err != nil {
113 slog.Error("startup failed", slog.Any("err", err))
114 os.Exit(1)
115 }
116 defer db.Close()
117
118 ctx := context.Background()
119
120 if *seed {
121 if err := runSeed(ctx, db, *seedSessions, *seedDays); err != nil {
122 slog.Error(fmt.Sprintf("seed: %v", err))
123 os.Exit(1)
124 }
125 return
126 }
127
128 dist := distFS()
129
130 assets, err := web.LoadAssets(dist)
131 if err != nil {
132 slog.Error("startup failed", slog.Any("err", err))
133 os.Exit(1)
134 }
135
136 templates, err := fs.Sub(templateFS, "templates")
137 if err != nil {
138 slog.Error("startup failed", slog.Any("err", err))
139 os.Exit(1)
140 }
141
142 renderer, err := web.NewRenderer(templates, templateFuncs, layoutTemplates, pageTemplates)
143 if err != nil {
144 slog.Error("startup failed", slog.Any("err", err))
145 os.Exit(1)
146 }
147
148 geoipPath := dataDir + "/db.mmdb"
149 s := &site{
150 renderer: renderer,
151 db: db,
152 dist: dist,
153 assets: assets,
154 geoip: LoadGeoIP(geoipPath),
155 ua: NewUAParser(),
156 typst: NewTypst(),
157 auth: web.NewAuthenticator(),
158 baseScript: assets.Script("static_src/base/index.js"),
159 baseStyles: assets.Styles("static_src/base/index.js"),
160 pagesScript: assets.Script("static_src/pages/index.js"),
161 pagesStyles: assets.Styles("static_src/pages/index.js"),
162 propsScript: assets.Script("static_src/properties/index.js"),
163 propsStyles: assets.Styles("static_src/properties/index.js"),
164 }
165
166 // Best effort in the background, since the download is large and failing
167 // only costs country enrichment until the next boot.
168 go func() {
169 fresh, err := EnsureGeoIPDB(geoipPath)
170 switch {
171 case err != nil:
172 slog.Info(fmt.Sprintf("geoip refresh skipped: %v", err))
173 case fresh:
174 s.geoip.Reload()
175 }
176 }()
177
178 // Cancelled on shutdown so a sweep stops between chunks instead of holding
179 // the write lock while everything else drains.
180 sweepCtx, stopSweeping := context.WithCancel(context.Background())
181 defer stopSweeping()
182 go NewSweeper(db).Run(sweepCtx)
183
184 mux := http.NewServeMux()
185
186 mux.HandleFunc("GET /{$}", s.home)
187 mux.HandleFunc("GET /documentation", s.documentation)
188
189 // Signing in happens on auth.bythewood.me. This stays so an old bookmark
190 // and the "access dashboard" button both land somewhere useful.
191 mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
192 http.Redirect(w, r, web.LoginURL(r), http.StatusSeeOther)
193 })
194
195 mux.HandleFunc("GET /properties", s.auth.RequireAuth(s.properties))
196 // The read only view chat.bythewood.me's tools call, behind the session
197 // because it does not filter out the private properties.
198 mux.HandleFunc("GET /api/summary", s.auth.RequireAuthJSON(s.apiSummary))
199 mux.HandleFunc("POST /properties", s.auth.RequireAuth(s.propertyCreate))
200 mux.HandleFunc("POST /properties/{id}/delete", s.auth.RequireAuth(s.propertyDelete))
201 mux.HandleFunc("POST /properties/{id}/cards", s.auth.RequireAuth(s.propertyCards))
202 mux.HandleFunc("POST /properties/{id}/public", s.auth.RequireAuth(s.propertyPublic))
203
204 // /collect/ is an alias for embeds that hardcoded the trailing slash; those
205 // snippets live in other people's HTML.
206 for _, path := range []string{"/collect", "/collect/"} {
207 mux.HandleFunc("POST "+path, s.collect)
208 mux.HandleFunc("OPTIONS "+path, s.collectOptions)
209 }
210 mux.HandleFunc("GET /static/collector.js", s.collectorScript)
211
212 // The mux only answers 405 by itself when nothing else matches, and the
213 // "GET /" catch-all below matches every GET path there is.
214 for path, allow := range map[string]string{
215 "/collect": "OPTIONS, POST",
216 "/collect/": "OPTIONS, POST",
217 "/properties/{id}/delete": "POST",
218 "/properties/{id}/cards": "POST",
219 "/properties/{id}/public": "POST",
220 } {
221 mux.HandleFunc("GET "+path, methodNotAllowed(allow))
222 }
223
224 mux.HandleFunc("GET /favicon.ico", favicon)
225 mux.HandleFunc("GET /robots.txt", robots)
226 mux.HandleFunc("GET /sitemap.xml", sitemap)
227
228 mux.Handle("GET /static/", web.Static(dist, assets))
229
230 // Per-country admin-1 topojson, generated at image build, so the filenames
231 // are stable and cacheable for a year.
232 mux.Handle("GET /static_maps/", http.StripPrefix("/static_maps/",
233 cacheControl("public, max-age=31536000, immutable",
234 http.FileServer(http.FS(mapsFS())))))
235
236 mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
237 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
238 // EdgeCache fills in the site policy whenever a handler sets no
239 // Cache-Control of its own, so saying nothing here means the edge
240 // answers a liveness check out of cache long after this process has
241 // stopped serving.
242 w.Header().Set("Cache-Control", "no-store")
243 _, _ = w.Write([]byte("ok\n"))
244 })
245
246 // Registered as "/" and not "/{id}", which would claim every one-segment
247 // path and leave nothing to 404.
248 mux.HandleFunc("GET /", s.dashboardOrNotFound)
249
250 handler := web.Chain(mux,
251 web.Recovered,
252 web.Logged,
253 web.SecurityHeaders(csp()),
254 )
255
256 slog.Info(fmt.Sprintf("analytics serving %s (staging=%t, property=%s)", baseURL, Staging, analyticsID))
257 if err := web.Serve(listenAddr, handler); err != nil {
258 slog.Error("startup failed", slog.Any("err", err))
259 os.Exit(1)
260 }
261}
262
263func methodNotAllowed(allow string) http.HandlerFunc {
264 return func(w http.ResponseWriter, r *http.Request) {
265 w.Header().Set("Allow", allow)
266 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
267 }
268}
269
270func cacheControl(value string, next http.Handler) http.Handler {
271 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
272 w.Header().Set("Cache-Control", value)
273 next.ServeHTTP(w, r)
274 })
275}
276
277// dashboardOrNotFound routes a bare "/<uuid>" to a dashboard and everything
278// else to a 404.
279func (s *site) dashboardOrNotFound(w http.ResponseWriter, r *http.Request) {
280 slug := strings.Trim(r.URL.Path, "/")
281 if id, err := uuid.Parse(slug); err == nil && !strings.Contains(slug, "/") {
282 s.dashboard(w, r, id)
283 return
284 }
285 s.notFound(w, r)
286}
287
288func (s *site) notFound(w http.ResponseWriter, r *http.Request) {
289 data := s.page(r, "404", "That page does not exist.")
290 s.renderer.Render(w, http.StatusNotFound, "notfound.html", data)
291}
292
293// page builds the half of PageData every template needs.
294func (s *site) page(r *http.Request, title, description string) PageData {
295 return PageData{
296 Title: title,
297 Description: description,
298 Path: r.URL.Path,
299 Canonical: baseURL + r.URL.Path,
300 Staging: Staging,
301 Authenticated: s.auth.Authenticated(r),
302 Year: time.Now().Year(),
303 BaseURL: baseURL,
304 SourceURL: sourceURL,
305 SiteName: siteName,
306 AuthorName: authorName,
307 OGImage: baseURL + "/static/og/card.png",
308 JSONLD: pageGraph(title, description, baseURL+r.URL.Path),
309 Script: s.baseScript,
310 Styles: s.baseStyles,
311
312 // The collector posts to whatever origin served the page, so an empty
313 // server works in dev and in production alike.
314 CollectorID: collectorID(),
315 CollectorServer: "",
316 }
317}