5use BadMethodCallException;
44use Wikimedia\RequestTimeout\TimeoutException;
70 private $revisionStore;
74 private $loadBalancer;
76 private $jobQueueGroup;
78 private $commentStore;
82 private $recentDeletesCache;
86 private $webRequestID;
90 private $backlinkCacheFactory;
92 private $wikiPageFactory;
94 private $namespaceInfo;
96 private $contLangMsgTextFormatter;
99 private $isDeletePageUnitTest =
false;
107 private $suppress =
false;
111 private $logSubtype =
'delete';
113 private $forceImmediate =
false;
115 private $associatedTalk;
118 private $legacyHookErrors =
'';
120 private $mergeLegacyHookErrors =
true;
126 private $successfulDeletionsIDs;
131 private $wasScheduled;
133 private $attemptedDeletion =
false;
163 string $webRequestID,
172 $this->hookRunner =
new HookRunner( $hookContainer );
173 $this->revisionStore = $revisionStore;
174 $this->lbFactory = $lbFactory;
175 $this->loadBalancer = $this->lbFactory->
getMainLB();
176 $this->jobQueueGroup = $jobQueueGroup;
177 $this->commentStore = $commentStore;
179 $this->options = $serviceOptions;
180 $this->recentDeletesCache = $recentDeletesCache;
181 $this->localWikiID = $localWikiID;
182 $this->webRequestID = $webRequestID;
183 $this->wikiPageFactory = $wikiPageFactory;
184 $this->userFactory = $userFactory;
185 $this->backlinkCacheFactory = $backlinkCacheFactory;
186 $this->namespaceInfo = $namespaceInfo;
187 $this->contLangMsgTextFormatter = $contLangMsgTextFormatter;
190 $this->deleter = $deleter;
198 return $this->legacyHookErrors;
206 $this->mergeLegacyHookErrors = false;
218 $this->suppress = $suppress;
228 public function setTags( array $tags ): self {
240 $this->logSubtype = $logSubtype;
251 $this->forceImmediate = $forceImmediate;
262 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
263 return StatusValue::newFatal(
'delete-error-associated-alreadytalk' );
266 $talkPage = $this->wikiPageFactory->newFromLinkTarget(
267 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
269 if ( !$talkPage->exists() ) {
270 return StatusValue::newFatal(
'delete-error-associated-doesnotexist' );
272 return StatusValue::newGood();
288 $this->associatedTalk =
null;
292 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
293 throw new BadMethodCallException(
"Cannot delete associated talk page of a talk page! ($this->page)" );
296 $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
297 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
308 if ( !defined(
'MW_PHPUNIT_TEST' ) ) {
309 throw new BadMethodCallException( __METHOD__ .
' can only be used in tests!' );
311 $this->isDeletePageUnitTest = $test;
320 $this->attemptedDeletion =
true;
321 $this->successfulDeletionsIDs = [ self::PAGE_BASE => null ];
322 $this->wasScheduled = [ self::PAGE_BASE => null ];
323 if ( $this->associatedTalk ) {
324 $this->successfulDeletionsIDs[self::PAGE_TALK] =
null;
325 $this->wasScheduled[self::PAGE_TALK] =
null;
334 private function assertDeletionAttempted(): void {
335 if ( !$this->attemptedDeletion ) {
336 throw new BadMethodCallException(
'No deletion was attempted' );
345 $this->assertDeletionAttempted();
346 return $this->successfulDeletionsIDs;
356 $this->assertDeletionAttempted();
358 return $this->wasScheduled[self::PAGE_BASE];
366 $this->assertDeletionAttempted();
367 return $this->wasScheduled;
377 $this->setDeletionAttempted();
378 $status = $this->authorizeDeletion();
379 if ( !$status->isGood() ) {
383 return $this->deleteUnsafe( $reason );
389 private function authorizeDeletion(): PermissionStatus {
390 $status = PermissionStatus::newEmpty();
391 $this->deleter->authorizeWrite(
'delete', $this->page, $status );
392 if ( $this->associatedTalk ) {
393 $this->deleter->authorizeWrite(
'delete', $this->associatedTalk, $status );
395 if ( !$this->deleter->isAllowed(
'bigdelete' ) && $this->isBigDeletion() ) {
397 'delete-toomanyrevisions',
398 Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
410 private function isBigDeletion(): bool {
411 $revLimit = $this->options->get( MainConfigNames::DeleteRevisionsLimit );
417 $revCount = $this->revisionStore->countRevisionsByPageId(
$dbr, $this->page->getId() );
418 if ( $this->associatedTalk ) {
419 $revCount += $this->revisionStore->countRevisionsByPageId(
$dbr, $this->associatedTalk->getId() );
422 return $revCount > $revLimit;
439 $revCount = $this->revisionStore->countRevisionsByPageId(
$dbr, $this->page->getId() );
440 $revCount += $safetyMargin;
442 if ( $revCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ) ) {
444 } elseif ( !$this->associatedTalk ) {
448 $talkRevCount = $this->revisionStore->countRevisionsByPageId(
$dbr, $this->associatedTalk->getId() );
449 $talkRevCount += $safetyMargin;
451 return $talkRevCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
465 $this->setDeletionAttempted();
466 $origReason = $reason;
467 $hookStatus = $this->runPreDeleteHooks( $this->page, $reason );
468 if ( !$hookStatus->isGood() ) {
471 if ( $this->associatedTalk ) {
472 $talkReason = $this->contLangMsgTextFormatter->format(
473 MessageValue::new(
'delete-talk-summary-prefix' )->plaintextParams( $origReason )
475 $talkHookStatus = $this->runPreDeleteHooks( $this->associatedTalk, $talkReason );
476 if ( !$talkHookStatus->isGood() ) {
477 return $talkHookStatus;
481 $status = $this->deleteInternal( $this->page, self::PAGE_BASE, $reason );
482 if ( !$this->associatedTalk || !$status->isGood() ) {
489 $status->merge( $this->deleteInternal( $this->associatedTalk, self::PAGE_TALK, $talkReason ) );
498 private function runPreDeleteHooks(
WikiPage $page,
string &$reason ):
Status {
499 $status =
Status::newGood();
501 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
502 if ( !$this->hookRunner->onArticleDelete(
503 $page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress )
505 if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !==
'' ) {
506 if ( is_string( $this->legacyHookErrors ) ) {
507 $this->legacyHookErrors = [ $this->legacyHookErrors ];
509 foreach ( $this->legacyHookErrors as $legacyError ) {
513 if ( $status->isOK() ) {
515 $status->fatal(
'delete-hook-aborted' );
522 $hookRes = $this->hookRunner->onPageDelete( $page, $this->deleter, $reason, $status, $this->suppress );
523 if ( !$hookRes && !$status->isGood() ) {
548 ?
string $webRequestId =
null
551 $status = Status::newGood();
553 $dbw = $this->loadBalancer->getConnectionRef(
DB_PRIMARY );
554 $dbw->startAtomic( __METHOD__ );
557 $id = $page->
getId();
563 if ( $id === 0 || $page->
getLatest() !== $lockedLatest ) {
564 $dbw->endAtomic( __METHOD__ );
577 if ( !$revisionRecord ) {
578 throw new LogicException(
"No revisions for $page?" );
582 }
catch ( TimeoutException $e ) {
584 }
catch ( Exception $ex ) {
585 wfLogWarning( __METHOD__ .
': failed to load content during deletion! '
586 . $ex->getMessage() );
593 $explictTrxLogged =
false;
595 $done = $this->archiveRevisions( $page, $id );
596 if ( $done || !$this->forceImmediate ) {
599 $dbw->endAtomic( __METHOD__ );
600 if ( $dbw->explicitTrxActive() ) {
602 if ( !$explictTrxLogged ) {
603 $explictTrxLogged =
true;
604 LoggerFactory::getInstance(
'wfDebug' )->debug(
605 'explicit transaction active in ' . __METHOD__ .
' while deleting {title}', [
606 'title' =>
$title->getText(),
611 if ( $dbw->trxLevel() ) {
612 $dbw->commit( __METHOD__ );
614 $this->lbFactory->waitForReplication();
615 $dbw->startAtomic( __METHOD__ );
619 $dbw->endAtomic( __METHOD__ );
622 'namespace' =>
$title->getNamespace(),
623 'title' =>
$title->getDBkey(),
625 'requestId' => $webRequestId ?? $this->webRequestID,
627 'suppress' => $this->suppress,
628 'userId' => $this->deleter->getUser()->getId(),
629 'tags' => json_encode( $this->tags ),
630 'logsubtype' => $this->logSubtype,
631 'pageRole' => $pageRole,
635 $this->jobQueueGroup->push(
$job );
636 $this->wasScheduled[$pageRole] =
true;
639 $this->wasScheduled[$pageRole] =
false;
648 $archivedRevisionCount = $dbw->selectRowCount(
652 'ar_namespace' =>
$title->getNamespace(),
653 'ar_title' =>
$title->getDBkey(),
661 $wikiPageBeforeDelete = clone $page;
664 $dbw->delete(
'page', [
'page_id' => $id ], __METHOD__ );
667 $logtype = $this->suppress ?
'suppress' :
'delete';
670 $logEntry->setPerformer( $this->deleter->getUser() );
671 $logEntry->setTarget( $logTitle );
672 $logEntry->setComment( $reason );
673 $logEntry->addTags( $this->tags );
674 if ( !$this->isDeletePageUnitTest ) {
676 $logid = $logEntry->insert();
678 $dbw->onTransactionPreCommitOrIdle(
679 static function () use ( $logEntry, $logid ) {
681 $logEntry->publish( $logid );
689 $dbw->endAtomic( __METHOD__ );
691 $this->doDeleteUpdates( $page, $revisionRecord );
693 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
694 $this->hookRunner->onArticleDeleteComplete(
695 $wikiPageBeforeDelete,
701 $archivedRevisionCount
703 $this->hookRunner->onPageDeleteComplete(
704 $wikiPageBeforeDelete,
710 $archivedRevisionCount
712 $this->successfulDeletionsIDs[$pageRole] = $logid;
715 $key = $this->recentDeletesCache->makeKey(
'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
716 $this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY );
728 private function archiveRevisions(
WikiPage $page,
int $id ): bool {
730 $namespace = $page->
getTitle()->getNamespace();
731 $dbKey = $page->
getTitle()->getDBkey();
733 $dbw = $this->loadBalancer->getConnectionRef(
DB_PRIMARY );
735 $revQuery = $this->revisionStore->getQueryInfo();
739 if ( $this->suppress ) {
740 $bitfield = RevisionRecord::SUPPRESSED_ALL;
758 [
'revision',
'revision_comment_temp' ]
760 [
'rev_page' => $id ],
766 $deleteBatchSize = $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
772 [
'rev_page' => $id ],
774 [
'ORDER BY' =>
'rev_timestamp ASC, rev_id ASC',
'LIMIT' => $deleteBatchSize + 1 ],
786 foreach (
$res as $row ) {
787 if ( count( $revids ) >= $deleteBatchSize ) {
792 $comment = $this->commentStore->getComment(
'rev_comment', $row );
794 'ar_namespace' => $namespace,
795 'ar_title' => $dbKey,
796 'ar_actor' => $row->rev_actor,
797 'ar_timestamp' => $row->rev_timestamp,
798 'ar_minor_edit' => $row->rev_minor_edit,
799 'ar_rev_id' => $row->rev_id,
800 'ar_parent_id' => $row->rev_parent_id,
801 'ar_len' => $row->rev_len,
803 'ar_deleted' => $this->suppress ? $bitfield : $row->rev_deleted,
804 'ar_sha1' => $row->rev_sha1,
805 ] + $this->commentStore->insert( $dbw,
'ar_comment', $comment );
807 $rowsInsert[] = $rowInsert;
808 $revids[] = $row->rev_id;
812 if ( (
int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) {
813 $ipRevIds[] = $row->rev_id;
817 if ( count( $revids ) > 0 ) {
819 $dbw->insert(
'archive', $rowsInsert, __METHOD__ );
821 $dbw->delete(
'revision', [
'rev_id' => $revids ], __METHOD__ );
822 $dbw->delete(
'revision_comment_temp', [
'revcomment_rev' => $revids ], __METHOD__ );
824 if ( count( $ipRevIds ) > 0 ) {
825 $dbw->delete(
'ip_changes', [
'ipc_rev_id' => $ipRevIds ], __METHOD__ );
843 $countable = $page->isCountable();
844 }
catch ( TimeoutException $e ) {
846 }
catch ( Exception $ex ) {
853 if ( !$this->isDeletePageUnitTest ) {
855 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
856 [
'edits' => 1,
'articles' => $countable ? -1 : 0,
'pages' => -1 ]
860 $updates = $this->getDeletionUpdates( $page, $revRecord );
861 foreach ( $updates as $update ) {
862 DeferredUpdates::addUpdate( $update );
867 LinksUpdate::queueRecursiveJobsForTable(
871 $this->deleter->getUser()->getName(),
872 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
876 LinksUpdate::queueRecursiveJobsForTable(
880 $this->deleter->getUser()->getName(),
881 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
885 if ( !$this->isDeletePageUnitTest ) {
891 WikiModule::invalidateModuleCache(
899 $page->
loadFromRow(
false, WikiPage::READ_LATEST );
901 if ( !$this->isDeletePageUnitTest ) {
919 $slotContent = array_map( static function (
SlotRecord $slot ) {
920 return $slot->getContent();
921 }, $rev->getSlots()->getSlots() );
930 foreach ( $slotContent as $role =>
$content ) {
931 $handler =
$content->getContentHandler();
933 $updates = $handler->getDeletionUpdates(
938 $allUpdates = array_merge( $allUpdates, $updates );
941 $this->hookRunner->onPageDeletionDataUpdates(
942 $page->
getTitle(), $rev, $allUpdates );
945 $this->hookRunner->onWikiPageDeletionUpdates( $page,
$content, $allUpdates );
wfLogWarning( $msg, $callerOffset=1, $level=E_USER_WARNING)
Send a warning as a PHP error and the debug log.
wfEscapeWikiText( $text)
Escapes the given text so that it may be output using addWikiText() without any linking,...
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Class representing a cache/ephemeral data store.
Class for managing the deferral of updates within the scope of a PHP script invocation.
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the pending update queue for execution at the appropriate time.
Class to handle enqueueing of background jobs.
Class for creating new log entries and inserting them into the database.
A class containing constants representing the names of configuration variables.
const DeleteRevisionsLimit
Name constant for the DeleteRevisionsLimit setting, for use with Config::get()
const DeleteRevisionsBatchSize
Name constant for the DeleteRevisionsBatchSize setting, for use with Config::get()
Backend logic for performing a page delete action.
setDeletionAttempted()
Called before attempting a deletion, allows the result getters to be used.
canProbablyDeleteAssociatedTalk()
Tests whether it's probably possible to delete the associated talk page.
__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, ProperPageIdentity $page, Authority $deleter)
deleteInternal(WikiPage $page, string $pageRole, string $reason, ?string $webRequestId=null)
setTags(array $tags)
Change tags to apply to the deletion action.
getSuccessfulDeletionsIDs()
deleteIfAllowed(string $reason)
Same as deleteUnsafe, but checks permissions.
setLogSubtype(string $logSubtype)
Set a specific log subtype for the deletion log entry.
const PAGE_BASE
Constants used for the return value of getSuccessfulDeletionsIDs() and deletionsWereScheduled()
keepLegacyHookErrorsSeparate()
const CONSTRUCTOR_OPTIONS
isBatchedDelete(int $safetyMargin=0)
Determines if this deletion would be batched (executed over time by the job queue) or not (completed ...
setIsDeletePageUnitTest(bool $test)
doDeleteUpdates(WikiPage $page, RevisionRecord $revRecord)
getDeletionUpdates(WikiPage $page, RevisionRecord $rev)
deleteUnsafe(string $reason)
Back-end article deletion: deletes the article with database consistency, writes logs,...
forceImmediate(bool $forceImmediate)
If false, allows deleting over time via the job queue.
setDeleteAssociatedTalk(bool $delete)
If set to true and the page has a talk page, delete that one too.
setSuppress(bool $suppress)
If true, suppress all revisions and log the deletion in the suppression log instead of the deletion l...
Service for creating WikiPage objects.
newFromTitle(PageIdentity $pageIdentity)
Create a WikiPage object from a title.
The Message class deals with fetching and processing of interface message into a variety of formats.
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Variant of the Message class.
Database independent search index updater.
Class for handling updates to the site_stats table.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
fatal( $message,... $parameters)
Add an error and set OK to false, indicating that the operation as a whole was fatal.
static newGood( $value=null)
Factory function for good results.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Base representation for an editable wiki page.
getContent( $audience=RevisionRecord::FOR_PUBLIC, Authority $performer=null)
Get the content of the current revision.
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.
getId( $wikiId=self::LOCAL)
getTitle()
Get the title object of the article.
loadPageData( $from='fromdb')
Load the object from a given source by title.
getRevisionRecord()
Get the latest revision.
Base interface for content objects.
Interface that deferrable updates should implement.
Interface for a page that is (or could be, or used to be) an editable wiki page.
if(count( $args)< 1) $job