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

6.1 KB · 208 lines · Go Raw History
  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}