package aviso import ( "context" "crypto/rand" "database/sql" "encoding/base64" "errors" "fmt" "time" ) func newID() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b), nil } const selectCols = `id, endpoint, subject, p256dh, auth, vapid_key_id, revision` func scanStored(rows *sql.Rows) ([]Stored, error) { var out []Stored for rows.Next() { var st Stored if err := rows.Scan(&st.ID, &st.Endpoint, &st.Subject, &st.P256dh, &st.Auth, &st.VAPIDKeyID, &st.Revision); err != nil { return nil, err } out = append(out, st) } return out, rows.Err() } // List returns every device subject has enrolled, oldest first. func (s *Service) List(ctx context.Context, subject string) ([]Stored, error) { rows, err := s.cfg.DB.QueryContext(ctx, `SELECT `+selectCols+` FROM aviso_subscriptions WHERE subject = ? ORDER BY created_at, id`, subject) if err != nil { return nil, fmt.Errorf("aviso: list: %w", err) } defer rows.Close() return scanStored(rows) } // DeleteSubject removes every device subject enrolled — account // deletion's hook. func (s *Service) DeleteSubject(ctx context.Context, subject string) error { _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE subject = ?`, subject) if err != nil { return fmt.Errorf("aviso: delete subject: %w", err) } return nil } // Sweep deletes subscriptions not confirmed since t. Confirmation // moves on reconcile and on an accepted send, so this measures whether // the subscription is alive, not whether the person still wants it. func (s *Service) Sweep(ctx context.Context, t time.Time) error { _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE last_confirmed_at < ?`, t.Unix()) if err != nil { return fmt.Errorf("aviso: sweep: %w", err) } return nil } // put is Subscribe's write: insert, or update when the same subject // already holds the endpoint, in one transaction with the optional // previousEndpoint delete — which only removes the caller's own row, // so naming someone else's endpoint is a no-op rather than a weapon. func (s *Service) put(ctx context.Context, subject string, sub Subscription, previousEndpoint string) error { tx, err := s.cfg.DB.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("aviso: put: %w", err) } defer tx.Rollback() now := s.now().Unix() var owner string err = tx.QueryRowContext(ctx, `SELECT subject FROM aviso_subscriptions WHERE endpoint = ?`, sub.Endpoint).Scan(&owner) switch { case err == nil && owner != subject: return ErrOwnedElsewhere case err == nil: _, err = tx.ExecContext(ctx, `UPDATE aviso_subscriptions SET p256dh = ?, auth = ?, vapid_key_id = ?, revision = revision + 1, last_confirmed_at = ? WHERE endpoint = ?`, sub.P256dh, sub.Auth, s.keyID, now, sub.Endpoint) case errors.Is(err, sql.ErrNoRows): var id string if id, err = newID(); err != nil { return err } _, err = tx.ExecContext(ctx, `INSERT INTO aviso_subscriptions (id, endpoint, subject, p256dh, auth, vapid_key_id, revision, created_at, last_confirmed_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`, id, sub.Endpoint, subject, sub.P256dh, sub.Auth, s.keyID, now, now) } if err != nil { return fmt.Errorf("aviso: put: %w", err) } if previousEndpoint != "" && previousEndpoint != sub.Endpoint { if _, err := tx.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE endpoint = ? AND subject = ?`, previousEndpoint, subject); err != nil { return fmt.Errorf("aviso: put: %w", err) } } return tx.Commit() } // deleteOwn removes endpoint only if subject holds it; nil either way, // because Unsubscribe is idempotent and must not confirm whether an // endpoint exists to someone who does not own it. func (s *Service) deleteOwn(ctx context.Context, subject, endpoint string) error { _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE endpoint = ? AND subject = ?`, endpoint, subject) if err != nil { return fmt.Errorf("aviso: unsubscribe: %w", err) } return nil } // confirm bumps last_confirmed_at for an accepted send, only if the // row is still at the revision the send captured. func (s *Service) confirm(ctx context.Context, id string, revision int64) error { _, err := s.cfg.DB.ExecContext(ctx, `UPDATE aviso_subscriptions SET last_confirmed_at = ? WHERE id = ? AND revision = ?`, s.now().Unix(), id, revision) return err } // prune deletes a row the push service reported gone, only if it is // still at the revision the send captured: a browser that refreshed // meanwhile has a live subscription under the same id. func (s *Service) prune(ctx context.Context, id string, revision int64) error { _, err := s.cfg.DB.ExecContext(ctx, `DELETE FROM aviso_subscriptions WHERE id = ? AND revision = ?`, id, revision) return err }