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