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 "log/slog"
7 "net/http"
8 "time"
9)
10
11// home is the public front page, and a redirect to /properties once logged in.
12func (s *site) home(w http.ResponseWriter, r *http.Request) {
13 if s.auth.Authenticated(r) {
14 http.Redirect(w, r, "/properties", http.StatusSeeOther)
15 return
16 }
17
18 var properties, events int64
19 var first sql.NullInt64
20 err := s.db.QueryRowContext(r.Context(), `SELECT
21 (SELECT COUNT(*) FROM properties),
22 (SELECT COUNT(*) FROM events),
23 (SELECT MIN(created_at) FROM events)`).Scan(&properties, &events, &first)
24 if err != nil {
25 // Missing totals still render a usable page, so this is not fatal.
26 slog.Info(fmt.Sprintf("home totals: %v", err))
27 }
28
29 data := s.page(r, "Self-hosted analytics",
30 "Self-hosted website analytics. Page views, clicks, scrolls, sessions, and custom events.")
31 data.PageScript = s.pagesScript
32 data.PageStyles = s.pagesStyles
33 data.TotalProperties = properties
34 data.TotalEvents = events
35 // There is no user table to count; this instance has one operator.
36 data.TotalUsers = 1
37 if first.Valid {
38 data.FirstEventAt = time.UnixMilli(first.Int64).Format("Jan 2, 2006")
39 }
40
41 s.renderer.Render(w, http.StatusOK, "home.html", data)
42}
43
44func (s *site) documentation(w http.ResponseWriter, r *http.Request) {
45 data := s.page(r, "Documentation", "How to embed and operate Analytics.")
46 data.PageScript = s.pagesScript
47 data.PageStyles = s.pagesStyles
48 s.renderer.Render(w, http.StatusOK, "documentation.html", data)
49}
50
51// Inline rather than a file so it cannot drift from the navbar logo, which is
52// this same markup.
53const faviconSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
54 <rect x="6" y="38" width="10" height="22" rx="1.5" fill="#6b9e78"/>
55 <rect x="20" y="28" width="10" height="32" rx="1.5" fill="#6b9e78"/>
56 <rect x="34" y="18" width="10" height="42" rx="1.5" fill="#6b9e78"/>
57 <rect x="48" y="8" width="10" height="52" rx="1.5" fill="#6b9e78"/>
58 <rect x="48" y="8" width="10" height="6" rx="1.5" fill="#c9a84c"/>
59</svg>`
60
61func favicon(w http.ResponseWriter, r *http.Request) {
62 w.Header().Set("Content-Type", "image/svg+xml")
63 w.Header().Set("Cache-Control", "public, max-age=86400")
64 _, _ = w.Write([]byte(faviconSVG))
65}
66
67// robots keeps a staging hostname out of search results. It duplicates the
68// noindex in base.html because a meta tag is only read if the page is fetched.
69func robots(w http.ResponseWriter, r *http.Request) {
70 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
71 if Staging {
72 _, _ = w.Write([]byte("User-agent: *\nDisallow: /\n"))
73 return
74 }
75 _, _ = w.Write([]byte("User-agent: *\nAllow: /\nSitemap: " + baseURL + "/sitemap.xml\n"))
76}
77
78// sitemap lists the public pages. Dashboards stay out even when public, since
79// that URL is one an owner chose to hand out.
80func sitemap(w http.ResponseWriter, r *http.Request) {
81 w.Header().Set("Content-Type", "application/xml; charset=utf-8")
82
83 now := time.Now().Format("2006-01-02")
84 var b []byte
85 b = append(b, `<?xml version="1.0" encoding="UTF-8"?>`+"\n"...)
86 b = append(b, `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`+"\n"...)
87 for _, path := range []string{"/", "/documentation"} {
88 b = append(b, " <url><loc>"+baseURL+path+"</loc><lastmod>"+now+"</lastmod></url>\n"...)
89 }
90 b = append(b, "</urlset>\n"...)
91 _, _ = w.Write(b)
92}