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