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