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