Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.00% covered (success)
98.00%
49 / 50
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
SubscriptionManager
98.00% covered (success)
98.00%
49 / 50
75.00% covered (warning)
75.00%
3 / 4
10
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 create
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
5
 getSubscriptionsForUser
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Notifications\Push;
4
5use MediaWiki\Extension\Notifications\Mapper\AbstractMapper;
6use MediaWiki\Storage\NameTableStore;
7use Wikimedia\Rdbms\DBError;
8use Wikimedia\Rdbms\IDatabase;
9
10class SubscriptionManager extends AbstractMapper {
11
12    /** @var IDatabase */
13    private $dbw;
14
15    /** @var IDatabase */
16    private $dbr;
17
18    /** @var NameTableStore */
19    private $pushProviderStore;
20
21    /** @var NameTableStore */
22    private $pushTopicStore;
23
24    /** @var int */
25    private $maxSubscriptionsPerUser;
26
27    /**
28     * @param IDatabase $dbw primary DB connection (for writes)
29     * @param IDatabase $dbr replica DB connection (for reads)
30     * @param NameTableStore $pushProviderStore
31     * @param NameTableStore $pushTopicStore
32     * @param int $maxSubscriptionsPerUser
33     */
34    public function __construct(
35        IDatabase $dbw,
36        IDatabase $dbr,
37        NameTableStore $pushProviderStore,
38        NameTableStore $pushTopicStore,
39        int $maxSubscriptionsPerUser
40    ) {
41        parent::__construct();
42        $this->dbw = $dbw;
43        $this->dbr = $dbr;
44        $this->pushProviderStore = $pushProviderStore;
45        $this->pushTopicStore = $pushTopicStore;
46        $this->maxSubscriptionsPerUser = $maxSubscriptionsPerUser;
47    }
48
49    /**
50     * Store push subscription information for a central user ID.
51     * @param string $provider Provider name string (validated by presence in the PARAM_TYPE array)
52     * @param string $token Subscriber token provided by the push provider
53     * @param int $centralId
54     * @param string|null $topic APNS topic string
55     * @return bool true if the subscription was created; false if the token already exists
56     */
57    public function create( string $provider, string $token, int $centralId, ?string $topic = null ): bool {
58        $subscriptions = $this->getSubscriptionsForUser( $centralId );
59        if ( count( $subscriptions ) >= $this->maxSubscriptionsPerUser ) {
60            // If we exceed the number of subscriptions for this user, then delete the oldest subscription
61            // before inserting the new one, making it behave like a circular buffer.
62            // (Find the oldest subscription by iterating, since their order in the DB is not guaranteed.)
63            $oldest = $subscriptions[0];
64            foreach ( $subscriptions as $subscription ) {
65                if ( $subscription->getUpdated() < $oldest->getUpdated() ) {
66                    $oldest = $subscription;
67                }
68            }
69            $this->delete( [ $oldest->getToken() ], $centralId );
70        }
71        $topicId = $topic ? $this->pushTopicStore->acquireId( $topic ) : null;
72        $this->dbw->newInsertQueryBuilder()
73            ->insertInto( 'echo_push_subscription' )
74            ->ignore()
75            ->row( [
76                'eps_user' => $centralId,
77                'eps_provider' => $this->pushProviderStore->acquireId( $provider ),
78                'eps_token' => $token,
79                'eps_token_sha256' => hash( 'sha256', $token ),
80                'eps_data' => null,
81                'eps_topic' => $topicId,
82                'eps_updated' => $this->dbw->timestamp()
83            ] )
84            ->caller( __METHOD__ )
85            ->execute();
86        return (bool)$this->dbw->affectedRows();
87    }
88
89    /**
90     * Get full data for all registered subscriptions for a user (by central ID).
91     * @param int $centralId
92     * @return array array of Subscription objects
93     */
94    public function getSubscriptionsForUser( int $centralId ) {
95        $res = $this->dbr->newSelectQueryBuilder()
96            ->select( '*' )
97            ->from( 'echo_push_subscription' )
98            ->join( 'echo_push_provider', null, 'eps_provider = epp_id' )
99            ->leftJoin( 'echo_push_topic', null, 'eps_topic = ept_id' )
100            ->where( [ 'eps_user' => $centralId ] )
101            ->caller( __METHOD__ )
102            ->fetchResultSet();
103        $result = [];
104        foreach ( $res as $row ) {
105            $result[] = Subscription::newFromRow( $row );
106        }
107        return $result;
108    }
109
110    /**
111     * Delete one or more push subscriptions by token. Unless the current user is a push
112     * subscription manager, the query will also include the current central user ID as a condition.
113     * @param array $tokens Delete the subscription with these tokens
114     * @param int|null $centralId
115     * @return int number of rows deleted
116     * @throws DBError
117     */
118    public function delete( array $tokens, int $centralId = null ): int {
119        $cond = [ 'eps_token' => $tokens ];
120        if ( $centralId ) {
121            $cond['eps_user'] = $centralId;
122        }
123        $this->dbw->newDeleteQueryBuilder()
124            ->deleteFrom( 'echo_push_subscription' )
125            ->where( $cond )
126            ->caller( __METHOD__ )
127            ->execute();
128        return $this->dbw->affectedRows();
129    }
130
131}