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