Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 226
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialGlobalVanishRequest
0.00% covered (danger)
0.00%
0 / 226
0.00% covered (danger)
0.00%
0 / 19
4032
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 onSubmit
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
182
 onSuccess
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
110
 getFormFields
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 requiresUnblock
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 userCanExecute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 displayRestrictionError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 alterForm
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getDisplayFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 preHtml
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGlobalUser
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 generateUsername
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 eligibleForAutomaticVanish
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 sendVanishingSuccessfulEmail
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 getUserBlockAppealSitelinks
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
110
 queryWikidata
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
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
22namespace MediaWiki\Extension\CentralAuth\Special;
23
24use IDBAccessObject;
25use MailAddress;
26use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameDenylist;
27use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameFactory;
28use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameRequest;
29use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameRequestStore;
30use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
31use MediaWiki\Html\Html;
32use MediaWiki\HTMLForm\HTMLForm;
33use MediaWiki\Http\HttpRequestFactory;
34use MediaWiki\Json\FormatJson;
35use MediaWiki\Logger\LoggerFactory;
36use MediaWiki\Parser\Parser;
37use MediaWiki\SpecialPage\FormSpecialPage;
38use MediaWiki\Status\Status;
39use MediaWiki\User\User;
40use MediaWiki\User\UserIdentityLookup;
41use PermissionsError;
42use RuntimeException;
43use UserMailer;
44
45/**
46 * Request an account vanish.
47 */
48class 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}