1<?php
2/**
3 * Contain classes to list log entries
4 *
5 * Copyright © 2004 Brion Vibber <brion@pobox.com>
6 * https://www.mediawiki.org/
7 *
8 * This program is free software; you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation; either version 2 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License along
19 * with this program; if not, write to the Free Software Foundation, Inc.,
20 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
21 * http://www.gnu.org/copyleft/gpl.html
22 *
23 * @file
24 */
25
26use MediaWiki\MediaWikiServices;
27
28/**
29 * @ingroup Pager
30 */
31class LogPager extends ReverseChronologicalPager {
32	/** @var array Log types */
33	private $types = [];
34
35	/** @var string Events limited to those by performer when set */
36	private $performer = '';
37
38	/** @var string|Title Events limited to those about Title when set */
39	private $title = '';
40
41	/** @var bool */
42	private $pattern = false;
43
44	/** @var string */
45	private $typeCGI = '';
46
47	/** @var string */
48	private $action = '';
49
50	/** @var bool */
51	private $performerRestrictionsEnforced = false;
52
53	/** @var bool */
54	private $actionRestrictionsEnforced = false;
55
56	/** @var array */
57	private $mConds;
58
59	/** @var string */
60	private $mTagFilter;
61
62	/** @var LogEventsList */
63	public $mLogEventsList;
64
65	/**
66	 * @param LogEventsList $list
67	 * @param string|array $types Log types to show
68	 * @param string $performer The user who made the log entries
69	 * @param string|Title $title The page title the log entries are for
70	 * @param bool $pattern Do a prefix search rather than an exact title match
71	 * @param array $conds Extra conditions for the query
72	 * @param int|bool $year The year to start from. Default: false
73	 * @param int|bool $month The month to start from. Default: false
74	 * @param int|bool $day The day to start from. Default: false
75	 * @param string $tagFilter Tag
76	 * @param string $action Specific action (subtype) requested
77	 * @param int $logId Log entry ID, to limit to a single log entry.
78	 */
79	public function __construct( $list, $types = [], $performer = '', $title = '',
80		$pattern = false, $conds = [], $year = false, $month = false, $day = false,
81		$tagFilter = '', $action = '', $logId = 0
82	) {
83		parent::__construct( $list->getContext() );
84		$this->mConds = $conds;
85
86		$this->mLogEventsList = $list;
87
88		$this->limitType( $types ); // also excludes hidden types
89		$this->limitLogId( $logId );
90		$this->limitFilterTypes();
91		$this->limitPerformer( $performer );
92		$this->limitTitle( $title, $pattern );
93		$this->limitAction( $action );
94		$this->getDateCond( $year, $month, $day );
95		$this->mTagFilter = $tagFilter;
96
97		$this->mDb = wfGetDB( DB_REPLICA, 'logpager' );
98	}
99
100	public function getDefaultQuery() {
101		$query = parent::getDefaultQuery();
102		$query['type'] = $this->typeCGI; // arrays won't work here
103		$query['user'] = $this->performer;
104		$query['day'] = $this->mDay;
105		$query['month'] = $this->mMonth;
106		$query['year'] = $this->mYear;
107
108		return $query;
109	}
110
111	private function limitFilterTypes() {
112		if ( $this->hasEqualsClause( 'log_id' ) ) { // T220834
113			return;
114		}
115		$filterTypes = $this->getFilterParams();
116		foreach ( $filterTypes as $type => $hide ) {
117			if ( $hide ) {
118				$this->mConds[] = 'log_type != ' . $this->mDb->addQuotes( $type );
119			}
120		}
121	}
122
123	public function getFilterParams() {
124		$filters = [];
125		if ( count( $this->types ) ) {
126			return $filters;
127		}
128
129		$wpfilters = $this->getRequest()->getArray( "wpfilters" );
130		$filterLogTypes = $this->getConfig()->get( 'FilterLogTypes' );
131
132		foreach ( $filterLogTypes as $type => $default ) {
133			// Back-compat: Check old URL params if the new param wasn't passed
134			if ( $wpfilters === null ) {
135				$hide = $this->getRequest()->getBool( "hide_{$type}_log", $default );
136			} else {
137				$hide = !in_array( $type, $wpfilters );
138			}
139
140			$filters[$type] = $hide;
141		}
142
143		return $filters;
144	}
145
146	/**
147	 * Set the log reader to return only entries of the given type.
148	 * Type restrictions enforced here
149	 *
150	 * @param string|array $types Log types ('upload', 'delete', etc);
151	 *   empty string means no restriction
152	 */
153	private function limitType( $types ) {
154		$user = $this->getUser();
155		$restrictions = $this->getConfig()->get( 'LogRestrictions' );
156		// If $types is not an array, make it an array
157		$types = ( $types === '' ) ? [] : (array)$types;
158		// Don't even show header for private logs; don't recognize it...
159		$needReindex = false;
160		foreach ( $types as $type ) {
161			if ( isset( $restrictions[$type] )
162				&& !MediaWikiServices::getInstance()
163					->getPermissionManager()
164					->userHasRight( $user, $restrictions[$type] )
165			) {
166				$needReindex = true;
167				$types = array_diff( $types, [ $type ] );
168			}
169		}
170		if ( $needReindex ) {
171			// Lots of this code makes assumptions that
172			// the first entry in the array is $types[0].
173			$types = array_values( $types );
174		}
175		$this->types = $types;
176		// Don't show private logs to unprivileged users.
177		// Also, only show them upon specific request to avoid suprises.
178		$audience = $types ? 'user' : 'public';
179		$hideLogs = LogEventsList::getExcludeClause( $this->mDb, $audience, $user );
180		if ( $hideLogs !== false ) {
181			$this->mConds[] = $hideLogs;
182		}
183		if ( count( $types ) ) {
184			$this->mConds['log_type'] = $types;
185			// Set typeCGI; used in url param for paging
186			if ( count( $types ) == 1 ) {
187				$this->typeCGI = $types[0];
188			}
189		}
190	}
191
192	/**
193	 * Set the log reader to return only entries by the given user.
194	 *
195	 * @param string $name (In)valid user name
196	 * @return void
197	 */
198	private function limitPerformer( $name ) {
199		if ( $name == '' ) {
200			return;
201		}
202		$usertitle = Title::makeTitleSafe( NS_USER, $name );
203		if ( $usertitle === null ) {
204			return;
205		}
206		// Normalize username first so that non-existent users used
207		// in maintenance scripts work
208		$name = $usertitle->getText();
209
210		// Assume no joins required for log_user
211		$this->mConds[] = ActorMigration::newMigration()->getWhere(
212			wfGetDB( DB_REPLICA ), 'log_user', User::newFromName( $name, false )
213		)['conds'];
214
215		$this->enforcePerformerRestrictions();
216
217		$this->performer = $name;
218	}
219
220	/**
221	 * Set the log reader to return only entries affecting the given page.
222	 * (For the block and rights logs, this is a user page.)
223	 *
224	 * @param string|Title $page Title name
225	 * @param bool $pattern
226	 * @return void
227	 */
228	private function limitTitle( $page, $pattern ) {
229		if ( $page instanceof Title ) {
230			$title = $page;
231		} else {
232			$title = Title::newFromText( $page );
233			if ( strlen( $page ) == 0 || !$title instanceof Title ) {
234				return;
235			}
236		}
237
238		$this->title = $title->getPrefixedText();
239		$ns = $title->getNamespace();
240		$db = $this->mDb;
241
242		$interwikiDelimiter = $this->getConfig()->get( 'UserrightsInterwikiDelimiter' );
243
244		$doUserRightsLogLike = false;
245		if ( $this->types == [ 'rights' ] ) {
246			$parts = explode( $interwikiDelimiter, $title->getDBkey() );
247			if ( count( $parts ) == 2 ) {
248				list( $name, $database ) = array_map( 'trim', $parts );
249				if ( strstr( $database, '*' ) ) { // Search for wildcard in database name
250					$doUserRightsLogLike = true;
251				}
252			}
253		}
254
255		/**
256		 * Using the (log_namespace, log_title, log_timestamp) index with a
257		 * range scan (LIKE) on the first two parts, instead of simple equality,
258		 * makes it unusable for sorting.  Sorted retrieval using another index
259		 * would be possible, but then we might have to scan arbitrarily many
260		 * nodes of that index. Therefore, we need to avoid this if $wgMiserMode
261		 * is on.
262		 *
263		 * This is not a problem with simple title matches, because then we can
264		 * use the page_time index.  That should have no more than a few hundred
265		 * log entries for even the busiest pages, so it can be safely scanned
266		 * in full to satisfy an impossible condition on user or similar.
267		 */
268		$this->mConds['log_namespace'] = $ns;
269		if ( $doUserRightsLogLike ) {
270			$params = [ $name . $interwikiDelimiter ];
271			foreach ( explode( '*', $database ) as $databasepart ) {
272				$params[] = $databasepart;
273				$params[] = $db->anyString();
274			}
275			array_pop( $params ); // Get rid of the last % we added.
276			$this->mConds[] = 'log_title' . $db->buildLike( ...$params );
277		} elseif ( $pattern && !$this->getConfig()->get( 'MiserMode' ) ) {
278			$this->mConds[] = 'log_title' . $db->buildLike( $title->getDBkey(), $db->anyString() );
279			$this->pattern = $pattern;
280		} else {
281			$this->mConds['log_title'] = $title->getDBkey();
282		}
283		$this->enforceActionRestrictions();
284	}
285
286	/**
287	 * Set the log_action field to a specified value (or values)
288	 *
289	 * @param string $action
290	 */
291	private function limitAction( $action ) {
292		// Allow to filter the log by actions
293		$type = $this->typeCGI;
294		if ( $type === '' ) {
295			// nothing to do
296			return;
297		}
298		$actions = $this->getConfig()->get( 'ActionFilteredLogs' );
299		if ( isset( $actions[$type] ) ) {
300			// log type can be filtered by actions
301			$this->mLogEventsList->setAllowedActions( array_keys( $actions[$type] ) );
302			if ( $action !== '' && isset( $actions[$type][$action] ) ) {
303				// add condition to query
304				$this->mConds['log_action'] = $actions[$type][$action];
305				$this->action = $action;
306			}
307		}
308	}
309
310	/**
311	 * Limit to the (single) specified log ID.
312	 * @param int $logId The log entry ID.
313	 */
314	protected function limitLogId( $logId ) {
315		if ( !$logId ) {
316			return;
317		}
318		$this->mConds['log_id'] = $logId;
319	}
320
321	/**
322	 * Constructs the most part of the query. Extra conditions are sprinkled in
323	 * all over this class.
324	 * @return array
325	 */
326	public function getQueryInfo() {
327		$basic = DatabaseLogEntry::getSelectQueryData();
328
329		$tables = $basic['tables'];
330		$fields = $basic['fields'];
331		$conds = $basic['conds'];
332		$options = $basic['options'];
333		$joins = $basic['join_conds'];
334
335		# Add log_search table if there are conditions on it.
336		# This filters the results to only include log rows that have
337		# log_search records with the specified ls_field and ls_value values.
338		if ( array_key_exists( 'ls_field', $this->mConds ) ) {
339			$tables[] = 'log_search';
340			$options['IGNORE INDEX'] = [ 'log_search' => 'ls_log_id' ];
341			$options['USE INDEX'] = [ 'logging' => 'PRIMARY' ];
342			if ( !$this->hasEqualsClause( 'ls_field' )
343				|| !$this->hasEqualsClause( 'ls_value' )
344			) {
345				# Since (ls_field,ls_value,ls_logid) is unique, if the condition is
346				# to match a specific (ls_field,ls_value) tuple, then there will be
347				# no duplicate log rows. Otherwise, we need to remove the duplicates.
348				$options[] = 'DISTINCT';
349			}
350		}
351		# Don't show duplicate rows when using log_search
352		$joins['log_search'] = [ 'JOIN', 'ls_log_id=log_id' ];
353
354		// T221458: MySQL/MariaDB (10.1.37) can sometimes irrationally decide that querying `actor` before
355		// `logging` and filesorting is somehow better than querying $limit+1 rows from `logging`.
356		// Tell it not to reorder the query. But not when tag filtering or log_search was used, as it
357		// seems as likely to be harmed as helped in that case.
358		if ( !$this->mTagFilter && !array_key_exists( 'ls_field', $this->mConds ) ) {
359			$options[] = 'STRAIGHT_JOIN';
360		}
361		if ( $this->performer !== '' || $this->types !== [] ) {
362			// T223151, T237026: MariaDB's optimizer, at least 10.1, likes to choose a wildly bad plan for
363			// some reason for these code paths. Tell it not to use the wrong index it wants to pick.
364			$options['IGNORE INDEX'] = [ 'logging' => [ 'times' ] ];
365		}
366
367		$info = [
368			'tables' => $tables,
369			'fields' => $fields,
370			'conds' => array_merge( $conds, $this->mConds ),
371			'options' => $options,
372			'join_conds' => $joins,
373		];
374		# Add ChangeTags filter query
375		ChangeTags::modifyDisplayQuery( $info['tables'], $info['fields'], $info['conds'],
376			$info['join_conds'], $info['options'], $this->mTagFilter );
377
378		return $info;
379	}
380
381	/**
382	 * Checks if $this->mConds has $field matched to a *single* value
383	 * @param string $field
384	 * @return bool
385	 */
386	protected function hasEqualsClause( $field ) {
387		return (
388			array_key_exists( $field, $this->mConds ) &&
389			( !is_array( $this->mConds[$field] ) || count( $this->mConds[$field] ) == 1 )
390		);
391	}
392
393	public function getIndexField() {
394		return 'log_timestamp';
395	}
396
397	protected function getStartBody() {
398		# Do a link batch query
399		if ( $this->getNumRows() > 0 ) {
400			$lb = new LinkBatch;
401			foreach ( $this->mResult as $row ) {
402				$lb->add( $row->log_namespace, $row->log_title );
403				$lb->addObj( Title::makeTitleSafe( NS_USER, $row->user_name ) );
404				$lb->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->user_name ) );
405				$formatter = LogFormatter::newFromRow( $row );
406				foreach ( $formatter->getPreloadTitles() as $title ) {
407					$lb->addObj( $title );
408				}
409			}
410			$lb->execute();
411			$this->mResult->seek( 0 );
412		}
413
414		return '';
415	}
416
417	public function formatRow( $row ) {
418		return $this->mLogEventsList->logLine( $row );
419	}
420
421	public function getType() {
422		return $this->types;
423	}
424
425	/**
426	 * Guaranteed to either return a valid title string or a Zero-Length String
427	 *
428	 * @return string
429	 */
430	public function getPerformer() {
431		return $this->performer;
432	}
433
434	/**
435	 * @return string
436	 */
437	public function getPage() {
438		return $this->title;
439	}
440
441	/**
442	 * @return bool
443	 */
444	public function getPattern() {
445		return $this->pattern;
446	}
447
448	public function getYear() {
449		return $this->mYear;
450	}
451
452	public function getMonth() {
453		return $this->mMonth;
454	}
455
456	public function getDay() {
457		return $this->mDay;
458	}
459
460	public function getTagFilter() {
461		return $this->mTagFilter;
462	}
463
464	public function getAction() {
465		return $this->action;
466	}
467
468	public function doQuery() {
469		// Workaround MySQL optimizer bug
470		$this->mDb->setBigSelects();
471		parent::doQuery();
472		$this->mDb->setBigSelects( 'default' );
473	}
474
475	/**
476	 * Paranoia: avoid brute force searches (T19342)
477	 */
478	private function enforceActionRestrictions() {
479		if ( $this->actionRestrictionsEnforced ) {
480			return;
481		}
482		$this->actionRestrictionsEnforced = true;
483		$user = $this->getUser();
484		$permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
485		if ( !$permissionManager->userHasRight( $user, 'deletedhistory' ) ) {
486			$this->mConds[] = $this->mDb->bitAnd( 'log_deleted', LogPage::DELETED_ACTION ) . ' = 0';
487		} elseif ( !$permissionManager->userHasAnyRight( $user, 'suppressrevision', 'viewsuppressed' ) ) {
488			$this->mConds[] = $this->mDb->bitAnd( 'log_deleted', LogPage::SUPPRESSED_ACTION ) .
489				' != ' . LogPage::SUPPRESSED_USER;
490		}
491	}
492
493	/**
494	 * Paranoia: avoid brute force searches (T19342)
495	 */
496	private function enforcePerformerRestrictions() {
497		// Same as enforceActionRestrictions(), except for _USER instead of _ACTION bits.
498		if ( $this->performerRestrictionsEnforced ) {
499			return;
500		}
501		$this->performerRestrictionsEnforced = true;
502		$user = $this->getUser();
503		$permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
504		if ( !$permissionManager->userHasRight( $user, 'deletedhistory' ) ) {
505			$this->mConds[] = $this->mDb->bitAnd( 'log_deleted', LogPage::DELETED_USER ) . ' = 0';
506		} elseif ( !$permissionManager->userHasAnyRight( $user, 'suppressrevision', 'viewsuppressed' ) ) {
507			$this->mConds[] = $this->mDb->bitAnd( 'log_deleted', LogPage::SUPPRESSED_USER ) .
508				' != ' . LogPage::SUPPRESSED_ACTION;
509		}
510	}
511}
512