Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 267
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
UsersPager
0.00% covered (danger)
0.00%
0 / 266
0.00% covered (danger)
0.00%
0 / 11
3422
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
110
 getIndexField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getQueryInfo
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
210
 formatRow
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
156
 doBatchLookups
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 getPageHeader
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 1
30
 canSeeHideuser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAllGroups
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getDefaultQuery
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getGroupMemberships
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 buildGroupLink
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2004 Brooke Vibber, lcrocker, Tim Starling,
4 * Domas Mituzas, Antoine Musso, Jens Frank, Zhengzhu,
5 * 2006 Rob Church <robchur@gmail.com>
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
21 *
22 * @file
23 * @ingroup Pager
24 */
25
26namespace MediaWiki\Pager;
27
28use MediaWiki\Block\HideUserUtils;
29use MediaWiki\Cache\LinkBatchFactory;
30use MediaWiki\Context\IContextSource;
31use MediaWiki\HookContainer\HookContainer;
32use MediaWiki\HookContainer\HookRunner;
33use MediaWiki\Html\Html;
34use MediaWiki\HTMLForm\Field\HTMLHiddenField;
35use MediaWiki\HTMLForm\Field\HTMLInfoField;
36use MediaWiki\HTMLForm\Field\HTMLSelectField;
37use MediaWiki\HTMLForm\Field\HTMLSubmitField;
38use MediaWiki\HTMLForm\Field\HTMLUserTextField;
39use MediaWiki\HTMLForm\HTMLForm;
40use MediaWiki\Linker\Linker;
41use MediaWiki\Logger\LoggerFactory;
42use MediaWiki\MainConfigNames;
43use MediaWiki\Title\Title;
44use MediaWiki\User\UserGroupManager;
45use MediaWiki\User\UserGroupMembership;
46use MediaWiki\User\UserIdentity;
47use MediaWiki\User\UserIdentityLookup;
48use MediaWiki\User\UserIdentityValue;
49use stdClass;
50use Wikimedia\Rdbms\IConnectionProvider;
51
52/**
53 * This class is used to get a list of user. The ones with specials
54 * rights (sysop, bureaucrat, developer) will have them displayed
55 * next to their names.
56 *
57 * @ingroup Pager
58 */
59class UsersPager extends AlphabeticPager {
60
61    /**
62     * @var array[] A array with user ids as key and a array of groups as value
63     */
64    protected $userGroupCache;
65
66    /** @var string */
67    public $requestedGroup;
68
69    /** @var bool */
70    protected $editsOnly;
71
72    /** @var bool */
73    protected $temporaryGroupsOnly;
74
75    /** @var bool */
76    protected $creationSort;
77
78    /** @var bool|null */
79    protected $including;
80
81    /** @var string */
82    protected $requestedUser;
83
84    /** @var HideUserUtils */
85    protected $hideUserUtils;
86
87    private HookRunner $hookRunner;
88    private LinkBatchFactory $linkBatchFactory;
89    private UserGroupManager $userGroupManager;
90    private UserIdentityLookup $userIdentityLookup;
91
92    /**
93     * @param IContextSource $context
94     * @param HookContainer $hookContainer
95     * @param LinkBatchFactory $linkBatchFactory
96     * @param IConnectionProvider $dbProvider
97     * @param UserGroupManager $userGroupManager
98     * @param UserIdentityLookup $userIdentityLookup
99     * @param HideUserUtils $hideUserUtils
100     * @param string|null $par
101     * @param bool|null $including Whether this page is being transcluded in
102     * another page
103     */
104    public function __construct(
105        IContextSource $context,
106        HookContainer $hookContainer,
107        LinkBatchFactory $linkBatchFactory,
108        IConnectionProvider $dbProvider,
109        UserGroupManager $userGroupManager,
110        UserIdentityLookup $userIdentityLookup,
111        HideUserUtils $hideUserUtils,
112        $par,
113        $including
114    ) {
115        $this->setContext( $context );
116
117        $request = $this->getRequest();
118        $par ??= '';
119        $parms = explode( '/', $par );
120        $symsForAll = [ '*', 'user' ];
121
122        if ( $parms[0] != '' &&
123            ( in_array( $par, $userGroupManager->listAllGroups() ) || in_array( $par, $symsForAll ) )
124        ) {
125            $this->requestedGroup = $par;
126            $un = $request->getText( 'username' );
127        } elseif ( count( $parms ) == 2 ) {
128            $this->requestedGroup = $parms[0];
129            $un = $parms[1];
130        } else {
131            $this->requestedGroup = $request->getVal( 'group' );
132            $un = ( $par != '' ) ? $par : $request->getText( 'username' );
133        }
134
135        if ( in_array( $this->requestedGroup, $symsForAll ) ) {
136            $this->requestedGroup = '';
137        }
138        $this->editsOnly = $request->getBool( 'editsOnly' );
139        $this->temporaryGroupsOnly = $request->getBool( 'temporaryGroupsOnly' );
140        $this->creationSort = $request->getBool( 'creationSort' );
141        $this->including = $including;
142        $this->mDefaultDirection = $request->getBool( 'desc' )
143            ? IndexPager::DIR_DESCENDING
144            : IndexPager::DIR_ASCENDING;
145
146        $this->requestedUser = '';
147
148        if ( $un != '' ) {
149            $username = Title::makeTitleSafe( NS_USER, $un );
150
151            if ( $username !== null ) {
152                $this->requestedUser = $username->getText();
153            }
154        }
155
156        // Set database before parent constructor to avoid setting it there
157        $this->mDb = $dbProvider->getReplicaDatabase();
158        parent::__construct();
159        $this->userGroupManager = $userGroupManager;
160        $this->hookRunner = new HookRunner( $hookContainer );
161        $this->linkBatchFactory = $linkBatchFactory;
162        $this->userIdentityLookup = $userIdentityLookup;
163        $this->hideUserUtils = $hideUserUtils;
164    }
165
166    /**
167     * @return string
168     */
169    public function getIndexField() {
170        return $this->creationSort ? 'user_id' : 'user_name';
171    }
172
173    /**
174     * @return array
175     */
176    public function getQueryInfo() {
177        $dbr = $this->getDatabase();
178        $conds = [];
179        $options = [];
180
181        // Don't show hidden names
182        if ( !$this->canSeeHideuser() ) {
183            $conds[] = $this->hideUserUtils->getExpression( $dbr );
184            $deleted = '1=0';
185        } else {
186            // In MySQL, there's no separate boolean type so getExpression()
187            // effectively returns an integer, and MAX() works on the result of it.
188            // In PostgreSQL, getExpression() returns a special boolean type which
189            // can't go into MAX(). So we have to cast it to support PostgreSQL.
190
191            // A neater PostgreSQL-only solution would be bool_or(), but MySQL
192            // doesn't have that or need it. We could add a wrapper to SQLPlatform
193            // which returns MAX() on MySQL and bool_or() on PostgreSQL.
194
195            // This would not be necessary if we used "GROUP BY user_name,user_id",
196            // but MariaDB forgets how to use indexes if you do that.
197            $deleted = 'MAX(' . $dbr->buildIntegerCast(
198                $this->hideUserUtils->getExpression( $dbr, 'user_id', HideUserUtils::HIDDEN_USERS )
199            ) . ')';
200        }
201
202        if ( $this->requestedGroup != '' || $this->temporaryGroupsOnly ) {
203            $cond = $dbr->expr( 'ug_expiry', '>=', $dbr->timestamp() );
204            if ( !$this->temporaryGroupsOnly ) {
205                $cond = $cond->or( 'ug_expiry', '=', null );
206            }
207            $conds[] = $cond;
208        }
209
210        if ( $this->requestedGroup != '' ) {
211            $conds['ug_group'] = $this->requestedGroup;
212        }
213
214        if ( $this->requestedUser != '' ) {
215            # Sorted either by account creation or name
216            if ( $this->creationSort ) {
217                $userIdentity = $this->userIdentityLookup->getUserIdentityByName( $this->requestedUser );
218                if ( $userIdentity && $userIdentity->isRegistered() ) {
219                    $conds[] = $dbr->expr( 'user_id', '>=', $userIdentity->getId() );
220                }
221            } else {
222                $conds[] = $dbr->expr( 'user_name', '>=', $this->requestedUser );
223            }
224        }
225
226        if ( $this->editsOnly ) {
227            $conds[] = $dbr->expr( 'user_editcount', '>', 0 );
228        }
229
230        $options['GROUP BY'] = $this->creationSort ? 'user_id' : 'user_name';
231
232        $query = [
233            'tables' => [
234                'user',
235                'user_groups',
236                'block_with_target' => [
237                    'block_target',
238                    'block'
239                ]
240            ],
241            'fields' => [
242                'user_name' => $this->creationSort ? 'MAX(user_name)' : 'user_name',
243                'user_id' => $this->creationSort ? 'user_id' : 'MAX(user_id)',
244                'edits' => 'MAX(user_editcount)',
245                'creation' => 'MIN(user_registration)',
246                'deleted' => $deleted, // block/hide status
247                'sitewide' => 'MAX(bl_sitewide)'
248            ],
249            'options' => $options,
250            'join_conds' => [
251                'user_groups' => [ 'LEFT JOIN', 'user_id=ug_user' ],
252                'block_with_target' => [
253                    'LEFT JOIN', [
254                        'user_id=bt_user',
255                        'bt_auto' => 0
256                    ]
257                ],
258                'block' => [ 'JOIN', 'bl_target=bt_id' ]
259            ],
260            'conds' => $conds
261        ];
262
263        $this->hookRunner->onSpecialListusersQueryInfo( $this, $query );
264
265        return $query;
266    }
267
268    /**
269     * @param stdClass $row
270     * @return string
271     */
272    public function formatRow( $row ) {
273        if ( $row->user_id == 0 ) { # T18487
274            return '';
275        }
276
277        $userName = $row->user_name;
278
279        $ulinks = Linker::userLink( $row->user_id, $userName );
280        $ulinks .= Linker::userToolLinksRedContribs(
281            $row->user_id,
282            $userName,
283            (int)$row->edits,
284            // don't render parentheses in HTML markup (CSS will provide)
285            false
286        );
287
288        $lang = $this->getLanguage();
289
290        $groups = '';
291        $userIdentity = new UserIdentityValue( intval( $row->user_id ), $userName );
292        $ugms = $this->getGroupMemberships( $userIdentity );
293
294        if ( !$this->including && count( $ugms ) > 0 ) {
295            $list = [];
296            foreach ( $ugms as $ugm ) {
297                $list[] = $this->buildGroupLink( $ugm, $userName );
298            }
299            $groups = $lang->commaList( $list );
300        }
301
302        $item = $lang->specialList( $ulinks, $groups );
303
304        if ( $row->deleted ) {
305            $item = "<span class=\"deleted\">$item</span>";
306        }
307
308        $edits = '';
309        if ( !$this->including && $this->getConfig()->get( MainConfigNames::Edititis ) ) {
310            $count = $this->msg( 'usereditcount' )->numParams( $row->edits )->escaped();
311            $edits = $this->msg( 'word-separator' )->escaped() . $this->msg( 'brackets', $count )->escaped();
312        }
313
314        $created = '';
315        # Some rows may be null
316        if ( !$this->including && $row->creation ) {
317            $user = $this->getUser();
318            $d = $lang->userDate( $row->creation, $user );
319            $t = $lang->userTime( $row->creation, $user );
320            $created = $this->msg( 'usercreated', $d, $t, $row->user_name )->escaped();
321            $created = ' ' . $this->msg( 'parentheses' )->rawParams( $created )->escaped();
322        }
323
324        $blocked = $row->deleted !== null && $row->sitewide === '1' ?
325            ' ' . $this->msg( 'listusers-blocked', $userName )->escaped() :
326            '';
327
328        $this->hookRunner->onSpecialListusersFormatRow( $item, $row );
329
330        return Html::rawElement( 'li', [], "{$item}{$edits}{$created}{$blocked}" );
331    }
332
333    protected function doBatchLookups() {
334        $batch = $this->linkBatchFactory->newLinkBatch();
335        $userIds = [];
336        # Give some pointers to make user links
337        foreach ( $this->mResult as $row ) {
338            $batch->add( NS_USER, $row->user_name );
339            $batch->add( NS_USER_TALK, $row->user_name );
340            $userIds[] = (int)$row->user_id;
341        }
342
343        // Lookup groups for all the users
344        $queryBuilder = $this->userGroupManager->newQueryBuilder( $this->getDatabase() );
345        $groupRes = $queryBuilder->where( [ 'ug_user' => $userIds ] )
346            ->caller( __METHOD__ )
347            ->fetchResultSet();
348        $cache = [];
349        $groups = [];
350        foreach ( $groupRes as $row ) {
351            $ugm = $this->userGroupManager->newGroupMembershipFromRow( $row );
352            if ( !$ugm->isExpired() ) {
353                $cache[$row->ug_user][$row->ug_group] = $ugm;
354                $groups[$row->ug_group] = true;
355            }
356        }
357
358        // Give extensions a chance to add things like global user group data
359        // into the cache array to ensure proper output later on
360        $this->hookRunner->onUsersPagerDoBatchLookups( $this->getDatabase(), $userIds, $cache, $groups );
361
362        $this->userGroupCache = $cache;
363
364        // Add page of groups to link batch
365        foreach ( $groups as $group => $unused ) {
366            $groupPage = UserGroupMembership::getGroupPage( $group );
367            if ( $groupPage ) {
368                $batch->addObj( $groupPage );
369            }
370        }
371
372        $batch->execute();
373        $this->mResult->rewind();
374    }
375
376    /**
377     * @return string
378     */
379    public function getPageHeader() {
380        $self = explode( '/', $this->getTitle()->getPrefixedDBkey(), 2 )[0];
381
382        $groupOptions = [ $this->msg( 'group-all' )->text() => '' ];
383        foreach ( $this->getAllGroups() as $group => $groupText ) {
384            if ( array_key_exists( $groupText, $groupOptions ) ) {
385                LoggerFactory::getInstance( 'translation-problem' )->error(
386                    'The group {group_one} has the same translation as {group_two} for {lang}. ' .
387                    '{group_one} will not be displayed in group dropdown of the UsersPager.',
388                    [
389                        'group_one' => $group,
390                        'group_two' => $groupOptions[$groupText],
391                        'lang' => $this->getLanguage()->getCode(),
392                    ]
393                );
394                continue;
395            }
396            $groupOptions[ $groupText ] = $group;
397        }
398
399        $formDescriptor = [
400            'user' => [
401                'class' => HTMLUserTextField::class,
402                'label' => $this->msg( 'listusersfrom' )->text(),
403                'name' => 'username',
404                'default' => $this->requestedUser,
405            ],
406            'dropdown' => [
407                'label' => $this->msg( 'group' )->text(),
408                'name' => 'group',
409                'default' => $this->requestedGroup,
410                'class' => HTMLSelectField::class,
411                'options' => $groupOptions,
412            ],
413            'editsOnly' => [
414                'type' => 'check',
415                'label' => $this->msg( 'listusers-editsonly' )->text(),
416                'name' => 'editsOnly',
417                'id' => 'editsOnly',
418                'default' => $this->editsOnly
419            ],
420            'temporaryGroupsOnly' => [
421                'type' => 'check',
422                'label' => $this->msg( 'listusers-temporarygroupsonly' )->text(),
423                'name' => 'temporaryGroupsOnly',
424                'id' => 'temporaryGroupsOnly',
425                'default' => $this->temporaryGroupsOnly
426            ],
427            'creationSort' => [
428                'type' => 'check',
429                'label' => $this->msg( 'listusers-creationsort' )->text(),
430                'name' => 'creationSort',
431                'id' => 'creationSort',
432                'default' => $this->creationSort
433            ],
434            'desc' => [
435                'type' => 'check',
436                'label' => $this->msg( 'listusers-desc' )->text(),
437                'name' => 'desc',
438                'id' => 'desc',
439                'default' => $this->mDefaultDirection
440            ],
441            'limithiddenfield' => [
442                'class' => HTMLHiddenField::class,
443                'name' => 'limit',
444                'default' => $this->mLimit
445            ]
446        ];
447
448        $beforeSubmitButtonHookOut = '';
449        $this->hookRunner->onSpecialListusersHeaderForm( $this, $beforeSubmitButtonHookOut );
450
451        if ( $beforeSubmitButtonHookOut !== '' ) {
452            $formDescriptor[ 'beforeSubmitButtonHookOut' ] = [
453                'class' => HTMLInfoField::class,
454                'raw' => true,
455                'default' => $beforeSubmitButtonHookOut
456            ];
457        }
458
459        $formDescriptor[ 'submit' ] = [
460            'class' => HTMLSubmitField::class,
461            'buttonlabel-message' => 'listusers-submit',
462        ];
463
464        $beforeClosingFieldsetHookOut = '';
465        $this->hookRunner->onSpecialListusersHeader( $this, $beforeClosingFieldsetHookOut );
466
467        if ( $beforeClosingFieldsetHookOut !== '' ) {
468            $formDescriptor[ 'beforeClosingFieldsetHookOut' ] = [
469                'class' => HTMLInfoField::class,
470                'raw' => true,
471                'default' => $beforeClosingFieldsetHookOut
472            ];
473        }
474
475        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
476        $htmlForm
477            ->setMethod( 'get' )
478            ->setTitle( Title::newFromText( $self ) )
479            ->setId( 'mw-listusers-form' )
480            ->setFormIdentifier( 'mw-listusers-form' )
481            ->suppressDefaultSubmit()
482            ->setWrapperLegendMsg( 'listusers' );
483        return $htmlForm->prepareForm()->getHTML( true );
484    }
485
486    protected function canSeeHideuser() {
487        return $this->getAuthority()->isAllowed( 'hideuser' );
488    }
489
490    /**
491     * Get a list of all explicit groups
492     * @return array
493     */
494    private function getAllGroups() {
495        $result = [];
496        $lang = $this->getLanguage();
497        foreach ( $this->userGroupManager->listAllGroups() as $group ) {
498            $result[$group] = $lang->getGroupName( $group );
499        }
500        asort( $result );
501
502        return $result;
503    }
504
505    /**
506     * Preserve group and username offset parameters when paging
507     * @return array
508     */
509    public function getDefaultQuery() {
510        $query = parent::getDefaultQuery();
511        if ( $this->requestedGroup != '' ) {
512            $query['group'] = $this->requestedGroup;
513        }
514        if ( $this->requestedUser != '' ) {
515            $query['username'] = $this->requestedUser;
516        }
517        $this->hookRunner->onSpecialListusersDefaultQuery( $this, $query );
518
519        return $query;
520    }
521
522    /**
523     * Get an associative array containing groups the specified user belongs to,
524     * and the relevant UserGroupMembership objects
525     *
526     * @param UserIdentity $user
527     * @return UserGroupMembership[] (group name => UserGroupMembership object)
528     */
529    protected function getGroupMemberships( $user ) {
530        if ( $this->userGroupCache === null ) {
531            return $this->userGroupManager->getUserGroupMemberships( $user );
532        } else {
533            return $this->userGroupCache[$user->getId()] ?? [];
534        }
535    }
536
537    /**
538     * Format a link to a group description page
539     *
540     * @param string|UserGroupMembership $group Group name or UserGroupMembership object
541     * @param string $username
542     * @return string
543     */
544    protected function buildGroupLink( $group, $username ) {
545        return UserGroupMembership::getLinkHTML( $group, $this->getContext(), $username );
546    }
547}
548
549/**
550 * Retain the old class name for backwards compatibility.
551 * @deprecated since 1.41
552 */
553class_alias( UsersPager::class, 'UsersPager' );