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

10.8 KB · 399 lines · Go Raw History
  1package main
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"html/template"
  7	"log/slog"
  8	"net/http"
  9	"sort"
 10	"strconv"
 11	"strings"
 12	"time"
 13
 14	"github.com/google/uuid"
 15
 16	"analytics.bythewood.me/web"
 17)
 18
 19const (
 20	dayMS = 24 * 60 * 60 * 1000
 21	// Long enough to show a trend, short enough that the graph buckets daily.
 22	defaultRangeDays = 28
 23
 24	maxRangeDays = 3650
 25)
 26
 27// PageData is everything base.html and one page template need; the fields for
 28// pages other than the one rendering are zero.
 29type PageData struct {
 30	Title         string
 31	Description   string
 32	Path          string
 33	Canonical     string
 34	Staging       bool
 35	Authenticated bool
 36	Year          int
 37	BaseURL       string
 38	SourceURL     string
 39	SiteName      string
 40	AuthorName    string
 41
 42	OGImage string
 43	JSONLD  template.JS
 44
 45	Script string
 46	Styles []string
 47	// The per-page Vite entry, empty on pages that only need the shared bundle.
 48	PageScript string
 49	PageStyles []string
 50
 51	// Empty CollectorID renders no self-tracking snippet.
 52	CollectorID     string
 53	CollectorServer string
 54
 55	Error string
 56	Next  string
 57
 58	TotalProperties int64
 59	TotalEvents     int64
 60	TotalUsers      int
 61	FirstEventAt    string
 62
 63	Properties []PropertyRow
 64	Totals     PropertyTotals
 65	Query      string
 66
 67	Property   *DashProperty
 68	Dash       *Dashboard
 69	ReportedAt string
 70}
 71
 72// DashProperty is the property identity the dashboard chrome renders.
 73type DashProperty struct {
 74	ID          string
 75	Name        string
 76	IsProtected bool
 77	IsPublic    bool
 78}
 79
 80// Dashboard is one property's numbers over one date range.
 81type Dashboard struct {
 82	DateStart string
 83	DateEnd   string
 84	DateRange int64
 85	FilterURL string
 86	LiveUsers int64
 87
 88	EventCards   []EventCard
 89	CustomEvents []CustomEventDescriptor
 90
 91	Graph                 []GraphPoint
 92	ByScreenSize          []LabelCount
 93	ByDevice              []LabelCount
 94	ByBrowser             []LabelCount
 95	ByPlatform            []LabelCount
 96	ByPageURL             []LabelCount
 97	PageViewsByPageURL    []LabelCount
 98	ByCustomEvent         []LabelCount
 99	SessionsByReferrer    []LabelCount
100	PageViewsByUTMMedium  []LabelCount
101	PageViewsByUTMSource  []LabelCount
102	PageViewsByUTMCampaig []LabelCount
103
104	SessionsByCountry       map[string]int64
105	SessionsByCountryRegion map[string]map[string]int64
106
107	Bots BotTraffic
108
109	// Precomputed for the report templates, which have no charting engine and
110	// no JavaScript to compute this themselves.
111	ChartPolyline   string
112	ChartLabelStart string
113	ChartLabelEnd   string
114	ChartPeakCount  int64
115	ChartPeakLabel  string
116	BreakdownTotals BreakdownTotals
117	TopCountries    []LabelCount
118}
119
120// BreakdownTotals holds each breakdown's sum, floored at one so a template can
121// divide by it unguarded.
122type BreakdownTotals struct {
123	Device     int64
124	Browser    int64
125	Platform   int64
126	ScreenSize int64
127}
128
129// dashboard renders one property. Public properties are readable without a
130// session; everything else, including a missing property, redirects.
131func (s *site) dashboard(w http.ResponseWriter, r *http.Request, id uuid.UUID) {
132	ctx := r.Context()
133
134	p, err := lookupProperty(ctx, s.db, id)
135	if err != nil {
136		slog.Info(fmt.Sprintf("dashboard lookup: %v", err))
137		http.Error(w, "database error", http.StatusInternalServerError)
138		return
139	}
140	if p == nil {
141		http.Redirect(w, r, "/properties", http.StatusSeeOther)
142		return
143	}
144
145	authed := s.auth.Authenticated(r)
146	if !p.IsPublic && !authed {
147		http.Redirect(w, r, web.LoginURL(r), http.StatusSeeOther)
148		return
149	}
150
151	q := r.URL.Query()
152	today := time.Now()
153
154	dateStart := q.Get("date_start")
155	if dateStart == "" {
156		dateStart = today.AddDate(0, 0, -defaultRangeDays).Format("2006-01-02")
157	}
158	dateEnd := q.Get("date_end")
159	if dateEnd == "" {
160		dateEnd = today.Format("2006-01-02")
161	}
162
163	startMS, ok := parseDateToMS(dateStart, false)
164	if !ok {
165		http.Error(w, "bad date_start", http.StatusBadRequest)
166		return
167	}
168	endMS, ok := parseDateToMS(dateEnd, true)
169	if !ok {
170		http.Error(w, "bad date_end", http.StatusBadRequest)
171		return
172	}
173
174	// "custom" or absent means derive the range from the two dates.
175	var rangeDays int64
176	switch v := q.Get("date_range"); v {
177	case "", "custom":
178		rangeDays = max64((endMS-startMS)/dayMS, 1)
179	default:
180		n, err := strconv.ParseInt(v, 10, 64)
181		if err != nil || n < 1 {
182			n = defaultRangeDays
183		}
184		rangeDays = n
185	}
186
187	// Clamped outside the switch, so both arms are bounded. rangeDays sizes an
188	// allocating bucket slice that is then inlined into the page, and a public
189	// property makes this reachable with no cookie.
190	rangeDays = min64(max64(rangeDays, 1), maxRangeDays)
191
192	// The window too, so an ancient start date cannot widen the graph behind
193	// the clamp above.
194	if startMS < endMS-maxRangeDays*dayMS {
195		startMS = endMS - maxRangeDays*dayMS
196	}
197
198	prevStartMS := startMS - rangeDays*dayMS
199	prevEndMS := endMS - rangeDays*dayMS
200	filterURL := q.Get("filter_url")
201
202	// Anchor the graph to the requested end date; stepping back from today
203	// charts a historical range as zeros beside real metric cards.
204	graphEnd, err := time.ParseInLocation("2006-01-02", dateEnd, time.Local)
205	if err != nil {
206		graphEnd = today
207	}
208
209	d := &Dashboard{
210		DateStart: dateStart,
211		DateEnd:   dateEnd,
212		DateRange: rangeDays,
213		FilterURL: filterURL,
214		LiveUsers: totalLiveUsers(ctx, s.db, p.ID),
215	}
216
217	d.EventCards = standardEventCards(ctx, s.db, p.ID, startMS, endMS, prevStartMS, prevEndMS, filterURL)
218	customCards, customEvents := customEventCards(ctx, s.db, p.ID, p.CustomCards, startMS, endMS, prevStartMS, prevEndMS, filterURL)
219	d.EventCards = append(d.EventCards, customCards...)
220	d.CustomEvents = customEvents
221
222	d.Graph = eventsGraph(ctx, s.db, p.ID, startMS, endMS, filterURL, graphEnd, rangeDays)
223	d.ByScreenSize = eventsByScreenSize(ctx, s.db, p.ID, startMS, endMS, filterURL, 7)
224	d.ByDevice = eventsByDevice(ctx, s.db, p.ID, startMS, endMS, filterURL, 7)
225	d.ByBrowser = eventsByBrowser(ctx, s.db, p.ID, startMS, endMS, filterURL, 7)
226	d.ByPlatform = eventsByPlatform(ctx, s.db, p.ID, startMS, endMS, filterURL, 7)
227	d.ByPageURL = eventsByPageURL(ctx, s.db, p.ID, startMS, endMS, filterURL, 10)
228	d.PageViewsByPageURL = pageViewsByPageURL(ctx, s.db, p.ID, startMS, endMS, filterURL, 10)
229	d.ByCustomEvent = eventsByCustomEvent(ctx, s.db, p.ID, startMS, endMS, filterURL, 10)
230	d.SessionsByReferrer = sessionStartsByReferrer(ctx, s.db, p.ID, startMS, endMS, filterURL, 10)
231	d.PageViewsByUTMMedium = pageViewsByUTM(ctx, s.db, p.ID, startMS, endMS, filterURL, "medium", 10)
232	d.PageViewsByUTMSource = pageViewsByUTM(ctx, s.db, p.ID, startMS, endMS, filterURL, "source", 10)
233	d.PageViewsByUTMCampaig = pageViewsByUTM(ctx, s.db, p.ID, startMS, endMS, filterURL, "campaign", 10)
234	d.SessionsByCountry = sessionStartsByCountry(ctx, s.db, p.ID, startMS, endMS, filterURL)
235	d.SessionsByCountryRegion = sessionStartsByCountryRegion(ctx, s.db, p.ID, startMS, endMS, filterURL)
236	d.Bots = botTraffic(ctx, s.db, p.ID, startMS, endMS, 10)
237
238	d.fillReportExtras()
239
240	data := s.page(r, p.Name, "Analytics for "+p.Name)
241	data.PageScript = s.propsScript
242	data.PageStyles = s.propsStyles
243	data.Property = &DashProperty{
244		ID:          p.ID.String(),
245		Name:        p.Name,
246		IsProtected: p.IsProtected,
247		IsPublic:    p.IsPublic,
248	}
249	data.Dash = d
250	data.ReportedAt = time.Now().Format("2006-01-02 15:04")
251
252	// Report export is operator-only even for a public property: the PDF path
253	// spawns an unthrottled Typst compile.
254	if report, ok := reportFormat(q); ok {
255		if !authed {
256			http.Redirect(w, r, "/"+id.String(), http.StatusSeeOther)
257			return
258		}
259		s.renderReport(w, r, report, p.Name, data)
260		return
261	}
262
263	s.renderer.Render(w, http.StatusOK, "property.html", data)
264}
265
266// reportFormat reads ?report; a bare "?report" means pdf.
267func reportFormat(q map[string][]string) (string, bool) {
268	values, present := q["report"]
269	if !present {
270		return "", false
271	}
272	format := ""
273	if len(values) > 0 {
274		format = values[0]
275	}
276	if format == "" {
277		format = "pdf"
278	}
279	if format != "pdf" && format != "md" {
280		return "", false
281	}
282	return format, true
283}
284
285// fillReportExtras precomputes what the PDF and Markdown reports need. It runs
286// on every dashboard render, being arithmetic over data already in memory.
287func (d *Dashboard) fillReportExtras() {
288	d.ChartPolyline = chartPolyline(d.Graph)
289
290	if len(d.Graph) > 0 {
291		d.ChartLabelStart = d.Graph[0].Label
292		d.ChartLabelEnd = d.Graph[len(d.Graph)-1].Label
293		peak := d.Graph[0]
294		for _, p := range d.Graph[1:] {
295			if p.Count > peak.Count {
296				peak = p
297			}
298		}
299		d.ChartPeakCount = peak.Count
300		d.ChartPeakLabel = peak.Label
301	}
302
303	d.BreakdownTotals = BreakdownTotals{
304		Device:     sumCounts(d.ByDevice),
305		Browser:    sumCounts(d.ByBrowser),
306		Platform:   sumCounts(d.ByPlatform),
307		ScreenSize: sumCounts(d.ByScreenSize),
308	}
309
310	// The report renders a table, which needs an order a Go map does not have.
311	countries := make([]LabelCount, 0, len(d.SessionsByCountry))
312	for code, count := range d.SessionsByCountry {
313		countries = append(countries, LabelCount{Label: code, Count: count})
314	}
315	sort.Slice(countries, func(i, j int) bool {
316		if countries[i].Count != countries[j].Count {
317			return countries[i].Count > countries[j].Count
318		}
319		// Ties break by name so the report is reproducible.
320		return countries[i].Label < countries[j].Label
321	})
322	if len(countries) > 10 {
323		countries = countries[:10]
324	}
325	d.TopCountries = countries
326}
327
328// sumCounts is a breakdown's denominator, floored at one so a template can
329// divide by it unguarded.
330func sumCounts(items []LabelCount) int64 {
331	var total int64
332	for _, i := range items {
333		total += i.Count
334	}
335	return max64(total, 1)
336}
337
338// chartPolyline renders the time series as SVG polyline points, for reports that
339// have no browser. The geometry matches the viewBox in the report templates.
340func chartPolyline(points []GraphPoint) string {
341	if len(points) == 0 {
342		return ""
343	}
344
345	const (
346		width   = 600.0
347		height  = 100.0
348		padding = 4.0
349	)
350	usableH := height - 2*padding
351
352	var maxCount int64 = 1
353	for _, p := range points {
354		if p.Count > maxCount {
355			maxCount = p.Count
356		}
357	}
358
359	// A single bucket has no horizontal span to divide by.
360	if len(points) == 1 {
361		y := height - padding - float64(points[0].Count)/float64(maxCount)*usableH
362		return fmt.Sprintf("%.1f,%.1f", width/2, y)
363	}
364
365	parts := make([]string, 0, len(points))
366	for i, p := range points {
367		x := float64(i) / float64(len(points)-1) * width
368		y := height - padding - float64(p.Count)/float64(maxCount)*usableH
369		parts = append(parts, fmt.Sprintf("%.1f,%.1f", x, y))
370	}
371	return strings.Join(parts, " ")
372}
373
374func max64(a, b int64) int64 {
375	if a > b {
376		return a
377	}
378	return b
379}
380
381func min64(a, b int64) int64 {
382	if a < b {
383		return a
384	}
385	return b
386}
387
388// jsonBlock marshals a value for an inline <script type="application/json">.
389// HTML escaping stays on so a value containing "</script>" cannot close the
390// block; html/template cannot work that out inside a non-JavaScript script type.
391func jsonBlock(v any) template.JS {
392	b, err := json.Marshal(v)
393	if err != nil {
394		slog.Info(fmt.Sprintf("json block: %v", err))
395		return template.JS("null")
396	}
397	return template.JS(b)
398}