Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.62% covered (warning)
76.62%
213 / 278
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryAllUsers
76.90% covered (warning)
76.90%
213 / 277
42.86% covered (danger)
42.86%
3 / 7
104.40
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCanonicalUserName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 execute
71.29% covered (warning)
71.29%
144 / 202
0.00% covered (danger)
0.00%
0 / 1
119.49
 getCacheMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedParams
98.48% covered (success)
98.48%
65 / 66
0.00% covered (danger)
0.00%
0 / 1
2
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2007 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Api;
10
11use MediaWiki\Language\Language;
12use MediaWiki\MainConfigNames;
13use MediaWiki\Permissions\GroupPermissionsLookup;
14use MediaWiki\RecentChanges\RecentChangeLookup;
15use MediaWiki\User\TempUser\TempUserConfig;
16use MediaWiki\User\TempUser\TempUserDetailsLookup;
17use MediaWiki\User\UserFactory;
18use MediaWiki\User\UserGroupManager;
19use MediaWiki\User\UserIdentityValue;
20use Wikimedia\ParamValidator\ParamValidator;
21use Wikimedia\ParamValidator\TypeDef\IntegerDef;
22use Wikimedia\Rdbms\IExpression;
23use Wikimedia\Rdbms\LikeValue;
24
25/**
26 * Query module to enumerate all registered users.
27 *
28 * @ingroup API
29 */
30class ApiQueryAllUsers extends ApiQueryBase {
31    use ApiQueryBlockInfoTrait;
32
33    public function __construct(
34        ApiQuery $query,
35        string $moduleName,
36        private readonly UserFactory $userFactory,
37        private readonly UserGroupManager $userGroupManager,
38        private readonly GroupPermissionsLookup $groupPermissionsLookup,
39        private readonly Language $contentLanguage,
40        private readonly TempUserConfig $tempUserConfig,
41        private readonly RecentChangeLookup $recentChangeLookup,
42        private readonly TempUserDetailsLookup $tempUserDetailsLookup
43    ) {
44        parent::__construct( $query, $moduleName, 'au' );
45    }
46
47    /**
48     * This function converts the user name to a canonical form
49     * which is stored in the database.
50     * @param string $name
51     * @return string
52     */
53    private function getCanonicalUserName( $name ) {
54        $name = $this->contentLanguage->ucfirst( $name );
55        return strtr( $name, '_', ' ' );
56    }
57
58    public function execute() {
59        $params = $this->extractRequestParams();
60        $activeUserDays = $this->getConfig()->get( MainConfigNames::ActiveUserDays );
61
62        $db = $this->getDB();
63
64        $prop = $params['prop'];
65        if ( $prop !== null ) {
66            $prop = array_fill_keys( $prop, true );
67            $fld_blockinfo = isset( $prop['blockinfo'] );
68            $fld_editcount = isset( $prop['editcount'] );
69            $fld_groups = isset( $prop['groups'] );
70            $fld_rights = isset( $prop['rights'] );
71            $fld_registration = isset( $prop['registration'] );
72            $fld_implicitgroups = isset( $prop['implicitgroups'] );
73            $fld_centralids = isset( $prop['centralids'] );
74            $fld_tempexpired = isset( $prop['tempexpired'] );
75        } else {
76            $fld_blockinfo = $fld_editcount = $fld_groups = $fld_registration =
77                $fld_rights = $fld_implicitgroups = $fld_centralids = $fld_tempexpired = false;
78        }
79
80        $limit = $params['limit'];
81
82        $this->addTables( 'user' );
83
84        $dir = ( $params['dir'] == 'descending' ? 'older' : 'newer' );
85        $from = $params['from'] === null ? null : $this->getCanonicalUserName( $params['from'] );
86        $to = $params['to'] === null ? null : $this->getCanonicalUserName( $params['to'] );
87
88        # MySQL can't figure out that 'user_name' and 'qcc_title' are the same
89        # despite the JOIN condition, so manually sort on the correct one.
90        $userFieldToSort = $params['activeusers'] ? 'qcc_title' : 'user_name';
91
92        # Some of these subtable joins are going to give us duplicate rows, so
93        # calculate the maximum number of duplicates we might see.
94        $maxDuplicateRows = 1;
95
96        $this->addWhereRange( $userFieldToSort, $dir, $from, $to );
97
98        if ( $params['prefix'] !== null ) {
99            $this->addWhere(
100                $db->expr(
101                    $userFieldToSort,
102                    IExpression::LIKE,
103                    new LikeValue( $this->getCanonicalUserName( $params['prefix'] ), $db->anyString() )
104                )
105            );
106        }
107
108        $excludeNamed = $params['excludenamed'];
109        $excludeTemp = $params['excludetemp'];
110
111        if ( $this->tempUserConfig->isKnown() ) {
112            if ( $excludeTemp ) {
113                $this->addWhere(
114                    $this->tempUserConfig->getMatchCondition( $db, 'user_name', IExpression::NOT_LIKE )
115                );
116            }
117            if ( $excludeNamed ) {
118                $this->addWhere(
119                    $this->tempUserConfig->getMatchCondition( $db, 'user_name', IExpression::LIKE )
120                );
121            }
122        }
123
124        if ( $params['rights'] !== null && count( $params['rights'] ) ) {
125            $groups = [];
126            // TODO: this does not properly account for $wgRevokePermissions
127            foreach ( $params['rights'] as $r ) {
128                if ( in_array( $r, $this->getPermissionManager()->getImplicitRights(), true ) ) {
129                    $groups[] = '*';
130                } else {
131                    $groups = array_merge(
132                        $groups,
133                        $this->groupPermissionsLookup->getGroupsWithPermission( $r )
134                    );
135                }
136            }
137
138            if ( $groups === [] ) {
139                // No group with the given right(s) exists, no need for a query
140                $this->getResult()->addIndexedTagName( [ 'query', $this->getModuleName() ], '' );
141
142                return;
143            }
144
145            $groups = array_unique( $groups );
146            if ( in_array( '*', $groups, true ) || in_array( 'user', $groups, true ) ) {
147                // All user rows logically match but there are no "*"/"user" user_groups rows
148                $groups = [];
149            }
150
151            if ( $params['group'] === null ) {
152                $params['group'] = $groups;
153            } else {
154                $params['group'] = array_unique( array_merge( $params['group'], $groups ) );
155            }
156        }
157
158        $this->requireMaxOneParameter( $params, 'group', 'excludegroup' );
159
160        if ( $params['group'] !== null && count( $params['group'] ) ) {
161            // Filter only users that belong to a given group. This might
162            // produce as many rows-per-user as there are groups being checked.
163            $this->addTables( 'user_groups', 'ug1' );
164            $this->addJoinConds( [
165                'ug1' => [
166                    'JOIN',
167                    [
168                        'ug1.ug_user=user_id',
169                        'ug1.ug_group' => $params['group'],
170                        $db->expr( 'ug1.ug_expiry', '=', null )->or( 'ug1.ug_expiry', '>=', $db->timestamp() ),
171                    ]
172                ]
173            ] );
174            $maxDuplicateRows *= count( $params['group'] );
175        }
176
177        if ( $params['excludegroup'] !== null && count( $params['excludegroup'] ) ) {
178            // Filter only users don't belong to a given group. This can only
179            // produce one row-per-user, because we only keep on "no match".
180            $this->addTables( 'user_groups', 'ug1' );
181
182            $this->addJoinConds( [ 'ug1' => [ 'LEFT JOIN',
183                [
184                    'ug1.ug_user=user_id',
185                    $db->expr( 'ug1.ug_expiry', '=', null )->or( 'ug1.ug_expiry', '>=', $db->timestamp() ),
186                    'ug1.ug_group' => $params['excludegroup'],
187                ]
188            ] ] );
189            $this->addWhere( [ 'ug1.ug_user' => null ] );
190        }
191
192        if ( $params['witheditsonly'] ) {
193            $this->addWhere( $db->expr( 'user_editcount', '>', 0 ) );
194        }
195
196        $this->addDeletedUserFilter();
197
198        if ( $fld_groups || $fld_rights ) {
199            $this->addFields( [ 'groups' =>
200                $db->newSelectQueryBuilder()
201                    ->table( 'user_groups' )
202                    ->field( 'ug_group' )
203                    ->where( [
204                        'ug_user=user_id',
205                        $db->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $db->timestamp() )
206                    ] )
207                    ->buildGroupConcatField( '|' )
208            ] );
209        }
210
211        if ( $params['activeusers'] ) {
212            $activeUserSeconds = $activeUserDays * 86400;
213
214            // Filter query to only include users in the active users cache.
215            // There shouldn't be any duplicate rows in querycachetwo here.
216            $this->addTables( 'querycachetwo' );
217            $this->addJoinConds( [ 'querycachetwo' => [
218                'JOIN', [
219                    'qcc_type' => 'activeusers',
220                    'qcc_namespace' => NS_USER,
221                    'qcc_title=user_name',
222                ],
223            ] ] );
224
225            // Actually count the actions using a subquery (T66505 and T66507)
226            $timestamp = $db->timestamp( (int)wfTimestamp( TS_UNIX ) - $activeUserSeconds );
227            $subqueryBuilder = $db->newSelectQueryBuilder()
228                ->select( 'COUNT(*)' )
229                ->from( 'recentchanges' )
230                ->join( 'actor', null, 'rc_actor = actor_id' )
231                ->where( [
232                    'actor_user = user_id',
233                    $db->expr( 'rc_source', '=', $this->recentChangeLookup->getPrimarySources() ),
234                    $db->expr( 'rc_log_type', '=', null )
235                        ->or( 'rc_log_type', '!=', 'newusers' ),
236                    $db->expr( 'rc_timestamp', '>=', $timestamp ),
237                ] );
238            $this->addFields( [
239                'recentactions' => '(' . $subqueryBuilder->caller( __METHOD__ )->getSQL() . ')'
240            ] );
241        }
242
243        $sqlLimit = $limit + $maxDuplicateRows;
244        $this->addOption( 'LIMIT', $sqlLimit );
245
246        $this->addFields( [
247            'user_name',
248            'user_id'
249        ] );
250        $this->addFieldsIf( 'user_editcount', $fld_editcount );
251        $this->addFieldsIf( 'user_registration', $fld_registration );
252
253        $res = $this->select( __METHOD__ );
254        $count = 0;
255        $countDuplicates = 0;
256        $lastUser = false;
257        $result = $this->getResult();
258        $blockInfos = $fld_blockinfo ? $this->getBlockDetailsForRows( $res ) : null;
259        foreach ( $res as $row ) {
260            $count++;
261
262            if ( $lastUser === $row->user_name ) {
263                // Duplicate row due to one of the needed subtable joins.
264                // Ignore it, but count the number of them to sensibly handle
265                // miscalculation of $maxDuplicateRows.
266                $countDuplicates++;
267                if ( $countDuplicates == $maxDuplicateRows ) {
268                    ApiBase::dieDebug( __METHOD__, 'Saw more duplicate rows than expected' );
269                }
270                continue;
271            }
272
273            $countDuplicates = 0;
274            $lastUser = $row->user_name;
275
276            if ( $count > $limit ) {
277                // We've reached the one extra which shows that there are
278                // additional pages to be had. Stop here...
279                $this->setContinueEnumParameter( 'from', $row->user_name );
280                break;
281            }
282
283            if ( $count == $sqlLimit ) {
284                // Should never hit this (either the $countDuplicates check or
285                // the $count > $limit check should hit first), but check it
286                // anyway just in case.
287                ApiBase::dieDebug( __METHOD__, 'Saw more duplicate rows than expected' );
288            }
289
290            if ( $params['activeusers'] && (int)$row->recentactions === 0 ) {
291                // activeusers cache was out of date
292                continue;
293            }
294
295            $data = [
296                'userid' => (int)$row->user_id,
297                'name' => $row->user_name,
298            ];
299
300            if ( $fld_centralids ) {
301                $data += ApiQueryUserInfo::getCentralUserInfo(
302                    $this->getConfig(), $this->userFactory->newFromId( (int)$row->user_id ), $params['attachedwiki']
303                );
304            }
305
306            if ( $fld_blockinfo && isset( $blockInfos[$row->user_id] ) ) {
307                $data += $blockInfos[$row->user_id];
308            }
309            if ( $row->hu_deleted ) {
310                $data['hidden'] = true;
311            }
312            if ( $fld_editcount ) {
313                $data['editcount'] = (int)$row->user_editcount;
314            }
315            if ( $params['activeusers'] ) {
316                $data['recentactions'] = (int)$row->recentactions;
317            }
318            if ( $fld_registration ) {
319                $data['registration'] = $row->user_registration ?
320                    wfTimestamp( TS_ISO_8601, $row->user_registration ) : '';
321            }
322
323            if ( $fld_implicitgroups || $fld_groups || $fld_rights ) {
324                $implicitGroups = $this->userGroupManager
325                    ->getUserImplicitGroups( $this->userFactory->newFromId( (int)$row->user_id ) );
326                if ( isset( $row->groups ) && $row->groups !== '' ) {
327                    $groups = array_merge( $implicitGroups, explode( '|', $row->groups ) );
328                } else {
329                    $groups = $implicitGroups;
330                }
331
332                if ( $fld_groups ) {
333                    $data['groups'] = $groups;
334                    ApiResult::setIndexedTagName( $data['groups'], 'g' );
335                    ApiResult::setArrayType( $data['groups'], 'array' );
336                }
337
338                if ( $fld_implicitgroups ) {
339                    $data['implicitgroups'] = $implicitGroups;
340                    ApiResult::setIndexedTagName( $data['implicitgroups'], 'g' );
341                    ApiResult::setArrayType( $data['implicitgroups'], 'array' );
342                }
343
344                if ( $fld_rights ) {
345                    $user = $this->userFactory->newFromId( (int)$row->user_id );
346                    $data['rights'] = $this->getPermissionManager()->getUserPermissions( $user );
347                    ApiResult::setIndexedTagName( $data['rights'], 'r' );
348                    ApiResult::setArrayType( $data['rights'], 'array' );
349                }
350            }
351
352            if ( $fld_tempexpired ) {
353                if ( $this->tempUserConfig->isTempName( $row->user_name ) ) {
354                    $userIdentity = UserIdentityValue::newRegistered( $row->user_id, $row->user_name );
355                    $data['tempexpired'] = $this->tempUserDetailsLookup->isExpired( $userIdentity );
356                } else {
357                    $data['tempexpired'] = null;
358                }
359            }
360
361            $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $data );
362            if ( !$fit ) {
363                $this->setContinueEnumParameter( 'from', $data['name'] );
364                break;
365            }
366        }
367
368        $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'u' );
369    }
370
371    /** @inheritDoc */
372    public function getCacheMode( $params ) {
373        return 'anon-public-user-private';
374    }
375
376    /** @inheritDoc */
377    public function getAllowedParams( $flags = 0 ) {
378        $userGroups = $this->userGroupManager->listAllGroups();
379
380        if ( $flags & ApiBase::GET_VALUES_FOR_HELP ) {
381            sort( $userGroups );
382        }
383
384        return [
385            'from' => null,
386            'to' => null,
387            'prefix' => null,
388            'dir' => [
389                ParamValidator::PARAM_DEFAULT => 'ascending',
390                ParamValidator::PARAM_TYPE => [
391                    'ascending',
392                    'descending'
393                ],
394            ],
395            'group' => [
396                ParamValidator::PARAM_TYPE => $userGroups,
397                ParamValidator::PARAM_ISMULTI => true,
398            ],
399            'excludegroup' => [
400                ParamValidator::PARAM_TYPE => $userGroups,
401                ParamValidator::PARAM_ISMULTI => true,
402            ],
403            'rights' => [
404                ParamValidator::PARAM_TYPE => array_unique( array_merge(
405                    $this->getPermissionManager()->getAllPermissions(),
406                    $this->getPermissionManager()->getImplicitRights()
407                ) ),
408                ParamValidator::PARAM_ISMULTI => true,
409            ],
410            'prop' => [
411                ParamValidator::PARAM_ISMULTI => true,
412                ParamValidator::PARAM_TYPE => [
413                    'blockinfo',
414                    'groups',
415                    'implicitgroups',
416                    'rights',
417                    'editcount',
418                    'registration',
419                    'centralids',
420                    'tempexpired',
421                ],
422                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
423            ],
424            'limit' => [
425                ParamValidator::PARAM_DEFAULT => 10,
426                ParamValidator::PARAM_TYPE => 'limit',
427                IntegerDef::PARAM_MIN => 1,
428                IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
429                IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
430            ],
431            'witheditsonly' => false,
432            'activeusers' => [
433                ParamValidator::PARAM_DEFAULT => false,
434                ApiBase::PARAM_HELP_MSG => [
435                    'apihelp-query+allusers-param-activeusers',
436                    $this->getConfig()->get( MainConfigNames::ActiveUserDays )
437                ],
438            ],
439            'attachedwiki' => null,
440            'excludenamed' => [
441                ParamValidator::PARAM_TYPE => 'boolean',
442            ],
443            'excludetemp' => [
444                ParamValidator::PARAM_TYPE => 'boolean',
445            ],
446        ];
447    }
448
449    /** @inheritDoc */
450    protected function getExamplesMessages() {
451        return [
452            'action=query&list=allusers&aufrom=Y'
453                => 'apihelp-query+allusers-example-y',
454        ];
455    }
456
457    /** @inheritDoc */
458    public function getHelpUrls() {
459        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Allusers';
460    }
461}
462
463/** @deprecated class alias since 1.43 */
464class_alias( ApiQueryAllUsers::class, 'ApiQueryAllUsers' );