Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
163 / 163
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
ReportHandler
100.00% covered (success)
100.00%
163 / 163
100.00% covered (success)
100.00%
7 / 7
32
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 run
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 validateUserCanSubmitReport
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
10
 getIncidentReportObjectFromValidatedBody
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
9
 authorizeIncidentReport
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 submitIncidentReport
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
5
 getBodyParamSettings
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\ReportIncident\Api\Rest\Handler;
4
5use Language;
6use MediaWiki\CommentStore\CommentStore;
7use MediaWiki\Config\Config;
8use MediaWiki\Extension\ReportIncident\IncidentReport;
9use MediaWiki\Extension\ReportIncident\Services\ReportIncidentManager;
10use MediaWiki\Logger\LoggerFactory;
11use MediaWiki\Permissions\PermissionStatus;
12use MediaWiki\Rest\LocalizedHttpException;
13use MediaWiki\Rest\Response;
14use MediaWiki\Rest\SimpleHandler;
15use MediaWiki\Rest\TokenAwareHandlerTrait;
16use MediaWiki\Revision\RevisionStore;
17use MediaWiki\User\UserFactory;
18use MediaWiki\User\UserIdentity;
19use MediaWiki\User\UserIdentityLookup;
20use MediaWiki\User\UserIdentityValue;
21use MediaWiki\User\UserNameUtils;
22use Psr\Log\LoggerInterface;
23use Wikimedia\Message\MessageValue;
24use Wikimedia\ParamValidator\ParamValidator;
25use Wikimedia\Timestamp\ConvertibleTimestamp;
26
27/**
28 * REST handler for /reportincident/v0/report
29 */
30class ReportHandler extends SimpleHandler {
31
32    use TokenAwareHandlerTrait;
33
34    private Config $config;
35    private ReportIncidentManager $reportIncidentManager;
36    private RevisionStore $revisionStore;
37    private UserNameUtils $userNameUtils;
38    private UserIdentityLookup $userIdentityLookup;
39    private LoggerInterface $logger;
40    private UserFactory $userFactory;
41    private Language $contentLanguage;
42
43    /**
44     * @param Config $config
45     * @param RevisionStore $revisionStore
46     * @param UserNameUtils $userNameUtils
47     * @param UserIdentityLookup $userIdentityLookup
48     * @param ReportIncidentManager $reportIncidentManager
49     * @param UserFactory $userFactory
50     * @param Language $contentLanguage
51     */
52    public function __construct(
53        Config $config,
54        RevisionStore $revisionStore,
55        UserNameUtils $userNameUtils,
56        UserIdentityLookup $userIdentityLookup,
57        ReportIncidentManager $reportIncidentManager,
58        UserFactory $userFactory,
59        Language $contentLanguage
60    ) {
61        $this->config = $config;
62        $this->reportIncidentManager = $reportIncidentManager;
63        $this->revisionStore = $revisionStore;
64        $this->userNameUtils = $userNameUtils;
65        $this->userIdentityLookup = $userIdentityLookup;
66        $this->logger = LoggerFactory::getInstance( 'ReportIncident' );
67        $this->userFactory = $userFactory;
68        $this->contentLanguage = $contentLanguage;
69    }
70
71    public function run() {
72        if ( !$this->config->get( 'ReportIncidentApiEnabled' ) ) {
73            // Pretend the route doesn't exist if the feature flag is off.
74            throw new LocalizedHttpException(
75                new MessageValue( 'rest-no-match' ), 404
76            );
77        }
78        $user = $this->getAuthority()->getUser();
79        $this->validateUserCanSubmitReport( $user );
80        $incidentReport = $this->getIncidentReportObjectFromValidatedBody( $this->getValidatedBody() );
81        $this->authorizeIncidentReport( $user );
82        return $this->submitIncidentReport( $incidentReport );
83    }
84
85    /**
86     * Validates that a user can submit an incident report using the API.
87     * If the validation fails, a LocalizedHttpException will be thrown.
88     *
89     * @param UserIdentity $user The UserIdentity associated with the authority.
90     * @return void The method will return nothing if validation succeeds (and error otherwise).
91     * @throws LocalizedHttpException If validation fails
92     */
93    private function validateUserCanSubmitReport( $user ): void {
94        if ( !$user->isRegistered() ) {
95            throw new LocalizedHttpException(
96                new MessageValue( 'rest-permission-denied-anon' ), 401
97            );
98        }
99
100        $user = $this->userFactory->newFromUserIdentity( $user );
101
102        if ( $user->getEditCount() === 0 ) {
103            $this->logger->warning(
104                'User "{user}" with zero edits attempted to perform "reportincident".',
105                [ 'user' => $this->getAuthority()->getUser()->getName() ]
106            );
107            throw new LocalizedHttpException(
108                new MessageValue( 'apierror-permissiondenied', [ 'reportincident' ] ),
109                403
110            );
111        }
112
113        if ( $user->getBlock() ) {
114            $this->logger->warning(
115                'Blocked user "{user}" attempted to perform "reportincident".',
116                [ 'user' => $this->getAuthority()->getUser()->getName() ]
117            );
118            throw new LocalizedHttpException(
119                new MessageValue( 'apierror-blocked', [ 'reportincident' ] ),
120                403
121            );
122        }
123
124        $isDeveloperMode = $this->config->get( 'ReportIncidentDeveloperMode' );
125
126        $now = (int)ConvertibleTimestamp::now();
127        $registrationTime = (int)$user->getRegistration();
128        $reportIncidentMinimumAccountAgeInSeconds = $this->config->get( 'ReportIncidentMinimumAccountAgeInSeconds' );
129        if ( $registrationTime &&
130            $reportIncidentMinimumAccountAgeInSeconds &&
131            !$isDeveloperMode &&
132            ( ( $now - $registrationTime ) < $reportIncidentMinimumAccountAgeInSeconds ) ) {
133            $this->logger->warning(
134                'User "{user}" whose account is under wgReportIncidentMinimumAccountAgeInSeconds' .
135                ' threshold attempted to perform "reportincident".',
136                [ 'user' => $this->getAuthority()->getUser()->getName() ]
137            );
138            throw new LocalizedHttpException(
139                new MessageValue( 'apierror-permissiondenied', [ 'reportincident' ] ),
140                403
141            );
142        }
143
144        if ( !$isDeveloperMode && !$user->isEmailConfirmed() ) {
145            throw new LocalizedHttpException(
146                new MessageValue( 'reportincident-confirmedemail-required' ), 403
147            );
148        }
149    }
150
151    /**
152     * Gets the IncidentReport object from the request body
153     * after performing validation on the request data. If
154     * validation fails a LocalizedHttpException will be
155     * thrown.
156     *
157     * @param mixed $body The value of $this->getValidatedBody()
158     * @return IncidentReport If the validation succeeds
159     * @throws LocalizedHttpException If the validation fails
160     */
161    private function getIncidentReportObjectFromValidatedBody( $body ): IncidentReport {
162        // Validate the CSRF token in the request body.
163        $this->validateToken();
164        // Validate that the revision with the given ID exists.
165        $revisionId = $body['revisionId'];
166        $revision = $this->revisionStore->getRevisionById( $revisionId );
167        if ( !$revision ) {
168            throw new LocalizedHttpException(
169                new MessageValue( 'rest-nonexistent-revision', [ $revisionId ] ), 404 );
170        }
171        $body['revision'] = $revision;
172        // Validate that the user is either an IP or an existing user
173        $reportedUser = $body['reportedUser'];
174        '@phan-var string $reportedUser';
175        if ( $this->userNameUtils->isIP( $reportedUser ) ) {
176            $reportedUserIdentity = UserIdentityValue::newAnonymous( $reportedUser );
177        } else {
178            $reportedUserIdentity = $this->userIdentityLookup->getUserIdentityByName( $reportedUser );
179            if ( !$reportedUserIdentity || !$reportedUserIdentity->isRegistered() ) {
180                throw new LocalizedHttpException(
181                    new MessageValue( 'reportincident-dialog-violator-nonexistent', [ $reportedUser ] ), 404
182                );
183            }
184        }
185        $body['reportedUser'] = $reportedUserIdentity;
186        // Truncate the Something else details and Additional details fields.
187        if ( array_key_exists( 'details', $body ) && $body['details'] !== null ) {
188            $body['details'] = $this->contentLanguage->truncateForVisual(
189                $body['details'], CommentStore::COMMENT_CHARACTER_LIMIT
190            );
191        }
192        if ( array_key_exists( 'somethingElseDetails', $body ) && $body['somethingElseDetails'] !== null ) {
193            $body['somethingElseDetails'] = $this->contentLanguage->truncateForVisual(
194                $body['somethingElseDetails'], CommentStore::COMMENT_CHARACTER_LIMIT
195            );
196        }
197        return IncidentReport::newFromRestPayload(
198            $this->getAuthority()->getUser(),
199            $body
200        );
201    }
202
203    /**
204     * Authorises the incident report. If the authorisation fails, a LocalizedHttpException
205     * is thrown. Otherwise the authorisation succeeded.
206     *
207     * Should be called just before an attempt to record and notify is made as this
208     * will increase the rate limit. Doing this before form validation checks would
209     * mean reports that were not sent would be counted towards the rate limit.
210     *
211     * @param UserIdentity $user The UserIdentity associated with the authority.
212     * @return void
213     * @throws LocalizedHttpException On authorisation failure.
214     */
215    private function authorizeIncidentReport( $user ): void {
216        $user = $this->userFactory->newFromUserIdentity( $user );
217        $status = PermissionStatus::newEmpty();
218        if ( !$this->getAuthority()->authorizeAction( 'reportincident', $status ) ) {
219            if ( $status->hasMessage( 'actionthrottledtext' ) ) {
220                $this->logger->warning(
221                    'User "{user}" tripped rate limits for "reportincident".',
222                    [ 'user' => $this->getAuthority()->getUser()->getName() ]
223                );
224                throw new LocalizedHttpException(
225                    new MessageValue( 'apierror-ratelimited' ),
226                    429
227                );
228            } else {
229                if ( $user->isTemp() ) {
230                    // We'll deny temp users later on in the authorizeAction check below.
231                    $this->logger->warning(
232                        'Temporary user "{user}" attempted to perform "reportincident".',
233                        [ 'user' => $this->getAuthority()->getUser()->getName() ]
234                    );
235                } else {
236                    $this->logger->warning(
237                        'User "{user}" without permissions attempted to perform "reportincident".',
238                        [ 'user' => $this->getAuthority()->getUser()->getName() ]
239                    );
240                }
241                throw new LocalizedHttpException(
242                    new MessageValue( 'apierror-permissiondenied', [ 'reportincident' ] ),
243                    403
244                );
245            }
246        }
247    }
248
249    /**
250     * Submits an incident report given using the
251     * IncidentReport object. Does not perform
252     * any validation checks.
253     *
254     * @param IncidentReport $incidentReport The IncidentReport object generated from the request
255     * @return Response The Response object to be returned by ::run.
256     */
257    private function submitIncidentReport( IncidentReport $incidentReport ): Response {
258        $status = $this->reportIncidentManager->record( $incidentReport );
259        if ( $status->isGood() ) {
260            // TODO: If/when we store the reports in a DB table, we can move sending the email
261            // into a deferred update, so the user doesn't need to wait. For now, this is our
262            // only signal that a report was processed, so check the status of the sendEmail
263            // method
264            $status = $this->reportIncidentManager->notify( $incidentReport );
265            if ( !$status->isGood() ) {
266                $extraData = [];
267                if ( $this->config->get( 'ReportIncidentDeveloperMode' ) ) {
268                    $extraData = [ 'sentEmail' => $status->getEmailContents() ];
269                }
270                throw new LocalizedHttpException(
271                    new MessageValue( 'reportincident-unable-to-send' ),
272                    500,
273                    $extraData
274                );
275            }
276            if ( $this->config->get( 'ReportIncidentDeveloperMode' ) ) {
277                return $this->getResponseFactory()->createJson( [ 'sentEmail' => $status->getEmailContents() ] );
278            } else {
279                return $this->getResponseFactory()->createNoContent();
280            }
281        } else {
282            throw new LocalizedHttpException(
283                new MessageValue( $status->getErrors()[0]['message'] ), 400
284            );
285        }
286    }
287
288    public function getBodyParamSettings(): array {
289        return [
290            'reportedUser' => [
291                static::PARAM_SOURCE => 'body',
292                ParamValidator::PARAM_TYPE => 'string',
293                ParamValidator::PARAM_REQUIRED => true,
294            ],
295            'revisionId' => [
296                static::PARAM_SOURCE => 'body',
297                ParamValidator::PARAM_TYPE => 'integer',
298                ParamValidator::PARAM_REQUIRED => true,
299            ],
300            'behaviors' => [
301                static::PARAM_SOURCE => 'body',
302                ParamValidator::PARAM_TYPE => 'array',
303                ParamValidator::PARAM_REQUIRED => true,
304            ],
305            'details' => [
306                static::PARAM_SOURCE => 'body',
307                ParamValidator::PARAM_TYPE => 'string',
308                ParamValidator::PARAM_REQUIRED => false,
309            ],
310            'somethingElseDetails' => [
311                static::PARAM_SOURCE => 'body',
312                ParamValidator::PARAM_TYPE => 'string',
313                ParamValidator::PARAM_REQUIRED => false,
314            ],
315            'threadId' => [
316                static::PARAM_SOURCE => 'body',
317                ParamValidator::PARAM_TYPE => 'string',
318                ParamValidator::PARAM_REQUIRED => false,
319            ],
320        ] + $this->getTokenParamDefinition();
321    }
322
323}