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