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