1<?php
2/**
3 * Implements Special:Undelete
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup SpecialPage
22 */
23
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Revision\RevisionFactory;
26use MediaWiki\Revision\RevisionRecord;
27use MediaWiki\Revision\SlotRecord;
28use MediaWiki\Storage\NameTableAccessException;
29use Wikimedia\Rdbms\IResultWrapper;
30
31/**
32 * Special page allowing users with the appropriate permissions to view
33 * and restore deleted content.
34 *
35 * @ingroup SpecialPage
36 */
37class SpecialUndelete extends SpecialPage {
38	private $mAction;
39	private $mTarget;
40	private $mTimestamp;
41	private $mRestore;
42	private $mRevdel;
43	private $mInvert;
44	private $mFilename;
45	private $mTargetTimestamp;
46	private $mAllowed;
47	private $mCanView;
48	private $mComment;
49	private $mToken;
50	/** @var bool|null */
51	private $mPreview;
52	/** @var bool|null */
53	private $mDiff;
54	/** @var bool|null */
55	private $mDiffOnly;
56	/** @var bool|null */
57	private $mUnsuppress;
58	/** @var int[]|null */
59	private $mFileVersions;
60
61	/** @var Title */
62	private $mTargetObj;
63	/**
64	 * @var string Search prefix
65	 */
66	private $mSearchPrefix;
67
68	public function __construct() {
69		parent::__construct( 'Undelete', 'deletedhistory' );
70	}
71
72	public function doesWrites() {
73		return true;
74	}
75
76	private function loadRequest( $par ) {
77		$request = $this->getRequest();
78		$user = $this->getUser();
79
80		$this->mAction = $request->getVal( 'action' );
81		if ( $par !== null && $par !== '' ) {
82			$this->mTarget = $par;
83		} else {
84			$this->mTarget = $request->getVal( 'target' );
85		}
86
87		$this->mTargetObj = null;
88
89		if ( $this->mTarget !== null && $this->mTarget !== '' ) {
90			$this->mTargetObj = Title::newFromText( $this->mTarget );
91		}
92
93		$this->mSearchPrefix = $request->getText( 'prefix' );
94		$time = $request->getVal( 'timestamp' );
95		$this->mTimestamp = $time ? wfTimestamp( TS_MW, $time ) : '';
96		$this->mFilename = $request->getVal( 'file' );
97
98		$posted = $request->wasPosted() &&
99			$user->matchEditToken( $request->getVal( 'wpEditToken' ) );
100		$this->mRestore = $request->getCheck( 'restore' ) && $posted;
101		$this->mRevdel = $request->getCheck( 'revdel' ) && $posted;
102		$this->mInvert = $request->getCheck( 'invert' ) && $posted;
103		$this->mPreview = $request->getCheck( 'preview' ) && $posted;
104		$this->mDiff = $request->getCheck( 'diff' );
105		$this->mDiffOnly = $request->getBool( 'diffonly', $this->getUser()->getOption( 'diffonly' ) );
106		$this->mComment = $request->getText( 'wpComment' );
107		$this->mUnsuppress = $request->getVal( 'wpUnsuppress' ) && MediaWikiServices::getInstance()
108				->getPermissionManager()
109				->userHasRight( $user, 'suppressrevision' );
110		$this->mToken = $request->getVal( 'token' );
111
112		if ( $this->isAllowed( 'undelete' ) ) {
113			$this->mAllowed = true; // user can restore
114			$this->mCanView = true; // user can view content
115		} elseif ( $this->isAllowed( 'deletedtext' ) ) {
116			$this->mAllowed = false; // user cannot restore
117			$this->mCanView = true; // user can view content
118			$this->mRestore = false;
119		} else { // user can only view the list of revisions
120			$this->mAllowed = false;
121			$this->mCanView = false;
122			$this->mTimestamp = '';
123			$this->mRestore = false;
124		}
125
126		if ( $this->mRestore || $this->mInvert ) {
127			$timestamps = [];
128			$this->mFileVersions = [];
129			foreach ( $request->getValues() as $key => $val ) {
130				$matches = [];
131				if ( preg_match( '/^ts(\d{14})$/', $key, $matches ) ) {
132					array_push( $timestamps, $matches[1] );
133				}
134
135				if ( preg_match( '/^fileid(\d+)$/', $key, $matches ) ) {
136					$this->mFileVersions[] = intval( $matches[1] );
137				}
138			}
139			rsort( $timestamps );
140			$this->mTargetTimestamp = $timestamps;
141		}
142	}
143
144	/**
145	 * Checks whether a user is allowed the permission for the
146	 * specific title if one is set.
147	 *
148	 * @param string $permission
149	 * @param User|null $user
150	 * @return bool
151	 */
152	protected function isAllowed( $permission, User $user = null ) {
153		$user = $user ?: $this->getUser();
154		$permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
155		$block = $user->getBlock();
156
157		if ( $this->mTargetObj !== null ) {
158			return $permissionManager->userCan( $permission, $user, $this->mTargetObj );
159		} else {
160			$hasRight = $permissionManager->userHasRight( $user, $permission );
161			$sitewideBlock = $block && $block->isSitewide();
162			return $permission === 'undelete' ? ( $hasRight && !$sitewideBlock ) : $hasRight;
163		}
164	}
165
166	public function userCanExecute( User $user ) {
167		return $this->isAllowed( $this->mRestriction, $user );
168	}
169
170	/**
171	 * @inheritDoc
172	 */
173	public function checkPermissions() {
174		$user = $this->getUser();
175
176		// First check if user has the right to use this page. If not,
177		// show a permissions error whether they are blocked or not.
178		if ( !parent::userCanExecute( $user ) ) {
179			$this->displayRestrictionError();
180		}
181
182		// If a user has the right to use this page, but is blocked from
183		// the target, show a block error.
184		if (
185			$this->mTargetObj && MediaWikiServices::getInstance()
186				->getPermissionManager()
187				->isBlockedFrom( $user, $this->mTargetObj )
188		) {
189			throw new UserBlockedError( $user->getBlock() );
190		}
191
192		// Finally, do the comprehensive permission check via isAllowed.
193		if ( !$this->userCanExecute( $user ) ) {
194			$this->displayRestrictionError();
195		}
196	}
197
198	public function execute( $par ) {
199		$this->useTransactionalTimeLimit();
200
201		$user = $this->getUser();
202
203		$this->setHeaders();
204		$this->outputHeader();
205		$this->addHelpLink( 'Help:Deletion_and_undeletion' );
206
207		$this->loadRequest( $par );
208		$this->checkPermissions(); // Needs to be after mTargetObj is set
209
210		$out = $this->getOutput();
211
212		if ( $this->mTargetObj === null ) {
213			$out->addWikiMsg( 'undelete-header' );
214
215			# Not all users can just browse every deleted page from the list
216			if ( MediaWikiServices::getInstance()
217					->getPermissionManager()
218					->userHasRight( $user, 'browsearchive' )
219			) {
220				$this->showSearchForm();
221			}
222
223			return;
224		}
225
226		$this->addHelpLink( 'Help:Undelete' );
227		if ( $this->mAllowed ) {
228			$out->setPageTitle( $this->msg( 'undeletepage' ) );
229		} else {
230			$out->setPageTitle( $this->msg( 'viewdeletedpage' ) );
231		}
232
233		$this->getSkin()->setRelevantTitle( $this->mTargetObj );
234
235		if ( $this->mTimestamp !== '' ) {
236			$this->showRevision( $this->mTimestamp );
237		} elseif ( $this->mFilename !== null && $this->mTargetObj->inNamespace( NS_FILE ) ) {
238			$file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename );
239			// Check if user is allowed to see this file
240			if ( !$file->exists() ) {
241				$out->addWikiMsg( 'filedelete-nofile', $this->mFilename );
242			} elseif ( !$file->userCan( File::DELETED_FILE, $user ) ) {
243				if ( $file->isDeleted( File::DELETED_RESTRICTED ) ) {
244					throw new PermissionsError( 'suppressrevision' );
245				} else {
246					throw new PermissionsError( 'deletedtext' );
247				}
248			} elseif ( !$user->matchEditToken( $this->mToken, $this->mFilename ) ) {
249				$this->showFileConfirmationForm( $this->mFilename );
250			} else {
251				$this->showFile( $this->mFilename );
252			}
253		} elseif ( $this->mAction === 'submit' ) {
254			if ( $this->mRestore ) {
255				$this->undelete();
256			} elseif ( $this->mRevdel ) {
257				$this->redirectToRevDel();
258			}
259
260		} else {
261			$this->showHistory();
262		}
263	}
264
265	/**
266	 * Convert submitted form data to format expected by RevisionDelete and
267	 * redirect the request
268	 */
269	private function redirectToRevDel() {
270		$archive = new PageArchive( $this->mTargetObj );
271
272		$revisions = [];
273
274		foreach ( $this->getRequest()->getValues() as $key => $val ) {
275			$matches = [];
276			if ( preg_match( "/^ts(\d{14})$/", $key, $matches ) ) {
277				$revisionRecord = $archive->getRevisionRecordByTimestamp( $matches[1] );
278				if ( $revisionRecord ) {
279					// Can return null
280					$revisions[ $revisionRecord->getId() ] = 1;
281				}
282			}
283		}
284
285		$query = [
286			'type' => 'revision',
287			'ids' => $revisions,
288			'target' => $this->mTargetObj->getPrefixedText()
289		];
290		$url = SpecialPage::getTitleFor( 'Revisiondelete' )->getFullURL( $query );
291		$this->getOutput()->redirect( $url );
292	}
293
294	private function showSearchForm() {
295		$out = $this->getOutput();
296		$out->setPageTitle( $this->msg( 'undelete-search-title' ) );
297		$fuzzySearch = $this->getRequest()->getVal( 'fuzzy', true );
298
299		$out->enableOOUI();
300
301		$fields = [];
302		$fields[] = new OOUI\ActionFieldLayout(
303			new OOUI\TextInputWidget( [
304				'name' => 'prefix',
305				'inputId' => 'prefix',
306				'infusable' => true,
307				'value' => $this->mSearchPrefix,
308				'autofocus' => true,
309			] ),
310			new OOUI\ButtonInputWidget( [
311				'label' => $this->msg( 'undelete-search-submit' )->text(),
312				'flags' => [ 'primary', 'progressive' ],
313				'inputId' => 'searchUndelete',
314				'type' => 'submit',
315			] ),
316			[
317				'label' => new OOUI\HtmlSnippet(
318					$this->msg(
319						$fuzzySearch ? 'undelete-search-full' : 'undelete-search-prefix'
320					)->parse()
321				),
322				'align' => 'left',
323			]
324		);
325
326		$fieldset = new OOUI\FieldsetLayout( [
327			'label' => $this->msg( 'undelete-search-box' )->text(),
328			'items' => $fields,
329		] );
330
331		$form = new OOUI\FormLayout( [
332			'method' => 'get',
333			'action' => wfScript(),
334		] );
335
336		$form->appendContent(
337			$fieldset,
338			new OOUI\HtmlSnippet(
339				Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
340				Html::hidden( 'fuzzy', $fuzzySearch )
341			)
342		);
343
344		$out->addHTML(
345			new OOUI\PanelLayout( [
346				'expanded' => false,
347				'padded' => true,
348				'framed' => true,
349				'content' => $form,
350			] )
351		);
352
353		# List undeletable articles
354		if ( $this->mSearchPrefix ) {
355			// For now, we enable search engine match only when specifically asked to
356			// by using fuzzy=1 parameter.
357			if ( $fuzzySearch ) {
358				$result = PageArchive::listPagesBySearch( $this->mSearchPrefix );
359			} else {
360				$result = PageArchive::listPagesByPrefix( $this->mSearchPrefix );
361			}
362			$this->showList( $result );
363		}
364	}
365
366	/**
367	 * Generic list of deleted pages
368	 *
369	 * @param IResultWrapper $result
370	 * @return bool
371	 */
372	private function showList( $result ) {
373		$out = $this->getOutput();
374
375		if ( $result->numRows() == 0 ) {
376			$out->addWikiMsg( 'undelete-no-results' );
377
378			return false;
379		}
380
381		$out->addWikiMsg( 'undeletepagetext', $this->getLanguage()->formatNum( $result->numRows() ) );
382
383		$linkRenderer = $this->getLinkRenderer();
384		$undelete = $this->getPageTitle();
385		$out->addHTML( "<ul id='undeleteResultsList'>\n" );
386		foreach ( $result as $row ) {
387			$title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
388			if ( $title !== null ) {
389				$item = $linkRenderer->makeKnownLink(
390					$undelete,
391					$title->getPrefixedText(),
392					[],
393					[ 'target' => $title->getPrefixedText() ]
394				);
395			} else {
396				// The title is no longer valid, show as text
397				$item = Html::element(
398					'span',
399					[ 'class' => 'mw-invalidtitle' ],
400					Linker::getInvalidTitleDescription(
401						$this->getContext(),
402						$row->ar_namespace,
403						$row->ar_title
404					)
405				);
406			}
407			$revs = $this->msg( 'undeleterevisions' )->numParams( $row->count )->parse();
408			$out->addHTML(
409				Html::rawElement(
410					'li',
411					[ 'class' => 'undeleteResult' ],
412					"{$item} ({$revs})"
413				)
414			);
415		}
416		$result->free();
417		$out->addHTML( "</ul>\n" );
418
419		return true;
420	}
421
422	private function showRevision( $timestamp ) {
423		if ( !preg_match( '/[0-9]{14}/', $timestamp ) ) {
424			return;
425		}
426
427		$archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
428		if ( !$this->getHookRunner()->onUndeleteForm__showRevision(
429			$archive, $this->mTargetObj )
430		) {
431			return;
432		}
433		$revRecord = $archive->getRevisionRecordByTimestamp( $timestamp );
434
435		$out = $this->getOutput();
436		$user = $this->getUser();
437
438		if ( !$revRecord ) {
439			$out->addWikiMsg( 'undeleterevision-missing' );
440
441			return;
442		}
443
444		if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
445			if ( !RevisionRecord::userCanBitfield(
446				$revRecord->getVisibility(),
447				RevisionRecord::DELETED_TEXT,
448				$user
449			) ) {
450				$out->wrapWikiMsg(
451					"<div class='mw-warning plainlinks'>\n$1\n</div>\n",
452				$revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ?
453					'rev-suppressed-text-permission' : 'rev-deleted-text-permission'
454				);
455
456				return;
457			}
458
459			$out->wrapWikiMsg(
460				"<div class='mw-warning plainlinks'>\n$1\n</div>\n",
461				$revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ?
462					'rev-suppressed-text-view' : 'rev-deleted-text-view'
463			);
464			$out->addHTML( '<br />' );
465			// and we are allowed to see...
466		}
467
468		if ( $this->mDiff ) {
469			$previousRevRecord = $archive->getPreviousRevisionRecord( $timestamp );
470			if ( $previousRevRecord ) {
471				$this->showDiff( $previousRevRecord, $revRecord );
472				if ( $this->mDiffOnly ) {
473					return;
474				}
475
476				$out->addHTML( '<hr />' );
477			} else {
478				$out->addWikiMsg( 'undelete-nodiff' );
479			}
480		}
481
482		$link = $this->getLinkRenderer()->makeKnownLink(
483			$this->getPageTitle( $this->mTargetObj->getPrefixedDBkey() ),
484			$this->mTargetObj->getPrefixedText()
485		);
486
487		$lang = $this->getLanguage();
488
489		// date and time are separate parameters to facilitate localisation.
490		// $time is kept for backward compat reasons.
491		$time = $lang->userTimeAndDate( $timestamp, $user );
492		$d = $lang->userDate( $timestamp, $user );
493		$t = $lang->userTime( $timestamp, $user );
494		$userLink = Linker::revUserTools( $revRecord );
495
496		$content = $revRecord->getContent(
497			SlotRecord::MAIN,
498			RevisionRecord::FOR_THIS_USER,
499			$user
500		);
501
502		// TODO: MCR: this will have to become something like $hasTextSlots and $hasNonTextSlots
503		$isText = ( $content instanceof TextContent );
504
505		if ( $this->mPreview || $isText ) {
506			$openDiv = '<div id="mw-undelete-revision" class="mw-warning">';
507		} else {
508			$openDiv = '<div id="mw-undelete-revision">';
509		}
510		$out->addHTML( $openDiv );
511
512		// Revision delete links
513		if ( !$this->mDiff ) {
514			$revdel = Linker::getRevDeleteLink(
515				$user,
516				$revRecord,
517				$this->mTargetObj
518			);
519			if ( $revdel ) {
520				$out->addHTML( "$revdel " );
521			}
522		}
523
524		$out->addWikiMsg(
525			'undelete-revision',
526			Message::rawParam( $link ), $time,
527			Message::rawParam( $userLink ), $d, $t
528		);
529		$out->addHTML( '</div>' );
530
531		// Hook hard deprecated since 1.35
532		if ( $this->getHookContainer()->isRegistered( 'UndeleteShowRevision' ) ) {
533			// Only create the Revision object if needed
534			$rev = new Revision( $revRecord );
535			if ( !$this->getHookRunner()->onUndeleteShowRevision(
536				$this->mTargetObj,
537				$rev
538			) ) {
539				return;
540			}
541		}
542
543		if ( $this->mPreview || !$isText ) {
544			// NOTE: non-text content has no source view, so always use rendered preview
545
546			$popts = $out->parserOptions();
547			$renderer = MediaWikiServices::getInstance()->getRevisionRenderer();
548
549			$rendered = $renderer->getRenderedRevision(
550				$revRecord,
551				$popts,
552				$user,
553				[ 'audience' => RevisionRecord::FOR_THIS_USER ]
554			);
555
556			// Fail hard if the audience check fails, since we already checked
557			// at the beginning of this method.
558			$pout = $rendered->getRevisionParserOutput();
559
560			$out->addParserOutput( $pout, [
561				'enableSectionEditLinks' => false,
562			] );
563		}
564
565		$out->enableOOUI();
566		$buttonFields = [];
567
568		if ( $isText ) {
569			'@phan-var TextContent $content';
570			// TODO: MCR: make this work for multiple slots
571			// source view for textual content
572			$sourceView = Xml::element( 'textarea', [
573				'readonly' => 'readonly',
574				'cols' => 80,
575				'rows' => 25
576			], $content->getText() . "\n" );
577
578			$buttonFields[] = new OOUI\ButtonInputWidget( [
579				'type' => 'submit',
580				'name' => 'preview',
581				'label' => $this->msg( 'showpreview' )->text()
582			] );
583		} else {
584			$sourceView = '';
585		}
586
587		$buttonFields[] = new OOUI\ButtonInputWidget( [
588			'name' => 'diff',
589			'type' => 'submit',
590			'label' => $this->msg( 'showdiff' )->text()
591		] );
592
593		$out->addHTML(
594			$sourceView .
595				Xml::openElement( 'div', [
596					'style' => 'clear: both' ] ) .
597				Xml::openElement( 'form', [
598					'method' => 'post',
599					'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ) ] ) .
600				Xml::element( 'input', [
601					'type' => 'hidden',
602					'name' => 'target',
603					'value' => $this->mTargetObj->getPrefixedDBkey() ] ) .
604				Xml::element( 'input', [
605					'type' => 'hidden',
606					'name' => 'timestamp',
607					'value' => $timestamp ] ) .
608				Xml::element( 'input', [
609					'type' => 'hidden',
610					'name' => 'wpEditToken',
611					'value' => $user->getEditToken() ] ) .
612				new OOUI\FieldLayout(
613					new OOUI\Widget( [
614						'content' => new OOUI\HorizontalLayout( [
615							'items' => $buttonFields
616						] )
617					] )
618				) .
619				Xml::closeElement( 'form' ) .
620				Xml::closeElement( 'div' )
621		);
622	}
623
624	/**
625	 * Build a diff display between this and the previous either deleted
626	 * or non-deleted edit.
627	 *
628	 * @param RevisionRecord $previousRevRecord
629	 * @param RevisionRecord $currentRevRecord
630	 */
631	private function showDiff(
632		RevisionRecord $previousRevRecord,
633		RevisionRecord $currentRevRecord
634	) {
635		$currentTitle = Title::newFromLinkTarget( $currentRevRecord->getPageAsLinkTarget() );
636
637		$diffContext = clone $this->getContext();
638		$diffContext->setTitle( $currentTitle );
639		$diffContext->setWikiPage( WikiPage::factory( $currentTitle ) );
640
641		$contentModel = $currentRevRecord->getSlot(
642			SlotRecord::MAIN,
643			RevisionRecord::RAW
644		)->getModel();
645
646		$diffEngine = MediaWikiServices::getInstance()
647			->getContentHandlerFactory()
648			->getContentHandler( $contentModel )
649			->createDifferenceEngine( $diffContext );
650
651		$diffEngine->setRevisions( $previousRevRecord, $currentRevRecord );
652		$diffEngine->showDiffStyle();
653		$formattedDiff = $diffEngine->getDiff(
654			$this->diffHeader( $previousRevRecord, 'o' ),
655			$this->diffHeader( $currentRevRecord, 'n' )
656		);
657
658		$this->getOutput()->addHTML( "<div>$formattedDiff</div>\n" );
659	}
660
661	/**
662	 * @param RevisionRecord $revRecord
663	 * @param string $prefix
664	 * @return string
665	 */
666	private function diffHeader( RevisionRecord $revRecord, $prefix ) {
667		$isDeleted = !( $revRecord->getId() && $revRecord->getPageAsLinkTarget() );
668		if ( $isDeleted ) {
669			/// @todo FIXME: $rev->getTitle() is null for deleted revs...?
670			$targetPage = $this->getPageTitle();
671			$targetQuery = [
672				'target' => $this->mTargetObj->getPrefixedText(),
673				'timestamp' => wfTimestamp( TS_MW, $revRecord->getTimestamp() )
674			];
675		} else {
676			/// @todo FIXME: getId() may return non-zero for deleted revs...
677			$targetPage = $revRecord->getPageAsLinkTarget();
678			$targetQuery = [ 'oldid' => $revRecord->getId() ];
679		}
680
681		// Add show/hide deletion links if available
682		$user = $this->getUser();
683		$lang = $this->getLanguage();
684		$rdel = Linker::getRevDeleteLink( $user, $revRecord, $this->mTargetObj );
685
686		if ( $rdel ) {
687			$rdel = " $rdel";
688		}
689
690		$minor = $revRecord->isMinor() ? ChangesList::flag( 'minor' ) : '';
691
692		$tagIds = wfGetDB( DB_REPLICA )->selectFieldValues(
693			'change_tag',
694			'ct_tag_id',
695			[ 'ct_rev_id' => $revRecord->getId() ],
696			__METHOD__
697		);
698		$tags = [];
699		$changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore();
700		foreach ( $tagIds as $tagId ) {
701			try {
702				$tags[] = $changeTagDefStore->getName( (int)$tagId );
703			} catch ( NameTableAccessException $exception ) {
704				continue;
705			}
706		}
707		$tags = implode( ',', $tags );
708		$tagSummary = ChangeTags::formatSummaryRow( $tags, 'deleteddiff', $this->getContext() );
709
710		// FIXME This is reimplementing DifferenceEngine#getRevisionHeader
711		// and partially #showDiffPage, but worse
712		return '<div id="mw-diff-' . $prefix . 'title1"><strong>' .
713			$this->getLinkRenderer()->makeLink(
714				$targetPage,
715				$this->msg(
716					'revisionasof',
717					$lang->userTimeAndDate( $revRecord->getTimestamp(), $user ),
718					$lang->userDate( $revRecord->getTimestamp(), $user ),
719					$lang->userTime( $revRecord->getTimestamp(), $user )
720				)->text(),
721				[],
722				$targetQuery
723			) .
724			'</strong></div>' .
725			'<div id="mw-diff-' . $prefix . 'title2">' .
726			Linker::revUserTools( $revRecord ) . '<br />' .
727			'</div>' .
728			'<div id="mw-diff-' . $prefix . 'title3">' .
729			$minor . Linker::revComment( $revRecord ) . $rdel . '<br />' .
730			'</div>' .
731			'<div id="mw-diff-' . $prefix . 'title5">' .
732			$tagSummary[0] . '<br />' .
733			'</div>';
734	}
735
736	/**
737	 * Show a form confirming whether a tokenless user really wants to see a file
738	 * @param string $key
739	 */
740	private function showFileConfirmationForm( $key ) {
741		$out = $this->getOutput();
742		$lang = $this->getLanguage();
743		$user = $this->getUser();
744		$file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename );
745		$out->addWikiMsg( 'undelete-show-file-confirm',
746			$this->mTargetObj->getText(),
747			$lang->userDate( $file->getTimestamp(), $user ),
748			$lang->userTime( $file->getTimestamp(), $user ) );
749		$out->addHTML(
750			Xml::openElement( 'form', [
751					'method' => 'POST',
752					'action' => $this->getPageTitle()->getLocalURL( [
753						'target' => $this->mTarget,
754						'file' => $key,
755						'token' => $user->getEditToken( $key ),
756					] ),
757				]
758			) .
759				Xml::submitButton( $this->msg( 'undelete-show-file-submit' )->text() ) .
760				'</form>'
761		);
762	}
763
764	/**
765	 * Show a deleted file version requested by the visitor.
766	 * @param string $key
767	 */
768	private function showFile( $key ) {
769		$this->getOutput()->disable();
770
771		# We mustn't allow the output to be CDN cached, otherwise
772		# if an admin previews a deleted image, and it's cached, then
773		# a user without appropriate permissions can toddle off and
774		# nab the image, and CDN will serve it
775		$response = $this->getRequest()->response();
776		$response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
777		$response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' );
778		$response->header( 'Pragma: no-cache' );
779
780		$repo = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo();
781		$path = $repo->getZonePath( 'deleted' ) . '/' . $repo->getDeletedHashPath( $key ) . $key;
782		$repo->streamFileWithStatus( $path );
783	}
784
785	protected function showHistory() {
786		$this->checkReadOnly();
787
788		$out = $this->getOutput();
789		if ( $this->mAllowed ) {
790			$out->addModules( 'mediawiki.special.undelete' );
791		}
792		$out->wrapWikiMsg(
793			"<div class='mw-undelete-pagetitle'>\n$1\n</div>\n",
794			[ 'undeletepagetitle', wfEscapeWikiText( $this->mTargetObj->getPrefixedText() ) ]
795		);
796
797		$archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
798		$this->getHookRunner()->onUndeleteForm__showHistory( $archive, $this->mTargetObj );
799
800		$out->addHTML( '<div class="mw-undelete-history">' );
801		if ( $this->mAllowed ) {
802			$out->addWikiMsg( 'undeletehistory' );
803			$out->addWikiMsg( 'undeleterevdel' );
804		} else {
805			$out->addWikiMsg( 'undeletehistorynoadmin' );
806		}
807		$out->addHTML( '</div>' );
808
809		# List all stored revisions
810		$revisions = $archive->listRevisions();
811		$files = $archive->listFiles();
812
813		$haveRevisions = $revisions && $revisions->numRows() > 0;
814		$haveFiles = $files && $files->numRows() > 0;
815
816		# Batch existence check on user and talk pages
817		if ( $haveRevisions ) {
818			$batch = new LinkBatch();
819			foreach ( $revisions as $row ) {
820				$batch->addObj( Title::makeTitleSafe( NS_USER, $row->ar_user_text ) );
821				$batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->ar_user_text ) );
822			}
823			$batch->execute();
824			$revisions->seek( 0 );
825		}
826		if ( $haveFiles ) {
827			$batch = new LinkBatch();
828			foreach ( $files as $row ) {
829				$batch->addObj( Title::makeTitleSafe( NS_USER, $row->fa_user_text ) );
830				$batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->fa_user_text ) );
831			}
832			$batch->execute();
833			$files->seek( 0 );
834		}
835
836		if ( $this->mAllowed ) {
837			$out->enableOOUI();
838
839			$action = $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] );
840			# Start the form here
841			$form = new OOUI\FormLayout( [
842				'method' => 'post',
843				'action' => $action,
844				'id' => 'undelete',
845			] );
846		}
847
848		# Show relevant lines from the deletion log:
849		$deleteLogPage = new LogPage( 'delete' );
850		$out->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) . "\n" );
851		LogEventsList::showLogExtract( $out, 'delete', $this->mTargetObj );
852		# Show relevant lines from the suppression log:
853		$suppressLogPage = new LogPage( 'suppress' );
854		$permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
855		if ( $permissionManager->userHasRight( $this->getUser(), 'suppressionlog' ) ) {
856			$out->addHTML( Xml::element( 'h2', null, $suppressLogPage->getName()->text() ) . "\n" );
857			LogEventsList::showLogExtract( $out, 'suppress', $this->mTargetObj );
858		}
859
860		if ( $this->mAllowed && ( $haveRevisions || $haveFiles ) ) {
861			$fields = [];
862			$fields[] = new OOUI\Layout( [
863				'content' => new OOUI\HtmlSnippet( $this->msg( 'undeleteextrahelp' )->parseAsBlock() )
864			] );
865
866			$fields[] = new OOUI\FieldLayout(
867				new OOUI\TextInputWidget( [
868					'name' => 'wpComment',
869					'inputId' => 'wpComment',
870					'infusable' => true,
871					'value' => $this->mComment,
872					'autofocus' => true,
873					// HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
874					// (e.g. emojis) count for two each. This limit is overridden in JS to instead count
875					// Unicode codepoints.
876					'maxLength' => CommentStore::COMMENT_CHARACTER_LIMIT,
877				] ),
878				[
879					'label' => $this->msg( 'undeletecomment' )->text(),
880					'align' => 'top',
881				]
882			);
883
884			$fields[] = new OOUI\FieldLayout(
885				new OOUI\Widget( [
886					'content' => new OOUI\HorizontalLayout( [
887						'items' => [
888							new OOUI\ButtonInputWidget( [
889								'name' => 'restore',
890								'inputId' => 'mw-undelete-submit',
891								'value' => '1',
892								'label' => $this->msg( 'undeletebtn' )->text(),
893								'flags' => [ 'primary', 'progressive' ],
894								'type' => 'submit',
895							] ),
896							new OOUI\ButtonInputWidget( [
897								'name' => 'invert',
898								'inputId' => 'mw-undelete-invert',
899								'value' => '1',
900								'label' => $this->msg( 'undeleteinvert' )->text()
901							] ),
902						]
903					] )
904				] )
905			);
906
907			if ( $permissionManager->userHasRight( $this->getUser(), 'suppressrevision' ) ) {
908				$fields[] = new OOUI\FieldLayout(
909					new OOUI\CheckboxInputWidget( [
910						'name' => 'wpUnsuppress',
911						'inputId' => 'mw-undelete-unsuppress',
912						'value' => '1',
913					] ),
914					[
915						'label' => $this->msg( 'revdelete-unsuppress' )->text(),
916						'align' => 'inline',
917					]
918				);
919			}
920
921			$fieldset = new OOUI\FieldsetLayout( [
922				'label' => $this->msg( 'undelete-fieldset-title' )->text(),
923				'id' => 'mw-undelete-table',
924				'items' => $fields,
925			] );
926
927			$form->appendContent(
928				new OOUI\PanelLayout( [
929					'expanded' => false,
930					'padded' => true,
931					'framed' => true,
932					'content' => $fieldset,
933				] ),
934				new OOUI\HtmlSnippet(
935					Html::hidden( 'target', $this->mTarget ) .
936					Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() )
937				)
938			);
939		}
940
941		$history = '';
942		$history .= Xml::element( 'h2', null, $this->msg( 'history' )->text() ) . "\n";
943
944		if ( $haveRevisions ) {
945			# Show the page's stored (deleted) history
946
947			if ( $permissionManager->userHasRight( $this->getUser(), 'deleterevision' ) ) {
948				$history .= Html::element(
949					'button',
950					[
951						'name' => 'revdel',
952						'type' => 'submit',
953						'class' => 'deleterevision-log-submit mw-log-deleterevision-button'
954					],
955					$this->msg( 'showhideselectedversions' )->text()
956				) . "\n";
957			}
958
959			$history .= '<ul class="mw-undelete-revlist">';
960			$remaining = $revisions->numRows();
961			$earliestLiveTime = $this->mTargetObj->getEarliestRevTime();
962
963			foreach ( $revisions as $row ) {
964				$remaining--;
965				$history .= $this->formatRevisionRow( $row, $earliestLiveTime, $remaining );
966			}
967			$revisions->free();
968			$history .= '</ul>';
969		} else {
970			$out->addWikiMsg( 'nohistory' );
971		}
972
973		if ( $haveFiles ) {
974			$history .= Xml::element( 'h2', null, $this->msg( 'filehist' )->text() ) . "\n";
975			$history .= '<ul class="mw-undelete-revlist">';
976			foreach ( $files as $row ) {
977				$history .= $this->formatFileRow( $row );
978			}
979			$files->free();
980			$history .= '</ul>';
981		}
982
983		if ( $this->mAllowed ) {
984			# Slip in the hidden controls here
985			$misc = Html::hidden( 'target', $this->mTarget );
986			$misc .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() );
987			$history .= $misc;
988
989			$form->appendContent( new OOUI\HtmlSnippet( $history ) );
990			$out->addHTML( $form );
991		} else {
992			$out->addHTML( $history );
993		}
994
995		return true;
996	}
997
998	protected function formatRevisionRow( $row, $earliestLiveTime, $remaining ) {
999		$revRecord = MediaWikiServices::getInstance()
1000			->getRevisionFactory()
1001			->newRevisionFromArchiveRow(
1002				$row,
1003				RevisionFactory::READ_NORMAL,
1004				$this->mTargetObj
1005			);
1006
1007		$revTextSize = '';
1008		$ts = wfTimestamp( TS_MW, $row->ar_timestamp );
1009		// Build checkboxen...
1010		if ( $this->mAllowed ) {
1011			if ( $this->mInvert ) {
1012				if ( in_array( $ts, $this->mTargetTimestamp ) ) {
1013					$checkBox = Xml::check( "ts$ts" );
1014				} else {
1015					$checkBox = Xml::check( "ts$ts", true );
1016				}
1017			} else {
1018				$checkBox = Xml::check( "ts$ts" );
1019			}
1020		} else {
1021			$checkBox = '';
1022		}
1023
1024		// Build page & diff links...
1025		$user = $this->getUser();
1026		if ( $this->mCanView ) {
1027			$titleObj = $this->getPageTitle();
1028			# Last link
1029			if ( !RevisionRecord::userCanBitfield(
1030				$revRecord->getVisibility(),
1031				RevisionRecord::DELETED_TEXT,
1032				$this->getUser()
1033			) ) {
1034				$pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
1035				$last = $this->msg( 'diff' )->escaped();
1036			} elseif ( $remaining > 0 || ( $earliestLiveTime && $ts > $earliestLiveTime ) ) {
1037				$pageLink = $this->getPageLink( $revRecord, $titleObj, $ts );
1038				$last = $this->getLinkRenderer()->makeKnownLink(
1039					$titleObj,
1040					$this->msg( 'diff' )->text(),
1041					[],
1042					[
1043						'target' => $this->mTargetObj->getPrefixedText(),
1044						'timestamp' => $ts,
1045						'diff' => 'prev'
1046					]
1047				);
1048			} else {
1049				$pageLink = $this->getPageLink( $revRecord, $titleObj, $ts );
1050				$last = $this->msg( 'diff' )->escaped();
1051			}
1052		} else {
1053			$pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
1054			$last = $this->msg( 'diff' )->escaped();
1055		}
1056
1057		// User links
1058		$userLink = Linker::revUserTools( $revRecord );
1059
1060		// Minor edit
1061		$minor = $revRecord->isMinor() ? ChangesList::flag( 'minor' ) : '';
1062
1063		// Revision text size
1064		$size = $row->ar_len;
1065		if ( $size !== null ) {
1066			$revTextSize = Linker::formatRevisionSize( $size );
1067		}
1068
1069		// Edit summary
1070		$comment = Linker::revComment( $revRecord );
1071
1072		// Tags
1073		$attribs = [];
1074		list( $tagSummary, $classes ) = ChangeTags::formatSummaryRow(
1075			$row->ts_tags,
1076			'deletedhistory',
1077			$this->getContext()
1078		);
1079		if ( $classes ) {
1080			$attribs['class'] = implode( ' ', $classes );
1081		}
1082
1083		$revisionRow = $this->msg( 'undelete-revision-row2' )
1084			->rawParams(
1085				$checkBox,
1086				$last,
1087				$pageLink,
1088				$userLink,
1089				$minor,
1090				$revTextSize,
1091				$comment,
1092				$tagSummary
1093			)
1094			->escaped();
1095
1096		return Xml::tags( 'li', $attribs, $revisionRow ) . "\n";
1097	}
1098
1099	private function formatFileRow( $row ) {
1100		$file = ArchivedFile::newFromRow( $row );
1101		$ts = wfTimestamp( TS_MW, $row->fa_timestamp );
1102		$user = $this->getUser();
1103
1104		$checkBox = '';
1105		if ( $this->mCanView && $row->fa_storage_key ) {
1106			if ( $this->mAllowed ) {
1107				$checkBox = Xml::check( 'fileid' . $row->fa_id );
1108			}
1109			$key = urlencode( $row->fa_storage_key );
1110			$pageLink = $this->getFileLink( $file, $this->getPageTitle(), $ts, $key );
1111		} else {
1112			$pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
1113		}
1114		$userLink = $this->getFileUser( $file );
1115		$data = $this->msg( 'widthheight' )->numParams( $row->fa_width, $row->fa_height )->text();
1116		$bytes = $this->msg( 'parentheses' )
1117			->plaintextParams( $this->msg( 'nbytes' )->numParams( $row->fa_size )->text() )
1118			->plain();
1119		$data = htmlspecialchars( $data . ' ' . $bytes );
1120		$comment = $this->getFileComment( $file );
1121
1122		// Add show/hide deletion links if available
1123		$canHide = $this->isAllowed( 'deleterevision' );
1124		if ( $canHide || ( $file->getVisibility() && $this->isAllowed( 'deletedhistory' ) ) ) {
1125			if ( !$file->userCan( File::DELETED_RESTRICTED, $user ) ) {
1126				// Revision was hidden from sysops
1127				$revdlink = Linker::revDeleteLinkDisabled( $canHide );
1128			} else {
1129				$query = [
1130					'type' => 'filearchive',
1131					'target' => $this->mTargetObj->getPrefixedDBkey(),
1132					'ids' => $row->fa_id
1133				];
1134				$revdlink = Linker::revDeleteLink( $query,
1135					$file->isDeleted( File::DELETED_RESTRICTED ), $canHide );
1136			}
1137		} else {
1138			$revdlink = '';
1139		}
1140
1141		return "<li>$checkBox $revdlink $pageLink . . $userLink $data $comment</li>\n";
1142	}
1143
1144	/**
1145	 * Fetch revision text link if it's available to all users
1146	 *
1147	 * @param RevisionRecord $revRecord
1148	 * @param Title $titleObj
1149	 * @param string $ts Timestamp
1150	 * @return string
1151	 */
1152	private function getPageLink( RevisionRecord $revRecord, $titleObj, $ts ) {
1153		$user = $this->getUser();
1154		$time = $this->getLanguage()->userTimeAndDate( $ts, $user );
1155
1156		if ( !RevisionRecord::userCanBitfield(
1157			$revRecord->getVisibility(),
1158			RevisionRecord::DELETED_TEXT,
1159			$user
1160		) ) {
1161			return '<span class="history-deleted">' . $time . '</span>';
1162		}
1163
1164		$link = $this->getLinkRenderer()->makeKnownLink(
1165			$titleObj,
1166			$time,
1167			[],
1168			[
1169				'target' => $this->mTargetObj->getPrefixedText(),
1170				'timestamp' => $ts
1171			]
1172		);
1173
1174		if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
1175			$link = '<span class="history-deleted">' . $link . '</span>';
1176		}
1177
1178		return $link;
1179	}
1180
1181	/**
1182	 * Fetch image view link if it's available to all users
1183	 *
1184	 * @param File|ArchivedFile $file
1185	 * @param Title $titleObj
1186	 * @param string $ts A timestamp
1187	 * @param string $key A storage key
1188	 *
1189	 * @return string HTML fragment
1190	 */
1191	private function getFileLink( $file, $titleObj, $ts, $key ) {
1192		$user = $this->getUser();
1193		$time = $this->getLanguage()->userTimeAndDate( $ts, $user );
1194
1195		if ( !$file->userCan( File::DELETED_FILE, $user ) ) {
1196			return '<span class="history-deleted">' . htmlspecialchars( $time ) . '</span>';
1197		}
1198
1199		$link = $this->getLinkRenderer()->makeKnownLink(
1200			$titleObj,
1201			$time,
1202			[],
1203			[
1204				'target' => $this->mTargetObj->getPrefixedText(),
1205				'file' => $key,
1206				'token' => $user->getEditToken( $key )
1207			]
1208		);
1209
1210		if ( $file->isDeleted( File::DELETED_FILE ) ) {
1211			$link = '<span class="history-deleted">' . $link . '</span>';
1212		}
1213
1214		return $link;
1215	}
1216
1217	/**
1218	 * Fetch file's user id if it's available to this user
1219	 *
1220	 * @param File|ArchivedFile $file
1221	 * @return string HTML fragment
1222	 */
1223	private function getFileUser( $file ) {
1224		if ( !$file->userCan( File::DELETED_USER, $this->getUser() ) ) {
1225			return '<span class="history-deleted">' .
1226				$this->msg( 'rev-deleted-user' )->escaped() .
1227				'</span>';
1228		}
1229
1230		$link = Linker::userLink( $file->getRawUser(), $file->getRawUserText() ) .
1231			Linker::userToolLinks( $file->getRawUser(), $file->getRawUserText() );
1232
1233		if ( $file->isDeleted( File::DELETED_USER ) ) {
1234			$link = '<span class="history-deleted">' . $link . '</span>';
1235		}
1236
1237		return $link;
1238	}
1239
1240	/**
1241	 * Fetch file upload comment if it's available to this user
1242	 *
1243	 * @param File|ArchivedFile $file
1244	 * @return string HTML fragment
1245	 */
1246	private function getFileComment( $file ) {
1247		if ( !$file->userCan( File::DELETED_COMMENT, $this->getUser() ) ) {
1248			return '<span class="history-deleted"><span class="comment">' .
1249				$this->msg( 'rev-deleted-comment' )->escaped() . '</span></span>';
1250		}
1251
1252		$link = Linker::commentBlock( $file->getRawDescription() );
1253
1254		if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
1255			$link = '<span class="history-deleted">' . $link . '</span>';
1256		}
1257
1258		return $link;
1259	}
1260
1261	private function undelete() {
1262		if ( $this->getConfig()->get( 'UploadMaintenance' )
1263			&& $this->mTargetObj->getNamespace() == NS_FILE
1264		) {
1265			throw new ErrorPageError( 'undelete-error', 'filedelete-maintenance' );
1266		}
1267
1268		$this->checkReadOnly();
1269
1270		$out = $this->getOutput();
1271		$archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
1272		$this->getHookRunner()->onUndeleteForm__undelete( $archive, $this->mTargetObj );
1273		$ok = $archive->undeleteAsUser(
1274			$this->mTargetTimestamp,
1275			$this->getUser(),
1276			$this->mComment,
1277			$this->mFileVersions,
1278			$this->mUnsuppress
1279		);
1280
1281		if ( is_array( $ok ) ) {
1282			if ( $ok[1] ) { // Undeleted file count
1283				$this->getHookRunner()->onFileUndeleteComplete(
1284					$this->mTargetObj, $this->mFileVersions, $this->getUser(), $this->mComment );
1285			}
1286
1287			$link = $this->getLinkRenderer()->makeKnownLink( $this->mTargetObj );
1288			$out->addWikiMsg( 'undeletedpage', Message::rawParam( $link ) );
1289		} else {
1290			$out->setPageTitle( $this->msg( 'undelete-error' ) );
1291		}
1292
1293		// Show revision undeletion warnings and errors
1294		$status = $archive->getRevisionStatus();
1295		if ( $status && !$status->isGood() ) {
1296			$out->wrapWikiTextAsInterface(
1297				'error',
1298				'<div id="mw-error-cannotundelete">' .
1299				$status->getWikiText(
1300					'cannotundelete',
1301					'cannotundelete',
1302					$this->getLanguage()
1303				) . '</div>'
1304			);
1305		}
1306
1307		// Show file undeletion warnings and errors
1308		$status = $archive->getFileStatus();
1309		if ( $status && !$status->isGood() ) {
1310			$out->wrapWikiTextAsInterface(
1311				'error',
1312				$status->getWikiText(
1313					'undelete-error-short',
1314					'undelete-error-long',
1315					$this->getLanguage()
1316				)
1317			);
1318		}
1319	}
1320
1321	/**
1322	 * Return an array of subpages beginning with $search that this special page will accept.
1323	 *
1324	 * @param string $search Prefix to search for
1325	 * @param int $limit Maximum number of results to return (usually 10)
1326	 * @param int $offset Number of results to skip (usually 0)
1327	 * @return string[] Matching subpages
1328	 */
1329	public function prefixSearchSubpages( $search, $limit, $offset ) {
1330		return $this->prefixSearchString( $search, $limit, $offset );
1331	}
1332
1333	protected function getGroupName() {
1334		return 'pagetools';
1335	}
1336}
1337