MediaWiki  master
UndeletePage.php
Go to the documentation of this file.
1 <?php
21 namespace MediaWiki\Page;
22 
23 use ChangeTags;
24 use File;
26 use JobQueueGroup;
27 use LocalFile;
28 use ManualLogEntry;
38 use Psr\Log\LoggerInterface;
39 use ReadOnlyError;
40 use ReadOnlyMode;
41 use RepoGroup;
42 use Status;
43 use StatusValue;
46 use WikiPage;
47 
52 class UndeletePage {
53 
54  // Constants used as keys in the StatusValue returned by undelete()
55  public const FILES_RESTORED = 'files';
56  public const REVISIONS_RESTORED = 'revs';
57 
59  private $hookRunner;
61  private $jobQueueGroup;
63  private $loadBalancer;
65  private $logger;
67  private $readOnlyMode;
69  private $repoGroup;
71  private $revisionStore;
74 
76  private $page;
78  private $performer;
80  private $fileStatus;
82  private $revisionStatus;
84  private $timestamps = [];
86  private $fileVersions = [];
88  private $unsuppress = false;
90  private $tags = [];
95 
110  public function __construct(
111  HookContainer $hookContainer,
113  ILoadBalancer $loadBalancer,
116  LoggerInterface $logger,
117  RevisionStore $revisionStore,
120  Authority $performer,
121  PageUpdaterFactory $pageUpdaterFactory,
122  IContentHandlerFactory $contentHandlerFactory
123  ) {
124  $this->hookRunner = new HookRunner( $hookContainer );
125  $this->jobQueueGroup = $jobQueueGroup;
126  $this->loadBalancer = $loadBalancer;
127  $this->readOnlyMode = $readOnlyMode;
128  $this->repoGroup = $repoGroup;
129  $this->logger = $logger;
130  $this->revisionStore = $revisionStore;
131  $this->wikiPageFactory = $wikiPageFactory;
132 
133  $this->page = $page;
134  $this->performer = $performer;
135  $this->pageUpdaterFactory = $pageUpdaterFactory;
136  $this->contentHandlerFactory = $contentHandlerFactory;
137  }
138 
145  public function setUnsuppress( bool $unsuppress ): self {
146  $this->unsuppress = $unsuppress;
147  return $this;
148  }
149 
156  public function setTags( array $tags ): self {
157  $this->tags = $tags;
158  return $this;
159  }
160 
167  public function setUndeleteOnlyTimestamps( array $timestamps ): self {
168  $this->timestamps = $timestamps;
169  return $this;
170  }
171 
178  public function setUndeleteOnlyFileVersions( array $fileVersions ): self {
179  $this->fileVersions = $fileVersions;
180  return $this;
181  }
182 
189  public function undeleteIfAllowed( string $comment ): StatusValue {
190  $status = $this->authorizeUndeletion();
191  if ( !$status->isGood() ) {
192  return $status;
193  }
194 
195  return $this->undeleteUnsafe( $comment );
196  }
197 
201  private function authorizeUndeletion(): PermissionStatus {
202  $status = PermissionStatus::newEmpty();
203  $this->performer->authorizeWrite( 'undelete', $this->page, $status );
204  if ( $this->tags ) {
205  $status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->performer ) );
206  }
207  return $status;
208  }
209 
228  public function undeleteUnsafe( string $comment ): StatusValue {
229  $hookStatus = StatusValue::newGood();
230  $hookRes = $this->hookRunner->onPageUndelete(
231  $this->page,
232  $this->performer,
233  $comment,
234  $this->unsuppress,
235  $this->timestamps,
236  $this->fileVersions,
237  $hookStatus
238  );
239  if ( !$hookRes && !$hookStatus->isGood() ) {
240  // Note: as per the PageUndeleteHook documentation, `return false` is ignored if $status is good.
241  return $hookStatus;
242  }
243  // If both the set of text revisions and file revisions are empty,
244  // restore everything. Otherwise, just restore the requested items.
245  $restoreAll = $this->timestamps === [] && $this->fileVersions === [];
246 
247  $restoreText = $restoreAll || $this->timestamps !== [];
248  $restoreFiles = $restoreAll || $this->fileVersions !== [];
249 
250  $resStatus = StatusValue::newGood();
251  if ( $restoreFiles && $this->page->getNamespace() === NS_FILE ) {
253  $img = $this->repoGroup->getLocalRepo()->newFile( $this->page );
254  $img->load( File::READ_LATEST );
255  $this->fileStatus = $img->restore( $this->fileVersions, $this->unsuppress );
256  if ( !$this->fileStatus->isOK() ) {
257  return $this->fileStatus;
258  }
259  $filesRestored = $this->fileStatus->successCount;
260  $resStatus->merge( $this->fileStatus );
261  } else {
262  $filesRestored = 0;
263  }
264 
265  if ( $restoreText ) {
266  $this->revisionStatus = $this->undeleteRevisions( $comment );
267  if ( !$this->revisionStatus->isOK() ) {
268  return $this->revisionStatus;
269  }
270 
271  $textRestored = $this->revisionStatus->getValue();
272  $resStatus->merge( $this->revisionStatus );
273  } else {
274  $textRestored = 0;
275  }
276 
277  $resStatus->value = [
278  self::REVISIONS_RESTORED => $textRestored,
279  self::FILES_RESTORED => $filesRestored
280  ];
281 
282  if ( !$textRestored && !$filesRestored ) {
283  $this->logger->debug( "Undelete: nothing undeleted..." );
284  return $resStatus;
285  }
286 
287  $logEntry = new ManualLogEntry( 'delete', 'restore' );
288  $logEntry->setPerformer( $this->performer->getUser() );
289  $logEntry->setTarget( $this->page );
290  $logEntry->setComment( $comment );
291  $logEntry->addTags( $this->tags );
292  $logEntry->setParameters( [
293  ':assoc:count' => [
294  'revisions' => $textRestored,
295  'files' => $filesRestored,
296  ],
297  ] );
298 
299  $logid = $logEntry->insert();
300  $logEntry->publish( $logid );
301 
302  return $resStatus;
303  }
304 
313  private function undeleteRevisions( string $comment ): StatusValue {
314  if ( $this->readOnlyMode->isReadOnly() ) {
315  throw new ReadOnlyError();
316  }
317 
318  $dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
319  $dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
320 
321  $oldWhere = [
322  'ar_namespace' => $this->page->getNamespace(),
323  'ar_title' => $this->page->getDBkey(),
324  ];
325  if ( $this->timestamps ) {
326  $oldWhere['ar_timestamp'] = array_map( [ $dbw, 'timestamp' ], $this->timestamps );
327  }
328 
330  $queryInfo = $revisionStore->getArchiveQueryInfo();
331  $queryInfo['tables'][] = 'revision';
332  $queryInfo['fields'][] = 'rev_id';
333  $queryInfo['joins']['revision'] = [ 'LEFT JOIN', 'ar_rev_id=rev_id' ];
334 
335  $result = $dbw->select(
336  $queryInfo['tables'],
337  $queryInfo['fields'],
338  $oldWhere,
339  __METHOD__,
340  [ 'ORDER BY' => 'ar_timestamp' ],
341  $queryInfo['joins']
342  );
343 
344  $rev_count = $result->numRows();
345  if ( !$rev_count ) {
346  $this->logger->debug( __METHOD__ . ": no revisions to restore" );
347 
348  $status = Status::newGood( 0 );
349  $status->warning( "undelete-no-results" );
350  $dbw->endAtomic( __METHOD__ );
351 
352  return $status;
353  }
354 
355  // We use ar_id because there can be duplicate ar_rev_id even for the same
356  // page. In this case, we may be able to restore the first one.
357  $restoreFailedArIds = [];
358 
359  // Map rev_id to the ar_id that is allowed to use it. When checking later,
360  // if it doesn't match, the current ar_id can not be restored.
361 
362  // Value can be an ar_id or -1 (-1 means no ar_id can use it, since the
363  // rev_id is taken before we even start the restore).
364  $allowedRevIdToArIdMap = [];
365 
366  $latestRestorableRow = null;
367 
368  foreach ( $result as $row ) {
369  if ( $row->ar_rev_id ) {
370  // rev_id is taken even before we start restoring.
371  if ( $row->ar_rev_id === $row->rev_id ) {
372  $restoreFailedArIds[] = $row->ar_id;
373  $allowedRevIdToArIdMap[$row->ar_rev_id] = -1;
374  } else {
375  // rev_id is not taken yet in the DB, but it might be taken
376  // by a prior revision in the same restore operation. If
377  // not, we need to reserve it.
378  if ( isset( $allowedRevIdToArIdMap[$row->ar_rev_id] ) ) {
379  $restoreFailedArIds[] = $row->ar_id;
380  } else {
381  $allowedRevIdToArIdMap[$row->ar_rev_id] = $row->ar_id;
382  $latestRestorableRow = $row;
383  }
384  }
385  } else {
386  // If ar_rev_id is null, there can't be a collision, and a
387  // rev_id will be chosen automatically.
388  $latestRestorableRow = $row;
389  }
390  }
391 
392  // move back
393  $result->seek( 0 );
394 
395  $wikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
396 
397  $oldPageId = 0;
399  $revision = null;
400  $created = true;
401  $oldcountable = false;
402  $updatedCurrentRevision = false;
403  $restoredRevCount = 0;
404  $restoredPages = [];
405 
406  // If there are no restorable revisions, we can skip most of the steps.
407  if ( $latestRestorableRow === null ) {
408  $failedRevisionCount = $rev_count;
409  } else {
410  // pass this to ArticleUndelete hook
411  $oldPageId = (int)$latestRestorableRow->ar_page_id;
412 
413  // Grab the content to check consistency with global state before restoring the page.
414  // XXX: The only current use case is Wikibase, which tries to enforce uniqueness of
415  // certain things across all pages. There may be a better way to do that.
416  $revision = $revisionStore->newRevisionFromArchiveRow(
417  $latestRestorableRow,
418  0,
419  $this->page
420  );
421 
422  foreach ( $revision->getSlotRoles() as $role ) {
423  $content = $revision->getContent( $role, RevisionRecord::RAW );
424  // NOTE: article ID may not be known yet. validateSave() should not modify the database.
425  $contentHandler = $this->contentHandlerFactory->getContentHandler( $content->getModel() );
426  $validationParams = new ValidationParams( $wikiPage, 0 );
427  $status = $contentHandler->validateSave( $content, $validationParams );
428  if ( !$status->isOK() ) {
429  $dbw->endAtomic( __METHOD__ );
430 
431  return $status;
432  }
433  }
434 
435  $pageId = $wikiPage->insertOn( $dbw, $latestRestorableRow->ar_page_id );
436  if ( $pageId === false ) {
437  // The page ID is reserved; let's pick another
438  $pageId = $wikiPage->insertOn( $dbw );
439  if ( $pageId === false ) {
440  // The page title must be already taken (race condition)
441  $created = false;
442  }
443  }
444 
445  # Does this page already exist? We'll have to update it...
446  if ( !$created ) {
447  # Load latest data for the current page (T33179)
448  $wikiPage->loadPageData( WikiPage::READ_EXCLUSIVE );
449  $pageId = $wikiPage->getId();
450  $oldcountable = $wikiPage->isCountable();
451 
452  $previousTimestamp = false;
453  $latestRevId = $wikiPage->getLatest();
454  if ( $latestRevId ) {
455  $previousTimestamp = $revisionStore->getTimestampFromId(
456  $latestRevId,
457  RevisionStore::READ_LATEST
458  );
459  }
460  if ( $previousTimestamp === false ) {
461  $this->logger->debug( __METHOD__ . ": existing page refers to a page_latest that does not exist" );
462 
463  $status = Status::newGood( 0 );
464  $status->warning( 'undeleterevision-missing' );
465  $dbw->cancelAtomic( __METHOD__ );
466 
467  return $status;
468  }
469  } else {
470  $previousTimestamp = 0;
471  }
472 
473  // Check if a deleted revision will become the current revision...
474  if ( $latestRestorableRow->ar_timestamp > $previousTimestamp ) {
475  // Check the state of the newest to-be version...
476  if ( !$this->unsuppress
477  && ( $latestRestorableRow->ar_deleted & RevisionRecord::DELETED_TEXT )
478  ) {
479  $dbw->cancelAtomic( __METHOD__ );
480 
481  return Status::newFatal( "undeleterevdel" );
482  }
483  $updatedCurrentRevision = true;
484  }
485 
486  foreach ( $result as $row ) {
487  // Check for key dupes due to needed archive integrity.
488  if ( $row->ar_rev_id && $allowedRevIdToArIdMap[$row->ar_rev_id] !== $row->ar_id ) {
489  continue;
490  }
491  // Insert one revision at a time...maintaining deletion status
492  // unless we are specifically removing all restrictions...
493  $revision = $revisionStore->newRevisionFromArchiveRow(
494  $row,
495  0,
496  $this->page,
497  [
498  'page_id' => $pageId,
499  'deleted' => $this->unsuppress ? 0 : $row->ar_deleted
500  ]
501  );
502 
503  // This will also copy the revision to ip_changes if it was an IP edit.
504  $revisionStore->insertRevisionOn( $revision, $dbw );
505 
506  $restoredRevCount++;
507 
508  $this->hookRunner->onRevisionUndeleted( $revision, $row->ar_page_id );
509 
510  $restoredPages[$row->ar_page_id] = true;
511  }
512 
513  // Now that it's safely stored, take it out of the archive
514  // Don't delete rows that we failed to restore
515  $toDeleteConds = $oldWhere;
516  $failedRevisionCount = count( $restoreFailedArIds );
517  if ( $failedRevisionCount > 0 ) {
518  $toDeleteConds[] = 'ar_id NOT IN ( ' . $dbw->makeList( $restoreFailedArIds ) . ' )';
519  }
520 
521  $dbw->delete( 'archive',
522  $toDeleteConds,
523  __METHOD__ );
524  }
525 
526  $status = Status::newGood( $restoredRevCount );
527 
528  if ( $failedRevisionCount > 0 ) {
529  $status->warning( 'undeleterevision-duplicate-revid', $failedRevisionCount );
530  }
531 
532  // Was anything restored at all?
533  if ( $restoredRevCount ) {
534 
535  if ( $updatedCurrentRevision ) {
536  // Attach the latest revision to the page...
537  // XXX: updateRevisionOn should probably move into a PageStore service.
538  $wasnew = $wikiPage->updateRevisionOn(
539  $dbw,
540  $revision,
541  $created ? 0 : $wikiPage->getLatest()
542  );
543  } else {
544  $wasnew = false;
545  }
546 
547  if ( $created || $wasnew ) {
548  // Update site stats, link tables, etc
549  $user = $revision->getUser( RevisionRecord::RAW );
550  $options = [
551  'created' => $created,
552  'oldcountable' => $oldcountable,
553  'restored' => true,
554  'causeAction' => 'edit-page',
555  'causeAgent' => $user->getName(),
556  ];
557 
558  $updater = $this->pageUpdaterFactory->newDerivedPageDataUpdater( $wikiPage );
559  $updater->prepareUpdate( $revision, $options );
560  $updater->doUpdates();
561  }
562 
563  $this->hookRunner->onArticleUndelete(
564  $wikiPage->getTitle(), $created, $comment, $oldPageId, $restoredPages );
565 
566  if ( $this->page->getNamespace() === NS_FILE ) {
568  $this->page,
569  'imagelinks',
570  [ 'causeAction' => 'file-restore' ]
571  );
572  $this->jobQueueGroup->lazyPush( $job );
573  }
574  }
575 
576  $dbw->endAtomic( __METHOD__ );
577 
578  return $status;
579  }
580 
585  public function getFileStatus(): ?Status {
586  return $this->fileStatus;
587  }
588 
593  public function getRevisionStatus(): ?StatusValue {
594  return $this->revisionStatus;
595  }
596 }
ReadOnlyError
Show an error when the wiki is locked/read-only and the user tries to do something that requires writ...
Definition: ReadOnlyError.php:29
MediaWiki\Revision\RevisionRecord\RAW
const RAW
Definition: RevisionRecord.php:64
StatusValue
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: StatusValue.php:43
MediaWiki\Revision\RevisionRecord
Page revision base class.
Definition: RevisionRecord.php:47
StatusValue\newFatal
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:70
Page\UndeletePage\$contentHandlerFactory
IContentHandlerFactory $contentHandlerFactory
Definition: UndeletePage.php:94
Page\UndeletePage\getRevisionStatus
getRevisionStatus()
Definition: UndeletePage.php:593
Page\UndeletePage\setUndeleteOnlyTimestamps
setUndeleteOnlyTimestamps(array $timestamps)
If you don't want to undelete all revisions, pass an array of timestamps to undelete.
Definition: UndeletePage.php:167
MediaWiki\Revision\RevisionStore
Service for looking up page revisions.
Definition: RevisionStore.php:88
ReadOnlyMode
A service class for fetching the wiki's current read-only mode.
Definition: ReadOnlyMode.php:11
Page\UndeletePage\FILES_RESTORED
const FILES_RESTORED
Definition: UndeletePage.php:55
Page\UndeletePage\undeleteIfAllowed
undeleteIfAllowed(string $comment)
Same as undeleteUnsafe, but checks permissions.
Definition: UndeletePage.php:189
Page\UndeletePage\$timestamps
string[] $timestamps
Definition: UndeletePage.php:84
MediaWiki\Permissions\PermissionStatus\newEmpty
static newEmpty()
Definition: PermissionStatus.php:67
WikiPage
Class representing a MediaWiki article and history.
Definition: WikiPage.php:62
MediaWiki\Content\ValidationParams
Definition: ValidationParams.php:10
Page\UndeletePage\$fileVersions
int[] $fileVersions
Definition: UndeletePage.php:86
Wikimedia\Rdbms\IDatabase
Basic database interface for live and lazy-loaded relation database handles.
Definition: IDatabase.php:38
MediaWiki\Revision\RevisionRecord\DELETED_TEXT
const DELETED_TEXT
Definition: RevisionRecord.php:53
Status
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:44
Page\UndeletePage
Definition: UndeletePage.php:52
Page\UndeletePage\setUnsuppress
setUnsuppress(bool $unsuppress)
Whether to remove all ar_deleted/fa_deleted restrictions of seletected revs.
Definition: UndeletePage.php:145
StatusValue\getValue
getValue()
Definition: StatusValue.php:138
Page\UndeletePage\$logger
LoggerInterface $logger
Definition: UndeletePage.php:65
Page\UndeletePage\undeleteRevisions
undeleteRevisions(string $comment)
This is the meaty bit – It restores archived revisions of the given page to the revision table.
Definition: UndeletePage.php:313
File
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition: File.php:67
Page\UndeletePage\$readOnlyMode
ReadOnlyMode $readOnlyMode
Definition: UndeletePage.php:67
ChangeTags
Definition: ChangeTags.php:32
HTMLCacheUpdateJob
Job to purge the HTML/file cache for all pages that link to or use another page or file.
Definition: HTMLCacheUpdateJob.php:36
Page\UndeletePage\$tags
string[] $tags
Definition: UndeletePage.php:90
Page\UndeletePage\$unsuppress
bool $unsuppress
Definition: UndeletePage.php:88
StatusValue\merge
merge( $other, $overwriteValue=false)
Merge another status object into this one.
Definition: StatusValue.php:278
Page\WikiPageFactory
Definition: WikiPageFactory.php:19
Page\UndeletePage\authorizeUndeletion
authorizeUndeletion()
Definition: UndeletePage.php:201
Page\UndeletePage\__construct
__construct(HookContainer $hookContainer, JobQueueGroup $jobQueueGroup, ILoadBalancer $loadBalancer, ReadOnlyMode $readOnlyMode, RepoGroup $repoGroup, LoggerInterface $logger, RevisionStore $revisionStore, WikiPageFactory $wikiPageFactory, ProperPageIdentity $page, Authority $performer, PageUpdaterFactory $pageUpdaterFactory, IContentHandlerFactory $contentHandlerFactory)
Definition: UndeletePage.php:110
LocalFile
Class to represent a local file in the wiki's own database.
Definition: LocalFile.php:62
Page\UndeletePage\$repoGroup
RepoGroup $repoGroup
Definition: UndeletePage.php:69
MediaWiki\Permissions\Authority
This interface represents the authority associated the current execution context, such as a web reque...
Definition: Authority.php:37
ChangeTags\canAddTagsAccompanyingChange
static canAddTagsAccompanyingChange(array $tags, Authority $performer=null)
Is it OK to allow the user to apply all the specified tags at the same time as they edit/make the cha...
Definition: ChangeTags.php:625
Page\ProperPageIdentity
Interface for objects representing a page that is (or could be, or used to be) an editable page on a ...
Definition: ProperPageIdentity.php:43
$content
$content
Definition: router.php:76
Page\UndeletePage\$revisionStatus
StatusValue null $revisionStatus
Definition: UndeletePage.php:82
Page\UndeletePage\$wikiPageFactory
WikiPageFactory $wikiPageFactory
Definition: UndeletePage.php:73
MediaWiki\Content\IContentHandlerFactory
Definition: IContentHandlerFactory.php:10
StatusValue\newGood
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:82
DB_PRIMARY
const DB_PRIMARY
Definition: defines.php:27
Page\UndeletePage\$performer
Authority $performer
Definition: UndeletePage.php:78
HTMLCacheUpdateJob\newForBacklinks
static newForBacklinks(PageReference $page, $table, $params=[])
Definition: HTMLCacheUpdateJob.php:61
Page\UndeletePage\$revisionStore
RevisionStore $revisionStore
Definition: UndeletePage.php:71
Page\UndeletePage\$pageUpdaterFactory
PageUpdaterFactory $pageUpdaterFactory
Definition: UndeletePage.php:92
Page\UndeletePage\setUndeleteOnlyFileVersions
setUndeleteOnlyFileVersions(array $fileVersions)
If you don't want to undelete all file versions, pass an array of versions to undelete.
Definition: UndeletePage.php:178
Page\UndeletePage\$loadBalancer
ILoadBalancer $loadBalancer
Definition: UndeletePage.php:63
MediaWiki\Permissions\PermissionStatus
A StatusValue for permission errors.
Definition: PermissionStatus.php:35
Page\UndeletePage\getFileStatus
getFileStatus()
Definition: UndeletePage.php:585
Page\UndeletePage\undeleteUnsafe
undeleteUnsafe(string $comment)
Restore the given (or all) text and file revisions for the page.
Definition: UndeletePage.php:228
$job
if(count( $args)< 1) $job
Definition: recompressTracked.php:49
Page\UndeletePage\REVISIONS_RESTORED
const REVISIONS_RESTORED
Definition: UndeletePage.php:56
RepoGroup
Prioritized list of file repositories.
Definition: RepoGroup.php:32
MediaWiki\Page
Definition: ContentModelChangeFactory.php:23
MediaWiki\Storage\PageUpdaterFactory
A factory for PageUpdater instances.
Definition: PageUpdaterFactory.php:54
ManualLogEntry
Class for creating new log entries and inserting them into the database.
Definition: ManualLogEntry.php:44
MediaWiki\HookContainer\HookContainer
HookContainer class.
Definition: HookContainer.php:45
MediaWiki\HookContainer\HookRunner
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:554
NS_FILE
const NS_FILE
Definition: Defines.php:70
Page\UndeletePage\setTags
setTags(array $tags)
Change tags to add to log entry (the user should be able to add the specified tags before this is cal...
Definition: UndeletePage.php:156
Page\UndeletePage\$hookRunner
HookRunner $hookRunner
Definition: UndeletePage.php:59
Page\UndeletePage\$fileStatus
Status null $fileStatus
Definition: UndeletePage.php:80
Page\UndeletePage\$jobQueueGroup
JobQueueGroup $jobQueueGroup
Definition: UndeletePage.php:61
Wikimedia\Rdbms\ILoadBalancer
Database cluster connection, tracking, load balancing, and transaction manager interface.
Definition: ILoadBalancer.php:81
JobQueueGroup
Class to handle enqueueing of background jobs.
Definition: JobQueueGroup.php:32
Page\UndeletePage\$page
ProperPageIdentity $page
Definition: UndeletePage.php:76