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

3.3 KB · 93 lines · Go Raw History
 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}