Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
78.15% |
279 / 357 |
|
39.13% |
9 / 23 |
CRAP | |
0.00% |
0 / 1 |
DeletePage | |
78.15% |
279 / 357 |
|
39.13% |
9 / 23 |
146.75 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
1 | |||
getLegacyHookErrors | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
keepLegacyHookErrorsSeparate | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
setSuppress | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setTags | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setLogSubtype | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
forceImmediate | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
canProbablyDeleteAssociatedTalk | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
setDeleteAssociatedTalk | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
setIsDeletePageUnitTest | n/a |
0 / 0 |
n/a |
0 / 0 |
2 | |||||
setDeletionAttempted | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
2.09 | |||
assertDeletionAttempted | |
50.00% |
1 / 2 |
|
0.00% |
0 / 1 |
2.50 | |||
getSuccessfulDeletionsIDs | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
deletionsWereScheduled | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
deleteIfAllowed | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
authorizeDeletion | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
5.12 | |||
isBigDeletion | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
3.14 | |||
isBatchedDelete | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
deleteUnsafe | |
47.06% |
8 / 17 |
|
0.00% |
0 / 1 |
11.34 | |||
runPreDeleteHooks | |
47.06% |
8 / 17 |
|
0.00% |
0 / 1 |
21.02 | |||
deleteInternal | |
86.79% |
92 / 106 |
|
0.00% |
0 / 1 |
12.33 | |||
archiveRevisions | |
92.75% |
64 / 69 |
|
0.00% |
0 / 1 |
9.03 | |||
doDeleteUpdates | |
69.44% |
25 / 36 |
|
0.00% |
0 / 1 |
8.40 | |||
getDeletionUpdates | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
3.00 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Page; |
4 | |
5 | use BadMethodCallException; |
6 | use ChangeTags; |
7 | use DeletePageJob; |
8 | use Exception; |
9 | use JobQueueGroup; |
10 | use LogicException; |
11 | use ManualLogEntry; |
12 | use MediaWiki\Cache\BacklinkCacheFactory; |
13 | use MediaWiki\CommentStore\CommentStore; |
14 | use MediaWiki\Config\ServiceOptions; |
15 | use MediaWiki\Content\Content; |
16 | use MediaWiki\Deferred\DeferrableUpdate; |
17 | use MediaWiki\Deferred\DeferredUpdates; |
18 | use MediaWiki\Deferred\LinksUpdate\LinksDeletionUpdate; |
19 | use MediaWiki\Deferred\LinksUpdate\LinksUpdate; |
20 | use MediaWiki\Deferred\SiteStatsUpdate; |
21 | use MediaWiki\HookContainer\HookContainer; |
22 | use MediaWiki\HookContainer\HookRunner; |
23 | use MediaWiki\Language\RawMessage; |
24 | use MediaWiki\MainConfigNames; |
25 | use MediaWiki\Message\Message; |
26 | use MediaWiki\Permissions\Authority; |
27 | use MediaWiki\Permissions\PermissionStatus; |
28 | use MediaWiki\ResourceLoader\WikiModule; |
29 | use MediaWiki\Revision\RevisionRecord; |
30 | use MediaWiki\Revision\RevisionStore; |
31 | use MediaWiki\Revision\SlotRecord; |
32 | use MediaWiki\Search\SearchUpdate; |
33 | use MediaWiki\Status\Status; |
34 | use MediaWiki\Title\NamespaceInfo; |
35 | use MediaWiki\Title\Title; |
36 | use MediaWiki\User\UserFactory; |
37 | use StatusValue; |
38 | use Wikimedia\IPUtils; |
39 | use Wikimedia\Message\ITextFormatter; |
40 | use Wikimedia\Message\MessageValue; |
41 | use Wikimedia\ObjectCache\BagOStuff; |
42 | use Wikimedia\Rdbms\IDBAccessObject; |
43 | use Wikimedia\Rdbms\LBFactory; |
44 | use Wikimedia\RequestTimeout\TimeoutException; |
45 | use WikiPage; |
46 | |
47 | /** |
48 | * Backend logic for performing a page delete action. |
49 | * |
50 | * @since 1.37 |
51 | */ |
52 | class DeletePage { |
53 | /** |
54 | * @internal For use by PageCommandFactory |
55 | */ |
56 | public const CONSTRUCTOR_OPTIONS = [ |
57 | MainConfigNames::DeleteRevisionsBatchSize, |
58 | MainConfigNames::DeleteRevisionsLimit, |
59 | ]; |
60 | |
61 | /** |
62 | * Constants used for the return value of getSuccessfulDeletionsIDs() and deletionsWereScheduled() |
63 | */ |
64 | public const PAGE_BASE = 'base'; |
65 | public const PAGE_TALK = 'talk'; |
66 | |
67 | /** @var bool */ |
68 | private $isDeletePageUnitTest = false; |
69 | /** @var bool */ |
70 | private $suppress = false; |
71 | /** @var string[] */ |
72 | private $tags = []; |
73 | /** @var string */ |
74 | private $logSubtype = 'delete'; |
75 | /** @var bool */ |
76 | private $forceImmediate = false; |
77 | /** @var WikiPage|null If not null, it means that we have to delete it. */ |
78 | private $associatedTalk; |
79 | |
80 | /** @var string|array */ |
81 | private $legacyHookErrors = ''; |
82 | /** @var bool */ |
83 | private $mergeLegacyHookErrors = true; |
84 | |
85 | /** |
86 | * @var array<int|null>|null Keys are the self::PAGE_* constants. Values are null if the deletion couldn't happen |
87 | * (e.g. due to lacking perms) or was scheduled. PAGE_TALK is only set when deleting the associated talk. |
88 | */ |
89 | private $successfulDeletionsIDs; |
90 | /** |
91 | * @var array<bool|null>|null Keys are the self::PAGE_* constants. Values are null if the deletion couldn't happen |
92 | * (e.g. due to lacking perms). PAGE_TALK is only set when deleting the associated talk. |
93 | */ |
94 | private $wasScheduled; |
95 | /** @var bool Whether a deletion was attempted */ |
96 | private $attemptedDeletion = false; |
97 | |
98 | private HookRunner $hookRunner; |
99 | private RevisionStore $revisionStore; |
100 | private LBFactory $lbFactory; |
101 | private JobQueueGroup $jobQueueGroup; |
102 | private CommentStore $commentStore; |
103 | private ServiceOptions $options; |
104 | private BagOStuff $recentDeletesCache; |
105 | private string $localWikiID; |
106 | private string $webRequestID; |
107 | private WikiPageFactory $wikiPageFactory; |
108 | private UserFactory $userFactory; |
109 | private BacklinkCacheFactory $backlinkCacheFactory; |
110 | private NamespaceInfo $namespaceInfo; |
111 | private ITextFormatter $contLangMsgTextFormatter; |
112 | private RedirectStore $redirectStore; |
113 | private WikiPage $page; |
114 | private Authority $deleter; |
115 | |
116 | /** |
117 | * @internal Create via the PageDeleteFactory service. |
118 | */ |
119 | public function __construct( |
120 | HookContainer $hookContainer, |
121 | RevisionStore $revisionStore, |
122 | LBFactory $lbFactory, |
123 | JobQueueGroup $jobQueueGroup, |
124 | CommentStore $commentStore, |
125 | ServiceOptions $serviceOptions, |
126 | BagOStuff $recentDeletesCache, |
127 | string $localWikiID, |
128 | string $webRequestID, |
129 | WikiPageFactory $wikiPageFactory, |
130 | UserFactory $userFactory, |
131 | BacklinkCacheFactory $backlinkCacheFactory, |
132 | NamespaceInfo $namespaceInfo, |
133 | ITextFormatter $contLangMsgTextFormatter, |
134 | RedirectStore $redirectStore, |
135 | ProperPageIdentity $page, |
136 | Authority $deleter |
137 | ) { |
138 | $this->hookRunner = new HookRunner( $hookContainer ); |
139 | $this->revisionStore = $revisionStore; |
140 | $this->lbFactory = $lbFactory; |
141 | $this->jobQueueGroup = $jobQueueGroup; |
142 | $this->commentStore = $commentStore; |
143 | $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
144 | $this->options = $serviceOptions; |
145 | $this->recentDeletesCache = $recentDeletesCache; |
146 | $this->localWikiID = $localWikiID; |
147 | $this->webRequestID = $webRequestID; |
148 | $this->wikiPageFactory = $wikiPageFactory; |
149 | $this->userFactory = $userFactory; |
150 | $this->backlinkCacheFactory = $backlinkCacheFactory; |
151 | $this->namespaceInfo = $namespaceInfo; |
152 | $this->contLangMsgTextFormatter = $contLangMsgTextFormatter; |
153 | |
154 | $this->page = $wikiPageFactory->newFromTitle( $page ); |
155 | $this->deleter = $deleter; |
156 | $this->redirectStore = $redirectStore; |
157 | } |
158 | |
159 | /** |
160 | * @internal BC method for use by WikiPage::doDeleteArticleReal only. |
161 | * @return array|string |
162 | */ |
163 | public function getLegacyHookErrors() { |
164 | return $this->legacyHookErrors; |
165 | } |
166 | |
167 | /** |
168 | * @internal BC method for use by WikiPage::doDeleteArticleReal only. |
169 | * @return self |
170 | */ |
171 | public function keepLegacyHookErrorsSeparate(): self { |
172 | $this->mergeLegacyHookErrors = false; |
173 | return $this; |
174 | } |
175 | |
176 | /** |
177 | * If true, suppress all revisions and log the deletion in the suppression log instead of |
178 | * the deletion log. |
179 | * |
180 | * @param bool $suppress |
181 | * @return self For chaining |
182 | */ |
183 | public function setSuppress( bool $suppress ): self { |
184 | $this->suppress = $suppress; |
185 | return $this; |
186 | } |
187 | |
188 | /** |
189 | * Change tags to apply to the deletion action |
190 | * |
191 | * @param string[] $tags |
192 | * @return self For chaining |
193 | */ |
194 | public function setTags( array $tags ): self { |
195 | $this->tags = $tags; |
196 | return $this; |
197 | } |
198 | |
199 | /** |
200 | * Set a specific log subtype for the deletion log entry. |
201 | * |
202 | * @param string $logSubtype |
203 | * @return self For chaining |
204 | */ |
205 | public function setLogSubtype( string $logSubtype ): self { |
206 | $this->logSubtype = $logSubtype; |
207 | return $this; |
208 | } |
209 | |
210 | /** |
211 | * If false, allows deleting over time via the job queue |
212 | * |
213 | * @param bool $forceImmediate |
214 | * @return self For chaining |
215 | */ |
216 | public function forceImmediate( bool $forceImmediate ): self { |
217 | $this->forceImmediate = $forceImmediate; |
218 | return $this; |
219 | } |
220 | |
221 | /** |
222 | * Tests whether it's probably possible to delete the associated talk page. This checks the replica, |
223 | * so it may not see the latest master change, and is useful e.g. for building the UI. |
224 | */ |
225 | public function canProbablyDeleteAssociatedTalk(): StatusValue { |
226 | if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) { |
227 | return StatusValue::newFatal( 'delete-error-associated-alreadytalk' ); |
228 | } |
229 | // FIXME NamespaceInfo should work with PageIdentity |
230 | $talkPage = $this->wikiPageFactory->newFromLinkTarget( |
231 | $this->namespaceInfo->getTalkPage( $this->page->getTitle() ) |
232 | ); |
233 | if ( !$talkPage->exists() ) { |
234 | return StatusValue::newFatal( 'delete-error-associated-doesnotexist' ); |
235 | } |
236 | return StatusValue::newGood(); |
237 | } |
238 | |
239 | /** |
240 | * If set to true and the page has a talk page, delete that one too. Callers should call |
241 | * canProbablyDeleteAssociatedTalk first to make sure this is a valid operation. Note that the checks |
242 | * here are laxer than those in canProbablyDeleteAssociatedTalk. In particular, this doesn't check |
243 | * whether the page exists as that may be subject to race condition, and it's checked later on (in deleteInternal, |
244 | * using latest data) anyway. |
245 | * |
246 | * @param bool $delete |
247 | * @return self For chaining |
248 | * @throws BadMethodCallException If $delete is true and the given page is not a talk page. |
249 | */ |
250 | public function setDeleteAssociatedTalk( bool $delete ): self { |
251 | if ( !$delete ) { |
252 | $this->associatedTalk = null; |
253 | return $this; |
254 | } |
255 | |
256 | if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) { |
257 | throw new BadMethodCallException( "Cannot delete associated talk page of a talk page! ($this->page)" ); |
258 | } |
259 | // FIXME NamespaceInfo should work with PageIdentity |
260 | $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget( |
261 | $this->namespaceInfo->getTalkPage( $this->page->getTitle() ) |
262 | ); |
263 | return $this; |
264 | } |
265 | |
266 | /** |
267 | * @internal FIXME: Hack used when running the DeletePage unit test to disable some legacy code. |
268 | * @codeCoverageIgnore |
269 | * @param bool $test |
270 | */ |
271 | public function setIsDeletePageUnitTest( bool $test ): void { |
272 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
273 | throw new LogicException( __METHOD__ . ' can only be used in tests!' ); |
274 | } |
275 | $this->isDeletePageUnitTest = $test; |
276 | } |
277 | |
278 | /** |
279 | * Called before attempting a deletion, allows the result getters to be used |
280 | * @internal The only external caller allowed is DeletePageJob. |
281 | * @return self |
282 | */ |
283 | public function setDeletionAttempted(): self { |
284 | $this->attemptedDeletion = true; |
285 | $this->successfulDeletionsIDs = [ self::PAGE_BASE => null ]; |
286 | $this->wasScheduled = [ self::PAGE_BASE => null ]; |
287 | if ( $this->associatedTalk ) { |
288 | $this->successfulDeletionsIDs[self::PAGE_TALK] = null; |
289 | $this->wasScheduled[self::PAGE_TALK] = null; |
290 | } |
291 | return $this; |
292 | } |
293 | |
294 | /** |
295 | * Asserts that a deletion operation was attempted |
296 | * @throws BadMethodCallException |
297 | */ |
298 | private function assertDeletionAttempted(): void { |
299 | if ( !$this->attemptedDeletion ) { |
300 | throw new BadMethodCallException( 'No deletion was attempted' ); |
301 | } |
302 | } |
303 | |
304 | /** |
305 | * @return int[] Array of log IDs of successful deletions |
306 | * @throws BadMethodCallException If no deletions were attempted |
307 | */ |
308 | public function getSuccessfulDeletionsIDs(): array { |
309 | $this->assertDeletionAttempted(); |
310 | return $this->successfulDeletionsIDs; |
311 | } |
312 | |
313 | /** |
314 | * @return bool[] Whether the deletions were scheduled |
315 | * @throws BadMethodCallException If no deletions were attempted |
316 | */ |
317 | public function deletionsWereScheduled(): array { |
318 | $this->assertDeletionAttempted(); |
319 | return $this->wasScheduled; |
320 | } |
321 | |
322 | /** |
323 | * Same as deleteUnsafe, but checks permissions. |
324 | * |
325 | * @param string $reason |
326 | * @return StatusValue |
327 | */ |
328 | public function deleteIfAllowed( string $reason ): StatusValue { |
329 | $this->setDeletionAttempted(); |
330 | $status = $this->authorizeDeletion(); |
331 | if ( !$status->isGood() ) { |
332 | return $status; |
333 | } |
334 | |
335 | return $this->deleteUnsafe( $reason ); |
336 | } |
337 | |
338 | private function authorizeDeletion(): PermissionStatus { |
339 | $status = PermissionStatus::newEmpty(); |
340 | $this->deleter->authorizeWrite( 'delete', $this->page, $status ); |
341 | if ( $this->associatedTalk ) { |
342 | $this->deleter->authorizeWrite( 'delete', $this->associatedTalk, $status ); |
343 | } |
344 | if ( !$this->deleter->isAllowed( 'bigdelete' ) && $this->isBigDeletion() ) { |
345 | $status->fatal( |
346 | 'delete-toomanyrevisions', |
347 | Message::numParam( $this->options->get( MainConfigNames::DeleteRevisionsLimit ) ) |
348 | ); |
349 | } |
350 | if ( $this->tags ) { |
351 | $status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->deleter ) ); |
352 | } |
353 | return $status; |
354 | } |
355 | |
356 | private function isBigDeletion(): bool { |
357 | $revLimit = $this->options->get( MainConfigNames::DeleteRevisionsLimit ); |
358 | if ( !$revLimit ) { |
359 | return false; |
360 | } |
361 | |
362 | $dbr = $this->lbFactory->getReplicaDatabase(); |
363 | $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() ); |
364 | if ( $this->associatedTalk ) { |
365 | $revCount += $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() ); |
366 | } |
367 | |
368 | return $revCount > $revLimit; |
369 | } |
370 | |
371 | /** |
372 | * Determines if this deletion would be batched (executed over time by the job queue) |
373 | * or not (completed in the same request as the delete call). |
374 | * |
375 | * It is unlikely but possible that an edit from another request could push the page over the |
376 | * batching threshold after this function is called, but before the caller acts upon the |
377 | * return value. Callers must decide for themselves how to deal with this. $safetyMargin |
378 | * is provided as an unreliable but situationally useful help for some common cases. |
379 | * |
380 | * @param int $safetyMargin Added to the revision count when checking for batching |
381 | * @return bool True if deletion would be batched, false otherwise |
382 | */ |
383 | public function isBatchedDelete( int $safetyMargin = 0 ): bool { |
384 | $dbr = $this->lbFactory->getReplicaDatabase(); |
385 | $revCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->page->getId() ); |
386 | $revCount += $safetyMargin; |
387 | |
388 | if ( $revCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ) ) { |
389 | return true; |
390 | } elseif ( !$this->associatedTalk ) { |
391 | return false; |
392 | } |
393 | |
394 | $talkRevCount = $this->revisionStore->countRevisionsByPageId( $dbr, $this->associatedTalk->getId() ); |
395 | $talkRevCount += $safetyMargin; |
396 | |
397 | return $talkRevCount >= $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ); |
398 | } |
399 | |
400 | /** |
401 | * Back-end article deletion: deletes the article with database consistency, writes logs, purges caches. |
402 | * @note This method doesn't check user permissions. Use deleteIfAllowed for that. |
403 | * |
404 | * @param string $reason Delete reason for deletion log |
405 | * @return Status Status object: |
406 | * - If successful (or scheduled), a good Status |
407 | * - If a page couldn't be deleted because it wasn't found, a Status with a non-fatal 'cannotdelete' error. |
408 | * - A fatal Status otherwise. |
409 | */ |
410 | public function deleteUnsafe( string $reason ): Status { |
411 | $this->setDeletionAttempted(); |
412 | $origReason = $reason; |
413 | $hookStatus = $this->runPreDeleteHooks( $this->page, $reason ); |
414 | if ( !$hookStatus->isGood() ) { |
415 | return $hookStatus; |
416 | } |
417 | if ( $this->associatedTalk ) { |
418 | $talkReason = $this->contLangMsgTextFormatter->format( |
419 | MessageValue::new( 'delete-talk-summary-prefix' )->plaintextParams( $origReason ) |
420 | ); |
421 | $talkHookStatus = $this->runPreDeleteHooks( $this->associatedTalk, $talkReason ); |
422 | if ( !$talkHookStatus->isGood() ) { |
423 | return $talkHookStatus; |
424 | } |
425 | } |
426 | |
427 | $status = $this->deleteInternal( $this->page, self::PAGE_BASE, $reason ); |
428 | if ( !$this->associatedTalk || !$status->isGood() ) { |
429 | return $status; |
430 | } |
431 | // NOTE: If the page deletion above failed because the page is no longer there (e.g. race condition) we'll |
432 | // still try to delete the talk page, since it was the user's intention anyway. |
433 | // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable talkReason is set when used |
434 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable talkReason is set when used |
435 | $status->merge( $this->deleteInternal( $this->associatedTalk, self::PAGE_TALK, $talkReason ) ); |
436 | return $status; |
437 | } |
438 | |
439 | /** |
440 | * @param WikiPage $page |
441 | * @param string &$reason |
442 | * @return Status |
443 | */ |
444 | private function runPreDeleteHooks( WikiPage $page, string &$reason ): Status { |
445 | $status = Status::newGood(); |
446 | |
447 | $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter ); |
448 | if ( !$this->hookRunner->onArticleDelete( |
449 | $page, $legacyDeleter, $reason, $this->legacyHookErrors, $status, $this->suppress ) |
450 | ) { |
451 | if ( $this->mergeLegacyHookErrors && $this->legacyHookErrors !== '' ) { |
452 | if ( is_string( $this->legacyHookErrors ) ) { |
453 | $this->legacyHookErrors = [ $this->legacyHookErrors ]; |
454 | } |
455 | foreach ( $this->legacyHookErrors as $legacyError ) { |
456 | $status->fatal( new RawMessage( $legacyError ) ); |
457 | } |
458 | } |
459 | if ( $status->isOK() ) { |
460 | // Hook aborted but didn't set a fatal status |
461 | $status->fatal( 'delete-hook-aborted' ); |
462 | } |
463 | return $status; |
464 | } |
465 | |
466 | // Use a new Status in case a hook handler put something here without aborting. |
467 | $status = Status::newGood(); |
468 | $hookRes = $this->hookRunner->onPageDelete( $page, $this->deleter, $reason, $status, $this->suppress ); |
469 | if ( !$hookRes && !$status->isGood() ) { |
470 | // Note: as per the PageDeleteHook documentation, `return false` is ignored if $status is good. |
471 | return $status; |
472 | } |
473 | return Status::newGood(); |
474 | } |
475 | |
476 | /** |
477 | * @internal The only external caller allowed is DeletePageJob. |
478 | * Back-end article deletion |
479 | * |
480 | * Only invokes batching via the job queue if necessary per DeleteRevisionsBatchSize. |
481 | * Deletions can often be completed inline without involving the job queue. |
482 | * |
483 | * Potentially called many times per deletion operation for pages with many revisions. |
484 | * @param WikiPage $page |
485 | * @param string $pageRole |
486 | * @param string $reason |
487 | * @param string|null $webRequestId |
488 | * @param mixed|null $ticket Result of ILBFactory::getEmptyTransactionTicket() or null |
489 | * @return Status |
490 | */ |
491 | public function deleteInternal( |
492 | WikiPage $page, |
493 | string $pageRole, |
494 | string $reason, |
495 | ?string $webRequestId = null, |
496 | $ticket = null |
497 | ): Status { |
498 | $title = $page->getTitle(); |
499 | $status = Status::newGood(); |
500 | |
501 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
502 | $dbw->startAtomic( __METHOD__ ); |
503 | |
504 | $page->loadPageData( IDBAccessObject::READ_LATEST ); |
505 | $id = $page->getId(); |
506 | // T98706: lock the page from various other updates but avoid using |
507 | // IDBAccessObject::READ_LOCKING as that will carry over the FOR UPDATE to |
508 | // the revisions queries (which also JOIN on user). Only lock the page |
509 | // row and CAS check on page_latest to see if the trx snapshot matches. |
510 | $lockedLatest = $page->lockAndGetLatest(); |
511 | if ( $id === 0 || $page->getLatest() !== $lockedLatest ) { |
512 | $dbw->endAtomic( __METHOD__ ); |
513 | // Page not there or trx snapshot is stale |
514 | $status->error( 'cannotdelete', wfEscapeWikiText( $title->getPrefixedText() ) ); |
515 | return $status; |
516 | } |
517 | |
518 | // At this point we are now committed to returning an OK |
519 | // status unless some DB query error or other exception comes up. |
520 | // This way callers don't have to call rollback() if $status is bad |
521 | // unless they actually try to catch exceptions (which is rare). |
522 | |
523 | // we need to remember the old content so we can use it to generate all deletion updates. |
524 | $revisionRecord = $page->getRevisionRecord(); |
525 | if ( !$revisionRecord ) { |
526 | throw new LogicException( "No revisions for $page?" ); |
527 | } |
528 | try { |
529 | $content = $page->getContent( RevisionRecord::RAW ); |
530 | } catch ( TimeoutException $e ) { |
531 | throw $e; |
532 | } catch ( Exception $ex ) { |
533 | wfLogWarning( __METHOD__ . ': failed to load content during deletion! ' |
534 | . $ex->getMessage() ); |
535 | |
536 | $content = null; |
537 | } |
538 | |
539 | // Archive revisions. In immediate mode, archive all revisions. Otherwise, archive |
540 | // one batch of revisions and defer archival of any others to the job queue. |
541 | while ( true ) { |
542 | $done = $this->archiveRevisions( $page, $id ); |
543 | if ( $done || !$this->forceImmediate ) { |
544 | break; |
545 | } |
546 | $dbw->endAtomic( __METHOD__ ); |
547 | $this->lbFactory->commitAndWaitForReplication( __METHOD__, $ticket ); |
548 | $dbw->startAtomic( __METHOD__ ); |
549 | } |
550 | |
551 | if ( !$done ) { |
552 | $dbw->endAtomic( __METHOD__ ); |
553 | |
554 | $jobParams = [ |
555 | 'namespace' => $title->getNamespace(), |
556 | 'title' => $title->getDBkey(), |
557 | 'wikiPageId' => $id, |
558 | 'requestId' => $webRequestId ?? $this->webRequestID, |
559 | 'reason' => $reason, |
560 | 'suppress' => $this->suppress, |
561 | 'userId' => $this->deleter->getUser()->getId(), |
562 | 'tags' => json_encode( $this->tags ), |
563 | 'logsubtype' => $this->logSubtype, |
564 | 'pageRole' => $pageRole, |
565 | ]; |
566 | |
567 | $job = new DeletePageJob( $jobParams ); |
568 | $this->jobQueueGroup->push( $job ); |
569 | $this->wasScheduled[$pageRole] = true; |
570 | return $status; |
571 | } |
572 | $this->wasScheduled[$pageRole] = false; |
573 | |
574 | // Get archivedRevisionCount by db query, because there's no better alternative. |
575 | // Jobs cannot pass a count of archived revisions to the next job, because additional |
576 | // deletion operations can be started while the first is running. Jobs from each |
577 | // gracefully interleave, but would not know about each other's count. Deduplication |
578 | // in the job queue to avoid simultaneous deletion operations would add overhead. |
579 | // Number of archived revisions cannot be known beforehand, because edits can be made |
580 | // while deletion operations are being processed, changing the number of archivals. |
581 | $archivedRevisionCount = $dbw->newSelectQueryBuilder() |
582 | ->select( '*' ) |
583 | ->from( 'archive' ) |
584 | ->where( [ |
585 | 'ar_namespace' => $title->getNamespace(), |
586 | 'ar_title' => $title->getDBkey(), |
587 | 'ar_page_id' => $id |
588 | ] ) |
589 | ->caller( __METHOD__ )->fetchRowCount(); |
590 | |
591 | // Look up the redirect target before deleting the page to avoid inconsistent state (T348881). |
592 | // The cloning business below is specifically to allow hook handlers to check the redirect |
593 | // status before the deletion (see I715046dc8157047aff4d5bd03ea6b5a47aee58bb). |
594 | $page->getRedirectTarget(); |
595 | // Clone the title and wikiPage, so we have the information we need when |
596 | // we log and run the ArticleDeleteComplete hook. |
597 | $logTitle = clone $title; |
598 | $wikiPageBeforeDelete = clone $page; |
599 | |
600 | // Now that it's safely backed up, delete it |
601 | $dbw->newDeleteQueryBuilder() |
602 | ->deleteFrom( 'page' ) |
603 | ->where( [ 'page_id' => $id ] ) |
604 | ->caller( __METHOD__ )->execute(); |
605 | |
606 | // Log the deletion, if the page was suppressed, put it in the suppression log instead |
607 | $logtype = $this->suppress ? 'suppress' : 'delete'; |
608 | |
609 | $logEntry = new ManualLogEntry( $logtype, $this->logSubtype ); |
610 | $logEntry->setPerformer( $this->deleter->getUser() ); |
611 | $logEntry->setTarget( $logTitle ); |
612 | $logEntry->setComment( $reason ); |
613 | $logEntry->addTags( $this->tags ); |
614 | if ( !$this->isDeletePageUnitTest ) { |
615 | // TODO: Remove conditional once ManualLogEntry is servicified (T253717) |
616 | $logid = $logEntry->insert(); |
617 | |
618 | $dbw->onTransactionPreCommitOrIdle( |
619 | static function () use ( $logEntry, $logid ) { |
620 | // T58776: avoid deadlocks (especially from FileDeleteForm) |
621 | $logEntry->publish( $logid ); |
622 | }, |
623 | __METHOD__ |
624 | ); |
625 | } else { |
626 | $logid = 42; |
627 | } |
628 | |
629 | $dbw->endAtomic( __METHOD__ ); |
630 | |
631 | $this->doDeleteUpdates( $page, $revisionRecord ); |
632 | |
633 | // Reset the page object and the Title object |
634 | $page->loadFromRow( false, IDBAccessObject::READ_LATEST ); |
635 | |
636 | // Make sure there are no cached title instances that refer to the same page. |
637 | Title::clearCaches(); |
638 | |
639 | $legacyDeleter = $this->userFactory->newFromAuthority( $this->deleter ); |
640 | $this->hookRunner->onArticleDeleteComplete( |
641 | $wikiPageBeforeDelete, |
642 | $legacyDeleter, |
643 | $reason, |
644 | $id, |
645 | $content, |
646 | $logEntry, |
647 | $archivedRevisionCount |
648 | ); |
649 | $this->hookRunner->onPageDeleteComplete( |
650 | $wikiPageBeforeDelete, |
651 | $this->deleter, |
652 | $reason, |
653 | $id, |
654 | $revisionRecord, |
655 | $logEntry, |
656 | $archivedRevisionCount |
657 | ); |
658 | $this->successfulDeletionsIDs[$pageRole] = $logid; |
659 | |
660 | // Clear any cached redirect status for the now-deleted page. |
661 | $this->redirectStore->clearCache( $page ); |
662 | |
663 | // Show log excerpt on 404 pages rather than just a link |
664 | $key = $this->recentDeletesCache->makeKey( 'page-recent-delete', md5( $logTitle->getPrefixedText() ) ); |
665 | $this->recentDeletesCache->set( $key, 1, BagOStuff::TTL_DAY ); |
666 | |
667 | return $status; |
668 | } |
669 | |
670 | /** |
671 | * Archives revisions as part of page deletion. |
672 | * |
673 | * @param WikiPage $page |
674 | * @param int $id |
675 | * @return bool |
676 | */ |
677 | private function archiveRevisions( WikiPage $page, int $id ): bool { |
678 | // Given the lock above, we can be confident in the title and page ID values |
679 | $namespace = $page->getTitle()->getNamespace(); |
680 | $dbKey = $page->getTitle()->getDBkey(); |
681 | |
682 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
683 | |
684 | $revQuery = $this->revisionStore->getQueryInfo(); |
685 | $bitfield = false; |
686 | |
687 | // Bitfields to further suppress the content |
688 | if ( $this->suppress ) { |
689 | $bitfield = RevisionRecord::SUPPRESSED_ALL; |
690 | $revQuery['fields'] = array_diff( $revQuery['fields'], [ 'rev_deleted' ] ); |
691 | } |
692 | |
693 | // For now, shunt the revision data into the archive table. |
694 | // Text is *not* removed from the text table; bulk storage |
695 | // is left intact to avoid breaking block-compression or |
696 | // immutable storage schemes. |
697 | // In the future, we may keep revisions and mark them with |
698 | // the rev_deleted field, which is reserved for this purpose. |
699 | |
700 | // Lock rows in `revision` and its temp tables, but not any others. |
701 | // Note array_intersect() preserves keys from the first arg, and we're |
702 | // assuming $revQuery has `revision` primary and isn't using subtables |
703 | // for anything we care about. |
704 | $lockQuery = $revQuery; |
705 | $lockQuery['tables'] = array_intersect( |
706 | $revQuery['tables'], |
707 | [ 'revision', 'revision_comment_temp' ] |
708 | ); |
709 | unset( $lockQuery['fields'] ); |
710 | $dbw->newSelectQueryBuilder() |
711 | ->queryInfo( $lockQuery ) |
712 | ->where( [ 'rev_page' => $id ] ) |
713 | ->forUpdate() |
714 | ->caller( __METHOD__ ) |
715 | ->acquireRowLocks(); |
716 | |
717 | $deleteBatchSize = $this->options->get( MainConfigNames::DeleteRevisionsBatchSize ); |
718 | // Get as many of the page revisions as we are allowed to. The +1 lets us recognize the |
719 | // unusual case where there were exactly $deleteBatchSize revisions remaining. |
720 | $res = $dbw->newSelectQueryBuilder() |
721 | ->queryInfo( $revQuery ) |
722 | ->where( [ 'rev_page' => $id ] ) |
723 | ->orderBy( [ 'rev_timestamp', 'rev_id' ] ) |
724 | ->limit( $deleteBatchSize + 1 ) |
725 | ->caller( __METHOD__ ) |
726 | ->fetchResultSet(); |
727 | |
728 | // Build their equivalent archive rows |
729 | $rowsInsert = []; |
730 | $revids = []; |
731 | |
732 | /** @var int[] $ipRevIds Revision IDs of edits that were made by IPs */ |
733 | $ipRevIds = []; |
734 | |
735 | $done = true; |
736 | foreach ( $res as $row ) { |
737 | if ( count( $revids ) >= $deleteBatchSize ) { |
738 | $done = false; |
739 | break; |
740 | } |
741 | |
742 | $comment = $this->commentStore->getComment( 'rev_comment', $row ); |
743 | $rowInsert = [ |
744 | 'ar_namespace' => $namespace, |
745 | 'ar_title' => $dbKey, |
746 | 'ar_actor' => $row->rev_actor, |
747 | 'ar_timestamp' => $row->rev_timestamp, |
748 | 'ar_minor_edit' => $row->rev_minor_edit, |
749 | 'ar_rev_id' => $row->rev_id, |
750 | 'ar_parent_id' => $row->rev_parent_id, |
751 | 'ar_len' => $row->rev_len, |
752 | 'ar_page_id' => $id, |
753 | 'ar_deleted' => $this->suppress ? $bitfield : $row->rev_deleted, |
754 | 'ar_sha1' => $row->rev_sha1, |
755 | ] + $this->commentStore->insert( $dbw, 'ar_comment', $comment ); |
756 | |
757 | $rowsInsert[] = $rowInsert; |
758 | $revids[] = $row->rev_id; |
759 | |
760 | // Keep track of IP edits, so that the corresponding rows can |
761 | // be deleted in the ip_changes table. |
762 | if ( (int)$row->rev_user === 0 && IPUtils::isValid( $row->rev_user_text ) ) { |
763 | $ipRevIds[] = $row->rev_id; |
764 | } |
765 | } |
766 | |
767 | if ( count( $revids ) > 0 ) { |
768 | // Copy them into the archive table |
769 | $dbw->newInsertQueryBuilder() |
770 | ->insertInto( 'archive' ) |
771 | ->rows( $rowsInsert ) |
772 | ->caller( __METHOD__ )->execute(); |
773 | |
774 | $dbw->newDeleteQueryBuilder() |
775 | ->deleteFrom( 'revision' ) |
776 | ->where( [ 'rev_id' => $revids ] ) |
777 | ->caller( __METHOD__ )->execute(); |
778 | // Also delete records from ip_changes as applicable. |
779 | if ( count( $ipRevIds ) > 0 ) { |
780 | $dbw->newDeleteQueryBuilder() |
781 | ->deleteFrom( 'ip_changes' ) |
782 | ->where( [ 'ipc_rev_id' => $ipRevIds ] ) |
783 | ->caller( __METHOD__ )->execute(); |
784 | } |
785 | } |
786 | |
787 | return $done; |
788 | } |
789 | |
790 | /** |
791 | * Do some database updates after deletion |
792 | * |
793 | * @param WikiPage $page |
794 | * @param RevisionRecord $revRecord The current page revision at the time of |
795 | * deletion, used when determining the required updates. This may be needed because |
796 | * $page->getRevisionRecord() may already return null when the page proper was deleted. |
797 | */ |
798 | private function doDeleteUpdates( WikiPage $page, RevisionRecord $revRecord ): void { |
799 | try { |
800 | $countable = $page->isCountable(); |
801 | } catch ( TimeoutException $e ) { |
802 | throw $e; |
803 | } catch ( Exception $ex ) { |
804 | // fallback for deleting broken pages for which we cannot load the content for |
805 | // some reason. Note that doDeleteArticleReal() already logged this problem. |
806 | $countable = false; |
807 | } |
808 | |
809 | // Update site status |
810 | DeferredUpdates::addUpdate( SiteStatsUpdate::factory( |
811 | [ 'edits' => 1, 'articles' => $countable ? -1 : 0, 'pages' => -1 ] |
812 | ) ); |
813 | |
814 | // Delete pagelinks, update secondary indexes, etc |
815 | $updates = $this->getDeletionUpdates( $page, $revRecord ); |
816 | foreach ( $updates as $update ) { |
817 | DeferredUpdates::addUpdate( $update ); |
818 | } |
819 | |
820 | // Reparse any pages transcluding this page |
821 | LinksUpdate::queueRecursiveJobsForTable( |
822 | $page->getTitle(), |
823 | 'templatelinks', |
824 | 'delete-page', |
825 | $this->deleter->getUser()->getName(), |
826 | $this->backlinkCacheFactory->getBacklinkCache( $page->getTitle() ) |
827 | ); |
828 | // Reparse any pages including this image |
829 | if ( $page->getTitle()->getNamespace() === NS_FILE ) { |
830 | LinksUpdate::queueRecursiveJobsForTable( |
831 | $page->getTitle(), |
832 | 'imagelinks', |
833 | 'delete-page', |
834 | $this->deleter->getUser()->getName(), |
835 | $this->backlinkCacheFactory->getBacklinkCache( $page->getTitle() ) |
836 | ); |
837 | } |
838 | |
839 | if ( !$this->isDeletePageUnitTest ) { |
840 | // TODO Remove conditional once WikiPage::onArticleDelete is moved to a proper service |
841 | // Clear caches |
842 | WikiPage::onArticleDelete( $page->getTitle() ); |
843 | } |
844 | |
845 | WikiModule::invalidateModuleCache( |
846 | $page->getTitle(), |
847 | $revRecord, |
848 | null, |
849 | $this->localWikiID |
850 | ); |
851 | |
852 | // Reset the page object and the Title object |
853 | $page->loadFromRow( false, IDBAccessObject::READ_LATEST ); |
854 | |
855 | // Search engine |
856 | DeferredUpdates::addUpdate( new SearchUpdate( $page->getId(), $page->getTitle() ) ); |
857 | } |
858 | |
859 | /** |
860 | * Returns a list of updates to be performed when the page is deleted. The |
861 | * updates should remove any information about this page from secondary data |
862 | * stores such as links tables. |
863 | * |
864 | * @param WikiPage $page |
865 | * @param RevisionRecord $rev The revision being deleted. |
866 | * @return DeferrableUpdate[] |
867 | */ |
868 | private function getDeletionUpdates( WikiPage $page, RevisionRecord $rev ): array { |
869 | if ( $this->isDeletePageUnitTest ) { |
870 | // Hack: LinksDeletionUpdate reads from the global state in the constructor |
871 | return []; |
872 | } |
873 | $slotContent = array_map( static function ( SlotRecord $slot ) { |
874 | return $slot->getContent(); |
875 | }, $rev->getSlots()->getSlots() ); |
876 | |
877 | $allUpdates = [ new LinksDeletionUpdate( $page ) ]; |
878 | |
879 | // NOTE: once Content::getDeletionUpdates() is removed, we only need the content |
880 | // model here, not the content object! |
881 | // TODO: consolidate with similar logic in DerivedPageDataUpdater::getSecondaryDataUpdates() |
882 | /** @var ?Content $content */ |
883 | $content = null; // in case $slotContent is zero-length |
884 | foreach ( $slotContent as $role => $content ) { |
885 | $handler = $content->getContentHandler(); |
886 | |
887 | $updates = $handler->getDeletionUpdates( |
888 | $page->getTitle(), |
889 | $role |
890 | ); |
891 | |
892 | $allUpdates = array_merge( $allUpdates, $updates ); |
893 | } |
894 | |
895 | $this->hookRunner->onPageDeletionDataUpdates( |
896 | $page->getTitle(), $rev, $allUpdates ); |
897 | |
898 | // TODO: hard deprecate old hook in 1.33 |
899 | $this->hookRunner->onWikiPageDeletionUpdates( $page, $content, $allUpdates ); |
900 | return $allUpdates; |
901 | } |
902 | } |