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
1package main
2
3import (
4 "database/sql"
5 "fmt"
6 "html/template"
7 "log/slog"
8 "net/http"
9 "time"
10)
11
12// PageData is everything every template needs. A struct rather than a map:
13// html/template errors on a missing field but renders nothing for a missing key.
14type PageData struct {
15 Title string
16 Description string
17 Path string
18 Canonical string
19 Staging bool
20 Analytics bool
21 AnalyticsID string
22 Authenticated bool
23 Year int
24 BaseURL string
25 SourceURL string
26 SiteName string
27 AuthorName string
28
29 OGImage string
30 JSONLD template.JS
31 Script string
32 Styles []string
33 PageScript string
34 PageStyles []string
35
36 // Login
37 Next string
38 Error string
39
40 // Home
41 TotalChecks int64
42 TotalProperties int64
43 FirstCheckAt string
44
45 // Properties list and dashboard
46 Properties []*PropertyView
47 Query string
48 Property *PropertyView
49 InsightGroups []InsightGroup
50 ResponseTimes []ResponseTimePoint
51 StatusCodes []LabelCount
52 UptimeSlices []LabelPercent
53 GeneratedAt string
54}
55
56func (s *site) home(w http.ResponseWriter, r *http.Request) {
57 if s.auth.Authenticated(r) {
58 http.Redirect(w, r, "/properties", http.StatusSeeOther)
59 return
60 }
61
62 data := s.page(r, "Self-hosted uptime monitoring",
63 "Self-hosted uptime monitoring with public status pages, response time history, "+
64 "Lighthouse audits and crawl findings.")
65
66 var firstCheck sql.NullInt64
67 err := s.db.QueryRowContext(r.Context(),
68 `SELECT (SELECT COUNT(*) FROM checks),
69 (SELECT COUNT(*) FROM properties),
70 (SELECT MIN(created_at) FROM checks)`).
71 Scan(&data.TotalChecks, &data.TotalProperties, &firstCheck)
72 if err != nil {
73 // These numbers are decoration, so an error renders zeros rather than a 500.
74 slog.Info(fmt.Sprintf("home totals: %v", err))
75 }
76 if firstCheck.Valid {
77 data.FirstCheckAt = time.UnixMilli(firstCheck.Int64).UTC().Format("Jan 2, 2006")
78 }
79
80 data.PageScript = s.pagesScript
81 data.PageStyles = s.pagesStyles
82 s.renderer.Render(w, http.StatusOK, "home.html", data)
83}
84
85func favicon(w http.ResponseWriter, r *http.Request) {
86 w.Header().Set("Content-Type", "image/svg+xml")
87 w.Header().Set("Cache-Control", "public, max-age=86400")
88 _, _ = w.Write([]byte(faviconSVG))
89}
90
91const faviconSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
92<polyline points="2,34 18,34 24,28 30,14 36,52 42,20 48,34 62,34" fill="none" stroke="#6b9e78" stroke-width="6" stroke-linejoin="round" stroke-linecap="round"/>
93<circle cx="30" cy="14" r="3.5" fill="#c9a84c"/>
94</svg>`
95
96// robots refuses the crawl on staging. Both this and the noindex meta tag in
97// base.html are needed: a crawler obeying robots.txt never fetches the tag.
98func robots(w http.ResponseWriter, r *http.Request) {
99 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
100 if Staging {
101 _, _ = w.Write([]byte("User-agent: *\nDisallow: /\n"))
102 return
103 }
104 _, _ = fmt.Fprintf(w, "User-agent: *\nAllow: /\nSitemap: %s/sitemap.xml\n", baseURL)
105}
106
107// sitemap lists the one public page. Property dashboards stay out even when
108// public: a status page is handed to somebody, not searched for.
109func sitemap(w http.ResponseWriter, r *http.Request) {
110 w.Header().Set("Content-Type", "application/xml; charset=utf-8")
111 today := time.Now().UTC().Format("2006-01-02")
112 _, _ = fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
113<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
114 <url><loc>%s/</loc><lastmod>%s</lastmod></url>
115</urlset>
116`, baseURL, today)
117}