Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.26% covered (warning)
86.26%
113 / 131
38.46% covered (danger)
38.46%
5 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiCoreThank
86.26% covered (warning)
86.26%
113 / 131
38.46% covered (danger)
38.46%
5 / 13
35.83
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 execute
91.11% covered (success)
91.11%
41 / 45
0.00% covered (danger)
0.00%
0 / 1
10.07
 userAlreadySentThanks
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getRevisionFromId
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 getLogEntryFromId
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 getTitleFromRevision
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getSourceFromParams
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getUserFromRevision
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getUserFromLog
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 sendThanks
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
2.00
 getAllowedParams
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Thanks\Api;
4
5use ApiBase;
6use ApiMain;
7use DatabaseLogEntry;
8use EchoDiscussionParser;
9use LogEntry;
10use MediaWiki\Extension\Notifications\Model\Event;
11use MediaWiki\Extension\Thanks\Storage\Exceptions\InvalidLogType;
12use MediaWiki\Extension\Thanks\Storage\Exceptions\LogDeleted;
13use MediaWiki\Extension\Thanks\Storage\LogStore;
14use MediaWiki\Permissions\PermissionManager;
15use MediaWiki\Revision\RevisionRecord;
16use MediaWiki\Revision\RevisionStore;
17use MediaWiki\Title\Title;
18use MediaWiki\User\User;
19use MediaWiki\User\UserFactory;
20use MediaWiki\User\UserIdentity;
21use Wikimedia\ParamValidator\ParamValidator;
22use Wikimedia\ParamValidator\TypeDef\IntegerDef;
23
24/**
25 * API module to send thanks notifications for revisions and log entries.
26 *
27 * @ingroup API
28 * @ingroup Extensions
29 */
30class ApiCoreThank extends ApiThank {
31    protected RevisionStore $revisionStore;
32    protected UserFactory $userFactory;
33
34    public function __construct(
35        ApiMain $main,
36        $action,
37        PermissionManager $permissionManager,
38        RevisionStore $revisionStore,
39        UserFactory $userFactory,
40        LogStore $storage
41    ) {
42        parent::__construct(
43            $main,
44            $action,
45            $permissionManager,
46            $storage
47        );
48        $this->revisionStore = $revisionStore;
49        $this->userFactory = $userFactory;
50    }
51
52    /**
53     * Perform the API request.
54     * @suppress PhanTypeMismatchArgumentNullable T240141
55     * @suppress PhanPossiblyUndeclaredVariable Phan get's confused by the badly arranged code
56     */
57    public function execute() {
58        // Initial setup.
59        $user = $this->getUser();
60        $this->dieOnBadUser( $user );
61        $this->dieOnUserBlockedFromThanks( $user );
62        $params = $this->extractRequestParams();
63        $revcreation = false;
64
65        $this->requireOnlyOneParameter( $params, 'rev', 'log' );
66
67        // Extract type and ID from the parameters.
68        if ( isset( $params['rev'] ) && !isset( $params['log'] ) ) {
69            $type = 'rev';
70            $id = $params['rev'];
71        } elseif ( !isset( $params['rev'] ) && isset( $params['log'] ) ) {
72            $type = 'log';
73            $id = $params['log'];
74        } else {
75            $this->dieWithError( 'thanks-error-api-params', 'thanks-error-api-params' );
76        }
77
78        $recipientUsername = null;
79        // Determine thanks parameters.
80        if ( $type === 'log' ) {
81            $logEntry = $this->getLogEntryFromId( $id );
82            // If there's an associated revision, thank for that instead.
83            if ( $logEntry->getAssociatedRevId() ) {
84                $type = 'rev';
85                $id = $logEntry->getAssociatedRevId();
86            } else {
87                // If there's no associated revision, die if the user is sitewide blocked
88                $excerpt = '';
89                $title = $logEntry->getTarget();
90                $recipient = $this->getUserFromLog( $logEntry );
91                $recipientUsername = $recipient->getName();
92            }
93        }
94        if ( $type === 'rev' ) {
95            $revision = $this->getRevisionFromId( $id );
96            $excerpt = EchoDiscussionParser::getEditExcerpt( $revision, $this->getLanguage() );
97            $title = $this->getTitleFromRevision( $revision );
98            $this->dieOnUserBlockedFromTitle( $user, $title );
99
100            $recipient = $this->getUserFromRevision( $revision );
101            $recipientUsername = $recipient->getName();
102
103            // If there is no parent revid of this revision, it's a page creation.
104            if ( !$this->revisionStore->getPreviousRevision( $revision ) ) {
105                $revcreation = true;
106            }
107        }
108
109        // Send thanks.
110        if ( $this->userAlreadySentThanks( $user, $type, $id ) ) {
111            $this->markResultSuccess( $recipientUsername );
112        } else {
113            $this->dieOnBadRecipient( $user, $recipient );
114            $this->sendThanks(
115                $user,
116                $type,
117                $id,
118                $excerpt,
119                $recipient,
120                $this->getSourceFromParams( $params ),
121                $title,
122                $revcreation
123            );
124        }
125    }
126
127    /**
128     * Check the session data for an indication of whether this user has already sent this thanks.
129     * @param User $user The user being thanked.
130     * @param string $type Either 'rev' or 'log'.
131     * @param int $id The revision or log ID.
132     * @return bool
133     */
134    protected function userAlreadySentThanks( User $user, $type, $id ) {
135        if ( $type === 'rev' ) {
136            // For b/c with old-style keys
137            $type = '';
138        }
139        return (bool)$user->getRequest()->getSessionData( "thanks-thanked-$type$id" );
140    }
141
142    private function getRevisionFromId( $revId ) {
143        $revision = $this->revisionStore->getRevisionById( $revId );
144        // Revision ID 1 means an invalid argument was passed in.
145        // FIXME Get rid of this limitation! T344475
146        if ( !$revision || $revision->getId() === 1 ) {
147            $this->dieWithError( 'thanks-error-invalidrevision', 'invalidrevision' );
148        } elseif ( $revision->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
149            $this->dieWithError( 'thanks-error-revdeleted', 'revdeleted' );
150        }
151        return $revision;
152    }
153
154    /**
155     * Get the log entry from the ID.
156     * @param int $logId The log entry ID.
157     * @return DatabaseLogEntry
158     */
159    protected function getLogEntryFromId( $logId ): DatabaseLogEntry {
160        $logEntry = null;
161        try {
162            $logEntry = $this->storage->getLogEntryFromId( $logId );
163        } catch ( InvalidLogType $e ) {
164            $err = $this->msg( 'thanks-error-invalid-log-type', $e->getLogType() );
165            $this->dieWithError( $err, 'thanks-error-invalid-log-type' );
166        } catch ( LogDeleted $e ) {
167            $this->dieWithError( 'thanks-error-log-deleted', 'thanks-error-log-deleted' );
168        }
169
170        if ( !$logEntry ) {
171            $this->dieWithError( 'thanks-error-invalid-log-id', 'thanks-error-invalid-log-id' );
172        }
173        // @phan-suppress-next-line PhanTypeMismatchReturnNullable T240141
174        return $logEntry;
175    }
176
177    private function getTitleFromRevision( RevisionRecord $revision ) {
178        $title = Title::castFromPageIdentity( $revision->getPage() );
179        if ( !$title instanceof Title ) {
180            $this->dieWithError( 'thanks-error-notitle', 'notitle' );
181        }
182        return $title;
183    }
184
185    /**
186     * Set the source of the thanks, e.g. 'diff' or 'history'
187     * @param string[] $params Incoming API parameters, with a 'source' key.
188     * @return string The source, or 'undefined' if not provided.
189     */
190    private function getSourceFromParams( $params ) {
191        if ( $params['source'] ) {
192            return trim( $params['source'] );
193        } else {
194            return 'undefined';
195        }
196    }
197
198    /**
199     * @param RevisionRecord $revision
200     * @return User
201     */
202    private function getUserFromRevision( RevisionRecord $revision ) {
203        $recipient = $revision->getUser();
204        if ( !$recipient ) {
205            $this->dieWithError( 'thanks-error-invalidrecipient', 'invalidrecipient' );
206        }
207        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
208        return $this->userFactory->newFromUserIdentity( $recipient );
209    }
210
211    /**
212     * @param LogEntry $logEntry
213     * @return UserIdentity
214     */
215    private function getUserFromLog( LogEntry $logEntry ) {
216        $recipient = $logEntry->getPerformerIdentity();
217        return $this->userFactory->newFromUserIdentity( $recipient );
218    }
219
220    /**
221     * Create the thanks notification event, and log the thanks.
222     * @param User $user The thanks-sending user.
223     * @param string $type The thanks type ('rev' or 'log').
224     * @param int $id The log or revision ID.
225     * @param string $excerpt The excerpt to display as the thanks notification. This will only
226     * be used if it is not possible to retrieve the relevant excerpt at the time the
227     * notification is displayed (in order to account for changing visibility in the meantime).
228     * @param User $recipient The recipient of the thanks.
229     * @param string $source Where the thanks was given.
230     * @param Title $title The title of the page for which thanks is given.
231     * @param bool $revcreation True if the linked revision is a page creation.
232     */
233    protected function sendThanks(
234        User $user, $type, $id, $excerpt, User $recipient, $source, Title $title, $revcreation
235    ) {
236        $uniqueId = $type . '-' . $id;
237        // Do one last check to make sure we haven't sent Thanks before
238        if ( $this->haveAlreadyThanked( $user, $uniqueId ) ) {
239            // Pretend the thanks were sent
240            $this->markResultSuccess( $recipient->getName() );
241            return;
242        }
243
244        // Create the notification via Echo extension
245        Event::create( [
246            'type' => 'edit-thank',
247            'title' => $title,
248            'extra' => [
249                $type . 'id' => $id,
250                'thanked-user-id' => $recipient->getId(),
251                'source' => $source,
252                'excerpt' => $excerpt,
253                'revcreation' => $revcreation,
254            ],
255            'agent' => $user,
256        ] );
257
258        // And mark the thank in session for a cheaper check to prevent duplicates (Phab:T48690).
259        $user->getRequest()->setSessionData( "thanks-thanked-$type$id", true );
260        // Set success message
261        $this->markResultSuccess( $recipient->getName() );
262        $this->logThanks( $user, $recipient, $uniqueId );
263    }
264
265    public function getAllowedParams() {
266        return [
267            'rev' => [
268                ParamValidator::PARAM_TYPE => 'integer',
269                IntegerDef::PARAM_MIN => 1,
270                ParamValidator::PARAM_REQUIRED => false,
271            ],
272            'log' => [
273                ParamValidator::PARAM_TYPE => 'integer',
274                IntegerDef::PARAM_MIN => 1,
275                ParamValidator::PARAM_REQUIRED => false,
276            ],
277            'token' => [
278                ParamValidator::PARAM_TYPE => 'string',
279                ParamValidator::PARAM_REQUIRED => true,
280            ],
281            'source' => [
282                ParamValidator::PARAM_TYPE => 'string',
283                ParamValidator::PARAM_REQUIRED => false,
284            ]
285        ];
286    }
287
288    public function getHelpUrls() {
289        return [
290            'https://www.mediawiki.org/wiki/Extension:Thanks#API_Documentation',
291        ];
292    }
293
294    /**
295     * @see ApiBase::getExamplesMessages()
296     * @return array
297     */
298    protected function getExamplesMessages() {
299        return [
300            'action=thank&revid=456&source=diff&token=123ABC'
301                => 'apihelp-thank-example-1',
302        ];
303    }
304}