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