diff --git a/README.md b/README.md index 0dd65c6..7e3e532 100644 --- a/README.md +++ b/README.md @@ -49,14 +49,20 @@ puppy-tracker/ │ ├── go.mod │ ├── go.sum │ ├── main.go # SQLite store, LWW sync, static file serving -│ └── auth.go # accounts, sessions, invite-gated registration +│ ├── auth.go # accounts, sessions, invite-gated registration +│ ├── reminders.go # reminder rules, the evaluation loop, push subscriptions +│ ├── webpush.go # VAPID + RFC 8291/8188 message encryption +│ ├── pedigree.go # SKK lookup, background crawl, per-dog cache +│ └── htmlutil.go # scraping helpers for the pedigree crawl └── src/ # the web app ├── index.html ├── app.js ├── style.css ├── sw.js ├── manifest.json - └── icon.svg + ├── changelog.json + ├── icon.svg + └── icon-180.png, icon-192.png, icon-512.png ``` ## Run locally @@ -94,6 +100,55 @@ events, profile and photos. when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords aren't sent in the clear. +## Reminders + +Opt-in push notifications for the two things that are easy to lose track of: +"time to sleep" and "nothing logged for a while". Turn them on per rule in +**Settings**. + +- **Evaluated on the server.** A closed PWA has no timers, so the browser cannot + remind you of anything on its own. The server already holds the event log + (clients sync on every mutation), so a goroutine re-checks every enabled rule + once a minute and pushes the ones that have come due. +- **Two rule shapes.** `sleep` measures from the last `sleep-end` and fires only + while the puppy is awake. `pee` / `poo` / `eat` measure from the newest event + of that type. Each rule has its own interval and repeats at that interval while + it stays overdue. +- **Quiet while the puppy sleeps.** The event rules are suppressed whenever the + latest sleep boundary says "asleep", which is what keeps them from nagging all + night — and means an overdue rule fires promptly on waking instead. The server + derives sleep state exactly the way `currentSleepState()` does in `app.js`, + tie-break included, so both sides always agree. +- **One notification per rule.** Every push carries a `tag`, so a repeat replaces + the previous notification instead of stacking another one on the lock screen. +- **Late syncs cancel a reminder retroactively.** Rules measure from the event's + own timestamp, not from when the server heard about it, so a pee logged offline + at 03:10 and synced at 03:40 resets the clock as if it had arrived on time. +- **Web Push is implemented directly** (`server/webpush.go`): RFC 8291 message + encryption in the RFC 8188 `aes128gcm` content encoding, authorized with an + RFC 8292 VAPID token. It is stdlib-only, and checked against the RFC 8291 + test vector in `webpush_test.go`. Subscriptions the push service reports as + `404`/`410` are deleted. + +### Requirements + +- **HTTPS.** Push needs a secure context — the same reverse proxy you need for + `secureCookies`. +- **On iOS the app must be added to the Home Screen** (16.4+). Safari tabs have + no `PushManager` at all; the app detects this and says so instead of showing a + toggle that cannot work. iOS also drops subscriptions periodically, so the + client re-subscribes and re-registers its endpoint on every launch. +- **A VAPID key.** Generated into `vapid.json` next to `puppy.db` on first start, + or supplied via `-vapid-key` / `PUPPY_VAPID_KEY`. Browsers pin this key at + subscribe time: replacing it invalidates every existing subscription. If no key + can be established the server logs a warning and comes up without reminders — + the `/api/push/*` and `/api/reminders` routes are simply not registered, which + is also how the client knows to hide the UI. + +Settings has a *Send a test notification* button, which is the only practical way +to tell "never subscribed" apart from "subscribed but not delivering" — push +failures are invisible from the browser side, especially on iOS. + ## Pedigree lookup Set your dog's SKK chip or registration number in **Settings** (it rides the @@ -135,6 +190,10 @@ In your system flake: # Registration secret, kept out of the Nix store. The file holds: # PUPPY_INVITE_CODE=some-shared-secret inviteCodeFile = "/run/secrets/puppy-invite-code"; + # Optional. Without it the server generates and keeps its own Web Push + # key in /var/lib/puppy-tracker. The file holds: + # PUPPY_VAPID_KEY=base64url-p256-private-key + vapidKeyFile = "/run/secrets/puppy-vapid-key"; # Enable once you terminate TLS in front of the service. secureCookies = false; }; @@ -147,7 +206,8 @@ In your system flake: The server runs as a `DynamicUser` systemd unit. Data is stored in a SQLite database at `/var/lib/puppy-tracker/puppy.db` via `StateDirectory` (with photos -alongside it under `photos/`). +alongside it under `photos/`, and a generated `vapid.json` if no `vapidKeyFile` +is set). ## Notes diff --git a/module.nix b/module.nix index 97b9809..3523cda 100644 --- a/module.nix +++ b/module.nix @@ -39,6 +39,20 @@ in ''; }; + vapidKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + example = "/run/secrets/puppy-vapid-key"; + description = '' + Path to an EnvironmentFile containing the Web Push signing key as + `PUPPY_VAPID_KEY=...` (a base64url P-256 private scalar). When null the + server generates one on first start and keeps it in its state directory, + which is fine for a single host. Note that browsers pin this key when + they subscribe: replacing it silently breaks every existing reminder + subscription until each device re-enables notifications. + ''; + }; + secureCookies = lib.mkOption { type = lib.types.bool; default = false; @@ -78,9 +92,10 @@ in "-data /var/lib/puppy-tracker/puppy.db" ] ++ lib.optional cfg.secureCookies "-secure-cookies"); - # Invite code (registration secret) is read from an env file kept out of - # the store, exposed to the server as PUPPY_INVITE_CODE. - EnvironmentFile = lib.mkIf (cfg.inviteCodeFile != null) cfg.inviteCodeFile; + # Secrets (registration code, Web Push key) are read from env files kept + # out of the store, exposed to the server as PUPPY_INVITE_CODE and + # PUPPY_VAPID_KEY. + EnvironmentFile = lib.filter (f: f != null) [ cfg.inviteCodeFile cfg.vapidKeyFile ]; DynamicUser = true; StateDirectory = "puppy-tracker"; diff --git a/server/auth.go b/server/auth.go index 0c6b16c..fa2adc2 100644 --- a/server/auth.go +++ b/server/auth.go @@ -395,8 +395,9 @@ func (a *Auth) checkPassword(userID, password string) bool { } // deleteAccount removes a user and everything owned by them: events, profile, -// sessions, the user row, and their photo directory. The table wipes run in one -// transaction; photos are best-effort afterwards (orphaned files are harmless). +// reminders, push subscriptions, sessions, the user row, and their photo +// directory. The table wipes run in one transaction; photos are best-effort +// afterwards (orphaned files are harmless). func (a *Auth) deleteAccount(userID string) error { tx, err := a.db.Begin() if err != nil { @@ -407,6 +408,8 @@ func (a *Auth) deleteAccount(userID string) error { `DELETE FROM events WHERE user_id = ?`, `DELETE FROM exercises WHERE user_id = ?`, `DELETE FROM config WHERE user_id = ?`, + `DELETE FROM push_subscriptions WHERE user_id = ?`, + `DELETE FROM reminders WHERE user_id = ?`, `DELETE FROM sessions WHERE user_id = ?`, `DELETE FROM users WHERE id = ?`, } { diff --git a/server/main.go b/server/main.go index 0fa8837..16e441d 100644 --- a/server/main.go +++ b/server/main.go @@ -336,6 +336,23 @@ func openDB(path string) (*sql.DB, error) { 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() @@ -561,8 +578,9 @@ func newSWVersion(dir string) *swVersion { // 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", "changelog.json"}, + 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"}, } } @@ -618,6 +636,8 @@ func main() { "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) @@ -641,6 +661,16 @@ func main() { 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)") @@ -768,6 +798,17 @@ func main() { 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. + if scheduler != nil { + mux.HandleFunc("/api/push/key", auth.requireUser(scheduler.handleKey)) + mux.HandleFunc("/api/push/subscribe", auth.requireUser(scheduler.handleSubscribe)) + mux.HandleFunc("/api/push/unsubscribe", auth.requireUser(scheduler.handleUnsubscribe)) + mux.HandleFunc("/api/push/test", auth.requireUser(scheduler.handleTest)) + mux.HandleFunc("/api/reminders", auth.requireUser(scheduler.handleReminders)) + } + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) diff --git a/server/reminders.go b/server/reminders.go new file mode 100644 index 0000000..dcfc738 --- /dev/null +++ b/server/reminders.go @@ -0,0 +1,605 @@ +package main + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "sort" + "time" +) + +// Reminders are evaluated on the server because that is the only place that can +// act while every client is closed — a PWA gets no timers of its own once the +// tab is gone. The event log is already here (clients sync on every mutation), +// so a rule is just a query over it plus a push. +// +// Two shapes of rule, both keyed off the same synced events: +// +// - "sleep": the puppy has been awake too long. Fires only while awake, so it +// is silent overnight by construction. +// - "pee" / "poo" / "eat": nothing of that type logged for too long. Suppressed +// while the puppy is asleep — otherwise it nags all night — which also means +// it fires promptly on waking if it was already overdue, matching how a puppy +// actually behaves. + +// reminderKinds are the rules a user can enable, with the interval each starts +// at. Order is the order they appear in Settings. +var reminderKinds = []struct { + Kind string + Default int // minutes +}{ + {"sleep", 45}, + {"pee", 60}, + {"poo", 180}, + {"eat", 240}, +} + +func defaultInterval(kind string) (int, bool) { + for _, k := range reminderKinds { + if k.Kind == kind { + return k.Default, true + } + } + return 0, false +} + +// Reminder is one rule as the client sees it. LastFired is server-owned state +// and deliberately absent: a client PUT must never be able to reset it, or a +// device with a stale copy could make a rule re-fire immediately. +type Reminder struct { + Kind string `json:"kind"` + Enabled bool `json:"enabled"` + IntervalMin int `json:"intervalMin"` +} + +type ReminderStore struct { + db *sql.DB +} + +func newReminderStore(db *sql.DB) *ReminderStore { return &ReminderStore{db: db} } + +// get returns every rule for a user, filling in defaults for kinds they have +// never touched so the client always renders the full set. +func (rs *ReminderStore) get(userID string) ([]Reminder, error) { + rows, err := rs.db.Query( + `SELECT kind, enabled, interval_min FROM reminders WHERE user_id = ?`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + stored := map[string]Reminder{} + for rows.Next() { + var r Reminder + if err := rows.Scan(&r.Kind, &r.Enabled, &r.IntervalMin); err != nil { + return nil, err + } + stored[r.Kind] = r + } + if err := rows.Err(); err != nil { + return nil, err + } + + out := make([]Reminder, 0, len(reminderKinds)) + for _, k := range reminderKinds { + if r, ok := stored[k.Kind]; ok { + out = append(out, r) + continue + } + out = append(out, Reminder{Kind: k.Kind, Enabled: false, IntervalMin: k.Default}) + } + return out, nil +} + +// put writes the rules a client sent. Unknown kinds are ignored and intervals +// are clamped, so a bad client can't install a rule that fires every minute. +// Enabling a rule clears last_fired so it can alert immediately if already due, +// rather than waiting out an interval from whenever it last ran. +func (rs *ReminderStore) put(userID string, in []Reminder) error { + tx, err := rs.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + for _, r := range in { + if _, ok := defaultInterval(r.Kind); !ok { + continue + } + if r.IntervalMin < 5 { + r.IntervalMin = 5 + } + if r.IntervalMin > 24*60 { + r.IntervalMin = 24 * 60 + } + if _, err := tx.Exec(` + INSERT INTO reminders (user_id, kind, enabled, interval_min, last_fired, updated) + VALUES (?, ?, ?, ?, 0, ?) + ON CONFLICT(user_id, kind) DO UPDATE SET + enabled = excluded.enabled, + interval_min = excluded.interval_min, + updated = excluded.updated, + last_fired = CASE WHEN reminders.enabled = 0 AND excluded.enabled = 1 + THEN 0 ELSE reminders.last_fired END`, + userID, r.Kind, r.Enabled, r.IntervalMin, time.Now().UnixMilli()); err != nil { + return err + } + } + return tx.Commit() +} + +// ---------- subscriptions ---------- + +type SubscriptionStore struct { + db *sql.DB +} + +func newSubscriptionStore(db *sql.DB) *SubscriptionStore { return &SubscriptionStore{db: db} } + +// save records a device's push subscription. The endpoint is the primary key: +// browsers reuse it across sessions, and re-subscribing (which iOS forces +// regularly) must update the existing row rather than accumulate dead ones. +func (ss *SubscriptionStore) save(userID string, sub Subscription) error { + _, err := ss.db.Exec(` + INSERT INTO push_subscriptions (endpoint, user_id, p256dh, auth, created) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET + user_id = excluded.user_id, p256dh = excluded.p256dh, auth = excluded.auth`, + sub.Endpoint, userID, sub.Keys.P256dh, sub.Keys.Auth, time.Now().UnixMilli()) + return err +} + +func (ss *SubscriptionStore) delete(endpoint string) error { + _, err := ss.db.Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint) + return err +} + +func (ss *SubscriptionStore) forUser(userID string) ([]Subscription, error) { + rows, err := ss.db.Query( + `SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Subscription + for rows.Next() { + var s Subscription + if err := rows.Scan(&s.Endpoint, &s.Keys.P256dh, &s.Keys.Auth); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} + +// ---------- evaluation ---------- + +// sleepState mirrors currentSleepState() in app.js: the latest sleep boundary +// decides whether the puppy is awake, with an equal-timestamp tie broken by the +// later-written event so both sides agree on the same answer. +type sleepState struct { + state string // "asleep", "awake", or "" when nothing has ever been logged + since int64 // ms epoch of that boundary +} + +func (sch *Scheduler) sleepStateFor(userID string) (sleepState, error) { + var typ string + var at int64 + err := sch.db.QueryRow(` + SELECT type, at FROM events + WHERE user_id = ? AND deleted = 0 AND type IN ('sleep-start', 'sleep-end') + ORDER BY at DESC, updated DESC LIMIT 1`, userID).Scan(&typ, &at) + if errors.Is(err, sql.ErrNoRows) { + return sleepState{}, nil + } + if err != nil { + return sleepState{}, err + } + if typ == "sleep-start" { + return sleepState{state: "asleep", since: at}, nil + } + return sleepState{state: "awake", since: at}, nil +} + +// lastEventAt is the timestamp of the newest surviving event of a type, or 0 +// when there is none. +func (sch *Scheduler) lastEventAt(userID, typ string) (int64, error) { + var at sql.NullInt64 + err := sch.db.QueryRow( + `SELECT MAX(at) FROM events WHERE user_id = ? AND type = ? AND deleted = 0`, + userID, typ).Scan(&at) + if err != nil { + return 0, err + } + return at.Int64, nil +} + +// notification is the payload the service worker receives. Tag is what makes a +// repeat fire replace the previous notification instead of stacking a new one. +type notification struct { + Title string `json:"title"` + Body string `json:"body"` + Tag string `json:"tag"` + URL string `json:"url"` +} + +// due decides whether a rule should fire right now, and with what text. The +// returned notification is only meaningful when due is true. +func (sch *Scheduler) due(userID string, r Reminder, lastFired, now int64, sleep sleepState) (notification, bool, error) { + interval := int64(r.IntervalMin) * 60 * 1000 + + var since int64 + var title, body string + + if r.Kind == "sleep" { + // Only meaningful while awake; asleep means the rule has been satisfied. + if sleep.state != "awake" { + return notification{}, false, nil + } + since = sleep.since + title = "Time to sleep" + body = "Awake for " + humanDuration(now-since) + } else { + // A sleeping puppy isn't going to pee, eat, or poo — stay quiet until it + // wakes, at which point an already-overdue rule fires on the next tick. + if sleep.state == "asleep" { + return notification{}, false, nil + } + at, err := sch.lastEventAt(userID, r.Kind) + if err != nil { + return notification{}, false, err + } + if at == 0 { + // Nothing logged yet, so there is no clock to run. + return notification{}, false, nil + } + since = at + title, body = reminderText(r.Kind, now-since) + } + + if now-since < interval { + return notification{}, false, nil + } + // Once overdue, repeat at the rule's own interval rather than every tick. + if lastFired != 0 && now-lastFired < interval { + return notification{}, false, nil + } + return notification{ + Title: title, + Body: body, + Tag: "reminder:" + r.Kind, + URL: "./", + }, true, nil +} + +func reminderText(kind string, elapsed int64) (title, body string) { + switch kind { + case "pee": + return "Time for pee", "No pee logged for " + humanDuration(elapsed) + case "poo": + return "Time for poo", "No poo logged for " + humanDuration(elapsed) + case "eat": + return "Time for a meal", "No meal logged for " + humanDuration(elapsed) + } + return "Reminder", "Nothing logged for " + humanDuration(elapsed) +} + +// humanDuration renders an elapsed span the way the notification body reads it: +// "45 min", "1 h 12 min", "2 h". +func humanDuration(ms int64) string { + if ms < 0 { + ms = 0 + } + mins := ms / 60000 + h, m := mins/60, mins%60 + switch { + case h == 0: + return fmt.Sprintf("%d min", m) + case m == 0: + return fmt.Sprintf("%d h", h) + default: + return fmt.Sprintf("%d h %d min", h, m) + } +} + +// ---------- scheduler ---------- + +// Scheduler evaluates every enabled rule once a minute and pushes the ones that +// have come due. One goroutine for the whole server: the work per tick is a +// couple of indexed queries per user with a rule switched on. +type Scheduler struct { + db *sql.DB + subs *SubscriptionStore + rules *ReminderStore + key *VAPIDKey + client *http.Client +} + +// reminderTick is how often every enabled rule is re-evaluated. A minute is well +// under the shortest interval a rule can be set to, so a reminder never lands +// more than a minute late. +const reminderTick = time.Minute + +func newScheduler(db *sql.DB, subs *SubscriptionStore, rules *ReminderStore, key *VAPIDKey) *Scheduler { + return &Scheduler{ + db: db, + subs: subs, + rules: rules, + key: key, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (sch *Scheduler) run(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + if err := sch.tick(time.Now().UnixMilli()); err != nil { + log.Printf("reminder tick: %v", err) + } + } +} + +// tick evaluates all rules once. now is a parameter so tests can drive it. +func (sch *Scheduler) tick(now int64) error { + // Only users who both switched a rule on and have somewhere to push to. + rows, err := sch.db.Query(` + SELECT DISTINCT r.user_id FROM reminders r + WHERE r.enabled = 1 + AND EXISTS (SELECT 1 FROM push_subscriptions s WHERE s.user_id = r.user_id)`) + if err != nil { + return err + } + var users []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return err + } + users = append(users, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + sort.Strings(users) + + for _, uid := range users { + if err := sch.tickUser(uid, now); err != nil { + log.Printf("reminders for %s: %v", uid, err) + } + } + return nil +} + +func (sch *Scheduler) tickUser(userID string, now int64) error { + sleep, err := sch.sleepStateFor(userID) + if err != nil { + return err + } + + rows, err := sch.db.Query( + `SELECT kind, enabled, interval_min, last_fired FROM reminders + WHERE user_id = ? AND enabled = 1`, userID) + if err != nil { + return err + } + type pending struct { + rule Reminder + note notification + } + var fire []pending + for rows.Next() { + var r Reminder + var lastFired int64 + if err := rows.Scan(&r.Kind, &r.Enabled, &r.IntervalMin, &lastFired); err != nil { + rows.Close() + return err + } + note, ok, err := sch.due(userID, r, lastFired, now, sleep) + if err != nil { + rows.Close() + return err + } + if ok { + fire = append(fire, pending{rule: r, note: note}) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + if len(fire) == 0 { + return nil + } + + subs, err := sch.subs.forUser(userID) + if err != nil { + return err + } + for _, p := range fire { + // Stamp the fire before sending: a push service that is slow or briefly + // erroring must not cause the same reminder to be retried every tick. + if _, err := sch.db.Exec( + `UPDATE reminders SET last_fired = ? WHERE user_id = ? AND kind = ?`, + now, userID, p.rule.Kind); err != nil { + return err + } + sch.broadcast(subs, p.note) + } + return nil +} + +// broadcast sends one notification to every device the user has registered, +// pruning any subscription the push service reports as permanently gone. +func (sch *Scheduler) broadcast(subs []Subscription, note notification) { + payload, err := json.Marshal(note) + if err != nil { + log.Printf("marshal notification: %v", err) + return + } + for _, sub := range subs { + if err := sch.key.send(sch.client, sub, payload, 3600); err != nil { + var pe *pushError + if errors.As(err, &pe) && pe.Gone { + if delErr := sch.subs.delete(sub.Endpoint); delErr != nil { + log.Printf("drop dead subscription: %v", delErr) + } + continue + } + log.Printf("push send: %v", err) + } + } +} + +// ---------- handlers ---------- + +// handleKey hands the client the VAPID public key it must pass as +// applicationServerKey when subscribing. Public by design — it only identifies +// this server; the private half never leaves it. +func (sch *Scheduler) handleKey(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]string{"key": sch.key.Public}) +} + +// handleSubscribe stores (or refreshes) the calling device's subscription. +// Clients re-post on every launch, because iOS quietly drops subscriptions and +// a stale one would silently stop receiving. +func (sch *Scheduler) handleSubscribe(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var sub Subscription + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&sub); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if err := validSubscription(sub); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := sch.subs.save(userID(r), sub); err != nil { + log.Printf("save subscription: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleUnsubscribe drops one endpoint. Scoped to the caller so one account +// can't delete another's device. +func (sch *Scheduler) handleUnsubscribe(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + Endpoint string `json:"endpoint"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if _, err := sch.db.Exec( + `DELETE FROM push_subscriptions WHERE endpoint = ? AND user_id = ?`, + body.Endpoint, userID(r)); err != nil { + log.Printf("delete subscription: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleTest pushes one notification to the caller's devices. Push failures are +// invisible from the browser side — especially on iOS — so this is the only +// practical way to tell "not subscribed" apart from "subscribed but undelivered". +func (sch *Scheduler) handleTest(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + subs, err := sch.subs.forUser(userID(r)) + if err != nil { + log.Printf("test push: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if len(subs) == 0 { + http.Error(w, "no subscriptions", http.StatusNotFound) + return + } + sch.broadcast(subs, notification{ + Title: "Reminders are on", + Body: "This is what a reminder looks like.", + Tag: "reminder:test", + URL: "./", + }) + w.WriteHeader(http.StatusNoContent) +} + +// handleReminders reads and writes the caller's rules. +func (sch *Scheduler) handleReminders(w http.ResponseWriter, r *http.Request) { + uid := userID(r) + switch r.Method { + case http.MethodGet: + case http.MethodPut: + var body struct { + Reminders []Reminder `json:"reminders"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if err := sch.rules.put(uid, body.Reminders); err != nil { + log.Printf("put reminders: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Both verbs answer with the stored set, so a PUT tells the client exactly + // what was kept after clamping. + list, err := sch.rules.get(uid) + if err != nil { + log.Printf("get reminders: %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(map[string]any{"reminders": list}) +} + +// validSubscription rejects anything we could not push to later, so a bad row +// never reaches the scheduler. The key sizes are fixed by RFC 8291: an +// uncompressed P-256 point and a 16-byte auth secret. +func validSubscription(sub Subscription) error { + u, err := url.Parse(sub.Endpoint) + if err != nil || u.Scheme != "https" || u.Host == "" { + return errors.New("endpoint must be an https URL") + } + if len(sub.Endpoint) > 2048 { + return errors.New("endpoint too long") + } + p256dh, err := b64.DecodeString(sub.Keys.P256dh) + if err != nil || len(p256dh) != 65 || p256dh[0] != 4 { + return errors.New("bad p256dh key") + } + auth, err := b64.DecodeString(sub.Keys.Auth) + if err != nil || len(auth) != 16 { + return errors.New("bad auth secret") + } + return nil +} diff --git a/server/reminders_test.go b/server/reminders_test.go new file mode 100644 index 0000000..ae58903 --- /dev/null +++ b/server/reminders_test.go @@ -0,0 +1,387 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +const minute = int64(60 * 1000) + +func testScheduler(t *testing.T) *Scheduler { + t.Helper() + db, err := openDB(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { db.Close() }) + key, err := newVAPIDKey() + if err != nil { + t.Fatalf("vapid: %v", err) + } + return newScheduler(db, newSubscriptionStore(db), newReminderStore(db), key) +} + +func addEvent(t *testing.T, sch *Scheduler, userID, typ string, at int64) { + t.Helper() + _, err := sch.db.Exec( + `INSERT INTO events (id, type, at, updated, user_id) VALUES (?, ?, ?, ?, ?)`, + typ+"-"+time.Now().Format("150405.000000000")+"-"+string(rune('a'+at%26)), typ, at, at, userID) + if err != nil { + t.Fatalf("insert %s: %v", typ, err) + } +} + +func TestDueSleepRuleFiresOnlyWhileAwake(t *testing.T) { + sch := testScheduler(t) + now := int64(1_700_000_000_000) + rule := Reminder{Kind: "sleep", Enabled: true, IntervalMin: 45} + + // Awake for an hour: past the 45 minute rule. + addEvent(t, sch, "u1", "sleep-end", now-60*minute) + note, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "awake", since: now - 60*minute}) + if err != nil { + t.Fatalf("due: %v", err) + } + if !ok { + t.Fatal("expected the sleep rule to fire after an hour awake") + } + if note.Body != "Awake for 1 h" { + t.Errorf("body = %q, want %q", note.Body, "Awake for 1 h") + } + if note.Tag != "reminder:sleep" { + t.Errorf("tag = %q, want reminder:sleep", note.Tag) + } + + // Same elapsed time, but the puppy is now asleep — the rule is satisfied. + if _, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "asleep", since: now - 60*minute}); err != nil || ok { + t.Errorf("asleep: fired = %v (err %v), want no fire", ok, err) + } + + // Nothing ever logged: no baseline to measure from. + if _, ok, err := sch.due("u1", rule, 0, now, sleepState{}); err != nil || ok { + t.Errorf("no sleep history: fired = %v (err %v), want no fire", ok, err) + } +} + +func TestDueEventRuleSuppressedWhileAsleep(t *testing.T) { + sch := testScheduler(t) + now := int64(1_700_000_000_000) + rule := Reminder{Kind: "pee", Enabled: true, IntervalMin: 60} + + addEvent(t, sch, "u1", "pee", now-90*minute) + + // Asleep: silent even though the pee clock is well past the interval. This is + // what keeps the rule from nagging all night. + if _, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "asleep", since: now - 30*minute}); err != nil || ok { + t.Errorf("asleep: fired = %v (err %v), want no fire", ok, err) + } + + // Awake with the same history: fires immediately, which is what happens the + // moment a sleep-end lands on an already-overdue rule. + note, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "awake", since: now - 1*minute}) + if err != nil { + t.Fatalf("due: %v", err) + } + if !ok { + t.Fatal("expected the pee rule to fire while awake and overdue") + } + if note.Body != "No pee logged for 1 h 30 min" { + t.Errorf("body = %q", note.Body) + } +} + +func TestDueEventRuleNeedsAnEventToMeasureFrom(t *testing.T) { + sch := testScheduler(t) + now := int64(1_700_000_000_000) + rule := Reminder{Kind: "poo", Enabled: true, IntervalMin: 180} + + // No poo has ever been logged, so there is no clock running yet. + if _, ok, err := sch.due("u1", rule, 0, now, sleepState{state: "awake", since: now}); err != nil || ok { + t.Errorf("no history: fired = %v (err %v), want no fire", ok, err) + } +} + +func TestDueRepeatsAtIntervalNotEveryTick(t *testing.T) { + sch := testScheduler(t) + now := int64(1_700_000_000_000) + rule := Reminder{Kind: "eat", Enabled: true, IntervalMin: 240} + awake := sleepState{state: "awake", since: now - 300*minute} + + addEvent(t, sch, "u1", "eat", now-300*minute) + + // Fired a minute ago: stay quiet rather than re-alerting on every tick. + if _, ok, err := sch.due("u1", rule, now-1*minute, now, awake); err != nil || ok { + t.Errorf("just fired: fired = %v (err %v), want no fire", ok, err) + } + // A full interval later it repeats. + if _, ok, err := sch.due("u1", rule, now-240*minute, now, awake); err != nil || !ok { + t.Errorf("interval elapsed: fired = %v (err %v), want fire", ok, err) + } +} + +// A late-syncing device must be able to cancel a reminder retroactively: the +// clock runs on the event's own timestamp, not on when the server heard about it. +func TestDueUsesEventTimeNotSyncTime(t *testing.T) { + sch := testScheduler(t) + now := int64(1_700_000_000_000) + rule := Reminder{Kind: "pee", Enabled: true, IntervalMin: 60} + awake := sleepState{state: "awake", since: now - 300*minute} + + // Logged 10 minutes ago on a phone that was offline, synced just now. + _, err := sch.db.Exec( + `INSERT INTO events (id, type, at, updated, user_id) VALUES ('late', 'pee', ?, ?, 'u1')`, + now-10*minute, now) + if err != nil { + t.Fatalf("insert: %v", err) + } + if _, ok, err := sch.due("u1", rule, 0, now, awake); err != nil || ok { + t.Errorf("late-synced pee: fired = %v (err %v), want no fire", ok, err) + } +} + +func TestDeletedEventsDoNotResetTheClock(t *testing.T) { + sch := testScheduler(t) + now := int64(1_700_000_000_000) + rule := Reminder{Kind: "pee", Enabled: true, IntervalMin: 60} + awake := sleepState{state: "awake", since: now - 300*minute} + + addEvent(t, sch, "u1", "pee", now-90*minute) + // A mistyped pee, logged a minute ago and then deleted: its tombstone must + // not count as the most recent pee. + if _, err := sch.db.Exec( + `INSERT INTO events (id, type, at, updated, deleted, user_id) VALUES ('gone', 'pee', ?, ?, 1, 'u1')`, + now-1*minute, now); err != nil { + t.Fatalf("insert: %v", err) + } + if _, ok, err := sch.due("u1", rule, 0, now, awake); err != nil || !ok { + t.Errorf("deleted pee: fired = %v (err %v), want fire", ok, err) + } +} + +func TestSleepStateMatchesLatestBoundary(t *testing.T) { + sch := testScheduler(t) + now := int64(1_700_000_000_000) + + if s, err := sch.sleepStateFor("u1"); err != nil || s.state != "" { + t.Errorf("empty log: state = %q (err %v), want empty", s.state, err) + } + + addEvent(t, sch, "u1", "sleep-start", now-120*minute) + addEvent(t, sch, "u1", "sleep-end", now-30*minute) + s, err := sch.sleepStateFor("u1") + if err != nil { + t.Fatalf("sleepStateFor: %v", err) + } + if s.state != "awake" || s.since != now-30*minute { + t.Errorf("state = %q since = %d, want awake since %d", s.state, s.since, now-30*minute) + } + + // A later sleep-start flips it back. + addEvent(t, sch, "u1", "sleep-start", now-5*minute) + if s, err := sch.sleepStateFor("u1"); err != nil || s.state != "asleep" { + t.Errorf("state = %q (err %v), want asleep", s.state, err) + } +} + +func TestRemindersStoreDefaultsAndClamping(t *testing.T) { + sch := testScheduler(t) + + list, err := sch.rules.get("u1") + if err != nil { + t.Fatalf("get: %v", err) + } + if len(list) != len(reminderKinds) { + t.Fatalf("got %d rules, want %d", len(list), len(reminderKinds)) + } + for _, r := range list { + if r.Enabled { + t.Errorf("%s enabled by default", r.Kind) + } + } + + // Out-of-range intervals are clamped, and unknown kinds ignored entirely. + if err := sch.rules.put("u1", []Reminder{ + {Kind: "pee", Enabled: true, IntervalMin: 1}, + {Kind: "sleep", Enabled: true, IntervalMin: 99999}, + {Kind: "bark", Enabled: true, IntervalMin: 30}, + }); err != nil { + t.Fatalf("put: %v", err) + } + got := map[string]Reminder{} + list, _ = sch.rules.get("u1") + for _, r := range list { + got[r.Kind] = r + } + if got["pee"].IntervalMin != 5 { + t.Errorf("pee interval = %d, want clamped to 5", got["pee"].IntervalMin) + } + if got["sleep"].IntervalMin != 24*60 { + t.Errorf("sleep interval = %d, want clamped to %d", got["sleep"].IntervalMin, 24*60) + } + if _, ok := got["bark"]; ok { + t.Error("unknown kind 'bark' was stored") + } +} + +// Enabling a rule must clear last_fired, so switching it on alerts right away +// when it is already overdue instead of waiting out a stale interval. +func TestEnablingClearsLastFired(t *testing.T) { + sch := testScheduler(t) + now := time.Now().UnixMilli() + + if err := sch.rules.put("u1", []Reminder{{Kind: "pee", Enabled: true, IntervalMin: 60}}); err != nil { + t.Fatalf("put: %v", err) + } + if _, err := sch.db.Exec( + `UPDATE reminders SET last_fired = ? WHERE user_id = 'u1' AND kind = 'pee'`, now); err != nil { + t.Fatalf("stamp: %v", err) + } + // Off, then on again. + if err := sch.rules.put("u1", []Reminder{{Kind: "pee", Enabled: false, IntervalMin: 60}}); err != nil { + t.Fatalf("put off: %v", err) + } + if err := sch.rules.put("u1", []Reminder{{Kind: "pee", Enabled: true, IntervalMin: 60}}); err != nil { + t.Fatalf("put on: %v", err) + } + var lastFired int64 + if err := sch.db.QueryRow( + `SELECT last_fired FROM reminders WHERE user_id = 'u1' AND kind = 'pee'`).Scan(&lastFired); err != nil { + t.Fatalf("scan: %v", err) + } + if lastFired != 0 { + t.Errorf("last_fired = %d, want 0 after re-enabling", lastFired) + } +} + +func TestHumanDuration(t *testing.T) { + cases := []struct { + ms int64 + want string + }{ + {0, "0 min"}, + {45 * minute, "45 min"}, + {60 * minute, "1 h"}, + {72 * minute, "1 h 12 min"}, + {-5, "0 min"}, + } + for _, c := range cases { + if got := humanDuration(c.ms); got != c.want { + t.Errorf("humanDuration(%d) = %q, want %q", c.ms, got, c.want) + } + } +} + +func TestValidSubscription(t *testing.T) { + good := Subscription{Endpoint: "https://push.example.net/x"} + good.Keys.P256dh = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4" + good.Keys.Auth = "BTBZMqHH6r4Tts7J_aSIgg" + if err := validSubscription(good); err != nil { + t.Fatalf("valid subscription rejected: %v", err) + } + + insecure := good + insecure.Endpoint = "http://push.example.net/x" + if err := validSubscription(insecure); err == nil { + t.Error("http endpoint accepted") + } + + shortAuth := good + shortAuth.Keys.Auth = "AAAA" + if err := validSubscription(shortAuth); err == nil { + t.Error("short auth secret accepted") + } + + badKey := good + badKey.Keys.P256dh = "AAAA" + if err := validSubscription(badKey); err == nil { + t.Error("malformed p256dh accepted") + } +} + +// End-to-end through tick: a due rule reaches a push service with the right +// headers and an encrypted body, gets stamped so it will not immediately repeat, +// and a subscription the service reports as gone is dropped. +func TestTickSendsStampsAndPrunes(t *testing.T) { + sch := testScheduler(t) + now := time.Now().UnixMilli() + + type received struct { + encoding string + auth string + body int + } + var got []received + gone := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + got = append(got, received{ + encoding: r.Header.Get("Content-Encoding"), + auth: r.Header.Get("Authorization"), + body: len(body), + }) + if gone { + w.WriteHeader(http.StatusGone) + return + } + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + sub := Subscription{Endpoint: srv.URL + "/push/abc"} + sub.Keys.P256dh = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4" + sub.Keys.Auth = "BTBZMqHH6r4Tts7J_aSIgg" + if err := sch.subs.save("u1", sub); err != nil { + t.Fatalf("save subscription: %v", err) + } + if err := sch.rules.put("u1", []Reminder{{Kind: "sleep", Enabled: true, IntervalMin: 45}}); err != nil { + t.Fatalf("put rule: %v", err) + } + addEvent(t, sch, "u1", "sleep-end", now-60*minute) + + if err := sch.tick(now); err != nil { + t.Fatalf("tick: %v", err) + } + if len(got) != 1 { + t.Fatalf("push service saw %d requests, want 1", len(got)) + } + if got[0].encoding != "aes128gcm" { + t.Errorf("Content-Encoding = %q, want aes128gcm", got[0].encoding) + } + if !strings.HasPrefix(got[0].auth, "vapid t=") { + t.Errorf("Authorization = %q, want a vapid token", got[0].auth) + } + // Header (16 salt + 4 length + 1 + 65 key) plus a non-empty GCM record. + if got[0].body <= 86 { + t.Errorf("body was %d bytes, want an encrypted record", got[0].body) + } + + // Immediately re-ticking must not re-send: last_fired was stamped. + if err := sch.tick(now + 1000); err != nil { + t.Fatalf("tick: %v", err) + } + if len(got) != 1 { + t.Fatalf("re-tick sent again (%d requests total)", len(got)) + } + + // A full interval later it repeats — and this time the service says the + // subscription is gone, so it must be pruned. + gone = true + if err := sch.tick(now + 46*minute); err != nil { + t.Fatalf("tick: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected a repeat after the interval, got %d requests", len(got)) + } + left, err := sch.subs.forUser("u1") + if err != nil { + t.Fatalf("forUser: %v", err) + } + if len(left) != 0 { + t.Errorf("dead subscription was kept: %d remain", len(left)) + } +} diff --git a/server/webpush.go b/server/webpush.go new file mode 100644 index 0000000..4d2c623 --- /dev/null +++ b/server/webpush.go @@ -0,0 +1,316 @@ +package main + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "log" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// Web Push, implemented against the RFC rather than pulled in as a dependency: +// message encryption is RFC 8291 (ECDH to a per-message key) wrapped in the +// RFC 8188 aes128gcm content encoding, and the request is authorized with a +// VAPID (RFC 8292) ES256 JWT identifying this server to the push service. +// It is ~150 lines of stdlib crypto, and encryptPayload is checked against the +// RFC 8291 §5 test vector in webpush_test.go. + +// b64 is the unpadded base64url alphabet every web push field uses: the keys a +// browser hands us in a PushSubscription, the JWT segments, and the VAPID key. +var b64 = base64.RawURLEncoding + +// Subscription is a browser's PushSubscription: where to send, plus the two +// keys its service worker will decrypt with. Stored verbatim per device. +type Subscription struct { + Endpoint string `json:"endpoint"` + Keys struct { + P256dh string `json:"p256dh"` // the client's public key, uncompressed P-256 point + Auth string `json:"auth"` // 16-byte shared authentication secret + } `json:"keys"` +} + +// VAPIDKey is this server's identity to push services. The same key must be +// used for the lifetime of a subscription: browsers pin the public key given at +// subscribe time, so rotating it invalidates every existing subscription. +type VAPIDKey struct { + priv *ecdsa.PrivateKey + // Public is the uncompressed public point, base64url — handed to the client + // as applicationServerKey and echoed in the Authorization header. + Public string +} + +// vapidFile is the on-disk form of a VAPID key: just the P-256 scalar, so the +// public half is always rederived and can never drift out of sync with it. +type vapidFile struct { + Private string `json:"private"` +} + +func newVAPIDKey() (*VAPIDKey, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + return vapidFromKey(priv), nil +} + +func vapidFromKey(priv *ecdsa.PrivateKey) *VAPIDKey { + pub, _ := priv.PublicKey.ECDH() + return &VAPIDKey{priv: priv, Public: b64.EncodeToString(pub.Bytes())} +} + +func (k *VAPIDKey) marshal() ([]byte, error) { + return json.MarshalIndent(vapidFile{Private: b64.EncodeToString(k.priv.D.FillBytes(make([]byte, 32)))}, "", " ") +} + +func parseVAPIDKey(raw []byte) (*VAPIDKey, error) { + var f vapidFile + if err := json.Unmarshal(raw, &f); err != nil { + return nil, err + } + return vapidFromSeed(f.Private) +} + +// vapidFromSeed rebuilds the keypair from the base64url private scalar, the form +// both the key file and the -vapid-key flag carry. +func vapidFromSeed(seed string) (*VAPIDKey, error) { + d, err := b64.DecodeString(strings.TrimSpace(seed)) + if err != nil { + return nil, fmt.Errorf("decode vapid key: %w", err) + } + if len(d) != 32 { + return nil, fmt.Errorf("vapid key must be 32 bytes, got %d", len(d)) + } + ecdhPriv, err := ecdh.P256().NewPrivateKey(d) + if err != nil { + return nil, fmt.Errorf("invalid vapid key: %w", err) + } + // crypto/ecdh validated the scalar and derived the point for us; split the + // uncompressed encoding (0x04 | X | Y) back into the coordinates ecdsa wants. + point := ecdhPriv.PublicKey().Bytes() + if len(point) != 65 || point[0] != 4 { + return nil, fmt.Errorf("invalid vapid key: bad public point") + } + priv := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{ + Curve: elliptic.P256(), + X: new(big.Int).SetBytes(point[1:33]), + Y: new(big.Int).SetBytes(point[33:]), + }, + D: new(big.Int).SetBytes(d), + } + return vapidFromKey(priv), nil +} + +// authHeader builds the VAPID Authorization header for one push endpoint. The +// audience is the endpoint's origin — a token minted for one push service is +// not valid at another — and the short expiry bounds replay if it leaks. +func (k *VAPIDKey) authHeader(endpoint, subject string) (string, error) { + u, err := url.Parse(endpoint) + if err != nil { + return "", err + } + claims := map[string]any{ + "aud": u.Scheme + "://" + u.Host, + "exp": time.Now().Add(12 * time.Hour).Unix(), + "sub": subject, + } + body, err := json.Marshal(claims) + if err != nil { + return "", err + } + // Header is constant for ES256, so it is spelled out rather than marshalled. + signing := b64.EncodeToString([]byte(`{"typ":"JWT","alg":"ES256"}`)) + "." + b64.EncodeToString(body) + sum := sha256.Sum256([]byte(signing)) + r, s, err := ecdsa.Sign(rand.Reader, k.priv, sum[:]) + if err != nil { + return "", err + } + // JWS wants the raw r||s pair, fixed-width — not the ASN.1 sequence + // ecdsa.SignASN1 would give us. + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + jwt := signing + "." + b64.EncodeToString(sig) + return "vapid t=" + jwt + ", k=" + k.Public, nil +} + +// encryptPayload encrypts plaintext for one subscription per RFC 8291, emitting +// a complete RFC 8188 aes128gcm body: a header carrying the salt and this +// message's ephemeral public key, followed by a single AES-GCM record. +// +// salt and the ephemeral key are parameters rather than generated inline purely +// so the RFC test vector can be reproduced; callers pass nil for both. +func encryptPayload(sub Subscription, plaintext, salt []byte, eph *ecdh.PrivateKey) ([]byte, error) { + clientPubRaw, err := b64.DecodeString(sub.Keys.P256dh) + if err != nil { + return nil, fmt.Errorf("decode p256dh: %w", err) + } + authSecret, err := b64.DecodeString(sub.Keys.Auth) + if err != nil { + return nil, fmt.Errorf("decode auth: %w", err) + } + clientPub, err := ecdh.P256().NewPublicKey(clientPubRaw) + if err != nil { + return nil, fmt.Errorf("invalid p256dh: %w", err) + } + if eph == nil { + if eph, err = ecdh.P256().GenerateKey(rand.Reader); err != nil { + return nil, err + } + } + if salt == nil { + salt = make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return nil, err + } + } + shared, err := eph.ECDH(clientPub) + if err != nil { + return nil, fmt.Errorf("ecdh: %w", err) + } + + // RFC 8291 §3.4: the auth secret salts a first extraction that binds the + // derived key to *both* public keys, so a message can only be decrypted by + // the subscription it was addressed to. + ephPub := eph.PublicKey().Bytes() + keyInfo := append([]byte("WebPush: info\x00"), clientPubRaw...) + keyInfo = append(keyInfo, ephPub...) + ikm, err := hkdf.Key(sha256.New, shared, authSecret, string(keyInfo), 32) + if err != nil { + return nil, err + } + cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16) + if err != nil { + return nil, err + } + nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(cek) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + // Single record, so the padding delimiter is 0x02 ("last record") with no + // padding after it. Multi-record chunking would use 0x01 for earlier records. + record := gcm.Seal(nil, nonce, append(append([]byte{}, plaintext...), 0x02), nil) + + // RFC 8188 §2.1 header: salt | record size | key id length | key id. + var out bytes.Buffer + out.Write(salt) + _ = binary.Write(&out, binary.BigEndian, uint32(4096)) + out.WriteByte(byte(len(ephPub))) + out.Write(ephPub) + out.Write(record) + return out.Bytes(), nil +} + +// pushError reports a push service rejecting a send. Gone is set for the 404 and +// 410 responses that mean the subscription is permanently dead, which is the +// signal callers use to drop it — any other failure is transient and kept. +type pushError struct { + Status int + Body string + Gone bool +} + +func (e *pushError) Error() string { + return fmt.Sprintf("push service returned %d: %s", e.Status, e.Body) +} + +// send delivers one encrypted message to a subscription's endpoint. +func (k *VAPIDKey) send(client *http.Client, sub Subscription, payload []byte, ttl int) error { + body, err := encryptPayload(sub, payload, nil, nil) + if err != nil { + return err + } + auth, err := k.authHeader(sub.Endpoint, vapidSubject) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, sub.Endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", auth) + req.Header.Set("Content-Encoding", "aes128gcm") + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("TTL", fmt.Sprint(ttl)) + // Reminders are only useful while current: if the device is offline long + // enough for a later evaluation to supersede this one, dropping it is right. + req.Header.Set("Urgency", "normal") + + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode >= 200 && res.StatusCode < 300 { + return nil + } + msg := make([]byte, 512) + n, _ := res.Body.Read(msg) + return &pushError{ + Status: res.StatusCode, + Body: strings.TrimSpace(string(msg[:n])), + Gone: res.StatusCode == http.StatusNotFound || res.StatusCode == http.StatusGone, + } +} + +// vapidSubject identifies this server to push services. RFC 8292 wants a +// contact URL; push services in practice only require that it be present and +// well-formed, and this app has no operator address to offer. +const vapidSubject = "mailto:puppy-tracker@localhost" + +// loadVAPIDKey resolves the server's push identity. An explicit seed (flag or +// env) wins so deployments can hold the key in a secrets file; otherwise it is +// read from path, and generated and persisted there on first run. Rotating this +// key silently breaks every existing subscription, so it is only ever created +// when absent — never regenerated on a read error. +func loadVAPIDKey(seed, path string) (*VAPIDKey, error) { + if seed != "" { + return vapidFromSeed(seed) + } + raw, err := os.ReadFile(path) + if err == nil { + return parseVAPIDKey(raw) + } + if !os.IsNotExist(err) { + return nil, err + } + key, err := newVAPIDKey() + if err != nil { + return nil, err + } + out, err := key.marshal() + if err != nil { + return nil, err + } + // 0600: the private half is the only thing stopping someone else pushing + // notifications to this app's users. + if err := os.WriteFile(path, out, 0o600); err != nil { + return nil, err + } + log.Printf("generated VAPID key at %s", path) + return key, nil +} diff --git a/server/webpush_test.go b/server/webpush_test.go new file mode 100644 index 0000000..03dacbd --- /dev/null +++ b/server/webpush_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "crypto/ecdh" + "testing" +) + +// The worked example from RFC 8291 §5. Reproducing it exactly pins every step +// of the derivation — the ECDH, both HKDF extractions, the record padding and +// the RFC 8188 header layout — against the spec rather than against ourselves. +func TestEncryptPayloadRFC8291Vector(t *testing.T) { + const ( + plaintext = "When I grow up, I want to be a watermelon" + authSecret = "BTBZMqHH6r4Tts7J_aSIgg" + receiverPub = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4" + senderPriv = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw" + saltB64 = "DGv6ra1nlYgDCS1FRnbzlw" + wantCiphered = "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml" + + "mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT" + + "pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN" + ) + + var sub Subscription + sub.Endpoint = "https://push.example.net/push/JzLQ3raZJfFBR0aqvOMsLrt54w4rJUsV" + sub.Keys.P256dh = receiverPub + sub.Keys.Auth = authSecret + + salt, err := b64.DecodeString(saltB64) + if err != nil { + t.Fatalf("decode salt: %v", err) + } + seed, err := b64.DecodeString(senderPriv) + if err != nil { + t.Fatalf("decode sender key: %v", err) + } + eph, err := ecdh.P256().NewPrivateKey(seed) + if err != nil { + t.Fatalf("sender key: %v", err) + } + + got, err := encryptPayload(sub, []byte(plaintext), salt, eph) + if err != nil { + t.Fatalf("encryptPayload: %v", err) + } + if b64.EncodeToString(got) != wantCiphered { + t.Errorf("ciphertext mismatch\n got: %s\nwant: %s", b64.EncodeToString(got), wantCiphered) + } +} + +// A VAPID key must survive the round trip through its on-disk form, since the +// public half is pinned by every subscription made while it was in use. +func TestVAPIDKeyRoundTrip(t *testing.T) { + key, err := newVAPIDKey() + if err != nil { + t.Fatalf("generate: %v", err) + } + raw, err := key.marshal() + if err != nil { + t.Fatalf("marshal: %v", err) + } + back, err := parseVAPIDKey(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + if back.Public != key.Public { + t.Errorf("public key changed across round trip: %s != %s", back.Public, key.Public) + } + if _, err := back.authHeader("https://push.example.net/push/abc", vapidSubject); err != nil { + t.Errorf("authHeader: %v", err) + } +} diff --git a/src/app.js b/src/app.js index b667da5..d645887 100644 --- a/src/app.js +++ b/src/app.js @@ -2538,6 +2538,7 @@ settingsTheme.checked = effectiveTheme() === "dark"; settingsConfetti.checked = confettiEnabled(); settingsDialog.showModal(); + refreshRemindersUI(); setTimeout(() => settingsName.focus(), 50); } @@ -2556,6 +2557,15 @@ renderHeader(); refreshPedigreeButton(); settingsDialog.close(); + // Rules are saved with the rest of Settings; the notification toggle itself + // already acted when it was flipped, since permission needs a user gesture. + if (!remindersSection.hidden && !remindersRules.hidden) { + try { + await saveReminderRules(collectReminderRules()); + } catch (err) { + console.warn("saving reminders failed:", err); + } + } try { await pushConfig(cfg); } catch (err) { @@ -2568,6 +2578,277 @@ settingsDialog.close(); }); + // ---------- reminders ---------- + // The server decides when a reminder is due and pushes it (server/reminders.go); + // this side only manages the browser's push subscription and the rule settings. + // Nothing here can fire a notification on its own — a closed PWA has no timers, + // which is the whole reason the evaluation lives on the host. + + const REMINDER_LABELS = { + sleep: "Time to sleep, awake for", + pee: "Time for pee, none for", + poo: "Time for poo, none for", + eat: "Time for a meal, none for", + }; + + const remindersSection = document.getElementById("reminders-section"); + const remindersToggle = document.getElementById("reminders-enabled"); + const remindersHint = document.getElementById("reminders-hint"); + const remindersRules = document.getElementById("reminders-rules"); + const remindersTest = document.getElementById("reminders-test"); + + let pushKey = null; // VAPID public key, once the server has given us one + let reminderRules = []; // last-known rule set, re-rendered into the dialog + + // iOS only exposes push to a PWA that was added to the Home Screen; in a plain + // Safari tab PushManager doesn't exist at all, so the toggle would be dead. + const isStandalone = () => + window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true; + const isIOS = () => + /iP(hone|ad|od)/.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + + const pushSupported = () => + "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; + + // applicationServerKey wants raw bytes, not the base64url the server sends. + function b64UrlToBytes(s) { + const pad = "=".repeat((4 - (s.length % 4)) % 4); + const bin = atob((s + pad).replace(/-/g, "+").replace(/_/g, "/")); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + + function bytesToB64Url(bytes) { + let bin = ""; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + // subscribeForPush returns a live subscription, registering it with the host. + // It runs on every launch, not just when the toggle is flipped: iOS silently + // drops subscriptions, and a stale endpoint fails invisibly until re-posted. + async function subscribeForPush() { + const reg = await navigator.serviceWorker.ready; + let sub = await reg.pushManager.getSubscription(); + + // A subscription made under a different VAPID key can't be reused — the + // browser rejects re-subscribing with a new key — so drop it first. Only + // when we can positively see a mismatch, though: if a browser doesn't + // expose options, re-subscribing blindly would mint a fresh endpoint on + // every launch and strand the old row on the server. + if (sub) { + const existing = sub.options && sub.options.applicationServerKey; + if (existing && bytesToB64Url(new Uint8Array(existing)) !== pushKey) { + try { await sub.unsubscribe(); } catch { /* ignore */ } + sub = null; + } + } + if (!sub) { + sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: b64UrlToBytes(pushKey), + }); + } + const res = await fetch("api/push/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(sub), + }); + if (!res.ok) throw new Error(`subscribe failed: ${res.status}`); + return sub; + } + + async function unsubscribeFromPush() { + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (!sub) return; + // Tell the host first: if the local unsubscribe succeeds but the POST never + // lands, the server would keep pushing to an endpoint nobody listens on. + try { + await fetch("api/push/unsubscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ endpoint: sub.endpoint }), + }); + } catch { /* the endpoint dies on its own once the browser drops it */ } + try { await sub.unsubscribe(); } catch { /* ignore */ } + } + + async function loadReminderRules() { + const res = await fetch("api/reminders"); + if (!res.ok) throw new Error(`reminders: ${res.status}`); + const body = await res.json(); + reminderRules = body.reminders || []; + return reminderRules; + } + + async function saveReminderRules(rules) { + const res = await fetch("api/reminders", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reminders: rules }), + }); + if (!res.ok) throw new Error(`reminders: ${res.status}`); + // The server clamps intervals, so adopt what it actually stored. + reminderRules = (await res.json()).reminders || rules; + } + + function renderReminderRules() { + remindersRules.replaceChildren(); + for (const rule of reminderRules) { + const row = document.createElement("div"); + row.className = "reminder-row"; + row.dataset.kind = rule.kind; + + const name = document.createElement("label"); + name.className = "reminder-name"; + const on = document.createElement("input"); + on.type = "checkbox"; + on.className = "switch reminder-on"; + on.setAttribute("role", "switch"); + on.checked = rule.enabled; + const text = document.createElement("span"); + text.textContent = REMINDER_LABELS[rule.kind] || rule.kind; + name.append(on, text); + + const after = document.createElement("span"); + after.className = "reminder-after"; + const mins = document.createElement("input"); + mins.type = "number"; + mins.className = "reminder-mins"; + mins.min = "5"; + mins.max = "1440"; + mins.step = "5"; + mins.value = String(rule.intervalMin); + const unit = document.createElement("span"); + unit.textContent = "min"; + after.append(mins, unit); + + row.append(name, after); + remindersRules.append(row); + } + } + + // collectReminderRules reads the dialog back into the rule shape the API takes. + function collectReminderRules() { + return [...remindersRules.querySelectorAll(".reminder-row")].map((row) => ({ + kind: row.dataset.kind, + enabled: row.querySelector(".reminder-on").checked, + intervalMin: Number(row.querySelector(".reminder-mins").value) || 60, + })); + } + + function setReminderHint(text) { + remindersHint.textContent = text || ""; + remindersHint.hidden = !text; + } + + // Reflects permission + subscription state: the rules only matter once there + // is somewhere to deliver them. + function showReminderControls(subscribed) { + remindersToggle.checked = subscribed; + remindersRules.hidden = !subscribed; + remindersTest.hidden = !subscribed; + } + + // initReminders runs once at startup. It establishes whether reminders are + // available at all (the server may have no VAPID key, the browser may have no + // push support) and refreshes an existing subscription. + async function initReminders() { + if (!pushSupported()) { + // On iOS this is the Home Screen requirement rather than a missing feature, + // and it's worth saying so — the toggle is otherwise just absent. + if (isIOS() && !isStandalone()) { + remindersSection.hidden = false; + remindersToggle.disabled = true; + setReminderHint("Add Puppy Tracker to your Home Screen to enable reminders."); + } + return; + } + try { + const res = await fetch("api/push/key"); + if (!res.ok) return; // host has push disabled; leave the section hidden + pushKey = (await res.json()).key; + } catch { + return; // offline at boot: try again next launch + } + if (!pushKey) return; + remindersSection.hidden = false; + + if (Notification.permission !== "granted") { + showReminderControls(false); + return; + } + try { + await subscribeForPush(); + showReminderControls(true); + } catch (err) { + console.warn("push re-subscribe failed:", err); + showReminderControls(false); + } + } + + // The toggle acts immediately rather than on Save: requesting notification + // permission has to happen inside a user gesture, and on iOS a deferred + // request is simply ignored. + remindersToggle.addEventListener("change", async () => { + if (!remindersToggle.checked) { + setReminderHint(""); + showReminderControls(false); + await unsubscribeFromPush(); + return; + } + remindersToggle.disabled = true; + try { + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + showReminderControls(false); + setReminderHint( + permission === "denied" + ? "Notifications are blocked for this app in your browser settings." + : "Notifications need permission to work." + ); + return; + } + await subscribeForPush(); + await loadReminderRules(); + renderReminderRules(); + showReminderControls(true); + setReminderHint(""); + } catch (err) { + console.warn("enabling reminders failed:", err); + showReminderControls(false); + setReminderHint("Couldn't enable reminders. Try again once you're online."); + } finally { + remindersToggle.disabled = false; + } + }); + + remindersTest.addEventListener("click", async () => { + remindersTest.disabled = true; + try { + const res = await fetch("api/push/test", { method: "POST" }); + setReminderHint(res.ok ? "Test sent." : "Couldn't send a test notification."); + } catch { + setReminderHint("Couldn't send a test notification."); + } finally { + remindersTest.disabled = false; + } + }); + + // Called when the settings dialog opens, so the rows show what the host has. + async function refreshRemindersUI() { + if (remindersSection.hidden || !pushKey) return; + try { + await loadReminderRules(); + renderReminderRules(); + } catch (err) { + console.warn("loading reminders failed:", err); + } + } + // ---------- pedigree lookup ---------- // A separate full-screen view that resolves a dog against SKK Hunddata by // chip / registration number / name and renders its ancestry as a tree. The @@ -3626,6 +3907,7 @@ refreshPedigreeButton(); sync(); syncConfig(); + initReminders(); } function showAuth() { diff --git a/src/changelog.json b/src/changelog.json index 8733af7..76177fc 100644 --- a/src/changelog.json +++ b/src/changelog.json @@ -1,4 +1,5 @@ [ + { "date": "2026-08-20", "text": "Added reminders: turn them on in Settings and your phone gets a notification when it's time to sleep (\"Awake for 45 min\") or when there's been no pee, poo or meal for a while. Each one has its own interval, they arrive even with the app closed, and they stay quiet while the puppy is logged as asleep so you're not nagged all night. On iPhone, add Puppy Tracker to your Home Screen first — iOS only allows notifications for installed apps" }, { "date": "2026-08-18", "text": "The sleep button that would just repeat the last one is now disabled: while asleep you can only tap ⏰ Sleep end, and while awake only 😴 Sleep start — no more accidental double taps creating zero-length sleep windows. If you did miss a boundary, you can still add it at the right time from the event log" }, { "date": "2026-08-02", "text": "Added free-text notes: tap 📝 Note to jot down things that happened on a day — vaccinations, vet visits, milestones — with a date, optional photo, and any text. All your notes are collected in a new Notes section that stays visible whatever day you're viewing, so you can see at a glance when things like a tick vaccination were done" }, { "date": "2026-08-02", "text": "The Daily counts chart now has Pees / Poos / Meals checkboxes so you can focus on just the metrics you care about — untick the rest to see, say, only poos; your choice is remembered" }, diff --git a/src/icon-180.png b/src/icon-180.png new file mode 100644 index 0000000..2f4e31b Binary files /dev/null and b/src/icon-180.png differ diff --git a/src/icon-192.png b/src/icon-192.png new file mode 100644 index 0000000..4e60111 Binary files /dev/null and b/src/icon-192.png differ diff --git a/src/icon-512.png b/src/icon-512.png new file mode 100644 index 0000000..053f803 Binary files /dev/null and b/src/icon-512.png differ diff --git a/src/index.html b/src/index.html index fe6730d..22dbc09 100644 --- a/src/index.html +++ b/src/index.html @@ -7,7 +7,7 @@ Puppy Tracker - +