Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 998
0.00% covered (danger)
0.00%
0 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialUndelete
0.00% covered (danger)
0.00%
0 / 997
0.00% covered (danger)
0.00%
0 / 30
33672
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadRequest
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
462
 isAllowed
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 userCanExecute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkPermissions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 execute
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
240
 redirectToRevDel
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
132
 renderUndeleteSelectionError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 showSearchForm
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
20
 showList
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
20
 showRevision
0.00% covered (danger)
0.00%
0 / 161
0.00% covered (danger)
0.00%
0 / 1
420
 showDiff
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 diffHeader
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
56
 showFileConfirmationForm
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 showFile
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 addRevisionsToBatch
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 addFilesToBatch
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 showMoreHistory
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 formatRevisionHistory
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 showHistory
0.00% covered (danger)
0.00%
0 / 231
0.00% covered (danger)
0.00%
0 / 1
702
 formatRevisionRow
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
132
 formatFileRow
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
72
 getPageLink
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 getFileLink
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 getFileUser
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getFileComment
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 undelete
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
110
 prefixSearchSubpages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\Cache\LinkBatch;
10use MediaWiki\Cache\LinkBatchFactory;
11use MediaWiki\ChangeTags\ChangeTags;
12use MediaWiki\CommentFormatter\CommentFormatter;
13use MediaWiki\CommentStore\CommentStore;
14use MediaWiki\Content\IContentHandlerFactory;
15use MediaWiki\Content\TextContent;
16use MediaWiki\Context\DerivativeContext;
17use MediaWiki\Exception\ErrorPageError;
18use MediaWiki\Exception\PermissionsError;
19use MediaWiki\Exception\UserBlockedError;
20use MediaWiki\FileRepo\File\ArchivedFile;
21use MediaWiki\FileRepo\File\File;
22use MediaWiki\FileRepo\LocalRepo;
23use MediaWiki\FileRepo\RepoGroup;
24use MediaWiki\Html\Html;
25use MediaWiki\Linker\Linker;
26use MediaWiki\Linker\LinkTarget;
27use MediaWiki\Logging\LogEventsList;
28use MediaWiki\Logging\LogPage;
29use MediaWiki\MainConfigNames;
30use MediaWiki\Message\Message;
31use MediaWiki\Page\PageArchive;
32use MediaWiki\Page\UndeletePage;
33use MediaWiki\Page\UndeletePageFactory;
34use MediaWiki\Page\WikiPageFactory;
35use MediaWiki\Parser\ParserOptions;
36use MediaWiki\Permissions\PermissionManager;
37use MediaWiki\RecentChanges\ChangesList;
38use MediaWiki\Revision\ArchivedRevisionLookup;
39use MediaWiki\Revision\RevisionAccessException;
40use MediaWiki\Revision\RevisionArchiveRecord;
41use MediaWiki\Revision\RevisionRecord;
42use MediaWiki\Revision\RevisionRenderer;
43use MediaWiki\Revision\RevisionStore;
44use MediaWiki\Revision\SlotRecord;
45use MediaWiki\SpecialPage\SpecialPage;
46use MediaWiki\Storage\NameTableAccessException;
47use MediaWiki\Storage\NameTableStore;
48use MediaWiki\Title\Title;
49use MediaWiki\User\Options\UserOptionsLookup;
50use MediaWiki\User\User;
51use MediaWiki\User\UserIdentityValue;
52use MediaWiki\Watchlist\WatchlistManager;
53use OOUI\ActionFieldLayout;
54use OOUI\ButtonInputWidget;
55use OOUI\CheckboxInputWidget;
56use OOUI\DropdownInputWidget;
57use OOUI\FieldLayout;
58use OOUI\FieldsetLayout;
59use OOUI\FormLayout;
60use OOUI\HorizontalLayout;
61use OOUI\HtmlSnippet;
62use OOUI\Layout;
63use OOUI\PanelLayout;
64use OOUI\TextInputWidget;
65use OOUI\Widget;
66use SearchEngineFactory;
67use Wikimedia\Rdbms\IConnectionProvider;
68use Wikimedia\Rdbms\IDBAccessObject;
69use Wikimedia\Rdbms\IResultWrapper;
70use Wikimedia\Timestamp\TimestampFormat as TS;
71
72/**
73 * Special page allowing users with the appropriate permissions to view
74 * and restore deleted content.
75 *
76 * @ingroup SpecialPage
77 */
78class SpecialUndelete extends SpecialPage {
79
80    /**
81     * Limit of revisions (Page history) to display.
82     * (If there are more items to display - "Load more" button will appear).
83     */
84    private const REVISION_HISTORY_LIMIT = 500;
85
86    /** @var string|null */
87    private $mAction;
88    /** @var string */
89    private $mTarget;
90    /** @var string */
91    private $mTimestamp;
92    /** @var bool */
93    private $mRestore;
94    /** @var bool */
95    private $mRevdel;
96    /** @var bool */
97    private $mInvert;
98    /** @var string */
99    private $mFilename;
100    /** @var string[] */
101    private $mTargetTimestamp = [];
102    /** @var bool */
103    private $mAllowed;
104    /** @var bool */
105    private $mCanView;
106    /** @var string */
107    private $mComment = '';
108    /** @var string */
109    private $mToken;
110    /** @var bool|null */
111    private $mPreview;
112    /** @var bool|null */
113    private $mDiff;
114    /** @var bool|null */
115    private $mDiffOnly;
116    /** @var bool|null */
117    private $mUnsuppress;
118    /** @var int[] */
119    private $mFileVersions = [];
120    /** @var bool|null */
121    private $mUndeleteTalk;
122    /** @var string|null Timestamp at which to start a "load more" request (open interval) */
123    private $mHistoryOffset;
124
125    /** @var Title|null */
126    private $mTargetObj;
127    /**
128     * @var string Search prefix
129     */
130    private $mSearchPrefix;
131
132    private PermissionManager $permissionManager;
133    private RevisionStore $revisionStore;
134    private RevisionRenderer $revisionRenderer;
135    private IContentHandlerFactory $contentHandlerFactory;
136    private NameTableStore $changeTagDefStore;
137    private LinkBatchFactory $linkBatchFactory;
138    private LocalRepo $localRepo;
139    private IConnectionProvider $dbProvider;
140    private UserOptionsLookup $userOptionsLookup;
141    private WikiPageFactory $wikiPageFactory;
142    private SearchEngineFactory $searchEngineFactory;
143    private UndeletePageFactory $undeletePageFactory;
144    private ArchivedRevisionLookup $archivedRevisionLookup;
145    private CommentFormatter $commentFormatter;
146    private WatchlistManager $watchlistManager;
147
148    public function __construct(
149        PermissionManager $permissionManager,
150        RevisionStore $revisionStore,
151        RevisionRenderer $revisionRenderer,
152        IContentHandlerFactory $contentHandlerFactory,
153        NameTableStore $changeTagDefStore,
154        LinkBatchFactory $linkBatchFactory,
155        RepoGroup $repoGroup,
156        IConnectionProvider $dbProvider,
157        UserOptionsLookup $userOptionsLookup,
158        WikiPageFactory $wikiPageFactory,
159        SearchEngineFactory $searchEngineFactory,
160        UndeletePageFactory $undeletePageFactory,
161        ArchivedRevisionLookup $archivedRevisionLookup,
162        CommentFormatter $commentFormatter,
163        WatchlistManager $watchlistManager
164    ) {
165        parent::__construct( 'Undelete', 'deletedhistory' );
166        $this->permissionManager = $permissionManager;
167        $this->revisionStore = $revisionStore;
168        $this->revisionRenderer = $revisionRenderer;
169        $this->contentHandlerFactory = $contentHandlerFactory;
170        $this->changeTagDefStore = $changeTagDefStore;
171        $this->linkBatchFactory = $linkBatchFactory;
172        $this->localRepo = $repoGroup->getLocalRepo();
173        $this->dbProvider = $dbProvider;
174        $this->userOptionsLookup = $userOptionsLookup;
175        $this->wikiPageFactory = $wikiPageFactory;
176        $this->searchEngineFactory = $searchEngineFactory;
177        $this->undeletePageFactory = $undeletePageFactory;
178        $this->archivedRevisionLookup = $archivedRevisionLookup;
179        $this->commentFormatter = $commentFormatter;
180        $this->watchlistManager = $watchlistManager;
181    }
182
183    /** @inheritDoc */
184    public function doesWrites() {
185        return true;
186    }
187
188    private function loadRequest( ?string $par ) {
189        $request = $this->getRequest();
190        $user = $this->getUser();
191
192        $this->mAction = $request->getRawVal( 'action' );
193        if ( $par !== null && $par !== '' ) {
194            $this->mTarget = $par;
195        } else {
196            $this->mTarget = $request->getVal( 'target' );
197        }
198
199        $this->mTargetObj = null;
200
201        if ( $this->mTarget !== null && $this->mTarget !== '' ) {
202            $this->mTargetObj = Title::newFromText( $this->mTarget );
203        }
204
205        $this->mSearchPrefix = $request->getText( 'prefix' );
206        $time = $request->getVal( 'timestamp' );
207        $this->mTimestamp = $time ? wfTimestamp( TS::MW, $time ) : '';
208        $this->mFilename = $request->getVal( 'file' );
209
210        $posted = $request->wasPosted() &&
211            $user->matchEditToken( $request->getVal( 'wpEditToken' ) );
212        $this->mRestore = $request->getCheck( 'restore' ) && $posted;
213        $this->mRevdel = $request->getCheck( 'revdel' ) && $posted;
214        $this->mInvert = $request->getCheck( 'invert' ) && $posted;
215        $this->mPreview = $request->getCheck( 'preview' ) && $posted;
216        $this->mDiff = $request->getCheck( 'diff' );
217        $this->mDiffOnly = $request->getBool( 'diffonly',
218            $this->userOptionsLookup->getOption( $this->getUser(), 'diffonly' ) );
219        $commentList = $request->getText( 'wpCommentList', 'other' );
220        $comment = $request->getText( 'wpComment' );
221        if ( $commentList === 'other' ) {
222            $this->mComment = $comment;
223        } elseif ( $comment !== '' ) {
224            $this->mComment = $commentList . $this->msg( 'colon-separator' )->inContentLanguage()->text() . $comment;
225        } else {
226            $this->mComment = $commentList;
227        }
228        $this->mUnsuppress = $request->getVal( 'wpUnsuppress' ) &&
229            $this->permissionManager->userHasRight( $user, 'suppressrevision' );
230        $this->mToken = $request->getVal( 'token' );
231        $this->mUndeleteTalk = $request->getCheck( 'undeletetalk' );
232        $this->mHistoryOffset = $request->getVal( 'historyoffset' );
233
234        if ( $this->isAllowed( 'undelete' ) ) {
235            $this->mAllowed = true; // user can restore
236            $this->mCanView = true; // user can view content
237        } elseif ( $this->isAllowed( 'deletedtext' ) ) {
238            $this->mAllowed = false; // user cannot restore
239            $this->mCanView = true; // user can view content
240            $this->mRestore = false;
241        } else { // user can only view the list of revisions
242            $this->mAllowed = false;
243            $this->mCanView = false;
244            $this->mTimestamp = '';
245            $this->mRestore = false;
246        }
247
248        if ( $this->mRestore || $this->mInvert ) {
249            $timestamps = [];
250            $this->mFileVersions = [];
251            foreach ( $request->getValues() as $key => $val ) {
252                $matches = [];
253                if ( preg_match( '/^ts(\d{14})$/', $key, $matches ) ) {
254                    $timestamps[] = $matches[1];
255                }
256
257                if ( preg_match( '/^fileid(\d+)$/', $key, $matches ) ) {
258                    $this->mFileVersions[] = intval( $matches[1] );
259                }
260            }
261            rsort( $timestamps );
262            $this->mTargetTimestamp = $timestamps;
263        }
264    }
265
266    /**
267     * Checks whether a user is allowed the permission for the
268     * specific title if one is set.
269     *
270     * @param string $permission
271     * @param User|null $user
272     * @return bool
273     */
274    protected function isAllowed( $permission, ?User $user = null ) {
275        $user ??= $this->getUser();
276        $block = $user->getBlock();
277
278        if ( $this->mTargetObj !== null ) {
279            return $this->permissionManager->userCan( $permission, $user, $this->mTargetObj );
280        } else {
281            $hasRight = $this->permissionManager->userHasRight( $user, $permission );
282            $sitewideBlock = $block && $block->isSitewide();
283            return $permission === 'undelete' ? ( $hasRight && !$sitewideBlock ) : $hasRight;
284        }
285    }
286
287    /** @inheritDoc */
288    public function userCanExecute( User $user ) {
289        return $this->isAllowed( $this->mRestriction, $user );
290    }
291
292    /**
293     * @inheritDoc
294     */
295    public function checkPermissions() {
296        $user = $this->getUser();
297
298        // First check if user has the right to use this page. If not,
299        // show a permissions error whether they are blocked or not.
300        if ( !parent::userCanExecute( $user ) ) {
301            $this->displayRestrictionError();
302        }
303
304        // If a user has the right to use this page, but is blocked from
305        // the target, show a block error.
306        if (
307            $this->mTargetObj && $this->permissionManager->isBlockedFrom( $user, $this->mTargetObj ) ) {
308            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
309            throw new UserBlockedError( $user->getBlock() );
310        }
311
312        // Finally, do the comprehensive permission check via isAllowed.
313        if ( !$this->userCanExecute( $user ) ) {
314            $this->displayRestrictionError();
315        }
316    }
317
318    /** @inheritDoc */
319    public function execute( $par ) {
320        $this->useTransactionalTimeLimit();
321
322        $user = $this->getUser();
323
324        $this->setHeaders();
325        $this->outputHeader();
326        $this->addHelpLink( 'Help:Deletion_and_undeletion' );
327
328        $this->loadRequest( $par );
329        $this->checkPermissions(); // Needs to be after mTargetObj is set
330
331        $out = $this->getOutput();
332        // This page uses Html::warningBox and Html::errorBox
333        $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
334
335        if ( $this->mTargetObj === null ) {
336            $out->addWikiMsg( 'undelete-header' );
337
338            # Not all users can just browse every deleted page from the list
339            if ( $this->permissionManager->userHasRight( $user, 'browsearchive' ) ) {
340                $this->showSearchForm();
341            }
342
343            return;
344        }
345
346        $this->addHelpLink( 'Help:Undelete' );
347        if ( $this->mAllowed ) {
348            $out->setPageTitleMsg( $this->msg( 'undeletepage' ) );
349        } else {
350            $out->setPageTitleMsg( $this->msg( 'viewdeletedpage' ) );
351        }
352
353        $this->getSkin()->setRelevantTitle( $this->mTargetObj );
354
355        if ( $this->mTimestamp !== '' ) {
356            $this->showRevision( $this->mTimestamp );
357        } elseif ( $this->mFilename !== null && $this->mTargetObj->inNamespace( NS_FILE ) ) {
358            $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename );
359            // Check if user is allowed to see this file
360            if ( !$file->exists() ) {
361                $out->addWikiMsg( 'filedelete-nofile', $this->mFilename );
362            } elseif ( !$file->userCan( File::DELETED_FILE, $user ) ) {
363                if ( $file->isDeleted( File::DELETED_RESTRICTED ) ) {
364                    throw new PermissionsError( 'suppressrevision' );
365                } else {
366                    throw new PermissionsError( 'deletedtext' );
367                }
368            } elseif ( !$user->matchEditToken( $this->mToken, $this->mFilename ) ) {
369                $this->showFileConfirmationForm( $this->mFilename );
370            } else {
371                $this->showFile( $this->mFilename );
372            }
373        } elseif ( $this->mAction === 'submit' ) {
374            if ( $this->mRestore ) {
375                $this->undelete();
376            } elseif ( $this->mRevdel ) {
377                $this->redirectToRevDel();
378            }
379        } elseif ( $this->mAction === 'render' ) {
380            $this->showMoreHistory();
381        } else {
382            $this->showHistory();
383        }
384    }
385
386    /**
387     * Convert submitted form data to format expected by RevisionDelete and
388     * redirect the request
389     */
390    private function redirectToRevDel() {
391        $revisionIds = [];
392        $fileArchiveIds = [];
393
394        foreach ( $this->getRequest()->getValues() as $key => $val ) {
395            $matches = [];
396            if ( preg_match( "/^ts(\d{14})$/", $key, $matches ) ) {
397                $revisionRecord = $this->archivedRevisionLookup
398                    ->getRevisionRecordByTimestamp( $this->mTargetObj, $matches[1] );
399                if ( $revisionRecord ) {
400                    $revisionIds[] = (int)$revisionRecord->getId();
401                }
402            } elseif ( preg_match( '/^fileid(\d+)$/', $key, $matches ) ) {
403                $fileArchiveIds[] = (int)$matches[1];
404            }
405        }
406
407        $hasRevisions = count( $revisionIds ) > 0;
408        $hasFiles = count( $fileArchiveIds ) > 0;
409
410        // Check selection validity, see if mixed or nothing selected
411        if ( $hasRevisions && $hasFiles ) {
412            $this->renderUndeleteSelectionError( 'mixed' );
413            return;
414        } elseif ( !$hasRevisions && !$hasFiles ) {
415            $this->renderUndeleteSelectionError( 'none' );
416            return;
417        }
418
419        // Exp. assoc array of id => 1 (ids[123]=1)
420        $idsForQuery = $hasFiles
421            ? array_fill_keys( $fileArchiveIds, 1 )
422            : array_fill_keys( $revisionIds, 1 );
423
424        $query = [
425            'type' => $hasFiles ? 'filearchive' : 'revision',
426            'ids' => $idsForQuery,
427            'target' => $this->mTargetObj->getPrefixedText()
428        ];
429
430        $url = SpecialPage::getTitleFor( 'Revisiondelete' )->getFullURL( $query );
431        $this->getOutput()->redirect( $url );
432    }
433
434    /**
435     * Render a clearer error box for undelete selection problems
436     *
437     * @param string $case 'mixed' = both page and file selected, 'none' = nothing selected
438     */
439    private function renderUndeleteSelectionError( string $case ): void {
440        $msg = $case === 'mixed'
441            ? $this->msg( 'undelete-error-mixed' )
442            : $this->msg( 'undelete-error-none' );
443
444        $this->getOutput()->addHTML( Html::errorBox( $msg->parse() ) );
445    }
446
447    private function showSearchForm() {
448        $out = $this->getOutput();
449        $out->setPageTitleMsg( $this->msg( 'undelete-search-title' ) );
450        $fuzzySearch = $this->getRequest()->getVal( 'fuzzy', '1' );
451
452        $out->enableOOUI();
453
454        $fields = [];
455        $fields[] = new ActionFieldLayout(
456            new TextInputWidget( [
457                'name' => 'prefix',
458                'inputId' => 'prefix',
459                'infusable' => true,
460                'value' => $this->mSearchPrefix,
461                'autofocus' => true,
462            ] ),
463            new ButtonInputWidget( [
464                'label' => $this->msg( 'undelete-search-submit' )->text(),
465                'flags' => [ 'primary', 'progressive' ],
466                'inputId' => 'searchUndelete',
467                'type' => 'submit',
468            ] ),
469            [
470                'label' => new HtmlSnippet(
471                    $this->msg(
472                        $fuzzySearch ? 'undelete-search-full' : 'undelete-search-prefix'
473                    )->parse()
474                ),
475                'align' => 'left',
476            ]
477        );
478
479        $fieldset = new FieldsetLayout( [
480            'label' => $this->msg( 'undelete-search-box' )->text(),
481            'items' => $fields,
482        ] );
483
484        $form = new FormLayout( [
485            'method' => 'get',
486            'action' => wfScript(),
487        ] );
488
489        $form->appendContent(
490            $fieldset,
491            new HtmlSnippet(
492                Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
493                Html::hidden( 'fuzzy', $fuzzySearch )
494            )
495        );
496
497        $out->addHTML(
498            ( new PanelLayout( [
499                'expanded' => false,
500                'padded' => true,
501                'framed' => true,
502                'content' => $form,
503            ] ) )->toString()
504        );
505
506        # List undeletable articles
507        if ( $this->mSearchPrefix ) {
508            // For now, we enable search engine match only when specifically asked to
509            // by using fuzzy=1 parameter.
510            if ( $fuzzySearch ) {
511                $result = PageArchive::listPagesBySearch( $this->mSearchPrefix );
512            } else {
513                $result = PageArchive::listPagesByPrefix( $this->mSearchPrefix );
514            }
515            $this->showList( $result );
516        }
517    }
518
519    /**
520     * Generic list of deleted pages
521     *
522     * @param IResultWrapper $result
523     * @return bool
524     */
525    private function showList( $result ) {
526        $out = $this->getOutput();
527
528        if ( $result->numRows() == 0 ) {
529            $out->addWikiMsg( 'undelete-no-results' );
530
531            return false;
532        }
533
534        $out->addWikiMsg( 'undeletepagetext', $this->getLanguage()->formatNum( $result->numRows() ) );
535
536        $linkRenderer = $this->getLinkRenderer();
537        $undelete = $this->getPageTitle();
538        $out->addHTML( "<ul id='undeleteResultsList'>\n" );
539        foreach ( $result as $row ) {
540            $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
541            if ( $title !== null ) {
542                $item = $linkRenderer->makeKnownLink(
543                    $undelete,
544                    $title->getPrefixedText(),
545                    [],
546                    [ 'target' => $title->getPrefixedText() ]
547                );
548            } else {
549                // The title is no longer valid, show as text
550                $item = Html::element(
551                    'span',
552                    [ 'class' => 'mw-invalidtitle' ],
553                    Linker::getInvalidTitleDescription(
554                        $this->getContext(),
555                        $row->ar_namespace,
556                        $row->ar_title
557                    )
558                );
559            }
560            $revs = $this->msg( 'undeleterevisions' )->numParams( $row->count )->parse();
561            $out->addHTML(
562                Html::rawElement(
563                    'li',
564                    [ 'class' => 'undeleteResult' ],
565                    $item . $this->msg( 'word-separator' )->escaped() .
566                        $this->msg( 'parentheses' )->rawParams( $revs )->escaped()
567                )
568            );
569        }
570        $result->free();
571        $out->addHTML( "</ul>\n" );
572
573        return true;
574    }
575
576    private function showRevision( string $timestamp ) {
577        if ( !preg_match( '/[0-9]{14}/', $timestamp ) ) {
578            return;
579        }
580        $out = $this->getOutput();
581        $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
582
583        // When viewing a specific revision, add a subtitle link back to the overall
584        // history, see T284114
585        $listLink = $this->getLinkRenderer()->makeKnownLink(
586            $this->getPageTitle(),
587            $this->msg( 'undelete-back-to-list' )->text(),
588            [],
589            [ 'target' => $this->mTargetObj->getPrefixedText() ]
590        );
591        // same < arrow as with subpages
592        $subtitle = "&lt; $listLink";
593        $out->setSubtitle( $subtitle );
594
595        $archive = new PageArchive( $this->mTargetObj );
596        // FIXME: This hook must be deprecated, passing PageArchive by ref is awful.
597        if ( !$this->getHookRunner()->onUndeleteForm__showRevision(
598            $archive, $this->mTargetObj )
599        ) {
600            return;
601        }
602        $revRecord = $this->archivedRevisionLookup->getRevisionRecordByTimestamp( $this->mTargetObj, $timestamp );
603
604        $user = $this->getUser();
605
606        if ( !$revRecord ) {
607            $out->addWikiMsg( 'undeleterevision-missing' );
608            return;
609        }
610
611        if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
612            // Used in wikilinks, should not contain whitespaces
613            $titleText = $this->mTargetObj->getPrefixedURL();
614            if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
615                $msg = $revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED )
616                    ? [ 'rev-suppressed-text-permission', $titleText ]
617                    : [ 'rev-deleted-text-permission', $titleText ];
618                $out->addHTML(
619                    Html::warningBox(
620                        $this->msg( $msg[0], $msg[1] )->parse(),
621                        'plainlinks'
622                    )
623                );
624                return;
625            }
626
627            $msg = $revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED )
628                ? [ 'rev-suppressed-text-view', $titleText ]
629                : [ 'rev-deleted-text-view', $titleText ];
630            $out->addHTML(
631                Html::warningBox(
632                    $this->msg( $msg[0], $msg[1] )->parse(),
633                    'plainlinks'
634                )
635            );
636            // and we are allowed to see...
637        }
638
639        if ( $this->mDiff ) {
640            $previousRevRecord = $this->archivedRevisionLookup
641                ->getPreviousRevisionRecord( $this->mTargetObj, $timestamp );
642            if ( $previousRevRecord ) {
643                $this->showDiff( $previousRevRecord, $revRecord );
644                if ( $this->mDiffOnly ) {
645                    return;
646                }
647
648                $out->addHTML( '<hr />' );
649            } else {
650                $out->addWikiMsg( 'undelete-nodiff' );
651            }
652        }
653
654        $link = $this->getLinkRenderer()->makeKnownLink(
655            $this->getPageTitle( $this->mTargetObj->getPrefixedDBkey() ),
656            $this->mTargetObj->getPrefixedText()
657        );
658
659        $lang = $this->getLanguage();
660
661        // date and time are separate parameters to facilitate localisation.
662        // $time is kept for backward compat reasons.
663        $time = $lang->userTimeAndDate( $timestamp, $user );
664        $d = $lang->userDate( $timestamp, $user );
665        $t = $lang->userTime( $timestamp, $user );
666        $userLink = Linker::revUserTools( $revRecord );
667
668        try {
669            $content = $revRecord->getContent(
670                SlotRecord::MAIN,
671                RevisionRecord::FOR_THIS_USER,
672                $user
673            );
674        } catch ( RevisionAccessException ) {
675            $content = null;
676        }
677
678        // TODO: MCR: this will have to become something like $hasTextSlots and $hasNonTextSlots
679        $isText = ( $content instanceof TextContent );
680
681        $undeleteRevisionContent = '';
682        // Revision delete links
683        if ( !$this->mDiff ) {
684            $revdel = Linker::getRevDeleteLink(
685                $user,
686                $revRecord,
687                $this->mTargetObj
688            );
689            if ( $revdel ) {
690                $undeleteRevisionContent = $revdel . ' ';
691            }
692        }
693
694        $undeleteRevisionContent .= $out->msg(
695            'undelete-revision',
696            Message::rawParam( $link ),
697            $time,
698            Message::rawParam( $userLink ),
699            $d,
700            $t
701        )->parseAsBlock();
702
703        if ( $this->mPreview || $isText ) {
704            $out->addHTML(
705                Html::warningBox(
706                    $undeleteRevisionContent,
707                    'mw-undelete-revision'
708                )
709            );
710        } else {
711            $out->addHTML(
712                Html::rawElement(
713                    'div',
714                    [ 'class' => 'mw-undelete-revision', ],
715                    $undeleteRevisionContent
716                )
717            );
718        }
719
720        if ( $this->mPreview || !$isText ) {
721            // NOTE: non-text content has no source view, so always use rendered preview
722
723            $popts = ParserOptions::newFromContext( $this->getContext() );
724
725            try {
726                $rendered = $this->revisionRenderer->getRenderedRevision(
727                    $revRecord,
728                    $popts,
729                    $user,
730                    [ 'audience' => RevisionRecord::FOR_THIS_USER, 'causeAction' => 'undelete-preview' ]
731                );
732
733                // Fail hard if the audience check fails, since we already checked
734                // at the beginning of this method.
735                $pout = $rendered->getRevisionParserOutput();
736
737                $out->addParserOutput( $pout, $popts, [
738                    'enableSectionEditLinks' => false,
739                ] );
740            } catch ( RevisionAccessException ) {
741            }
742        }
743
744        $out->enableOOUI();
745        $buttonFields = [];
746
747        if ( $isText ) {
748            '@phan-var TextContent $content';
749            // TODO: MCR: make this work for multiple slots
750            // source view for textual content
751            $sourceView = Html::element( 'textarea', [
752                'readonly' => 'readonly',
753                'cols' => 80,
754                'rows' => 25
755            ], $content->getText() . "\n" );
756
757            $buttonFields[] = new ButtonInputWidget( [
758                'type' => 'submit',
759                'name' => 'preview',
760                'label' => $this->msg( 'showpreview' )->text()
761            ] );
762        } else {
763            $sourceView = '';
764        }
765
766        $buttonFields[] = new ButtonInputWidget( [
767            'name' => 'diff',
768            'type' => 'submit',
769            'label' => $this->msg( 'showdiff' )->text()
770        ] );
771
772        $out->addHTML(
773            $sourceView .
774                Html::openElement( 'div', [
775                    'style' => 'clear: both' ] ) .
776                Html::openElement( 'form', [
777                    'method' => 'post',
778                    'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ) ] ) .
779                Html::element( 'input', [
780                    'type' => 'hidden',
781                    'name' => 'target',
782                    'value' => $this->mTargetObj->getPrefixedDBkey() ] ) .
783                Html::element( 'input', [
784                    'type' => 'hidden',
785                    'name' => 'timestamp',
786                    'value' => $timestamp ] ) .
787                Html::element( 'input', [
788                    'type' => 'hidden',
789                    'name' => 'wpEditToken',
790                    'value' => $user->getEditToken() ] ) .
791                new FieldLayout(
792                    new Widget( [
793                        'content' => new HorizontalLayout( [
794                            'items' => $buttonFields
795                        ] )
796                    ] )
797                ) .
798                Html::closeElement( 'form' ) .
799                Html::closeElement( 'div' )
800        );
801    }
802
803    /**
804     * Build a diff display between this and the previous either deleted
805     * or non-deleted edit.
806     *
807     * @param RevisionRecord $previousRevRecord
808     * @param RevisionRecord $currentRevRecord
809     */
810    private function showDiff(
811        RevisionRecord $previousRevRecord,
812        RevisionRecord $currentRevRecord
813    ) {
814        $currentTitle = Title::newFromPageIdentity( $currentRevRecord->getPage() );
815
816        $diffContext = new DerivativeContext( $this->getContext() );
817        $diffContext->setTitle( $currentTitle );
818        $diffContext->setWikiPage( $this->wikiPageFactory->newFromTitle( $currentTitle ) );
819
820        $contentModel = $currentRevRecord->getSlot(
821            SlotRecord::MAIN,
822            RevisionRecord::RAW
823        )->getModel();
824
825        $diffEngine = $this->contentHandlerFactory->getContentHandler( $contentModel )
826            ->createDifferenceEngine( $diffContext );
827
828        $diffEngine->setRevisions( $previousRevRecord, $currentRevRecord );
829        $diffEngine->showDiffStyle();
830        $formattedDiff = $diffEngine->getDiff(
831            $this->diffHeader( $previousRevRecord, 'o' ),
832            $this->diffHeader( $currentRevRecord, 'n' )
833        );
834
835        if ( $formattedDiff === false ) {
836            if ( $diffEngine->hasSuppressedRevision() ) {
837                $error = 'rev-suppressed-no-diff';
838            } elseif ( $diffEngine->hasDeletedRevision() ) {
839                $error = 'rev-deleted-no-diff';
840            } else {
841                // Something else went wrong when loading the diff - at least explain that something was wrong ...
842                $error = 'undelete-error-loading-diff';
843            }
844            $this->getOutput()->addHTML( $this->msg( $error )->parse() );
845        } else {
846            $this->getOutput()->addHTML( "<div>$formattedDiff</div>\n" );
847        }
848    }
849
850    /**
851     * @param RevisionRecord $revRecord
852     * @param string $prefix
853     * @return string
854     */
855    private function diffHeader( RevisionRecord $revRecord, $prefix ) {
856        if ( $revRecord instanceof RevisionArchiveRecord ) {
857            // Revision in the archive table, only viewable via this special page
858            $targetPage = $this->getPageTitle();
859            $targetQuery = [
860                'target' => $this->mTargetObj->getPrefixedText(),
861                'timestamp' => wfTimestamp( TS::MW, $revRecord->getTimestamp() )
862            ];
863        } else {
864            // Revision in the revision table, viewable by oldid
865            $targetPage = $revRecord->getPageAsLinkTarget();
866            $targetQuery = [ 'oldid' => $revRecord->getId() ];
867        }
868
869        // Add show/hide deletion links if available
870        $user = $this->getUser();
871        $lang = $this->getLanguage();
872        $rdel = Linker::getRevDeleteLink( $user, $revRecord, $this->mTargetObj );
873
874        if ( $rdel ) {
875            $rdel = " $rdel";
876        }
877
878        $minor = $revRecord->isMinor() ? ChangesList::flag( 'minor' ) : '';
879
880        $dbr = $this->dbProvider->getReplicaDatabase();
881        $tagIds = $dbr->newSelectQueryBuilder()
882            ->select( 'ct_tag_id' )
883            ->from( 'change_tag' )
884            ->where( [ 'ct_rev_id' => $revRecord->getId() ] )
885            ->caller( __METHOD__ )->fetchFieldValues();
886        $tags = [];
887        foreach ( $tagIds as $tagId ) {
888            try {
889                $tags[] = $this->changeTagDefStore->getName( (int)$tagId );
890            } catch ( NameTableAccessException ) {
891                continue;
892            }
893        }
894        $tags = implode( ',', $tags );
895        $tagSummary = ChangeTags::formatSummaryRow( $tags, 'deleteddiff', $this->getContext() );
896        $asof = $this->getLinkRenderer()->makeLink(
897            $targetPage,
898            $this->msg(
899                'revisionasof',
900                $lang->userTimeAndDate( $revRecord->getTimestamp(), $user ),
901                $lang->userDate( $revRecord->getTimestamp(), $user ),
902                $lang->userTime( $revRecord->getTimestamp(), $user )
903            )->text(),
904            [],
905            $targetQuery
906        );
907        if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
908            $asof = Html::rawElement(
909                'span',
910                [ 'class' => Linker::getRevisionDeletedClass( $revRecord ) ],
911                $asof
912            );
913        }
914
915        // FIXME This is reimplementing DifferenceEngine#getRevisionHeader
916        // and partially #showDiffPage, but worse
917        return '<div id="mw-diff-' . $prefix . 'title1"><strong>' .
918            $asof .
919            '</strong></div>' .
920            '<div id="mw-diff-' . $prefix . 'title2">' .
921            Linker::revUserTools( $revRecord ) . '<br />' .
922            '</div>' .
923            '<div id="mw-diff-' . $prefix . 'title3">' .
924            $minor . $this->commentFormatter->formatRevision( $revRecord, $user ) . $rdel . '<br />' .
925            '</div>' .
926            '<div id="mw-diff-' . $prefix . 'title5">' .
927            $tagSummary[0] . '<br />' .
928            '</div>';
929    }
930
931    /**
932     * Show a form confirming whether a tokenless user really wants to see a file
933     * @param string $key
934     */
935    private function showFileConfirmationForm( $key ) {
936        $out = $this->getOutput();
937        $lang = $this->getLanguage();
938        $user = $this->getUser();
939        $file = new ArchivedFile( $this->mTargetObj, 0, $this->mFilename );
940        $out->addWikiMsg( 'undelete-show-file-confirm',
941            $this->mTargetObj->getText(),
942            $lang->userDate( $file->getTimestamp(), $user ),
943            $lang->userTime( $file->getTimestamp(), $user ) );
944        $out->addHTML(
945            Html::rawElement( 'form', [
946                    'method' => 'POST',
947                    'action' => $this->getPageTitle()->getLocalURL( [
948                        'target' => $this->mTarget,
949                        'file' => $key,
950                        'token' => $user->getEditToken( $key ),
951                    ] ),
952                ],
953                Html::submitButton( $this->msg( 'undelete-show-file-submit' )->text() )
954            )
955        );
956    }
957
958    /**
959     * Show a deleted file version requested by the visitor.
960     * @param string $key
961     */
962    private function showFile( $key ) {
963        $this->getOutput()->disable();
964
965        # We mustn't allow the output to be CDN cached, otherwise
966        # if an admin previews a deleted image, and it's cached, then
967        # a user without appropriate permissions can toddle off and
968        # nab the image, and CDN will serve it
969        $response = $this->getRequest()->response();
970        $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
971        $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' );
972
973        $path = $this->localRepo->getZonePath( 'deleted' ) . '/' . $this->localRepo->getDeletedHashPath( $key ) . $key;
974        $this->localRepo->streamFileWithStatus( $path );
975    }
976
977    private function addRevisionsToBatch( LinkBatch $batch, IResultWrapper $revisions ) {
978        foreach ( $revisions as $row ) {
979            $batch->addUser( new UserIdentityValue( (int)$row->ar_user, $row->ar_user_text ) );
980        }
981    }
982
983    private function addFilesToBatch( LinkBatch $batch, IResultWrapper $files ) {
984        foreach ( $files as $row ) {
985            $batch->add( NS_USER, $row->fa_user_text );
986            $batch->add( NS_USER_TALK, $row->fa_user_text );
987        }
988    }
989
990    /**
991     * Handle XHR "show more history" requests (T249977)
992     */
993    protected function showMoreHistory() {
994        $out = $this->getOutput();
995        $out->setArticleBodyOnly( true );
996        $dbr = $this->dbProvider->getReplicaDatabase();
997        if ( $this->mHistoryOffset ) {
998            $extraConds = [ $dbr->expr( 'ar_timestamp', '<', $dbr->timestamp( $this->mHistoryOffset ) ) ];
999        } else {
1000            $extraConds = [];
1001        }
1002        $revisions = $this->archivedRevisionLookup->listRevisions(
1003            $this->mTargetObj,
1004            $extraConds,
1005            self::REVISION_HISTORY_LIMIT + 1
1006        );
1007        $batch = $this->linkBatchFactory->newLinkBatch()->setCaller( __METHOD__ );
1008        $this->addRevisionsToBatch( $batch, $revisions );
1009        $batch->execute();
1010        $out->addHTML( $this->formatRevisionHistory( $revisions ) );
1011
1012        if ( $revisions->numRows() > self::REVISION_HISTORY_LIMIT ) {
1013            // Indicate to JS that the "show more" button should remain active
1014            $out->setStatusCode( 206 );
1015        }
1016    }
1017
1018    /**
1019     * Generate the <ul> element representing a list of deleted revisions
1020     *
1021     * @param IResultWrapper $revisions
1022     * @return string
1023     */
1024    protected function formatRevisionHistory( IResultWrapper $revisions ) {
1025        $history = Html::openElement( 'ul', [ 'class' => 'mw-undelete-revlist' ] );
1026
1027        // Exclude the last data row if there is more data than history limit amount
1028        $numRevisions = $revisions->numRows();
1029        $displayCount = min( $numRevisions, self::REVISION_HISTORY_LIMIT );
1030        $firstRev = $this->revisionStore->getFirstRevision( $this->mTargetObj );
1031        $earliestLiveTime = $firstRev ? $firstRev->getTimestamp() : null;
1032
1033        $revisions->rewind();
1034        for ( $i = 0; $i < $displayCount; $i++ ) {
1035            $row = $revisions->fetchObject();
1036            // The $remaining parameter controls diff links and so must
1037            // include the undisplayed row beyond the display limit.
1038            $history .= $this->formatRevisionRow( $row, $earliestLiveTime, $numRevisions - $i );
1039        }
1040        $history .= Html::closeElement( 'ul' );
1041        return $history;
1042    }
1043
1044    protected function showHistory() {
1045        $this->checkReadOnly();
1046
1047        $out = $this->getOutput();
1048        if ( $this->mAllowed ) {
1049            $out->addModules( 'mediawiki.misc-authed-ooui' );
1050            $out->addModuleStyles( 'mediawiki.special' );
1051        }
1052        $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
1053        $out->wrapWikiMsg(
1054            "<div class='mw-undelete-pagetitle'>\n$1\n</div>\n",
1055            [ 'undeletepagetitle', wfEscapeWikiText( $this->mTargetObj->getPrefixedText() ) ]
1056        );
1057
1058        $archive = new PageArchive( $this->mTargetObj );
1059        // FIXME: This hook must be deprecated, passing PageArchive by ref is awful.
1060        $this->getHookRunner()->onUndeleteForm__showHistory( $archive, $this->mTargetObj );
1061
1062        $out->addHTML( Html::openElement( 'div', [ 'class' => 'mw-undelete-history' ] ) );
1063        if ( $this->mAllowed ) {
1064            $out->addWikiMsg( 'undeletehistory' );
1065            $out->addWikiMsg( 'undeleterevdel' );
1066        } else {
1067            $out->addWikiMsg( 'undeletehistorynoadmin' );
1068        }
1069        $out->addHTML( Html::closeElement( 'div' ) );
1070
1071        # List all stored revisions
1072        $revisions = $this->archivedRevisionLookup->listRevisions(
1073            $this->mTargetObj,
1074            [],
1075            self::REVISION_HISTORY_LIMIT + 1
1076        );
1077        $files = $archive->listFiles();
1078        $numRevisions = $revisions->numRows();
1079        $showLoadMore = $numRevisions > self::REVISION_HISTORY_LIMIT;
1080        $haveRevisions = $numRevisions > 0;
1081        $haveFiles = $files && $files->numRows() > 0;
1082
1083        # Batch existence check on user and talk pages
1084        if ( $haveRevisions || $haveFiles ) {
1085            $batch = $this->linkBatchFactory->newLinkBatch()->setCaller( __METHOD__ );
1086            $this->addRevisionsToBatch( $batch, $revisions );
1087            if ( $haveFiles ) {
1088                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable -- $files is non-null
1089                $this->addFilesToBatch( $batch, $files );
1090            }
1091            $batch->execute();
1092        }
1093
1094        if ( $this->mAllowed ) {
1095            $out->enableOOUI();
1096
1097            $action = $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] );
1098            # Start the form here
1099            $form = new FormLayout( [
1100                'method' => 'post',
1101                'action' => $action,
1102                'id' => 'undelete',
1103            ] );
1104        }
1105
1106        # Show relevant lines from the deletion log:
1107        $deleteLogPage = new LogPage( 'delete' );
1108        $out->addHTML( Html::element( 'h2', [], $deleteLogPage->getName()->text() ) . "\n" );
1109        LogEventsList::showLogExtract( $out, 'delete', $this->mTargetObj );
1110        # Show relevant lines from the suppression log:
1111        $suppressLogPage = new LogPage( 'suppress' );
1112        if ( $this->permissionManager->userHasRight( $this->getUser(), 'suppressionlog' ) ) {
1113            $out->addHTML( Html::element( 'h2', [], $suppressLogPage->getName()->text() ) . "\n" );
1114            LogEventsList::showLogExtract( $out, 'suppress', $this->mTargetObj );
1115        }
1116
1117        if ( $this->mAllowed && ( $haveRevisions || $haveFiles ) ) {
1118            $unsuppressAllowed = $this->permissionManager->userHasRight( $this->getUser(), 'suppressrevision' );
1119            $fields = [];
1120            $fields[] = new Layout( [
1121                'content' => new HtmlSnippet( $this->msg( 'undeleteextrahelp' )->parseAsBlock() )
1122            ] );
1123
1124            $dropdownComment = $this->msg( 'undelete-comment-dropdown' )
1125                ->page( $this->mTargetObj )->inContentLanguage()->text();
1126            // Add additional specific reasons for unsuppress
1127            if ( $unsuppressAllowed ) {
1128                $dropdownComment .= "\n" . $this->msg( 'undelete-comment-dropdown-unsuppress' )
1129                    ->page( $this->mTargetObj )->inContentLanguage()->text();
1130            }
1131            $options = Html::listDropdownOptions(
1132                $dropdownComment,
1133                [ 'other' => $this->msg( 'undeletecommentotherlist' )->text() ]
1134            );
1135            $options = Html::listDropdownOptionsOoui( $options );
1136
1137            $fields[] = new FieldLayout(
1138                new DropdownInputWidget( [
1139                    'name' => 'wpCommentList',
1140                    'inputId' => 'wpCommentList',
1141                    'infusable' => true,
1142                    'value' => $this->getRequest()->getText( 'wpCommentList', 'other' ),
1143                    'options' => $options,
1144                ] ),
1145                [
1146                    'label' => $this->msg( 'undeletecomment' )->text(),
1147                    'align' => 'top',
1148                ]
1149            );
1150
1151            $fields[] = new FieldLayout(
1152                new TextInputWidget( [
1153                    'name' => 'wpComment',
1154                    'inputId' => 'wpComment',
1155                    'infusable' => true,
1156                    'value' => $this->getRequest()->getText( 'wpComment' ),
1157                    'autofocus' => true,
1158                    // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
1159                    // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
1160                    // Unicode codepoints.
1161                    'maxLength' => CommentStore::COMMENT_CHARACTER_LIMIT,
1162                ] ),
1163                [
1164                    'label' => $this->msg( 'undeleteothercomment' )->text(),
1165                    'align' => 'top',
1166                ]
1167            );
1168
1169            if ( $this->getUser()->isRegistered() ) {
1170                $checkWatch = $this->watchlistManager->isWatched( $this->getUser(), $this->mTargetObj )
1171                    || $this->getRequest()->getText( 'wpWatch' );
1172                $fields[] = new FieldLayout(
1173                    new CheckboxInputWidget( [
1174                        'name' => 'wpWatch',
1175                        'inputId' => 'mw-undelete-watch',
1176                        'value' => '1',
1177                        'selected' => $checkWatch,
1178                    ] ),
1179                    [
1180                        'label' => $this->msg( 'watchthis' )->text(),
1181                        'align' => 'inline',
1182                    ]
1183                );
1184            }
1185
1186            if ( $unsuppressAllowed ) {
1187                $fields[] = new FieldLayout(
1188                    new CheckboxInputWidget( [
1189                        'name' => 'wpUnsuppress',
1190                        'inputId' => 'mw-undelete-unsuppress',
1191                        'value' => '1',
1192                    ] ),
1193                    [
1194                        'label' => $this->msg( 'revdelete-unsuppress' )->text(),
1195                        'align' => 'inline',
1196                    ]
1197                );
1198            }
1199
1200            $undelPage = $this->undeletePageFactory->newUndeletePage(
1201                $this->wikiPageFactory->newFromTitle( $this->mTargetObj ),
1202                $this->getContext()->getAuthority()
1203            );
1204            if ( $undelPage->canProbablyUndeleteAssociatedTalk()->isGood() ) {
1205                $fields[] = new FieldLayout(
1206                    new CheckboxInputWidget( [
1207                        'name' => 'undeletetalk',
1208                        'inputId' => 'mw-undelete-undeletetalk',
1209                        'selected' => false,
1210                    ] ),
1211                    [
1212                        'label' => $this->msg( 'undelete-undeletetalk' )->text(),
1213                        'align' => 'inline',
1214                    ]
1215                );
1216            }
1217
1218            $fields[] = new FieldLayout(
1219                new Widget( [
1220                    'content' => new HorizontalLayout( [
1221                        'items' => [
1222                            new ButtonInputWidget( [
1223                                'name' => 'restore',
1224                                'inputId' => 'mw-undelete-submit',
1225                                'value' => '1',
1226                                'label' => $this->msg( 'undeletebtn' )->text(),
1227                                'flags' => [ 'primary', 'progressive' ],
1228                                'type' => 'submit',
1229                            ] ),
1230                            new ButtonInputWidget( [
1231                                'name' => 'invert',
1232                                'inputId' => 'mw-undelete-invert',
1233                                'value' => '1',
1234                                'label' => $this->msg( 'undeleteinvert' )->text()
1235                            ] ),
1236                        ]
1237                    ] )
1238                ] )
1239            );
1240
1241            $fieldset = new FieldsetLayout( [
1242                'label' => $this->msg( 'undelete-fieldset-title' )->text(),
1243                'id' => 'mw-undelete-table',
1244                'items' => $fields,
1245            ] );
1246
1247            $link = '';
1248            if ( $this->getAuthority()->isAllowed( 'editinterface' ) ) {
1249                if ( $unsuppressAllowed ) {
1250                    $link .= $this->getLinkRenderer()->makeKnownLink(
1251                        $this->msg( 'undelete-comment-dropdown-unsuppress' )->inContentLanguage()->getTitle(),
1252                        $this->msg( 'undelete-edit-commentlist-unsuppress' )->text(),
1253                        [],
1254                        [ 'action' => 'edit' ]
1255                    );
1256                    $link .= $this->msg( 'pipe-separator' )->escaped();
1257                }
1258                $link .= $this->getLinkRenderer()->makeKnownLink(
1259                    $this->msg( 'undelete-comment-dropdown' )->inContentLanguage()->getTitle(),
1260                    $this->msg( 'undelete-edit-commentlist' )->text(),
1261                    [],
1262                    [ 'action' => 'edit' ]
1263                );
1264
1265                $link = Html::rawElement( 'p', [ 'class' => 'mw-undelete-editcomments' ], $link );
1266            }
1267
1268            // @phan-suppress-next-line PhanPossiblyUndeclaredVariable form is set, when used here
1269            $form->appendContent(
1270                new PanelLayout( [
1271                    'expanded' => false,
1272                    'padded' => true,
1273                    'framed' => true,
1274                    'content' => $fieldset,
1275                ] ),
1276                new HtmlSnippet(
1277                    $link .
1278                    Html::hidden( 'target', $this->mTarget ) .
1279                    Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() )
1280                )
1281            );
1282        }
1283
1284        $history = '';
1285        $history .= Html::element( 'h2', [], $this->msg( 'history' )->text() ) . "\n";
1286
1287        if ( $haveRevisions ) {
1288            # Show the page's stored (deleted) history
1289
1290            if ( $this->mAllowed && $this->permissionManager->userHasRight( $this->getUser(), 'deleterevision' ) ) {
1291                $history .= Html::element(
1292                    'button',
1293                    [
1294                        'name' => 'revdel',
1295                        'type' => 'submit',
1296                        'class' => [ 'deleterevision-log-submit', 'mw-log-deleterevision-button' ]
1297                    ],
1298                    $this->msg( 'showhideselectedversions' )->text()
1299                ) . "\n";
1300            }
1301
1302            $history .= $this->formatRevisionHistory( $revisions );
1303
1304            if ( $showLoadMore ) {
1305                $history .=
1306                    Html::openElement( 'div' ) .
1307                    Html::element(
1308                        'span',
1309                        [ 'id' => 'mw-load-more-revisions' ],
1310                        $this->msg( 'undelete-load-more-revisions' )->text()
1311                    ) .
1312                    Html::closeElement( 'div' ) .
1313                    "\n";
1314            }
1315        } else {
1316            $out->addWikiMsg( 'nohistory' );
1317        }
1318
1319        if ( $haveFiles ) {
1320            $history .= Html::element( 'h2', [], $this->msg( 'filehist' )->text() ) . "\n";
1321            $history .= Html::openElement( 'ul', [ 'class' => 'mw-undelete-revlist' ] );
1322            foreach ( $files as $row ) {
1323                $history .= $this->formatFileRow( $row );
1324            }
1325            $files->free();
1326            $history .= Html::closeElement( 'ul' );
1327        }
1328
1329        if ( $this->mAllowed ) {
1330            # Slip in the hidden controls here
1331            $misc = Html::hidden( 'target', $this->mTarget );
1332            $misc .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() );
1333            $history .= $misc;
1334
1335            // @phan-suppress-next-line PhanPossiblyUndeclaredVariable form is set, when used here
1336            $form->appendContent( new HtmlSnippet( $history ) );
1337            // @phan-suppress-next-line PhanPossiblyUndeclaredVariable form is set, when used here
1338            $out->addHTML( (string)$form );
1339        } else {
1340            $out->addHTML( $history );
1341        }
1342    }
1343
1344    /**
1345     * @param \stdClass $row
1346     * @param string $earliestLiveTime
1347     * @param int $remaining
1348     * @return string
1349     */
1350    protected function formatRevisionRow( $row, $earliestLiveTime, $remaining ) {
1351        $revRecord = $this->revisionStore->newRevisionFromArchiveRow(
1352                $row,
1353                IDBAccessObject::READ_NORMAL,
1354                $this->mTargetObj
1355            );
1356
1357        $revTextSize = '';
1358        $ts = wfTimestamp( TS::MW, $row->ar_timestamp );
1359        // Build checkboxen...
1360        if ( $this->mAllowed ) {
1361            if ( $this->mInvert ) {
1362                if ( in_array( $ts, $this->mTargetTimestamp ) ) {
1363                    $checkBox = Html::check( "ts$ts" );
1364                } else {
1365                    $checkBox = Html::check( "ts$ts", true );
1366                }
1367            } else {
1368                $checkBox = Html::check( "ts$ts" );
1369            }
1370        } else {
1371            $checkBox = '';
1372        }
1373
1374        // Build page & diff links...
1375        $user = $this->getUser();
1376        if ( $this->mCanView ) {
1377            $titleObj = $this->getPageTitle();
1378            # Last link
1379            if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
1380                $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
1381                $last = $this->msg( 'diff' )->escaped();
1382            } elseif ( $remaining > 0 || ( $earliestLiveTime && $ts > $earliestLiveTime ) ) {
1383                $pageLink = $this->getPageLink( $revRecord, $titleObj, $ts );
1384                $last = $this->getLinkRenderer()->makeKnownLink(
1385                    $titleObj,
1386                    $this->msg( 'diff' )->text(),
1387                    [],
1388                    [
1389                        'target' => $this->mTargetObj->getPrefixedText(),
1390                        'timestamp' => $ts,
1391                        'diff' => 'prev'
1392                    ]
1393                );
1394            } else {
1395                $pageLink = $this->getPageLink( $revRecord, $titleObj, $ts );
1396                $last = $this->msg( 'diff' )->escaped();
1397            }
1398        } else {
1399            $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
1400            $last = $this->msg( 'diff' )->escaped();
1401        }
1402
1403        // User links
1404        $userLink = Linker::revUserTools( $revRecord );
1405
1406        // Minor edit
1407        $minor = $revRecord->isMinor() ? ChangesList::flag( 'minor' ) : '';
1408
1409        // Revision text size
1410        $size = $row->ar_len;
1411        if ( $size !== null ) {
1412            $revTextSize = Linker::formatRevisionSize( $size );
1413        }
1414
1415        // Edit summary
1416        $comment = $this->commentFormatter->formatRevision( $revRecord, $user );
1417
1418        // Tags
1419        $attribs = [];
1420        [ $tagSummary, $classes ] = ChangeTags::formatSummaryRow(
1421            $row->ts_tags,
1422            'deletedhistory',
1423            $this->getContext()
1424        );
1425        $attribs['class'] = $classes;
1426
1427        $revisionRow = $this->msg( 'undelete-revision-row2' )
1428            ->rawParams(
1429                $checkBox,
1430                $last,
1431                $pageLink,
1432                $userLink,
1433                $minor,
1434                $revTextSize,
1435                $comment,
1436                $tagSummary
1437            )
1438            ->escaped();
1439
1440        return Html::rawElement( 'li', $attribs, $revisionRow ) . "\n";
1441    }
1442
1443    private function formatFileRow( \stdClass $row ): string {
1444        $file = ArchivedFile::newFromRow( $row );
1445        $ts = wfTimestamp( TS::MW, $row->fa_timestamp );
1446        $user = $this->getUser();
1447
1448        $checkBox = '';
1449        if ( $this->mCanView && $row->fa_storage_key ) {
1450            if ( $this->mAllowed ) {
1451                $checkBox = Html::check( 'fileid' . $row->fa_id );
1452            }
1453            $key = urlencode( $row->fa_storage_key );
1454            $pageLink = $this->getFileLink( $file, $this->getPageTitle(), $ts, $key );
1455        } else {
1456            $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
1457        }
1458        $userLink = $this->getFileUser( $file );
1459        $data = $this->msg( 'widthheight' )->numParams( $row->fa_width, $row->fa_height )->text();
1460        $bytes = $this->msg( 'parentheses' )
1461            ->plaintextParams( $this->msg( 'nbytes' )->numParams( $row->fa_size )->text() )
1462            ->plain();
1463        $data = htmlspecialchars( $data . ' ' . $bytes );
1464        $comment = $this->getFileComment( $file );
1465
1466        // Add show/hide deletion links if available
1467        $canHide = $this->isAllowed( 'deleterevision' );
1468        if ( $canHide || ( $file->getVisibility() && $this->isAllowed( 'deletedhistory' ) ) ) {
1469            if ( !$file->userCan( File::DELETED_RESTRICTED, $user ) ) {
1470                // Revision was hidden from sysops
1471                $revdlink = Linker::revDeleteLinkDisabled( $canHide );
1472            } else {
1473                $query = [
1474                    'type' => 'filearchive',
1475                    'target' => $this->mTargetObj->getPrefixedDBkey(),
1476                    'ids' => $row->fa_id
1477                ];
1478                $revdlink = Linker::revDeleteLink( $query,
1479                    $file->isDeleted( File::DELETED_RESTRICTED ), $canHide );
1480            }
1481        } else {
1482            $revdlink = '';
1483        }
1484
1485        return "<li>$checkBox $revdlink $pageLink . . $userLink $data $comment</li>\n";
1486    }
1487
1488    /**
1489     * Fetch revision text link if it's available to all users
1490     *
1491     * @param RevisionRecord $revRecord
1492     * @param LinkTarget $target
1493     * @param string $ts Timestamp
1494     * @return string
1495     */
1496    private function getPageLink( RevisionRecord $revRecord, LinkTarget $target, $ts ) {
1497        $user = $this->getUser();
1498        $time = $this->getLanguage()->userTimeAndDate( $ts, $user );
1499
1500        if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
1501            // TODO The condition cannot be true when the function is called
1502            return Html::element(
1503                'span',
1504                [ 'class' => 'history-deleted' ],
1505                $time
1506            );
1507        }
1508
1509        $link = $this->getLinkRenderer()->makeKnownLink(
1510            $target,
1511            $time,
1512            [],
1513            [
1514                'target' => $this->mTargetObj->getPrefixedText(),
1515                'timestamp' => $ts
1516            ]
1517        );
1518
1519        if ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
1520            $class = Linker::getRevisionDeletedClass( $revRecord );
1521            $link = '<span class="' . $class . '">' . $link . '</span>';
1522        }
1523
1524        return $link;
1525    }
1526
1527    /**
1528     * Fetch image view link if it's available to all users
1529     *
1530     * @param File|ArchivedFile $file
1531     * @param LinkTarget $target
1532     * @param string $ts A timestamp
1533     * @param string $key A storage key
1534     *
1535     * @return string HTML fragment
1536     */
1537    private function getFileLink( $file, LinkTarget $target, $ts, $key ) {
1538        $user = $this->getUser();
1539        $time = $this->getLanguage()->userTimeAndDate( $ts, $user );
1540
1541        if ( !$file->userCan( File::DELETED_FILE, $user ) ) {
1542            return Html::element(
1543                'span',
1544                [ 'class' => 'history-deleted' ],
1545                $time
1546            );
1547        }
1548
1549        if ( $file->exists() ) {
1550            $link = $this->getLinkRenderer()->makeKnownLink(
1551                $target,
1552                $time,
1553                [],
1554                [
1555                    'target' => $this->mTargetObj->getPrefixedText(),
1556                    'file' => $key,
1557                    'token' => $user->getEditToken( $key )
1558                ]
1559            );
1560        } else {
1561            $link = htmlspecialchars( $time );
1562        }
1563
1564        if ( $file->isDeleted( File::DELETED_FILE ) ) {
1565            $link = '<span class="history-deleted">' . $link . '</span>';
1566        }
1567
1568        return $link;
1569    }
1570
1571    /**
1572     * Fetch file's user id if it's available to this user
1573     *
1574     * @param File|ArchivedFile $file
1575     * @return string HTML fragment
1576     */
1577    private function getFileUser( $file ) {
1578        $uploader = $file->getUploader( File::FOR_THIS_USER, $this->getAuthority() );
1579        if ( !$uploader ) {
1580            return Html::rawElement(
1581                'span',
1582                [ 'class' => 'history-deleted' ],
1583                $this->msg( 'rev-deleted-user' )->escaped()
1584            );
1585        }
1586
1587        $link = Linker::userLink( $uploader->getId(), $uploader->getName() ) .
1588            Linker::userToolLinks( $uploader->getId(), $uploader->getName() );
1589
1590        if ( $file->isDeleted( File::DELETED_USER ) ) {
1591            $link = Html::rawElement(
1592                'span',
1593                [ 'class' => 'history-deleted' ],
1594                $link
1595            );
1596        }
1597
1598        return $link;
1599    }
1600
1601    /**
1602     * Fetch file upload comment if it's available to this user
1603     *
1604     * @param File|ArchivedFile $file
1605     * @return string HTML fragment
1606     */
1607    private function getFileComment( $file ) {
1608        if ( !$file->userCan( File::DELETED_COMMENT, $this->getAuthority() ) ) {
1609            return Html::rawElement(
1610                'span',
1611                [ 'class' => 'history-deleted' ],
1612                Html::rawElement(
1613                    'span',
1614                    [ 'class' => 'comment' ],
1615                    $this->msg( 'rev-deleted-comment' )->escaped()
1616                )
1617            );
1618        }
1619
1620        $comment = $file->getDescription( File::FOR_THIS_USER, $this->getAuthority() );
1621        $link = $this->commentFormatter->formatBlock( $comment );
1622
1623        if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
1624            $link = Html::rawElement(
1625                'span',
1626                [ 'class' => 'history-deleted' ],
1627                $link
1628            );
1629        }
1630
1631        return $link;
1632    }
1633
1634    private function undelete() {
1635        if ( $this->getConfig()->get( MainConfigNames::UploadMaintenance )
1636            && $this->mTargetObj->getNamespace() === NS_FILE
1637        ) {
1638            throw new ErrorPageError( 'undelete-error', 'filedelete-maintenance' );
1639        }
1640
1641        $this->checkReadOnly();
1642
1643        $out = $this->getOutput();
1644        $undeletePage = $this->undeletePageFactory->newUndeletePage(
1645            $this->wikiPageFactory->newFromTitle( $this->mTargetObj ),
1646            $this->getAuthority()
1647        );
1648        if ( $this->mUndeleteTalk && $undeletePage->canProbablyUndeleteAssociatedTalk()->isGood() ) {
1649            $undeletePage->setUndeleteAssociatedTalk( true );
1650        }
1651        $status = $undeletePage
1652            ->setUndeleteOnlyTimestamps( $this->mTargetTimestamp )
1653            ->setUndeleteOnlyFileVersions( $this->mFileVersions )
1654            ->setUnsuppress( $this->mUnsuppress )
1655            // TODO This is currently duplicating some permission checks, but we do need it (T305680)
1656            ->undeleteIfAllowed( $this->mComment );
1657
1658        if ( !$status->isGood() ) {
1659            $out->setPageTitleMsg( $this->msg( 'undelete-error' ) );
1660            foreach ( $status->getMessages() as $msg ) {
1661                $out->addHTML( Html::errorBox(
1662                    $this->msg( $msg )->parse()
1663                ) );
1664            }
1665            return;
1666        }
1667
1668        $restoredRevs = $status->getValue()[UndeletePage::REVISIONS_RESTORED];
1669        $restoredFiles = $status->getValue()[UndeletePage::FILES_RESTORED];
1670
1671        if ( $restoredRevs === 0 && $restoredFiles === 0 ) {
1672            // TODO Should use a different message here
1673            $out->setPageTitleMsg( $this->msg( 'undelete-error' ) );
1674        } else {
1675            if ( $status->getValue()[UndeletePage::FILES_RESTORED] !== 0 ) {
1676                $this->getHookRunner()->onFileUndeleteComplete(
1677                    $this->mTargetObj, $this->mFileVersions, $this->getUser(), $this->mComment );
1678            }
1679
1680            $link = $this->getLinkRenderer()->makeKnownLink( $this->mTargetObj );
1681            $out->addWikiMsg( 'undeletedpage', Message::rawParam( $link ) );
1682
1683            $this->watchlistManager->setWatch(
1684                $this->getRequest()->getCheck( 'wpWatch' ),
1685                $this->getAuthority(),
1686                $this->mTargetObj
1687            );
1688        }
1689    }
1690
1691    /**
1692     * Return an array of subpages beginning with $search that this special page will accept.
1693     *
1694     * @param string $search Prefix to search for
1695     * @param int $limit Maximum number of results to return (usually 10)
1696     * @param int $offset Number of results to skip (usually 0)
1697     * @return string[] Matching subpages
1698     */
1699    public function prefixSearchSubpages( $search, $limit, $offset ) {
1700        return $this->prefixSearchString( $search, $limit, $offset, $this->searchEngineFactory );
1701    }
1702
1703    /** @inheritDoc */
1704    protected function getGroupName() {
1705        return 'pagetools';
1706    }
1707}
1708
1709/**
1710 * Retain the old class name for backwards compatibility.
1711 * @deprecated since 1.41
1712 */
1713class_alias( SpecialUndelete::class, 'SpecialUndelete' );