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