Add push reminders for sleep, pee, poo and meals
A closed PWA has no timers, so reminders are evaluated on the server: the event log is already there (clients sync on every mutation), and a ticker re-checks each enabled rule once a minute and pushes the ones that are 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 and stay quiet while the puppy is asleep — otherwise they nag all night, and suppressing them means an overdue rule instead fires promptly on waking, which is when it actually matters. Sleep state is derived exactly the way currentSleepState() does in app.js, tie-break included, so both sides always agree. Rules read the event's own timestamp rather than when it synced, so a pee logged offline at 03:10 cancels the reminder retroactively. Every push carries a tag, so a repeat replaces the previous notification instead of stacking another one on the lock screen. last_fired is server-owned and not writable by a client, so a stale device can't force a re-fire. Web Push is implemented directly rather than pulled in as a dependency: RFC 8291 encryption in the RFC 8188 aes128gcm coding with an RFC 8292 VAPID token, stdlib only, checked against the RFC 8291 test vector. The key is generated into vapid.json beside the DB or supplied via -vapid-key; without one the server logs a warning, skips registering the routes, and the client hides the UI. Subscriptions a push service reports as 404/410 are dropped. PNG icons are added because iOS gates push on a Home Screen install and rejects SVG for apple-touch-icon, and Android has no notification icon without them.
This commit is contained in:
+5
-2
@@ -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 = ?`,
|
||||
} {
|
||||
|
||||
+43
-2
@@ -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"))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user