Commit 2979e93075
Verified · cmc
internal/store/migrations/0059_push.down.sql added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | DROP TABLE push_queue; | |
| 2 | DROP TABLE push_devices; | |
| 3 | ALTER TABLE users DROP COLUMN notify_push; | |
internal/store/migrations/0059_push.up.sql added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | -- Apple devices an account has registered, and the queue of pushes bound | |
| 2 | -- for them. The mail queue's table is named `notifications`, so this one | |
| 3 | -- cannot be; the columns mirror it so the drainer is the mailer's loop. | |
| 4 | CREATE TABLE push_devices ( | |
| 5 | id INTEGER PRIMARY KEY, | |
| 6 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 7 | token TEXT NOT NULL UNIQUE, | |
| 8 | label TEXT NOT NULL DEFAULT '', | |
| 9 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 10 | last_seen_at TEXT | |
| 11 | ); | |
| 12 | CREATE INDEX push_devices_user ON push_devices(user_id); | |
| 13 | ||
| 14 | CREATE TABLE push_queue ( | |
| 15 | id INTEGER PRIMARY KEY, | |
| 16 | device_id INTEGER NOT NULL REFERENCES push_devices(id) ON DELETE CASCADE, | |
| 17 | title TEXT NOT NULL, | |
| 18 | body TEXT NOT NULL, | |
| 19 | path TEXT NOT NULL, | |
| 20 | attempts INTEGER NOT NULL DEFAULT 0, | |
| 21 | next_attempt_at TEXT, | |
| 22 | sent_at TEXT, | |
| 23 | failed_at TEXT, | |
| 24 | last_error TEXT, | |
| 25 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 26 | ); | |
| 27 | CREATE INDEX push_queue_due ON push_queue(next_attempt_at) | |
| 28 | WHERE sent_at IS NULL AND failed_at IS NULL; | |
| 29 | ||
| 30 | -- Whether activity reaches the account's registered devices. Defaults on | |
| 31 | -- and costs nothing for an account with no devices; it exists so a user | |
| 32 | -- with a phone and an iPad silences both without deregistering each. | |
| 33 | ALTER TABLE users ADD COLUMN notify_push INTEGER NOT NULL DEFAULT 1; | |
internal/store/push.go added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "errors" | |
| 6 | ) | |
| 7 | ||
| 8 | // PushDevice is one Apple device an account has registered. Token is the | |
| 9 | // APNs device token: an address, not a credential, but device-identifying | |
| 10 | // and never logged or echoed in full. | |
| 11 | type PushDevice struct { | |
| 12 | ID int64 | |
| 13 | UserID int64 | |
| 14 | Token string | |
| 15 | Label string | |
| 16 | CreatedAt string | |
| 17 | LastSeenAt string | |
| 18 | } | |
| 19 | ||
| 20 | // AddPushDevice registers a token to an account. A token already present | |
| 21 | // changes hands rather than erroring: Apple reuses tokens, and a reinstall | |
| 22 | // hands the same one to whichever account signs in next. | |
| 23 | func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error) { | |
| 24 | res, err := s.DB.Exec(` | |
| 25 | INSERT INTO push_devices (user_id, token, label) VALUES (?, ?, ?) | |
| 26 | ON CONFLICT(token) DO UPDATE SET user_id = excluded.user_id, label = excluded.label`, | |
| 27 | userID, token, label) | |
| 28 | if err != nil { | |
| 29 | return 0, err | |
| 30 | } | |
| 31 | return res.LastInsertId() | |
| 32 | } | |
| 33 | ||
| 34 | func (s *Store) PushDevices(userID int64) ([]PushDevice, error) { | |
| 35 | rows, err := s.DB.Query(` | |
| 36 | SELECT id, user_id, token, label, created_at, COALESCE(last_seen_at, '') | |
| 37 | FROM push_devices WHERE user_id = ? ORDER BY id`, userID) | |
| 38 | if err != nil { | |
| 39 | return nil, err | |
| 40 | } | |
| 41 | defer rows.Close() | |
| 42 | var out []PushDevice | |
| 43 | for rows.Next() { | |
| 44 | var d PushDevice | |
| 45 | if err := rows.Scan(&d.ID, &d.UserID, &d.Token, &d.Label, &d.CreatedAt, &d.LastSeenAt); err != nil { | |
| 46 | return nil, err | |
| 47 | } | |
| 48 | out = append(out, d) | |
| 49 | } | |
| 50 | return out, rows.Err() | |
| 51 | } | |
| 52 | ||
| 53 | // RemovePushDevice deletes one of the account's own devices. Scoping the | |
| 54 | // delete by user_id rather than checking ownership first means another | |
| 55 | // account's id is ErrNotFound, which is the same answer as an id that | |
| 56 | // never existed — a caller learns nothing about other accounts' devices. | |
| 57 | func (s *Store) RemovePushDevice(userID, id int64) error { | |
| 58 | res, err := s.DB.Exec("DELETE FROM push_devices WHERE id = ? AND user_id = ?", id, userID) | |
| 59 | if err != nil { | |
| 60 | return err | |
| 61 | } | |
| 62 | n, err := res.RowsAffected() | |
| 63 | if err != nil { | |
| 64 | return err | |
| 65 | } | |
| 66 | if n == 0 { | |
| 67 | return ErrNotFound | |
| 68 | } | |
| 69 | return nil | |
| 70 | } | |
| 71 | ||
| 72 | func (s *Store) PushEnabled(userID int64) (bool, error) { | |
| 73 | var on int | |
| 74 | err := s.DB.QueryRow("SELECT notify_push FROM users WHERE id = ?", userID).Scan(&on) | |
| 75 | if errors.Is(err, sql.ErrNoRows) { | |
| 76 | return false, ErrNotFound | |
| 77 | } | |
| 78 | return on != 0, err | |
| 79 | } | |
| 80 | ||
| 81 | func (s *Store) SetPushEnabled(userID int64, on bool) error { | |
| 82 | v := 0 | |
| 83 | if on { | |
| 84 | v = 1 | |
| 85 | } | |
| 86 | _, err := s.DB.Exec("UPDATE users SET notify_push = ? WHERE id = ?", v, userID) | |
| 87 | return err | |
| 88 | } | |
internal/store/push_test.go added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | func pushFixture(t *testing.T) *Store { | |
| 6 | t.Helper() | |
| 7 | s := open(t) | |
| 8 | if err := s.MigrateUp(); err != nil { | |
| 9 | t.Fatal(err) | |
| 10 | } | |
| 11 | return s | |
| 12 | } | |
| 13 | ||
| 14 | func TestPushDevices(t *testing.T) { | |
| 15 | s := pushFixture(t) | |
| 16 | uid, err := s.CreateUser("alice", false) | |
| 17 | if err != nil { | |
| 18 | t.Fatal(err) | |
| 19 | } | |
| 20 | ||
| 21 | if _, err := s.AddPushDevice(uid, "tok-a", "iphone"); err != nil { | |
| 22 | t.Fatalf("AddPushDevice: %v", err) | |
| 23 | } | |
| 24 | devices, err := s.PushDevices(uid) | |
| 25 | if err != nil { | |
| 26 | t.Fatalf("PushDevices: %v", err) | |
| 27 | } | |
| 28 | if len(devices) != 1 || devices[0].Token != "tok-a" || devices[0].Label != "iphone" { | |
| 29 | t.Fatalf("got %+v", devices) | |
| 30 | } | |
| 31 | ||
| 32 | // Apple reuses tokens: re-registering updates the label and the owner | |
| 33 | // rather than erroring, so a reinstall under another account works. | |
| 34 | bob, err := s.CreateUser("bob", false) | |
| 35 | if err != nil { | |
| 36 | t.Fatal(err) | |
| 37 | } | |
| 38 | if _, err := s.AddPushDevice(bob, "tok-a", "ipad"); err != nil { | |
| 39 | t.Fatalf("re-register: %v", err) | |
| 40 | } | |
| 41 | if d, _ := s.PushDevices(uid); len(d) != 0 { | |
| 42 | t.Fatalf("token still owned by alice: %+v", d) | |
| 43 | } | |
| 44 | d, _ := s.PushDevices(bob) | |
| 45 | if len(d) != 1 || d[0].Label != "ipad" { | |
| 46 | t.Fatalf("got %+v", d) | |
| 47 | } | |
| 48 | ||
| 49 | // Removal is scoped to the owner: alice cannot remove bob's device. | |
| 50 | if err := s.RemovePushDevice(uid, d[0].ID); err != ErrNotFound { | |
| 51 | t.Fatalf("cross-account remove: got %v, want ErrNotFound", err) | |
| 52 | } | |
| 53 | if err := s.RemovePushDevice(bob, d[0].ID); err != nil { | |
| 54 | t.Fatalf("RemovePushDevice: %v", err) | |
| 55 | } | |
| 56 | if d, _ := s.PushDevices(bob); len(d) != 0 { | |
| 57 | t.Fatalf("device survived removal: %+v", d) | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | func TestPushEnabledDefaultsOn(t *testing.T) { | |
| 62 | s := pushFixture(t) | |
| 63 | uid, err := s.CreateUser("alice", false) | |
| 64 | if err != nil { | |
| 65 | t.Fatal(err) | |
| 66 | } | |
| 67 | on, err := s.PushEnabled(uid) | |
| 68 | if err != nil { | |
| 69 | t.Fatalf("PushEnabled: %v", err) | |
| 70 | } | |
| 71 | if !on { | |
| 72 | t.Fatal("notify_push should default on") | |
| 73 | } | |
| 74 | if err := s.SetPushEnabled(uid, false); err != nil { | |
| 75 | t.Fatalf("SetPushEnabled: %v", err) | |
| 76 | } | |
| 77 | if on, _ := s.PushEnabled(uid); on { | |
| 78 | t.Fatal("SetPushEnabled(false) did not stick") | |
| 79 | } | |
| 80 | } | |