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