Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
163 / 163 |
|
100.00% |
7 / 7 |
CRAP | |
100.00% |
1 / 1 |
ReportHandler | |
100.00% |
163 / 163 |
|
100.00% |
7 / 7 |
32 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
run | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
validateUserCanSubmitReport | |
100.00% |
43 / 43 |
|
100.00% |
1 / 1 |
10 | |||
getIncidentReportObjectFromValidatedBody | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
9 | |||
authorizeIncidentReport | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
4 | |||
submitIncidentReport | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
5 | |||
getBodyParamSettings | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\ReportIncident\Api\Rest\Handler; |
4 | |
5 | use Language; |
6 | use MediaWiki\CommentStore\CommentStore; |
7 | use MediaWiki\Config\Config; |
8 | use MediaWiki\Extension\ReportIncident\IncidentReport; |
9 | use MediaWiki\Extension\ReportIncident\Services\ReportIncidentManager; |
10 | use MediaWiki\Logger\LoggerFactory; |
11 | use MediaWiki\Permissions\PermissionStatus; |
12 | use MediaWiki\Rest\LocalizedHttpException; |
13 | use MediaWiki\Rest\Response; |
14 | use MediaWiki\Rest\SimpleHandler; |
15 | use MediaWiki\Rest\TokenAwareHandlerTrait; |
16 | use MediaWiki\Revision\RevisionStore; |
17 | use MediaWiki\User\UserFactory; |
18 | use MediaWiki\User\UserIdentity; |
19 | use MediaWiki\User\UserIdentityLookup; |
20 | use MediaWiki\User\UserIdentityValue; |
21 | use MediaWiki\User\UserNameUtils; |
22 | use Psr\Log\LoggerInterface; |
23 | use Wikimedia\Message\MessageValue; |
24 | use Wikimedia\ParamValidator\ParamValidator; |
25 | use Wikimedia\Timestamp\ConvertibleTimestamp; |
26 | |
27 | /** |
28 | * REST handler for /reportincident/v0/report |
29 | */ |
30 | class 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 | } |