MediaWiki master
DeletePage.php
Go to the documentation of this file.
1<?php
2
3namespace MediaWiki\Page;
4
5use BadMethodCallException;
6use Exception;
7use LogicException;
39use StatusValue;
40use Wikimedia\IPUtils;
46use Wikimedia\RequestTimeout\TimeoutException;
47use Wikimedia\Timestamp\ConvertibleTimestamp;
48use Wikimedia\Timestamp\TimestampFormat as TS;
49
60 public const CONSTRUCTOR_OPTIONS = [
63 ];
64
68 public const PAGE_BASE = 'base';
69 public const PAGE_TALK = 'talk';
70
72 private $isDeletePageUnitTest = false;
74 private $suppress = false;
76 private $tags = [];
78 private $logSubtype = 'delete';
80 private $forceImmediate = false;
82 private $associatedTalk;
83
85 private $legacyHookErrors = '';
87 private $mergeLegacyHookErrors = true;
88
93 private $successfulDeletionsIDs;
98 private $wasScheduled;
100 private $attemptedDeletion = false;
101
102 private HookRunner $hookRunner;
103 private WikiPage $page;
104
108 public function __construct(
109 HookContainer $hookContainer,
110 private readonly DomainEventDispatcher $eventDispatcher,
111 private readonly RevisionStore $revisionStore,
112 private readonly LBFactory $lbFactory,
113 private readonly JobQueueGroup $jobQueueGroup,
114 private readonly CommentStore $commentStore,
115 private readonly ServiceOptions $options,
116 private readonly BagOStuff $recentDeletesCache,
117 private readonly string $webRequestID,
118 private readonly WikiPageFactory $wikiPageFactory,
119 private readonly UserFactory $userFactory,
120 private readonly BacklinkCacheFactory $backlinkCacheFactory,
121 private readonly NamespaceInfo $namespaceInfo,
122 private readonly ITextFormatter $contLangMsgTextFormatter,
123 private readonly RedirectStore $redirectStore,
124 ProperPageIdentity $page,
125 private readonly Authority $deleter,
126 private readonly WriteDuplicator $linkWriteDuplicator,
127 private readonly UserEditTracker $userEditTracker
128 ) {
129 $this->hookRunner = new HookRunner( $hookContainer );
130 $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
131 $this->page = $wikiPageFactory->newFromTitle( $page );
132 }
133
138 public function getLegacyHookErrors() {
139 return $this->legacyHookErrors;
140 }
141
146 public function keepLegacyHookErrorsSeparate(): self {
147 $this->mergeLegacyHookErrors = false;
148 return $this;
149 }
150
158 public function setSuppress( bool $suppress ): self {
159 $this->suppress = $suppress;
160 return $this;
161 }
162
169 public function setTags( array $tags ): self {
170 $this->tags = $tags;
171 return $this;
172 }
173
180 public function setLogSubtype( string $logSubtype ): self {
181 $this->logSubtype = $logSubtype;
182 return $this;
183 }
184
191 public function forceImmediate( bool $forceImmediate ): self {
192 $this->forceImmediate = $forceImmediate;
193 return $this;
194 }
195
201 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
202 return StatusValue::newFatal( 'delete-error-associated-alreadytalk' );
203 }
204 // FIXME NamespaceInfo should work with PageIdentity
205 $talkPage = $this->wikiPageFactory->newFromLinkTarget(
206 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
207 );
208 if ( !$talkPage->exists() ) {
209 return StatusValue::newFatal( 'delete-error-associated-doesnotexist' );
210 }
211 return StatusValue::newGood();
212 }
213
225 public function setDeleteAssociatedTalk( bool $delete ): self {
226 if ( !$delete ) {
227 $this->associatedTalk = null;
228 return $this;
229 }
230
231 if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
232 throw new BadMethodCallException( "Cannot delete associated talk page of a talk page! ($this->page)" );
233 }
234 // FIXME NamespaceInfo should work with PageIdentity
235 $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
236 $this->namespaceInfo->getTalkPage( $this->page->getTitle() )
237 );
238 return $this;
239 }
240
246 public function setIsDeletePageUnitTest( bool $test ): void {
247 if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
248 throw new LogicException( __METHOD__ . ' can only be used in tests!' );
249 }
250 $this->isDeletePageUnitTest = $test;
251 }
252
258 public function setDeletionAttempted(): self {
259 $this->attemptedDeletion = true;
260 $this->successfulDeletionsIDs = [ self::PAGE_BASE => null ];
261 $this->wasScheduled = [ self::PAGE_BASE => null ];
262 if ( $this->associatedTalk ) {
263 $this->successfulDeletionsIDs[self::PAGE_TALK] = null;
264 $this->wasScheduled[self::PAGE_TALK] = null;
265 }
266 return $this;
267 }
268
273 private function assertDeletionAttempted(): void {
274 if ( !$this->attemptedDeletion ) {
275 throw new BadMethodCallException( 'No deletion was attempted' );
276 }
277 }
278
283 public function getSuccessfulDeletionsIDs(): array {
284 $this->assertDeletionAttempted();
285 return $this->successfulDeletionsIDs;
286 }
287
292 public function deletionsWereScheduled(): array {
293 $this->assertDeletionAttempted();
294 return $this->wasScheduled;
295 }
296
303 public function deleteIfAllowed( string $reason ): StatusValue {
304 $this->setDeletionAttempted();
305 $status = $this->authorizeDeletion();
306 if ( !$status->isGood() ) {
307 return $status;
308 }
309
310 return $this->deleteUnsafe( $reason );
311 }
312
313 private function authorizeDeletion(): PermissionStatus {
314 $status = PermissionStatus::newEmpty();
315 $this->deleter->authorizeWrite( 'delete', $this->page, $status );
316 if ( $this->associatedTalk ) {
317 $this->deleter->authorizeWrite( 'delete', $this->associatedTalk, $status );
318 }
319 if ( !$this->deleter->isAllowed( 'bigdelete' ) && $this->isBigDeletion() ) {
320 $status->fatal(
321 'delete-toomanyrevisions',
322 Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) )
323 );
324 }
325 if ( $this->tags ) {
326 $status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->deleter ) );
327 }
328 return $status;
329 }
330
331 private function isBigDeletion(): bool {
332 $revLimit = $this->options->get( MainConfigNames::DeleteRevisionsLimit );
333 if ( !$revLimit ) {
334 return false;
335 }
336
337 $dbr = $this->lbFactory->getReplicaDatabase();
338 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
339 if ( $this->associatedTalk ) {
340 $revCount += $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
341 }
342
343 return $revCount > $revLimit;
344 }
345
358 public function isBatchedDelete( int $safetyMargin = 0 ): bool {
359 $dbr = $this->lbFactory->getReplicaDatabase();
360 $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() );
361 $revCount += $safetyMargin;
362
363 if ( $revCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ) ) {
364 return true;
365 } elseif ( !$this->associatedTalk ) {
366 return false;
367 }
368
369 $talkRevCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() );
370 $talkRevCount += $safetyMargin;
371
372 return $talkRevCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
373 }
374
385 public function deleteUnsafe( string $reason ): Status {
386 $this->setDeletionAttempted();
387 $origReason = $reason;
388 $hookStatus = $this->runPreDeleteHooks( $this->page, $reason );
389 if ( !$hookStatus->isGood() ) {
390 return $hookStatus;
391 }
392 if ( $this->associatedTalk ) {
393 $talkReason = $this->contLangMsgTextFormatter->format(
394 MessageValue::new( 'delete-talk-summary-prefix' )->plaintextParams( $origReason )
395 );
396 $talkHookStatus = $this->runPreDeleteHooks( $this->associatedTalk, $talkReason );
397 if ( !$talkHookStatus->isGood() ) {
398 return $talkHookStatus;
399 }
400 }
401
402 $status = $this->deleteInternal( $this->page, self::PAGE_BASE, $reason );
403 if ( !$this->associatedTalk || !$status->isGood() ) {
404 return $status;
405 }
406 // NOTE: If the page deletion above failed because the page is no longer there (e.g. race condition) we'll
407 // still try to delete the talk page, since it was the user's intention anyway.
408 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable talkReason is set when used
409 $status->merge( $this->deleteInternal( $this->associatedTalk, self::PAGE_TALK, $talkReason ) );
410 return $status;
411 }
412
418 private function runPreDeleteHooks( WikiPage $page, string &$reason ): Status {
419 $status = Status::newGood();
420
421 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
422 if ( !$this->hookRunner->onArticleDelete(
423 $page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress )
424 ) {
425 if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !== '' ) {
426 if ( is_string( $this->legacyHookErrors ) ) {
427 $this->legacyHookErrors = [ $this->legacyHookErrors ];
428 }
429 foreach ( $this->legacyHookErrors as $legacyError ) {
430 $status->fatal( new RawMessage( $legacyError ) );
431 }
432 }
433 if ( $status->isOK() ) {
434 // Hook aborted but didn't set a fatal status
435 $status->fatal( 'delete-hook-aborted' );
436 }
437 return $status;
438 }
439
440 // Use a new Status in case a hook handler put something here without aborting.
441 $status = Status::newGood();
442 $hookRes = $this->hookRunner->onPageDelete( $page, $this->deleter, $reason, $status, $this->suppress );
443 if ( !$hookRes && !$status->isGood() ) {
444 // Note: as per the PageDeleteHook documentation, `return false` is ignored if $status is good.
445 return $status;
446 }
447 return Status::newGood();
448 }
449
465 public function deleteInternal(
466 WikiPage $page,
467 string $pageRole,
468 string $reason,
469 ?string $webRequestId = null,
470 $ticket = null
471 ): Status {
472 $title = $page->getTitle();
473 $status = Status::newGood();
474
475 $dbw = $this->lbFactory->getPrimaryDatabase();
476 $dbw->startAtomic( __METHOD__ );
477
478 $page->loadPageData( IDBAccessObject::READ_LATEST );
479 $id = $page->getId();
480 // T98706: lock the page from various other updates but avoid using
481 // IDBAccessObject::READ_LOCKING as that will carry over the FOR UPDATE to
482 // the revisions queries (which also JOIN on user). Only lock the page
483 // row and CAS check on page_latest to see if the trx snapshot matches.
484 $lockedLatest = $page->lockAndGetLatest();
485 if ( $id === 0 || $page->getLatest() !== $lockedLatest ) {
486 $dbw->endAtomic( __METHOD__ );
487 // Page not there or trx snapshot is stale
488 $status->error( 'cannotdelete', wfEscapeWikiText( $title->getPrefixedText() ) );
489 return $status;
490 }
491
492 // At this point we are now committed to returning an OK
493 // status unless some DB query error or other exception comes up.
494 // This way callers don't have to call rollback() if $status is bad
495 // unless they actually try to catch exceptions (which is rare).
496
497 // we need to remember the old content so we can use it to generate all deletion updates.
498 $revisionRecord = $page->getRevisionRecord();
499 if ( !$revisionRecord ) {
500 throw new LogicException( "No revisions for $page?" );
501 }
502 try {
503 $content = $page->getContent( RevisionRecord::RAW );
504 } catch ( TimeoutException $e ) {
505 throw $e;
506 } catch ( Exception $ex ) {
507 wfLogWarning( __METHOD__ . ': failed to load content during deletion! '
508 . $ex->getMessage() );
509
510 $content = null;
511 }
512
513 // Archive revisions. In immediate mode, archive all revisions. Otherwise, archive
514 // one batch of revisions and defer archival of any others to the job queue.
515 while ( true ) {
516 $done = $this->archiveRevisions( $page, $id );
517 if ( $done || !$this->forceImmediate ) {
518 break;
519 }
520 $dbw->endAtomic( __METHOD__ );
521 $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
522 $dbw->startAtomic( __METHOD__ );
523 }
524
525 if ( !$done ) {
526 $dbw->endAtomic( __METHOD__ );
527
528 $jobParams = [
529 'namespace' => $title->getNamespace(),
530 'title' => $title->getDBkey(),
531 'wikiPageId' => $id,
532 'requestId' => $webRequestId ?? $this->webRequestID,
533 'reason' => $reason,
534 'suppress' => $this->suppress,
535 'userId' => $this->deleter->getUser()->getId(),
536 'tags' => json_encode( $this->tags ),
537 'logsubtype' => $this->logSubtype,
538 'pageRole' => $pageRole,
539 ];
540
541 $job = new DeletePageJob( $jobParams );
542 $this->jobQueueGroup->push( $job );
543 $this->wasScheduled[$pageRole] = true;
544 return $status;
545 }
546 $this->wasScheduled[$pageRole] = false;
547
548 // Get archivedRevisionCount by db query, because there's no better alternative.
549 // Jobs cannot pass a count of archived revisions to the next job, because additional
550 // deletion operations can be started while the first is running. Jobs from each
551 // gracefully interleave, but would not know about each other's count. Deduplication
552 // in the job queue to avoid simultaneous deletion operations would add overhead.
553 // Number of archived revisions cannot be known beforehand, because edits can be made
554 // while deletion operations are being processed, changing the number of archivals.
555 $archivedRevisionCount = $dbw->newSelectQueryBuilder()
556 ->select( '*' )
557 ->from( 'archive' )
558 ->where( [
559 'ar_namespace' => $title->getNamespace(),
560 'ar_title' => $title->getDBkey(),
561 'ar_page_id' => $id
562 ] )
563 ->caller( __METHOD__ )->fetchRowCount();
564
565 // Look up the redirect target before deleting the page to avoid inconsistent state (T348881).
566 // The cloning business below is specifically to allow hook handlers to check the redirect
567 // status before the deletion (see I715046dc8157047aff4d5bd03ea6b5a47aee58bb).
568 $page->getRedirectTarget();
569 // Clone the title and wikiPage, so we have the information we need when
570 // we log and run the ArticleDeleteComplete hook.
571 $logTitle = clone $title;
572 $wikiPageBeforeDelete = clone $page;
573 $pageBeforeDelete = $page->toPageRecord();
574
575 // Now that it's safely backed up, delete it
576 $delete = $dbw->newDeleteQueryBuilder()
577 ->deleteFrom( 'page' )
578 ->where( [ 'page_id' => $id ] )
579 ->caller( __METHOD__ );
580 $delete->execute();
581 $this->linkWriteDuplicator->duplicate( $delete );
582
583 // Log the deletion, if the page was suppressed, put it in the suppression log instead
584 $logtype = $this->suppress ? 'suppress' : 'delete';
585
586 $logEntry = new ManualLogEntry( $logtype, $this->logSubtype );
587 $logEntry->setPerformer( $this->deleter->getUser() );
588 $logEntry->setTarget( $logTitle );
589 $logEntry->setComment( $reason );
590 $logEntry->addTags( $this->tags );
591 if ( !$this->isDeletePageUnitTest ) {
592 // TODO: Remove conditional once ManualLogEntry is servicified (T253717)
593 $logid = $logEntry->insert();
594
595 $dbw->onTransactionPreCommitOrIdle(
596 static function () use ( $logEntry, $logid ) {
597 // T58776: avoid deadlocks (especially from FileDeleteForm)
598 $logEntry->publish( $logid );
599 },
600 __METHOD__
601 );
602 } else {
603 $logid = 42;
604 }
605
606 $this->eventDispatcher->dispatch( new PageDeletedEvent(
607 $pageBeforeDelete,
608 $revisionRecord,
609 $this->deleter->getUser(),
610 $this->tags,
611 [ PageDeletedEvent::FLAG_SUPPRESSED => $this->suppress ],
612 $logEntry->getTimestamp(),
613 $reason,
614 $archivedRevisionCount,
615 $pageBeforeDelete->isRedirect() ? $this->redirectStore->getRedirectTarget( $page ) : null
616 ), $this->lbFactory );
617
618 $dbw->endAtomic( __METHOD__ );
619
620 $this->doDeleteUpdates( $wikiPageBeforeDelete, $revisionRecord );
621
622 // Reset the page object and the Title object
623 $page->loadFromRow( false, IDBAccessObject::READ_LATEST );
624
625 // Make sure there are no cached title instances that refer to the same page.
626 Title::clearCaches();
627
628 $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter );
629 $this->hookRunner->onArticleDeleteComplete(
630 $wikiPageBeforeDelete,
631 $legacyDeleter,
632 $reason,
633 $id,
634 $content,
635 $logEntry,
636 $archivedRevisionCount
637 );
638 $this->hookRunner->onPageDeleteComplete(
639 $pageBeforeDelete,
640 $this->deleter,
641 $reason,
642 $id,
643 $revisionRecord,
644 $logEntry,
645 $archivedRevisionCount
646 );
647 $this->successfulDeletionsIDs[$pageRole] = $logid;
648
649 // Clear any cached redirect status for the now-deleted page.
650 $this->redirectStore->clearCache( $page );
651
652 // Show log excerpt on 404 pages rather than just a link
653 $key = $this->recentDeletesCache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) );
654 $this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY );
655
656 return $status;
657 }
658
666 private function archiveRevisions( WikiPage $page, int $id ): bool {
667 // Given the lock above, we can be confident in the title and page ID values
668 $namespace = $page->getTitle()->getNamespace();
669 $dbKey = $page->getTitle()->getDBkey();
670
671 $dbw = $this->lbFactory->getPrimaryDatabase();
672
673 $revQuery = $this->revisionStore->getQueryInfo();
674 $bitfield = false;
675
676 // Bitfields to further suppress the content
677 if ( $this->suppress ) {
678 $bitfield = RevisionRecord::SUPPRESSED_ALL;
679 $revQuery['fields'] = array_diff( $revQuery['fields'], [ 'rev_deleted' ] );
680 }
681
682 // For now, shunt the revision data into the archive table.
683 // Text is *not* removed from the text table; bulk storage
684 // is left intact to avoid breaking block-compression or
685 // immutable storage schemes.
686 // In the future, we may keep revisions and mark them with
687 // the rev_deleted field, which is reserved for this purpose.
688
689 // Lock rows in `revision` and its temp tables, but not any others.
690 // Note array_intersect() preserves keys from the first arg, and we're
691 // assuming $revQuery has `revision` primary and isn't using subtables
692 // for anything we care about.
693 $lockQuery = $revQuery;
694 $lockQuery['tables'] = array_intersect(
695 $revQuery['tables'],
696 [ 'revision', 'revision_comment_temp' ]
697 );
698 unset( $lockQuery['fields'] );
699 $dbw->newSelectQueryBuilder()
700 ->queryInfo( $lockQuery )
701 ->where( [ 'rev_page' => $id ] )
702 ->forUpdate()
703 ->caller( __METHOD__ )
704 ->acquireRowLocks();
705
706 $deleteBatchSize = $this->options->get( MainConfigNames::DeleteRevisionsBatchSize );
707 // Get as many of the page revisions as we are allowed to. The +1 lets us recognize the
708 // unusual case where there were exactly $deleteBatchSize revisions remaining.
709 $res = $dbw->newSelectQueryBuilder()
710 ->queryInfo( $revQuery )
711 ->where( [ 'rev_page' => $id ] )
712 ->orderBy( [ 'rev_timestamp', 'rev_id' ] )
713 ->limit( $deleteBatchSize + 1 )
714 ->caller( __METHOD__ )
715 ->fetchResultSet();
716
717 // Build their equivalent archive rows
718 $rowsInsert = [];
719 $revids = [];
720
722 $ipRevIds = [];
723
724 $done = true;
725 $revAuthors = [];
726 foreach ( $res as $row ) {
727 if ( count( $revids ) >= $deleteBatchSize ) {
728 $done = false;
729 break;
730 }
731
732 $comment = $this->commentStore->getComment( 'rev_comment', $row );
733 $rowInsert = [
734 'ar_namespace' => $namespace,
735 'ar_title' => $dbKey,
736 'ar_actor' => $row->rev_actor,
737 'ar_timestamp' => $row->rev_timestamp,
738 'ar_minor_edit' => $row->rev_minor_edit,
739 'ar_rev_id' => $row->rev_id,
740 'ar_parent_id' => $row->rev_parent_id,
741 'ar_len' => $row->rev_len,
742 'ar_page_id' => $id,
743 'ar_deleted' => $this->suppress ? $bitfield : $row->rev_deleted,
744 ] + $this->commentStore->insert( $dbw, 'ar_comment', $comment );
745
746 $rowsInsert[] = $rowInsert;
747 $revids[] = $row->rev_id;
748
749 // Keep track of IP edits, so that the corresponding rows can
750 // be deleted in the ip_changes table.
751 if ( (int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) {
752 $ipRevIds[] = $row->rev_id;
753 }
754
755 // Record the timestamp of the first edit by the user to this page,
756 // used for purging UserEditTracker cache.
757 if ( !array_key_exists( (int)$row->rev_user, $revAuthors ) ) {
758 $userIdentity = new UserIdentityValue( (int)$row->rev_user, $row->rev_user_text );
759 $timestamp = ConvertibleTimestamp::convert( TS::MW, $row->rev_timestamp );
760 $revAuthors[$userIdentity->getId()] = [ $userIdentity, $timestamp ];
761 }
762 }
763
764 $this->userEditTracker->invalidateCachedFirstEditTimestamps( array_values( $revAuthors ) );
765
766 if ( count( $revids ) > 0 ) {
767 // Copy them into the archive table
768 $dbw->newInsertQueryBuilder()
769 ->insertInto( 'archive' )
770 ->rows( $rowsInsert )
771 ->caller( __METHOD__ )->execute();
772
773 $dbw->newDeleteQueryBuilder()
774 ->deleteFrom( 'revision' )
775 ->where( [ 'rev_id' => $revids ] )
776 ->caller( __METHOD__ )->execute();
777 // Also delete records from ip_changes as applicable.
778 if ( count( $ipRevIds ) > 0 ) {
779 $dbw->newDeleteQueryBuilder()
780 ->deleteFrom( 'ip_changes' )
781 ->where( [ 'ipc_rev_id' => $ipRevIds ] )
782 ->caller( __METHOD__ )->execute();
783 }
784 }
785
786 return $done;
787 }
788
797 private function doDeleteUpdates( WikiPage $pageBeforeDelete, RevisionRecord $revRecord ): void {
798 try {
799 $countable = $pageBeforeDelete->isCountable();
800 } catch ( TimeoutException $e ) {
801 throw $e;
802 } catch ( Exception ) {
803 // fallback for deleting broken pages for which we cannot load the content for
804 // some reason. Note that doDeleteArticleReal() already logged this problem.
805 $countable = false;
806 }
807
808 // Update site status
809 // TODO: Move to ChangeTrackingEventIngress,
810 // see https://gerrit.wikimedia.org/r/c/mediawiki/core/+/1099177
811 DeferredUpdates::addUpdate( SiteStatsUpdate::factory(
812 [ 'edits' => 1, 'articles' => $countable ? -1 : 0, 'pages' => -1 ]
813 ) );
814
815 // Delete pagelinks, update secondary indexes, etc
816 $updates = $this->getDeletionUpdates( $pageBeforeDelete, $revRecord );
817 foreach ( $updates as $update ) {
818 DeferredUpdates::addUpdate( $update );
819 }
820
821 // Reparse any pages transcluding this page
822 LinksUpdate::queueRecursiveJobsForTable(
823 $pageBeforeDelete->getTitle(),
824 'templatelinks',
825 'delete-page',
826 $this->deleter->getUser()->getName(),
827 $this->backlinkCacheFactory->getBacklinkCache( $pageBeforeDelete->getTitle() )
828 );
829 // Reparse any pages including this image
830 if ( $pageBeforeDelete->getTitle()->getNamespace() === NS_FILE ) {
831 LinksUpdate::queueRecursiveJobsForTable(
832 $pageBeforeDelete->getTitle(),
833 'imagelinks',
834 'delete-page',
835 $this->deleter->getUser()->getName(),
836 $this->backlinkCacheFactory->getBacklinkCache( $pageBeforeDelete->getTitle() )
837 );
838 }
839
840 if ( !$this->isDeletePageUnitTest ) {
841 // TODO Remove conditional once WikiPage::onArticleDelete is moved to a proper service
842 // Clear caches
843 WikiPage::onArticleDelete( $pageBeforeDelete->getTitle() );
844 }
845 }
846
856 private function getDeletionUpdates( WikiPage $page, RevisionRecord $rev ): array {
857 if ( $this->isDeletePageUnitTest ) {
858 // Hack: LinksDeletionUpdate reads from the global state in the constructor
859 return [];
860 }
861 $slotContent = array_map( static function ( SlotRecord $slot ) {
862 return $slot->getContent();
863 }, $rev->getSlots()->getSlots() );
864
865 $allUpdates = [ new LinksDeletionUpdate( $page ) ];
866
867 // NOTE: once Content::getDeletionUpdates() is removed, we only need the content
868 // model here, not the content object!
869 // TODO: consolidate with similar logic in DerivedPageDataUpdater::getSecondaryDataUpdates()
871 $content = null; // in case $slotContent is zero-length
872 foreach ( $slotContent as $role => $content ) {
873 $handler = $content->getContentHandler();
874
875 $updates = $handler->getDeletionUpdates(
876 $page->getTitle(),
877 $role
878 );
879
880 $allUpdates = array_merge( $allUpdates, $updates );
881 }
882
883 $this->hookRunner->onPageDeletionDataUpdates(
884 $page->getTitle(), $rev, $allUpdates );
885
886 // TODO: hard deprecate old hook in 1.33
887 $this->hookRunner->onWikiPageDeletionUpdates( $page, $content, $allUpdates );
888 return $allUpdates;
889 }
890}
const NS_FILE
Definition Defines.php:57
wfEscapeWikiText( $input)
Escapes the given text so that it may be output using addWikiText() without any linking,...
wfLogWarning( $msg, $callerOffset=1, $level=E_USER_WARNING)
Send a warning as a PHP error and the debug log.
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:71
Recent changes tagging.
Handle database storage of comments such as edit summaries and log reasons.
A class for passing options to services.
Defer callable updates to run later in the PHP process.
Update object handling the cleanup of links tables after a page was deleted.
Class the manages updates of *_link tables as well as similar extension-managed tables.
Class for handling updates to the site_stats table.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Handle enqueueing of background jobs.
Variant of the Message class.
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()
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:144
Backend logic for performing a page delete action.
setDeletionAttempted()
Called before attempting a deletion, allows the result getters to be used.
__construct(HookContainer $hookContainer, private readonly DomainEventDispatcher $eventDispatcher, private readonly RevisionStore $revisionStore, private readonly LBFactory $lbFactory, private readonly JobQueueGroup $jobQueueGroup, private readonly CommentStore $commentStore, private readonly ServiceOptions $options, private readonly BagOStuff $recentDeletesCache, private readonly string $webRequestID, private readonly WikiPageFactory $wikiPageFactory, private readonly UserFactory $userFactory, private readonly BacklinkCacheFactory $backlinkCacheFactory, private readonly NamespaceInfo $namespaceInfo, private readonly ITextFormatter $contLangMsgTextFormatter, private readonly RedirectStore $redirectStore, ProperPageIdentity $page, private readonly Authority $deleter, private readonly WriteDuplicator $linkWriteDuplicator, private readonly UserEditTracker $userEditTracker)
canProbablyDeleteAssociatedTalk()
Tests whether it's probably possible to delete the associated talk page.
setTags(array $tags)
Change tags to apply to the deletion action.
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()
isBatchedDelete(int $safetyMargin=0)
Determines if this deletion would be batched (executed over time by the job queue) or not (completed ...
deleteInternal(WikiPage $page, string $pageRole, string $reason, ?string $webRequestId=null, $ticket=null)
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...
Domain event representing page deletion.
Service for storing and retrieving page redirect information.
Service for creating WikiPage objects.
Base representation for an editable wiki page.
Definition WikiPage.php:83
getContent( $audience=RevisionRecord::FOR_PUBLIC, ?Authority $performer=null)
Get the content of the latest revision.
Definition WikiPage.php:776
getRevisionRecord()
Get the latest revision.
Definition WikiPage.php:758
getLatest( $wikiId=self::LOCAL)
Get the page_latest field.
Definition WikiPage.php:695
getRedirectTarget()
If this page is a redirect, get its target.
Definition WikiPage.php:977
getId( $wikiId=self::LOCAL)
Definition WikiPage.php:541
toPageRecord()
Returns the page represented by this WikiPage as a PageStoreRecord.
loadPageData( $from=IDBAccessObject::READ_NORMAL)
Load the object from a given source by title.
Definition WikiPage.php:413
lockAndGetLatest()
Lock the page row for this title+id and return page_latest (or 0)
A StatusValue for permission errors.
Page revision base class.
Service for looking up page revisions.
Value object representing a content slot associated with a page revision.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
Represents a title within MediaWiki.
Definition Title.php:69
Track info about user edit counts and timings.
Create User objects.
Value object representing a user's identity.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Value object representing a message for i18n.
Abstract class for any ephemeral data store.
Definition BagOStuff.php:73
Content objects represent page content, e.g.
Definition Content.php:28
Interface that deferrable updates should implement.
Service for sending domain events to registered listeners.
Interface for a page that is (or could be, or used to be) an editable wiki page.
This interface represents the authority associated with the current execution context,...
Definition Authority.php:23
Interface for database access objects.
if(count( $args)< 1) $job