internal/store/push.go

202 lines · 6602 bytes

  1package store
  2
  3import (
  4	"database/sql"
  5	"errors"
  6	"time"
  7)
  8
  9// PushDevice is one Apple device an account has registered. Token is the
 10// APNs device token: an address, not a credential, but device-identifying
 11// and never logged or echoed in full.
 12type PushDevice struct {
 13	ID         int64
 14	UserID     int64
 15	Token      string
 16	Label      string
 17	CreatedAt  string
 18	LastSeenAt string
 19}
 20
 21// AddPushDevice registers a token to an account. A token already present
 22// changes hands rather than erroring: Apple reuses tokens, and a reinstall
 23// hands the same one to whichever account signs in next. The id is read
 24// back by token rather than taken from LastInsertId, which SQLite leaves
 25// unchanged when the DO UPDATE arm fires instead of the INSERT.
 26//
 27// The row id survives that handover, so queue rows written for the
 28// previous owner would still be delivered to the device — and an alert
 29// carries the repository name and item number in full. Undelivered rows
 30// go with the ownership, in the same transaction; sent and dead-lettered
 31// rows are history and stay.
 32func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error) {
 33	tx, err := s.DB.Begin()
 34	if err != nil {
 35		return 0, err
 36	}
 37	defer tx.Rollback()
 38	var prev int64
 39	if err := tx.QueryRow("SELECT user_id FROM push_devices WHERE token = ?", token).Scan(&prev); err != nil && !errors.Is(err, sql.ErrNoRows) {
 40		return 0, err
 41	}
 42	if _, err := tx.Exec(`
 43		INSERT INTO push_devices (user_id, token, label) VALUES (?, ?, ?)
 44		ON CONFLICT(token) DO UPDATE SET user_id = excluded.user_id, label = excluded.label`,
 45		userID, token, label); err != nil {
 46		return 0, err
 47	}
 48	var id int64
 49	if err := tx.QueryRow("SELECT id FROM push_devices WHERE token = ?", token).Scan(&id); err != nil {
 50		return 0, err
 51	}
 52	if prev != 0 && prev != userID {
 53		if _, err := tx.Exec(
 54			"DELETE FROM push_queue WHERE device_id = ? AND sent_at IS NULL AND failed_at IS NULL", id); err != nil {
 55			return 0, err
 56		}
 57	}
 58	return id, tx.Commit()
 59}
 60
 61func (s *Store) PushDevices(userID int64) ([]PushDevice, error) {
 62	rows, err := s.DB.Query(`
 63		SELECT id, user_id, token, label, created_at, COALESCE(last_seen_at, '')
 64		FROM push_devices WHERE user_id = ? ORDER BY id`, userID)
 65	if err != nil {
 66		return nil, err
 67	}
 68	defer rows.Close()
 69	var out []PushDevice
 70	for rows.Next() {
 71		var d PushDevice
 72		if err := rows.Scan(&d.ID, &d.UserID, &d.Token, &d.Label, &d.CreatedAt, &d.LastSeenAt); err != nil {
 73			return nil, err
 74		}
 75		out = append(out, d)
 76	}
 77	return out, rows.Err()
 78}
 79
 80// RemovePushDevice deletes one of the account's own devices. Scoping the
 81// delete by user_id rather than checking ownership first means another
 82// account's id is ErrNotFound, which is the same answer as an id that
 83// never existed — a caller learns nothing about other accounts' devices.
 84func (s *Store) RemovePushDevice(userID, id int64) error {
 85	res, err := s.DB.Exec("DELETE FROM push_devices WHERE id = ? AND user_id = ?", id, userID)
 86	if err != nil {
 87		return err
 88	}
 89	n, err := res.RowsAffected()
 90	if err != nil {
 91		return err
 92	}
 93	if n == 0 {
 94		return ErrNotFound
 95	}
 96	return nil
 97}
 98
 99func (s *Store) PushEnabled(userID int64) (bool, error) {
100	var on int
101	err := s.DB.QueryRow("SELECT notify_push FROM users WHERE id = ?", userID).Scan(&on)
102	if errors.Is(err, sql.ErrNoRows) {
103		return false, ErrNotFound
104	}
105	return on != 0, err
106}
107
108func (s *Store) SetPushEnabled(userID int64, on bool) error {
109	v := 0
110	if on {
111		v = 1
112	}
113	_, err := s.DB.Exec("UPDATE users SET notify_push = ? WHERE id = ?", v, userID)
114	return err
115}
116
117// QueuedPush is one pending push, joined to the token it is bound for so
118// the drainer needs one query rather than two.
119type QueuedPush struct {
120	ID       int64
121	DeviceID int64
122	Token    string
123	// Username is the recipient. One device token is one install, and an
124	// install registers against every account signed in on it, so the
125	// alert has to name which of them it is for.
126	Username string
127	Title    string
128	Body     string
129	Path     string
130	Attempts int
131	// Badge is the recipient's unread inbox count, for the alert's badge.
132	// Counted here rather than at enqueue so a cleared inbox is reflected.
133	Badge int
134}
135
136// EnqueuePush writes one row per registered device, and nothing when the
137// account has push off or no devices — the same shape as
138// ActivityMailAddress returning "" when notify_mail is off. Mute, watch
139// and actor-exclusion are already settled by NotifyRecipients before a
140// caller reaches here.
141func (s *Store) EnqueuePush(userID int64, title, body, path string) error {
142	on, err := s.PushEnabled(userID)
143	if err != nil || !on {
144		return err
145	}
146	_, err = s.DB.Exec(`
147		INSERT INTO push_queue (device_id, title, body, path)
148		SELECT id, ?, ?, ? FROM push_devices WHERE user_id = ?`,
149		title, body, path, userID)
150	return err
151}
152
153func (s *Store) DuePush(limit int) ([]QueuedPush, error) {
154	rows, err := s.DB.Query(`
155		SELECT q.id, q.device_id, d.token, u.username, q.title, q.body, q.path, q.attempts,
156		       (SELECT COUNT(*) FROM inbox WHERE user_id = d.user_id AND read_at IS NULL)
157		FROM push_queue q
158		JOIN push_devices d ON d.id = q.device_id
159		JOIN users u ON u.id = d.user_id
160		WHERE q.sent_at IS NULL AND q.failed_at IS NULL
161		  AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
162		ORDER BY q.id LIMIT ?`, fmtTime(time.Now()), limit)
163	if err != nil {
164		return nil, err
165	}
166	defer rows.Close()
167	var out []QueuedPush
168	for rows.Next() {
169		var p QueuedPush
170		if err := rows.Scan(&p.ID, &p.DeviceID, &p.Token, &p.Username, &p.Title, &p.Body, &p.Path, &p.Attempts, &p.Badge); err != nil {
171			return nil, err
172		}
173		out = append(out, p)
174	}
175	return out, rows.Err()
176}
177
178func (s *Store) MarkPushSent(id int64) error {
179	_, err := s.DB.Exec(
180		"UPDATE push_queue SET sent_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1 WHERE id = ?", id)
181	return err
182}
183
184func (s *Store) MarkPushFailed(id int64, errMsg string, nextAt *time.Time) error {
185	if nextAt == nil {
186		_, err := s.DB.Exec(
187			"UPDATE push_queue SET failed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1, last_error = ? WHERE id = ?",
188			errMsg, id)
189		return err
190	}
191	_, err := s.DB.Exec(
192		"UPDATE push_queue SET attempts = attempts + 1, last_error = ?, next_attempt_at = ? WHERE id = ?",
193		errMsg, fmtTime(*nextAt), id)
194	return err
195}
196
197// DeletePushDeviceByToken drops a device Apple has told us is gone. The
198// queue rows cascade, so nothing is left retrying at a dead token.
199func (s *Store) DeletePushDeviceByToken(token string) error {
200	_, err := s.DB.Exec("DELETE FROM push_devices WHERE token = ?", token)
201	return err
202}