MovePage.php 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905
  1. <?php
  2. /**
  3. * This program is free software; you can redistribute it and/or modify
  4. * it under the terms of the GNU General Public License as published by
  5. * the Free Software Foundation; either version 2 of the License, or
  6. * (at your option) any later version.
  7. *
  8. * This program is distributed in the hope that it will be useful,
  9. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. * GNU General Public License for more details.
  12. *
  13. * You should have received a copy of the GNU General Public License along
  14. * with this program; if not, write to the Free Software Foundation, Inc.,
  15. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  16. * http://www.gnu.org/copyleft/gpl.html
  17. *
  18. * @file
  19. */
  20. use MediaWiki\Config\ServiceOptions;
  21. use MediaWiki\MediaWikiServices;
  22. use MediaWiki\Page\MovePageFactory;
  23. use MediaWiki\Permissions\PermissionManager;
  24. use MediaWiki\Revision\SlotRecord;
  25. use Wikimedia\Rdbms\IDatabase;
  26. use Wikimedia\Rdbms\ILoadBalancer;
  27. /**
  28. * Handles the backend logic of moving a page from one title
  29. * to another.
  30. *
  31. * @since 1.24
  32. */
  33. class MovePage {
  34. /**
  35. * @var Title
  36. */
  37. protected $oldTitle;
  38. /**
  39. * @var Title
  40. */
  41. protected $newTitle;
  42. /**
  43. * @var ServiceOptions
  44. */
  45. protected $options;
  46. /**
  47. * @var ILoadBalancer
  48. */
  49. protected $loadBalancer;
  50. /**
  51. * @var NamespaceInfo
  52. */
  53. protected $nsInfo;
  54. /**
  55. * @var WatchedItemStoreInterface
  56. */
  57. protected $watchedItems;
  58. /**
  59. * @var PermissionManager
  60. */
  61. protected $permMgr;
  62. /**
  63. * @var RepoGroup
  64. */
  65. protected $repoGroup;
  66. /**
  67. * Calling this directly is deprecated in 1.34. Use MovePageFactory instead.
  68. *
  69. * @param Title $oldTitle
  70. * @param Title $newTitle
  71. * @param ServiceOptions|null $options
  72. * @param ILoadBalancer|null $loadBalancer
  73. * @param NamespaceInfo|null $nsInfo
  74. * @param WatchedItemStoreInterface|null $watchedItems
  75. * @param PermissionManager|null $permMgr
  76. */
  77. public function __construct(
  78. Title $oldTitle,
  79. Title $newTitle,
  80. ServiceOptions $options = null,
  81. ILoadBalancer $loadBalancer = null,
  82. NamespaceInfo $nsInfo = null,
  83. WatchedItemStoreInterface $watchedItems = null,
  84. PermissionManager $permMgr = null,
  85. RepoGroup $repoGroup = null
  86. ) {
  87. $this->oldTitle = $oldTitle;
  88. $this->newTitle = $newTitle;
  89. $this->options = $options ??
  90. new ServiceOptions( MovePageFactory::$constructorOptions,
  91. MediaWikiServices::getInstance()->getMainConfig() );
  92. $this->loadBalancer =
  93. $loadBalancer ?? MediaWikiServices::getInstance()->getDBLoadBalancer();
  94. $this->nsInfo = $nsInfo ?? MediaWikiServices::getInstance()->getNamespaceInfo();
  95. $this->watchedItems =
  96. $watchedItems ?? MediaWikiServices::getInstance()->getWatchedItemStore();
  97. $this->permMgr = $permMgr ?? MediaWikiServices::getInstance()->getPermissionManager();
  98. $this->repoGroup = $repoGroup ?? MediaWikiServices::getInstance()->getRepoGroup();
  99. }
  100. /**
  101. * Check if the user is allowed to perform the move.
  102. *
  103. * @param User $user
  104. * @param string|null $reason To check against summary spam regex. Set to null to skip the check,
  105. * for instance to display errors preemptively before the user has filled in a summary.
  106. * @return Status
  107. */
  108. public function checkPermissions( User $user, $reason ) {
  109. $status = new Status();
  110. $errors = wfMergeErrorArrays(
  111. $this->permMgr->getPermissionErrors( 'move', $user, $this->oldTitle ),
  112. $this->permMgr->getPermissionErrors( 'edit', $user, $this->oldTitle ),
  113. $this->permMgr->getPermissionErrors( 'move-target', $user, $this->newTitle ),
  114. $this->permMgr->getPermissionErrors( 'edit', $user, $this->newTitle )
  115. );
  116. // Convert into a Status object
  117. if ( $errors ) {
  118. foreach ( $errors as $error ) {
  119. $status->fatal( ...$error );
  120. }
  121. }
  122. if ( $reason !== null && EditPage::matchSummarySpamRegex( $reason ) !== false ) {
  123. // This is kind of lame, won't display nice
  124. $status->fatal( 'spamprotectiontext' );
  125. }
  126. $tp = $this->newTitle->getTitleProtection();
  127. $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
  128. if ( $tp !== false && !$permissionManager->userHasRight( $user, $tp['permission'] ) ) {
  129. $status->fatal( 'cantmove-titleprotected' );
  130. }
  131. Hooks::run( 'MovePageCheckPermissions',
  132. [ $this->oldTitle, $this->newTitle, $user, $reason, $status ]
  133. );
  134. return $status;
  135. }
  136. /**
  137. * Does various sanity checks that the move is
  138. * valid. Only things based on the two titles
  139. * should be checked here.
  140. *
  141. * @return Status
  142. */
  143. public function isValidMove() {
  144. $status = new Status();
  145. if ( $this->oldTitle->equals( $this->newTitle ) ) {
  146. $status->fatal( 'selfmove' );
  147. } elseif ( $this->newTitle->getArticleID() && !$this->isValidMoveTarget() ) {
  148. // The move is allowed only if (1) the target doesn't exist, or (2) the target is a
  149. // redirect to the source, and has no history (so we can undo bad moves right after
  150. // they're done).
  151. $status->fatal( 'articleexists' );
  152. }
  153. // @todo If the old title is invalid, maybe we should check if it somehow exists in the
  154. // database and allow moving it to a valid name? Why prohibit the move from an empty name
  155. // without checking in the database?
  156. if ( $this->oldTitle->getDBkey() == '' ) {
  157. $status->fatal( 'badarticleerror' );
  158. } elseif ( $this->oldTitle->isExternal() ) {
  159. $status->fatal( 'immobile-source-namespace-iw' );
  160. } elseif ( !$this->oldTitle->isMovable() ) {
  161. $status->fatal( 'immobile-source-namespace', $this->oldTitle->getNsText() );
  162. } elseif ( !$this->oldTitle->exists() ) {
  163. $status->fatal( 'movepage-source-doesnt-exist' );
  164. }
  165. if ( $this->newTitle->isExternal() ) {
  166. $status->fatal( 'immobile-target-namespace-iw' );
  167. } elseif ( !$this->newTitle->isMovable() ) {
  168. $status->fatal( 'immobile-target-namespace', $this->newTitle->getNsText() );
  169. }
  170. if ( !$this->newTitle->isValid() ) {
  171. $status->fatal( 'movepage-invalid-target-title' );
  172. }
  173. // Content model checks
  174. if ( !$this->options->get( 'ContentHandlerUseDB' ) &&
  175. $this->oldTitle->getContentModel() !== $this->newTitle->getContentModel() ) {
  176. // can't move a page if that would change the page's content model
  177. $status->fatal(
  178. 'bad-target-model',
  179. ContentHandler::getLocalizedName( $this->oldTitle->getContentModel() ),
  180. ContentHandler::getLocalizedName( $this->newTitle->getContentModel() )
  181. );
  182. } elseif (
  183. !ContentHandler::getForTitle( $this->oldTitle )->canBeUsedOn( $this->newTitle )
  184. ) {
  185. $status->fatal(
  186. 'content-not-allowed-here',
  187. ContentHandler::getLocalizedName( $this->oldTitle->getContentModel() ),
  188. $this->newTitle->getPrefixedText(),
  189. SlotRecord::MAIN
  190. );
  191. }
  192. // Image-specific checks
  193. if ( $this->oldTitle->inNamespace( NS_FILE ) ) {
  194. $status->merge( $this->isValidFileMove() );
  195. }
  196. if ( $this->newTitle->inNamespace( NS_FILE ) && !$this->oldTitle->inNamespace( NS_FILE ) ) {
  197. $status->fatal( 'nonfile-cannot-move-to-file' );
  198. }
  199. // Hook for extensions to say a title can't be moved for technical reasons
  200. Hooks::run( 'MovePageIsValidMove', [ $this->oldTitle, $this->newTitle, $status ] );
  201. return $status;
  202. }
  203. /**
  204. * Sanity checks for when a file is being moved
  205. *
  206. * @return Status
  207. */
  208. protected function isValidFileMove() {
  209. $status = new Status();
  210. if ( !$this->newTitle->inNamespace( NS_FILE ) ) {
  211. $status->fatal( 'imagenocrossnamespace' );
  212. // No need for further errors about the target filename being wrong
  213. return $status;
  214. }
  215. $file = $this->repoGroup->getLocalRepo()->newFile( $this->oldTitle );
  216. $file->load( File::READ_LATEST );
  217. if ( $file->exists() ) {
  218. if ( $this->newTitle->getText() != wfStripIllegalFilenameChars( $this->newTitle->getText() ) ) {
  219. $status->fatal( 'imageinvalidfilename' );
  220. }
  221. if ( !File::checkExtensionCompatibility( $file, $this->newTitle->getDBkey() ) ) {
  222. $status->fatal( 'imagetypemismatch' );
  223. }
  224. }
  225. return $status;
  226. }
  227. /**
  228. * Checks if $this can be moved to a given Title
  229. * - Selects for update, so don't call it unless you mean business
  230. *
  231. * @since 1.25
  232. * @return bool
  233. */
  234. protected function isValidMoveTarget() {
  235. # Is it an existing file?
  236. if ( $this->newTitle->inNamespace( NS_FILE ) ) {
  237. $file = $this->repoGroup->getLocalRepo()->newFile( $this->newTitle );
  238. $file->load( File::READ_LATEST );
  239. if ( $file->exists() ) {
  240. wfDebug( __METHOD__ . ": file exists\n" );
  241. return false;
  242. }
  243. }
  244. # Is it a redirect with no history?
  245. if ( !$this->newTitle->isSingleRevRedirect() ) {
  246. wfDebug( __METHOD__ . ": not a one-rev redirect\n" );
  247. return false;
  248. }
  249. # Get the article text
  250. $rev = Revision::newFromTitle( $this->newTitle, false, Revision::READ_LATEST );
  251. if ( !is_object( $rev ) ) {
  252. return false;
  253. }
  254. $content = $rev->getContent();
  255. # Does the redirect point to the source?
  256. # Or is it a broken self-redirect, usually caused by namespace collisions?
  257. $redirTitle = $content ? $content->getRedirectTarget() : null;
  258. if ( $redirTitle ) {
  259. if ( $redirTitle->getPrefixedDBkey() !== $this->oldTitle->getPrefixedDBkey() &&
  260. $redirTitle->getPrefixedDBkey() !== $this->newTitle->getPrefixedDBkey() ) {
  261. wfDebug( __METHOD__ . ": redirect points to other page\n" );
  262. return false;
  263. } else {
  264. return true;
  265. }
  266. } else {
  267. # Fail safe (not a redirect after all. strange.)
  268. wfDebug( __METHOD__ . ": failsafe: database says " . $this->newTitle->getPrefixedDBkey() .
  269. " is a redirect, but it doesn't contain a valid redirect.\n" );
  270. return false;
  271. }
  272. }
  273. /**
  274. * Move a page without taking user permissions into account. Only checks if the move is itself
  275. * invalid, e.g., trying to move a special page or trying to move a page onto one that already
  276. * exists.
  277. *
  278. * @param User $user
  279. * @param string|null $reason
  280. * @param bool|null $createRedirect
  281. * @param string[] $changeTags Change tags to apply to the entry in the move log
  282. * @return Status
  283. */
  284. public function move(
  285. User $user, $reason = null, $createRedirect = true, array $changeTags = []
  286. ) {
  287. $status = $this->isValidMove();
  288. if ( !$status->isOK() ) {
  289. return $status;
  290. }
  291. return $this->moveUnsafe( $user, $reason, $createRedirect, $changeTags );
  292. }
  293. /**
  294. * Same as move(), but with permissions checks.
  295. *
  296. * @param User $user
  297. * @param string|null $reason
  298. * @param bool|null $createRedirect Ignored if user doesn't have suppressredirect permission
  299. * @param string[] $changeTags Change tags to apply to the entry in the move log
  300. * @return Status
  301. */
  302. public function moveIfAllowed(
  303. User $user, $reason = null, $createRedirect = true, array $changeTags = []
  304. ) {
  305. $status = $this->isValidMove();
  306. $status->merge( $this->checkPermissions( $user, $reason ) );
  307. if ( $changeTags ) {
  308. $status->merge( ChangeTags::canAddTagsAccompanyingChange( $changeTags, $user ) );
  309. }
  310. if ( !$status->isOK() ) {
  311. // Auto-block user's IP if the account was "hard" blocked
  312. $user->spreadAnyEditBlock();
  313. return $status;
  314. }
  315. // Check suppressredirect permission
  316. $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
  317. if ( !$permissionManager->userHasRight( $user, 'suppressredirect' ) ) {
  318. $createRedirect = true;
  319. }
  320. return $this->moveUnsafe( $user, $reason, $createRedirect, $changeTags );
  321. }
  322. /**
  323. * Move the source page's subpages to be subpages of the target page, without checking user
  324. * permissions. The caller is responsible for moving the source page itself. We will still not
  325. * do moves that are inherently not allowed, nor will we move more than $wgMaximumMovedPages.
  326. *
  327. * @param User $user
  328. * @param string|null $reason The reason for the move
  329. * @param bool|null $createRedirect Whether to create redirects from the old subpages to
  330. * the new ones
  331. * @param string[] $changeTags Applied to entries in the move log and redirect page revision
  332. * @return Status Good if no errors occurred. Ok if at least one page succeeded. The "value"
  333. * of the top-level status is an array containing the per-title status for each page. For any
  334. * move that succeeded, the "value" of the per-title status is the new page title.
  335. */
  336. public function moveSubpages(
  337. User $user, $reason = null, $createRedirect = true, array $changeTags = []
  338. ) {
  339. return $this->moveSubpagesInternal( false, $user, $reason, $createRedirect, $changeTags );
  340. }
  341. /**
  342. * Move the source page's subpages to be subpages of the target page, with user permission
  343. * checks. The caller is responsible for moving the source page itself.
  344. *
  345. * @param User $user
  346. * @param string|null $reason The reason for the move
  347. * @param bool|null $createRedirect Whether to create redirects from the old subpages to
  348. * the new ones. Ignored if the user doesn't have the 'suppressredirect' right.
  349. * @param string[] $changeTags Applied to entries in the move log and redirect page revision
  350. * @return Status Good if no errors occurred. Ok if at least one page succeeded. The "value"
  351. * of the top-level status is an array containing the per-title status for each page. For any
  352. * move that succeeded, the "value" of the per-title status is the new page title.
  353. */
  354. public function moveSubpagesIfAllowed(
  355. User $user, $reason = null, $createRedirect = true, array $changeTags = []
  356. ) {
  357. return $this->moveSubpagesInternal( true, $user, $reason, $createRedirect, $changeTags );
  358. }
  359. /**
  360. * @param bool $checkPermissions
  361. * @param User $user
  362. * @param string $reason
  363. * @param bool $createRedirect
  364. * @param array $changeTags
  365. * @return Status
  366. */
  367. private function moveSubpagesInternal(
  368. $checkPermissions, User $user, $reason, $createRedirect, array $changeTags
  369. ) {
  370. global $wgMaximumMovedPages;
  371. $services = MediaWikiServices::getInstance();
  372. if ( $checkPermissions ) {
  373. if ( !$services->getPermissionManager()->userCan(
  374. 'move-subpages', $user, $this->oldTitle )
  375. ) {
  376. return Status::newFatal( 'cant-move-subpages' );
  377. }
  378. }
  379. $nsInfo = $services->getNamespaceInfo();
  380. // Do the source and target namespaces support subpages?
  381. if ( !$nsInfo->hasSubpages( $this->oldTitle->getNamespace() ) ) {
  382. return Status::newFatal( 'namespace-nosubpages',
  383. $nsInfo->getCanonicalName( $this->oldTitle->getNamespace() ) );
  384. }
  385. if ( !$nsInfo->hasSubpages( $this->newTitle->getNamespace() ) ) {
  386. return Status::newFatal( 'namespace-nosubpages',
  387. $nsInfo->getCanonicalName( $this->newTitle->getNamespace() ) );
  388. }
  389. // Return a status for the overall result. Its value will be an array with per-title
  390. // status for each subpage. Merge any errors from the per-title statuses into the
  391. // top-level status without resetting the overall result.
  392. $topStatus = Status::newGood();
  393. $perTitleStatus = [];
  394. $subpages = $this->oldTitle->getSubpages( $wgMaximumMovedPages + 1 );
  395. $count = 0;
  396. foreach ( $subpages as $oldSubpage ) {
  397. $count++;
  398. if ( $count > $wgMaximumMovedPages ) {
  399. $status = Status::newFatal( 'movepage-max-pages', $wgMaximumMovedPages );
  400. $perTitleStatus[$oldSubpage->getPrefixedText()] = $status;
  401. $topStatus->merge( $status );
  402. $topStatus->setOK( true );
  403. break;
  404. }
  405. // We don't know whether this function was called before or after moving the root page,
  406. // so check both titles
  407. if ( $oldSubpage->getArticleID() == $this->oldTitle->getArticleID() ||
  408. $oldSubpage->getArticleID() == $this->newTitle->getArticleID()
  409. ) {
  410. // When moving a page to a subpage of itself, don't move it twice
  411. continue;
  412. }
  413. $newPageName = preg_replace(
  414. '#^' . preg_quote( $this->oldTitle->getDBkey(), '#' ) . '#',
  415. StringUtils::escapeRegexReplacement( $this->newTitle->getDBkey() ), # T23234
  416. $oldSubpage->getDBkey() );
  417. if ( $oldSubpage->isTalkPage() ) {
  418. $newNs = $this->newTitle->getTalkPage()->getNamespace();
  419. } else {
  420. $newNs = $this->newTitle->getSubjectPage()->getNamespace();
  421. }
  422. // T16385: we need makeTitleSafe because the new page names may be longer than 255
  423. // characters.
  424. $newSubpage = Title::makeTitleSafe( $newNs, $newPageName );
  425. $mp = new MovePage( $oldSubpage, $newSubpage );
  426. $method = $checkPermissions ? 'moveIfAllowed' : 'move';
  427. /** @var Status $status */
  428. $status = $mp->$method( $user, $reason, $createRedirect, $changeTags );
  429. if ( $status->isOK() ) {
  430. $status->setResult( true, $newSubpage->getPrefixedText() );
  431. }
  432. $perTitleStatus[$oldSubpage->getPrefixedText()] = $status;
  433. $topStatus->merge( $status );
  434. $topStatus->setOK( true );
  435. }
  436. $topStatus->value = $perTitleStatus;
  437. return $topStatus;
  438. }
  439. /**
  440. * Moves *without* any sort of safety or sanity checks. Hooks can still fail the move, however.
  441. *
  442. * @param User $user
  443. * @param string $reason
  444. * @param bool $createRedirect
  445. * @param string[] $changeTags Change tags to apply to the entry in the move log
  446. * @return Status
  447. */
  448. private function moveUnsafe( User $user, $reason, $createRedirect, array $changeTags ) {
  449. $status = Status::newGood();
  450. Hooks::run( 'TitleMove', [ $this->oldTitle, $this->newTitle, $user, $reason, &$status ] );
  451. if ( !$status->isOK() ) {
  452. // Move was aborted by the hook
  453. return $status;
  454. }
  455. $dbw = $this->loadBalancer->getConnection( DB_MASTER );
  456. $dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
  457. Hooks::run( 'TitleMoveStarting', [ $this->oldTitle, $this->newTitle, $user ] );
  458. $pageid = $this->oldTitle->getArticleID( Title::READ_LATEST );
  459. $protected = $this->oldTitle->isProtected();
  460. // Do the actual move; if this fails, it will throw an MWException(!)
  461. $nullRevision = $this->moveToInternal( $user, $this->newTitle, $reason, $createRedirect,
  462. $changeTags );
  463. // Refresh the sortkey for this row. Be careful to avoid resetting
  464. // cl_timestamp, which may disturb time-based lists on some sites.
  465. // @todo This block should be killed, it's duplicating code
  466. // from LinksUpdate::getCategoryInsertions() and friends.
  467. $prefixes = $dbw->select(
  468. 'categorylinks',
  469. [ 'cl_sortkey_prefix', 'cl_to' ],
  470. [ 'cl_from' => $pageid ],
  471. __METHOD__
  472. );
  473. $type = $this->nsInfo->getCategoryLinkType( $this->newTitle->getNamespace() );
  474. foreach ( $prefixes as $prefixRow ) {
  475. $prefix = $prefixRow->cl_sortkey_prefix;
  476. $catTo = $prefixRow->cl_to;
  477. $dbw->update( 'categorylinks',
  478. [
  479. 'cl_sortkey' => Collation::singleton()->getSortKey(
  480. $this->newTitle->getCategorySortkey( $prefix ) ),
  481. 'cl_collation' => $this->options->get( 'CategoryCollation' ),
  482. 'cl_type' => $type,
  483. 'cl_timestamp=cl_timestamp' ],
  484. [
  485. 'cl_from' => $pageid,
  486. 'cl_to' => $catTo ],
  487. __METHOD__
  488. );
  489. }
  490. $redirid = $this->oldTitle->getArticleID();
  491. if ( $protected ) {
  492. # Protect the redirect title as the title used to be...
  493. $res = $dbw->select(
  494. 'page_restrictions',
  495. [ 'pr_type', 'pr_level', 'pr_cascade', 'pr_user', 'pr_expiry' ],
  496. [ 'pr_page' => $pageid ],
  497. __METHOD__,
  498. 'FOR UPDATE'
  499. );
  500. $rowsInsert = [];
  501. foreach ( $res as $row ) {
  502. $rowsInsert[] = [
  503. 'pr_page' => $redirid,
  504. 'pr_type' => $row->pr_type,
  505. 'pr_level' => $row->pr_level,
  506. 'pr_cascade' => $row->pr_cascade,
  507. 'pr_user' => $row->pr_user,
  508. 'pr_expiry' => $row->pr_expiry
  509. ];
  510. }
  511. $dbw->insert( 'page_restrictions', $rowsInsert, __METHOD__, [ 'IGNORE' ] );
  512. // Build comment for log
  513. $comment = wfMessage(
  514. 'prot_1movedto2',
  515. $this->oldTitle->getPrefixedText(),
  516. $this->newTitle->getPrefixedText()
  517. )->inContentLanguage()->text();
  518. if ( $reason ) {
  519. $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
  520. }
  521. // reread inserted pr_ids for log relation
  522. $insertedPrIds = $dbw->select(
  523. 'page_restrictions',
  524. 'pr_id',
  525. [ 'pr_page' => $redirid ],
  526. __METHOD__
  527. );
  528. $logRelationsValues = [];
  529. foreach ( $insertedPrIds as $prid ) {
  530. $logRelationsValues[] = $prid->pr_id;
  531. }
  532. // Update the protection log
  533. $logEntry = new ManualLogEntry( 'protect', 'move_prot' );
  534. $logEntry->setTarget( $this->newTitle );
  535. $logEntry->setComment( $comment );
  536. $logEntry->setPerformer( $user );
  537. $logEntry->setParameters( [
  538. '4::oldtitle' => $this->oldTitle->getPrefixedText(),
  539. ] );
  540. $logEntry->setRelations( [ 'pr_id' => $logRelationsValues ] );
  541. $logEntry->addTags( $changeTags );
  542. $logId = $logEntry->insert();
  543. $logEntry->publish( $logId );
  544. }
  545. // Update *_from_namespace fields as needed
  546. if ( $this->oldTitle->getNamespace() != $this->newTitle->getNamespace() ) {
  547. $dbw->update( 'pagelinks',
  548. [ 'pl_from_namespace' => $this->newTitle->getNamespace() ],
  549. [ 'pl_from' => $pageid ],
  550. __METHOD__
  551. );
  552. $dbw->update( 'templatelinks',
  553. [ 'tl_from_namespace' => $this->newTitle->getNamespace() ],
  554. [ 'tl_from' => $pageid ],
  555. __METHOD__
  556. );
  557. $dbw->update( 'imagelinks',
  558. [ 'il_from_namespace' => $this->newTitle->getNamespace() ],
  559. [ 'il_from' => $pageid ],
  560. __METHOD__
  561. );
  562. }
  563. # Update watchlists
  564. $oldtitle = $this->oldTitle->getDBkey();
  565. $newtitle = $this->newTitle->getDBkey();
  566. $oldsnamespace = $this->nsInfo->getSubject( $this->oldTitle->getNamespace() );
  567. $newsnamespace = $this->nsInfo->getSubject( $this->newTitle->getNamespace() );
  568. if ( $oldsnamespace != $newsnamespace || $oldtitle != $newtitle ) {
  569. $this->watchedItems->duplicateAllAssociatedEntries( $this->oldTitle, $this->newTitle );
  570. }
  571. // If it is a file then move it last.
  572. // This is done after all database changes so that file system errors cancel the transaction.
  573. if ( $this->oldTitle->getNamespace() == NS_FILE ) {
  574. $status = $this->moveFile( $this->oldTitle, $this->newTitle );
  575. if ( !$status->isOK() ) {
  576. $dbw->cancelAtomic( __METHOD__ );
  577. return $status;
  578. }
  579. }
  580. Hooks::run(
  581. 'TitleMoveCompleting',
  582. [ $this->oldTitle, $this->newTitle,
  583. $user, $pageid, $redirid, $reason, $nullRevision ]
  584. );
  585. $dbw->endAtomic( __METHOD__ );
  586. $params = [
  587. &$this->oldTitle,
  588. &$this->newTitle,
  589. &$user,
  590. $pageid,
  591. $redirid,
  592. $reason,
  593. $nullRevision
  594. ];
  595. // Keep each single hook handler atomic
  596. DeferredUpdates::addUpdate(
  597. new AtomicSectionUpdate(
  598. $dbw,
  599. __METHOD__,
  600. // Hold onto $user to avoid HHVM bug where it no longer
  601. // becomes a reference (T118683)
  602. function () use ( $params, &$user ) {
  603. Hooks::run( 'TitleMoveComplete', $params );
  604. }
  605. )
  606. );
  607. return Status::newGood();
  608. }
  609. /**
  610. * Move a file associated with a page to a new location.
  611. * Can also be used to revert after a DB failure.
  612. *
  613. * @private
  614. * @param Title $oldTitle Old location to move the file from.
  615. * @param Title $newTitle New location to move the file to.
  616. * @return Status
  617. */
  618. private function moveFile( $oldTitle, $newTitle ) {
  619. $status = Status::newFatal(
  620. 'cannotdelete',
  621. $oldTitle->getPrefixedText()
  622. );
  623. $file = $this->repoGroup->getLocalRepo()->newFile( $oldTitle );
  624. $file->load( File::READ_LATEST );
  625. if ( $file->exists() ) {
  626. $status = $file->move( $newTitle );
  627. }
  628. // Clear RepoGroup process cache
  629. $this->repoGroup->clearCache( $oldTitle );
  630. $this->repoGroup->clearCache( $newTitle ); # clear false negative cache
  631. return $status;
  632. }
  633. /**
  634. * Move page to a title which is either a redirect to the
  635. * source page or nonexistent
  636. *
  637. * @todo This was basically directly moved from Title, it should be split into
  638. * smaller functions
  639. * @param User $user the User doing the move
  640. * @param Title $nt The page to move to, which should be a redirect or non-existent
  641. * @param string $reason The reason for the move
  642. * @param bool $createRedirect Whether to leave a redirect at the old title. Does not check
  643. * if the user has the suppressredirect right
  644. * @param string[] $changeTags Change tags to apply to the entry in the move log
  645. * @return Revision the revision created by the move
  646. * @throws MWException
  647. */
  648. private function moveToInternal( User $user, &$nt, $reason = '', $createRedirect = true,
  649. array $changeTags = []
  650. ) {
  651. if ( $nt->exists() ) {
  652. $moveOverRedirect = true;
  653. $logType = 'move_redir';
  654. } else {
  655. $moveOverRedirect = false;
  656. $logType = 'move';
  657. }
  658. if ( $moveOverRedirect ) {
  659. $overwriteMessage = wfMessage(
  660. 'delete_and_move_reason',
  661. $this->oldTitle->getPrefixedText()
  662. )->inContentLanguage()->text();
  663. $newpage = WikiPage::factory( $nt );
  664. $errs = [];
  665. $status = $newpage->doDeleteArticleReal(
  666. $overwriteMessage,
  667. /* $suppress */ false,
  668. $nt->getArticleID(),
  669. /* $commit */ false,
  670. $errs,
  671. $user,
  672. $changeTags,
  673. 'delete_redir'
  674. );
  675. if ( !$status->isGood() ) {
  676. throw new MWException( 'Failed to delete page-move revision: '
  677. . $status->getWikiText( false, false, 'en' ) );
  678. }
  679. $nt->resetArticleID( false );
  680. }
  681. if ( $createRedirect ) {
  682. if ( $this->oldTitle->getNamespace() == NS_CATEGORY
  683. && !wfMessage( 'category-move-redirect-override' )->inContentLanguage()->isDisabled()
  684. ) {
  685. $redirectContent = new WikitextContent(
  686. wfMessage( 'category-move-redirect-override' )
  687. ->params( $nt->getPrefixedText() )->inContentLanguage()->plain() );
  688. } else {
  689. $contentHandler = ContentHandler::getForTitle( $this->oldTitle );
  690. $redirectContent = $contentHandler->makeRedirectContent( $nt,
  691. wfMessage( 'move-redirect-text' )->inContentLanguage()->plain() );
  692. }
  693. // NOTE: If this page's content model does not support redirects, $redirectContent will be null.
  694. } else {
  695. $redirectContent = null;
  696. }
  697. // Figure out whether the content model is no longer the default
  698. $oldDefault = ContentHandler::getDefaultModelFor( $this->oldTitle );
  699. $contentModel = $this->oldTitle->getContentModel();
  700. $newDefault = ContentHandler::getDefaultModelFor( $nt );
  701. $defaultContentModelChanging = ( $oldDefault !== $newDefault
  702. && $oldDefault === $contentModel );
  703. // T59084: log_page should be the ID of the *moved* page
  704. $oldid = $this->oldTitle->getArticleID();
  705. $logTitle = clone $this->oldTitle;
  706. $logEntry = new ManualLogEntry( 'move', $logType );
  707. $logEntry->setPerformer( $user );
  708. $logEntry->setTarget( $logTitle );
  709. $logEntry->setComment( $reason );
  710. $logEntry->setParameters( [
  711. '4::target' => $nt->getPrefixedText(),
  712. '5::noredir' => $redirectContent ? '0' : '1',
  713. ] );
  714. $formatter = LogFormatter::newFromEntry( $logEntry );
  715. $formatter->setContext( RequestContext::newExtraneousContext( $this->oldTitle ) );
  716. $comment = $formatter->getPlainActionText();
  717. if ( $reason ) {
  718. $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
  719. }
  720. $dbw = $this->loadBalancer->getConnection( DB_MASTER );
  721. $oldpage = WikiPage::factory( $this->oldTitle );
  722. $oldcountable = $oldpage->isCountable();
  723. $newpage = WikiPage::factory( $nt );
  724. # Change the name of the target page:
  725. $dbw->update( 'page',
  726. /* SET */ [
  727. 'page_namespace' => $nt->getNamespace(),
  728. 'page_title' => $nt->getDBkey(),
  729. ],
  730. /* WHERE */ [ 'page_id' => $oldid ],
  731. __METHOD__
  732. );
  733. # Save a null revision in the page's history notifying of the move
  734. $nullRevision = Revision::newNullRevision( $dbw, $oldid, $comment, true, $user );
  735. if ( !is_object( $nullRevision ) ) {
  736. throw new MWException( 'Failed to create null revision while moving page ID '
  737. . $oldid . ' to ' . $nt->getPrefixedDBkey() );
  738. }
  739. $nullRevId = $nullRevision->insertOn( $dbw );
  740. $logEntry->setAssociatedRevId( $nullRevId );
  741. /**
  742. * T163966
  743. * Increment user_editcount during page moves
  744. * Moved from SpecialMovepage.php per T195550
  745. */
  746. $user->incEditCount();
  747. if ( !$redirectContent ) {
  748. // Clean up the old title *before* reset article id - T47348
  749. WikiPage::onArticleDelete( $this->oldTitle );
  750. }
  751. $this->oldTitle->resetArticleID( 0 ); // 0 == non existing
  752. $nt->resetArticleID( $oldid );
  753. $newpage->loadPageData( WikiPage::READ_LOCKING ); // T48397
  754. $newpage->updateRevisionOn( $dbw, $nullRevision );
  755. Hooks::run( 'NewRevisionFromEditComplete',
  756. [ $newpage, $nullRevision, $nullRevision->getParentId(), $user ] );
  757. $newpage->doEditUpdates( $nullRevision, $user,
  758. [ 'changed' => false, 'moved' => true, 'oldcountable' => $oldcountable ] );
  759. // If the default content model changes, we need to populate rev_content_model
  760. if ( $defaultContentModelChanging ) {
  761. $dbw->update(
  762. 'revision',
  763. [ 'rev_content_model' => $contentModel ],
  764. [ 'rev_page' => $nt->getArticleID(), 'rev_content_model IS NULL' ],
  765. __METHOD__
  766. );
  767. }
  768. WikiPage::onArticleCreate( $nt );
  769. # Recreate the redirect, this time in the other direction.
  770. if ( $redirectContent ) {
  771. $redirectArticle = WikiPage::factory( $this->oldTitle );
  772. $redirectArticle->loadFromRow( false, WikiPage::READ_LOCKING ); // T48397
  773. $newid = $redirectArticle->insertOn( $dbw );
  774. if ( $newid ) { // sanity
  775. $this->oldTitle->resetArticleID( $newid );
  776. $redirectRevision = new Revision( [
  777. 'title' => $this->oldTitle, // for determining the default content model
  778. 'page' => $newid,
  779. 'user_text' => $user->getName(),
  780. 'user' => $user->getId(),
  781. 'comment' => $comment,
  782. 'content' => $redirectContent ] );
  783. $redirectRevId = $redirectRevision->insertOn( $dbw );
  784. $redirectArticle->updateRevisionOn( $dbw, $redirectRevision, 0 );
  785. Hooks::run( 'NewRevisionFromEditComplete',
  786. [ $redirectArticle, $redirectRevision, false, $user ] );
  787. $redirectArticle->doEditUpdates( $redirectRevision, $user, [ 'created' => true ] );
  788. // make a copy because of log entry below
  789. $redirectTags = $changeTags;
  790. if ( in_array( 'mw-new-redirect', ChangeTags::getSoftwareTags() ) ) {
  791. $redirectTags[] = 'mw-new-redirect';
  792. }
  793. ChangeTags::addTags( $redirectTags, null, $redirectRevId, null );
  794. }
  795. }
  796. # Log the move
  797. $logid = $logEntry->insert();
  798. $logEntry->addTags( $changeTags );
  799. $logEntry->publish( $logid );
  800. return $nullRevision;
  801. }
  802. }