Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 658
0.00% covered (danger)
0.00%
0 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialCentralAuth
0.00% covered (danger)
0.00%
0 / 658
0.00% covered (danger)
0.00%
0 / 34
19182
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 11
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
 execute
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 1
600
 showNonexistentError
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 showRenameInProgressError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 doSubmit
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
272
 showStatusError
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 showError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showSuccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showUsernameForm
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 showInfo
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getInfoFields
0.00% covered (danger)
0.00%
0 / 78
0.00% covered (danger)
0.00%
0 / 1
380
 showWikiLists
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 listHeader
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 listFooter
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 listAccounts
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 listWikiItem
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
30
 getAttachedTimestampField
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 formatMergeMethod
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 formatBlockStatus
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 formatBlockParams
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
72
 getRestrictionListHTML
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 formatEditcount
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 formatGroups
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 foreignLink
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 foreignUserLink
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 adminCheck
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showActionForm
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 showStatusForm
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
6
 showLogExtract
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 evaluateTotalEditcount
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getMergeMethodDescriptions
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 prefixSearchSubpages
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\CentralAuth\Special;
4
5use DateInterval;
6use DerivativeContext;
7use Exception;
8use HTMLForm;
9use InvalidArgumentException;
10use LogEventsList;
11use MediaWiki\Block\Restriction\ActionRestriction;
12use MediaWiki\Block\Restriction\NamespaceRestriction;
13use MediaWiki\Block\Restriction\PageRestriction;
14use MediaWiki\CommentFormatter\CommentFormatter;
15use MediaWiki\Extension\CentralAuth\CentralAuthDatabaseManager;
16use MediaWiki\Extension\CentralAuth\CentralAuthUIService;
17use MediaWiki\Extension\CentralAuth\GlobalRename\GlobalRenameFactory;
18use MediaWiki\Extension\CentralAuth\User\CentralAuthGlobalRegistrationProvider;
19use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
20use MediaWiki\Extension\CentralAuth\Widget\HTMLGlobalUserTextField;
21use MediaWiki\Html\Html;
22use MediaWiki\Parser\Sanitizer;
23use MediaWiki\SpecialPage\SpecialPage;
24use MediaWiki\Title\NamespaceInfo;
25use MediaWiki\Title\Title;
26use MediaWiki\User\Registration\UserRegistrationLookup;
27use MediaWiki\User\TempUser\TempUserConfig;
28use MediaWiki\User\User;
29use MediaWiki\User\UserFactory;
30use MediaWiki\User\UserGroupMembership;
31use MediaWiki\User\UserNameUtils;
32use MediaWiki\Utils\MWTimestamp;
33use MediaWiki\WikiMap\WikiMap;
34use MediaWiki\WikiMap\WikiReference;
35use Wikimedia\Rdbms\IExpression;
36use Wikimedia\Rdbms\LikeValue;
37use Wikimedia\Rdbms\ReadOnlyMode;
38use Xml;
39
40class SpecialCentralAuth extends SpecialPage {
41    /** @var string */
42    private $mUserName;
43    /** @var bool */
44    private $mCanUnmerge;
45    /** @var bool */
46    private $mCanLock;
47    /** @var bool */
48    private $mCanSuppress;
49    /** @var bool */
50    private $mCanEdit;
51    /** @var bool */
52    private $mCanChangeGroups;
53
54    /**
55     * @var CentralAuthUser
56     */
57    private $mGlobalUser;
58
59    /**
60     * @var array[]
61     */
62    private $mAttachedLocalAccounts;
63
64    /**
65     * @var array
66     */
67    private $mUnattachedLocalAccounts;
68
69    /** @var string */
70    private $mMethod;
71
72    /** @var bool */
73    private $mPosted;
74
75    /** @var string[] */
76    private $mWikis;
77
78    private CommentFormatter $commentFormatter;
79    private NamespaceInfo $namespaceInfo;
80    private ReadOnlyMode $readOnlyMode;
81    private TempUserConfig $tempUserConfig;
82    private UserFactory $userFactory;
83    private UserNameUtils $userNameUtils;
84    private UserRegistrationLookup $userRegistrationLookup;
85    private CentralAuthDatabaseManager $databaseManager;
86    private CentralAuthUIService $uiService;
87    private GlobalRenameFactory $globalRenameFactory;
88
89    /**
90     * @param CommentFormatter $commentFormatter
91     * @param NamespaceInfo $namespaceInfo
92     * @param ReadOnlyMode $readOnlyMode
93     * @param TempUserConfig $tempUserConfig
94     * @param UserFactory $userFactory
95     * @param UserNameUtils $userNameUtils
96     * @param UserRegistrationLookup $userRegistrationLookup
97     * @param CentralAuthDatabaseManager $databaseManager
98     * @param CentralAuthUIService $uiService
99     * @param GlobalRenameFactory $globalRenameFactory
100     */
101    public function __construct(
102        CommentFormatter $commentFormatter,
103        NamespaceInfo $namespaceInfo,
104        ReadOnlyMode $readOnlyMode,
105        TempUserConfig $tempUserConfig,
106        UserFactory $userFactory,
107        UserNameUtils $userNameUtils,
108        UserRegistrationLookup $userRegistrationLookup,
109        CentralAuthDatabaseManager $databaseManager,
110        CentralAuthUIService $uiService,
111        GlobalRenameFactory $globalRenameFactory
112    ) {
113        parent::__construct( 'CentralAuth' );
114        $this->commentFormatter = $commentFormatter;
115        $this->namespaceInfo = $namespaceInfo;
116        $this->readOnlyMode = $readOnlyMode;
117        $this->tempUserConfig = $tempUserConfig;
118        $this->userFactory = $userFactory;
119        $this->userNameUtils = $userNameUtils;
120        $this->userRegistrationLookup = $userRegistrationLookup;
121        $this->databaseManager = $databaseManager;
122        $this->uiService = $uiService;
123        $this->globalRenameFactory = $globalRenameFactory;
124    }
125
126    public function doesWrites() {
127        return true;
128    }
129
130    /** @inheritDoc */
131    public function execute( $subpage ) {
132        $this->setHeaders();
133        $this->addHelpLink( 'Extension:CentralAuth' );
134
135        $authority = $this->getContext()->getAuthority();
136        $this->mCanUnmerge = $authority->isAllowed( 'centralauth-unmerge' );
137        $this->mCanLock = $authority->isAllowed( 'centralauth-lock' );
138        $this->mCanSuppress = $authority->isAllowed( 'centralauth-suppress' );
139        $this->mCanEdit = $this->mCanUnmerge || $this->mCanLock || $this->mCanSuppress;
140        $this->mCanChangeGroups = $authority->isAllowed( 'globalgroupmembership' );
141
142        $this->getOutput()->setPageTitleMsg(
143            $this->msg( $this->mCanEdit ? 'centralauth' : 'centralauth-ro' )
144        );
145        $this->getOutput()->addModules( 'ext.centralauth' );
146        $this->getOutput()->addModuleStyles( 'ext.centralauth.misc.styles' );
147        $this->getOutput()->addJsConfigVars(
148            'wgMergeMethodDescriptions', $this->getMergeMethodDescriptions()
149        );
150
151        $this->mUserName = trim(
152            str_replace(
153                '_',
154                ' ',
155                $this->getRequest()->getText( 'target', $subpage ?? '' )
156            )
157        );
158
159        $this->mUserName = $this->getContentLanguage()->ucfirst( $this->mUserName );
160
161        $this->mPosted = $this->getRequest()->wasPosted();
162        $this->mMethod = $this->getRequest()->getVal( 'wpMethod' );
163        $this->mWikis = (array)$this->getRequest()->getArray( 'wpWikis' );
164
165        // Possible demo states
166
167        // success, all accounts merged
168        // successful login, some accounts merged, others left
169        // successful login, others left
170        // not account owner, others left
171
172        // is owner / is not owner
173        // did / did not merge some accounts
174        // do / don't have more accounts to merge
175
176        if ( $this->mUserName === '' ) {
177            # First time through
178            $this->getOutput()->addWikiMsg( 'centralauth-admin-intro' );
179            $this->showUsernameForm();
180            return;
181        }
182
183        $userPage = Title::makeTitleSafe( NS_USER, $this->mUserName );
184        if ( $userPage ) {
185            $localUser = User::newFromName( $userPage->getText(), false );
186            $this->getSkin()->setRelevantUser( $localUser );
187        }
188
189        // per T49991
190        $this->getOutput()->setHTMLTitle( $this->msg(
191            'pagetitle',
192            $this->msg(
193                $this->mCanEdit ? 'centralauth-admin-title' : 'centralauth-admin-title-ro',
194                $this->mUserName
195            )->plain()
196        ) );
197
198        $canonUsername = $this->userNameUtils->getCanonical( $this->mUserName );
199        if ( $canonUsername === false ) {
200            $this->showNonexistentError();
201            return;
202        }
203
204        $globalUser = $this->getRequest()->wasPosted()
205            ? CentralAuthUser::getPrimaryInstanceByName( $this->mUserName )
206            : CentralAuthUser::getInstanceByName( $this->mUserName );
207        $this->mGlobalUser = $globalUser;
208
209        if ( ( $globalUser->isSuppressed() || $globalUser->isHidden() ) &&
210            !$this->mCanSuppress
211        ) {
212            // Claim that there's nothing if the global account is hidden and the user is not
213            // allowed to see it.
214            $this->showNonexistentError();
215            return;
216        }
217
218        $continue = true;
219        if ( $this->mCanEdit && $this->mPosted ) {
220            $this->databaseManager->assertNotReadOnly();
221            $continue = $this->doSubmit();
222        }
223
224        // Show just a user friendly message when a rename is in progress
225        try {
226            $this->mAttachedLocalAccounts = $globalUser->queryAttached();
227        } catch ( Exception $e ) {
228            if ( $globalUser->renameInProgress() ) {
229                $this->showRenameInProgressError();
230                return;
231            }
232            // Rethrow
233            throw $e;
234        }
235
236        $this->mUnattachedLocalAccounts = $globalUser->queryUnattached();
237
238        if ( !$globalUser->exists() && !count( $this->mUnattachedLocalAccounts ) ) {
239            // Nothing to see here
240            $this->showNonexistentError();
241            return;
242        }
243
244        $this->showUsernameForm();
245        if ( $continue && $globalUser->exists() ) {
246            $this->showInfo();
247            if ( $this->mCanLock ) {
248                $this->showStatusForm();
249            }
250            if ( $this->mCanUnmerge ) {
251                $this->showActionForm( 'delete' );
252            }
253            $this->showLogExtract();
254            $this->showWikiLists();
255        } elseif ( $continue && !$globalUser->exists() ) {
256            // No global account, but we can still list the local ones
257            $this->showError( 'centralauth-admin-nonexistent', $this->mUserName );
258            $this->showWikiLists();
259        }
260    }
261
262    private function showNonexistentError() {
263        $this->showError( 'centralauth-admin-nonexistent', $this->mUserName );
264        $this->showUsernameForm();
265    }
266
267    private function showRenameInProgressError() {
268        $this->showError( 'centralauth-admin-rename-in-progress', $this->mUserName );
269        $renameStatus = $this->globalRenameFactory->newGlobalRenameUserStatus( $this->mUserName );
270        $names = $renameStatus->getNames();
271        $this->uiService->showRenameLogExtract( $this->getContext(), $names[1] );
272    }
273
274    /**
275     * @return bool Returns true if the normal form should be displayed
276     */
277    public function doSubmit() {
278        $deleted = false;
279        $globalUser = $this->mGlobalUser;
280        $request = $this->getRequest();
281
282        $givenState = $request->getVal( 'wpUserState' );
283        $stateCheck = $givenState === $globalUser->getStateHash( true );
284
285        if ( !$this->getUser()->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {
286            $this->showError( 'centralauth-token-mismatch' );
287        } elseif ( $this->mMethod == 'unmerge' && $this->mCanUnmerge ) {
288            $status = $globalUser->adminUnattach( $this->mWikis );
289            if ( !$status->isGood() ) {
290                $this->showStatusError( $status->getWikiText() );
291            } else {
292                $this->showSuccess( 'centralauth-admin-unmerge-success',
293                    $this->getLanguage()->formatNum( $status->successCount ),
294                    /* deprecated */ $status->successCount );
295            }
296        } elseif ( $this->mMethod == 'delete' && $this->mCanUnmerge ) {
297            $status = $globalUser->adminDelete( $request->getVal( 'reason' ), $this->getUser() );
298            if ( !$status->isGood() ) {
299                $this->showStatusError( $status->getWikiText() );
300            } else {
301                $this->showSuccess( 'centralauth-admin-delete-success', $this->mUserName );
302                $deleted = true;
303            }
304        } elseif ( $this->mMethod == 'set-status' && !$stateCheck ) {
305            $this->showError( 'centralauth-state-mismatch' );
306        } elseif ( $this->mMethod == 'set-status' && $this->mCanLock ) {
307            $setLocked = $request->getBool( 'wpStatusLocked' );
308            $setHidden = $request->getInt( 'wpStatusHidden', -1 );
309            $reason = $request->getText( 'wpReasonList' );
310            $reasonDetail = $request->getText( 'wpReason' );
311
312            if ( $reason == 'other' ) {
313                $reason = $reasonDetail;
314            } elseif ( $reasonDetail ) {
315                $reason .= $this->msg( 'colon-separator' )->inContentLanguage()->text() .
316                    $reasonDetail;
317            }
318
319            $status = $globalUser->adminLockHide(
320                $setLocked,
321                $setHidden,
322                $reason,
323                $this->getContext()
324            );
325
326            // Tell the user what happened
327            if ( !$status->isGood() ) {
328                $this->showStatusError( $status->getWikiText() );
329            } elseif ( $status->successCount > 0 ) {
330                $this->showSuccess( 'centralauth-admin-setstatus-success', $this->mUserName );
331            }
332        } else {
333            $this->showError( 'centralauth-admin-bad-input' );
334        }
335        return !$deleted;
336    }
337
338    /**
339     * @param string $wikitext
340     */
341    private function showStatusError( $wikitext ) {
342        $out = $this->getOutput();
343        $out->addHTML(
344            Html::errorBox(
345                $out->parseAsInterface( $wikitext )
346            )
347        );
348    }
349
350    /**
351     * @param string $key
352     * @param mixed ...$params
353     */
354    private function showError( $key, ...$params ) {
355        $this->getOutput()->addHTML( Html::errorBox( $this->msg( $key, ...$params )->parse() ) );
356    }
357
358    /**
359     * @param string $key
360     * @param mixed ...$params
361     */
362    private function showSuccess( $key, ...$params ) {
363        $this->getOutput()->addHTML( Html::successBox( $this->msg( $key, ...$params )->parse() ) );
364    }
365
366    private function showUsernameForm() {
367        $lookup = $this->msg(
368            $this->mCanEdit ? 'centralauth-admin-lookup-rw' : 'centralauth-admin-lookup-ro'
369        )->text();
370
371        $formDescriptor = [
372            'user' => [
373                'class' => HTMLGlobalUserTextField::class,
374                'name' => 'target',
375                'label-message' => 'centralauth-admin-username',
376                'size' => 25,
377                'id' => 'target',
378                'default' => $this->mUserName,
379                'required' => true
380            ]
381        ];
382
383        $legend = $this->msg( $this->mCanEdit ? 'centralauth-admin-manage' : 'centralauth-admin-view' )->text();
384
385        $context = new DerivativeContext( $this->getContext() );
386        // Remove subpage
387        $context->setTitle( $this->getPageTitle() );
388        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $context );
389        $htmlForm
390            ->setMethod( 'get' )
391            ->setSubmitText( $lookup )
392            ->setSubmitID( 'centralauth-submit-find' )
393            ->setWrapperLegend( $legend )
394            ->prepareForm()
395            ->displayForm( false );
396    }
397
398    private function showInfo() {
399        $attribs = $this->getInfoFields();
400
401        // Give grep a chance to find the usages:
402        // centralauth-admin-info-username, centralauth-admin-info-registered,
403        // centralauth-admin-info-editcount, centralauth-admin-info-locked,
404        // centralauth-admin-info-hidden, centralauth-admin-info-groups
405        $content = Xml::openElement( "ul" );
406        foreach ( $attribs as $tag => $data ) {
407            $content .= Xml::openElement( "li" ) . Xml::openElement( "strong" );
408            $msg = $this->msg( "centralauth-admin-info-$tag" );
409            if ( $tag === 'groups' ) {
410                // @TODO: This special case is ugly
411                $msg->numParams( count( $this->mGlobalUser->getGlobalGroups() ) );
412            }
413            $content .= $msg->escaped();
414            $content .= Xml::closeElement( "strong" ) . ' ' . $data . Xml::closeElement( "li" );
415        }
416        $content .= Xml::closeElement( "ul" );
417        $out = Xml::fieldset(
418            $this->msg( 'centralauth-admin-info-header' )->text(),
419            $content,
420            [ "id" => "mw-centralauth-info" ]
421        );
422        $this->getOutput()->addHTML( $out );
423    }
424
425    /**
426     * @return array Content is already escaped
427     */
428    private function getInfoFields() {
429        $globalUser = $this->mGlobalUser;
430
431        $reg = $globalUser->getRegistration();
432        $age = $this->uiService->prettyTimespan(
433            $this->getContext(),
434            (int)wfTimestamp( TS_UNIX ) - (int)wfTimestamp( TS_UNIX, $reg )
435        );
436        $attribs = [
437            'username' => htmlspecialchars( $globalUser->getName() ),
438            'registered' => htmlspecialchars(
439                $this->getLanguage()->timeanddate( $reg, true ) . " ($age)" ),
440            'editcount' => htmlspecialchars(
441                $this->getLanguage()->formatNum( $this->evaluateTotalEditcount() ) ),
442            'attached' => htmlspecialchars(
443                $this->getLanguage()->formatNum( count( $this->mAttachedLocalAccounts ) ) ),
444        ];
445
446        if (
447            // Renaming self is not allowed.
448            $globalUser->getName() !== $this->getContext()->getUser()->getName()
449            && $this->getContext()->getAuthority()->isAllowed( 'centralauth-rename' )
450        ) {
451            $renameLink = $this->getLinkRenderer()->makeKnownLink(
452                SpecialPage::getTitleFor( 'GlobalRenameUser', $globalUser->getName() ),
453                $this->msg( 'centralauth-admin-info-username-rename' )->text()
454            );
455
456            $attribs['username'] .= $this->msg( 'word-separator' )->escaped();
457            $attribs['username'] .= $this->msg( 'parentheses' )->rawParams( $renameLink )->escaped();
458        }
459
460        if ( count( $this->mUnattachedLocalAccounts ) ) {
461            $attribs['unattached'] = htmlspecialchars(
462                $this->getLanguage()->formatNum( count( $this->mUnattachedLocalAccounts ) ) );
463        }
464
465        if ( $globalUser->isLocked() ) {
466            $attribs['locked'] = $this->msg( 'centralauth-admin-yes' )->escaped();
467        }
468
469        if ( $this->mCanSuppress ) {
470            $attribs['hidden'] = $this->uiService->formatHiddenLevel(
471                $this->getContext(),
472                $globalUser->getHiddenLevelInt()
473            );
474        }
475
476        if ( $this->tempUserConfig->isTempName( $globalUser->getName() ) ) {
477            $localUser = $this->userFactory->newFromName( $globalUser->getName() );
478            // if the central user is valid, the local username is too, but Phan doesn't know that
479            '@phan-var User $localUser';
480            $registrationDate = $this->userRegistrationLookup
481                ->getRegistration( $localUser, CentralAuthGlobalRegistrationProvider::TYPE );
482            $expirationDays = $this->tempUserConfig->getExpireAfterDays();
483            if ( $registrationDate && $expirationDays ) {
484                // Add one day to account for the expiration script running daily
485                $expirationDate = MWTimestamp::getInstance( $registrationDate )
486                    ->add( new DateInterval( 'P' . ( $expirationDays + 1 ) . 'D' ) );
487                if ( $expirationDate->getTimestamp() < MWTimestamp::time() ) {
488                    $attribs['expired'] = htmlspecialchars( $this->getLanguage()
489                        ->userTimeAndDate( $expirationDate->timestamp, $localUser ) );
490                }
491            }
492        }
493
494        $groups = $globalUser->getGlobalGroupsWithExpiration();
495        if ( $groups ) {
496            $groupLinks = [];
497            // Ensure temporary groups are displayed first, to avoid ambiguity like
498            // "first, second (expires at some point)" (unclear if only second expires or if both expire)
499            uasort( $groups, static function ( $first, $second ) {
500                if ( !$first && $second ) {
501                    return 1;
502                } elseif ( $first && !$second ) {
503                    return -1;
504                } else {
505                    return 0;
506                }
507            } );
508
509            $uiLanguage = $this->getLanguage();
510            $uiUser = $this->getUser();
511
512            foreach ( $groups as $group => $expiry ) {
513                $link = $this->getLinkRenderer()->makeLink(
514                    SpecialPage::getTitleFor( 'GlobalGroupPermissions', $group ),
515                    $uiLanguage->getGroupName( $group )
516                );
517
518                if ( $expiry ) {
519                    $link = $this->msg( 'group-membership-link-with-expiry' )
520                        ->rawParams( $link )
521                        ->params( $uiLanguage->userTimeAndDate( $expiry, $uiUser ) )
522                        ->escaped();
523                }
524
525                $groupLinks[] = $link;
526            }
527
528            $attribs['groups'] = $uiLanguage->commaList( $groupLinks );
529        }
530
531        if ( $this->mCanChangeGroups ) {
532            if ( !isset( $attribs['groups'] ) ) {
533                $attribs['groups'] = $this->msg( 'rightsnone' )->escaped();
534            }
535
536            $manageGroupsLink = $this->getLinkRenderer()->makeKnownLink(
537                SpecialPage::getTitleFor( 'GlobalGroupMembership', $globalUser->getName() ),
538                $this->msg( 'centralauth-admin-info-groups-manage' )->text()
539            );
540
541            $attribs['groups'] .= $this->msg( 'word-separator' )->escaped();
542            $attribs['groups'] .= $this->msg( 'parentheses' )->rawParams( $manageGroupsLink )->escaped();
543        }
544
545        return $attribs;
546    }
547
548    private function showWikiLists() {
549        $merged = $this->mAttachedLocalAccounts;
550        $remainder = $this->mUnattachedLocalAccounts;
551
552        $legend = $this->mCanUnmerge && $this->mGlobalUser->exists() ?
553            $this->msg( 'centralauth-admin-list-legend-rw' )->text() :
554            $this->msg( 'centralauth-admin-list-legend-ro' )->text();
555
556        $this->getOutput()->addHTML( Xml::fieldset( $legend ) );
557        $this->getOutput()->addHTML( $this->listHeader() );
558        $this->getOutput()->addHTML( $this->listAccounts( $merged ) );
559        if ( $remainder ) {
560            $this->getOutput()->addHTML( $this->listAccounts( $remainder ) );
561        }
562        $this->getOutput()->addHTML( $this->listFooter() );
563        $this->getOutput()->addHTML( Xml::closeElement( 'fieldset' ) );
564        $this->getOutput()->addModuleStyles( 'jquery.tablesorter.styles' );
565        $this->getOutput()->addModules( 'jquery.tablesorter' );
566    }
567
568    /**
569     * @return string
570     */
571    private function listHeader() {
572        $columns = [
573            // centralauth-admin-list-localwiki
574            "localwiki",
575            // centralauth-admin-list-attached-on
576            "attached-on",
577            // centralauth-admin-list-method
578            "method",
579            // centralauth-admin-list-blocked
580            "blocked",
581            // centralauth-admin-list-editcount
582            "editcount",
583            // centralauth-admin-list-groups
584            "groups",
585        ];
586        $header = Xml::openElement( 'form', [
587            'method' => 'post',
588            'action' =>
589            $this->getPageTitle( $this->mUserName )->getLocalUrl( 'action=submit' ),
590            'id' => 'mw-centralauth-merged'
591        ] );
592        $header .= Html::hidden( 'wpMethod', 'unmerge' ) .
593            Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) .
594            Xml::openElement(
595                'table', [ 'class' => 'wikitable sortable mw-centralauth-wikislist' ] ) .
596            "\n" . Xml::openElement( 'thead' ) . Xml::openElement( 'tr' );
597        if ( $this->mCanUnmerge && $this->mGlobalUser->exists() ) {
598            $header .= Xml::openElement( 'th' ) . Xml::closeElement( 'th' );
599        }
600        foreach ( $columns as $c ) {
601            $header .= Xml::openElement( 'th' ) .
602                $this->msg( "centralauth-admin-list-$c" )->escaped() .
603                Xml::closeElement( 'th' );
604        }
605        $header .= Xml::closeElement( 'tr' ) .
606            Xml::closeElement( 'thead' ) .
607            Xml::openElement( 'tbody' );
608
609        return $header;
610    }
611
612    /**
613     * @return string
614     */
615    private function listFooter() {
616        $footer = Xml::closeElement( 'tbody' ) .
617            Xml::closeElement( 'table' );
618
619        if ( $this->mCanUnmerge && $this->mGlobalUser->exists() ) {
620            $footer .= Xml::submitButton( $this->msg( 'centralauth-admin-unmerge' )->text() );
621        }
622
623        return $footer . Xml::closeElement( 'form' );
624    }
625
626    /**
627     * @param array $list
628     * @return string
629     */
630    private function listAccounts( array $list ) {
631        ksort( $list );
632        return implode( "\n", array_map( [ $this, 'listWikiItem' ], $list ) );
633    }
634
635    /**
636     * @param array $row
637     * @return string
638     */
639    private function listWikiItem( array $row ) {
640        $html = Xml::openElement( 'tr' );
641
642        if ( $this->mCanUnmerge && $this->mGlobalUser->exists() ) {
643            if ( !empty( $row['attachedMethod'] ) ) {
644                $html .= Xml::openElement( 'td' ) .
645                    $this->adminCheck( $row['wiki'] ) .
646                    Xml::closeElement( 'td' );
647            } else {
648                // Account is unattached, don't show checkbox to detach
649                $html .= Xml::element( 'td' );
650            }
651        }
652
653        $html .= Xml::openElement( 'td' ) .
654            $this->foreignUserLink( $row['wiki'] ) .
655            Xml::closeElement( 'td' );
656
657        $attachedTimestamp = $row['attachedTimestamp'] ?? '';
658
659        $html .= $this->getAttachedTimestampField( $attachedTimestamp ) .
660            Xml::openElement( 'td', [ 'style' => "text-align: center;" ] );
661
662        if ( empty( $row['attachedMethod'] ) ) {
663            $html .= $this->msg( 'centralauth-admin-unattached' )->parse();
664        } else {
665            $html .= $this->formatMergeMethod( $row['attachedMethod'] );
666        }
667
668        $html .= Xml::closeElement( 'td' ) .
669            Xml::openElement( 'td' ) .
670            $this->formatBlockStatus( $row ) .
671            Xml::closeElement( 'td' ) .
672            Xml::openElement( 'td', [ 'style' => "text-align: right;" ] ) .
673            $this->formatEditcount( $row ) .
674            Xml::closeElement( 'td' ) .
675            Xml::openElement( 'td' ) .
676            $this->formatGroups( $row ) .
677            Xml::closeElement( 'td' ) .
678            Xml::closeElement( 'tr' );
679
680        return $html;
681    }
682
683    /**
684     * @param string|null $attachedTimestamp
685     *
686     * @return string
687     */
688    private function getAttachedTimestampField( $attachedTimestamp ) {
689        if ( !$attachedTimestamp ) {
690            $html = Xml::openElement( 'td', [ 'data-sort-value' => '0' ] ) .
691                $this->msg( 'centralauth-admin-unattached' )->parse();
692        } else {
693            $html = Xml::openElement( 'td',
694                [ 'data-sort-value' => $attachedTimestamp ] ) .
695                // visible date and time in users preference
696                htmlspecialchars( $this->getLanguage()->timeanddate( $attachedTimestamp, true ) );
697        }
698
699        $html .= Xml::closeElement( 'td' );
700        return $html;
701    }
702
703    /**
704     * @param string $method
705     * @return string
706     */
707    private function formatMergeMethod( $method ) {
708        // Give grep a chance to find the usages:
709        // centralauth-merge-method-primary, centralauth-merge-method-empty,
710        // centralauth-merge-method-mail, centralauth-merge-method-password,
711        // centralauth-merge-method-admin, centralauth-merge-method-new,
712        // centralauth-merge-method-login
713        $brief = $this->msg( "centralauth-merge-method-{$method}" )->text();
714        $html =
715            Html::element(
716                'img', [
717                    'src' => $this->getConfig()->get( 'ExtensionAssetsPath' )
718                        . "/CentralAuth/images/icons/merged-{$method}.png",
719                    'alt' => $brief,
720                    'title' => $brief,
721                ]
722            )
723            . Html::element(
724                'span', [
725                    'class' => 'merge-method-help',
726                    'title' => $brief,
727                    'data-centralauth-mergemethod' => $method
728                ],
729                $this->msg( 'centralauth-merge-method-questionmark' )->text()
730            );
731
732        return $html;
733    }
734
735    /**
736     * @param array $row
737     * @return string
738     */
739    private function formatBlockStatus( $row ) {
740        $additionalHtml = '';
741        if ( isset( $row['blocked'] ) && $row['blocked'] ) {
742            $optionMessage = $this->formatBlockParams( $row );
743            if ( $row['block-expiry'] == 'infinity' ) {
744                $text = $this->msg( 'centralauth-admin-blocked2-indef' )->text();
745            } else {
746                $expiry = $this->getLanguage()->timeanddate( $row['block-expiry'], true );
747                $expiryd = $this->getLanguage()->date( $row['block-expiry'], true );
748                $expiryt = $this->getLanguage()->time( $row['block-expiry'], true );
749
750                $text = $this->msg( 'centralauth-admin-blocked2', $expiry, $expiryd, $expiryt )
751                    ->text();
752            }
753
754            if ( $row['block-reason'] ) {
755                $reason = Sanitizer::escapeHtmlAllowEntities( $row['block-reason'] );
756                $reason = $this->commentFormatter->formatLinks(
757                    $reason,
758                    null,
759                    false,
760                    $row['wiki']
761                );
762
763                $msg = $this->msg( 'centralauth-admin-blocked-reason' );
764                $msg->rawParams( '<span class="plainlinks">' . $reason . '</span>' );
765
766                $additionalHtml .= Html::rawElement( 'br' ) . $msg->parse();
767            }
768
769            $additionalHtml .= ' ' . $optionMessage;
770
771        } else {
772            $text = $this->msg( 'centralauth-admin-notblocked' )->text();
773        }
774
775        return self::foreignLink(
776            $row['wiki'],
777            'Special:Log/block',
778            $text,
779            $this->msg( 'centralauth-admin-blocklog' )->text(),
780            'page=User:' . urlencode( $this->mUserName )
781        ) . $additionalHtml;
782    }
783
784    /**
785     * Format a block's parameters.
786     *
787     * @see BlockListPager::formatValue()
788     *
789     * @param array $row
790     * @return string
791     */
792    private function formatBlockParams( $row ) {
793        global $wgConf;
794
795        // Ensure all the data is loaded before trying to use.
796        $wgConf->loadFullData();
797
798        $properties = [];
799
800        if ( $row['block-sitewide'] ) {
801            $properties[] = $this->msg( 'blocklist-editing-sitewide' )->escaped();
802        }
803
804        if ( !$row['block-sitewide'] && $row['block-restrictions'] ) {
805            $list = $this->getRestrictionListHTML( $row );
806            if ( $list ) {
807                $properties[] = $this->msg( 'blocklist-editing' )->escaped() . $list;
808            }
809        }
810
811        $options = [
812            'anononly' => 'anononlyblock',
813            'nocreate' => 'createaccountblock',
814            'noautoblock' => 'noautoblockblock',
815            'noemail' => 'emailblock',
816            'nousertalk' => 'blocklist-nousertalk',
817        ];
818        foreach ( $options as $option => $msg ) {
819            if ( $row['block-' . $option] ) {
820                $properties[] = $this->msg( $msg )->escaped();
821            }
822        }
823
824        if ( !$properties ) {
825            return '';
826        }
827
828        return Html::rawElement(
829            'ul',
830            [],
831            implode( '', array_map( static function ( $prop ) {
832                return Html::rawElement(
833                    'li',
834                    [],
835                    $prop
836                );
837            }, $properties ) )
838        );
839    }
840
841    /**
842     * @see BlockListPager::getRestrictionListHTML()
843     *
844     * @param array $row
845     *
846     * @return string
847     */
848    private function getRestrictionListHTML( array $row ) {
849        $count = array_reduce( $row['block-restrictions'], static function ( $carry, $restriction ) {
850            $carry[$restriction->getType()] += 1;
851            return $carry;
852        }, [
853            PageRestriction::TYPE => 0,
854            NamespaceRestriction::TYPE => 0,
855            ActionRestriction::TYPE => 0,
856        ] );
857
858        $restrictions = [];
859        foreach ( $count as $type => $value ) {
860            if ( $value === 0 ) {
861                continue;
862            }
863
864            $restrictions[] = Html::rawElement(
865                'li',
866                [],
867                self::foreignLink(
868                    $row['wiki'],
869                    'Special:BlockList/' . $row['name'],
870                    $this->msg( 'centralauth-block-editing-' . $type, $value )->text()
871                )
872            );
873        }
874
875        if ( count( $restrictions ) === 0 ) {
876            return '';
877        }
878
879        return Html::rawElement(
880            'ul',
881            [],
882            implode( '', $restrictions )
883        );
884    }
885
886    /**
887     * @param array $row
888     * @return string
889     * @throws Exception
890     */
891    private function formatEditcount( $row ) {
892        $wiki = WikiMap::getWiki( $row['wiki'] );
893        if ( !$wiki ) {
894            throw new InvalidArgumentException( "Invalid wiki: {$row['wiki']}" );
895        }
896        $wikiname = $wiki->getDisplayName();
897        $editCount = $this->getLanguage()->formatNum( intval( $row['editCount'] ) );
898
899        return self::foreignLink(
900            $row['wiki'],
901            'Special:Contributions/' . $this->mUserName,
902            $editCount,
903            $this->msg( 'centralauth-foreign-contributions' )
904                ->params( $editCount, $wikiname )->text()
905        );
906    }
907
908    /**
909     * @param array $row
910     * @return string
911     */
912    private function formatGroups( $row ) {
913        if ( !count( $row['groupMemberships'] ) ) {
914            return '';
915        }
916
917        // We place temporary groups before non-expiring groups in the list.
918        // This is to avoid the ambiguity of something like
919        // "sysop, bureaucrat (temporary)" -- users might wonder whether the
920        // "temporary" indication applies to both groups, or just the last one
921        $listTemporary = [];
922        $list = [];
923        /** @var UserGroupMembership $ugm */
924        foreach ( $row['groupMemberships'] as $group => $ugm ) {
925            if ( $ugm->getExpiry() ) {
926                $listTemporary[] = $this->msg( 'centralauth-admin-group-temporary',
927                    wfEscapeWikitext( $group ) )->parse();
928            } else {
929                $list[] = htmlspecialchars( $group );
930            }
931        }
932        return $this->getLanguage()->commaList( array_merge( $listTemporary, $list ) );
933    }
934
935    /**
936     * @param string|WikiReference $wikiID
937     * @param string $title
938     * @param string $text not HTML escaped
939     * @param string $hint
940     * @param string $params
941     * @return string
942     * @throws Exception
943     */
944    public static function foreignLink( $wikiID, $title, $text, $hint = '', $params = '' ) {
945        if ( $wikiID instanceof WikiReference ) {
946            $wiki = $wikiID;
947        } else {
948            $wiki = WikiMap::getWiki( $wikiID );
949            if ( !$wiki ) {
950                throw new InvalidArgumentException( "Invalid wiki: $wikiID" );
951            }
952        }
953
954        $url = $wiki->getFullUrl( $title );
955        if ( $params ) {
956            $url .= '?' . $params;
957        }
958        return Xml::element( 'a',
959            [
960                'href' => $url,
961                'title' => $hint,
962            ],
963            $text );
964    }
965
966    /**
967     * @param string $wikiID
968     * @return string
969     * @throws Exception
970     */
971    private function foreignUserLink( $wikiID ) {
972        $wiki = WikiMap::getWiki( $wikiID );
973        if ( !$wiki ) {
974            throw new InvalidArgumentException( "Invalid wiki: $wikiID" );
975        }
976
977        $wikiname = $wiki->getDisplayName();
978        return self::foreignLink(
979            $wiki,
980            $this->namespaceInfo->getCanonicalName( NS_USER ) . ':' . $this->mUserName,
981            $wikiname,
982            $this->msg( 'centralauth-foreign-link', $this->mUserName, $wikiname )->text()
983        );
984    }
985
986    /**
987     * @param string $wikiID
988     * @return string
989     */
990    private function adminCheck( $wikiID ) {
991        return Xml::check( 'wpWikis[]', false, [ 'value' => $wikiID ] );
992    }
993
994    /**
995     * @param string $action Only 'delete' supported
996     */
997    private function showActionForm( $action ) {
998        $this->getOutput()->addHTML(
999            # to be able to find messages: centralauth-admin-delete-title,
1000            # centralauth-admin-delete-description, centralauth-admin-delete-button
1001            Xml::fieldset( $this->msg( "centralauth-admin-{$action}-title" )->text() ) .
1002            Xml::openElement( 'form', [
1003                'method' => 'POST',
1004                'action' => $this->getPageTitle()->getFullUrl(
1005                    'target=' . urlencode( $this->mUserName )
1006                ),
1007                'id' => "mw-centralauth-$action" ] ) .
1008            Html::hidden( 'wpMethod', $action ) .
1009            Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) .
1010                $this->msg( "centralauth-admin-{$action}-description" )->parseAsBlock() .
1011            Xml::buildForm(
1012                [ 'centralauth-admin-reason' => Xml::input( 'reason',
1013                    false, false, [ 'id' => "{$action}-reason" ] ) ],
1014                "centralauth-admin-{$action}-button"
1015            ) . Xml::closeElement( 'form' ) . Xml::closeElement( 'fieldset' ) );
1016    }
1017
1018    private function showStatusForm() {
1019        // Allows locking, hiding, locking and hiding.
1020        $form = '';
1021        $form .= Xml::fieldset( $this->msg( 'centralauth-admin-status' )->text() );
1022        $form .= Html::hidden( 'wpMethod', 'set-status' );
1023        $form .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() );
1024        $form .= Html::hidden( 'wpUserState', $this->mGlobalUser->getStateHash( false ) );
1025        $form .= $this->msg( 'centralauth-admin-status-intro' )->parseAsBlock();
1026
1027        // Radio buttons
1028        $radioLocked =
1029            Xml::radioLabel(
1030                $this->msg( 'centralauth-admin-status-locked-no' )->text(),
1031                'wpStatusLocked',
1032                '0',
1033                'mw-centralauth-status-locked-no',
1034                !$this->mGlobalUser->isLocked() ) .
1035            '<br />' .
1036            Xml::radioLabel(
1037                $this->msg( 'centralauth-admin-status-locked-yes' )->text(),
1038                'wpStatusLocked',
1039                '1',
1040                'mw-centralauth-status-locked-yes',
1041                $this->mGlobalUser->isLocked() );
1042
1043        $radioHidden =
1044            Xml::radioLabel(
1045                $this->msg( 'centralauth-admin-status-hidden-no' )->text(),
1046                'wpStatusHidden',
1047                (string)CentralAuthUser::HIDDEN_LEVEL_NONE,
1048                'mw-centralauth-status-hidden-no',
1049                $this->mGlobalUser->getHiddenLevelInt() == CentralAuthUser::HIDDEN_LEVEL_NONE );
1050        if ( $this->mCanSuppress ) {
1051            $radioHidden .= '<br />' .
1052                Xml::radioLabel(
1053                    $this->msg( 'centralauth-admin-status-hidden-list' )->text(),
1054                    'wpStatusHidden',
1055                    (string)CentralAuthUser::HIDDEN_LEVEL_LISTS,
1056                    'mw-centralauth-status-hidden-list',
1057                    $this->mGlobalUser->getHiddenLevelInt() == CentralAuthUser::HIDDEN_LEVEL_LISTS
1058                ) .
1059                '<br />' .
1060                Xml::radioLabel(
1061                    $this->msg( 'centralauth-admin-status-hidden-oversight' )->text(),
1062                    'wpStatusHidden',
1063                    (string)CentralAuthUser::HIDDEN_LEVEL_SUPPRESSED,
1064                    'mw-centralauth-status-hidden-oversight',
1065                    $this->mGlobalUser->getHiddenLevelInt() == CentralAuthUser::HIDDEN_LEVEL_SUPPRESSED
1066                );
1067        }
1068
1069        $reasonList = Xml::listDropdown(
1070            'wpReasonList',
1071            $this->msg( 'centralauth-admin-status-reasons' )->inContentLanguage()->text(),
1072            $this->msg( 'centralauth-admin-reason-other-select' )->inContentLanguage()->text()
1073        );
1074        $reasonField = Xml::input( 'wpReason', 45, false );
1075
1076        $form .= Xml::buildForm(
1077            [
1078                'centralauth-admin-status-locked' => $radioLocked,
1079                'centralauth-admin-status-hidden' => $radioHidden,
1080                'centralauth-admin-reason' => $reasonList,
1081                'centralauth-admin-reason-other' => $reasonField
1082            ],
1083            'centralauth-admin-status-submit'
1084        );
1085
1086        $form .= Xml::closeElement( 'fieldset' );
1087        $form = Xml::tags(
1088            'form',
1089            [
1090                'method' => 'POST',
1091                'action' => $this->getPageTitle()->getFullURL(
1092                    [ 'target' => $this->mUserName ]
1093                ),
1094            ],
1095            $form
1096        );
1097        $this->getOutput()->addHTML( $form );
1098    }
1099
1100    private function showLogExtract() {
1101        $user = $this->mGlobalUser->getName();
1102        $title = Title::newFromText( $this->namespaceInfo->getCanonicalName( NS_USER ) . ":{$user}@global" );
1103        if ( !$title ) {
1104            // Don't fatal even if a Title couldn't be generated
1105            // because we've invalid usernames too :/
1106            return;
1107        }
1108        $logTypes = [ 'globalauth' ];
1109        if ( $this->mCanSuppress ) {
1110            $logTypes[] = 'suppress';
1111        }
1112        $text = '';
1113        $numRows = LogEventsList::showLogExtract(
1114            $text,
1115            $logTypes,
1116            $title->getPrefixedText(),
1117            '',
1118            [ 'showIfEmpty' => true ] );
1119
1120        if ( $numRows ) {
1121            $this->getOutput()->addHTML( Xml::fieldset(
1122                $this->msg( 'centralauth-admin-logsnippet' )->text(),
1123                $text
1124            ) );
1125
1126            return;
1127        }
1128
1129        if ( $this->mGlobalUser->isLocked() ) {
1130            $logOtherWikiMsg = $this
1131                ->msg( 'centralauth-admin-log-otherwiki' )
1132                ->params( $this->mGlobalUser->getName() );
1133
1134            if ( !$logOtherWikiMsg->isDisabled() ) {
1135                $this->getOutput()->addHTML(
1136                    Html::warningBox(
1137                        $logOtherWikiMsg->parse(),
1138                        'centralauth-admin-log-otherwiki'
1139                    )
1140                );
1141            }
1142        }
1143    }
1144
1145    /**
1146     * @return int
1147     */
1148    private function evaluateTotalEditcount() {
1149        $total = 0;
1150        foreach ( $this->mAttachedLocalAccounts as $acc ) {
1151            $total += $acc['editCount'];
1152        }
1153        return $total;
1154    }
1155
1156    /**
1157     * @return array[]
1158     */
1159    private function getMergeMethodDescriptions() {
1160        // Give grep a chance to find the usages:
1161        // centralauth-merge-method-primary, centralauth-merge-method-new,
1162        // centralauth-merge-method-empty, centralauth-merge-method-password,
1163        // centralauth-merge-method-mail, centralauth-merge-method-admin,
1164        // centralauth-merge-method-login
1165        // Give grep a chance to find the usages:
1166        // centralauth-merge-method-primary-desc, centralauth-merge-method-new-desc,
1167        // centralauth-merge-method-empty-desc, centralauth-merge-method-password-desc,
1168        // centralauth-merge-method-mail-desc, centralauth-merge-method-admin-desc,
1169        // centralauth-merge-method-login-desc
1170        $mergeMethodDescriptions = [];
1171        foreach ( [ 'primary', 'new', 'empty', 'password', 'mail', 'admin', 'login' ] as $method ) {
1172            $mergeMethodDescriptions[$method] = [
1173                'short' => $this->getLanguage()->ucfirst(
1174                    $this->msg( "centralauth-merge-method-{$method}" )->escaped()
1175                ),
1176                'desc' => $this->msg( "centralauth-merge-method-{$method}-desc" )->escaped()
1177            ];
1178        }
1179        return $mergeMethodDescriptions;
1180    }
1181
1182    /**
1183     * Return an array of subpages beginning with $search that this special page will accept.
1184     *
1185     * @param string $search Prefix to search for
1186     * @param int $limit Maximum number of results to return (usually 10)
1187     * @param int $offset Number of results to skip (usually 0)
1188     * @return string[] Matching subpages
1189     */
1190    public function prefixSearchSubpages( $search, $limit, $offset ) {
1191        $search = $this->userNameUtils->getCanonical( $search );
1192        if ( !$search ) {
1193            // No prefix suggestion for invalid user
1194            return [];
1195        }
1196
1197        $dbr = $this->databaseManager->getCentralReplicaDB();
1198
1199        // Autocomplete subpage as user list - non-hidden users to allow caching
1200        return $dbr->newSelectQueryBuilder()
1201            ->select( 'gu_name' )
1202            ->from( 'globaluser' )
1203            ->where( [
1204                $dbr->expr( 'gu_name', IExpression::LIKE, new LikeValue( $search, $dbr->anyString() ) ),
1205                'gu_hidden_level' => CentralAuthUser::HIDDEN_LEVEL_NONE,
1206            ] )
1207            ->orderBy( 'gu_name' )
1208            ->limit( $limit )
1209            ->offset( $offset )
1210            ->caller( __METHOD__ )
1211            ->fetchFieldValues();
1212    }
1213
1214    /** @inheritDoc */
1215    protected function getGroupName() {
1216        return 'users';
1217    }
1218}