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