1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 * @ingroup Pager
20 */
21
22use MediaWiki\Cache\LinkBatchFactory;
23use MediaWiki\HookContainer\HookContainer;
24use MediaWiki\User\UserGroupManager;
25use Wikimedia\Rdbms\ILoadBalancer;
26
27/**
28 * This class is used to get a list of active users. The ones with specials
29 * rights (sysop, bureaucrat, developer) will have them displayed
30 * next to their names.
31 *
32 * @ingroup Pager
33 */
34class ActiveUsersPager extends UsersPager {
35
36	/**
37	 * @var FormOptions
38	 */
39	protected $opts;
40
41	/**
42	 * @var string[]
43	 */
44	protected $groups;
45
46	/**
47	 * @var array
48	 */
49	private $blockStatusByUid;
50
51	/** @var int */
52	private $RCMaxAge;
53
54	/** @var string[] */
55	private $excludegroups;
56
57	/**
58	 * @param IContextSource|null $context
59	 * @param FormOptions $opts
60	 * @param LinkBatchFactory|null $linkBatchFactory
61	 * @param HookContainer $hookContainer
62	 * @param ILoadBalancer $loadBalancer
63	 * @param UserGroupManager $userGroupManager
64	 */
65	public function __construct(
66		?IContextSource $context,
67		FormOptions $opts,
68		LinkBatchFactory $linkBatchFactory,
69		HookContainer $hookContainer,
70		ILoadBalancer $loadBalancer,
71		UserGroupManager $userGroupManager
72	) {
73		parent::__construct(
74			$context,
75			null,
76			null,
77			$linkBatchFactory,
78			$hookContainer,
79			$loadBalancer,
80			$userGroupManager
81		);
82
83		$this->RCMaxAge = $this->getConfig()->get( 'ActiveUserDays' );
84		$this->requestedUser = '';
85
86		$un = $opts->getValue( 'username' );
87		if ( $un != '' ) {
88			$username = Title::makeTitleSafe( NS_USER, $un );
89			if ( $username !== null ) {
90				$this->requestedUser = $username->getText();
91			}
92		}
93
94		$this->groups = $opts->getValue( 'groups' );
95		$this->excludegroups = $opts->getValue( 'excludegroups' );
96		// Backwards-compatibility with old URLs
97		if ( $opts->getValue( 'hidebots' ) ) {
98			$this->excludegroups[] = 'bot';
99		}
100		if ( $opts->getValue( 'hidesysops' ) ) {
101			$this->excludegroups[] = 'sysop';
102		}
103	}
104
105	public function getIndexField() {
106		return 'qcc_title';
107	}
108
109	public function getQueryInfo( $data = null ) {
110		$dbr = $this->getDatabase();
111
112		$activeUserSeconds = $this->getConfig()->get( 'ActiveUserDays' ) * 86400;
113		$timestamp = $dbr->timestamp( wfTimestamp( TS_UNIX ) - $activeUserSeconds );
114		$fname = __METHOD__ . ' (' . $this->getSqlComment() . ')';
115
116		// Inner subselect to pull the active users out of querycachetwo
117		$tables = [ 'querycachetwo', 'user', 'actor' ];
118		$fields = [ 'qcc_title', 'user_id', 'actor_id' ];
119		$jconds = [
120			'user' => [ 'JOIN', 'user_name = qcc_title' ],
121			'actor' => [ 'JOIN', 'actor_user = user_id' ],
122		];
123		$conds = [
124			'qcc_type' => 'activeusers',
125			'qcc_namespace' => NS_USER,
126		];
127		$options = [];
128		if ( $data !== null ) {
129			$options['ORDER BY'] = 'qcc_title ' . $data['order'];
130			$options['LIMIT'] = $data['limit'];
131			$conds = array_merge( $conds, $data['conds'] );
132		}
133		if ( $this->requestedUser != '' ) {
134			$conds[] = 'qcc_title >= ' . $dbr->addQuotes( $this->requestedUser );
135		}
136		if ( $this->groups !== [] ) {
137			$tables['ug1'] = 'user_groups';
138			$jconds['ug1'] = [ 'JOIN', 'ug1.ug_user = user_id' ];
139			$conds['ug1.ug_group'] = $this->groups;
140			$conds[] = 'ug1.ug_expiry IS NULL OR ug1.ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() );
141		}
142		if ( $this->excludegroups !== [] ) {
143			$tables['ug2'] = 'user_groups';
144			$jconds['ug2'] = [ 'LEFT JOIN', [
145				'ug2.ug_user = user_id',
146				'ug2.ug_group' => $this->excludegroups,
147				'ug2.ug_expiry IS NULL OR ug2.ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ),
148			] ];
149			$conds['ug2.ug_user'] = null;
150		}
151		if ( !$this->canSeeHideuser() ) {
152			$conds[] = 'NOT EXISTS (' . $dbr->selectSQLText(
153					'ipblocks', '1', [ 'ipb_user=user_id', 'ipb_deleted' => 1 ], __METHOD__
154				) . ')';
155		}
156		$subquery = $dbr->buildSelectSubquery( $tables, $fields, $conds, $fname, $options, $jconds );
157
158		// Outer query to select the recent edit counts for the selected active users
159		$tables = [ 'qcc_users' => $subquery, 'recentchanges' ];
160		$jconds = [ 'recentchanges' => [ 'LEFT JOIN', [
161			'rc_actor = actor_id',
162			'rc_type != ' . $dbr->addQuotes( RC_EXTERNAL ), // Don't count wikidata.
163			'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE ), // Don't count categorization changes.
164			'rc_log_type IS NULL OR rc_log_type != ' . $dbr->addQuotes( 'newusers' ),
165			'rc_timestamp >= ' . $dbr->addQuotes( $timestamp ),
166		] ] ];
167		$conds = [];
168
169		return [
170			'tables' => $tables,
171			'fields' => [
172				'qcc_title',
173				'user_name' => 'qcc_title',
174				'user_id' => 'user_id',
175				'recentedits' => 'COUNT(rc_id)'
176			],
177			'options' => [ 'GROUP BY' => [ 'qcc_title', 'user_id' ] ],
178			'conds' => $conds,
179			'join_conds' => $jconds,
180		];
181	}
182
183	protected function buildQueryInfo( $offset, $limit, $order ) {
184		$fname = __METHOD__ . ' (' . $this->getSqlComment() . ')';
185
186		$sortColumns = array_merge( [ $this->mIndexField ], $this->mExtraSortFields );
187		if ( $order === self::QUERY_ASCENDING ) {
188			$dir = 'ASC';
189			$orderBy = $sortColumns;
190			$operator = $this->mIncludeOffset ? '>=' : '>';
191		} else {
192			$dir = 'DESC';
193			$orderBy = [];
194			foreach ( $sortColumns as $col ) {
195				$orderBy[] = $col . ' DESC';
196			}
197			$operator = $this->mIncludeOffset ? '<=' : '<';
198		}
199		$info = $this->getQueryInfo( [
200			'limit' => intval( $limit ),
201			'order' => $dir,
202			'conds' =>
203				$offset != '' ? [ $this->mIndexField . $operator . $this->getDatabase()->addQuotes( $offset ) ] : [],
204		] );
205
206		$tables = $info['tables'];
207		$fields = $info['fields'];
208		$conds = $info['conds'];
209		$options = $info['options'];
210		$join_conds = $info['join_conds'];
211		$options['ORDER BY'] = $orderBy;
212		return [ $tables, $fields, $conds, $fname, $options, $join_conds ];
213	}
214
215	protected function doBatchLookups() {
216		parent::doBatchLookups();
217
218		$uids = [];
219		foreach ( $this->mResult as $row ) {
220			$uids[] = $row->user_id;
221		}
222		// Fetch the block status of the user for showing "(blocked)" text and for
223		// striking out names of suppressed users when privileged user views the list.
224		// Although the first query already hits the block table for un-privileged, this
225		// is done in two queries to avoid huge quicksorts and to make COUNT(*) correct.
226		$dbr = $this->getDatabase();
227		$res = $dbr->select( 'ipblocks',
228			[ 'ipb_user', 'deleted' => 'MAX(ipb_deleted)', 'sitewide' => 'MAX(ipb_sitewide)' ],
229			[ 'ipb_user' => $uids ],
230			__METHOD__,
231			[ 'GROUP BY' => [ 'ipb_user' ] ]
232		);
233		$this->blockStatusByUid = [];
234		foreach ( $res as $row ) {
235			$this->blockStatusByUid[$row->ipb_user] = [
236				'deleted' => $row->deleted,
237				'sitewide' => $row->sitewide,
238			];
239		}
240		$this->mResult->seek( 0 );
241	}
242
243	public function formatRow( $row ) {
244		$userName = $row->user_name;
245
246		$ulinks = Linker::userLink( $row->user_id, $userName );
247		$ulinks .= Linker::userToolLinks(
248			$row->user_id,
249			$userName,
250			// Should the contributions link be red if the user has no edits (using default)
251			false,
252			// Customisation flags (using default 0)
253			0,
254			// User edit count (using default)
255			null,
256			// do not wrap the message in parentheses (CSS will provide these)
257			false
258		);
259
260		$lang = $this->getLanguage();
261
262		$list = [];
263
264		$ugms = self::getGroupMemberships( intval( $row->user_id ), $this->userGroupCache );
265		foreach ( $ugms as $ugm ) {
266			$list[] = $this->buildGroupLink( $ugm, $userName );
267		}
268
269		$groups = $lang->commaList( $list );
270
271		$item = $lang->specialList( $ulinks, $groups );
272
273		// If there is a block, 'deleted' and 'sitewide' are both set on
274		// $this->blockStatusByUid[$row->user_id].
275		$blocked = '';
276		$isBlocked = isset( $this->blockStatusByUid[$row->user_id] );
277		if ( $isBlocked ) {
278			if ( $this->blockStatusByUid[$row->user_id]['deleted'] == 1 ) {
279				$item = "<span class=\"deleted\">$item</span>";
280			}
281			if ( $this->blockStatusByUid[$row->user_id]['sitewide'] == 1 ) {
282				$blocked = ' ' . $this->msg( 'listusers-blocked', $userName )->escaped();
283			}
284		}
285		$count = $this->msg( 'activeusers-count' )->numParams( $row->recentedits )
286			->params( $userName )->numParams( $this->RCMaxAge )->escaped();
287
288		return Html::rawElement( 'li', [], "{$item} [{$count}]{$blocked}" );
289	}
290
291}
292