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.8 KB · 318 lines · Go Raw History
  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}