Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
79.67% |
239 / 300 |
|
20.00% |
3 / 15 |
CRAP | |
0.00% |
0 / 1 |
| UndeletePage | |
79.67% |
239 / 300 |
|
20.00% |
3 / 15 |
106.87 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
| setUnsuppress | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| setTags | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| setUndeleteOnlyTimestamps | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| setUndeleteOnlyFileVersions | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| canProbablyUndeleteAssociatedTalk | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| setUndeleteAssociatedTalk | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
2.06 | |||
| undeleteIfAllowed | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| authorizeUndeletion | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
| undeleteUnsafe | |
82.28% |
65 / 79 |
|
0.00% |
0 / 1 |
24.69 | |||
| runPreUndeleteHook | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
5.01 | |||
| addLogEntry | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
| undeleteRevisions | |
83.21% |
114 / 137 |
|
0.00% |
0 / 1 |
25.50 | |||
| getFileStatus | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| getRevisionStatus | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\Page; |
| 8 | |
| 9 | use MediaWiki\ChangeTags\ChangeTags; |
| 10 | use MediaWiki\Content\IContentHandlerFactory; |
| 11 | use MediaWiki\Content\ValidationParams; |
| 12 | use MediaWiki\Exception\ReadOnlyError; |
| 13 | use MediaWiki\FileRepo\File\LocalFile; |
| 14 | use MediaWiki\FileRepo\RepoGroup; |
| 15 | use MediaWiki\HookContainer\HookContainer; |
| 16 | use MediaWiki\HookContainer\HookRunner; |
| 17 | use MediaWiki\JobQueue\JobQueueGroup; |
| 18 | use MediaWiki\JobQueue\Jobs\HTMLCacheUpdateJob; |
| 19 | use MediaWiki\Logging\ManualLogEntry; |
| 20 | use MediaWiki\Page\Event\PageLatestRevisionChangedEvent; |
| 21 | use MediaWiki\Permissions\Authority; |
| 22 | use MediaWiki\Permissions\PermissionStatus; |
| 23 | use MediaWiki\Revision\ArchivedRevisionLookup; |
| 24 | use MediaWiki\Revision\RevisionRecord; |
| 25 | use MediaWiki\Revision\RevisionStore; |
| 26 | use MediaWiki\Status\Status; |
| 27 | use MediaWiki\Storage\PageUpdater; |
| 28 | use MediaWiki\Storage\PageUpdaterFactory; |
| 29 | use MediaWiki\Title\NamespaceInfo; |
| 30 | use Psr\Log\LoggerInterface; |
| 31 | use StatusValue; |
| 32 | use Wikimedia\Assert\Assert; |
| 33 | use Wikimedia\Message\ITextFormatter; |
| 34 | use Wikimedia\Message\MessageValue; |
| 35 | use Wikimedia\Rdbms\IConnectionProvider; |
| 36 | use Wikimedia\Rdbms\IDatabase; |
| 37 | use Wikimedia\Rdbms\IDBAccessObject; |
| 38 | use Wikimedia\Rdbms\ReadOnlyMode; |
| 39 | use Wikimedia\Timestamp\TimestampFormat as TS; |
| 40 | |
| 41 | /** |
| 42 | * Backend logic for performing a page undelete action. |
| 43 | * |
| 44 | * @since 1.38 |
| 45 | */ |
| 46 | class UndeletePage { |
| 47 | |
| 48 | // Constants used as keys in the StatusValue returned by undelete() |
| 49 | public const FILES_RESTORED = 'files'; |
| 50 | public const REVISIONS_RESTORED = 'revs'; |
| 51 | |
| 52 | /** @var Status|null */ |
| 53 | private $fileStatus; |
| 54 | /** @var StatusValue<array{int, bool, ?RevisionRecord, array<int,true>}>|null */ |
| 55 | private $revisionStatus; |
| 56 | /** @var string[] */ |
| 57 | private $timestamps = []; |
| 58 | /** @var int[] */ |
| 59 | private $fileVersions = []; |
| 60 | /** @var bool */ |
| 61 | private $unsuppress = false; |
| 62 | /** @var string[] */ |
| 63 | private $tags = []; |
| 64 | /** @var WikiPage|null If not null, it means that we have to undelete it. */ |
| 65 | private $associatedTalk; |
| 66 | |
| 67 | private HookRunner $hookRunner; |
| 68 | private JobQueueGroup $jobQueueGroup; |
| 69 | private IConnectionProvider $dbProvider; |
| 70 | private ReadOnlyMode $readOnlyMode; |
| 71 | private RepoGroup $repoGroup; |
| 72 | private LoggerInterface $logger; |
| 73 | private RevisionStore $revisionStore; |
| 74 | private WikiPageFactory $wikiPageFactory; |
| 75 | private PageUpdaterFactory $pageUpdaterFactory; |
| 76 | private IContentHandlerFactory $contentHandlerFactory; |
| 77 | private ArchivedRevisionLookup $archivedRevisionLookup; |
| 78 | private NamespaceInfo $namespaceInfo; |
| 79 | private ITextFormatter $contLangMsgTextFormatter; |
| 80 | private ProperPageIdentity $page; |
| 81 | private Authority $performer; |
| 82 | |
| 83 | /** |
| 84 | * @internal Create via the UndeletePageFactory service. |
| 85 | */ |
| 86 | public function __construct( |
| 87 | HookContainer $hookContainer, |
| 88 | JobQueueGroup $jobQueueGroup, |
| 89 | IConnectionProvider $dbProvider, |
| 90 | ReadOnlyMode $readOnlyMode, |
| 91 | RepoGroup $repoGroup, |
| 92 | LoggerInterface $logger, |
| 93 | RevisionStore $revisionStore, |
| 94 | WikiPageFactory $wikiPageFactory, |
| 95 | PageUpdaterFactory $pageUpdaterFactory, |
| 96 | IContentHandlerFactory $contentHandlerFactory, |
| 97 | ArchivedRevisionLookup $archivedRevisionLookup, |
| 98 | NamespaceInfo $namespaceInfo, |
| 99 | ITextFormatter $contLangMsgTextFormatter, |
| 100 | ProperPageIdentity $page, |
| 101 | Authority $performer |
| 102 | ) { |
| 103 | $this->hookRunner = new HookRunner( $hookContainer ); |
| 104 | $this->jobQueueGroup = $jobQueueGroup; |
| 105 | $this->dbProvider = $dbProvider; |
| 106 | $this->readOnlyMode = $readOnlyMode; |
| 107 | $this->repoGroup = $repoGroup; |
| 108 | $this->logger = $logger; |
| 109 | $this->revisionStore = $revisionStore; |
| 110 | $this->wikiPageFactory = $wikiPageFactory; |
| 111 | $this->pageUpdaterFactory = $pageUpdaterFactory; |
| 112 | $this->contentHandlerFactory = $contentHandlerFactory; |
| 113 | $this->archivedRevisionLookup = $archivedRevisionLookup; |
| 114 | $this->namespaceInfo = $namespaceInfo; |
| 115 | $this->contLangMsgTextFormatter = $contLangMsgTextFormatter; |
| 116 | |
| 117 | $this->page = $page; |
| 118 | $this->performer = $performer; |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Whether to remove all ar_deleted/fa_deleted restrictions of selected revs. |
| 123 | * |
| 124 | * @param bool $unsuppress |
| 125 | * @return self For chaining |
| 126 | */ |
| 127 | public function setUnsuppress( bool $unsuppress ): self { |
| 128 | $this->unsuppress = $unsuppress; |
| 129 | return $this; |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * Change tags to add to log entry (the user should be able to add the specified tags before this is called) |
| 134 | * |
| 135 | * @param string[] $tags |
| 136 | * @return self For chaining |
| 137 | */ |
| 138 | public function setTags( array $tags ): self { |
| 139 | $this->tags = $tags; |
| 140 | return $this; |
| 141 | } |
| 142 | |
| 143 | /** |
| 144 | * If you don't want to undelete all revisions, pass an array of timestamps to undelete. |
| 145 | * |
| 146 | * @param string[] $timestamps |
| 147 | * @return self For chaining |
| 148 | */ |
| 149 | public function setUndeleteOnlyTimestamps( array $timestamps ): self { |
| 150 | $this->timestamps = $timestamps; |
| 151 | return $this; |
| 152 | } |
| 153 | |
| 154 | /** |
| 155 | * If you don't want to undelete all file versions, pass an array of versions to undelete. |
| 156 | * |
| 157 | * @param int[] $fileVersions |
| 158 | * @return self For chaining |
| 159 | */ |
| 160 | public function setUndeleteOnlyFileVersions( array $fileVersions ): self { |
| 161 | $this->fileVersions = $fileVersions; |
| 162 | return $this; |
| 163 | } |
| 164 | |
| 165 | /** |
| 166 | * Tests whether it's probably possible to undelete the associated talk page. This checks the replica, |
| 167 | * so it may not see the latest master change, and is useful e.g. for building the UI. |
| 168 | * @return StatusValue<never> |
| 169 | */ |
| 170 | public function canProbablyUndeleteAssociatedTalk(): StatusValue { |
| 171 | if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) { |
| 172 | return StatusValue::newFatal( 'undelete-error-associated-alreadytalk' ); |
| 173 | } |
| 174 | // @todo FIXME: NamespaceInfo should work with PageIdentity |
| 175 | $thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page ); |
| 176 | $talkPage = $this->wikiPageFactory->newFromLinkTarget( |
| 177 | $this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() ) |
| 178 | ); |
| 179 | // NOTE: The talk may exist, but have some deleted revision. That's fine. |
| 180 | if ( !$this->archivedRevisionLookup->hasArchivedRevisions( $talkPage ) ) { |
| 181 | return StatusValue::newFatal( 'undelete-error-associated-notdeleted' ); |
| 182 | } |
| 183 | return StatusValue::newGood(); |
| 184 | } |
| 185 | |
| 186 | /** |
| 187 | * Whether to delete the associated talk page with the subject page |
| 188 | * |
| 189 | * @param bool $undelete |
| 190 | * @return self For chaining |
| 191 | */ |
| 192 | public function setUndeleteAssociatedTalk( bool $undelete ): self { |
| 193 | if ( !$undelete ) { |
| 194 | $this->associatedTalk = null; |
| 195 | return $this; |
| 196 | } |
| 197 | |
| 198 | // @todo FIXME: NamespaceInfo should accept PageIdentity |
| 199 | $thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page ); |
| 200 | $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget( |
| 201 | $this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() ) |
| 202 | ); |
| 203 | return $this; |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Same as undeleteUnsafe, but checks permissions. |
| 208 | * |
| 209 | * @param string $comment |
| 210 | * @return StatusValue<array{files:int, revs:int}> |
| 211 | */ |
| 212 | public function undeleteIfAllowed( string $comment ): StatusValue { |
| 213 | $status = $this->authorizeUndeletion(); |
| 214 | if ( !$status->isGood() ) { |
| 215 | return $status; |
| 216 | } |
| 217 | |
| 218 | return $this->undeleteUnsafe( $comment ); |
| 219 | } |
| 220 | |
| 221 | private function authorizeUndeletion(): PermissionStatus { |
| 222 | $status = PermissionStatus::newEmpty(); |
| 223 | $this->performer->authorizeWrite( 'undelete', $this->page, $status ); |
| 224 | if ( $this->associatedTalk ) { |
| 225 | $this->performer->authorizeWrite( 'undelete', $this->associatedTalk, $status ); |
| 226 | } |
| 227 | if ( $this->tags ) { |
| 228 | $status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->performer ) ); |
| 229 | } |
| 230 | return $status; |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * Restore the given (or all) text and file revisions for the page. |
| 235 | * Once restored, the items will be removed from the archive tables. |
| 236 | * The deletion log will be updated with an undeletion notice. |
| 237 | * |
| 238 | * This also sets Status objects, $this->fileStatus and $this->revisionStatus |
| 239 | * (depending what operations are attempted). |
| 240 | * |
| 241 | * @note This method doesn't check user permissions. Use undeleteIfAllowed for that. |
| 242 | * |
| 243 | * @param string $comment |
| 244 | * @return StatusValue<array{revs:int, files:int}> Good Status with the following value on success: |
| 245 | * [ |
| 246 | * self::REVISIONS_RESTORED => number of text revisions restored, |
| 247 | * self::FILES_RESTORED => number of file revisions restored |
| 248 | * ] |
| 249 | * Fatal Status on failure. |
| 250 | */ |
| 251 | public function undeleteUnsafe( string $comment ): StatusValue { |
| 252 | $hookStatus = $this->runPreUndeleteHook( $comment ); |
| 253 | if ( !$hookStatus->isGood() ) { |
| 254 | return $hookStatus; |
| 255 | } |
| 256 | // If both the set of text revisions and file revisions are empty, |
| 257 | // restore everything. Otherwise, just restore the requested items. |
| 258 | $restoreAll = $this->timestamps === [] && $this->fileVersions === []; |
| 259 | |
| 260 | $restoreText = $restoreAll || $this->timestamps !== []; |
| 261 | $restoreFiles = $restoreAll || $this->fileVersions !== []; |
| 262 | |
| 263 | $resStatus = StatusValue::newGood(); |
| 264 | $filesRestored = 0; |
| 265 | if ( $restoreFiles && $this->page->getNamespace() === NS_FILE ) { |
| 266 | /** @var LocalFile $img */ |
| 267 | $img = $this->repoGroup->getLocalRepo()->newFile( $this->page ); |
| 268 | $img->load( IDBAccessObject::READ_LATEST ); |
| 269 | $this->fileStatus = $img->restore( $this->fileVersions, $this->unsuppress ); |
| 270 | if ( !$this->fileStatus->isOK() ) { |
| 271 | return $this->fileStatus; |
| 272 | } |
| 273 | $filesRestored = $this->fileStatus->successCount; |
| 274 | $resStatus->merge( $this->fileStatus ); |
| 275 | } |
| 276 | |
| 277 | $textRestored = 0; |
| 278 | $pageCreated = false; |
| 279 | $restoredRevision = null; |
| 280 | $restoredPageIds = []; |
| 281 | if ( $restoreText ) { |
| 282 | // If we already restored files, then don't bail if there isn't any text to restore |
| 283 | $acceptNoRevisions = $filesRestored > 0; |
| 284 | $this->revisionStatus = $this->undeleteRevisions( |
| 285 | $this->page, $this->timestamps, |
| 286 | $comment, $acceptNoRevisions |
| 287 | ); |
| 288 | if ( !$this->revisionStatus->isOK() ) { |
| 289 | // Type mismatch for status's value doesn't matter for non-OK status |
| 290 | // @phan-suppress-next-line PhanTypeMismatchReturn |
| 291 | return $this->revisionStatus; |
| 292 | } |
| 293 | |
| 294 | [ $textRestored, $pageCreated, $restoredRevision, $restoredPageIds ] = $this->revisionStatus->getValue(); |
| 295 | $resStatus->merge( $this->revisionStatus ); |
| 296 | } |
| 297 | |
| 298 | $talkRestored = 0; |
| 299 | $talkCreated = false; |
| 300 | $restoredTalkRevision = null; |
| 301 | $restoredTalkPageIds = []; |
| 302 | if ( $this->associatedTalk ) { |
| 303 | $talkStatus = $this->canProbablyUndeleteAssociatedTalk(); |
| 304 | // if undeletion of the page fails we don't want to undelete the talk page |
| 305 | if ( $talkStatus->isGood() && $resStatus->isGood() ) { |
| 306 | $talkStatus = $this->undeleteRevisions( $this->associatedTalk, [], $comment, false ); |
| 307 | if ( !$talkStatus->isOK() ) { |
| 308 | // Type mismatch for status's value doesn't matter for non-OK status |
| 309 | // @phan-suppress-next-line PhanTypeMismatchReturn |
| 310 | return $talkStatus; |
| 311 | } |
| 312 | [ $talkRestored, $talkCreated, $restoredTalkRevision, $restoredTalkPageIds ] = $talkStatus->getValue(); |
| 313 | |
| 314 | } else { |
| 315 | // Add errors as warnings since the talk page is secondary to the main action |
| 316 | foreach ( $talkStatus->getMessages() as $msg ) { |
| 317 | $resStatus->warning( $msg ); |
| 318 | } |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | $resStatus->value = [ |
| 323 | self::REVISIONS_RESTORED => $textRestored + $talkRestored, |
| 324 | self::FILES_RESTORED => $filesRestored |
| 325 | ]; |
| 326 | |
| 327 | if ( !$textRestored && !$filesRestored && !$talkRestored ) { |
| 328 | $this->logger->debug( "Undelete: nothing undeleted..." ); |
| 329 | return $resStatus; |
| 330 | } |
| 331 | |
| 332 | if ( $textRestored || $filesRestored ) { |
| 333 | $logEntry = $this->addLogEntry( $this->page, $comment, $textRestored, $filesRestored ); |
| 334 | |
| 335 | if ( $textRestored ) { |
| 336 | $this->hookRunner->onPageUndeleteComplete( |
| 337 | $this->page, |
| 338 | $this->performer, |
| 339 | $comment, |
| 340 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable |
| 341 | $restoredRevision, |
| 342 | $logEntry, |
| 343 | $textRestored, |
| 344 | $pageCreated, |
| 345 | $restoredPageIds |
| 346 | ); |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | if ( $talkRestored ) { |
| 351 | $talkRestoredComment = $this->contLangMsgTextFormatter->format( |
| 352 | MessageValue::new( 'undelete-talk-summary-prefix' )->plaintextParams( $comment ) |
| 353 | ); |
| 354 | $logEntry = $this->addLogEntry( $this->associatedTalk, $talkRestoredComment, $talkRestored, 0 ); |
| 355 | |
| 356 | $this->hookRunner->onPageUndeleteComplete( |
| 357 | $this->associatedTalk, |
| 358 | $this->performer, |
| 359 | $talkRestoredComment, |
| 360 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable |
| 361 | $restoredTalkRevision, |
| 362 | $logEntry, |
| 363 | $talkRestored, |
| 364 | $talkCreated, |
| 365 | $restoredTalkPageIds |
| 366 | ); |
| 367 | } |
| 368 | |
| 369 | return $resStatus; |
| 370 | } |
| 371 | |
| 372 | /** |
| 373 | * @return StatusValue<never> |
| 374 | */ |
| 375 | private function runPreUndeleteHook( string $comment ): StatusValue { |
| 376 | $checkPages = [ $this->page ]; |
| 377 | if ( $this->associatedTalk ) { |
| 378 | $checkPages[] = $this->associatedTalk; |
| 379 | } |
| 380 | foreach ( $checkPages as $page ) { |
| 381 | $hookStatus = StatusValue::newGood(); |
| 382 | $hookRes = $this->hookRunner->onPageUndelete( |
| 383 | $page, |
| 384 | $this->performer, |
| 385 | $comment, |
| 386 | $this->unsuppress, |
| 387 | $this->timestamps, |
| 388 | $this->fileVersions, |
| 389 | $hookStatus |
| 390 | ); |
| 391 | if ( !$hookRes && !$hookStatus->isGood() ) { |
| 392 | // Note: as per the PageUndeleteHook documentation, `return false` is ignored if $status is good. |
| 393 | return $hookStatus; |
| 394 | } |
| 395 | } |
| 396 | return Status::newGood(); |
| 397 | } |
| 398 | |
| 399 | /** |
| 400 | * @param ProperPageIdentity $page |
| 401 | * @param string $comment |
| 402 | * @param int $textRestored |
| 403 | * @param int $filesRestored |
| 404 | * |
| 405 | * @return ManualLogEntry |
| 406 | */ |
| 407 | private function addLogEntry( |
| 408 | ProperPageIdentity $page, |
| 409 | string $comment, |
| 410 | int $textRestored, |
| 411 | int $filesRestored |
| 412 | ): ManualLogEntry { |
| 413 | $logEntry = new ManualLogEntry( 'delete', 'restore' ); |
| 414 | $logEntry->setPerformer( $this->performer->getUser() ); |
| 415 | $logEntry->setTarget( $page ); |
| 416 | $logEntry->setComment( $comment ); |
| 417 | $logEntry->addTags( $this->tags ); |
| 418 | $logEntry->setParameters( [ |
| 419 | ':assoc:count' => [ |
| 420 | 'revisions' => $textRestored, |
| 421 | 'files' => $filesRestored, |
| 422 | ], |
| 423 | ] ); |
| 424 | |
| 425 | $logid = $logEntry->insert(); |
| 426 | $logEntry->publish( $logid ); |
| 427 | |
| 428 | return $logEntry; |
| 429 | } |
| 430 | |
| 431 | /** |
| 432 | * This is the meaty bit -- It restores archived revisions of the given page |
| 433 | * to the revision table. |
| 434 | * |
| 435 | * @param ProperPageIdentity $page |
| 436 | * @param string[] $timestamps |
| 437 | * @param string $comment |
| 438 | * @param bool $acceptNoRevisions Whether to return a good status rather than an error |
| 439 | * if no revisions are undeleted. |
| 440 | * @throws ReadOnlyError |
| 441 | * @return StatusValue<array{int, bool, ?RevisionRecord, array<int,true>}> |
| 442 | */ |
| 443 | private function undeleteRevisions( |
| 444 | ProperPageIdentity $page, array $timestamps, |
| 445 | string $comment, bool $acceptNoRevisions |
| 446 | ): StatusValue { |
| 447 | if ( $this->readOnlyMode->isReadOnly() ) { |
| 448 | throw new ReadOnlyError(); |
| 449 | } |
| 450 | |
| 451 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
| 452 | $dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); |
| 453 | |
| 454 | $oldWhere = [ |
| 455 | 'ar_namespace' => $page->getNamespace(), |
| 456 | 'ar_title' => $page->getDBkey(), |
| 457 | ]; |
| 458 | if ( $timestamps ) { |
| 459 | $oldWhere['ar_timestamp'] = array_map( $dbw->timestamp( ... ), $timestamps ); |
| 460 | } |
| 461 | |
| 462 | $revisionStore = $this->revisionStore; |
| 463 | $result = $revisionStore->newArchiveSelectQueryBuilder( $dbw ) |
| 464 | ->joinComment() |
| 465 | ->leftJoin( 'revision', null, 'ar_rev_id=rev_id' ) |
| 466 | ->field( 'rev_id' ) |
| 467 | ->where( $oldWhere ) |
| 468 | ->orderBy( 'ar_timestamp' ) |
| 469 | ->caller( __METHOD__ )->fetchResultSet(); |
| 470 | |
| 471 | $rev_count = $result->numRows(); |
| 472 | if ( !$rev_count ) { |
| 473 | $this->logger->debug( __METHOD__ . ": no revisions to restore" ); |
| 474 | |
| 475 | // Status value is count of revisions, whether the page has been created, |
| 476 | // last revision undeleted and all undeleted pages |
| 477 | $status = Status::newGood( [ 0, false, null, [] ] ); |
| 478 | if ( !$acceptNoRevisions ) { |
| 479 | $status->error( "undelete-no-results" ); |
| 480 | } |
| 481 | $dbw->endAtomic( __METHOD__ ); |
| 482 | |
| 483 | return $status; |
| 484 | } |
| 485 | |
| 486 | $result->seek( $rev_count - 1 ); |
| 487 | $latestRestorableRow = $result->current(); |
| 488 | |
| 489 | // move back |
| 490 | $result->seek( 0 ); |
| 491 | |
| 492 | $wikiPage = $this->wikiPageFactory->newFromTitle( $page ); |
| 493 | |
| 494 | $created = true; |
| 495 | $oldcountable = false; |
| 496 | $updatedCurrentRevision = false; |
| 497 | $restoredRevCount = 0; |
| 498 | $restoredPages = []; |
| 499 | |
| 500 | // pass this to ArticleUndelete hook |
| 501 | $oldPageId = (int)$latestRestorableRow->ar_page_id; |
| 502 | |
| 503 | // Grab the content to check consistency with global state before restoring the page. |
| 504 | // XXX: The only current use case is Wikibase, which tries to enforce uniqueness of |
| 505 | // certain things across all pages. There may be a better way to do that. |
| 506 | $revision = $revisionStore->newRevisionFromArchiveRow( |
| 507 | $latestRestorableRow, |
| 508 | 0, |
| 509 | $page |
| 510 | ); |
| 511 | |
| 512 | foreach ( $revision->getSlotRoles() as $role ) { |
| 513 | $content = $revision->getContent( $role, RevisionRecord::RAW ); |
| 514 | // NOTE: article ID may not be known yet. validateSave() should not modify the database. |
| 515 | $contentHandler = $this->contentHandlerFactory->getContentHandler( $content->getModel() ); |
| 516 | $validationParams = new ValidationParams( $wikiPage, 0 ); |
| 517 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable RAW never returns null |
| 518 | $status = $contentHandler->validateSave( $content, $validationParams ); |
| 519 | if ( !$status->isOK() ) { |
| 520 | $dbw->endAtomic( __METHOD__ ); |
| 521 | |
| 522 | return $status; |
| 523 | } |
| 524 | } |
| 525 | |
| 526 | // Grab page state before changing it |
| 527 | $updater = $this->pageUpdaterFactory->newDerivedPageDataUpdater( $wikiPage ); |
| 528 | $updater->grabCurrentRevision(); |
| 529 | |
| 530 | $pageId = $wikiPage->insertOn( $dbw, $latestRestorableRow->ar_page_id ); |
| 531 | if ( $pageId === false ) { |
| 532 | // The page ID is reserved; let's pick another |
| 533 | $pageId = $wikiPage->insertOn( $dbw ); |
| 534 | if ( $pageId === false ) { |
| 535 | // The page title must be already taken (race condition) |
| 536 | $created = false; |
| 537 | } |
| 538 | } |
| 539 | |
| 540 | # Does this page already exist? We'll have to update it... |
| 541 | if ( !$created ) { |
| 542 | # Load latest data for the current page (T33179) |
| 543 | $wikiPage->loadPageData( IDBAccessObject::READ_EXCLUSIVE ); |
| 544 | $pageId = $wikiPage->getId(); |
| 545 | $oldcountable = $wikiPage->isCountable(); |
| 546 | |
| 547 | $previousTimestamp = false; |
| 548 | $latestRevId = $wikiPage->getLatest(); |
| 549 | if ( $latestRevId ) { |
| 550 | $previousTimestamp = $revisionStore->getTimestampFromId( |
| 551 | $latestRevId, |
| 552 | IDBAccessObject::READ_LATEST |
| 553 | ); |
| 554 | } |
| 555 | if ( $previousTimestamp === false ) { |
| 556 | $this->logger->debug( __METHOD__ . ": existing page refers to a page_latest that does not exist" ); |
| 557 | |
| 558 | // Status value is count of revisions, whether the page has been created, |
| 559 | // last revision undeleted and all undeleted pages |
| 560 | $status = Status::newGood( [ 0, false, null, [] ] ); |
| 561 | $status->error( 'undeleterevision-missing' ); |
| 562 | $dbw->cancelAtomic( __METHOD__ ); |
| 563 | |
| 564 | return $status; |
| 565 | } |
| 566 | } else { |
| 567 | $previousTimestamp = 0; |
| 568 | } |
| 569 | |
| 570 | // Re-create the PageIdentity using $pageId |
| 571 | $page = PageIdentityValue::localIdentity( |
| 572 | $pageId, |
| 573 | $page->getNamespace(), |
| 574 | $page->getDBkey() |
| 575 | ); |
| 576 | |
| 577 | Assert::postcondition( $page->exists(), 'The page should exist now' ); |
| 578 | |
| 579 | // Check if a deleted revision will become the current revision... |
| 580 | $latestRestorableRowTimestamp = wfTimestamp( TS::MW, $latestRestorableRow->ar_timestamp ); |
| 581 | if ( $latestRestorableRowTimestamp > $previousTimestamp ) { |
| 582 | // Check the state of the newest to-be version... |
| 583 | if ( !$this->unsuppress |
| 584 | && ( $latestRestorableRow->ar_deleted & RevisionRecord::DELETED_TEXT ) |
| 585 | ) { |
| 586 | $dbw->cancelAtomic( __METHOD__ ); |
| 587 | |
| 588 | return Status::newFatal( "undeleterevdel" ); |
| 589 | } |
| 590 | $updatedCurrentRevision = true; |
| 591 | } |
| 592 | |
| 593 | foreach ( $result as $row ) { |
| 594 | // Insert one revision at a time...maintaining deletion status |
| 595 | // unless we are specifically removing all restrictions... |
| 596 | $revision = $revisionStore->newRevisionFromArchiveRow( |
| 597 | $row, |
| 598 | 0, |
| 599 | $page, |
| 600 | [ |
| 601 | 'page_id' => $pageId, |
| 602 | 'deleted' => $this->unsuppress ? 0 : $row->ar_deleted |
| 603 | ] |
| 604 | ); |
| 605 | |
| 606 | // This will also copy the revision to ip_changes if it was an IP edit. |
| 607 | $revision = $revisionStore->insertRevisionOn( $revision, $dbw ); |
| 608 | |
| 609 | $restoredRevCount++; |
| 610 | |
| 611 | $this->hookRunner->onRevisionUndeleted( $revision, $row->ar_page_id ); |
| 612 | |
| 613 | $restoredPages[$row->ar_page_id] = true; |
| 614 | } |
| 615 | |
| 616 | // Now that it's safely stored, take it out of the archive |
| 617 | $dbw->newDeleteQueryBuilder() |
| 618 | ->deleteFrom( 'archive' ) |
| 619 | ->where( $oldWhere ) |
| 620 | ->caller( __METHOD__ )->execute(); |
| 621 | |
| 622 | // Status value is count of revisions, whether the page has been created, |
| 623 | // last revision undeleted and all undeleted pages |
| 624 | $status = Status::newGood( [ $restoredRevCount, $created, $revision, $restoredPages ] ); |
| 625 | |
| 626 | // Was anything restored at all? |
| 627 | if ( $restoredRevCount ) { |
| 628 | |
| 629 | if ( $updatedCurrentRevision ) { |
| 630 | // Attach the latest revision to the page... |
| 631 | // XXX: updateRevisionOn should probably move into a PageStore service. |
| 632 | $wasnew = $wikiPage->updateRevisionOn( |
| 633 | $dbw, |
| 634 | $revision, |
| 635 | $created ? 0 : $wikiPage->getLatest() |
| 636 | ); |
| 637 | } else { |
| 638 | $wasnew = false; |
| 639 | } |
| 640 | |
| 641 | if ( $created || $wasnew ) { |
| 642 | // Update site stats, link tables, etc |
| 643 | $options = [ |
| 644 | PageLatestRevisionChangedEvent::FLAG_SILENT => true, |
| 645 | PageLatestRevisionChangedEvent::FLAG_IMPLICIT => true, |
| 646 | 'created' => $created, |
| 647 | 'oldcountable' => $oldcountable, |
| 648 | 'reason' => $comment |
| 649 | ]; |
| 650 | |
| 651 | $updater->setCause( PageUpdater::CAUSE_UNDELETE ); |
| 652 | $updater->setPerformer( $this->performer->getUser() ); |
| 653 | $updater->prepareUpdate( $revision, $options ); |
| 654 | $updater->doUpdates(); |
| 655 | } |
| 656 | |
| 657 | $this->hookRunner->onArticleUndelete( |
| 658 | $wikiPage->getTitle(), $created, $comment, $oldPageId, $restoredPages ); |
| 659 | |
| 660 | if ( $page->getNamespace() === NS_FILE ) { |
| 661 | $job = HTMLCacheUpdateJob::newForBacklinks( |
| 662 | $page, |
| 663 | 'imagelinks', |
| 664 | [ 'causeAction' => 'undelete-file' ] |
| 665 | ); |
| 666 | $this->jobQueueGroup->lazyPush( $job ); |
| 667 | } |
| 668 | } |
| 669 | |
| 670 | $dbw->endAtomic( __METHOD__ ); |
| 671 | |
| 672 | return $status; |
| 673 | } |
| 674 | |
| 675 | /** |
| 676 | * @internal BC method to be used by PageArchive only |
| 677 | * @return Status|null |
| 678 | */ |
| 679 | public function getFileStatus(): ?Status { |
| 680 | return $this->fileStatus; |
| 681 | } |
| 682 | |
| 683 | /** |
| 684 | * @internal BC methods to be used by PageArchive only |
| 685 | * @return StatusValue<array{int, bool, ?RevisionRecord, array<int,true>}>|null |
| 686 | */ |
| 687 | public function getRevisionStatus(): ?StatusValue { |
| 688 | return $this->revisionStatus; |
| 689 | } |
| 690 | } |