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

18.2 KB · 639 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"log/slog"
  8	"math"
  9	"net/http"
 10	"regexp"
 11	"sort"
 12	"strconv"
 13	"strings"
 14	"time"
 15
 16	"github.com/google/uuid"
 17)
 18
 19// PropertyView is one property, with everything the templates need already
 20// computed.
 21type PropertyView struct {
 22	ID          string
 23	URL         string
 24	Name        string
 25	IsPublic    bool
 26	IsProtected bool
 27
 28	CurrentStatus    int64
 29	AvgResponseTime  int64
 30	RecentUptimePct  *float64
 31	RecentTickStream []string
 32	TotalChecks      int64
 33
 34	CrawlState          string
 35	CrawlerInsights     []Insight
 36	LastCrawlSuccessAt  *time.Time
 37	LastCrawlError      string
 38	LastCrawlDurationMS *int64
 39	LastCrawlPagesCount *int64
 40	NextRunAtCrawler    *time.Time
 41	CrawlStartedAt      *time.Time
 42
 43	LighthouseState          string
 44	LighthouseScores         *Scores
 45	LighthouseDetails        *Details
 46	LastLighthouseSuccessAt  *time.Time
 47	LastLighthouseError      string
 48	LastLighthouseDurationMS *int64
 49	NextLighthouseRunAt      *time.Time
 50	LighthouseStartedAt      *time.Time
 51	AvgLighthouseScore       *int64
 52
 53	AlertState string
 54	CreatedAt  time.Time
 55	UpdatedAt  time.Time
 56
 57	// Security posture, read off the most recent response's headers.
 58	IsHTTPS                   bool
 59	InvalidCert               bool
 60	HasMIMEType               bool
 61	HasContentSniffProtection bool
 62	HasClickjackProtection    bool
 63	HidesServerVersion        bool
 64	HasHSTS                   bool
 65	HasHSTSPreload            bool
 66	HasSecurityIssue          bool
 67}
 68
 69type InsightGroup struct {
 70	Type  string
 71	Items []Insight
 72}
 73
 74// ResponseTimePoint is one bar of the phase-breakdown chart. The four phases
 75// are pointers because rows written before those columns existed have none.
 76type ResponseTimePoint struct {
 77	Label string `json:"label"`
 78	Total int64  `json:"total"`
 79	DNS   *int64 `json:"dns"`
 80	TCP   *int64 `json:"tcp"`
 81	TLS   *int64 `json:"tls"`
 82	TTFB  *int64 `json:"ttfb"`
 83}
 84
 85type LabelCount struct {
 86	Label int64 `json:"label"`
 87	Count int64 `json:"count"`
 88}
 89
 90type LabelPercent struct {
 91	Label string  `json:"label"`
 92	Count float64 `json:"count"`
 93}
 94
 95func msToTime(ms *int64) *time.Time {
 96	if ms == nil {
 97		return nil
 98	}
 99	t := time.UnixMilli(*ms).UTC()
100	return &t
101}
102
103func deref(s *string) string {
104	if s == nil {
105		return ""
106	}
107	return *s
108}
109
110// buildPropertyView computes everything the dashboard shows for one property,
111// over one 100-check window so every figure on it covers the same span.
112func (s *site) buildPropertyView(ctx context.Context, p *Property) (*PropertyView, error) {
113	recent, err := recentChecks(ctx, s.db, p.ID, 100)
114	if err != nil {
115		return nil, err
116	}
117	total, err := countChecks(ctx, s.db, p.ID)
118	if err != nil {
119		return nil, err
120	}
121
122	v := &PropertyView{
123		ID:          p.ID.String(),
124		URL:         p.URL,
125		Name:        p.Name(),
126		IsPublic:    p.IsPublic,
127		IsProtected: p.IsProtected,
128		TotalChecks: total,
129
130		CrawlState:          p.CrawlState,
131		LastCrawlSuccessAt:  msToTime(p.LastCrawlSuccessAt),
132		LastCrawlError:      deref(p.LastCrawlError),
133		LastCrawlDurationMS: p.LastCrawlDurationMS,
134		LastCrawlPagesCount: p.LastCrawlPagesCount,
135		NextRunAtCrawler:    msToTime(p.NextRunAtCrawler),
136		CrawlStartedAt:      msToTime(p.CrawlStartedAt),
137
138		LighthouseState:          p.LighthouseState,
139		LastLighthouseSuccessAt:  msToTime(p.LastLighthouseSuccessAt),
140		LastLighthouseError:      deref(p.LastLighthouseError),
141		LastLighthouseDurationMS: p.LastLighthouseDurationMS,
142		NextLighthouseRunAt:      msToTime(p.NextLighthouseRunAt),
143		LighthouseStartedAt:      msToTime(p.LighthouseStartedAt),
144
145		AlertState: p.AlertState,
146		CreatedAt:  time.UnixMilli(p.CreatedAt).UTC(),
147		UpdatedAt:  time.UnixMilli(p.UpdatedAt).UTC(),
148	}
149
150	// A property with no checks yet reads as 200 rather than 0, so a freshly
151	// added site is not a red zero until its first probe.
152	v.CurrentStatus = 200
153	if len(recent) > 0 {
154		v.CurrentStatus = recent[0].StatusCode
155	}
156
157	// The rolling average covers 31 checks and not 100, so it describes the
158	// last hour and a half.
159	if window := min(len(recent), 31); window > 0 {
160		var sum int64
161		for _, c := range recent[:window] {
162			sum += c.ResponseMS
163		}
164		v.AvgResponseTime = sum / int64(window)
165	}
166
167	if len(recent) > 0 {
168		var up int
169		for _, c := range recent {
170			if c.StatusCode == 200 {
171				up++
172			}
173		}
174		pct := math.Round(float64(up)/float64(len(recent))*1000) / 10
175		v.RecentUptimePct = &pct
176	}
177
178	// Oldest first, so the bar reads left to right like the charts above it.
179	ticks := make([]string, 0, len(recent))
180	for i := len(recent) - 1; i >= 0; i-- {
181		if recent[i].StatusCode == 200 {
182			ticks = append(ticks, "up")
183		} else {
184			ticks = append(ticks, "down")
185		}
186	}
187	if len(ticks) > 30 {
188		ticks = ticks[len(ticks)-30:]
189	}
190	v.RecentTickStream = ticks
191
192	v.applySecurityPosture(recent)
193	v.applyStoredJSON(p)
194
195	return v, nil
196}
197
198var hstsMaxAge = regexp.MustCompile(`max-age=(\d+)`)
199
200// applySecurityPosture reads the latest response's headers rather than probing
201// again, so it costs nothing and can be up to three minutes stale.
202func (v *PropertyView) applySecurityPosture(recent []Check) {
203	headers := map[string]string{}
204	if len(recent) > 0 {
205		var raw map[string]string
206		if err := json.Unmarshal([]byte(recent[0].Headers), &raw); err == nil {
207			for k, val := range raw {
208				headers[strings.ToLower(k)] = strings.ToLower(val)
209			}
210		}
211	}
212
213	v.IsHTTPS = strings.HasPrefix(v.URL, "https://")
214	v.InvalidCert = v.CurrentStatus == 526
215
216	_, v.HasMIMEType = headers["content-type"]
217	v.HasContentSniffProtection = headers["x-content-type-options"] == "nosniff"
218
219	switch headers["x-frame-options"] {
220	case "deny", "sameorigin", "allow-from":
221		v.HasClickjackProtection = true
222	}
223
224	// Four spellings, because all four turn up in the wild and a server
225	// announcing its exact version is naming which CVEs to try.
226	v.HidesServerVersion = true
227	for _, h := range []string{"server", "x-server", "powered-by", "x-powered-by"} {
228		if _, ok := headers[h]; ok {
229			v.HidesServerVersion = false
230			break
231		}
232	}
233
234	hsts := headers["strict-transport-security"]
235	if m := hstsMaxAge.FindStringSubmatch(hsts); m != nil {
236		// A year is what the browser preload lists require, and anything
237		// shorter reports as absent. ParseInt because a twenty digit max-age
238		// is legal text that would overflow a hand-rolled accumulator.
239		if seconds, err := strconv.ParseInt(m[1], 10, 64); err == nil {
240			v.HasHSTS = seconds >= 31_536_000
241		}
242	}
243	v.HasHSTSPreload = strings.Contains(hsts, "preload")
244
245	v.HasSecurityIssue = !v.IsHTTPS ||
246		!v.HasMIMEType ||
247		!v.HasContentSniffProtection ||
248		!v.HasClickjackProtection ||
249		!v.HidesServerVersion ||
250		!v.HasHSTS ||
251		!v.HasHSTSPreload
252}
253
254// applyStoredJSON decodes the three JSON columns, each degrading to empty
255// rather than failing the request, so a malformed result costs one panel.
256func (v *PropertyView) applyStoredJSON(p *Property) {
257	if p.CrawlerInsights != nil {
258		if err := json.Unmarshal([]byte(*p.CrawlerInsights), &v.CrawlerInsights); err != nil {
259			slog.Info(fmt.Sprintf("property %s: crawler insights did not decode: %v", v.ID, err))
260			v.CrawlerInsights = nil
261		}
262	}
263
264	if p.LighthouseScores != nil {
265		var scores Scores
266		if err := json.Unmarshal([]byte(*p.LighthouseScores), &scores); err == nil {
267			v.LighthouseScores = &scores
268			avg := int64(math.Round(float64(
269				scores.Performance+scores.Accessibility+scores.BestPractices+scores.SEO) / 4))
270			v.AvgLighthouseScore = &avg
271		} else {
272			slog.Info(fmt.Sprintf("property %s: lighthouse scores did not decode: %v", v.ID, err))
273		}
274	}
275
276	if p.LighthouseDetails != nil && *p.LighthouseDetails != "null" {
277		var details Details
278		if err := json.Unmarshal([]byte(*p.LighthouseDetails), &details); err == nil {
279			v.LighthouseDetails = &details
280		}
281	}
282}
283
284// groupInsights buckets findings by type and orders each bucket errors first.
285// Types are sorted and the inner sort is stable, so the order holds.
286func groupInsights(insights []Insight) []InsightGroup {
287	buckets := map[string][]Insight{}
288	for _, i := range insights {
289		kind := i.Type
290		if kind == "" {
291			kind = "other"
292		}
293		buckets[kind] = append(buckets[kind], i)
294	}
295
296	kinds := make([]string, 0, len(buckets))
297	for k := range buckets {
298		kinds = append(kinds, k)
299	}
300	sort.Strings(kinds)
301
302	rank := func(sev string) int {
303		switch sev {
304		case sevError:
305			return 0
306		case sevWarn:
307			return 1
308		}
309		return 2
310	}
311
312	groups := make([]InsightGroup, 0, len(kinds))
313	for _, k := range kinds {
314		items := buckets[k]
315		sort.SliceStable(items, func(a, b int) bool {
316			return rank(items[a].Severity) < rank(items[b].Severity)
317		})
318		groups = append(groups, InsightGroup{Type: k, Items: items})
319	}
320	return groups
321}
322
323func (s *site) dashboard(w http.ResponseWriter, r *http.Request, id uuid.UUID) {
324	p, err := getProperty(r.Context(), s.db, id)
325	if err != nil {
326		slog.Info(fmt.Sprintf("dashboard %s: %v", id, err))
327		http.Error(w, "internal server error", http.StatusInternalServerError)
328		return
329	}
330	if p == nil {
331		s.notFound(w, r)
332		return
333	}
334
335	authed := s.auth.Authenticated(r)
336	if !p.IsPublic && !authed {
337		// A redirect to login rather than a 404, so a bookmark lands somewhere
338		// useful. It leaks that the id exists, which is fine for a v4 UUID.
339		http.Redirect(w, r, "/login", http.StatusSeeOther)
340		return
341	}
342
343	view, err := s.buildPropertyView(r.Context(), p)
344	if err != nil {
345		slog.Info(fmt.Sprintf("dashboard %s: %v", id, err))
346		http.Error(w, "internal server error", http.StatusInternalServerError)
347		return
348	}
349
350	data := s.page(r, view.Name, "Status for "+view.Name)
351	data.Property = view
352	data.InsightGroups = groupInsights(view.CrawlerInsights)
353
354	// Report export is operator-only even for a public property, since the PDF
355	// path spawns a Typst compile for anyone who finds the URL.
356	if format := r.URL.Query().Get("report"); format != "" {
357		if !authed {
358			http.Redirect(w, r, "/"+view.ID, http.StatusSeeOther)
359			return
360		}
361		if format != "md" {
362			format = "pdf"
363		}
364		data.GeneratedAt = time.Now().Format("2006-01-02 15:04 MST")
365		s.renderReport(w, r, format, view.Name, data)
366		return
367	}
368
369	recent, err := recentChecks(r.Context(), s.db, id, 31)
370	if err != nil {
371		slog.Info(fmt.Sprintf("dashboard %s charts: %v", id, err))
372	}
373	// Oldest first, so the time axis reads left to right.
374	for i := len(recent) - 1; i >= 0; i-- {
375		c := recent[i]
376		data.ResponseTimes = append(data.ResponseTimes, ResponseTimePoint{
377			Label: time.UnixMilli(c.CreatedAt).UTC().Format(time.RFC3339),
378			Total: c.ResponseMS,
379			DNS:   c.DNSMS,
380			TCP:   c.TCPMS,
381			TLS:   c.TLSMS,
382			TTFB:  c.TTFBMS,
383		})
384	}
385
386	codes, err := countStatusCodes(r.Context(), s.db, id)
387	if err != nil {
388		slog.Info(fmt.Sprintf("dashboard %s status codes: %v", id, err))
389	}
390	for _, c := range codes {
391		data.StatusCodes = append(data.StatusCodes, LabelCount{Label: c.Code, Count: c.Count})
392	}
393
394	up, down, err := countUptime(r.Context(), s.db, id)
395	if err != nil {
396		slog.Info(fmt.Sprintf("dashboard %s uptime: %v", id, err))
397	}
398	pct := func(n int64) float64 {
399		if total := up + down; total > 0 {
400			return math.Round(float64(n)/float64(total)*10000) / 100
401		}
402		return 0
403	}
404	data.UptimeSlices = []LabelPercent{
405		{Label: "Uptime", Count: pct(up)},
406		{Label: "Downtime", Count: pct(down)},
407	}
408
409	data.PageScript = s.propsScript
410	data.PageStyles = s.propsStyles
411	s.renderer.Render(w, http.StatusOK, "property.html", data)
412}
413
414// statusPayload is what the dashboard's polling JavaScript reads. Its shape is
415// fixed by frontend/static_src/properties/scripts/property_crawl_status.js.
416type statusPayload struct {
417	Crawler    crawlerStatus    `json:"crawler"`
418	Lighthouse lighthouseStatus `json:"lighthouse"`
419	ServerTime string           `json:"server_time"`
420	OK         *bool            `json:"ok,omitempty"`
421	Reason     string           `json:"reason,omitempty"`
422}
423
424type crawlerStatus struct {
425	State              string         `json:"state"`
426	StartedAt          *string        `json:"started_at"`
427	LastAttemptAt      *string        `json:"last_attempt_at"`
428	LastSuccessAt      *string        `json:"last_success_at"`
429	LastError          *string        `json:"last_error"`
430	LastDurationMS     *int64         `json:"last_duration_ms"`
431	PagesCount         *int64         `json:"pages_count"`
432	NextRunAt          *string        `json:"next_run_at"`
433	IsOverdue          bool           `json:"is_overdue"`
434	InsightsTotal      int            `json:"insights_total"`
435	InsightsBySeverity map[string]int `json:"insights_by_severity"`
436	Progress           *float64       `json:"progress"`
437}
438
439type lighthouseStatus struct {
440	State          string  `json:"state"`
441	StartedAt      *string `json:"started_at"`
442	LastAttemptAt  *string `json:"last_attempt_at"`
443	LastSuccessAt  *string `json:"last_success_at"`
444	LastError      *string `json:"last_error"`
445	LastDurationMS *int64  `json:"last_duration_ms"`
446	NextRunAt      *string `json:"next_run_at"`
447	IsOverdue      bool    `json:"is_overdue"`
448	Scores         *Scores `json:"scores"`
449}
450
451func isoOrNil(ms *int64) *string {
452	if ms == nil {
453		return nil
454	}
455	s := time.UnixMilli(*ms).UTC().Format(time.RFC3339)
456	return &s
457}
458
459func nilIfEmpty(s *string) *string {
460	if s == nil || *s == "" {
461		return nil
462	}
463	return s
464}
465
466// crawlProgress estimates a running crawl against PageCap, capped at 0.9 so
467// the bar never claims to be finished while it is still working.
468func crawlProgress(p *Property) *float64 {
469	if p.CrawlState != "running" {
470		return nil
471	}
472	pages := int64(0)
473	if p.LastCrawlPagesCount != nil {
474		pages = *p.LastCrawlPagesCount
475	}
476	progress := 0.05
477	if pages > 0 {
478		progress = math.Min(float64(pages)/float64(PageCap), 0.9)
479	}
480	return &progress
481}
482
483func buildStatusPayload(p *Property) statusPayload {
484	now := nowMS()
485
486	severity := map[string]int{sevError: 0, sevWarn: 0, sevInfo: 0}
487	insightsTotal := 0
488	if p.CrawlerInsights != nil {
489		var insights []Insight
490		if err := json.Unmarshal([]byte(*p.CrawlerInsights), &insights); err == nil {
491			insightsTotal = len(insights)
492			for _, i := range insights {
493				sev := i.Severity
494				if _, known := severity[sev]; !known {
495					sev = sevInfo
496				}
497				severity[sev]++
498			}
499		}
500	}
501
502	overdue := func(next *int64) bool { return next != nil && *next <= now }
503
504	payload := statusPayload{
505		Crawler: crawlerStatus{
506			State:              p.CrawlState,
507			StartedAt:          isoOrNil(p.CrawlStartedAt),
508			LastAttemptAt:      isoOrNil(p.LastRunAtCrawler),
509			LastSuccessAt:      isoOrNil(p.LastCrawlSuccessAt),
510			LastError:          nilIfEmpty(p.LastCrawlError),
511			LastDurationMS:     p.LastCrawlDurationMS,
512			PagesCount:         p.LastCrawlPagesCount,
513			NextRunAt:          isoOrNil(p.NextRunAtCrawler),
514			IsOverdue:          overdue(p.NextRunAtCrawler),
515			InsightsTotal:      insightsTotal,
516			InsightsBySeverity: severity,
517			Progress:           crawlProgress(p),
518		},
519		Lighthouse: lighthouseStatus{
520			State:          p.LighthouseState,
521			StartedAt:      isoOrNil(p.LighthouseStartedAt),
522			LastAttemptAt:  isoOrNil(p.LastLighthouseRunAt),
523			LastSuccessAt:  isoOrNil(p.LastLighthouseSuccessAt),
524			LastError:      nilIfEmpty(p.LastLighthouseError),
525			LastDurationMS: p.LastLighthouseDurationMS,
526			NextRunAt:      isoOrNil(p.NextLighthouseRunAt),
527			IsOverdue:      overdue(p.NextLighthouseRunAt),
528		},
529		ServerTime: time.Now().UTC().Format(time.RFC3339),
530	}
531
532	if p.LighthouseScores != nil {
533		var scores Scores
534		if err := json.Unmarshal([]byte(*p.LighthouseScores), &scores); err == nil {
535			payload.Lighthouse.Scores = &scores
536		}
537	}
538
539	return payload
540}
541
542// writeJSON is the single place a JSON response is written, so the header and
543// the encoder settings cannot drift between endpoints.
544func writeJSON(w http.ResponseWriter, status int, payload any) {
545	w.Header().Set("Content-Type", "application/json; charset=utf-8")
546	w.WriteHeader(status)
547	enc := json.NewEncoder(w)
548	// Go escapes <, > and & by default, which corrupts a response fetch()
549	// parses, and these error strings come from crawled sites.
550	enc.SetEscapeHTML(false)
551	if err := enc.Encode(payload); err != nil {
552		slog.Info(fmt.Sprintf("write json: %v", err))
553	}
554}
555
556func (s *site) propertyStatus(w http.ResponseWriter, r *http.Request) {
557	p, ok := s.lookupForJSON(w, r)
558	if !ok {
559		return
560	}
561	if !p.IsPublic && !s.auth.Authenticated(r) {
562		writeJSON(w, http.StatusForbidden, map[string]any{"error": "forbidden"})
563		return
564	}
565	writeJSON(w, http.StatusOK, buildStatusPayload(p))
566}
567
568// lookupForJSON resolves the {id} path value, answering in JSON on failure.
569func (s *site) lookupForJSON(w http.ResponseWriter, r *http.Request) (*Property, bool) {
570	id, err := uuid.Parse(r.PathValue("id"))
571	if err != nil {
572		writeJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"})
573		return nil, false
574	}
575	p, err := getProperty(r.Context(), s.db, id)
576	if err != nil {
577		slog.Info(fmt.Sprintf("lookup %s: %v", id, err))
578		writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "server_error"})
579		return nil, false
580	}
581	if p == nil {
582		writeJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"})
583		return nil, false
584	}
585	return p, true
586}
587
588// requeue is the shared body of the two "run it now" buttons. It runs nothing
589// itself, it moves the due time into the past for the scheduler to pick up.
590func (s *site) requeue(w http.ResponseWriter, r *http.Request, kind string) {
591	p, ok := s.lookupForJSON(w, r)
592	if !ok {
593		return
594	}
595
596	var state, dueColumn, errColumn string
597	switch kind {
598	case "crawl":
599		state, dueColumn, errColumn = p.CrawlState, "next_run_at_crawler", "last_crawl_error"
600	default:
601		state, dueColumn, errColumn = p.LighthouseState, "next_lighthouse_run_at", "last_lighthouse_error"
602	}
603
604	if state == "queued" || state == "running" {
605		payload := buildStatusPayload(p)
606		no := false
607		payload.OK = &no
608		payload.Reason = "already_running"
609		writeJSON(w, http.StatusConflict, payload)
610		return
611	}
612
613	now := nowMS()
614	if _, err := s.db.ExecContext(r.Context(),
615		"UPDATE properties SET "+dueColumn+" = ?, "+errColumn+" = NULL, updated_at = ? WHERE id = ?",
616		now, now, p.ID[:]); err != nil {
617		slog.Info(fmt.Sprintf("requeue %s for %s: %v", kind, p.URL, err))
618		writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": "server_error"})
619		return
620	}
621
622	updated, err := getProperty(r.Context(), s.db, p.ID)
623	if err != nil || updated == nil {
624		updated = p
625	}
626	payload := buildStatusPayload(updated)
627	yes := true
628	payload.OK = &yes
629	writeJSON(w, http.StatusOK, payload)
630}
631
632func (s *site) propertyRecrawl(w http.ResponseWriter, r *http.Request) {
633	s.requeue(w, r, "crawl")
634}
635
636func (s *site) propertyRerunLighthouse(w http.ResponseWriter, r *http.Request) {
637	s.requeue(w, r, "lighthouse")
638}