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

5.4 KB · 184 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"database/sql"
  6	"encoding/json"
  7	"fmt"
  8	"os"
  9	"path/filepath"
 10	"time"
 11
 12	"github.com/google/uuid"
 13	_ "modernc.org/sqlite"
 14)
 15
 16// There is no migration table. Every statement is IF NOT EXISTS, so this runs
 17// against an existing database as a no-op; a schema change is a new guarded block.
 18const schema = `
 19CREATE TABLE IF NOT EXISTS properties (
 20    id            BLOB PRIMARY KEY,
 21    name          TEXT NOT NULL,
 22    custom_cards  TEXT NOT NULL DEFAULT '[]',
 23    is_protected  INTEGER NOT NULL DEFAULT 0,
 24    is_public     INTEGER NOT NULL DEFAULT 0,
 25    created_at    INTEGER NOT NULL,
 26    updated_at    INTEGER NOT NULL
 27);
 28
 29CREATE TABLE IF NOT EXISTS events (
 30    id              INTEGER PRIMARY KEY AUTOINCREMENT,
 31    property_id     BLOB NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
 32    event           TEXT NOT NULL,
 33    created_at      INTEGER NOT NULL,
 34    user_id         TEXT,
 35    url             TEXT,
 36    title           TEXT,
 37    referrer        TEXT,
 38    user_agent      TEXT,
 39    platform        TEXT,
 40    browser         TEXT,
 41    device          TEXT,
 42    screen_width    INTEGER,
 43    screen_height   INTEGER,
 44    country         TEXT,
 45    region          TEXT,
 46    city            TEXT,
 47    lat             REAL,
 48    lon             REAL,
 49    utm_source      TEXT,
 50    utm_medium      TEXT,
 51    utm_campaign    TEXT,
 52    utm_term        TEXT,
 53    utm_content     TEXT,
 54    time_on_page_ms INTEGER,
 55    extra           TEXT NOT NULL DEFAULT '{}'
 56);
 57CREATE INDEX IF NOT EXISTS events_property_created       ON events(property_id, created_at);
 58CREATE INDEX IF NOT EXISTS events_property_event_created ON events(property_id, event, created_at);
 59
 60CREATE TABLE IF NOT EXISTS bot_events (
 61    id           INTEGER PRIMARY KEY AUTOINCREMENT,
 62    property_id  BLOB NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
 63    event        TEXT NOT NULL,
 64    created_at   INTEGER NOT NULL,
 65    bot_name     TEXT,
 66    url          TEXT,
 67    user_agent   TEXT,
 68    country      TEXT,
 69    extra        TEXT NOT NULL DEFAULT '{}'
 70);
 71CREATE INDEX IF NOT EXISTS bot_events_property_created ON bot_events(property_id, created_at);
 72
 73`
 74
 75// openDB opens the SQLite database and applies the schema. modernc.org/sqlite is
 76// pure Go, which keeps CGO_ENABLED=0. Pragmas go in the DSN because they are per
 77// connection and database/sql opens connections lazily.
 78func openDB(path string) (*sql.DB, error) {
 79	if dir := filepath.Dir(path); dir != "" {
 80		if err := os.MkdirAll(dir, 0o755); err != nil {
 81			return nil, fmt.Errorf("create data dir: %w", err)
 82		}
 83	}
 84
 85	dsn := path +
 86		"?_pragma=journal_mode(WAL)" +
 87		"&_pragma=synchronous(NORMAL)" +
 88		"&_pragma=busy_timeout(5000)" +
 89		// Lets the retention sweep hand freed pages back. It only takes effect
 90		// on an empty file, so a database made before this keeps auto_vacuum
 91		// NONE and needs a one-off VACUUM to convert.
 92		"&_pragma=auto_vacuum(INCREMENTAL)" +
 93		"&_pragma=foreign_keys(ON)"
 94
 95	db, err := sql.Open("sqlite", dsn)
 96	if err != nil {
 97		return nil, fmt.Errorf("open sqlite: %w", err)
 98	}
 99
100	// SQLite writes under a database-wide lock; the pool is sized for the
101	// readers WAL lets run alongside a write.
102	db.SetMaxOpenConns(8)
103	db.SetMaxIdleConns(8)
104	db.SetConnMaxLifetime(time.Hour)
105
106	if err := db.Ping(); err != nil {
107		return nil, fmt.Errorf("ping sqlite: %w", err)
108	}
109	if _, err := db.Exec(schema); err != nil {
110		return nil, fmt.Errorf("apply schema: %w", err)
111	}
112	if _, err := db.Exec(renameProprium); err != nil {
113		return nil, fmt.Errorf("rename proprium: %w", err)
114	}
115	return db, nil
116}
117
118// renameProprium is a one-time data migration. It is idempotent and matches
119// nothing once it has run, so it can be deleted once every database has.
120const renameProprium = `
121UPDATE properties
122   SET name = 'analytics.bythewood.me',
123       updated_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000
124 WHERE name = 'Proprium';
125DROP TABLE IF EXISTS meta;
126`
127
128// Property is one tracked site.
129type Property struct {
130	ID          uuid.UUID
131	Name        string
132	CustomCards []CustomCard
133	IsProtected bool
134	IsPublic    bool
135	CreatedAt   int64
136	UpdatedAt   int64
137}
138
139// CustomCard is a custom event pinned to the dashboard as a metric card. Value
140// is the pinned flag, named for what the stored JSON already calls it.
141type CustomCard struct {
142	Event string `json:"event"`
143	Value bool   `json:"value"`
144}
145
146const propertyColumns = `id, name, custom_cards, is_protected, is_public, created_at, updated_at`
147
148// scanProperty reads one row of propertyColumns. Malformed custom_cards JSON
149// degrades to no cards rather than failing the request.
150func scanProperty(scan func(...any) error) (*Property, error) {
151	var (
152		p     Property
153		idRaw []byte
154		cards string
155		prot  int64
156		pub   int64
157	)
158	if err := scan(&idRaw, &p.Name, &cards, &prot, &pub, &p.CreatedAt, &p.UpdatedAt); err != nil {
159		return nil, err
160	}
161	id, err := uuid.FromBytes(idRaw)
162	if err != nil {
163		return nil, fmt.Errorf("property id is not a uuid: %w", err)
164	}
165	p.ID = id
166	p.IsProtected = prot != 0
167	p.IsPublic = pub != 0
168	if err := json.Unmarshal([]byte(cards), &p.CustomCards); err != nil {
169		p.CustomCards = nil
170	}
171	return &p, nil
172}
173
174// lookupProperty returns the property with this id, or nil when there is none.
175func lookupProperty(ctx context.Context, db *sql.DB, id uuid.UUID) (*Property, error) {
176	row := db.QueryRowContext(ctx,
177		"SELECT "+propertyColumns+" FROM properties WHERE id = ?", id[:])
178	p, err := scanProperty(row.Scan)
179	if err == sql.ErrNoRows {
180		return nil, nil
181	}
182	return p, err
183}