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