Completed
Branch master (939199)
by
unknown
39:35
created

includes/specials/pagers/ContribsPager.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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
22
/**
23
 * Pager for Special:Contributions
24
 * @ingroup Pager
25
 */
26
class ContribsPager extends ReverseChronologicalPager {
27
28
	public $mDefaultDirection = IndexPager::DIR_DESCENDING;
29
	public $messages;
30
	public $target;
31
	public $namespace = '';
32
	public $mDb;
33
	public $preventClickjacking = false;
34
35
	/** @var IDatabase */
36
	public $mDbSecondary;
37
38
	/**
39
	 * @var array
40
	 */
41
	protected $mParentLens;
42
43
	function __construct( IContextSource $context, array $options ) {
44
		parent::__construct( $context );
45
46
		$msgs = [
47
			'diff',
48
			'hist',
49
			'pipe-separator',
50
			'uctop'
51
		];
52
53
		foreach ( $msgs as $msg ) {
54
			$this->messages[$msg] = $this->msg( $msg )->escaped();
55
		}
56
57
		$this->target = isset( $options['target'] ) ? $options['target'] : '';
58
		$this->contribs = isset( $options['contribs'] ) ? $options['contribs'] : 'users';
59
		$this->namespace = isset( $options['namespace'] ) ? $options['namespace'] : '';
60
		$this->tagFilter = isset( $options['tagfilter'] ) ? $options['tagfilter'] : false;
61
		$this->nsInvert = isset( $options['nsInvert'] ) ? $options['nsInvert'] : false;
62
		$this->associated = isset( $options['associated'] ) ? $options['associated'] : false;
63
64
		$this->deletedOnly = !empty( $options['deletedOnly'] );
65
		$this->topOnly = !empty( $options['topOnly'] );
66
		$this->newOnly = !empty( $options['newOnly'] );
67
		$this->hideMinor = !empty( $options['hideMinor'] );
0 ignored issues
show
The property hideMinor does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
68
69
		$year = isset( $options['year'] ) ? $options['year'] : false;
70
		$month = isset( $options['month'] ) ? $options['month'] : false;
71
		$this->getDateCond( $year, $month );
72
73
		// Most of this code will use the 'contributions' group DB, which can map to replica DBs
74
		// with extra user based indexes or partioning by user. The additional metadata
75
		// queries should use a regular replica DB since the lookup pattern is not all by user.
76
		$this->mDbSecondary = wfGetDB( DB_REPLICA ); // any random replica DB
77
		$this->mDb = wfGetDB( DB_REPLICA, 'contributions' );
78
	}
79
80
	function getDefaultQuery() {
81
		$query = parent::getDefaultQuery();
82
		$query['target'] = $this->target;
83
84
		return $query;
85
	}
86
87
	/**
88
	 * This method basically executes the exact same code as the parent class, though with
89
	 * a hook added, to allow extensions to add additional queries.
90
	 *
91
	 * @param string $offset Index offset, inclusive
92
	 * @param int $limit Exact query limit
93
	 * @param bool $descending Query direction, false for ascending, true for descending
94
	 * @return ResultWrapper
95
	 */
96
	function reallyDoQuery( $offset, $limit, $descending ) {
97
		list( $tables, $fields, $conds, $fname, $options, $join_conds ) = $this->buildQueryInfo(
98
			$offset,
99
			$limit,
100
			$descending
101
		);
102
103
		/*
104
		 * This hook will allow extensions to add in additional queries, so they can get their data
105
		 * in My Contributions as well. Extensions should append their results to the $data array.
106
		 *
107
		 * Extension queries have to implement the navbar requirement as well. They should
108
		 * - have a column aliased as $pager->getIndexField()
109
		 * - have LIMIT set
110
		 * - have a WHERE-clause that compares the $pager->getIndexField()-equivalent column to the offset
111
		 * - have the ORDER BY specified based upon the details provided by the navbar
112
		 *
113
		 * See includes/Pager.php buildQueryInfo() method on how to build LIMIT, WHERE & ORDER BY
114
		 *
115
		 * &$data: an array of results of all contribs queries
116
		 * $pager: the ContribsPager object hooked into
117
		 * $offset: see phpdoc above
118
		 * $limit: see phpdoc above
119
		 * $descending: see phpdoc above
120
		 */
121
		$data = [ $this->mDb->select(
122
			$tables, $fields, $conds, $fname, $options, $join_conds
123
		) ];
124
		Hooks::run(
125
			'ContribsPager::reallyDoQuery',
126
			[ &$data, $this, $offset, $limit, $descending ]
127
		);
128
129
		$result = [];
130
131
		// loop all results and collect them in an array
132 View Code Duplication
		foreach ( $data as $query ) {
133
			foreach ( $query as $i => $row ) {
134
				// use index column as key, allowing us to easily sort in PHP
135
				$result[$row->{$this->getIndexField()} . "-$i"] = $row;
136
			}
137
		}
138
139
		// sort results
140
		if ( $descending ) {
141
			ksort( $result );
142
		} else {
143
			krsort( $result );
144
		}
145
146
		// enforce limit
147
		$result = array_slice( $result, 0, $limit );
148
149
		// get rid of array keys
150
		$result = array_values( $result );
151
152
		return new FakeResultWrapper( $result );
153
	}
154
155
	function getQueryInfo() {
156
		list( $tables, $index, $userCond, $join_cond ) = $this->getUserCond();
157
158
		$user = $this->getUser();
159
		$conds = array_merge( $userCond, $this->getNamespaceCond() );
160
161
		// Paranoia: avoid brute force searches (bug 17342)
162 View Code Duplication
		if ( !$user->isAllowed( 'deletedhistory' ) ) {
163
			$conds[] = $this->mDb->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0';
164
		} elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
165
			$conds[] = $this->mDb->bitAnd( 'rev_deleted', Revision::SUPPRESSED_USER ) .
166
				' != ' . Revision::SUPPRESSED_USER;
167
		}
168
169
		# Don't include orphaned revisions
170
		$join_cond['page'] = Revision::pageJoinCond();
171
		# Get the current user name for accounts
172
		$join_cond['user'] = Revision::userJoinCond();
173
174
		$options = [];
175
		if ( $index ) {
176
			$options['USE INDEX'] = [ 'revision' => $index ];
177
		}
178
179
		$queryInfo = [
180
			'tables' => $tables,
181
			'fields' => array_merge(
182
				Revision::selectFields(),
183
				Revision::selectUserFields(),
184
				[ 'page_namespace', 'page_title', 'page_is_new',
185
					'page_latest', 'page_is_redirect', 'page_len' ]
186
			),
187
			'conds' => $conds,
188
			'options' => $options,
189
			'join_conds' => $join_cond
190
		];
191
192
		ChangeTags::modifyDisplayQuery(
193
			$queryInfo['tables'],
194
			$queryInfo['fields'],
195
			$queryInfo['conds'],
196
			$queryInfo['join_conds'],
197
			$queryInfo['options'],
198
			$this->tagFilter
199
		);
200
201
		Hooks::run( 'ContribsPager::getQueryInfo', [ &$this, &$queryInfo ] );
202
203
		return $queryInfo;
204
	}
205
206
	function getUserCond() {
207
		$condition = [];
208
		$join_conds = [];
209
		$tables = [ 'revision', 'page', 'user' ];
210
		$index = false;
211
		if ( $this->contribs == 'newbie' ) {
212
			$max = $this->mDb->selectField( 'user', 'max(user_id)', false, __METHOD__ );
213
			$condition[] = 'rev_user >' . (int)( $max - $max / 100 );
214
			# ignore local groups with the bot right
215
			# @todo FIXME: Global groups may have 'bot' rights
216
			$groupsWithBotPermission = User::getGroupsWithPermission( 'bot' );
217
			if ( count( $groupsWithBotPermission ) ) {
218
				$tables[] = 'user_groups';
219
				$condition[] = 'ug_group IS NULL';
220
				$join_conds['user_groups'] = [
221
					'LEFT JOIN', [
222
						'ug_user = rev_user',
223
						'ug_group' => $groupsWithBotPermission
224
					]
225
				];
226
			}
227
			// (T140537) Disallow looking too far in the past for 'newbies' queries. If the user requested
228
			// a timestamp offset far in the past such that there are no edits by users with user_ids in
229
			// the range, we would end up scanning all revisions from that offset until start of time.
230
			$condition[] = 'rev_timestamp > ' .
231
				$this->mDb->addQuotes( $this->mDb->timestamp( wfTimestamp() - 30 * 24 * 60 * 60 ) );
232
		} else {
233
			$uid = User::idFromName( $this->target );
234
			if ( $uid ) {
235
				$condition['rev_user'] = $uid;
236
				$index = 'user_timestamp';
237
			} else {
238
				$condition['rev_user_text'] = $this->target;
239
				$index = 'usertext_timestamp';
240
			}
241
		}
242
243
		if ( $this->deletedOnly ) {
244
			$condition[] = 'rev_deleted != 0';
245
		}
246
247
		if ( $this->topOnly ) {
248
			$condition[] = 'rev_id = page_latest';
249
		}
250
251
		if ( $this->newOnly ) {
252
			$condition[] = 'rev_parent_id = 0';
253
		}
254
255
		if ( $this->hideMinor ) {
256
			$condition[] = 'rev_minor_edit = 0';
257
		}
258
259
		return [ $tables, $index, $condition, $join_conds ];
260
	}
261
262
	function getNamespaceCond() {
263
		if ( $this->namespace !== '' ) {
264
			$selectedNS = $this->mDb->addQuotes( $this->namespace );
265
			$eq_op = $this->nsInvert ? '!=' : '=';
266
			$bool_op = $this->nsInvert ? 'AND' : 'OR';
267
268
			if ( !$this->associated ) {
269
				return [ "page_namespace $eq_op $selectedNS" ];
270
			}
271
272
			$associatedNS = $this->mDb->addQuotes(
273
				MWNamespace::getAssociated( $this->namespace )
274
			);
275
276
			return [
277
				"page_namespace $eq_op $selectedNS " .
278
				$bool_op .
279
				" page_namespace $eq_op $associatedNS"
280
			];
281
		}
282
283
		return [];
284
	}
285
286
	function getIndexField() {
287
		return 'rev_timestamp';
288
	}
289
290
	function doBatchLookups() {
291
		# Do a link batch query
292
		$this->mResult->seek( 0 );
293
		$parentRevIds = [];
294
		$this->mParentLens = [];
295
		$batch = new LinkBatch();
296
		# Give some pointers to make (last) links
297
		foreach ( $this->mResult as $row ) {
298
			if ( isset( $row->rev_parent_id ) && $row->rev_parent_id ) {
299
				$parentRevIds[] = $row->rev_parent_id;
300
			}
301
			if ( isset( $row->rev_id ) ) {
302
				$this->mParentLens[$row->rev_id] = $row->rev_len;
303
				if ( $this->contribs === 'newbie' ) { // multiple users
304
					$batch->add( NS_USER, $row->user_name );
305
					$batch->add( NS_USER_TALK, $row->user_name );
306
				}
307
				$batch->add( $row->page_namespace, $row->page_title );
308
			}
309
		}
310
		# Fetch rev_len for revisions not already scanned above
311
		$this->mParentLens += Revision::getParentLengths(
312
			$this->mDbSecondary,
313
			array_diff( $parentRevIds, array_keys( $this->mParentLens ) )
314
		);
315
		$batch->execute();
316
		$this->mResult->seek( 0 );
317
	}
318
319
	/**
320
	 * @return string
321
	 */
322
	function getStartBody() {
323
		return "<ul class=\"mw-contributions-list\">\n";
324
	}
325
326
	/**
327
	 * @return string
328
	 */
329
	function getEndBody() {
330
		return "</ul>\n";
331
	}
332
333
	/**
334
	 * Generates each row in the contributions list.
335
	 *
336
	 * Contributions which are marked "top" are currently on top of the history.
337
	 * For these contributions, a [rollback] link is shown for users with roll-
338
	 * back privileges. The rollback link restores the most recent version that
339
	 * was not written by the target user.
340
	 *
341
	 * @todo This would probably look a lot nicer in a table.
342
	 * @param object $row
343
	 * @return string
344
	 */
345
	function formatRow( $row ) {
346
347
		$ret = '';
348
		$classes = [];
349
350
		/*
351
		 * There may be more than just revision rows. To make sure that we'll only be processing
352
		 * revisions here, let's _try_ to build a revision out of our row (without displaying
353
		 * notices though) and then trying to grab data from the built object. If we succeed,
354
		 * we're definitely dealing with revision data and we may proceed, if not, we'll leave it
355
		 * to extensions to subscribe to the hook to parse the row.
356
		 */
357
		MediaWiki\suppressWarnings();
358
		try {
359
			$rev = new Revision( $row );
360
			$validRevision = (bool)$rev->getId();
361
		} catch ( Exception $e ) {
362
			$validRevision = false;
363
		}
364
		MediaWiki\restoreWarnings();
365
366
		if ( $validRevision ) {
367
			$classes = [];
368
369
			$page = Title::newFromRow( $row );
370
			$link = Linker::link(
371
				$page,
372
				htmlspecialchars( $page->getPrefixedText() ),
373
				[ 'class' => 'mw-contributions-title' ],
374
				$page->isRedirect() ? [ 'redirect' => 'no' ] : []
375
			);
376
			# Mark current revisions
377
			$topmarktext = '';
378
			$user = $this->getUser();
379
			if ( $row->rev_id === $row->page_latest ) {
380
				$topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>';
381
				$classes[] = 'mw-contributions-current';
382
				# Add rollback link
383
				if ( !$row->page_is_new && $page->quickUserCan( 'rollback', $user )
384
					&& $page->quickUserCan( 'edit', $user )
385
				) {
386
					$this->preventClickjacking();
387
					$topmarktext .= ' ' . Linker::generateRollback( $rev, $this->getContext() );
388
				}
389
			}
390
			# Is there a visible previous revision?
391 View Code Duplication
			if ( $rev->userCan( Revision::DELETED_TEXT, $user ) && $rev->getParentId() !== 0 ) {
392
				$difftext = Linker::linkKnown(
393
					$page,
394
					$this->messages['diff'],
395
					[],
396
					[
397
						'diff' => 'prev',
398
						'oldid' => $row->rev_id
399
					]
400
				);
401
			} else {
402
				$difftext = $this->messages['diff'];
403
			}
404
			$histlink = Linker::linkKnown(
405
				$page,
406
				$this->messages['hist'],
407
				[],
408
				[ 'action' => 'history' ]
409
			);
410
411
			if ( $row->rev_parent_id === null ) {
412
				// For some reason rev_parent_id isn't populated for this row.
413
				// Its rumoured this is true on wikipedia for some revisions (bug 34922).
414
				// Next best thing is to have the total number of bytes.
415
				$chardiff = ' <span class="mw-changeslist-separator">. .</span> ';
416
				$chardiff .= Linker::formatRevisionSize( $row->rev_len );
417
				$chardiff .= ' <span class="mw-changeslist-separator">. .</span> ';
418
			} else {
419
				$parentLen = 0;
420
				if ( isset( $this->mParentLens[$row->rev_parent_id] ) ) {
421
					$parentLen = $this->mParentLens[$row->rev_parent_id];
422
				}
423
424
				$chardiff = ' <span class="mw-changeslist-separator">. .</span> ';
425
				$chardiff .= ChangesList::showCharacterDifference(
426
					$parentLen,
427
					$row->rev_len,
428
					$this->getContext()
429
				);
430
				$chardiff .= ' <span class="mw-changeslist-separator">. .</span> ';
431
			}
432
433
			$lang = $this->getLanguage();
434
			$comment = $lang->getDirMark() . Linker::revComment( $rev, false, true );
435
			$date = $lang->userTimeAndDate( $row->rev_timestamp, $user );
436
			if ( $rev->userCan( Revision::DELETED_TEXT, $user ) ) {
437
				$d = Linker::linkKnown(
438
					$page,
439
					htmlspecialchars( $date ),
440
					[ 'class' => 'mw-changeslist-date' ],
441
					[ 'oldid' => intval( $row->rev_id ) ]
442
				);
443
			} else {
444
				$d = htmlspecialchars( $date );
445
			}
446
			if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
447
				$d = '<span class="history-deleted">' . $d . '</span>';
448
			}
449
450
			# Show user names for /newbies as there may be different users.
451
			# Note that we already excluded rows with hidden user names.
452
			if ( $this->contribs == 'newbie' ) {
453
				$userlink = ' . . ' . $lang->getDirMark()
454
					. Linker::userLink( $rev->getUser(), $rev->getUserText() );
455
				$userlink .= ' ' . $this->msg( 'parentheses' )->rawParams(
456
						Linker::userTalkLink( $rev->getUser(), $rev->getUserText() ) )->escaped() . ' ';
457
			} else {
458
				$userlink = '';
459
			}
460
461
			$flags = [];
462
			if ( $rev->getParentId() === 0 ) {
463
				$flags[] = ChangesList::flag( 'newpage' );
464
			}
465
466
			if ( $rev->isMinor() ) {
467
				$flags[] = ChangesList::flag( 'minor' );
468
			}
469
470
			$del = Linker::getRevDeleteLink( $user, $rev, $page );
471
			if ( $del !== '' ) {
472
				$del .= ' ';
473
			}
474
475
			$diffHistLinks = $this->msg( 'parentheses' )
476
				->rawParams( $difftext . $this->messages['pipe-separator'] . $histlink )
477
				->escaped();
478
479
			# Tags, if any.
480
			list( $tagSummary, $newClasses ) = ChangeTags::formatSummaryRow(
481
				$row->ts_tags,
482
				'contributions',
483
				$this->getContext()
484
			);
485
			$classes = array_merge( $classes, $newClasses );
486
487
			Hooks::run( 'SpecialContributions::formatRow::flags', [ $this->getContext(), $row, &$flags ] );
488
489
			$templateParams = [
490
				'del' => $del,
491
				'timestamp' => $d,
492
				'diffHistLinks' => $diffHistLinks,
493
				'charDifference' => $chardiff,
494
				'flags' => $flags,
495
				'articleLink' => $link,
496
				'userlink' => $userlink,
497
				'logText' => $comment,
498
				'topmarktext' => $topmarktext,
499
				'tagSummary' => $tagSummary,
500
			];
501
502
			# Denote if username is redacted for this edit
503
			if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
504
				$templateParams['rev-deleted-user-contribs'] =
505
					$this->msg( 'rev-deleted-user-contribs' )->escaped();
506
			}
507
508
			$templateParser = new TemplateParser();
509
			$ret = $templateParser->processTemplate(
510
				'SpecialContributionsLine',
511
				$templateParams
512
			);
513
		}
514
515
		// Let extensions add data
516
		Hooks::run( 'ContributionsLineEnding', [ $this, &$ret, $row, &$classes ] );
517
518
		// TODO: Handle exceptions in the catch block above.  Do any extensions rely on
519
		// receiving empty rows?
520
521
		if ( $classes === [] && $ret === '' ) {
522
			wfDebug( "Dropping Special:Contribution row that could not be formatted\n" );
523
			return "<!-- Could not format Special:Contribution row. -->\n";
524
		}
525
526
		// FIXME: The signature of the ContributionsLineEnding hook makes it
527
		// very awkward to move this LI wrapper into the template.
528
		return Html::rawElement( 'li', [ 'class' => $classes ], $ret ) . "\n";
529
	}
530
531
	/**
532
	 * Overwrite Pager function and return a helpful comment
533
	 * @return string
534
	 */
535
	function getSqlComment() {
536
		if ( $this->namespace || $this->deletedOnly ) {
537
			// potentially slow, see CR r58153
538
			return 'contributions page filtered for namespace or RevisionDeleted edits';
539
		} else {
540
			return 'contributions page unfiltered';
541
		}
542
	}
543
544
	protected function preventClickjacking() {
545
		$this->preventClickjacking = true;
546
	}
547
548
	/**
549
	 * @return bool
550
	 */
551
	public function getPreventClickjacking() {
552
		return $this->preventClickjacking;
553
	}
554
}
555