67 private $isDeletePageUnitTest =
false;
69 private $suppress =
false;
73 private $logSubtype =
'delete';
75 private $forceImmediate =
false;
77 private $associatedTalk;
80 private $legacyHookErrors =
'';
82 private $mergeLegacyHookErrors =
true;
88 private $successfulDeletionsIDs;
93 private $wasScheduled;
95 private $attemptedDeletion =
false;
104 private string $localWikiID;
105 private string $webRequestID;
127 string $webRequestID,
137 $this->hookRunner =
new HookRunner( $hookContainer );
138 $this->revisionStore = $revisionStore;
139 $this->lbFactory = $lbFactory;
140 $this->jobQueueGroup = $jobQueueGroup;
141 $this->commentStore = $commentStore;
143 $this->options = $serviceOptions;
144 $this->recentDeletesCache = $recentDeletesCache;
145 $this->localWikiID = $localWikiID;
146 $this->webRequestID = $webRequestID;
147 $this->wikiPageFactory = $wikiPageFactory;
148 $this->userFactory = $userFactory;
149 $this->backlinkCacheFactory = $backlinkCacheFactory;
150 $this->namespaceInfo = $namespaceInfo;
151 $this->contLangMsgTextFormatter = $contLangMsgTextFormatter;
154 $this->deleter = $deleter;
155 $this->redirectStore = $redirectStore;
163 return $this->legacyHookErrors;
171 $this->mergeLegacyHookErrors = false;
183 $this->suppress = $suppress;
193 public function setTags( array $tags ): self {
205 $this->logSubtype = $logSubtype;
216 $this->forceImmediate = $forceImmediate;
227 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
228 return StatusValue::newFatal(
'delete-error-associated-alreadytalk' );
231 $talkPage = $this->wikiPageFactory->newFromLinkTarget(
232 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
234 if ( !$talkPage->exists() ) {
235 return StatusValue::newFatal(
'delete-error-associated-doesnotexist' );
237 return StatusValue::newGood();
253 $this->associatedTalk =
null;
257 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
258 throw new BadMethodCallException(
"Cannot delete associated talk page of a talk page! ($this->page)" );
261 $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
262 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
273 if ( !defined(
'MW_PHPUNIT_TEST' ) ) {
274 throw new LogicException( __METHOD__ .
' can only be used in tests!' );
276 $this->isDeletePageUnitTest = $test;
285 $this->attemptedDeletion = true;
286 $this->successfulDeletionsIDs = [ self::PAGE_BASE => null ];
287 $this->wasScheduled = [ self::PAGE_BASE => null ];
288 if ( $this->associatedTalk ) {
289 $this->successfulDeletionsIDs[self::PAGE_TALK] =
null;
290 $this->wasScheduled[self::PAGE_TALK] =
null;
299 private function assertDeletionAttempted(): void {
300 if ( !$this->attemptedDeletion ) {
301 throw new BadMethodCallException(
'No deletion was attempted' );
310 $this->assertDeletionAttempted();
311 return $this->successfulDeletionsIDs;
319 $this->assertDeletionAttempted();
320 return $this->wasScheduled;
330 $this->setDeletionAttempted();
331 $status = $this->authorizeDeletion();
332 if ( !$status->isGood() ) {
336 return $this->deleteUnsafe( $reason );
342 private function authorizeDeletion(): PermissionStatus {
343 $status = PermissionStatus::newEmpty();
344 $this->deleter->authorizeWrite(
'delete', $this->page, $status );
345 if ( $this->associatedTalk ) {
346 $this->deleter->authorizeWrite(
'delete', $this->associatedTalk, $status );
348 if ( !$this->deleter->isAllowed(
'bigdelete' ) && $this->isBigDeletion() ) {
350 'delete-toomanyrevisions',
351 Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
363 private function isBigDeletion(): bool {
364 $revLimit = $this->options->get( MainConfigNames::DeleteRevisionsLimit );
369 $dbr = $this->lbFactory->getReplicaDatabase();
370 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
371 if ( $this->associatedTalk ) {
372 $revCount += $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
375 return $revCount > $revLimit;
391 $dbr = $this->lbFactory->getReplicaDatabase();
392 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
393 $revCount += $safetyMargin;
395 if ( $revCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ) ) {
397 } elseif ( !$this->associatedTalk ) {
401 $talkRevCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
402 $talkRevCount += $safetyMargin;
404 return $talkRevCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
418 $this->setDeletionAttempted();
419 $origReason = $reason;
420 $hookStatus = $this->runPreDeleteHooks( $this->page, $reason );
421 if ( !$hookStatus->isGood() ) {
424 if ( $this->associatedTalk ) {
425 $talkReason = $this->contLangMsgTextFormatter->format(
426 MessageValue::new(
'delete-talk-summary-prefix' )->plaintextParams( $origReason )
428 $talkHookStatus = $this->runPreDeleteHooks( $this->associatedTalk, $talkReason );
429 if ( !$talkHookStatus->isGood() ) {
430 return $talkHookStatus;
434 $status = $this->deleteInternal( $this->page, self::PAGE_BASE, $reason );
435 if ( !$this->associatedTalk || !$status->isGood() ) {
442 $status->merge( $this->deleteInternal( $this->associatedTalk, self::PAGE_TALK, $talkReason ) );
451 private function runPreDeleteHooks(
WikiPage $page,
string &$reason ): Status {
452 $status = Status::newGood();
454 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
455 if ( !$this->hookRunner->onArticleDelete(
456 $page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress )
458 if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !==
'' ) {
459 if ( is_string( $this->legacyHookErrors ) ) {
460 $this->legacyHookErrors = [ $this->legacyHookErrors ];
462 foreach ( $this->legacyHookErrors as $legacyError ) {
463 $status->fatal(
new RawMessage( $legacyError ) );
466 if ( $status->isOK() ) {
468 $status->fatal(
'delete-hook-aborted' );
474 $status = Status::newGood();
475 $hookRes = $this->hookRunner->onPageDelete( $page, $this->deleter, $reason, $status, $this->suppress );
476 if ( !$hookRes && !$status->isGood() ) {
480 return Status::newGood();
502 ?
string $webRequestId =
null,
506 $status = Status::newGood();
508 $dbw = $this->lbFactory->getPrimaryDatabase();
509 $dbw->startAtomic( __METHOD__ );
512 $id = $page->
getId();
518 if ( $id === 0 || $page->
getLatest() !== $lockedLatest ) {
519 $dbw->endAtomic( __METHOD__ );
521 $status->error(
'cannotdelete',
wfEscapeWikiText( $title->getPrefixedText() ) );
532 if ( !$revisionRecord ) {
533 throw new LogicException(
"No revisions for $page?" );
536 $content = $page->
getContent( RevisionRecord::RAW );
537 }
catch ( TimeoutException $e ) {
539 }
catch ( Exception $ex ) {
540 wfLogWarning( __METHOD__ .
': failed to load content during deletion! '
541 . $ex->getMessage() );
549 $done = $this->archiveRevisions( $page, $id );
550 if ( $done || !$this->forceImmediate ) {
553 $dbw->endAtomic( __METHOD__ );
554 $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
555 $dbw->startAtomic( __METHOD__ );
559 $dbw->endAtomic( __METHOD__ );
562 'namespace' => $title->getNamespace(),
563 'title' => $title->getDBkey(),
565 'requestId' => $webRequestId ?? $this->webRequestID,
567 'suppress' => $this->suppress,
568 'userId' => $this->deleter->getUser()->getId(),
569 'tags' => json_encode( $this->tags ),
570 'logsubtype' => $this->logSubtype,
571 'pageRole' => $pageRole,
575 $this->jobQueueGroup->push(
$job );
576 $this->wasScheduled[$pageRole] =
true;
579 $this->wasScheduled[$pageRole] =
false;
588 $archivedRevisionCount = $dbw->newSelectQueryBuilder()
592 'ar_namespace' => $title->getNamespace(),
593 'ar_title' => $title->getDBkey(),
596 ->caller( __METHOD__ )->fetchRowCount();
604 $logTitle = clone $title;
605 $wikiPageBeforeDelete = clone $page;
608 $dbw->newDeleteQueryBuilder()
609 ->deleteFrom(
'page' )
610 ->where( [
'page_id' => $id ] )
611 ->caller( __METHOD__ )->execute();
614 $logtype = $this->suppress ?
'suppress' :
'delete';
617 $logEntry->setPerformer( $this->deleter->getUser() );
618 $logEntry->setTarget( $logTitle );
619 $logEntry->setComment( $reason );
620 $logEntry->addTags( $this->tags );
621 if ( !$this->isDeletePageUnitTest ) {
623 $logid = $logEntry->insert();
625 $dbw->onTransactionPreCommitOrIdle(
626 static function () use ( $logEntry, $logid ) {
628 $logEntry->publish( $logid );
636 $dbw->endAtomic( __METHOD__ );
638 $this->doDeleteUpdates( $page, $revisionRecord );
640 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
641 $this->hookRunner->onArticleDeleteComplete(
642 $wikiPageBeforeDelete,
648 $archivedRevisionCount
650 $this->hookRunner->onPageDeleteComplete(
651 $wikiPageBeforeDelete,
657 $archivedRevisionCount
659 $this->successfulDeletionsIDs[$pageRole] = $logid;
662 $this->redirectStore->clearCache( $page );
665 $key = $this->recentDeletesCache->makeKey(
'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
666 $this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY );
678 private function archiveRevisions(
WikiPage $page,
int $id ): bool {
680 $namespace = $page->
getTitle()->getNamespace();
681 $dbKey = $page->
getTitle()->getDBkey();
683 $dbw = $this->lbFactory->getPrimaryDatabase();
685 $revQuery = $this->revisionStore->getQueryInfo();
689 if ( $this->suppress ) {
690 $bitfield = RevisionRecord::SUPPRESSED_ALL;
691 $revQuery[
'fields'] = array_diff( $revQuery[
'fields'], [
'rev_deleted' ] );
705 $lockQuery = $revQuery;
706 $lockQuery[
'tables'] = array_intersect(
708 [
'revision',
'revision_comment_temp' ]
710 unset( $lockQuery[
'fields'] );
711 $dbw->newSelectQueryBuilder()
712 ->queryInfo( $lockQuery )
713 ->where( [
'rev_page' => $id ] )
715 ->caller( __METHOD__ )
718 $deleteBatchSize = $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
721 $res = $dbw->newSelectQueryBuilder()
722 ->queryInfo( $revQuery )
723 ->where( [
'rev_page' => $id ] )
724 ->orderBy( [
'rev_timestamp',
'rev_id' ] )
725 ->limit( $deleteBatchSize + 1 )
726 ->caller( __METHOD__ )
737 foreach ( $res as $row ) {
738 if ( count( $revids ) >= $deleteBatchSize ) {
743 $comment = $this->commentStore->getComment(
'rev_comment', $row );
745 'ar_namespace' => $namespace,
746 'ar_title' => $dbKey,
747 'ar_actor' => $row->rev_actor,
748 'ar_timestamp' => $row->rev_timestamp,
749 'ar_minor_edit' => $row->rev_minor_edit,
750 'ar_rev_id' => $row->rev_id,
751 'ar_parent_id' => $row->rev_parent_id,
752 'ar_len' => $row->rev_len,
754 'ar_deleted' => $this->suppress ? $bitfield : $row->rev_deleted,
755 'ar_sha1' => $row->rev_sha1,
756 ] + $this->commentStore->insert( $dbw,
'ar_comment', $comment );
758 $rowsInsert[] = $rowInsert;
759 $revids[] = $row->rev_id;
763 if ( (
int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) {
764 $ipRevIds[] = $row->rev_id;
768 if ( count( $revids ) > 0 ) {
770 $dbw->newInsertQueryBuilder()
771 ->insertInto(
'archive' )
772 ->rows( $rowsInsert )
773 ->caller( __METHOD__ )->execute();
775 $dbw->newDeleteQueryBuilder()
776 ->deleteFrom(
'revision' )
777 ->where( [
'rev_id' => $revids ] )
778 ->caller( __METHOD__ )->execute();
780 if ( count( $ipRevIds ) > 0 ) {
781 $dbw->newDeleteQueryBuilder()
782 ->deleteFrom(
'ip_changes' )
783 ->where( [
'ipc_rev_id' => $ipRevIds ] )
784 ->caller( __METHOD__ )->execute();
799 private function doDeleteUpdates(
WikiPage $page, RevisionRecord $revRecord ): void {
801 $countable = $page->isCountable();
802 }
catch ( TimeoutException $e ) {
804 }
catch ( Exception $ex ) {
811 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
812 [
'edits' => 1,
'articles' => $countable ? -1 : 0,
'pages' => -1 ]
816 $updates = $this->getDeletionUpdates( $page, $revRecord );
817 foreach ( $updates as $update ) {
818 DeferredUpdates::addUpdate( $update );
822 LinksUpdate::queueRecursiveJobsForTable(
826 $this->deleter->getUser()->getName(),
827 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
831 LinksUpdate::queueRecursiveJobsForTable(
835 $this->deleter->getUser()->getName(),
836 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
840 if ( !$this->isDeletePageUnitTest ) {
846 WikiModule::invalidateModuleCache(
854 $page->
loadFromRow(
false, IDBAccessObject::READ_LATEST );
857 DeferredUpdates::addUpdate(
new SearchUpdate( $page->
getId(), $page->
getTitle() ) );
869 private function getDeletionUpdates(
WikiPage $page, RevisionRecord $rev ): array {
870 if ( $this->isDeletePageUnitTest ) {
874 $slotContent = array_map(
static function ( SlotRecord $slot ) {
875 return $slot->getContent();
876 }, $rev->getSlots()->getSlots() );
878 $allUpdates = [
new LinksDeletionUpdate( $page ) ];
885 foreach ( $slotContent as $role => $content ) {
886 $handler = $content->getContentHandler();
888 $updates = $handler->getDeletionUpdates(
893 $allUpdates = array_merge( $allUpdates, $updates );
896 $this->hookRunner->onPageDeletionDataUpdates(
897 $page->
getTitle(), $rev, $allUpdates );
900 $this->hookRunner->onWikiPageDeletionUpdates( $page, $content, $allUpdates );
Class for creating new log entries and inserting them into the database.
__construct(HookContainer $hookContainer, RevisionStore $revisionStore, LBFactory $lbFactory, JobQueueGroup $jobQueueGroup, CommentStore $commentStore, ServiceOptions $serviceOptions, BagOStuff $recentDeletesCache, string $localWikiID, string $webRequestID, WikiPageFactory $wikiPageFactory, UserFactory $userFactory, BacklinkCacheFactory $backlinkCacheFactory, NamespaceInfo $namespaceInfo, ITextFormatter $contLangMsgTextFormatter, RedirectStore $redirectStore, ProperPageIdentity $page, Authority $deleter)
Base representation for an editable wiki page.
loadFromRow( $data, $from)
Load the object from a database row.
lockAndGetLatest()
Lock the page row for this title+id and return page_latest (or 0)
getLatest( $wikiId=self::LOCAL)
Get the page_latest field.
static onArticleDelete(Title $title)
Clears caches when article is deleted.
getContent( $audience=RevisionRecord::FOR_PUBLIC, ?Authority $performer=null)
Get the content of the current revision.
loadPageData( $from='fromdb')
Load the object from a given source by title.