5use BadMethodCallException;
43use 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() ) ) {
259 return StatusValue::newFatal(
'delete-error-associated-alreadytalk' );
262 $talkPage = $this->wikiPageFactory->newFromLinkTarget(
263 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
265 if ( !$talkPage->exists() ) {
266 return StatusValue::newFatal(
'delete-error-associated-doesnotexist' );
268 return StatusValue::newGood();
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 BadMethodCallException( __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' );
506 $hookRes = $this->hookRunner->onPageDelete( $page, $this->deleter, $reason, $status, $this->suppress );
507 if ( !$hookRes && !$status->isGood() ) {
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__ );
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->selectRowCount(
636 'ar_namespace' =>
$title->getNamespace(),
637 'ar_title' =>
$title->getDBkey(),
645 $wikiPageBeforeDelete = clone $page;
648 $dbw->delete(
'page', [
'page_id' => $id ], __METHOD__ );
651 $logtype = $this->suppress ?
'suppress' :
'delete';
654 $logEntry->setPerformer( $this->deleter->getUser() );
655 $logEntry->setTarget( $logTitle );
656 $logEntry->setComment( $reason );
657 $logEntry->addTags( $this->tags );
658 if ( !$this->isDeletePageUnitTest ) {
660 $logid = $logEntry->insert();
662 $dbw->onTransactionPreCommitOrIdle(
663 static function () use ( $logEntry, $logid ) {
665 $logEntry->publish( $logid );
673 $dbw->endAtomic( __METHOD__ );
675 $this->doDeleteUpdates( $page, $revisionRecord );
677 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
678 $this->hookRunner->onArticleDeleteComplete(
679 $wikiPageBeforeDelete,
685 $archivedRevisionCount
687 $this->hookRunner->onPageDeleteComplete(
688 $wikiPageBeforeDelete,
694 $archivedRevisionCount
696 $this->successfulDeletionsIDs[$pageRole] = $logid;
699 $key = $this->recentDeletesCache->makeKey(
'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
700 $this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY );
712 private function archiveRevisions(
WikiPage $page,
int $id ): bool {
714 $namespace = $page->
getTitle()->getNamespace();
715 $dbKey = $page->
getTitle()->getDBkey();
717 $dbw = $this->lbFactory->getPrimaryDatabase();
719 $revQuery = $this->revisionStore->getQueryInfo();
723 if ( $this->suppress ) {
724 $bitfield = RevisionRecord::SUPPRESSED_ALL;
740 $lockQuery[
'tables'] = array_intersect(
742 [
'revision',
'revision_comment_temp' ]
744 unset( $lockQuery[
'fields'] );
745 $dbw->newSelectQueryBuilder()
746 ->queryInfo( $lockQuery )
747 ->where( [
'rev_page' => $id ] )
749 ->caller( __METHOD__ )
752 $deleteBatchSize = $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
758 [
'rev_page' => $id ],
760 [
'ORDER BY' =>
'rev_timestamp ASC, rev_id ASC',
'LIMIT' => $deleteBatchSize + 1 ],
772 foreach (
$res as $row ) {
773 if ( count( $revids ) >= $deleteBatchSize ) {
778 $comment = $this->commentStore->getComment(
'rev_comment', $row );
780 'ar_namespace' => $namespace,
781 'ar_title' => $dbKey,
782 'ar_actor' => $row->rev_actor,
783 'ar_timestamp' => $row->rev_timestamp,
784 'ar_minor_edit' => $row->rev_minor_edit,
785 'ar_rev_id' => $row->rev_id,
786 'ar_parent_id' => $row->rev_parent_id,
787 'ar_len' => $row->rev_len,
789 'ar_deleted' => $this->suppress ? $bitfield : $row->rev_deleted,
790 'ar_sha1' => $row->rev_sha1,
791 ] + $this->commentStore->insert( $dbw,
'ar_comment', $comment );
793 $rowsInsert[] = $rowInsert;
794 $revids[] = $row->rev_id;
798 if ( (
int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) {
799 $ipRevIds[] = $row->rev_id;
803 if ( count( $revids ) > 0 ) {
805 $dbw->insert(
'archive', $rowsInsert, __METHOD__ );
807 $dbw->delete(
'revision', [
'rev_id' => $revids ], __METHOD__ );
809 $dbw->delete(
'revision_comment_temp', [
'revcomment_rev' => $revids ], __METHOD__ );
812 if ( count( $ipRevIds ) > 0 ) {
813 $dbw->delete(
'ip_changes', [
'ipc_rev_id' => $ipRevIds ], __METHOD__ );
831 $countable = $page->isCountable();
832 }
catch ( TimeoutException $e ) {
834 }
catch ( Exception $ex ) {
841 if ( !$this->isDeletePageUnitTest ) {
843 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
844 [
'edits' => 1,
'articles' => $countable ? -1 : 0,
'pages' => -1 ]
848 $updates = $this->getDeletionUpdates( $page, $revRecord );
849 foreach ( $updates as $update ) {
850 DeferredUpdates::addUpdate( $update );
855 LinksUpdate::queueRecursiveJobsForTable(
859 $this->deleter->getUser()->getName(),
860 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
864 LinksUpdate::queueRecursiveJobsForTable(
868 $this->deleter->getUser()->getName(),
869 $this->backlinkCacheFactory->getBacklinkCache( $page->
getTitle() )
873 if ( !$this->isDeletePageUnitTest ) {
879 WikiModule::invalidateModuleCache(
887 $page->
loadFromRow(
false, WikiPage::READ_LATEST );
889 if ( !$this->isDeletePageUnitTest ) {
907 $slotContent = array_map( static function (
SlotRecord $slot ) {
908 return $slot->getContent();
909 }, $rev->getSlots()->getSlots() );
918 foreach ( $slotContent as $role =>
$content ) {
919 $handler =
$content->getContentHandler();
921 $updates = $handler->getDeletionUpdates(
926 $allUpdates = array_merge( $allUpdates, $updates );
929 $this->hookRunner->onPageDeletionDataUpdates(
930 $page->
getTitle(), $rev, $allUpdates );
933 $this->hookRunner->onWikiPageDeletionUpdates( $page,
$content, $allUpdates );
const SCHEMA_COMPAT_WRITE_OLD
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'))
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.
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...
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 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