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