Files
puppy-tracker/server/main.go
T
Alexander Heldt 5a08fb4510 Let a meal say what kind of food it was
Grams alone put dry and fresh in the same total, so the log could not show that
fresh had been creeping up or that a soft stomach followed a switch. A meal can
now carry a kind the user names themselves.

The whole thing is optional, and that constraint shaped most of it. "No kind"
is a real value rather than a missing one: it is what every meal already logged
carries, so nothing needed migrating; it is always offered in the picker; and
with no kinds defined the picker, the legend and the split are all absent, so
the app is byte-for-byte the one it was for anyone who never wants this. The
checks cover that case specifically, because it is the one nobody would notice
breaking.

Kinds are a third synced collection beside events and exercises, with the same
contract — uuid ids, per-item last-write-wins, tombstoned deletes — so renaming
a kind updates the meals logged as it, and deleting one leaves them readable
under the name the tombstone kept. FoodKindStore duplicates ExerciseStore
closely; Store and ExerciseStore were already near-twins, so a third in that
shape is this file's pattern and leaves two working collections untouched.
Folding all three into one store over a table name is the tidier end state and
a separate job.

Two decisions worth naming. The default kind is a flag on the kind rather than
a profile field: the profile is last-write-wins across the whole row, and this
codebase already carries a special case for pedigree_id because that dropped a
value once — per-item LWW means two devices that each choose a default resolve
to the newer instead. And each kind keeps a colorIndex fixed at creation, so
deleting one never repaints the charts of the kinds around it.

The bars stack by kind with a line fitted per kind. Each line sits at that
kind's own daily amount rather than at the top of its segment: the segment's
height is what the kind ate, but its position is an accident of what is stacked
beneath it. So a line can cross a segment it does not belong to — dashed and in
the kind's colour, with the figures named underneath either way.

One sentence per kind would grow with the list, so only kinds whose move beats
their own scatter get one and the rest fold into a clause. Both tests are ones
foodTrend already applied; nothing new is being claimed.

A guest labels a meal with a kind that exists but cannot add, rename or delete
one, exactly as with the exercise library.
2026-09-22 10:31:27 +00:00

1231 lines
42 KiB
Go

package main
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
At int64 `json:"at"`
Note string `json:"note"`
PhotoID string `json:"photoId,omitempty"` // photo UUIDs, comma-separated (legacy events hold one)
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
// FoodKindID names which sort of food, for "eat" events. Empty is a real
// answer — "no kind" — and is what every meal logged before kinds existed
// carries, so none of them needed rewriting.
FoodKindID string `json:"foodKindId,omitempty"`
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
// LoggedBy names the guest link an event was logged through, empty for the
// owner's own. It is stamped by the server from the session (see Store.sync)
// and never read off the wire, so a client can neither forge nor rewrite it.
LoggedBy string `json:"loggedBy,omitempty"`
// LoggedByShare is that link's id. LoggedBy is a label the owner typed and
// two links may well share one ("Sitter"), so the id — not the label — is
// what decides whether a guest may change this event. Sent to the client so
// it can grey out what it isn't allowed to touch; opaque and harmless.
LoggedByShare string `json:"loggedByShare,omitempty"`
}
// Exercise is a user-defined training exercise (e.g. "Sit", "Leash walking"):
// a name plus optional instruction text. Training sessions reference one by
// ExerciseID on the event. Exercises sync exactly like events: UUID ids,
// last-write-wins on UpdatedAt, tombstoned deletes.
type Exercise struct {
ID string `json:"id"`
Name string `json:"name"`
Note string `json:"note"` // instructions / reminder how to do it
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
}
// eventTypeDayExcluded marks a day the owner has taken out of the charts and
// averages — a sitter's thin day, a stay at kennels. It is an event so it rides
// the ordinary sync (per-item last-write-wins, tombstone to un-mark) rather than
// needing a table and endpoint of its own; the client reads it in app.js.
const eventTypeDayExcluded = "day-excluded"
var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
func validUUID(s string) bool { return uuidRE.MatchString(s) }
var birthdayRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
func validBirthday(s string) bool { return s == "" || birthdayRE.MatchString(s) }
// Config is the shared puppy profile (name + birthday). It lives on the host so
// every client sees the same values without configuring each device. UpdatedAt
// drives last-write-wins, mirroring how events sync.
type Config struct {
Name string `json:"name"`
Birthday string `json:"birthday"`
// PedigreeID is the dog's SKK chip or registration number. When set, the app
// unlocks the pedigree view and looks this dog up; empty means no pedigree.
PedigreeID string `json:"pedigreeId"`
UpdatedAt int64 `json:"updatedAt"`
}
type ConfigStore struct {
db *sql.DB
}
func newConfigStore(db *sql.DB) *ConfigStore {
return &ConfigStore{db: db}
}
func (cs *ConfigStore) get(userID string) Config {
var c Config
// One profile row per user. A missing row is the pre-configuration state,
// so a zero-value Config is the right answer.
err := cs.db.QueryRow(
`SELECT name, birthday, pedigree_id, updated FROM config WHERE user_id = ?`, userID,
).Scan(&c.Name, &c.Birthday, &c.PedigreeID, &c.UpdatedAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
log.Printf("config get: %v", err)
}
return c
}
// merge applies an incoming config for one user with last-write-wins by
// UpdatedAt and returns the resulting stored config (which the caller sends back).
func (cs *ConfigStore) merge(userID string, in Config) (Config, error) {
// Name/birthday/updated are last-write-wins: the incoming row replaces the
// stored one only when strictly newer. The pedigree id is stickier — an empty
// incoming value never clears a stored one, so a clock race between devices
// can't drop it; when both are set, the newer profile's id wins with the rest.
_, err := cs.db.Exec(`
INSERT INTO config (user_id, name, birthday, pedigree_id, updated)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
name = excluded.name, birthday = excluded.birthday,
pedigree_id = CASE WHEN excluded.pedigree_id != '' THEN excluded.pedigree_id ELSE config.pedigree_id END,
updated = excluded.updated
WHERE excluded.updated > config.updated`,
userID, in.Name, in.Birthday, in.PedigreeID, in.UpdatedAt)
if err != nil {
return Config{}, err
}
// Adopt a pedigree id the server is missing even from an older-stamped profile,
// so a device that set it isn't blocked by another device's newer name/birthday
// edit. (A set id is only ever changed by a newer profile that also sets one.)
if in.PedigreeID != "" {
if _, err := cs.db.Exec(
`UPDATE config SET pedigree_id = ? WHERE user_id = ? AND pedigree_id = ''`,
in.PedigreeID, userID); err != nil {
return Config{}, err
}
}
return cs.get(userID), nil
}
type Store struct {
db *sql.DB
}
func newStore(db *sql.DB) *Store {
return &Store{db: db}
}
// sync merges one user's client events into the store using last-write-wins by
// UpdatedAt, then returns that user's full merged set (tombstones included, as
// they must propagate). loggedBy/shareID describe the caller's session — the
// guest link's label and id, both empty for the owner. They are stamped onto
// events this call inserts, and shareID additionally decides which existing
// events the caller is allowed to change.
func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
// The WHERE clause on the upsert is the last-write-wins rule: an incoming
// event only overwrites the stored one when its updatedAt is strictly newer.
// Two further guards ride on it:
//
// - events.user_id = excluded.user_id — one user can never clobber
// another's row even if a client forges a colliding event ID; the row
// stays put and, because reads are scoped, stays invisible to them.
// - the logged_by_share clause — an owner (excluded.logged_by_share = '')
// may change anything; a guest may only change events logged through
// their own link. So a sitter can fix up their own entries, and cannot
// edit or delete a single one of the owner's. A rejected row simply
// stays as it was, and the caller gets the stored version back.
//
// The two attribution columns are deliberately absent from the DO UPDATE SET
// list: attribution is decided once, by whoever first inserted the event, and
// a later edit by anyone leaves it alone. That is also what makes it
// unspoofable — a guest re-POSTs the owner's whole event list on every sync,
// but those rows already exist and so keep their stored values.
stmt, err := tx.Prepare(`
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id, logged_by, logged_by_share, food_kind_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight,
grams = excluded.grams, exercise_id = excluded.exercise_id,
food_kind_id = excluded.food_kind_id,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id
AND (excluded.logged_by_share = ''
OR events.logged_by_share = excluded.logged_by_share)`)
if err != nil {
return nil, err
}
defer stmt.Close()
for _, ce := range client {
if ce.ID == "" {
continue
}
// Marking a day as not counted is a judgment about the record rather
// than something that happened to the puppy, so it belongs to the owner
// alongside everything else a guest may not decide. The client hides the
// control; this is what enforces it.
if shareID != "" && ce.Type == eventTypeDayExcluded {
continue
}
if _, err := stmt.Exec(
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, loggedBy, shareID, ce.FoodKindID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.all(userID)
}
// all returns one user's events, tombstones included.
func (s *Store) all(userID string) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, logged_by, logged_by_share, food_kind_id
FROM events WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Event, 0)
for rows.Next() {
var e Event
if err := rows.Scan(
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.LoggedBy, &e.LoggedByShare, &e.FoodKindID,
); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// FoodKind is a user-named sort of food ("Dry", "Fresh"), referenced by
// FoodKindID on an "eat" event. Empty means no kind, which is what every meal
// logged before kinds existed carries and what anyone who doesn't want to
// classify their food keeps carrying.
//
// Same contract as Exercise — UUID ids, last-write-wins on UpdatedAt,
// tombstoned deletes — plus two fields of its own:
//
// - IsDefault marks the kind the log dialog pre-selects. It lives here rather
// than in the profile because the profile is last-write-wins across the
// whole row, and this file already carries a special case for pedigree_id
// to stop a clock race dropping it. Per-item LWW needs no such case: two
// devices setting different defaults resolve to the newer one.
// - ColorIndex fixes which palette entry the charts give it, assigned at
// creation. Deriving colour from position in the live list would silently
// recolour every past chart the moment a kind was deleted.
type FoodKind struct {
ID string `json:"id"`
Name string `json:"name"`
IsDefault bool `json:"isDefault,omitempty"`
ColorIndex int `json:"colorIndex"`
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
}
// FoodKindStore is ExerciseStore for food kinds. The duplication is deliberate:
// Store and ExerciseStore are already near-twins, so a third in the same shape
// is the pattern this file has established, and it leaves both working
// collections untouched. Folding all three into one store parameterised by
// table name is the tidier end state, and a separate job.
type FoodKindStore struct {
db *sql.DB
}
func newFoodKindStore(db *sql.DB) *FoodKindStore {
return &FoodKindStore{db: db}
}
func (s *FoodKindStore) sync(userID string, client []FoodKind) ([]FoodKind, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO food_kinds (id, name, is_default, color_index, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, is_default = excluded.is_default,
color_index = excluded.color_index,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > food_kinds.updated
AND food_kinds.user_id = excluded.user_id`)
if err != nil {
return nil, err
}
defer stmt.Close()
for _, k := range client {
if k.ID == "" {
continue
}
if _, err := stmt.Exec(
k.ID, k.Name, k.IsDefault, k.ColorIndex, k.UpdatedAt, k.Deleted, userID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.all(userID)
}
func (s *FoodKindStore) all(userID string) ([]FoodKind, error) {
rows, err := s.db.Query(
`SELECT id, name, is_default, color_index, updated, deleted
FROM food_kinds WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]FoodKind, 0)
for rows.Next() {
var k FoodKind
if err := rows.Scan(&k.ID, &k.Name, &k.IsDefault, &k.ColorIndex, &k.UpdatedAt, &k.Deleted); err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
// ExerciseStore mirrors Store for the exercises collection: same LWW sync by
// UpdatedAt, same user_id guard against cross-user id collisions, same
// tombstone propagation.
type ExerciseStore struct {
db *sql.DB
}
func newExerciseStore(db *sql.DB) *ExerciseStore {
return &ExerciseStore{db: db}
}
func (s *ExerciseStore) sync(userID string, client []Exercise) ([]Exercise, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO exercises (id, name, note, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, note = excluded.note,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > exercises.updated
AND exercises.user_id = excluded.user_id`)
if err != nil {
return nil, err
}
defer stmt.Close()
for _, ce := range client {
if ce.ID == "" {
continue
}
if _, err := stmt.Exec(
ce.ID, ce.Name, ce.Note, ce.UpdatedAt, ce.Deleted, userID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.all(userID)
}
func (s *ExerciseStore) all(userID string) ([]Exercise, error) {
rows, err := s.db.Query(
`SELECT id, name, note, updated, deleted
FROM exercises WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]Exercise, 0)
for rows.Next() {
var e Exercise
if err := rows.Scan(&e.ID, &e.Name, &e.Note, &e.UpdatedAt, &e.Deleted); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// openDB opens (creating if needed) the SQLite database and ensures the schema
// exists. WAL mode plays nicely with concurrent readers during a sync write;
// busy_timeout avoids spurious "database is locked" errors under contention.
func openDB(path string) (*sql.DB, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
for _, pragma := range []string{
`PRAGMA journal_mode = WAL`,
`PRAGMA busy_timeout = 5000`,
`PRAGMA synchronous = NORMAL`,
} {
if _, err := db.Exec(pragma); err != nil {
db.Close()
return nil, err
}
}
// Fresh-install schema. Every row is scoped to a user_id; the empty string
// is the "ownerless" bucket that legacy single-tenant data lands in until
// the first account adopts it (see Auth.adopt).
schema := `
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT '',
at INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0,
grams REAL NOT NULL DEFAULT 0,
exercise_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT '',
logged_by TEXT NOT NULL DEFAULT '',
logged_by_share TEXT NOT NULL DEFAULT '',
food_kind_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
CREATE TABLE IF NOT EXISTS exercises (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id);
CREATE TABLE IF NOT EXISTS food_kinds (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
color_index INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_food_kinds_user ON food_kinds(user_id);
CREATE TABLE IF NOT EXISTS config (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
birthday TEXT NOT NULL DEFAULT '',
pedigree_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
password TEXT NOT NULL,
created INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created INTEGER NOT NULL,
expires INTEGER NOT NULL,
share_id TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS share_links (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
secret TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT '',
created INTEGER NOT NULL,
expires INTEGER NOT NULL,
last_used INTEGER NOT NULL DEFAULT 0,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_share_links_user ON share_links(user_id);
CREATE TABLE IF NOT EXISTS pedigree_cache (
hundid TEXT PRIMARY KEY,
subject TEXT NOT NULL DEFAULT '',
nodes TEXT NOT NULL DEFAULT '',
generations INTEGER NOT NULL DEFAULT 0,
fetched INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
endpoint TEXT PRIMARY KEY,
user_id TEXT NOT NULL DEFAULT '',
p256dh TEXT NOT NULL DEFAULT '',
auth TEXT NOT NULL DEFAULT '',
created INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_push_subs_user ON push_subscriptions(user_id);
CREATE TABLE IF NOT EXISTS reminders (
user_id TEXT NOT NULL,
kind TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
interval_min INTEGER NOT NULL DEFAULT 0,
last_fired INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, kind)
);`
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, err
}
if err := migrateSchema(db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// migrateSchema upgrades a single-tenant database (from before accounts existed)
// in place: it adds events.user_id and rewrites the config table from its
// old single-row (id = 1) shape to one keyed by user_id. Pre-accounts data ends
// up ownerless (user_id = ”), ready for the first account to adopt. It is a
// no-op on a fresh DB, where openDB already created the current schema.
func migrateSchema(db *sql.DB) error {
has, err := columnExists(db, "events", "user_id")
if err != nil {
return err
}
if !has {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN user_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id)`); err != nil {
return err
}
}
hasExercise, err := columnExists(db, "events", "exercise_id")
if err != nil {
return err
}
if !hasExercise {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN exercise_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasGrams, err := columnExists(db, "events", "grams")
if err != nil {
return err
}
if !hasGrams {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN grams REAL NOT NULL DEFAULT 0`); err != nil {
return err
}
}
// Guest links (see Auth.createShare). Every column defaults to the empty
// string, which is exactly what pre-guest-link rows mean: an event nobody
// but the owner logged, and a session that isn't a guest's.
hasLoggedBy, err := columnExists(db, "events", "logged_by")
if err != nil {
return err
}
if !hasLoggedBy {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN logged_by TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasLoggedByShare, err := columnExists(db, "events", "logged_by_share")
if err != nil {
return err
}
if !hasLoggedByShare {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN logged_by_share TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
// Guest links are re-showable in Settings, which means keeping the token
// itself and not only its hash (see Auth.createShare). Links made before
// this have an empty secret and simply cannot be shown again.
hasSecret, err := columnExists(db, "share_links", "secret")
if err != nil {
return err
}
if !hasSecret {
if _, err := db.Exec(`ALTER TABLE share_links ADD COLUMN secret TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
// Which sort of food a meal was. Empty on every existing row, which is
// exactly right: those meals have no kind, and none of them need rewriting.
hasFoodKind, err := columnExists(db, "events", "food_kind_id")
if err != nil {
return err
}
if !hasFoodKind {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN food_kind_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasShareID, err := columnExists(db, "sessions", "share_id")
if err != nil {
return err
}
if !hasShareID {
if _, err := db.Exec(`ALTER TABLE sessions ADD COLUMN share_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
oldConfig, err := columnExists(db, "config", "id")
if err != nil {
return err
}
if oldConfig {
// Rebuild config keyed by user_id, moving the lone id=1 row into the
// ownerless bucket.
stmts := []string{
`ALTER TABLE config RENAME TO config_old`,
`CREATE TABLE config (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
birthday TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0
)`,
`INSERT INTO config (user_id, name, birthday, updated)
SELECT '', name, birthday, updated FROM config_old WHERE id = 1`,
`DROP TABLE config_old`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
return err
}
}
}
hasPedigree, err := columnExists(db, "config", "pedigree_id")
if err != nil {
return err
}
if !hasPedigree {
if _, err := db.Exec(`ALTER TABLE config ADD COLUMN pedigree_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
return nil
}
// columnExists reports whether the given table has a column of the given name.
// A missing table reports false (no error), which is what fresh installs want.
func columnExists(db *sql.DB, table, col string) (bool, error) {
rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, table)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return false, err
}
if name == col {
return true, nil
}
}
return false, rows.Err()
}
// migrateJSON imports a pre-SQLite events.json / config.json sitting in dataDir
// into an otherwise-empty database, then renames each file to *.imported so the
// import runs exactly once. It is a no-op when the DB already holds data or the
// legacy files are absent.
func migrateJSON(db *sql.DB, dataDir string) error {
if err := importEvents(db, filepath.Join(dataDir, "events.json")); err != nil {
return err
}
return importConfig(db, filepath.Join(dataDir, "config.json"))
}
func importEvents(db *sql.DB, path string) error {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM events`).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil // DB already has data; never clobber it
}
f, err := os.Open(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
var evs []Event
dec := json.NewDecoder(f)
err = dec.Decode(&evs)
f.Close()
if err != nil && !errors.Is(err, io.EOF) {
return err
}
// Imported as ownerless (user_id = ""); the first account to register adopts
// them. Mirrors how in-place schema migration parks legacy rows.
store := newStore(db)
if _, err := store.sync("", "", "", evs); err != nil {
return err
}
log.Printf("migrated %d events from %s", len(evs), path)
return os.Rename(path, path+".imported")
}
func importConfig(db *sql.DB, path string) error {
var n int
if err := db.QueryRow(`SELECT COUNT(*) FROM config`).Scan(&n); err != nil {
return err
}
if n > 0 {
return nil
}
f, err := os.Open(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return err
}
var c Config
dec := json.NewDecoder(f)
err = dec.Decode(&c)
f.Close()
if err != nil && !errors.Is(err, io.EOF) {
return err
}
if c.UpdatedAt == 0 {
// Nothing meaningful to import; leave config empty.
return os.Rename(path, path+".imported")
}
// Ownerless until the first account adopts it (see importEvents).
if _, err := newConfigStore(db).merge("", c); err != nil {
return err
}
log.Printf("migrated config from %s", path)
return os.Rename(path, path+".imported")
}
type syncRequest struct {
Events []Event `json:"events"`
}
type syncResponse struct {
Events []Event `json:"events"`
ServerNow int64 `json:"serverNow"`
}
type exerciseSyncRequest struct {
Exercises []Exercise `json:"exercises"`
}
type exerciseSyncResponse struct {
Exercises []Exercise `json:"exercises"`
}
type foodKindSyncRequest struct {
FoodKinds []FoodKind `json:"foodKinds"`
}
type foodKindSyncResponse struct {
FoodKinds []FoodKind `json:"foodKinds"`
}
type cacheControlFS struct {
root http.FileSystem
}
func (c cacheControlFS) Open(name string) (http.File, error) { return c.root.Open(name) }
// swVersion computes a short content hash over the static assets the service
// worker caches. The hash is substituted into sw.js at serve time (see
// serveSW), so the served worker changes whenever any asset changes — that byte
// difference is what makes the browser install a new worker and prompt to
// reload. Results are memoised and only recomputed when a file's size or modtime
// changes, so the steady state is a handful of cheap stats.
type swVersion struct {
dir string
files []string
mu sync.Mutex
sig string // signature of (name,size,modtime) across files
hash string
}
func newSWVersion(dir string) *swVersion {
// The assets the SW caches and that actually change between builds. sw.js
// itself is excluded: it carries the placeholder, so hashing it would be
// circular and it never changes except when we edit it here.
return &swVersion{
dir: dir,
files: []string{"index.html", "style.css", "app.js", "manifest.json", "icon.svg",
"icon-180.png", "icon-192.png", "icon-512.png", "changelog.json"},
}
}
func (v *swVersion) hashHex() string {
v.mu.Lock()
defer v.mu.Unlock()
var sb strings.Builder
for _, name := range v.files {
if info, err := os.Stat(filepath.Join(v.dir, name)); err == nil {
fmt.Fprintf(&sb, "%s:%d:%d;", name, info.Size(), info.ModTime().UnixNano())
}
}
sig := sb.String()
if sig == v.sig && v.hash != "" {
return v.hash
}
h := sha256.New()
for _, name := range v.files {
f, err := os.Open(filepath.Join(v.dir, name))
if err != nil {
continue
}
_, _ = io.Copy(h, f)
f.Close()
}
v.sig = sig
v.hash = hex.EncodeToString(h.Sum(nil))[:12]
return v.hash
}
// serveSW renders sw.js with the current build hash substituted for its
// placeholder. no-cache lets the browser refetch and byte-compare on each
// update check; the substituted hash is what actually differs between builds.
func serveSW(w http.ResponseWriter, staticDir string, ver *swVersion) {
data, err := os.ReadFile(filepath.Join(staticDir, "sw.js"))
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
out := bytes.ReplaceAll(data, []byte("__BUILD_HASH__"), []byte(ver.hashHex()))
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(out)
}
func main() {
addr := flag.String("addr", ":8080", "listen address (e.g. :8080 or 0.0.0.0:8080)")
dataPath := flag.String("data", "puppy.db", "path to SQLite database file")
staticDir := flag.String("static", "", "directory of static files to serve")
inviteCode := flag.String("invite-code", os.Getenv("PUPPY_INVITE_CODE"),
"shared secret required to register (env PUPPY_INVITE_CODE); empty disables registration")
secureCookies := flag.Bool("secure-cookies", false,
"mark session cookies Secure (enable when served over HTTPS / behind a TLS proxy)")
vapidKey := flag.String("vapid-key", os.Getenv("PUPPY_VAPID_KEY"),
"base64url P-256 private key for Web Push (env PUPPY_VAPID_KEY); generated next to the DB when unset")
flag.Parse()
db, err := openDB(*dataPath)
if err != nil {
log.Fatalf("open db: %v", err)
}
defer db.Close()
// One-time import of any pre-SQLite JSON data sitting next to the DB.
if err := migrateJSON(db, filepath.Dir(*dataPath)); err != nil {
log.Fatalf("migrate json: %v", err)
}
store := newStore(db)
configStore := newConfigStore(db)
exerciseStore := newExerciseStore(db)
foodKindStore := newFoodKindStore(db)
pedigrees := newPedManager(db)
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
if err := os.MkdirAll(photosDir, 0o755); err != nil {
log.Fatalf("mkdir photos: %v", err)
}
// Reminders are optional: if the push identity cannot be established the rest
// of the app must still come up, just without notifications.
var scheduler *Scheduler
if key, err := loadVAPIDKey(*vapidKey, filepath.Join(filepath.Dir(*dataPath), "vapid.json")); err != nil {
log.Printf("WARNING: push reminders disabled: %v", err)
} else {
scheduler = newScheduler(db, newSubscriptionStore(db), newReminderStore(db), key)
go scheduler.run(reminderTick)
}
auth := newAuth(db, *inviteCode, *secureCookies, photosDir)
if *inviteCode == "" {
log.Print("WARNING: no invite code set — registration is disabled (set -invite-code / PUPPY_INVITE_CODE)")
}
mux := http.NewServeMux()
mux.HandleFunc("/api/register", auth.handleRegister)
mux.HandleFunc("/api/login", auth.handleLogin)
mux.HandleFunc("/api/logout", auth.handleLogout)
mux.HandleFunc("/api/me", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
auth.handleMe(w, r)
case http.MethodDelete:
// Deleting the account is the owner's alone, so this arm — and only
// this arm — is gated; a guest still needs the GET to learn its role.
if isGuest(r) {
http.Error(w, "guest links cannot do this", http.StatusForbidden)
return
}
auth.handleDeleteAccount(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}))
// Guest links: minting, listing and revoking are the owner's, redeeming is
// the unauthenticated entry point the link itself points at.
mux.HandleFunc("/api/shares", auth.requireOwner(auth.handleShares))
mux.HandleFunc("/api/shares/", auth.requireOwner(auth.handleShare))
mux.HandleFunc("/guest/", auth.handleRedeem)
mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req syncRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
merged, err := store.sync(userID(r), guestLabel(r), sessionOf(r).shareID, req.Events)
if err != nil {
log.Printf("sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(syncResponse{
Events: merged,
ServerNow: time.Now().UnixMilli(),
})
}))
// POST /api/exercises/sync — merge the caller's training exercises, same
// LWW contract as /api/events/sync.
mux.HandleFunc("/api/exercises/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req exerciseSyncRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
// Exercises are the owner's library, not a log: a guest logs training
// sessions against them (ordinary events) but does not get to rename or
// delete them. Dropping the incoming list makes this direction-only —
// the guest still receives the full set back. The client hides the
// editing UI to match; this is the part that enforces it.
incoming := req.Exercises
if isGuest(r) {
incoming = nil
}
merged, err := exerciseStore.sync(userID(r), incoming)
if err != nil {
log.Printf("exercises sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(exerciseSyncResponse{Exercises: merged})
}))
// POST /api/foodkinds/sync — the same contract again for the food kinds a
// meal can be labelled with.
mux.HandleFunc("/api/foodkinds/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req foodKindSyncRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
// The library is the owner's, exactly as the exercise list is: a guest
// labels a meal with a kind that exists, but does not invent, rename or
// delete one. They still receive the full set, so the picker works.
incoming := req.FoodKinds
if isGuest(r) {
incoming = nil
}
merged, err := foodKindStore.sync(userID(r), incoming)
if err != nil {
log.Printf("food kinds sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(foodKindSyncResponse{FoodKinds: merged})
}))
// GET /api/config — return the caller's puppy profile.
// PUT /api/config — update it (last-write-wins by updatedAt).
mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
writeConfig := func(c Config) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(c)
}
switch r.Method {
case http.MethodGet:
writeConfig(configStore.get(userID(r)))
case http.MethodPut, http.MethodPost:
// The profile (name, birthday, pedigree id) is the owner's to set.
// The GET above stays open — a guest needs the name and birthday to
// render the header at all.
if isGuest(r) {
http.Error(w, "guest links cannot do this", http.StatusForbidden)
return
}
var in Config
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
in.Name = strings.TrimSpace(in.Name)
if len(in.Name) > 100 {
in.Name = in.Name[:100]
}
in.PedigreeID = strings.TrimSpace(in.PedigreeID)
if len(in.PedigreeID) > 64 {
in.PedigreeID = in.PedigreeID[:64]
}
if !validBirthday(in.Birthday) {
http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest)
return
}
merged, err := configStore.merge(userID(r), in)
if err != nil {
log.Printf("config save: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
writeConfig(merged)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}))
// POST /api/pedigree — resolve a dog by chip / registration number / name and
// return its ancestry tree (immediately for the first generations, then a
// background crawl deepens it). GET /api/pedigree/status polls that crawl.
mux.HandleFunc("/api/pedigree", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
pedigrees.handleLookup(w, r)
}))
mux.HandleFunc("/api/pedigree/status", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
pedigrees.handleStatus(w, r)
}))
// Push reminders. Registered only when the scheduler came up, so a server
// without a usable VAPID key 404s these rather than half-working — which is
// also what tells the client to hide the reminder UI entirely. Owner-only:
// the reminders are the owner's own, and a guest device subscribing would
// route them to the sitter's lock screen.
if scheduler != nil {
mux.HandleFunc("/api/push/key", auth.requireOwner(scheduler.handleKey))
mux.HandleFunc("/api/push/subscribe", auth.requireOwner(scheduler.handleSubscribe))
mux.HandleFunc("/api/push/unsubscribe", auth.requireOwner(scheduler.handleUnsubscribe))
mux.HandleFunc("/api/push/test", auth.requireOwner(scheduler.handleTest))
mux.HandleFunc("/api/reminders", auth.requireOwner(scheduler.handleReminders))
}
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
// POST /api/photos — multipart upload with form fields `id` (UUID) and
// `file` (JPEG). The client generates the ID so the event referencing
// the photo can be written before the upload round-trips.
mux.HandleFunc("/api/photos", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 15<<20)
if err := r.ParseMultipartForm(15 << 20); err != nil {
http.Error(w, "parse: "+err.Error(), http.StatusBadRequest)
return
}
id := r.FormValue("id")
if !validUUID(id) {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "no file: "+err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
// Photos live under the owner's directory so a photo can only ever be
// read back by the account that uploaded it.
userDir := filepath.Join(photosDir, userID(r))
if err := os.MkdirAll(userDir, 0o755); err != nil {
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
return
}
dstPath := filepath.Join(userDir, id+".jpg")
tmp := dstPath + ".tmp"
dst, err := os.Create(tmp)
if err != nil {
http.Error(w, "create: "+err.Error(), http.StatusInternalServerError)
return
}
if _, err := io.Copy(dst, file); err != nil {
dst.Close()
os.Remove(tmp)
http.Error(w, "copy: "+err.Error(), http.StatusInternalServerError)
return
}
if err := dst.Close(); err != nil {
os.Remove(tmp)
http.Error(w, "close: "+err.Error(), http.StatusInternalServerError)
return
}
if err := os.Rename(tmp, dstPath); err != nil {
os.Remove(tmp)
http.Error(w, "rename: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
}))
// GET /api/photos/<id> — serves the caller's own JPEG. Photos are immutable
// per ID so we mark them as long-lived; the private cache keeps them per
// user. Serving only from the caller's directory makes ownership implicit.
mux.HandleFunc("/api/photos/", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/photos/")
if !validUUID(id) {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
path := filepath.Join(photosDir, userID(r), id+".jpg")
f, err := os.Open(path)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
http.Error(w, "stat: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
http.ServeContent(w, r, path, stat.ModTime(), f)
}))
if *staticDir != "" {
fileServer := http.FileServer(http.Dir(*staticDir))
swVer := newSWVersion(*staticDir)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// The service worker is rendered with a per-build hash of the static
// assets, so any asset change yields a byte-different sw.js — that's
// what makes the browser detect an update and show the reload prompt.
if r.URL.Path == "/sw.js" {
serveSW(w, *staticDir, swVer)
return
}
// SPA fallback: unknown paths -> index.html (so deep links work).
if !strings.HasPrefix(r.URL.Path, "/api/") {
// All static assets revalidate on every request (cheap 304s via
// Last-Modified). Offline/fast loads are the service worker
// cache's job; leaving these to the browser's heuristic HTTP
// caching let a stale app.js pair with a fresh index.html.
w.Header().Set("Cache-Control", "no-cache")
candidate := filepath.Join(*staticDir, filepath.FromSlash(r.URL.Path))
if r.URL.Path != "/" {
if info, err := os.Stat(candidate); err != nil || info.IsDir() {
r.URL.Path = "/"
}
}
}
fileServer.ServeHTTP(w, r)
})
}
srv := &http.Server{
Addr: *addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("puppy-tracker listening on %s (data=%s, static=%s)", *addr, *dataPath, *staticDir)
log.Fatal(srv.ListenAndServe())
}