5 use BadMethodCallException;
39 use Wikimedia\IPUtils;
43 use Wikimedia\RequestTimeout\TimeoutException;
69 private $revisionStore;
73 private $jobQueueGroup;
75 private $commentStore;
79 private $recentDeletesCache;
83 private $webRequestID;
87 private $backlinkCacheFactory;
89 private $wikiPageFactory;
91 private $namespaceInfo;
93 private $contLangMsgTextFormatter;
96 private $isDeletePageUnitTest =
false;
104 private $suppress =
false;
108 private $logSubtype =
'delete';
110 private $forceImmediate =
false;
112 private $associatedTalk;
115 private $legacyHookErrors =
'';
117 private $mergeLegacyHookErrors =
true;
123 private $successfulDeletionsIDs;
128 private $wasScheduled;
130 private $attemptedDeletion =
false;
160 string $webRequestID,
169 $this->hookRunner =
new HookRunner( $hookContainer );
170 $this->revisionStore = $revisionStore;
171 $this->lbFactory = $lbFactory;
172 $this->jobQueueGroup = $jobQueueGroup;
173 $this->commentStore = $commentStore;
175 $this->options = $serviceOptions;
176 $this->recentDeletesCache = $recentDeletesCache;
177 $this->localWikiID = $localWikiID;
178 $this->webRequestID = $webRequestID;
179 $this->wikiPageFactory = $wikiPageFactory;
180 $this->userFactory = $userFactory;
181 $this->backlinkCacheFactory = $backlinkCacheFactory;
182 $this->namespaceInfo = $namespaceInfo;
183 $this->contLangMsgTextFormatter = $contLangMsgTextFormatter;
186 $this->deleter = $deleter;
194 return $this->legacyHookErrors;
202 $this->mergeLegacyHookErrors = false;
214 $this->suppress = $suppress;
224 public function setTags( array $tags ): self {
236 $this->logSubtype = $logSubtype;
247 $this->forceImmediate = $forceImmediate;
258 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
262 $talkPage = $this->wikiPageFactory->newFromLinkTarget(
263 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
265 if ( !$talkPage->exists() ) {
284 $this->associatedTalk =
null;
288 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
289 throw new BadMethodCallException(
"Cannot delete associated talk page of a talk page! ($this->page)" );
292 $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
293 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
304 if ( !defined(
'MW_PHPUNIT_TEST' ) ) {
305 throw new LogicException( __METHOD__ .
' can only be used in tests!' );
307 $this->isDeletePageUnitTest = $test;
316 $this->attemptedDeletion =
true;
317 $this->successfulDeletionsIDs = [ self::PAGE_BASE => null ];
318 $this->wasScheduled = [ self::PAGE_BASE => null ];
319 if ( $this->associatedTalk ) {
320 $this->successfulDeletionsIDs[self::PAGE_TALK] =
null;
321 $this->wasScheduled[self::PAGE_TALK] =
null;
330 private function assertDeletionAttempted(): void {
331 if ( !$this->attemptedDeletion ) {
332 throw new BadMethodCallException(
'No deletion was attempted' );
341 $this->assertDeletionAttempted();
342 return $this->successfulDeletionsIDs;
350 $this->assertDeletionAttempted();
351 return $this->wasScheduled;
361 $this->setDeletionAttempted();
362 $status = $this->authorizeDeletion();
363 if ( !$status->isGood() ) {
367 return $this->deleteUnsafe( $reason );
373 private function authorizeDeletion(): PermissionStatus {
374 $status = PermissionStatus::newEmpty();
375 $this->deleter->authorizeWrite(
'delete', $this->page, $status );
376 if ( $this->associatedTalk ) {
377 $this->deleter->authorizeWrite(
'delete', $this->associatedTalk, $status );
379 if ( !$this->deleter->isAllowed(
'bigdelete' ) && $this->isBigDeletion() ) {
381 'delete-toomanyrevisions',
382 Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
394 private function isBigDeletion(): bool {
395 $revLimit = $this->options->get( MainConfigNames::DeleteRevisionsLimit );
400 $dbr = $this->lbFactory->getReplicaDatabase();
401 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
402 if ( $this->associatedTalk ) {
403 $revCount += $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
406 return $revCount > $revLimit;
422 $dbr = $this->lbFactory->getReplicaDatabase();
423 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
424 $revCount += $safetyMargin;
426 if ( $revCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ) ) {
428 } elseif ( !$this->associatedTalk ) {
432 $talkRevCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
433 $talkRevCount += $safetyMargin;
435 return $talkRevCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
449 $this->setDeletionAttempted();
450 $origReason = $reason;
451 $hookStatus = $this->runPreDeleteHooks( $this->page, $reason );
452 if ( !$hookStatus->isGood() ) {
455 if ( $this->associatedTalk ) {
456 $talkReason = $this->contLangMsgTextFormatter->format(
457 MessageValue::new(
'delete-talk-summary-prefix' )->plaintextParams( $origReason )
459 $talkHookStatus = $this->runPreDeleteHooks( $this->associatedTalk, $talkReason );
460 if ( !$talkHookStatus->isGood() ) {
461 return $talkHookStatus;
465 $status = $this->deleteInternal( $this->page, self::PAGE_BASE, $reason );
466 if ( !$this->associatedTalk || !$status->isGood() ) {
473 $status->merge( $this->deleteInternal( $this->associatedTalk, self::PAGE_TALK, $talkReason ) );
482 private function runPreDeleteHooks(
WikiPage $page,
string &$reason ): Status {
483 $status = Status::newGood();
485 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
486 if ( !$this->hookRunner->onArticleDelete(
487 $page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress )
489 if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !==
'' ) {
490 if ( is_string( $this->legacyHookErrors ) ) {
491 $this->legacyHookErrors = [ $this->legacyHookErrors ];
493 foreach ( $this->legacyHookErrors as $legacyError ) {
494 $status->fatal(
new RawMessage( $legacyError ) );
497 if ( $status->isOK() ) {
499 $status->fatal(
'delete-hook-aborted' );
505 $status = Status::newGood();
506 $hookRes = $this->hookRunner->onPageDelete( $page, $this->deleter, $reason, $status, $this->suppress );
507 if ( !$hookRes && !$status->isGood() ) {
511 return Status::newGood();
532 ?
string $webRequestId =
null
535 $status = Status::newGood();
537 $dbw = $this->lbFactory->getPrimaryDatabase();
538 $dbw->startAtomic( __METHOD__ );
541 $id = $page->
getId();
547 if ( $id === 0 || $page->
getLatest() !== $lockedLatest ) {
548 $dbw->endAtomic( __METHOD__ );
550 $status->error(
'cannotdelete',
wfEscapeWikiText( $title->getPrefixedText() ) );
561 if ( !$revisionRecord ) {
562 throw new LogicException(
"No revisions for $page?" );
566 }
catch ( TimeoutException $e ) {
568 }
catch ( Exception $ex ) {
569 wfLogWarning( __METHOD__ .
': failed to load content during deletion! '
570 . $ex->getMessage() );
577 $explictTrxLogged =
false;
579 $done = $this->archiveRevisions( $page, $id );
580 if ( $done || !$this->forceImmediate ) {
583 $dbw->endAtomic( __METHOD__ );
584 if ( $dbw->explicitTrxActive() ) {
586 if ( !$explictTrxLogged ) {
587 $explictTrxLogged =
true;
588 LoggerFactory::getInstance(
'wfDebug' )->debug(
589 'explicit transaction active in ' . __METHOD__ .
' while deleting {title}', [
590 'title' => $title->getText(),
595 if ( $dbw->trxLevel() ) {
596 $dbw->commit( __METHOD__ );
598 $this->lbFactory->waitForReplication();
599 $dbw->startAtomic( __METHOD__ );
603 $dbw->endAtomic( __METHOD__ );
606 'namespace' => $title->getNamespace(),
607 'title' => $title->getDBkey(),
609 'requestId' => $webRequestId ?? $this->webRequestID,
611 'suppress' => $this->suppress,
612 'userId' => $this->deleter->getUser()->getId(),
613 'tags' => json_encode( $this->tags ),
614 'logsubtype' => $this->logSubtype,
615 'pageRole' => $pageRole,
619 $this->jobQueueGroup->push(
$job );
620 $this->wasScheduled[$pageRole] =
true;
623 $this->wasScheduled[$pageRole] =
false;
632 $archivedRevisionCount = $dbw->newSelectQueryBuilder()
636 'ar_namespace' => $title->getNamespace(),
637 'ar_title' => $title->getDBkey(),
640 ->caller( __METHOD__ )->fetchRowCount();
644 $logTitle = clone $title;
645 $wikiPageBeforeDelete = clone $page;
648 $dbw->newDeleteQueryBuilder()
649 ->deleteFrom(
'page' )
650 ->where( [
'page_id' => $id ] )
651 ->caller( __METHOD__ )->execute();
654 $logtype = $this->suppress ?
'suppress' :
'delete';
657 $logEntry->setPerformer( $this->deleter->getUser() );
658 $logEntry->setTarget( $logTitle );
659 $logEntry->setComment( $reason );
660 $logEntry->addTags( $this->tags );
661 if ( !$this->isDeletePageUnitTest ) {
663 $logid = $logEntry->insert();
665 $dbw->onTransactionPreCommitOrIdle(
666 static function () use ( $logEntry, $logid ) {
668 $logEntry->publish( $logid );
676 $dbw->endAtomic( __METHOD__ );
678 $this->doDeleteUpdates( $page, $revisionRecord );
680 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
681 $this->hookRunner->onArticleDeleteComplete(
682 $wikiPageBeforeDelete,
688 $archivedRevisionCount
690 $this->hookRunner->onPageDeleteComplete(
691 $wikiPageBeforeDelete,
697 $archivedRevisionCount
699 $this->successfulDeletionsIDs[$pageRole] = $logid;
702 $key = $this->recentDeletesCache->makeKey(
'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
703 $this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY );
715 private function archiveRevisions(
WikiPage $page,
int $id ): bool {
717 $namespace = $page->
getTitle()->getNamespace();
718 $dbKey = $page->
getTitle()->getDBkey();
720 $dbw = $this->lbFactory->getPrimaryDatabase();
722 $revQuery = $this->revisionStore->getQueryInfo();
726 if ( $this->suppress ) {
727 $bitfield = RevisionRecord::SUPPRESSED_ALL;
728 $revQuery[
'fields'] = array_diff( $revQuery[
'fields'], [
'rev_deleted' ] );
742 $lockQuery = $revQuery;
743 $lockQuery[
'tables'] = array_intersect(
745 [
'revision',
'revision_comment_temp' ]
747 unset( $lockQuery[
'fields'] );
748 $dbw->newSelectQueryBuilder()
749 ->queryInfo( $lockQuery )
750 ->where( [
'rev_page' => $id ] )
752 ->caller( __METHOD__ )
755 $deleteBatchSize = $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
761 [
'rev_page' => $id ],
763 [
'ORDER BY' =>
'rev_timestamp ASC, rev_id ASC',
'LIMIT' => $deleteBatchSize + 1 ],
775 foreach ( $res as $row ) {
776 if ( count( $revids ) >= $deleteBatchSize ) {
781 $comment = $this->commentStore->getComment(
'rev_comment', $row );
783 'ar_namespace' => $namespace,
784 'ar_title' => $dbKey,
785 'ar_actor' => $row->rev_actor,
786 'ar_timestamp' => $row->rev_timestamp,
787 'ar_minor_edit' => $row->rev_minor_edit,
788 'ar_rev_id' => $row->rev_id,
789 'ar_parent_id' => $row->rev_parent_id,
790 'ar_len' => $row->rev_len,
792 'ar_deleted' => $this->suppress ? $bitfield : $row->rev_deleted,
793 'ar_sha1' => $row->rev_sha1,
794 ] + $this->commentStore->insert( $dbw,
'ar_comment', $comment );
796 $rowsInsert[] = $rowInsert;
797 $revids[] = $row->rev_id;
801 if ( (
int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) {
802 $ipRevIds[] = $row->rev_id;
806 if ( count( $revids ) > 0 ) {
808 $dbw->newInsertQueryBuilder()
809 ->insertInto(
'archive' )
810 ->rows( $rowsInsert )
811 ->caller( __METHOD__ )->execute();
813 $dbw->newDeleteQueryBuilder()
814 ->deleteFrom(
'revision' )
815 ->where( [
'rev_id' => $revids ] )
816 ->caller( __METHOD__ )->execute();
818 if ( count( $ipRevIds ) > 0 ) {
819 $dbw->newDeleteQueryBuilder()
820 ->deleteFrom(
'ip_changes' )
821 ->where( [
'ipc_rev_id' => $ipRevIds ] )
822 ->caller( __METHOD__ )->execute();
837 private function doDeleteUpdates(
WikiPage $page, RevisionRecord $revRecord ): void {
839 $countable = $page->isCountable();
840 }
catch ( TimeoutException $e ) {
842 }
catch ( Exception $ex ) {
850 [
'edits' => 1,
'articles' => $countable ? -1 : 0,
'pages' => -1 ]
854 $updates = $this->getDeletionUpdates( $page, $revRecord );
855 foreach ( $updates as $update ) {
860 LinksUpdate::queueRecursiveJobsForTable(
864 $this->deleter->getUser()->getName(),
865 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
869 LinksUpdate::queueRecursiveJobsForTable(
873 $this->deleter->getUser()->getName(),
874 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
878 if ( !$this->isDeletePageUnitTest ) {
884 WikiModule::invalidateModuleCache(
892 $page->
loadFromRow(
false, WikiPage::READ_LATEST );
907 private function getDeletionUpdates(
WikiPage $page, RevisionRecord $rev ): array {
908 if ( $this->isDeletePageUnitTest ) {
912 $slotContent = array_map(
static function ( SlotRecord $slot ) {
913 return $slot->getContent();
914 }, $rev->getSlots()->getSlots() );
916 $allUpdates = [
new LinksDeletionUpdate( $page ) ];
923 foreach ( $slotContent as $role =>
$content ) {
924 $handler =
$content->getContentHandler();
926 $updates = $handler->getDeletionUpdates(
931 $allUpdates = array_merge( $allUpdates, $updates );
934 $this->hookRunner->onPageDeletionDataUpdates(
935 $page->
getTitle(), $rev, $allUpdates );
938 $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,...
if(!defined('MW_SETUP_CALLBACK'))
Class representing a cache/ephemeral data store.
Defer callable updates to run later in the PHP process.
static addUpdate(DeferrableUpdate $update, $stage=self::POSTSEND)
Add an update to the pending update queue for execution at the appropriate time.
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)
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.
Database independent search index updater.
Class for handling updates to the site_stats table.
static factory(array $deltas)
Generic operation result class Has warning/error list, boolean status and arbitrary value.
static newFatal( $message,... $parameters)
Factory function for fatal errors.
static newGood( $value=null)
Factory function for good results.
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 representing page content.
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