Issues (4122)

Security Analysis    not enabled

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  SQL Injection
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Header Injection
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Variable Injection
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

includes/actions/HistoryAction.php (5 issues)

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
 * Page history
4
 *
5
 * Split off from Article.php and Skin.php, 2003-12-22
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 Actions
24
 */
25
26
/**
27
 * This class handles printing the history page for an article. In order to
28
 * be efficient, it uses timestamps rather than offsets for paging, to avoid
29
 * costly LIMIT,offset queries.
30
 *
31
 * Construct it by passing in an Article, and call $h->history() to print the
32
 * history.
33
 *
34
 * @ingroup Actions
35
 */
36
class HistoryAction extends FormlessAction {
37
	const DIR_PREV = 0;
38
	const DIR_NEXT = 1;
39
40
	/** @var array Array of message keys and strings */
41
	public $message;
42
43
	public function getName() {
44
		return 'history';
45
	}
46
47
	public function requiresWrite() {
48
		return false;
49
	}
50
51
	public function requiresUnblock() {
52
		return false;
53
	}
54
55
	protected function getPageTitle() {
56
		return $this->msg( 'history-title', $this->getTitle()->getPrefixedText() )->text();
57
	}
58
59
	protected function getDescription() {
60
		// Creation of a subtitle link pointing to [[Special:Log]]
61
		return Linker::linkKnown(
62
			SpecialPage::getTitleFor( 'Log' ),
63
			$this->msg( 'viewpagelogs' )->escaped(),
64
			[],
65
			[ 'page' => $this->getTitle()->getPrefixedText() ]
66
		);
67
	}
68
69
	/**
70
	 * @return WikiPage|Article|ImagePage|CategoryPage|Page The Article object we are working on.
71
	 */
72
	public function getArticle() {
73
		return $this->page;
74
	}
75
76
	/**
77
	 * As we use the same small set of messages in various methods and that
78
	 * they are called often, we call them once and save them in $this->message
79
	 */
80
	private function preCacheMessages() {
81
		// Precache various messages
82
		if ( !isset( $this->message ) ) {
83
			$msgs = [ 'cur', 'last', 'pipe-separator' ];
84
			foreach ( $msgs as $msg ) {
85
				$this->message[$msg] = $this->msg( $msg )->escaped();
86
			}
87
		}
88
	}
89
90
	/**
91
	 * Print the history page for an article.
92
	 */
93
	function onView() {
94
		$out = $this->getOutput();
95
		$request = $this->getRequest();
96
97
		/**
98
		 * Allow client caching.
99
		 */
100
		if ( $out->checkLastModified( $this->page->getTouched() ) ) {
101
			return; // Client cache fresh and headers sent, nothing more to do.
102
		}
103
104
		$this->preCacheMessages();
105
		$config = $this->context->getConfig();
106
107
		# Fill in the file cache if not set already
108
		if ( HTMLFileCache::useFileCache( $this->getContext() ) ) {
109
			$cache = new HTMLFileCache( $this->getTitle(), 'history' );
110
			if ( !$cache->isCacheGood( /* Assume up to date */ ) ) {
111
				ob_start( [ &$cache, 'saveToFileCache' ] );
112
			}
113
		}
114
115
		// Setup page variables.
116
		$out->setFeedAppendQuery( 'action=history' );
117
		$out->addModules( 'mediawiki.action.history' );
118
		$out->addModuleStyles( [
119
			'mediawiki.action.history.styles',
120
			'mediawiki.special.changeslist',
121
		] );
122
		if ( $config->get( 'UseMediaWikiUIEverywhere' ) ) {
123
			$out = $this->getOutput();
124
			$out->addModuleStyles( [
125
				'mediawiki.ui.input',
126
				'mediawiki.ui.checkbox',
127
			] );
128
		}
129
130
		// Handle atom/RSS feeds.
131
		$feedType = $request->getVal( 'feed' );
132
		if ( $feedType ) {
133
			$this->feed( $feedType );
134
135
			return;
136
		}
137
138
		$this->addHelpLink( '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Page_history', true );
139
140
		// Fail nicely if article doesn't exist.
141
		if ( !$this->page->exists() ) {
142
			global $wgSend404Code;
143
			if ( $wgSend404Code ) {
144
				$out->setStatusCode( 404 );
145
			}
146
			$out->addWikiMsg( 'nohistory' );
147
			# show deletion/move log if there is an entry
148
			LogEventsList::showLogExtract(
149
				$out,
150
				[ 'delete', 'move' ],
151
				$this->getTitle(),
152
				'',
153
				[ 'lim' => 10,
154
					'conds' => [ "log_action != 'revision'" ],
155
					'showIfEmpty' => false,
156
					'msgKey' => [ 'moveddeleted-notice' ]
157
				]
158
			);
159
160
			return;
161
		}
162
163
		/**
164
		 * Add date selector to quickly get to a certain time
165
		 */
166
		$year = $request->getInt( 'year' );
167
		$month = $request->getInt( 'month' );
168
		$tagFilter = $request->getVal( 'tagfilter' );
169
		$tagSelector = ChangeTags::buildTagFilterSelector( $tagFilter );
170
171
		/**
172
		 * Option to show only revisions that have been (partially) hidden via RevisionDelete
173
		 */
174
		if ( $request->getBool( 'deleted' ) ) {
175
			$conds = [ 'rev_deleted != 0' ];
176
		} else {
177
			$conds = [];
178
		}
179
		if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) {
180
			$checkDeleted = Xml::checkLabel( $this->msg( 'history-show-deleted' )->text(),
181
				'deleted', 'mw-show-deleted-only', $request->getBool( 'deleted' ) ) . "\n";
182
		} else {
183
			$checkDeleted = '';
184
		}
185
186
		// Add the general form
187
		$action = htmlspecialchars( wfScript() );
188
		$out->addHTML(
189
			"<form action=\"$action\" method=\"get\" id=\"mw-history-searchform\">" .
190
			Xml::fieldset(
191
				$this->msg( 'history-fieldset-title' )->text(),
192
				false,
193
				[ 'id' => 'mw-history-search' ]
194
			) .
195
			Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . "\n" .
196
			Html::hidden( 'action', 'history' ) . "\n" .
197
			Xml::dateMenu(
198
				( $year == null ? MWTimestamp::getLocalInstance()->format( 'Y' ) : $year ),
199
				$month
200
			) . '&#160;' .
201
			( $tagSelector ? ( implode( '&#160;', $tagSelector ) . '&#160;' ) : '' ) .
202
			$checkDeleted .
203
			Html::submitButton(
204
				$this->msg( 'historyaction-submit' )->text(),
205
				[],
206
				[ 'mw-ui-progressive' ]
207
			) . "\n" .
208
			'</fieldset></form>'
209
		);
210
211
		Hooks::run( 'PageHistoryBeforeList', [ &$this->page, $this->getContext() ] );
212
213
		// Create and output the list.
214
		$pager = new HistoryPager( $this, $year, $month, $tagFilter, $conds );
215
		$out->addHTML(
216
			$pager->getNavigationBar() .
217
			$pager->getBody() .
218
			$pager->getNavigationBar()
219
		);
220
		$out->preventClickjacking( $pager->getPreventClickjacking() );
221
	}
222
223
	/**
224
	 * Fetch an array of revisions, specified by a given limit, offset and
225
	 * direction. This is now only used by the feeds. It was previously
226
	 * used by the main UI but that's now handled by the pager.
227
	 *
228
	 * @param int $limit The limit number of revisions to get
229
	 * @param int $offset
230
	 * @param int $direction Either self::DIR_PREV or self::DIR_NEXT
231
	 * @return ResultWrapper
232
	 */
233
	function fetchRevisions( $limit, $offset, $direction ) {
234
		// Fail if article doesn't exist.
235
		if ( !$this->getTitle()->exists() ) {
236
			return new FakeResultWrapper( [] );
237
		}
238
239
		$dbr = wfGetDB( DB_REPLICA );
240
241
		if ( $direction === self::DIR_PREV ) {
242
			list( $dirs, $oper ) = [ "ASC", ">=" ];
243
		} else { /* $direction === self::DIR_NEXT */
244
			list( $dirs, $oper ) = [ "DESC", "<=" ];
245
		}
246
247
		if ( $offset ) {
248
			$offsets = [ "rev_timestamp $oper " . $dbr->addQuotes( $dbr->timestamp( $offset ) ) ];
249
		} else {
250
			$offsets = [];
251
		}
252
253
		$page_id = $this->page->getId();
254
255
		return $dbr->select( 'revision',
256
			Revision::selectFields(),
257
			array_merge( [ 'rev_page' => $page_id ], $offsets ),
258
			__METHOD__,
259
			[ 'ORDER BY' => "rev_timestamp $dirs",
260
				'USE INDEX' => 'page_timestamp', 'LIMIT' => $limit ]
261
		);
262
	}
263
264
	/**
265
	 * Output a subscription feed listing recent edits to this page.
266
	 *
267
	 * @param string $type Feed type
268
	 */
269
	function feed( $type ) {
270
		if ( !FeedUtils::checkFeedOutput( $type ) ) {
271
			return;
272
		}
273
		$request = $this->getRequest();
274
275
		$feedClasses = $this->context->getConfig()->get( 'FeedClasses' );
276
		/** @var RSSFeed|AtomFeed $feed */
277
		$feed = new $feedClasses[$type](
278
			$this->getTitle()->getPrefixedText() . ' - ' .
279
			$this->msg( 'history-feed-title' )->inContentLanguage()->text(),
280
			$this->msg( 'history-feed-description' )->inContentLanguage()->text(),
281
			$this->getTitle()->getFullURL( 'action=history' )
282
		);
283
284
		// Get a limit on number of feed entries. Provide a sane default
285
		// of 10 if none is defined (but limit to $wgFeedLimit max)
286
		$limit = $request->getInt( 'limit', 10 );
287
		$limit = min(
288
			max( $limit, 1 ),
289
			$this->context->getConfig()->get( 'FeedLimit' )
290
		);
291
292
		$items = $this->fetchRevisions( $limit, 0, self::DIR_NEXT );
293
294
		// Generate feed elements enclosed between header and footer.
295
		$feed->outHeader();
296
		if ( $items->numRows() ) {
297
			foreach ( $items as $row ) {
298
				$feed->outItem( $this->feedItem( $row ) );
0 ignored issues
show
It seems like $row defined by $row on line 297 can be null; however, HistoryAction::feedItem() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
299
			}
300
		} else {
301
			$feed->outItem( $this->feedEmpty() );
302
		}
303
		$feed->outFooter();
304
	}
305
306
	function feedEmpty() {
307
		return new FeedItem(
308
			$this->msg( 'nohistory' )->inContentLanguage()->text(),
309
			$this->msg( 'history-feed-empty' )->inContentLanguage()->parseAsBlock(),
310
			$this->getTitle()->getFullURL(),
311
			wfTimestamp( TS_MW ),
0 ignored issues
show
It seems like wfTimestamp(TS_MW) targeting wfTimestamp() can also be of type false; however, FeedItem::__construct() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
312
			'',
313
			$this->getTitle()->getTalkPage()->getFullURL()
314
		);
315
	}
316
317
	/**
318
	 * Generate a FeedItem object from a given revision table row
319
	 * Borrows Recent Changes' feed generation functions for formatting;
320
	 * includes a diff to the previous revision (if any).
321
	 *
322
	 * @param stdClass|array $row Database row
323
	 * @return FeedItem
324
	 */
325
	function feedItem( $row ) {
326
		$rev = new Revision( $row );
327
		$rev->setTitle( $this->getTitle() );
328
		$text = FeedUtils::formatDiffRow(
329
			$this->getTitle(),
330
			$this->getTitle()->getPreviousRevisionID( $rev->getId() ),
331
			$rev->getId(),
332
			$rev->getTimestamp(),
333
			$rev->getComment()
334
		);
335
		if ( $rev->getComment() == '' ) {
336
			global $wgContLang;
337
			$title = $this->msg( 'history-feed-item-nocomment',
338
				$rev->getUserText(),
339
				$wgContLang->timeanddate( $rev->getTimestamp() ),
340
				$wgContLang->date( $rev->getTimestamp() ),
341
				$wgContLang->time( $rev->getTimestamp() ) )->inContentLanguage()->text();
342
		} else {
343
			$title = $rev->getUserText() .
344
				$this->msg( 'colon-separator' )->inContentLanguage()->text() .
345
				FeedItem::stripComment( $rev->getComment() );
346
		}
347
348
		return new FeedItem(
349
			$title,
350
			$text,
351
			$this->getTitle()->getFullURL( 'diff=' . $rev->getId() . '&oldid=prev' ),
352
			$rev->getTimestamp(),
0 ignored issues
show
It seems like $rev->getTimestamp() targeting Revision::getTimestamp() can also be of type false; however, FeedItem::__construct() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
353
			$rev->getUserText(),
0 ignored issues
show
It seems like $rev->getUserText() targeting Revision::getUserText() can also be of type boolean; however, FeedItem::__construct() does only seem to accept string, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
354
			$this->getTitle()->getTalkPage()->getFullURL()
355
		);
356
	}
357
}
358
359
/**
360
 * @ingroup Pager
361
 * @ingroup Actions
362
 */
363
class HistoryPager extends ReverseChronologicalPager {
364
	/**
365
	 * @var bool|stdClass
366
	 */
367
	public $lastRow = false;
368
369
	public $counter, $historyPage, $buttons, $conds;
370
371
	protected $oldIdChecked;
372
373
	protected $preventClickjacking = false;
374
	/**
375
	 * @var array
376
	 */
377
	protected $parentLens;
378
379
	/** @var bool Whether to show the tag editing UI */
380
	protected $showTagEditUI;
381
382
	/**
383
	 * @param HistoryAction $historyPage
384
	 * @param string $year
385
	 * @param string $month
386
	 * @param string $tagFilter
387
	 * @param array $conds
388
	 */
389
	function __construct( $historyPage, $year = '', $month = '', $tagFilter = '', $conds = [] ) {
390
		parent::__construct( $historyPage->getContext() );
391
		$this->historyPage = $historyPage;
392
		$this->tagFilter = $tagFilter;
0 ignored issues
show
The property tagFilter 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...
393
		$this->getDateCond( $year, $month );
394
		$this->conds = $conds;
395
		$this->showTagEditUI = ChangeTags::showTagEditingUI( $this->getUser() );
396
	}
397
398
	// For hook compatibility...
399
	function getArticle() {
400
		return $this->historyPage->getArticle();
401
	}
402
403
	function getSqlComment() {
404
		if ( $this->conds ) {
405
			return 'history page filtered'; // potentially slow, see CR r58153
406
		} else {
407
			return 'history page unfiltered';
408
		}
409
	}
410
411
	function getQueryInfo() {
412
		$queryInfo = [
413
			'tables' => [ 'revision', 'user' ],
414
			'fields' => array_merge( Revision::selectFields(), Revision::selectUserFields() ),
415
			'conds' => array_merge(
416
				[ 'rev_page' => $this->getWikiPage()->getId() ],
417
				$this->conds ),
418
			'options' => [ 'USE INDEX' => [ 'revision' => 'page_timestamp' ] ],
419
			'join_conds' => [ 'user' => Revision::userJoinCond() ],
420
		];
421
		ChangeTags::modifyDisplayQuery(
422
			$queryInfo['tables'],
423
			$queryInfo['fields'],
424
			$queryInfo['conds'],
425
			$queryInfo['join_conds'],
426
			$queryInfo['options'],
427
			$this->tagFilter
428
		);
429
		Hooks::run( 'PageHistoryPager::getQueryInfo', [ &$this, &$queryInfo ] );
430
431
		return $queryInfo;
432
	}
433
434
	function getIndexField() {
435
		return 'rev_timestamp';
436
	}
437
438
	/**
439
	 * @param stdClass $row
440
	 * @return string
441
	 */
442
	function formatRow( $row ) {
443
		if ( $this->lastRow ) {
444
			$latest = ( $this->counter == 1 && $this->mIsFirst );
445
			$firstInList = $this->counter == 1;
446
			$this->counter++;
447
448
			$notifTimestamp = $this->getConfig()->get( 'ShowUpdatedMarker' )
449
				? $this->getTitle()->getNotificationTimestamp( $this->getUser() )
450
				: false;
451
452
			$s = $this->historyLine(
453
				$this->lastRow, $row, $notifTimestamp, $latest, $firstInList );
454
		} else {
455
			$s = '';
456
		}
457
		$this->lastRow = $row;
458
459
		return $s;
460
	}
461
462
	function doBatchLookups() {
463
		if ( !Hooks::run( 'PageHistoryPager::doBatchLookups', [ $this, $this->mResult ] ) ) {
464
			return;
465
		}
466
467
		# Do a link batch query
468
		$this->mResult->seek( 0 );
469
		$batch = new LinkBatch();
470
		$revIds = [];
471
		foreach ( $this->mResult as $row ) {
472
			if ( $row->rev_parent_id ) {
473
				$revIds[] = $row->rev_parent_id;
474
			}
475
			if ( !is_null( $row->user_name ) ) {
476
				$batch->add( NS_USER, $row->user_name );
477
				$batch->add( NS_USER_TALK, $row->user_name );
478
			} else { # for anons or usernames of imported revisions
479
				$batch->add( NS_USER, $row->rev_user_text );
480
				$batch->add( NS_USER_TALK, $row->rev_user_text );
481
			}
482
		}
483
		$this->parentLens = Revision::getParentLengths( $this->mDb, $revIds );
484
		$batch->execute();
485
		$this->mResult->seek( 0 );
486
	}
487
488
	/**
489
	 * Creates begin of history list with a submit button
490
	 *
491
	 * @return string HTML output
492
	 */
493
	function getStartBody() {
494
		$this->lastRow = false;
495
		$this->counter = 1;
496
		$this->oldIdChecked = 0;
497
498
		$this->getOutput()->wrapWikiMsg( "<div class='mw-history-legend'>\n$1\n</div>", 'histlegend' );
499
		$s = Html::openElement( 'form', [ 'action' => wfScript(),
500
			'id' => 'mw-history-compare' ] ) . "\n";
501
		$s .= Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . "\n";
502
		$s .= Html::hidden( 'action', 'historysubmit' ) . "\n";
503
		$s .= Html::hidden( 'type', 'revision' ) . "\n";
504
505
		// Button container stored in $this->buttons for re-use in getEndBody()
506
		$this->buttons = '<div>';
507
		$className = 'historysubmit mw-history-compareselectedversions-button';
508
		$attrs = [ 'class' => $className ]
509
			+ Linker::tooltipAndAccesskeyAttribs( 'compareselectedversions' );
510
		$this->buttons .= $this->submitButton( $this->msg( 'compareselectedversions' )->text(),
511
			$attrs
512
		) . "\n";
513
514
		$user = $this->getUser();
515
		$actionButtons = '';
516
		if ( $user->isAllowed( 'deleterevision' ) ) {
517
			$actionButtons .= $this->getRevisionButton( 'revisiondelete', 'showhideselectedversions' );
518
		}
519
		if ( $this->showTagEditUI ) {
520
			$actionButtons .= $this->getRevisionButton( 'editchangetags', 'history-edit-tags' );
521
		}
522
		if ( $actionButtons ) {
523
			$this->buttons .= Xml::tags( 'div', [ 'class' =>
524
				'mw-history-revisionactions' ], $actionButtons );
525
		}
526
527
		if ( $user->isAllowed( 'deleterevision' ) || $this->showTagEditUI ) {
528
			$this->buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML();
529
		}
530
531
		$this->buttons .= '</div>';
532
533
		$s .= $this->buttons;
534
		$s .= '<ul id="pagehistory">' . "\n";
535
536
		return $s;
537
	}
538
539
	private function getRevisionButton( $name, $msg ) {
540
		$this->preventClickjacking();
541
		# Note bug #20966, <button> is non-standard in IE<8
542
		$element = Html::element(
543
			'button',
544
			[
545
				'type' => 'submit',
546
				'name' => $name,
547
				'value' => '1',
548
				'class' => "historysubmit mw-history-$name-button",
549
			],
550
			$this->msg( $msg )->text()
551
		) . "\n";
552
		return $element;
553
	}
554
555
	function getEndBody() {
556
		if ( $this->lastRow ) {
557
			$latest = $this->counter == 1 && $this->mIsFirst;
558
			$firstInList = $this->counter == 1;
559
			if ( $this->mIsBackwards ) {
560
				# Next row is unknown, but for UI reasons, probably exists if an offset has been specified
561
				if ( $this->mOffset == '' ) {
562
					$next = null;
563
				} else {
564
					$next = 'unknown';
565
				}
566
			} else {
567
				# The next row is the past-the-end row
568
				$next = $this->mPastTheEndRow;
569
			}
570
			$this->counter++;
571
572
			$notifTimestamp = $this->getConfig()->get( 'ShowUpdatedMarker' )
573
				? $this->getTitle()->getNotificationTimestamp( $this->getUser() )
574
				: false;
575
576
			$s = $this->historyLine(
577
				$this->lastRow, $next, $notifTimestamp, $latest, $firstInList );
578
		} else {
579
			$s = '';
580
		}
581
		$s .= "</ul>\n";
582
		# Add second buttons only if there is more than one rev
583
		if ( $this->getNumRows() > 2 ) {
584
			$s .= $this->buttons;
585
		}
586
		$s .= '</form>';
587
588
		return $s;
589
	}
590
591
	/**
592
	 * Creates a submit button
593
	 *
594
	 * @param string $message Text of the submit button, will be escaped
595
	 * @param array $attributes Attributes
596
	 * @return string HTML output for the submit button
597
	 */
598
	function submitButton( $message, $attributes = [] ) {
599
		# Disable submit button if history has 1 revision only
600
		if ( $this->getNumRows() > 1 ) {
601
			return Html::submitButton( $message, $attributes );
602
		} else {
603
			return '';
604
		}
605
	}
606
607
	/**
608
	 * Returns a row from the history printout.
609
	 *
610
	 * @todo document some more, and maybe clean up the code (some params redundant?)
611
	 *
612
	 * @param stdClass $row The database row corresponding to the previous line.
613
	 * @param mixed $next The database row corresponding to the next line
614
	 *   (chronologically previous)
615
	 * @param bool|string $notificationtimestamp
616
	 * @param bool $latest Whether this row corresponds to the page's latest revision.
617
	 * @param bool $firstInList Whether this row corresponds to the first
618
	 *   displayed on this history page.
619
	 * @return string HTML output for the row
620
	 */
621
	function historyLine( $row, $next, $notificationtimestamp = false,
622
		$latest = false, $firstInList = false ) {
623
		$rev = new Revision( $row );
624
		$rev->setTitle( $this->getTitle() );
625
626
		if ( is_object( $next ) ) {
627
			$prevRev = new Revision( $next );
628
			$prevRev->setTitle( $this->getTitle() );
629
		} else {
630
			$prevRev = null;
631
		}
632
633
		$curlink = $this->curLink( $rev, $latest );
634
		$lastlink = $this->lastLink( $rev, $next );
635
		$curLastlinks = $curlink . $this->historyPage->message['pipe-separator'] . $lastlink;
636
		$histLinks = Html::rawElement(
637
			'span',
638
			[ 'class' => 'mw-history-histlinks' ],
639
			$this->msg( 'parentheses' )->rawParams( $curLastlinks )->escaped()
640
		);
641
642
		$diffButtons = $this->diffButtons( $rev, $firstInList );
643
		$s = $histLinks . $diffButtons;
644
645
		$link = $this->revLink( $rev );
646
		$classes = [];
647
648
		$del = '';
649
		$user = $this->getUser();
650
		$canRevDelete = $user->isAllowed( 'deleterevision' );
651
		// Show checkboxes for each revision, to allow for revision deletion and
652
		// change tags
653
		if ( $canRevDelete || $this->showTagEditUI ) {
654
			$this->preventClickjacking();
655
			// If revision was hidden from sysops and we don't need the checkbox
656
			// for anything else, disable it
657 View Code Duplication
			if ( !$this->showTagEditUI && !$rev->userCan( Revision::DELETED_RESTRICTED, $user ) ) {
658
				$del = Xml::check( 'deleterevisions', false, [ 'disabled' => 'disabled' ] );
659
			// Otherwise, enable the checkbox...
660
			} else {
661
				$del = Xml::check( 'showhiderevisions', false,
662
					[ 'name' => 'ids[' . $rev->getId() . ']' ] );
663
			}
664
		// User can only view deleted revisions...
665
		} elseif ( $rev->getVisibility() && $user->isAllowed( 'deletedhistory' ) ) {
666
			// If revision was hidden from sysops, disable the link
667
			if ( !$rev->userCan( Revision::DELETED_RESTRICTED, $user ) ) {
668
				$del = Linker::revDeleteLinkDisabled( false );
669
			// Otherwise, show the link...
670
			} else {
671
				$query = [ 'type' => 'revision',
672
					'target' => $this->getTitle()->getPrefixedDBkey(), 'ids' => $rev->getId() ];
673
				$del .= Linker::revDeleteLink( $query,
674
					$rev->isDeleted( Revision::DELETED_RESTRICTED ), false );
675
			}
676
		}
677
		if ( $del ) {
678
			$s .= " $del ";
679
		}
680
681
		$lang = $this->getLanguage();
682
		$dirmark = $lang->getDirMark();
683
684
		$s .= " $link";
685
		$s .= $dirmark;
686
		$s .= " <span class='history-user'>" .
687
			Linker::revUserTools( $rev, true ) . "</span>";
688
		$s .= $dirmark;
689
690
		if ( $rev->isMinor() ) {
691
			$s .= ' ' . ChangesList::flag( 'minor', $this->getContext() );
692
		}
693
694
		# Sometimes rev_len isn't populated
695
		if ( $rev->getSize() !== null ) {
696
			# Size is always public data
697
			$prevSize = isset( $this->parentLens[$row->rev_parent_id] )
698
				? $this->parentLens[$row->rev_parent_id]
699
				: 0;
700
			$sDiff = ChangesList::showCharacterDifference( $prevSize, $rev->getSize() );
701
			$fSize = Linker::formatRevisionSize( $rev->getSize() );
702
			$s .= ' <span class="mw-changeslist-separator">. .</span> ' . "$fSize $sDiff";
703
		}
704
705
		# Text following the character difference is added just before running hooks
706
		$s2 = Linker::revComment( $rev, false, true );
707
708
		if ( $notificationtimestamp && ( $row->rev_timestamp >= $notificationtimestamp ) ) {
709
			$s2 .= ' <span class="updatedmarker">' . $this->msg( 'updatedmarker' )->escaped() . '</span>';
710
			$classes[] = 'mw-history-line-updated';
711
		}
712
713
		$tools = [];
714
715
		# Rollback and undo links
716
		if ( $prevRev && $this->getTitle()->quickUserCan( 'edit', $user ) ) {
717
			if ( $latest && $this->getTitle()->quickUserCan( 'rollback', $user ) ) {
718
				// Get a rollback link without the brackets
719
				$rollbackLink = Linker::generateRollback(
720
					$rev,
721
					$this->getContext(),
722
					[ 'verify', 'noBrackets' ]
723
				);
724
				if ( $rollbackLink ) {
725
					$this->preventClickjacking();
726
					$tools[] = $rollbackLink;
727
				}
728
			}
729
730
			if ( !$rev->isDeleted( Revision::DELETED_TEXT )
731
				&& !$prevRev->isDeleted( Revision::DELETED_TEXT )
732
			) {
733
				# Create undo tooltip for the first (=latest) line only
734
				$undoTooltip = $latest
735
					? [ 'title' => $this->msg( 'tooltip-undo' )->text() ]
736
					: [];
737
				$undolink = Linker::linkKnown(
738
					$this->getTitle(),
739
					$this->msg( 'editundo' )->escaped(),
740
					$undoTooltip,
741
					[
742
						'action' => 'edit',
743
						'undoafter' => $prevRev->getId(),
744
						'undo' => $rev->getId()
745
					]
746
				);
747
				$tools[] = "<span class=\"mw-history-undo\">{$undolink}</span>";
748
			}
749
		}
750
		// Allow extension to add their own links here
751
		Hooks::run( 'HistoryRevisionTools', [ $rev, &$tools, $prevRev, $user ] );
752
753
		if ( $tools ) {
754
			$s2 .= ' ' . $this->msg( 'parentheses' )->rawParams( $lang->pipeList( $tools ) )->escaped();
755
		}
756
757
		# Tags
758
		list( $tagSummary, $newClasses ) = ChangeTags::formatSummaryRow(
759
			$row->ts_tags,
760
			'history',
761
			$this->getContext()
762
		);
763
		$classes = array_merge( $classes, $newClasses );
764
		if ( $tagSummary !== '' ) {
765
			$s2 .= " $tagSummary";
766
		}
767
768
		# Include separator between character difference and following text
769
		if ( $s2 !== '' ) {
770
			$s .= ' <span class="mw-changeslist-separator">. .</span> ' . $s2;
771
		}
772
773
		Hooks::run( 'PageHistoryLineEnding', [ $this, &$row, &$s, &$classes ] );
774
775
		$attribs = [];
776
		if ( $classes ) {
777
			$attribs['class'] = implode( ' ', $classes );
778
		}
779
780
		return Xml::tags( 'li', $attribs, $s ) . "\n";
781
	}
782
783
	/**
784
	 * Create a link to view this revision of the page
785
	 *
786
	 * @param Revision $rev
787
	 * @return string
788
	 */
789
	function revLink( $rev ) {
790
		$date = $this->getLanguage()->userTimeAndDate( $rev->getTimestamp(), $this->getUser() );
791
		$date = htmlspecialchars( $date );
792
		if ( $rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
793
			$link = Linker::linkKnown(
794
				$this->getTitle(),
795
				$date,
796
				[ 'class' => 'mw-changeslist-date' ],
797
				[ 'oldid' => $rev->getId() ]
798
			);
799
		} else {
800
			$link = $date;
801
		}
802
		if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
803
			$link = "<span class=\"history-deleted\">$link</span>";
804
		}
805
806
		return $link;
807
	}
808
809
	/**
810
	 * Create a diff-to-current link for this revision for this page
811
	 *
812
	 * @param Revision $rev
813
	 * @param bool $latest This is the latest revision of the page?
814
	 * @return string
815
	 */
816
	function curLink( $rev, $latest ) {
817
		$cur = $this->historyPage->message['cur'];
818
		if ( $latest || !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
819
			return $cur;
820
		} else {
821
			return Linker::linkKnown(
822
				$this->getTitle(),
823
				$cur,
824
				[],
825
				[
826
					'diff' => $this->getWikiPage()->getLatest(),
827
					'oldid' => $rev->getId()
828
				]
829
			);
830
		}
831
	}
832
833
	/**
834
	 * Create a diff-to-previous link for this revision for this page.
835
	 *
836
	 * @param Revision $prevRev The revision being displayed
837
	 * @param stdClass|string|null $next The next revision in list (that is
838
	 *        the previous one in chronological order).
839
	 *        May either be a row, "unknown" or null.
840
	 * @return string
841
	 */
842
	function lastLink( $prevRev, $next ) {
843
		$last = $this->historyPage->message['last'];
844
845
		if ( $next === null ) {
846
			# Probably no next row
847
			return $last;
848
		}
849
850
		if ( $next === 'unknown' ) {
851
			# Next row probably exists but is unknown, use an oldid=prev link
852
			return Linker::linkKnown(
853
				$this->getTitle(),
854
				$last,
855
				[],
856
				[
857
					'diff' => $prevRev->getId(),
858
					'oldid' => 'prev'
859
				]
860
			);
861
		}
862
863
		$nextRev = new Revision( $next );
864
865
		if ( !$prevRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
866
			|| !$nextRev->userCan( Revision::DELETED_TEXT, $this->getUser() )
867
		) {
868
			return $last;
869
		}
870
871
		return Linker::linkKnown(
872
			$this->getTitle(),
873
			$last,
874
			[],
875
			[
876
				'diff' => $prevRev->getId(),
877
				'oldid' => $next->rev_id
878
			]
879
		);
880
	}
881
882
	/**
883
	 * Create radio buttons for page history
884
	 *
885
	 * @param Revision $rev
886
	 * @param bool $firstInList Is this version the first one?
887
	 *
888
	 * @return string HTML output for the radio buttons
889
	 */
890
	function diffButtons( $rev, $firstInList ) {
891
		if ( $this->getNumRows() > 1 ) {
892
			$id = $rev->getId();
893
			$radio = [ 'type' => 'radio', 'value' => $id ];
894
			/** @todo Move title texts to javascript */
895
			if ( $firstInList ) {
896
				$first = Xml::element( 'input',
897
					array_merge( $radio, [
898
						'style' => 'visibility:hidden',
899
						'name' => 'oldid',
900
						'id' => 'mw-oldid-null' ] )
901
				);
902
				$checkmark = [ 'checked' => 'checked' ];
903
			} else {
904
				# Check visibility of old revisions
905
				if ( !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
906
					$radio['disabled'] = 'disabled';
907
					$checkmark = []; // We will check the next possible one
908
				} elseif ( !$this->oldIdChecked ) {
909
					$checkmark = [ 'checked' => 'checked' ];
910
					$this->oldIdChecked = $id;
911
				} else {
912
					$checkmark = [];
913
				}
914
				$first = Xml::element( 'input',
915
					array_merge( $radio, $checkmark, [
916
						'name' => 'oldid',
917
						'id' => "mw-oldid-$id" ] ) );
918
				$checkmark = [];
919
			}
920
			$second = Xml::element( 'input',
921
				array_merge( $radio, $checkmark, [
922
					'name' => 'diff',
923
					'id' => "mw-diff-$id" ] ) );
924
925
			return $first . $second;
926
		} else {
927
			return '';
928
		}
929
	}
930
931
	/**
932
	 * This is called if a write operation is possible from the generated HTML
933
	 * @param bool $enable
934
	 */
935
	function preventClickjacking( $enable = true ) {
936
		$this->preventClickjacking = $enable;
937
	}
938
939
	/**
940
	 * Get the "prevent clickjacking" flag
941
	 * @return bool
942
	 */
943
	function getPreventClickjacking() {
944
		return $this->preventClickjacking;
945
	}
946
947
}
948