Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 226 |
|
0.00% |
0 / 19 |
CRAP | |
0.00% |
0 / 1 |
SpecialGlobalVanishRequest | |
0.00% |
0 / 226 |
|
0.00% |
0 / 19 |
4032 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
onSubmit | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
182 | |||
onSuccess | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
execute | |
0.00% |
0 / 41 |
|
0.00% |
0 / 1 |
110 | |||
getFormFields | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
requiresUnblock | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
userCanExecute | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
displayRestrictionError | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
alterForm | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getDisplayFormat | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
preHtml | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGlobalUser | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
generateUsername | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
eligibleForAutomaticVanish | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
sendVanishingSuccessfulEmail | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
30 | |||
getUserBlockAppealSitelinks | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
110 | |||
queryWikidata | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | /** |
3 | * @section LICENSE |
4 | * This program is free software; you can redistribute it and/or modify |
5 | * it under the terms of the GNU General Public License as published by |
6 | * the Free Software Foundation; either version 2 of the License, or |
7 | * (at your option) any later version. |
8 | * |
9 | * This program is distributed in the hope that it will be useful, |
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
12 | * GNU General Public License for more details. |
13 | * |
14 | * You should have received a copy of the GNU General Public License along |
15 | * with this program; if not, write to the Free Software Foundation, Inc., |
16 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
17 | * http://www.gnu.org/copyleft/gpl.html |
18 | * |
19 | * @file |
20 | */ |
21 | |
22 | namespace MediaWiki\Extension\CentralAuth\Special; |
23 | |
24 | use IDBAccessObject; |
25 | use MailAddress; |
26 | use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameDenylist; |
27 | use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameFactory; |
28 | use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameRequest; |
29 | use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameRequestStore; |
30 | use MediaWiki\Extension\CentralAuth\User\CentralAuthUser; |
31 | use MediaWiki\Html\Html; |
32 | use MediaWiki\HTMLForm\HTMLForm; |
33 | use MediaWiki\Http\HttpRequestFactory; |
34 | use MediaWiki\Json\FormatJson; |
35 | use MediaWiki\Logger\LoggerFactory; |
36 | use MediaWiki\Parser\Parser; |
37 | use MediaWiki\SpecialPage\FormSpecialPage; |
38 | use MediaWiki\Status\Status; |
39 | use MediaWiki\User\User; |
40 | use MediaWiki\User\UserIdentityLookup; |
41 | use PermissionsError; |
42 | use RuntimeException; |
43 | use UserMailer; |
44 | |
45 | /** |
46 | * Request an account vanish. |
47 | */ |
48 | class SpecialGlobalVanishRequest extends FormSpecialPage { |
49 | |
50 | /** @var \Psr\Log\LoggerInterface */ |
51 | private $logger; |
52 | |
53 | /** @var GlobalRenameDenylist */ |
54 | private $globalRenameDenylist; |
55 | |
56 | /** @var GlobalRenameRequestStore */ |
57 | private $globalRenameRequestStore; |
58 | |
59 | /** @var GlobalRenameFactory */ |
60 | private $globalRenameFactory; |
61 | |
62 | /** @var HttpRequestFactory */ |
63 | private $httpRequestFactory; |
64 | |
65 | /** @var UserIdentityLookup */ |
66 | private $userIdentityLookup; |
67 | |
68 | /** |
69 | * @param GlobalRenameDenylist $globalRenameDenylist |
70 | * @param GlobalRenameRequestStore $globalRenameRequestStore |
71 | * @param GlobalRenameFactory $globalRenameFactory |
72 | * @param HttpRequestFactory $httpRequestFactory |
73 | * @param UserIdentityLookup $userIdentityLookup |
74 | */ |
75 | public function __construct( |
76 | GlobalRenameDenylist $globalRenameDenylist, |
77 | GlobalRenameRequestStore $globalRenameRequestStore, |
78 | GlobalRenameFactory $globalRenameFactory, |
79 | HttpRequestFactory $httpRequestFactory, |
80 | UserIdentityLookup $userIdentityLookup |
81 | ) { |
82 | parent::__construct( 'GlobalVanishRequest' ); |
83 | |
84 | $this->logger = LoggerFactory::getInstance( 'CentralAuth' ); |
85 | $this->globalRenameDenylist = $globalRenameDenylist; |
86 | $this->globalRenameRequestStore = $globalRenameRequestStore; |
87 | $this->globalRenameFactory = $globalRenameFactory; |
88 | $this->httpRequestFactory = $httpRequestFactory; |
89 | $this->userIdentityLookup = $userIdentityLookup; |
90 | } |
91 | |
92 | /** @inheritDoc */ |
93 | public function onSubmit( array $data ): Status { |
94 | $newUsername = $this->generateUsername(); |
95 | if ( !$newUsername ) { |
96 | return Status::newFatal( $this->msg( 'globalvanishrequest-save-error' ) ); |
97 | } |
98 | |
99 | // Verify that the user is a global user. |
100 | $causer = $this->getGlobalUser(); |
101 | if ( !$causer ) { |
102 | return Status::newFatal( $this->msg( 'globalvanishrequest-globaluser-error' ) ); |
103 | } |
104 | |
105 | // Disallow for users that have blocks on any connected wikis. |
106 | if ( $causer->isBlocked() ) { |
107 | return Status::newFatal( $this->msg( 'globalvanishrequest-blocked-error' ) ); |
108 | } |
109 | |
110 | // Disallow duplicate rename / vanish requests. |
111 | $username = $this->getUser()->getName(); |
112 | if ( $this->globalRenameRequestStore->currentNameHasPendingRequest( $username ) ) { |
113 | return Status::newFatal( $this->msg( 'globalvanishrequest-pending-request-error' ) ); |
114 | } |
115 | |
116 | $request = $this->globalRenameRequestStore |
117 | ->newBlankRequest() |
118 | ->setName( $username ) |
119 | ->setNewName( $newUsername ) |
120 | ->setReason( $data['reason'] ?? null ) |
121 | ->setType( GlobalRenameRequest::VANISH ); |
122 | |
123 | $automaticVanishPerformerName = $this->getConfig()->get( 'CentralAuthAutomaticVanishPerformer' ); |
124 | $automaticVanishPerformer = $automaticVanishPerformerName !== null |
125 | ? CentralAuthUser::getInstanceByName( $automaticVanishPerformerName ) |
126 | : null; |
127 | $localAutomaticVanishPerformer = $this->userIdentityLookup |
128 | ->getUserIdentityByName( $automaticVanishPerformerName ); |
129 | |
130 | // Immediately start the vanish if we already know that the user is |
131 | // eligible for approval without a review. |
132 | if ( |
133 | isset( $automaticVanishPerformer ) && |
134 | $automaticVanishPerformer->exists() && |
135 | $automaticVanishPerformer->isAttached() && |
136 | isset( $localAutomaticVanishPerformer ) && |
137 | $this->eligibleForAutomaticVanish() |
138 | ) { |
139 | $request->setPerformer( $automaticVanishPerformer->getId() ); |
140 | $requestArray = $request->toArray(); |
141 | |
142 | // We need to add this two fields that are usually being provided by the Form |
143 | $requestArray['movepages'] = true; |
144 | $requestArray['suppressredirects'] = true; |
145 | |
146 | $renameResult = $this->globalRenameFactory |
147 | ->newGlobalRenameUser( $localAutomaticVanishPerformer, $causer, $request->getNewName() ) |
148 | ->withSession( $this->getContext()->exportSession() ) |
149 | ->rename( $requestArray ); |
150 | |
151 | if ( !$renameResult->isGood() ) { |
152 | return $renameResult; |
153 | } |
154 | |
155 | // We still want to leave a record that this happened, so change |
156 | // the status over to 'approved' for the subsequent save. |
157 | $request |
158 | ->setComments( $this->msg( 'globalvanishrequest-autoapprove-note' ) ) |
159 | ->setReason( $this->msg( 'globalvanishrequest-autoapprove-note' ) ) |
160 | ->setStatus( GlobalRenameRequest::APPROVED ); |
161 | |
162 | $this->sendVanishingSuccessfulEmail( $request ); |
163 | } |
164 | |
165 | // Save the request to the database for it to be processed later. |
166 | if ( !$this->globalRenameRequestStore->save( $request ) ) { |
167 | return Status::newFatal( $this->msg( 'globalvanishrequest-save-error' ) ); |
168 | } |
169 | |
170 | return Status::newGood(); |
171 | } |
172 | |
173 | /** @inheritDoc */ |
174 | public function onSuccess(): void { |
175 | $isVanished = $this->globalRenameRequestStore |
176 | ->currentNameHasApprovedVanish( |
177 | $this->getUser()->getName(), IDBAccessObject::READ_LATEST ); |
178 | |
179 | $destination = $isVanished ? 'vanished' : 'status'; |
180 | |
181 | $this->getOutput()->redirect( |
182 | $this->getPageTitle( $destination )->getFullURL(), '303' |
183 | ); |
184 | } |
185 | |
186 | /** @inheritDoc */ |
187 | public function execute( $subPage ): void { |
188 | $out = $this->getOutput(); |
189 | |
190 | if ( $subPage === 'vanished' ) { |
191 | $out->setPageTitleMsg( $this->msg( 'globalvanishrequest-vanished-title' ) ); |
192 | $out->addWikiMsg( 'globalvanishrequest-vanished-text' ); |
193 | return; |
194 | } |
195 | |
196 | $this->requireNamedUser(); |
197 | $username = $this->getUser()->getName(); |
198 | $hasPending = $this->globalRenameRequestStore->currentNameHasPendingRequest( $username ); |
199 | |
200 | if ( $subPage === 'status' ) { |
201 | if ( !$hasPending ) { |
202 | $out->redirect( $this->getPageTitle()->getFullURL(), '303' ); |
203 | return; |
204 | } |
205 | |
206 | $out->setPageTitleMsg( $this->msg( 'globalvanishrequest-status-title' ) ); |
207 | $out->addWikiMsg( 'globalvanishrequest-status-text' ); |
208 | return; |
209 | } |
210 | |
211 | // Preemptively check if the user has any blocks, and if so prevent the |
212 | // form from rendering and give them a link to appeal. |
213 | $causer = $this->getGlobalUser(); |
214 | if ( $causer ) { |
215 | $blockedWikiIds = []; |
216 | foreach ( $causer->getBlocks() as $wikiId => $blocks ) { |
217 | if ( count( $blocks ) > 0 ) { |
218 | $blockedWikiIds[] = $wikiId; |
219 | } |
220 | } |
221 | |
222 | if ( count( $blockedWikiIds ) > 0 ) { |
223 | $out->setPageTitleMsg( $this->msg( 'globalvanishrequest-blocked-title' ) ); |
224 | |
225 | $sitelinks = $this->getUserBlockAppealSitelinks( $blockedWikiIds ); |
226 | if ( count( $sitelinks ) > 0 ) { |
227 | $out->addWikiMsg( 'globalvanishrequest-blocked-text' ); |
228 | } else { |
229 | $out->addWikiMsg( 'globalvanishrequest-blocked-text-minimal' ); |
230 | } |
231 | |
232 | // Create an unordered list of appeal links that are relevant to |
233 | // the user. For each wiki that the user is blocked in, the |
234 | // relevant appeal page on that wiki is added. |
235 | $appealListItems = array_map( |
236 | fn ( $sitelink ) => Html::rawElement( 'li', [], |
237 | Parser::stripOuterParagraph( $out->parseAsContent( $sitelink ) ) |
238 | ), |
239 | $sitelinks |
240 | ); |
241 | $out->addHTML( Html::rawElement( 'ul', [], implode( '', $appealListItems ) ) ); |
242 | |
243 | return; |
244 | } |
245 | } |
246 | |
247 | if ( $hasPending ) { |
248 | $out = $this->getOutput(); |
249 | $out->redirect( $this->getPageTitle( 'status' )->getFullURL(), '303' ); |
250 | return; |
251 | } |
252 | |
253 | $out->addModules( 'ext.centralauth.globalvanishrequest' ); |
254 | |
255 | parent::execute( $subPage ); |
256 | } |
257 | |
258 | /** @inheritDoc */ |
259 | public function getFormFields(): array { |
260 | return [ |
261 | 'username' => [ |
262 | 'cssclass' => 'mw-globalvanishrequest-field', |
263 | 'default' => $this->getUser()->getName(), |
264 | 'label-message' => 'globalvanishrequest-username-label', |
265 | 'required' => true, |
266 | 'type' => 'text', |
267 | 'disabled' => true, |
268 | ], |
269 | 'reason' => [ |
270 | 'cssclass' => 'mw-globalvanishrequest-field', |
271 | 'id' => 'mw-vanishrequest-reason', |
272 | 'label-message' => 'globalvanishrequest-reason-label', |
273 | 'name' => 'reason', |
274 | 'rows' => 3, |
275 | 'type' => 'textarea', |
276 | ], |
277 | ]; |
278 | } |
279 | |
280 | /** @inheritDoc */ |
281 | public function doesWrites(): bool { |
282 | return true; |
283 | } |
284 | |
285 | /** |
286 | * Blocked users should not be able to request a vanish. |
287 | * @return bool |
288 | */ |
289 | public function requiresUnblock(): bool { |
290 | return true; |
291 | } |
292 | |
293 | /** @inheritDoc */ |
294 | public function userCanExecute( User $user ): bool { |
295 | return $this->globalRenameDenylist->checkUser( $user->getName() ); |
296 | } |
297 | |
298 | /** @inheritDoc */ |
299 | public function displayRestrictionError(): void { |
300 | throw new PermissionsError( null, [ 'centralauth-badaccess-blacklisted' ] ); |
301 | } |
302 | |
303 | /** @inheritDoc */ |
304 | protected function alterForm( HTMLForm $form ): void { |
305 | $form |
306 | ->setSubmitTextMsg( 'globalvanishrequest-submit-text' ) |
307 | ->setSubmitID( 'mw-vanishrequest-submit' ); |
308 | } |
309 | |
310 | /** @inheritDoc */ |
311 | protected function getDisplayFormat(): string { |
312 | return 'ooui'; |
313 | } |
314 | |
315 | /** @inheritDoc */ |
316 | protected function preHtml(): string { |
317 | return $this->msg( 'globalvanishrequest-pretext' )->parse(); |
318 | } |
319 | |
320 | /** @inheritDoc */ |
321 | protected function getGroupName(): string { |
322 | return 'login'; |
323 | } |
324 | |
325 | /** |
326 | * Return the global user if the authenticated user has a global account. |
327 | * @return CentralAuthUser|false |
328 | */ |
329 | private function getGlobalUser() { |
330 | $user = $this->getUser(); |
331 | $causer = CentralAuthUser::getInstance( $user ); |
332 | |
333 | if ( $causer->exists() && $causer->isAttached() ) { |
334 | return $causer; |
335 | } |
336 | return false; |
337 | } |
338 | |
339 | /** |
340 | * Generate a random username that the user requesting a vanish would be |
341 | * renamed to if the request is accepted. |
342 | * |
343 | * @return string|false contains a string if successful |
344 | */ |
345 | private function generateUsername() { |
346 | $attempts = 0; |
347 | |
348 | do { |
349 | $random = wfRandomString(); |
350 | $candidate = "Vanished user {$random}"; |
351 | if ( GlobalRenameRequest::isNameAvailable( $candidate, IDBAccessObject::READ_NORMAL )->isOK() ) { |
352 | return $candidate; |
353 | } |
354 | $attempts++; |
355 | } while ( $attempts < 5 ); |
356 | |
357 | return false; |
358 | } |
359 | |
360 | /** |
361 | * Checks if the currently authenticated user is eligible for automatic vanishing. |
362 | * @return bool |
363 | */ |
364 | private function eligibleForAutomaticVanish(): bool { |
365 | $causer = $this->getGlobalUser(); |
366 | if ( !$causer ) { |
367 | return false; |
368 | } |
369 | |
370 | return $causer->getGlobalEditCount() === 0 && |
371 | !$causer->isBlocked() && |
372 | !$causer->hasPublicLogs(); |
373 | } |
374 | |
375 | /** |
376 | * Attempt to send a success email to the user whose vanish was fulfilled. |
377 | * TODO: https://phabricator.wikimedia.org/T369134 - refactor email sending |
378 | * @param GlobalRenameRequest $request |
379 | * @return void |
380 | */ |
381 | private function sendVanishingSuccessfulEmail( GlobalRenameRequest $request ): void { |
382 | $causer = $this->getGlobalUser(); |
383 | if ( !$causer ) { |
384 | return; |
385 | } |
386 | |
387 | $bodyKey = $request->getComments() === '' |
388 | ? 'globalrenamequeue-vanish-email-body-approved' |
389 | : 'globalrenamequeue-vanish-email-body-approved-with-note'; |
390 | |
391 | $subject = $this->msg( 'globalrenamequeue-vanish-email-subject-approved' ) |
392 | ->inContentLanguage() |
393 | ->text(); |
394 | $body = $this->msg( $bodyKey, [ $request->getName(), $request->getComments() ] ) |
395 | ->inContentLanguage() |
396 | ->text(); |
397 | |
398 | $from = new MailAddress( |
399 | $this->getConfig()->get( 'PasswordSender' ), |
400 | $this->msg( 'emailsender' )->inContentLanguage()->text() |
401 | ); |
402 | $to = new MailAddress( $causer->getEmail(), $causer->getName(), '' ); |
403 | |
404 | // Users don't always have email addresses. Since this is acceptable |
405 | // and expected behavior, bail out with a warning if there isn't one. |
406 | if ( !$to->address ) { |
407 | $this->logger->info( |
408 | "Unable to sent approval email to User:{oldName} as there is no email address to send to.", |
409 | [ 'oldName' => $request->getName(), 'component' => 'GlobalVanish' ] |
410 | ); |
411 | return; |
412 | } |
413 | |
414 | $this->logger->info( 'Send approval email to User:{oldName}', [ |
415 | 'oldName' => $request->getName(), |
416 | 'component' => 'GlobalVanish', |
417 | ] ); |
418 | |
419 | // Attempt to send the email, and log an error if this fails. |
420 | $emailSendResult = UserMailer::send( $to, $from, $subject, $body ); |
421 | if ( !$emailSendResult->isOK() ) { |
422 | $this->logger->error( $emailSendResult->getValue() ); |
423 | } |
424 | } |
425 | |
426 | /** |
427 | * Retrieve the block appeal links most relevant for the user. |
428 | * |
429 | * For each wiki the user is blocked on, the appeal link for that wiki will |
430 | * be returned. If that page is not available, then fallback pages will be |
431 | * attempted as well if configured. |
432 | * |
433 | * @param array $wikiIds a list of wikis to fetch links from |
434 | * @return array sitelinks to the most relevant appeal pages for the user |
435 | */ |
436 | private function getUserBlockAppealSitelinks( array $wikiIds ): array { |
437 | $sitelinks = []; |
438 | |
439 | $entityIds = $this->getConfig()->get( 'CentralAuthBlockAppealWikidataIds' ); |
440 | if ( isset( $entityIds ) && count( $entityIds ) > 0 ) { |
441 | // Fetch block appeal and block policy pages from the Wikidata API. |
442 | $parameters = [ |
443 | "action" => "wbgetentities", |
444 | "format" => "json", |
445 | "ids" => implode( '|', $entityIds ), |
446 | "props" => "sitelinks|sitelinks/urls", |
447 | "formatversion" => "2", |
448 | ]; |
449 | $wikidataResult = $this->queryWikidata( $parameters ); |
450 | if ( !$wikidataResult->isGood() ) { |
451 | return []; |
452 | } |
453 | $wikidataResponse = $wikidataResult->getValue(); |
454 | $entities = $wikidataResponse['entities']; |
455 | |
456 | // Iterate through every wiki with blocks and find the most |
457 | // relevant page for appealing blocks on an account. |
458 | foreach ( $wikiIds as $wikiId ) { |
459 | foreach ( $entityIds as $entityId ) { |
460 | if ( isset( $entities[$entityId]['sitelinks'][$wikiId]['url'] ) ) { |
461 | $sitelink = $entities[$entityId]['sitelinks'][$wikiId]; |
462 | $sitelinks[] = "[{$sitelink['url']} {$sitelink['title']}]"; |
463 | break; |
464 | } |
465 | } |
466 | } |
467 | } |
468 | |
469 | // Fallback to showing a fallback URL (if configured) in the event that |
470 | // no appeal links were able to be found from the Wikidata API. |
471 | if ( count( $sitelinks ) === 0 ) { |
472 | $appealUrl = $this->getConfig()->get( 'CentralAuthFallbackAppealUrl' ); |
473 | $appealTitle = $this->getConfig()->get( 'CentralAuthFallbackAppealTitle' ); |
474 | |
475 | if ( isset( $appealUrl ) && isset( $appealTitle ) ) { |
476 | $sitelinks[] = "[{$appealUrl} {$appealTitle}]"; |
477 | } |
478 | } |
479 | |
480 | return $sitelinks; |
481 | } |
482 | |
483 | /** |
484 | * Retrieve entity data from the Wikidata API. |
485 | * |
486 | * @param array $parameters |
487 | * @return Status |
488 | */ |
489 | private function queryWikidata( array $parameters ): Status { |
490 | $options = [ |
491 | 'method' => 'GET', |
492 | 'userAgent' => "{$this->httpRequestFactory->getUserAgent()} CentralAuth", |
493 | ]; |
494 | $url = $this->getConfig()->get( 'CentralAuthWikidataApiUrl' ); |
495 | if ( !isset( $url ) ) { |
496 | return Status::newFatal( |
497 | 'Cannot make Wikidata request for entities as $wgCentralAuthWikidataApiUrl is unset.' |
498 | ); |
499 | } |
500 | $encodedParameters = wfArrayToCgi( $parameters ); |
501 | $request = $this->httpRequestFactory->create( "{$url}?{$encodedParameters}", $options, __METHOD__ ); |
502 | |
503 | $httpResult = $request->execute(); |
504 | if ( $httpResult->isOK() ) { |
505 | $httpResult->merge( FormatJson::parse( $request->getContent(), FormatJson::FORCE_ASSOC ), true ); |
506 | } |
507 | |
508 | [ $errorsOnlyStatus, $warningsOnlyStatus ] = $httpResult->splitByErrorType(); |
509 | if ( !$warningsOnlyStatus->isGood() ) { |
510 | LoggerFactory::getInstance( 'CentralAuth' )->warning( |
511 | $warningsOnlyStatus->getWikiText( false, false, 'en' ), |
512 | [ 'exception' => new RuntimeException ] |
513 | ); |
514 | } |
515 | return $errorsOnlyStatus; |
516 | } |
517 | } |