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