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 "encoding/json"
5 "fmt"
6 "log/slog"
7 "net/http"
8 "strings"
9 "time"
10
11 "github.com/google/uuid"
12)
13
14// PropertyRow is one row of the list, with its counts already gathered.
15type PropertyRow struct {
16 ID string
17 Name string
18 IsProtected bool
19 IsPublic bool
20 IsActive bool
21 TotalEvents int64
22 TotalPageViews int64
23 TotalSessionStarts int64
24}
25
26// PropertyTotals is the summary strip above the list.
27type PropertyTotals struct {
28 Properties int
29 Events int64
30 PageViews int64
31 Sessions int64
32}
33
34// activeWindow is how recently a property must have seen an event to show as
35// live. A week, since a personal site can go quiet for days without being broken.
36const activeWindow = 7 * 24 * time.Hour
37
38func (s *site) properties(w http.ResponseWriter, r *http.Request) {
39 ctx := r.Context()
40 search := strings.TrimSpace(r.URL.Query().Get("q"))
41
42 query := "SELECT " + propertyColumns + " FROM properties"
43 var args []any
44 if search != "" {
45 query += " WHERE name LIKE ?"
46 args = append(args, "%"+search+"%")
47 }
48 // Protected first, so this site's own property leads the list.
49 query += " ORDER BY is_protected DESC, created_at ASC"
50
51 rows, err := s.db.QueryContext(ctx, query, args...)
52 if err != nil {
53 slog.Info(fmt.Sprintf("properties list: %v", err))
54 http.Error(w, "database error", http.StatusInternalServerError)
55 return
56 }
57 defer rows.Close()
58
59 var props []*Property
60 for rows.Next() {
61 p, err := scanProperty(rows.Scan)
62 if err != nil {
63 slog.Info(fmt.Sprintf("properties scan: %v", err))
64 continue
65 }
66 props = append(props, p)
67 }
68
69 listed := make([]PropertyRow, 0, len(props))
70 var totals PropertyTotals
71 activeSince := time.Now().Add(-activeWindow).UnixMilli()
72
73 for _, p := range props {
74 var total, pv, ss, active int64
75 // Four correlated subqueries rather than four round trips. Still N+1
76 // across the list, which is fine at this scale.
77 err := s.db.QueryRowContext(ctx, `SELECT
78 (SELECT COUNT(*) FROM events WHERE property_id = ?1),
79 (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND event = 'page_view'),
80 (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND event = 'session_start'),
81 (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND created_at >= ?2)`,
82 p.ID[:], activeSince).Scan(&total, &pv, &ss, &active)
83 if err != nil {
84 slog.Info(fmt.Sprintf("properties counts for %s: %v", p.ID, err))
85 }
86
87 totals.Events += total
88 totals.PageViews += pv
89 totals.Sessions += ss
90
91 listed = append(listed, PropertyRow{
92 ID: p.ID.String(),
93 Name: p.Name,
94 IsProtected: p.IsProtected,
95 IsPublic: p.IsPublic,
96 IsActive: active > 0,
97 TotalEvents: total,
98 TotalPageViews: pv,
99 TotalSessionStarts: ss,
100 })
101 }
102 totals.Properties = len(listed)
103
104 data := s.page(r, "Properties", "Manage your properties.")
105 data.Properties = listed
106 data.Totals = totals
107 data.Query = search
108 s.renderer.Render(w, http.StatusOK, "properties.html", data)
109}
110
111func (s *site) propertyCreate(w http.ResponseWriter, r *http.Request) {
112 if err := r.ParseForm(); err != nil {
113 http.Error(w, "bad request", http.StatusBadRequest)
114 return
115 }
116 name := strings.TrimSpace(r.PostFormValue("name"))
117 if name == "" {
118 http.Redirect(w, r, "/properties", http.StatusSeeOther)
119 return
120 }
121
122 id := uuid.New()
123 now := time.Now().UnixMilli()
124 if _, err := s.db.ExecContext(r.Context(),
125 `INSERT INTO properties (id, name, custom_cards, is_protected, is_public, created_at, updated_at)
126 VALUES (?, ?, '[]', 0, 0, ?, ?)`,
127 id[:], name, now, now); err != nil {
128 slog.Info(fmt.Sprintf("property create: %v", err))
129 http.Error(w, "database error", http.StatusInternalServerError)
130 return
131 }
132 http.Redirect(w, r, "/properties", http.StatusSeeOther)
133}
134
135// propertyDelete removes a property and, by ON DELETE CASCADE, its events. The
136// is_protected guard is in the WHERE clause because a hidden button in the
137// template is not a permission.
138func (s *site) propertyDelete(w http.ResponseWriter, r *http.Request) {
139 id, ok := parseIDPath(r)
140 if !ok {
141 http.NotFound(w, r)
142 return
143 }
144 if _, err := s.db.ExecContext(r.Context(),
145 "DELETE FROM properties WHERE id = ? AND is_protected = 0", id[:]); err != nil {
146 slog.Info(fmt.Sprintf("property delete: %v", err))
147 }
148 http.Redirect(w, r, "/properties", http.StatusSeeOther)
149}
150
151// propertyCards stores which custom events are pinned as dashboard tiles. The
152// body is re-encoded, so the column always holds JSON of a known shape.
153func (s *site) propertyCards(w http.ResponseWriter, r *http.Request) {
154 id, ok := parseIDPath(r)
155 if !ok {
156 http.NotFound(w, r)
157 return
158 }
159
160 var cards []CustomCard
161 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&cards); err != nil {
162 cards = []CustomCard{}
163 }
164 encoded, err := json.Marshal(cards)
165 if err != nil {
166 encoded = []byte("[]")
167 }
168
169 if _, err := s.db.ExecContext(r.Context(),
170 "UPDATE properties SET custom_cards = ?, updated_at = ? WHERE id = ?",
171 string(encoded), time.Now().UnixMilli(), id[:]); err != nil {
172 slog.Info(fmt.Sprintf("property cards: %v", err))
173 writeJSON(w, http.StatusInternalServerError, map[string]any{"success": false})
174 return
175 }
176 writeJSON(w, http.StatusOK, map[string]any{"success": true})
177}
178
179// propertyPublic flips whether a dashboard is readable without logging in.
180func (s *site) propertyPublic(w http.ResponseWriter, r *http.Request) {
181 id, ok := parseIDPath(r)
182 if !ok {
183 http.NotFound(w, r)
184 return
185 }
186 if _, err := s.db.ExecContext(r.Context(),
187 "UPDATE properties SET is_public = 1 - is_public, updated_at = ? WHERE id = ?",
188 time.Now().UnixMilli(), id[:]); err != nil {
189 slog.Info(fmt.Sprintf("property public toggle: %v", err))
190 writeJSON(w, http.StatusInternalServerError, map[string]any{"success": false})
191 return
192 }
193 writeJSON(w, http.StatusOK, map[string]any{"success": true})
194}
195
196func parseIDPath(r *http.Request) (uuid.UUID, bool) {
197 id, err := uuid.Parse(r.PathValue("id"))
198 return id, err == nil
199}
200
201func writeJSON(w http.ResponseWriter, status int, v any) {
202 w.Header().Set("Content-Type", "application/json; charset=utf-8")
203 w.WriteHeader(status)
204 enc := json.NewEncoder(w)
205 enc.SetEscapeHTML(false)
206 _ = enc.Encode(v)
207}