1<?php 2 3/** 4 * This program is free software; you can redistribute it and/or modify 5 * it under the terms of the GNU General Public License as published by 6 * the Free Software Foundation; either version 2 of the License, or 7 * (at your option) any later version. 8 * 9 * This program is distributed in the hope that it will be useful, 10 * but WITHOUT ANY WARRANTY; without even the implied warranty of 11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 * GNU General Public License for more details. 13 * 14 * You should have received a copy of the GNU General Public License along 15 * with this program; if not, write to the Free Software Foundation, Inc., 16 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 17 * http://www.gnu.org/copyleft/gpl.html 18 * 19 * @file 20 */ 21 22use MediaWiki\Collation\CollationFactory; 23use MediaWiki\Config\ServiceOptions; 24use MediaWiki\Content\IContentHandlerFactory; 25use MediaWiki\EditPage\SpamChecker; 26use MediaWiki\HookContainer\HookContainer; 27use MediaWiki\HookContainer\HookRunner; 28use MediaWiki\MediaWikiServices; 29use MediaWiki\Page\MovePageFactory; 30use MediaWiki\Page\PageIdentity; 31use MediaWiki\Page\WikiPageFactory; 32use MediaWiki\Permissions\Authority; 33use MediaWiki\Permissions\PermissionStatus; 34use MediaWiki\Revision\MutableRevisionRecord; 35use MediaWiki\Revision\RevisionRecord; 36use MediaWiki\Revision\RevisionStore; 37use MediaWiki\Revision\SlotRecord; 38use MediaWiki\User\UserEditTracker; 39use MediaWiki\User\UserFactory; 40use MediaWiki\User\UserIdentity; 41use Wikimedia\Rdbms\IDatabase; 42use Wikimedia\Rdbms\ILoadBalancer; 43 44/** 45 * Handles the backend logic of moving a page from one title 46 * to another. 47 * 48 * @since 1.24 49 */ 50class MovePage { 51 52 /** 53 * @var Title 54 */ 55 protected $oldTitle; 56 57 /** 58 * @var Title 59 */ 60 protected $newTitle; 61 62 /** 63 * @var ServiceOptions 64 */ 65 protected $options; 66 67 /** 68 * @var ILoadBalancer 69 */ 70 protected $loadBalancer; 71 72 /** 73 * @var NamespaceInfo 74 */ 75 protected $nsInfo; 76 77 /** 78 * @var WatchedItemStoreInterface 79 */ 80 protected $watchedItems; 81 82 /** 83 * @var RepoGroup 84 */ 85 protected $repoGroup; 86 87 /** 88 * @var IContentHandlerFactory 89 */ 90 private $contentHandlerFactory; 91 92 /** 93 * @var RevisionStore 94 */ 95 private $revisionStore; 96 97 /** 98 * @var SpamChecker 99 */ 100 private $spamChecker; 101 102 /** 103 * @var HookRunner 104 */ 105 private $hookRunner; 106 107 /** 108 * @var WikiPageFactory 109 */ 110 private $wikiPageFactory; 111 112 /** 113 * @var UserFactory 114 */ 115 private $userFactory; 116 117 /** @var UserEditTracker */ 118 private $userEditTracker; 119 120 /** @var MovePageFactory */ 121 private $movePageFactory; 122 123 /** @var CollationFactory */ 124 public $collationFactory; 125 126 /** 127 * @internal For use by PageCommandFactory 128 */ 129 public const CONSTRUCTOR_OPTIONS = [ 130 'CategoryCollation', 131 'MaximumMovedPages', 132 ]; 133 134 /** 135 * @param Title $oldTitle 136 * @param Title $newTitle 137 * @param ServiceOptions|null $options 138 * @param ILoadBalancer|null $loadBalancer 139 * @param NamespaceInfo|null $nsInfo 140 * @param WatchedItemStoreInterface|null $watchedItems 141 * @param RepoGroup|null $repoGroup 142 * @param IContentHandlerFactory|null $contentHandlerFactory 143 * @param RevisionStore|null $revisionStore 144 * @param SpamChecker|null $spamChecker 145 * @param HookContainer|null $hookContainer 146 * @param WikiPageFactory|null $wikiPageFactory 147 * @param UserFactory|null $userFactory 148 * @param UserEditTracker|null $userEditTracker 149 * @param MovePageFactory|null $movePageFactory 150 * @param CollationFactory|null $collationFactory 151 * @deprecated since 1.34, hard deprecated since 1.37. Use MovePageFactory instead. 152 */ 153 public function __construct( 154 Title $oldTitle, 155 Title $newTitle, 156 ServiceOptions $options = null, 157 ILoadBalancer $loadBalancer = null, 158 NamespaceInfo $nsInfo = null, 159 WatchedItemStoreInterface $watchedItems = null, 160 RepoGroup $repoGroup = null, 161 IContentHandlerFactory $contentHandlerFactory = null, 162 RevisionStore $revisionStore = null, 163 SpamChecker $spamChecker = null, 164 HookContainer $hookContainer = null, 165 WikiPageFactory $wikiPageFactory = null, 166 UserFactory $userFactory = null, 167 UserEditTracker $userEditTracker = null, 168 MovePageFactory $movePageFactory = null, 169 CollationFactory $collationFactory = null 170 ) { 171 if ( !$options ) { 172 wfDeprecatedMsg( 173 __METHOD__ . ' without providing all services is deprecated', 174 '1.34' 175 ); 176 } 177 178 $this->oldTitle = $oldTitle; 179 $this->newTitle = $newTitle; 180 181 $services = static function () { 182 // BC hack. Use a closure so this can be unit-tested. 183 return MediaWikiServices::getInstance(); 184 }; 185 $this->options = $options ?? 186 new ServiceOptions( 187 self::CONSTRUCTOR_OPTIONS, 188 $services()->getMainConfig() 189 ); 190 $this->loadBalancer = $loadBalancer ?? $services()->getDBLoadBalancer(); 191 $this->nsInfo = $nsInfo ?? $services()->getNamespaceInfo(); 192 $this->watchedItems = $watchedItems ?? $services()->getWatchedItemStore(); 193 $this->repoGroup = $repoGroup ?? $services()->getRepoGroup(); 194 $this->contentHandlerFactory = 195 $contentHandlerFactory ?? $services()->getContentHandlerFactory(); 196 197 $this->revisionStore = $revisionStore ?? $services()->getRevisionStore(); 198 $this->spamChecker = $spamChecker ?? $services()->getSpamChecker(); 199 $this->hookRunner = new HookRunner( $hookContainer ?? $services()->getHookContainer() ); 200 $this->wikiPageFactory = $wikiPageFactory ?? $services()->getWikiPageFactory(); 201 $this->userFactory = $userFactory ?? $services()->getUserFactory(); 202 $this->userEditTracker = $userEditTracker ?? $services()->getUserEditTracker(); 203 $this->movePageFactory = $movePageFactory ?? $services()->getMovePageFactory(); 204 $this->collationFactory = $collationFactory ?? $services()->getCollationFactory(); 205 } 206 207 /** 208 * @param callable $authorizer ( string $action, PageIdentity $target, PermissionStatus $status ) 209 * @param Authority $performer 210 * @param string|null $reason 211 * @return PermissionStatus 212 */ 213 private function authorizeInternal( 214 callable $authorizer, 215 Authority $performer, 216 ?string $reason 217 ): PermissionStatus { 218 $status = PermissionStatus::newEmpty(); 219 220 $authorizer( 'move', $this->oldTitle, $status ); 221 $authorizer( 'edit', $this->oldTitle, $status ); 222 $authorizer( 'move-target', $this->newTitle, $status ); 223 $authorizer( 'edit', $this->newTitle, $status ); 224 225 if ( $reason !== null && $this->spamChecker->checkSummary( $reason ) !== false ) { 226 // This is kind of lame, won't display nice 227 $status->fatal( 'spamprotectiontext' ); 228 } 229 230 $tp = $this->newTitle->getTitleProtection(); 231 if ( $tp !== false && !$performer->isAllowed( $tp['permission'] ) ) { 232 $status->fatal( 'cantmove-titleprotected' ); 233 } 234 235 // TODO: change hook signature to accept Authority and PermissionStatus 236 $user = $this->userFactory->newFromAuthority( $performer ); 237 $status = Status::wrap( $status ); 238 $this->hookRunner->onMovePageCheckPermissions( 239 $this->oldTitle, $this->newTitle, $user, $reason, $status ); 240 // TODO: remove conversion code after hook signature is changed. 241 $permissionStatus = PermissionStatus::newEmpty(); 242 foreach ( $status->getErrorsArray() as $error ) { 243 $permissionStatus->fatal( ...$error ); 244 } 245 return $permissionStatus; 246 } 247 248 /** 249 * Check whether $performer can execute the move. 250 * 251 * @note this method does not guarantee full permissions check, so it should 252 * only be used to to decide whether to show a move form. To authorize the move 253 * action use {@link self::authorizeMove} instead. 254 * 255 * @param Authority $performer 256 * @param string|null $reason 257 * @return PermissionStatus 258 */ 259 public function probablyCanMove( Authority $performer, string $reason = null ): PermissionStatus { 260 return $this->authorizeInternal( 261 static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) { 262 return $performer->probablyCan( $action, $target, $status ); 263 }, 264 $performer, 265 $reason 266 ); 267 } 268 269 /** 270 * Authorize the move by $performer. 271 * 272 * @note this method should be used right before the actual move is performed. 273 * To check whether a current performer has the potential to move the page, 274 * use {@link self::probablyCanMove} instead. 275 * 276 * @param Authority $performer 277 * @param string|null $reason 278 * @return PermissionStatus 279 */ 280 public function authorizeMove( Authority $performer, string $reason = null ): PermissionStatus { 281 return $this->authorizeInternal( 282 static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) { 283 return $performer->authorizeWrite( $action, $target, $status ); 284 }, 285 $performer, 286 $reason 287 ); 288 } 289 290 /** 291 * Check if the user is allowed to perform the move. 292 * 293 * @param Authority $performer 294 * @param string|null $reason To check against summary spam regex. Set to null to skip the check, 295 * for instance to display errors preemptively before the user has filled in a summary. 296 * @deprecated since 1.36, use ::authorizeMove or ::probablyCanMove instead. 297 * @return Status 298 */ 299 public function checkPermissions( Authority $performer, $reason ) { 300 $permissionStatus = $this->authorizeInternal( 301 static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) { 302 return $performer->definitelyCan( $action, $target, $status ); 303 }, 304 $performer, 305 $reason 306 ); 307 return Status::wrap( $permissionStatus ); 308 } 309 310 /** 311 * Does various sanity checks that the move is 312 * valid. Only things based on the two titles 313 * should be checked here. 314 * 315 * @return Status 316 */ 317 public function isValidMove() { 318 $status = new Status(); 319 320 if ( $this->oldTitle->equals( $this->newTitle ) ) { 321 $status->fatal( 'selfmove' ); 322 } elseif ( $this->newTitle->getArticleID( Title::READ_LATEST /* T272386 */ ) 323 && !$this->isValidMoveTarget() 324 ) { 325 // The move is allowed only if (1) the target doesn't exist, or (2) the target is a 326 // redirect to the source, and has no history (so we can undo bad moves right after 327 // they're done). If the target is a single revision redirect to a different page, 328 // it can be deleted with just `delete-redirect` rights (i.e. without needing 329 // `delete`) - see T239277 330 $fatal = $this->newTitle->isSingleRevRedirect() ? 'redirectexists' : 'articleexists'; 331 $status->fatal( $fatal, $this->newTitle->getPrefixedText() ); 332 } 333 334 // @todo If the old title is invalid, maybe we should check if it somehow exists in the 335 // database and allow moving it to a valid name? Why prohibit the move from an empty name 336 // without checking in the database? 337 if ( $this->oldTitle->getDBkey() == '' ) { 338 $status->fatal( 'badarticleerror' ); 339 } elseif ( $this->oldTitle->isExternal() ) { 340 $status->fatal( 'immobile-source-namespace-iw' ); 341 } elseif ( !$this->oldTitle->isMovable() ) { 342 $nsText = $this->oldTitle->getNsText(); 343 if ( $nsText === '' ) { 344 $nsText = wfMessage( 'blanknamespace' )->text(); 345 } 346 $status->fatal( 'immobile-source-namespace', $nsText ); 347 } elseif ( !$this->oldTitle->exists() ) { 348 $status->fatal( 'movepage-source-doesnt-exist' ); 349 } 350 351 if ( $this->newTitle->isExternal() ) { 352 $status->fatal( 'immobile-target-namespace-iw' ); 353 } elseif ( !$this->newTitle->isMovable() ) { 354 $nsText = $this->newTitle->getNsText(); 355 if ( $nsText === '' ) { 356 $nsText = wfMessage( 'blanknamespace' )->text(); 357 } 358 $status->fatal( 'immobile-target-namespace', $nsText ); 359 } 360 if ( !$this->newTitle->isValid() ) { 361 $status->fatal( 'movepage-invalid-target-title' ); 362 } 363 364 // Content model checks 365 if ( !$this->contentHandlerFactory 366 ->getContentHandler( $this->oldTitle->getContentModel() ) 367 ->canBeUsedOn( $this->newTitle ) 368 ) { 369 $status->fatal( 370 'content-not-allowed-here', 371 ContentHandler::getLocalizedName( $this->oldTitle->getContentModel() ), 372 $this->newTitle->getPrefixedText(), 373 SlotRecord::MAIN 374 ); 375 } 376 377 // Image-specific checks 378 if ( $this->oldTitle->inNamespace( NS_FILE ) ) { 379 $status->merge( $this->isValidFileMove() ); 380 } 381 382 if ( $this->newTitle->inNamespace( NS_FILE ) && !$this->oldTitle->inNamespace( NS_FILE ) ) { 383 $status->fatal( 'nonfile-cannot-move-to-file' ); 384 } 385 386 // Hook for extensions to say a title can't be moved for technical reasons 387 $this->hookRunner->onMovePageIsValidMove( $this->oldTitle, $this->newTitle, $status ); 388 389 return $status; 390 } 391 392 /** 393 * Sanity checks for when a file is being moved 394 * 395 * @return Status 396 */ 397 protected function isValidFileMove() { 398 $status = new Status(); 399 400 if ( !$this->newTitle->inNamespace( NS_FILE ) ) { 401 // No need for further errors about the target filename being wrong 402 return $status->fatal( 'imagenocrossnamespace' ); 403 } 404 405 $file = $this->repoGroup->getLocalRepo()->newFile( $this->oldTitle ); 406 $file->load( File::READ_LATEST ); 407 if ( $file->exists() ) { 408 if ( $this->newTitle->getText() != wfStripIllegalFilenameChars( $this->newTitle->getText() ) ) { 409 $status->fatal( 'imageinvalidfilename' ); 410 } 411 if ( !File::checkExtensionCompatibility( $file, $this->newTitle->getDBkey() ) ) { 412 $status->fatal( 'imagetypemismatch' ); 413 } 414 } 415 416 return $status; 417 } 418 419 /** 420 * Checks if $this can be moved to a given Title 421 * - Selects for update, so don't call it unless you mean business 422 * 423 * @since 1.25 424 * @return bool 425 */ 426 protected function isValidMoveTarget() { 427 # Is it an existing file? 428 if ( $this->newTitle->inNamespace( NS_FILE ) ) { 429 $file = $this->repoGroup->getLocalRepo()->newFile( $this->newTitle ); 430 $file->load( File::READ_LATEST ); 431 if ( $file->exists() ) { 432 wfDebug( __METHOD__ . ": file exists" ); 433 return false; 434 } 435 } 436 # Is it a redirect with no history? 437 if ( !$this->newTitle->isSingleRevRedirect() ) { 438 wfDebug( __METHOD__ . ": not a one-rev redirect" ); 439 return false; 440 } 441 # Get the article text 442 $rev = $this->revisionStore->getRevisionByTitle( 443 $this->newTitle, 444 0, 445 RevisionStore::READ_LATEST 446 ); 447 if ( !is_object( $rev ) ) { 448 return false; 449 } 450 $content = $rev->getContent( SlotRecord::MAIN ); 451 # Does the redirect point to the source? 452 # Or is it a broken self-redirect, usually caused by namespace collisions? 453 $redirTitle = $content ? $content->getRedirectTarget() : null; 454 455 if ( $redirTitle ) { 456 if ( $redirTitle->getPrefixedDBkey() !== $this->oldTitle->getPrefixedDBkey() && 457 $redirTitle->getPrefixedDBkey() !== $this->newTitle->getPrefixedDBkey() ) { 458 wfDebug( __METHOD__ . ": redirect points to other page" ); 459 return false; 460 } else { 461 return true; 462 } 463 } else { 464 # Fail safe (not a redirect after all. strange.) 465 wfDebug( __METHOD__ . ": failsafe: database says " . $this->newTitle->getPrefixedDBkey() . 466 " is a redirect, but it doesn't contain a valid redirect." ); 467 return false; 468 } 469 } 470 471 /** 472 * Move a page without taking user permissions into account. Only checks if the move is itself 473 * invalid, e.g., trying to move a special page or trying to move a page onto one that already 474 * exists. 475 * 476 * @param UserIdentity $user 477 * @param string|null $reason 478 * @param bool|null $createRedirect 479 * @param string[] $changeTags Change tags to apply to the entry in the move log 480 * @return Status 481 */ 482 public function move( 483 UserIdentity $user, $reason = null, $createRedirect = true, array $changeTags = [] 484 ) { 485 $status = $this->isValidMove(); 486 if ( !$status->isOK() ) { 487 return $status; 488 } 489 490 return $this->moveUnsafe( $user, $reason, $createRedirect, $changeTags ); 491 } 492 493 /** 494 * Same as move(), but with permissions checks. 495 * 496 * @param Authority $performer 497 * @param string|null $reason 498 * @param bool|null $createRedirect Ignored if user doesn't have suppressredirect permission 499 * @param string[] $changeTags Change tags to apply to the entry in the move log 500 * @return Status 501 */ 502 public function moveIfAllowed( 503 Authority $performer, $reason = null, $createRedirect = true, array $changeTags = [] 504 ) { 505 $status = $this->isValidMove(); 506 $status->merge( $this->authorizeMove( $performer, $reason ) ); 507 if ( $changeTags ) { 508 $status->merge( ChangeTags::canAddTagsAccompanyingChange( $changeTags, $performer ) ); 509 } 510 511 if ( !$status->isOK() ) { 512 // TODO: wrap block spreading into Authority side-effect? 513 $user = $this->userFactory->newFromAuthority( $performer ); 514 // Auto-block user's IP if the account was "hard" blocked 515 $user->spreadAnyEditBlock(); 516 return $status; 517 } 518 519 // Check suppressredirect permission 520 if ( !$performer->isAllowed( 'suppressredirect' ) ) { 521 $createRedirect = true; 522 } 523 524 return $this->moveUnsafe( $performer->getUser(), $reason, $createRedirect, $changeTags ); 525 } 526 527 /** 528 * Move the source page's subpages to be subpages of the target page, without checking user 529 * permissions. The caller is responsible for moving the source page itself. We will still not 530 * do moves that are inherently not allowed, nor will we move more than $wgMaximumMovedPages. 531 * 532 * @param UserIdentity $user 533 * @param string|null $reason The reason for the move 534 * @param bool|null $createRedirect Whether to create redirects from the old subpages to 535 * the new ones 536 * @param string[] $changeTags Applied to entries in the move log and redirect page revision 537 * @return Status Good if no errors occurred. Ok if at least one page succeeded. The "value" 538 * of the top-level status is an array containing the per-title status for each page. For any 539 * move that succeeded, the "value" of the per-title status is the new page title. 540 */ 541 public function moveSubpages( 542 UserIdentity $user, $reason = null, $createRedirect = true, array $changeTags = [] 543 ) { 544 return $this->moveSubpagesInternal( 545 function ( Title $oldSubpage, Title $newSubpage ) 546 use ( $user, $reason, $createRedirect, $changeTags ) { 547 $mp = $this->movePageFactory->newMovePage( $oldSubpage, $newSubpage ); 548 return $mp->move( $user, $reason, $createRedirect, $changeTags ); 549 } 550 ); 551 } 552 553 /** 554 * Move the source page's subpages to be subpages of the target page, with user permission 555 * checks. The caller is responsible for moving the source page itself. 556 * 557 * @param Authority $performer 558 * @param string|null $reason The reason for the move 559 * @param bool|null $createRedirect Whether to create redirects from the old subpages to 560 * the new ones. Ignored if the user doesn't have the 'suppressredirect' right. 561 * @param string[] $changeTags Applied to entries in the move log and redirect page revision 562 * @return Status Good if no errors occurred. Ok if at least one page succeeded. The "value" 563 * of the top-level status is an array containing the per-title status for each page. For any 564 * move that succeeded, the "value" of the per-title status is the new page title. 565 */ 566 public function moveSubpagesIfAllowed( 567 Authority $performer, $reason = null, $createRedirect = true, array $changeTags = [] 568 ) { 569 if ( !$performer->authorizeWrite( 'move-subpages', $this->oldTitle ) ) { 570 return Status::newFatal( 'cant-move-subpages' ); 571 } 572 return $this->moveSubpagesInternal( 573 function ( Title $oldSubpage, Title $newSubpage ) 574 use ( $performer, $reason, $createRedirect, $changeTags ) { 575 $mp = $this->movePageFactory->newMovePage( $oldSubpage, $newSubpage ); 576 return $mp->moveIfAllowed( $performer, $reason, $createRedirect, $changeTags ); 577 } 578 ); 579 } 580 581 /** 582 * @param callable $subpageMoveCallback 583 * @return Status 584 * @throws MWException 585 */ 586 private function moveSubpagesInternal( callable $subpageMoveCallback ) { 587 // Do the source and target namespaces support subpages? 588 if ( !$this->nsInfo->hasSubpages( $this->oldTitle->getNamespace() ) ) { 589 return Status::newFatal( 'namespace-nosubpages', 590 $this->nsInfo->getCanonicalName( $this->oldTitle->getNamespace() ) ); 591 } 592 if ( !$this->nsInfo->hasSubpages( $this->newTitle->getNamespace() ) ) { 593 return Status::newFatal( 'namespace-nosubpages', 594 $this->nsInfo->getCanonicalName( $this->newTitle->getNamespace() ) ); 595 } 596 597 // Return a status for the overall result. Its value will be an array with per-title 598 // status for each subpage. Merge any errors from the per-title statuses into the 599 // top-level status without resetting the overall result. 600 $maximumMovedPages = $this->options->get( 'MaximumMovedPages' ); 601 $topStatus = Status::newGood(); 602 $perTitleStatus = []; 603 $subpages = $this->oldTitle->getSubpages( $maximumMovedPages + 1 ); 604 $count = 0; 605 foreach ( $subpages as $oldSubpage ) { 606 $count++; 607 if ( $count > $maximumMovedPages ) { 608 $status = Status::newFatal( 'movepage-max-pages', $maximumMovedPages ); 609 $perTitleStatus[$oldSubpage->getPrefixedText()] = $status; 610 $topStatus->merge( $status ); 611 $topStatus->setOK( true ); 612 break; 613 } 614 615 // We don't know whether this function was called before or after moving the root page, 616 // so check both titles 617 if ( $oldSubpage->getArticleID() == $this->oldTitle->getArticleID() || 618 $oldSubpage->getArticleID() == $this->newTitle->getArticleID() 619 ) { 620 // When moving a page to a subpage of itself, don't move it twice 621 continue; 622 } 623 $newPageName = preg_replace( 624 '#^' . preg_quote( $this->oldTitle->getDBkey(), '#' ) . '#', 625 StringUtils::escapeRegexReplacement( $this->newTitle->getDBkey() ), # T23234 626 $oldSubpage->getDBkey() ); 627 if ( $oldSubpage->isTalkPage() ) { 628 $newNs = $this->newTitle->getTalkPage()->getNamespace(); 629 } else { 630 $newNs = $this->newTitle->getSubjectPage()->getNamespace(); 631 } 632 // T16385: we need makeTitleSafe because the new page names may be longer than 255 633 // characters. 634 $newSubpage = Title::makeTitleSafe( $newNs, $newPageName ); 635 $status = $subpageMoveCallback( $oldSubpage, $newSubpage ); 636 if ( $status->isOK() ) { 637 $status->setResult( true, $newSubpage->getPrefixedText() ); 638 } 639 $perTitleStatus[$oldSubpage->getPrefixedText()] = $status; 640 $topStatus->merge( $status ); 641 $topStatus->setOK( true ); 642 } 643 644 $topStatus->value = $perTitleStatus; 645 return $topStatus; 646 } 647 648 /** 649 * Moves *without* any sort of safety or sanity checks. Hooks can still fail the move, however. 650 * 651 * @param UserIdentity $user 652 * @param string $reason 653 * @param bool $createRedirect 654 * @param string[] $changeTags Change tags to apply to the entry in the move log 655 * @return Status 656 */ 657 private function moveUnsafe( UserIdentity $user, $reason, $createRedirect, array $changeTags ) { 658 $status = Status::newGood(); 659 660 // TODO: make hooks accept UserIdentity 661 $userObj = $this->userFactory->newFromUserIdentity( $user ); 662 $this->hookRunner->onTitleMove( $this->oldTitle, $this->newTitle, $userObj, $reason, $status ); 663 if ( !$status->isOK() ) { 664 // Move was aborted by the hook 665 return $status; 666 } 667 668 $dbw = $this->loadBalancer->getConnection( DB_PRIMARY ); 669 $dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); 670 671 $this->hookRunner->onTitleMoveStarting( $this->oldTitle, $this->newTitle, $userObj ); 672 673 $pageid = $this->oldTitle->getArticleID( Title::READ_LATEST ); 674 $protected = $this->oldTitle->isProtected(); 675 676 // Attempt the actual move 677 $moveAttemptResult = $this->moveToInternal( $user, $this->newTitle, $reason, $createRedirect, 678 $changeTags ); 679 680 if ( $moveAttemptResult instanceof Status ) { 681 // T265779: Attempt to delete target page failed 682 $dbw->cancelAtomic( __METHOD__ ); 683 return $moveAttemptResult; 684 } else { 685 $nullRevision = $moveAttemptResult; 686 } 687 688 // Refresh the sortkey for this row. Be careful to avoid resetting 689 // cl_timestamp, which may disturb time-based lists on some sites. 690 // @todo This block should be killed, it's duplicating code 691 // from LinksUpdate::getCategoryInsertions() and friends. 692 $prefixes = $dbw->select( 693 'categorylinks', 694 [ 'cl_sortkey_prefix', 'cl_to' ], 695 [ 'cl_from' => $pageid ], 696 __METHOD__ 697 ); 698 $type = $this->nsInfo->getCategoryLinkType( $this->newTitle->getNamespace() ); 699 $collation = $this->collationFactory->getCategoryCollation(); 700 foreach ( $prefixes as $prefixRow ) { 701 $prefix = $prefixRow->cl_sortkey_prefix; 702 $catTo = $prefixRow->cl_to; 703 $dbw->update( 'categorylinks', 704 [ 705 'cl_sortkey' => $collation->getSortKey( 706 $this->newTitle->getCategorySortkey( $prefix ) ), 707 'cl_collation' => $this->options->get( 'CategoryCollation' ), 708 'cl_type' => $type, 709 'cl_timestamp=cl_timestamp' ], 710 [ 711 'cl_from' => $pageid, 712 'cl_to' => $catTo ], 713 __METHOD__ 714 ); 715 } 716 717 $redirid = $this->oldTitle->getArticleID(); 718 719 if ( $protected ) { 720 # Protect the redirect title as the title used to be... 721 $res = $dbw->select( 722 'page_restrictions', 723 [ 'pr_type', 'pr_level', 'pr_cascade', 'pr_user', 'pr_expiry' ], 724 [ 'pr_page' => $pageid ], 725 __METHOD__, 726 'FOR UPDATE' 727 ); 728 $rowsInsert = []; 729 foreach ( $res as $row ) { 730 $rowsInsert[] = [ 731 'pr_page' => $redirid, 732 'pr_type' => $row->pr_type, 733 'pr_level' => $row->pr_level, 734 'pr_cascade' => $row->pr_cascade, 735 'pr_user' => $row->pr_user, 736 'pr_expiry' => $row->pr_expiry 737 ]; 738 } 739 $dbw->insert( 'page_restrictions', $rowsInsert, __METHOD__, [ 'IGNORE' ] ); 740 741 // Build comment for log 742 $comment = wfMessage( 743 'prot_1movedto2', 744 $this->oldTitle->getPrefixedText(), 745 $this->newTitle->getPrefixedText() 746 )->inContentLanguage()->text(); 747 if ( $reason ) { 748 $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason; 749 } 750 751 // reread inserted pr_ids for log relation 752 $insertedPrIds = $dbw->select( 753 'page_restrictions', 754 'pr_id', 755 [ 'pr_page' => $redirid ], 756 __METHOD__ 757 ); 758 $logRelationsValues = []; 759 foreach ( $insertedPrIds as $prid ) { 760 $logRelationsValues[] = $prid->pr_id; 761 } 762 763 // Update the protection log 764 $logEntry = new ManualLogEntry( 'protect', 'move_prot' ); 765 $logEntry->setTarget( $this->newTitle ); 766 $logEntry->setComment( $comment ); 767 $logEntry->setPerformer( $user ); 768 $logEntry->setParameters( [ 769 '4::oldtitle' => $this->oldTitle->getPrefixedText(), 770 ] ); 771 $logEntry->setRelations( [ 'pr_id' => $logRelationsValues ] ); 772 $logEntry->addTags( $changeTags ); 773 $logId = $logEntry->insert(); 774 $logEntry->publish( $logId ); 775 } 776 777 // Update *_from_namespace fields as needed 778 if ( $this->oldTitle->getNamespace() != $this->newTitle->getNamespace() ) { 779 $dbw->update( 'pagelinks', 780 [ 'pl_from_namespace' => $this->newTitle->getNamespace() ], 781 [ 'pl_from' => $pageid ], 782 __METHOD__ 783 ); 784 $dbw->update( 'templatelinks', 785 [ 'tl_from_namespace' => $this->newTitle->getNamespace() ], 786 [ 'tl_from' => $pageid ], 787 __METHOD__ 788 ); 789 $dbw->update( 'imagelinks', 790 [ 'il_from_namespace' => $this->newTitle->getNamespace() ], 791 [ 'il_from' => $pageid ], 792 __METHOD__ 793 ); 794 } 795 796 # Update watchlists 797 $oldtitle = $this->oldTitle->getDBkey(); 798 $newtitle = $this->newTitle->getDBkey(); 799 $oldsnamespace = $this->nsInfo->getSubject( $this->oldTitle->getNamespace() ); 800 $newsnamespace = $this->nsInfo->getSubject( $this->newTitle->getNamespace() ); 801 if ( $oldsnamespace != $newsnamespace || $oldtitle != $newtitle ) { 802 $this->watchedItems->duplicateAllAssociatedEntries( $this->oldTitle, $this->newTitle ); 803 } 804 805 // If it is a file then move it last. 806 // This is done after all database changes so that file system errors cancel the transaction. 807 if ( $this->oldTitle->getNamespace() === NS_FILE ) { 808 $status = $this->moveFile( $this->oldTitle, $this->newTitle ); 809 if ( !$status->isOK() ) { 810 $dbw->cancelAtomic( __METHOD__ ); 811 return $status; 812 } 813 } 814 815 $this->hookRunner->onPageMoveCompleting( 816 $this->oldTitle, $this->newTitle, 817 $user, $pageid, $redirid, $reason, $nullRevision 818 ); 819 820 $dbw->endAtomic( __METHOD__ ); 821 822 // Keep each single hook handler atomic 823 DeferredUpdates::addUpdate( 824 new AtomicSectionUpdate( 825 $dbw, 826 __METHOD__, 827 function () use ( $user, $pageid, $redirid, $reason, $nullRevision ) { 828 $this->hookRunner->onPageMoveComplete( 829 $this->oldTitle, 830 $this->newTitle, 831 $user, 832 $pageid, 833 $redirid, 834 $reason, 835 $nullRevision 836 ); 837 } 838 ) 839 ); 840 841 return Status::newGood(); 842 } 843 844 /** 845 * Move a file associated with a page to a new location. 846 * Can also be used to revert after a DB failure. 847 * 848 * @internal 849 * @param Title $oldTitle Old location to move the file from. 850 * @param Title $newTitle New location to move the file to. 851 * @return Status 852 */ 853 private function moveFile( $oldTitle, $newTitle ) { 854 $file = $this->repoGroup->getLocalRepo()->newFile( $oldTitle ); 855 $file->load( File::READ_LATEST ); 856 if ( $file->exists() ) { 857 $status = $file->move( $newTitle ); 858 } else { 859 $status = Status::newGood(); 860 } 861 862 // Clear RepoGroup process cache 863 $this->repoGroup->clearCache( $oldTitle ); 864 $this->repoGroup->clearCache( $newTitle ); # clear false negative cache 865 return $status; 866 } 867 868 /** 869 * Move page to a title which is either a redirect to the 870 * source page or nonexistent 871 * 872 * @todo This was basically directly moved from Title, it should be split into 873 * smaller functions 874 * @param UserIdentity $user doing the move 875 * @param Title &$nt The page to move to, which should be a redirect or non-existent 876 * @param string $reason The reason for the move 877 * @param bool $createRedirect Whether to leave a redirect at the old title. Does not check 878 * if the user has the suppressredirect right 879 * @param string[] $changeTags Change tags to apply to the entry in the move log 880 * @return RevisionRecord|Status The revision created by the move or Status object on failure 881 */ 882 private function moveToInternal( UserIdentity $user, &$nt, $reason = '', $createRedirect = true, 883 array $changeTags = [] 884 ) { 885 if ( $nt->getArticleId( Title::READ_LATEST ) ) { 886 $moveOverRedirect = true; 887 $logType = 'move_redir'; 888 } else { 889 $moveOverRedirect = false; 890 $logType = 'move'; 891 } 892 893 if ( $moveOverRedirect ) { 894 $overwriteMessage = wfMessage( 895 'delete_and_move_reason', 896 $this->oldTitle->getPrefixedText() 897 )->inContentLanguage()->text(); 898 $newpage = $this->wikiPageFactory->newFromTitle( $nt ); 899 $errs = []; 900 $status = $newpage->doDeleteArticleReal( 901 $overwriteMessage, 902 $user, 903 /* $suppress */ false, 904 /* unused */ null, 905 $errs, 906 /* unused */ null, 907 $changeTags, 908 'delete_redir' 909 ); 910 911 if ( !$status->isGood() ) { 912 return $status; 913 } 914 915 $nt->resetArticleID( false ); 916 } 917 918 if ( $createRedirect ) { 919 if ( $this->oldTitle->getNamespace() === NS_CATEGORY 920 && !wfMessage( 'category-move-redirect-override' )->inContentLanguage()->isDisabled() 921 ) { 922 $redirectContent = new WikitextContent( 923 wfMessage( 'category-move-redirect-override' ) 924 ->params( $nt->getPrefixedText() )->inContentLanguage()->plain() ); 925 } else { 926 $redirectContent = $this->contentHandlerFactory 927 ->getContentHandler( $this->oldTitle->getContentModel() ) 928 ->makeRedirectContent( 929 $nt, 930 wfMessage( 'move-redirect-text' )->inContentLanguage()->plain() 931 ); 932 } 933 934 // NOTE: If this page's content model does not support redirects, $redirectContent will be null. 935 } else { 936 $redirectContent = null; 937 } 938 939 // T59084: log_page should be the ID of the *moved* page 940 $oldid = $this->oldTitle->getArticleID(); 941 $logTitle = clone $this->oldTitle; 942 943 $logEntry = new ManualLogEntry( 'move', $logType ); 944 $logEntry->setPerformer( $user ); 945 $logEntry->setTarget( $logTitle ); 946 $logEntry->setComment( $reason ); 947 $logEntry->setParameters( [ 948 '4::target' => $nt->getPrefixedText(), 949 '5::noredir' => $redirectContent ? '0' : '1', 950 ] ); 951 952 $formatter = LogFormatter::newFromEntry( $logEntry ); 953 $formatter->setContext( RequestContext::newExtraneousContext( $this->oldTitle ) ); 954 $comment = $formatter->getPlainActionText(); 955 if ( $reason ) { 956 $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason; 957 } 958 959 $dbw = $this->loadBalancer->getConnection( DB_PRIMARY ); 960 961 $oldpage = $this->wikiPageFactory->newFromTitle( $this->oldTitle ); 962 $oldcountable = $oldpage->isCountable(); 963 964 $newpage = $this->wikiPageFactory->newFromTitle( $nt ); 965 966 # Change the name of the target page: 967 $dbw->update( 'page', 968 /* SET */ [ 969 'page_namespace' => $nt->getNamespace(), 970 'page_title' => $nt->getDBkey(), 971 ], 972 /* WHERE */ [ 'page_id' => $oldid ], 973 __METHOD__ 974 ); 975 976 // Reset $nt before using it to create the null revision (T248789). 977 // But not $this->oldTitle yet, see below (T47348). 978 $nt->resetArticleID( $oldid ); 979 980 $commentObj = CommentStoreComment::newUnsavedComment( $comment ); 981 # Save a null revision in the page's history notifying of the move 982 $nullRevision = $this->revisionStore->newNullRevision( 983 $dbw, 984 $nt, 985 $commentObj, 986 true, 987 $user 988 ); 989 if ( $nullRevision === null ) { 990 $id = $nt->getArticleID( Title::READ_EXCLUSIVE ); 991 $msg = 'Failed to create null revision while moving page ID ' . 992 $oldid . ' to ' . $nt->getPrefixedDBkey() . " (page ID $id)"; 993 994 throw new MWException( $msg ); 995 } 996 997 $nullRevision = $this->revisionStore->insertRevisionOn( $nullRevision, $dbw ); 998 $logEntry->setAssociatedRevId( $nullRevision->getId() ); 999 1000 /** 1001 * T163966 1002 * Increment user_editcount during page moves 1003 * Moved from SpecialMovepage.php per T195550 1004 */ 1005 $this->userEditTracker->incrementUserEditCount( $user ); 1006 1007 if ( !$redirectContent ) { 1008 // Clean up the old title *before* reset article id - T47348 1009 WikiPage::onArticleDelete( $this->oldTitle ); 1010 } 1011 1012 $this->oldTitle->resetArticleID( 0 ); // 0 == non existing 1013 $newpage->loadPageData( WikiPage::READ_LOCKING ); // T48397 1014 1015 $newpage->updateRevisionOn( $dbw, $nullRevision ); 1016 1017 $fakeTags = []; 1018 $this->hookRunner->onRevisionFromEditComplete( 1019 $newpage, $nullRevision, $nullRevision->getParentId(), $user, $fakeTags ); 1020 1021 $newpage->doEditUpdates( $nullRevision, $user, 1022 [ 'changed' => false, 'moved' => true, 'oldcountable' => $oldcountable ] ); 1023 1024 WikiPage::onArticleCreate( $nt ); 1025 1026 # Recreate the redirect, this time in the other direction. 1027 if ( $redirectContent ) { 1028 $redirectArticle = $this->wikiPageFactory->newFromTitle( $this->oldTitle ); 1029 $redirectArticle->loadFromRow( false, WikiPage::READ_LOCKING ); // T48397 1030 $newid = $redirectArticle->insertOn( $dbw ); 1031 if ( $newid ) { // sanity 1032 $this->oldTitle->resetArticleID( $newid ); 1033 $redirectRevRecord = new MutableRevisionRecord( $this->oldTitle ); 1034 $redirectRevRecord->setPageId( $newid ) 1035 ->setUser( $user ) 1036 ->setComment( $commentObj ) 1037 ->setContent( SlotRecord::MAIN, $redirectContent ) 1038 ->setTimestamp( MWTimestamp::now( TS_MW ) ); 1039 1040 $inserted = $this->revisionStore->insertRevisionOn( 1041 $redirectRevRecord, 1042 $dbw 1043 ); 1044 $redirectRevId = $inserted->getId(); 1045 $redirectArticle->updateRevisionOn( $dbw, $inserted, 0 ); 1046 1047 $fakeTags = []; 1048 $this->hookRunner->onRevisionFromEditComplete( 1049 $redirectArticle, 1050 $inserted, 1051 false, 1052 $user, 1053 $fakeTags 1054 ); 1055 1056 // Clear all caches to make sure no stale information is used 1057 // when parsing the newly created redirect. Without this, moves would fail 1058 // under certain conditions when Lua core runs on the new page. 1059 // It is not entirely clear why this is needed, we just found that 1060 // it fixes the issue at hand (T279832). 1061 Title::clearCaches(); 1062 1063 $redirectArticle->doEditUpdates( 1064 $inserted, 1065 $user, 1066 [ 'created' => true ] 1067 ); 1068 1069 // make a copy because of log entry below 1070 $redirectTags = $changeTags; 1071 if ( in_array( 'mw-new-redirect', ChangeTags::getSoftwareTags() ) ) { 1072 $redirectTags[] = 'mw-new-redirect'; 1073 } 1074 ChangeTags::addTags( $redirectTags, null, $redirectRevId, null ); 1075 } 1076 } 1077 1078 # Log the move 1079 $logid = $logEntry->insert(); 1080 1081 $logEntry->addTags( $changeTags ); 1082 $logEntry->publish( $logid ); 1083 1084 return $nullRevision; 1085 } 1086} 1087