Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.70% covered (warning)
77.70%
345 / 444
44.44% covered (danger)
44.44%
8 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
MovePage
77.70% covered (warning)
77.70%
345 / 444
44.44% covered (danger)
44.44%
8 / 18
191.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 setMaximumMovedPages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 authorizeInternal
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
6.01
 probablyCanMove
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 authorizeMove
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 checkPermissions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 isValidMove
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
18
 isValidFileMove
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
 isValidMoveTarget
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
9.26
 move
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 moveIfAllowed
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
4.77
 moveSubpages
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 moveSubpagesIfAllowed
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
2.08
 constructNoSubpagesStatus
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 moveSubpagesInternal
71.05% covered (warning)
71.05%
27 / 38
0.00% covered (danger)
0.00%
0 / 1
13.94
 moveUnsafe
53.33% covered (warning)
53.33%
56 / 105
0.00% covered (danger)
0.00%
0 / 1
23.30
 moveFile
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 moveToInternal
79.81% covered (warning)
79.81%
83 / 104
0.00% covered (danger)
0.00%
0 / 1
14.39
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Page;
8
9use MediaWiki\ChangeTags\ChangeTags;
10use MediaWiki\Collation\CollationFactory;
11use MediaWiki\Config\ServiceOptions;
12use MediaWiki\Content\ContentHandler;
13use MediaWiki\Content\IContentHandlerFactory;
14use MediaWiki\Content\WikitextContent;
15use MediaWiki\Context\RequestContext;
16use MediaWiki\DB\WriteDuplicator;
17use MediaWiki\Deferred\AtomicSectionUpdate;
18use MediaWiki\Deferred\DeferredUpdates;
19use MediaWiki\DomainEvent\DomainEventDispatcher;
20use MediaWiki\EditPage\SpamChecker;
21use MediaWiki\FileRepo\File\File;
22use MediaWiki\FileRepo\RepoGroup;
23use MediaWiki\HookContainer\HookContainer;
24use MediaWiki\HookContainer\HookRunner;
25use MediaWiki\Logging\LogFormatterFactory;
26use MediaWiki\Logging\ManualLogEntry;
27use MediaWiki\MainConfigNames;
28use MediaWiki\Page\Event\PageLatestRevisionChangedEvent;
29use MediaWiki\Page\Event\PageMovedEvent;
30use MediaWiki\Permissions\Authority;
31use MediaWiki\Permissions\PermissionStatus;
32use MediaWiki\Permissions\RestrictionStore;
33use MediaWiki\Revision\RevisionRecord;
34use MediaWiki\Revision\RevisionStore;
35use MediaWiki\Revision\SlotRecord;
36use MediaWiki\Status\Status;
37use MediaWiki\Storage\PageUpdaterFactory;
38use MediaWiki\Title\NamespaceInfo;
39use MediaWiki\Title\Title;
40use MediaWiki\Upload\UploadBase;
41use MediaWiki\User\UserEditTracker;
42use MediaWiki\User\UserFactory;
43use MediaWiki\User\UserIdentity;
44use MediaWiki\Watchlist\WatchedItemStoreInterface;
45use Wikimedia\ObjectCache\BagOStuff;
46use Wikimedia\Rdbms\IConnectionProvider;
47use Wikimedia\Rdbms\IDatabase;
48use Wikimedia\Rdbms\IDBAccessObject;
49use Wikimedia\StringUtils\StringUtils;
50
51/**
52 * Handles the backend logic of moving a page from one title
53 * to another.
54 *
55 * @since 1.24
56 */
57class MovePage {
58
59    protected Title $oldTitle;
60    protected Title $newTitle;
61    protected ServiceOptions $options;
62    protected IConnectionProvider $dbProvider;
63    protected NamespaceInfo $nsInfo;
64    protected WatchedItemStoreInterface $watchedItems;
65    protected RepoGroup $repoGroup;
66    private IContentHandlerFactory $contentHandlerFactory;
67    private BagOStuff $recentMovesCache;
68    private RevisionStore $revisionStore;
69    private SpamChecker $spamChecker;
70    private HookRunner $hookRunner;
71    private DomainEventDispatcher $eventDispatcher;
72    private WikiPageFactory $wikiPageFactory;
73    private UserFactory $userFactory;
74    private UserEditTracker $userEditTracker;
75    private MovePageFactory $movePageFactory;
76    public CollationFactory $collationFactory;
77    private PageUpdaterFactory $pageUpdaterFactory;
78    private RestrictionStore $restrictionStore;
79    private DeletePageFactory $deletePageFactory;
80    private LogFormatterFactory $logFormatterFactory;
81    private WriteDuplicator $linkWriteDuplicator;
82
83    /** @var int */
84    private $maximumMovedPages;
85
86    /**
87     * @internal For use by PageCommandFactory
88     */
89    public const CONSTRUCTOR_OPTIONS = [
90        MainConfigNames::CategoryCollation,
91        MainConfigNames::MaximumMovedPages,
92    ];
93
94    /**
95     * @see MovePageFactory
96     */
97    public function __construct(
98        PageIdentity $oldTitle,
99        PageIdentity $newTitle,
100        ServiceOptions $options,
101        IConnectionProvider $dbProvider,
102        NamespaceInfo $nsInfo,
103        WatchedItemStoreInterface $watchedItems,
104        RepoGroup $repoGroup,
105        IContentHandlerFactory $contentHandlerFactory,
106        BagOStuff $recentMovesCache,
107        RevisionStore $revisionStore,
108        SpamChecker $spamChecker,
109        HookContainer $hookContainer,
110        DomainEventDispatcher $eventDispatcher,
111        WikiPageFactory $wikiPageFactory,
112        UserFactory $userFactory,
113        UserEditTracker $userEditTracker,
114        MovePageFactory $movePageFactory,
115        CollationFactory $collationFactory,
116        PageUpdaterFactory $pageUpdaterFactory,
117        RestrictionStore $restrictionStore,
118        DeletePageFactory $deletePageFactory,
119        LogFormatterFactory $logFormatterFactory,
120        WriteDuplicator $linkWriteDuplicator
121    ) {
122        $this->oldTitle = Title::newFromPageIdentity( $oldTitle );
123        $this->newTitle = Title::newFromPageIdentity( $newTitle );
124
125        $this->options = $options;
126        $this->recentMovesCache = $recentMovesCache;
127        $this->dbProvider = $dbProvider;
128        $this->nsInfo = $nsInfo;
129        $this->watchedItems = $watchedItems;
130        $this->repoGroup = $repoGroup;
131        $this->contentHandlerFactory = $contentHandlerFactory;
132        $this->revisionStore = $revisionStore;
133        $this->spamChecker = $spamChecker;
134        $this->hookRunner = new HookRunner( $hookContainer );
135        $this->eventDispatcher = $eventDispatcher;
136        $this->wikiPageFactory = $wikiPageFactory;
137        $this->userFactory = $userFactory;
138        $this->userEditTracker = $userEditTracker;
139        $this->movePageFactory = $movePageFactory;
140        $this->collationFactory = $collationFactory;
141        $this->pageUpdaterFactory = $pageUpdaterFactory;
142        $this->restrictionStore = $restrictionStore;
143        $this->deletePageFactory = $deletePageFactory;
144        $this->logFormatterFactory = $logFormatterFactory;
145        $this->linkWriteDuplicator = $linkWriteDuplicator;
146
147        $this->maximumMovedPages = $this->options->get( MainConfigNames::MaximumMovedPages );
148    }
149
150    /**
151     * Override $wgMaximumMovedPages.
152     *
153     * @param int $max The maximum number of subpages to move, or -1 for no limit
154     */
155    public function setMaximumMovedPages( $max ) {
156        $this->maximumMovedPages = $max;
157    }
158
159    /**
160     * @param callable $authorizer ( string $action, PageIdentity $target, PermissionStatus $status )
161     * @param Authority $performer
162     * @param string|null $reason
163     * @return PermissionStatus
164     */
165    private function authorizeInternal(
166        callable $authorizer,
167        Authority $performer,
168        ?string $reason
169    ): PermissionStatus {
170        $status = PermissionStatus::newEmpty();
171
172        $authorizer( 'move', $this->oldTitle, $status );
173        $authorizer( 'edit', $this->oldTitle, $status );
174        $authorizer( 'move-target', $this->newTitle, $status );
175        $authorizer( 'edit', $this->newTitle, $status );
176
177        if ( $reason !== null && $this->spamChecker->checkSummary( $reason ) !== false ) {
178            // This is kind of lame, won't display nice
179            $status->fatal( 'spamprotectiontext' );
180        }
181
182        $tp = $this->restrictionStore->getCreateProtection( $this->newTitle );
183        if ( $tp && !$performer->isAllowed( $tp['permission'] ) ) {
184            $status->fatal( 'cantmove-titleprotected' );
185        }
186
187        // TODO: change hook signature to accept Authority and PermissionStatus
188        $user = $this->userFactory->newFromAuthority( $performer );
189        $status = Status::wrap( $status );
190        $this->hookRunner->onMovePageCheckPermissions(
191            $this->oldTitle, $this->newTitle, $user, $reason, $status );
192        // TODO: remove conversion code after hook signature is changed.
193        $permissionStatus = PermissionStatus::newEmpty();
194        foreach ( $status->getMessages() as $msg ) {
195            $permissionStatus->fatal( $msg );
196        }
197        return $permissionStatus;
198    }
199
200    /**
201     * Check whether $performer can execute the move.
202     *
203     * @note this method does not guarantee full permissions check, so it should
204     * only be used to to decide whether to show a move form. To authorize the move
205     * action use {@link self::authorizeMove} instead.
206     *
207     * @param Authority $performer
208     * @param string|null $reason
209     * @return PermissionStatus
210     */
211    public function probablyCanMove( Authority $performer, ?string $reason = null ): PermissionStatus {
212        return $this->authorizeInternal(
213            static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) {
214                return $performer->probablyCan( $action, $target, $status );
215            },
216            $performer,
217            $reason
218        );
219    }
220
221    /**
222     * Authorize the move by $performer.
223     *
224     * @note this method should be used right before the actual move is performed.
225     * To check whether a current performer has the potential to move the page,
226     * use {@link self::probablyCanMove} instead.
227     *
228     * @param Authority $performer
229     * @param string|null $reason
230     * @return PermissionStatus
231     */
232    public function authorizeMove( Authority $performer, ?string $reason = null ): PermissionStatus {
233        return $this->authorizeInternal(
234            static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) {
235                return $performer->authorizeWrite( $action, $target, $status );
236            },
237            $performer,
238            $reason
239        );
240    }
241
242    /**
243     * Check if the user is allowed to perform the move.
244     *
245     * @param Authority $performer
246     * @param string|null $reason To check against summary spam regex. Set to null to skip the check,
247     *   for instance to display errors preemptively before the user has filled in a summary.
248     * @deprecated since 1.36, use ::authorizeMove or ::probablyCanMove instead.
249     * @return Status<never>
250     */
251    public function checkPermissions( Authority $performer, $reason ) {
252        $permissionStatus = $this->authorizeInternal(
253            static function ( string $action, PageIdentity $target, PermissionStatus $status ) use ( $performer ) {
254                return $performer->definitelyCan( $action, $target, $status );
255            },
256            $performer,
257            $reason
258        );
259        return Status::wrap( $permissionStatus );
260    }
261
262    /**
263     * Does various checks that the move is
264     * valid. Only things based on the two titles
265     * should be checked here.
266     *
267     * @return Status<never>
268     */
269    public function isValidMove() {
270        $status = new Status();
271
272        if ( $this->oldTitle->equals( $this->newTitle ) ) {
273            $status->fatal( 'selfmove' );
274        } elseif ( $this->newTitle->getArticleID( IDBAccessObject::READ_LATEST /* T272386 */ )
275            && !$this->isValidMoveTarget()
276        ) {
277            // The move is allowed only if (1) the target doesn't exist, or (2) the target is a
278            // redirect to the source, and has no history (so we can undo bad moves right after
279            // they're done). If the target is a single revision redirect to a different page,
280            // it can be deleted with just `delete-redirect` rights (i.e. without needing
281            // `delete`) - see T239277
282            $fatal = $this->newTitle->isSingleRevRedirect() ? 'redirectexists' : 'articleexists';
283            $status->fatal( $fatal, $this->newTitle->getPrefixedText() );
284        }
285
286        // @todo If the old title is invalid, maybe we should check if it somehow exists in the
287        // database and allow moving it to a valid name? Why prohibit the move from an empty name
288        // without checking in the database?
289        if ( $this->oldTitle->getDBkey() == '' ) {
290            $status->fatal( 'badarticleerror' );
291        } elseif ( $this->oldTitle->isExternal() ) {
292            $status->fatal( 'immobile-source-namespace-iw' );
293        } elseif ( !$this->oldTitle->isMovable() ) {
294            $nsText = $this->oldTitle->getNsText();
295            if ( $nsText === '' ) {
296                $nsText = wfMessage( 'blanknamespace' )->text();
297            }
298            $status->fatal( 'immobile-source-namespace', $nsText );
299        } elseif ( !$this->oldTitle->exists() ) {
300            $status->fatal( 'movepage-source-doesnt-exist', $this->oldTitle->getPrefixedText() );
301        }
302
303        if ( $this->newTitle->isExternal() ) {
304            $status->fatal( 'immobile-target-namespace-iw' );
305        } elseif ( !$this->newTitle->isMovable() ) {
306            $nsText = $this->newTitle->getNsText();
307            if ( $nsText === '' ) {
308                $nsText = wfMessage( 'blanknamespace' )->text();
309            }
310            $status->fatal( 'immobile-target-namespace', $nsText );
311        }
312        if ( !$this->newTitle->isValid() ) {
313            $status->fatal( 'movepage-invalid-target-title' );
314        }
315
316        // Content model checks
317        if ( !$this->contentHandlerFactory
318            ->getContentHandler( $this->oldTitle->getContentModel() )
319            ->canBeUsedOn( $this->newTitle )
320        ) {
321            $status->fatal(
322                'content-not-allowed-here',
323                ContentHandler::getLocalizedName( $this->oldTitle->getContentModel() ),
324                $this->newTitle->getPrefixedText(),
325                SlotRecord::MAIN
326            );
327        }
328
329        // Image-specific checks
330        if ( $this->oldTitle->inNamespace( NS_FILE ) ) {
331            $status->merge( $this->isValidFileMove() );
332        }
333
334        if ( $this->newTitle->inNamespace( NS_FILE ) && !$this->oldTitle->inNamespace( NS_FILE ) ) {
335            $status->fatal( 'nonfile-cannot-move-to-file' );
336        }
337
338        // Hook for extensions to say a title can't be moved for technical reasons
339        $this->hookRunner->onMovePageIsValidMove( $this->oldTitle, $this->newTitle, $status );
340
341        return $status;
342    }
343
344    /**
345     * Checks for when a file is being moved
346     *
347     * @see UploadBase::getTitle
348     * @return Status<never>
349     */
350    protected function isValidFileMove() {
351        $status = new Status();
352
353        if ( !$this->newTitle->inNamespace( NS_FILE ) ) {
354            // No need for further errors about the target filename being wrong
355            return $status->fatal( 'imagenocrossnamespace' );
356        }
357
358        $file = $this->repoGroup->getLocalRepo()->newFile( $this->oldTitle );
359        $file->load( IDBAccessObject::READ_LATEST );
360        if ( $file->exists() ) {
361            if ( $this->newTitle->getText() != wfStripIllegalFilenameChars( $this->newTitle->getText() ) ) {
362                $status->fatal( 'imageinvalidfilename' );
363            }
364            if ( strlen( $this->newTitle->getText() ) > 240 ) {
365                $status->fatal( 'filename-toolong' );
366            }
367            if (
368                !$this->repoGroup->getLocalRepo()->backendSupportsUnicodePaths() &&
369                !preg_match( '/^[\x0-\x7f]*$/', $this->newTitle->getText() )
370            ) {
371                $status->fatal( 'windows-nonascii-filename' );
372            }
373            if ( strrpos( $this->newTitle->getText(), '.' ) === 0 ) {
374                // Filename cannot only be its extension
375                // Will probably fail the next check too.
376                $status->fatal( 'filename-tooshort' );
377            }
378            if ( !File::checkExtensionCompatibility( $file, $this->newTitle->getDBkey() ) ) {
379                $status->fatal( 'imagetypemismatch' );
380            }
381        }
382
383        return $status;
384    }
385
386    /**
387     * Checks if $this can be moved to a given Title
388     * - Selects for update, so don't call it unless you mean business
389     *
390     * @since 1.25
391     * @return bool
392     */
393    protected function isValidMoveTarget() {
394        # Is it an existing file?
395        if ( $this->newTitle->inNamespace( NS_FILE ) ) {
396            $file = $this->repoGroup->getLocalRepo()->newFile( $this->newTitle );
397            $file->load( IDBAccessObject::READ_LATEST );
398            if ( $file->exists() ) {
399                wfDebug( __METHOD__ . ": file exists" );
400                return false;
401            }
402        }
403        # Is it a redirect with no history?
404        if ( !$this->newTitle->isSingleRevRedirect() ) {
405            wfDebug( __METHOD__ . ": not a one-rev redirect" );
406            return false;
407        }
408        # Get the article text
409        $rev = $this->revisionStore->getRevisionByTitle(
410            $this->newTitle,
411            0,
412            IDBAccessObject::READ_LATEST
413        );
414        if ( !$rev ) {
415            return false;
416        }
417        $content = $rev->getContent( SlotRecord::MAIN );
418        # Does the redirect point to the source?
419        # Or is it a broken self-redirect, usually caused by namespace collisions?
420        $redirTitle = $content ? $content->getRedirectTarget() : null;
421
422        if ( $redirTitle ) {
423            if ( $redirTitle->getPrefixedDBkey() !== $this->oldTitle->getPrefixedDBkey() &&
424                $redirTitle->getPrefixedDBkey() !== $this->newTitle->getPrefixedDBkey() ) {
425                wfDebug( __METHOD__ . ": redirect points to other page" );
426                return false;
427            } else {
428                return true;
429            }
430        } else {
431            # Fail safe (not a redirect after all. strange.)
432            wfDebug( __METHOD__ . ": failsafe: database says " . $this->newTitle->getPrefixedDBkey() .
433                " is a redirect, but it doesn't contain a valid redirect." );
434            return false;
435        }
436    }
437
438    /**
439     * Move a page without taking user permissions into account. Only checks if the move is itself
440     * invalid, e.g., trying to move a special page or trying to move a page onto one that already
441     * exists.
442     *
443     * @param UserIdentity $user
444     * @param string|null $reason
445     * @param bool|null $createRedirect
446     * @param string[] $changeTags Change tags to apply to the entry in the move log
447     * @phpcs:ignore Generic.Files.LineLength.TooLong
448     * @return Status<array{nullRevision: RevisionRecord, redirectRevision: ?RevisionRecord, redirectPage: ?ExistingPageRecord}>
449     *  See {@link moveToInternal()}
450     */
451    public function move(
452        UserIdentity $user, $reason = null, $createRedirect = true, array $changeTags = []
453    ) {
454        $status = $this->isValidMove();
455        if ( !$status->isOK() ) {
456            return $status;
457        }
458
459        return $this->moveUnsafe( $user, $reason ?? '', $createRedirect, $changeTags );
460    }
461
462    /**
463     * Same as move(), but with permissions checks.
464     *
465     * @param Authority $performer
466     * @param string|null $reason
467     * @param bool $createRedirect Ignored if user doesn't have suppressredirect permission
468     * @param string[] $changeTags Change tags to apply to the entry in the move log
469     * @phpcs:ignore Generic.Files.LineLength.TooLong
470     * @return Status<array{nullRevision: RevisionRecord, redirectRevision: ?RevisionRecord, redirectPage: ?ExistingPageRecord}>
471     *  See {@link moveToInternal()}
472     */
473    public function moveIfAllowed(
474        Authority $performer, $reason = null, $createRedirect = true, array $changeTags = []
475    ) {
476        $status = $this->isValidMove();
477        $status->merge( $this->authorizeMove( $performer, $reason ) );
478        if ( $changeTags ) {
479            $status->merge( ChangeTags::canAddTagsAccompanyingChange( $changeTags, $performer ) );
480        }
481
482        if ( !$status->isOK() ) {
483            // TODO: wrap block spreading into Authority side-effect?
484            $user = $this->userFactory->newFromAuthority( $performer );
485            // Auto-block user's IP if the account was "hard" blocked
486            $user->spreadAnyEditBlock();
487            return $status;
488        }
489
490        // Check suppressredirect permission
491        if ( !$performer->isAllowed( 'suppressredirect' ) ) {
492            $createRedirect = true;
493        }
494
495        return $this->moveUnsafe( $performer->getUser(), $reason ?? '', $createRedirect, $changeTags );
496    }
497
498    /**
499     * Move the source page's subpages to be subpages of the target page, without checking user
500     * permissions. The caller is responsible for moving the source page itself. We will still not
501     * do moves that are inherently not allowed, nor will we move more than $wgMaximumMovedPages.
502     *
503     * @param UserIdentity $user
504     * @param string|null $reason The reason for the move
505     * @param bool|null $createRedirect Whether to create redirects from the old subpages to
506     *  the new ones
507     * @param string[] $changeTags Applied to entries in the move log and redirect page revision
508     * @return Status<array<string,Status<string>>> See {@link moveSubpagesInternal()}
509     */
510    public function moveSubpages(
511        UserIdentity $user, $reason = null, $createRedirect = true, array $changeTags = []
512    ) {
513        return $this->moveSubpagesInternal(
514            function ( Title $oldSubpage, Title $newSubpage )
515            use ( $user, $reason, $createRedirect, $changeTags ) {
516                $mp = $this->movePageFactory->newMovePage( $oldSubpage, $newSubpage );
517                return $mp->move( $user, $reason, $createRedirect, $changeTags );
518            }
519        );
520    }
521
522    /**
523     * Move the source page's subpages to be subpages of the target page, with user permission
524     * checks. The caller is responsible for moving the source page itself.
525     *
526     * @param Authority $performer
527     * @param string|null $reason The reason for the move
528     * @param bool|null $createRedirect Whether to create redirects from the old subpages to
529     *  the new ones. Ignored if the user doesn't have the 'suppressredirect' right.
530     * @param string[] $changeTags Applied to entries in the move log and redirect page revision
531     * @return Status<array<string,Status<string>>> See {@link moveSubpagesInternal()}
532     */
533    public function moveSubpagesIfAllowed(
534        Authority $performer, $reason = null, $createRedirect = true, array $changeTags = []
535    ) {
536        if ( !$performer->authorizeWrite( 'move-subpages', $this->oldTitle ) ) {
537            $status = Status::newFatal( 'cant-move-subpages' );
538            // No pages were moved, so the array of per-page statuses is empty
539            $status->value = [];
540            return $status;
541        }
542        return $this->moveSubpagesInternal(
543            function ( Title $oldSubpage, Title $newSubpage )
544            use ( $performer, $reason, $createRedirect, $changeTags ) {
545                $mp = $this->movePageFactory->newMovePage( $oldSubpage, $newSubpage );
546                return $mp->moveIfAllowed( $performer, $reason, $createRedirect, $changeTags );
547            }
548        );
549    }
550
551    /** @return Status<array<string,Status<string>>> */
552    private function constructNoSubpagesStatus( int $ns ): Status {
553        $status = Status::newFatal( 'namespace-nosubpages',
554            $this->nsInfo->getCanonicalName( $ns ) );
555        // No pages were moved, so the array of per-page statuses is empty
556        $status->value = [];
557        return $status;
558    }
559
560    /**
561     * @param callable $subpageMoveCallback
562     * @return Status<array<string,Status<string>>>
563     *  Good status if no errors occurred. OK status if at least one page succeeded.
564     *  The "value" of the top-level status is always set, even when errors occurred.
565     *  It is an array containing the per-title status for each page:
566     *  For any move that succeeded, the "value" of the per-title status is the new page title.
567     *  For any move that failed, the "value" of the per-title status is the title it tried to move
568     *  to, which may not be valid, e.g. if the new title would exceed the maximum title length.
569     */
570    private function moveSubpagesInternal( callable $subpageMoveCallback ) {
571        // Do the source and target namespaces support subpages?
572        if ( !$this->nsInfo->hasSubpages( $this->oldTitle->getNamespace() ) ) {
573            return $this->constructNoSubpagesStatus( $this->oldTitle->getNamespace() );
574        }
575        if ( !$this->nsInfo->hasSubpages( $this->newTitle->getNamespace() ) ) {
576            return $this->constructNoSubpagesStatus( $this->newTitle->getNamespace() );
577        }
578
579        // Return a status for the overall result. Its value will be an array with per-title
580        // status for each subpage. Merge any errors from the per-title statuses into the
581        // top-level status without resetting the overall result.
582        $max = $this->maximumMovedPages;
583        $topStatus = Status::newGood();
584        $perTitleStatus = [];
585        $subpages = $this->oldTitle->getSubpages( $max >= 0 ? $max + 1 : -1 );
586        $count = 0;
587        foreach ( $subpages as $oldSubpage ) {
588            $count++;
589            if ( $max >= 0 && $count > $max ) {
590                $status = Status::newFatal( 'movepage-max-pages', $max );
591                $perTitleStatus[$oldSubpage->getPrefixedText()] = $status;
592                $topStatus->merge( $status );
593                $topStatus->setOK( true );
594                break;
595            }
596
597            // We don't know whether this function was called before or after moving the root page,
598            // so check both titles
599            if ( $oldSubpage->getArticleID() == $this->oldTitle->getArticleID() ||
600                $oldSubpage->getArticleID() == $this->newTitle->getArticleID()
601            ) {
602                // When moving a page to a subpage of itself, don't move it twice
603                continue;
604            }
605            $newPageName = preg_replace(
606                    '#^' . preg_quote( $this->oldTitle->getDBkey(), '#' ) . '#',
607                    StringUtils::escapeRegexReplacement( $this->newTitle->getDBkey() ), # T23234
608                    $oldSubpage->getDBkey() );
609            if ( $oldSubpage->isTalkPage() ) {
610                $newNs = $this->nsInfo->getTalkPage( $this->newTitle )->getNamespace();
611            } else {
612                $newNs = $this->nsInfo->getSubjectPage( $this->newTitle )->getNamespace();
613            }
614            // T16385: we need makeTitleSafe because the new page names may be longer than 255
615            // characters.
616            $newSubpage = Title::makeTitleSafe( $newNs, $newPageName );
617            if ( !$newSubpage ) {
618                $status = Status::newFatal( 'title-invalid', $newPageName );
619                $status->value = Title::makeName( $newNs, $newPageName );
620            } else {
621                $status = $subpageMoveCallback( $oldSubpage, $newSubpage );
622                $status->value = $newSubpage->getPrefixedText();
623            }
624            $perTitleStatus[$oldSubpage->getPrefixedText()] = $status;
625            $topStatus->merge( $status );
626            $topStatus->setOK( true );
627        }
628
629        $topStatus->value = $perTitleStatus;
630        return $topStatus;
631    }
632
633    /**
634     * Moves *without* any sort of safety or other checks. Hooks can still fail the move, however.
635     *
636     * @param UserIdentity $user
637     * @param string $reason
638     * @param bool $createRedirect
639     * @param string[] $changeTags Change tags to apply to the entry in the move log
640     * @phpcs:ignore Generic.Files.LineLength.TooLong
641     * @return Status<array{nullRevision: RevisionRecord, redirectRevision: ?RevisionRecord, redirectPage: ?ExistingPageRecord}>
642     *  See {@link moveToInternal()}
643     */
644    private function moveUnsafe( UserIdentity $user, $reason, $createRedirect, array $changeTags ) {
645        $status = Status::newGood();
646
647        // TODO: make hooks accept UserIdentity
648        $userObj = $this->userFactory->newFromUserIdentity( $user );
649        $this->hookRunner->onTitleMove( $this->oldTitle, $this->newTitle, $userObj, $reason, $status );
650        if ( !$status->isOK() ) {
651            // Move was aborted by the hook
652            return $status;
653        }
654
655        $dbw = $this->dbProvider->getPrimaryDatabase();
656        $dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
657
658        $this->hookRunner->onTitleMoveStarting( $this->oldTitle, $this->newTitle, $userObj );
659
660        $pageStateBeforeMove = $this->oldTitle->toPageRecord( IDBAccessObject::READ_LATEST );
661        $pageid = $pageStateBeforeMove->getId();
662        $protected = $this->restrictionStore->isProtected( $this->oldTitle );
663
664        // Attempt the actual move
665        $moveAttemptResult = $this->moveToInternal( $user, $this->newTitle, $reason, $createRedirect,
666            $changeTags );
667
668        if ( !$moveAttemptResult->isGood() ) {
669            // T265779: Attempt to delete target page failed
670            $dbw->cancelAtomic( __METHOD__ );
671            return $moveAttemptResult;
672        } else {
673            $dummyRevision = $moveAttemptResult->getValue()['nullRevision'];
674            '@phan-var \MediaWiki\Revision\RevisionRecord $dummyRevision';
675        }
676
677        $redirid = $this->oldTitle->getArticleID();
678
679        if ( $protected ) {
680            # Protect the redirect title as the title used to be...
681            $res = $dbw->newSelectQueryBuilder()
682                ->select( [ 'pr_type', 'pr_level', 'pr_cascade', 'pr_expiry' ] )
683                ->from( 'page_restrictions' )
684                ->where( [ 'pr_page' => $pageid ] )
685                ->forUpdate()
686                ->caller( __METHOD__ )
687                ->fetchResultSet();
688            $rowsInsert = [];
689            foreach ( $res as $row ) {
690                $rowsInsert[] = [
691                    'pr_page' => $redirid,
692                    'pr_type' => $row->pr_type,
693                    'pr_level' => $row->pr_level,
694                    'pr_cascade' => $row->pr_cascade,
695                    'pr_expiry' => $row->pr_expiry
696                ];
697            }
698            if ( $rowsInsert ) {
699                $dbw->newInsertQueryBuilder()
700                    ->insertInto( 'page_restrictions' )
701                    ->ignore()
702                    ->rows( $rowsInsert )
703                    ->caller( __METHOD__ )->execute();
704            }
705
706            // Build comment for log
707            $comment = wfMessage(
708                'prot_1movedto2',
709                $this->oldTitle->getPrefixedText(),
710                $this->newTitle->getPrefixedText()
711            )->inContentLanguage()->text();
712            if ( $reason ) {
713                $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
714            }
715
716            // reread inserted pr_ids for log relation
717            $logRelationsValues = $dbw->newSelectQueryBuilder()
718                ->select( 'pr_id' )
719                ->from( 'page_restrictions' )
720                ->where( [ 'pr_page' => $redirid ] )
721                ->caller( __METHOD__ )->fetchFieldValues();
722
723            // Update the protection log
724            $logEntry = new ManualLogEntry( 'protect', 'move_prot' );
725            $logEntry->setTarget( $this->newTitle );
726            $logEntry->setComment( $comment );
727            $logEntry->setPerformer( $user );
728            $logEntry->setParameters( [
729                '4::oldtitle' => $this->oldTitle->getPrefixedText(),
730            ] );
731            $logEntry->setRelations( [ 'pr_id' => $logRelationsValues ] );
732            $logEntry->addTags( $changeTags );
733            $logId = $logEntry->insert();
734            $logEntry->publish( $logId );
735        }
736
737        # Update watchlists
738        $oldtitle = $this->oldTitle->getDBkey();
739        $newtitle = $this->newTitle->getDBkey();
740        $oldsnamespace = $this->nsInfo->getSubject( $this->oldTitle->getNamespace() );
741        $newsnamespace = $this->nsInfo->getSubject( $this->newTitle->getNamespace() );
742        if ( $oldsnamespace != $newsnamespace || $oldtitle != $newtitle ) {
743            $this->watchedItems->duplicateAllAssociatedEntries( $this->oldTitle, $this->newTitle );
744        }
745
746        // If it is a file then move it last.
747        // This is done after all database changes so that file system errors cancel the transaction.
748        if ( $this->oldTitle->getNamespace() === NS_FILE ) {
749            $status = $this->moveFile( $this->oldTitle, $this->newTitle );
750            if ( !$status->isOK() ) {
751                $dbw->cancelAtomic( __METHOD__ );
752                return $status;
753            }
754        }
755
756        $this->hookRunner->onPageMoveCompleting(
757            $this->oldTitle, $this->newTitle,
758            $user, $pageid, $redirid, $reason, $dummyRevision
759        );
760
761        // Emit an event describing the move
762        $this->eventDispatcher->dispatch( new PageMovedEvent(
763            $pageStateBeforeMove,
764            $this->newTitle->toPageRecord( IDBAccessObject::READ_LATEST ),
765            $user,
766            $reason,
767            $moveAttemptResult->getValue()['redirectPage']
768        ), $this->dbProvider );
769
770        $dbw->endAtomic( __METHOD__ );
771
772        // Keep each single hook handler atomic
773        DeferredUpdates::addUpdate(
774            new AtomicSectionUpdate(
775                $dbw,
776                __METHOD__,
777                function () use ( $user, $pageid, $redirid, $reason, $dummyRevision ) {
778                    $this->hookRunner->onPageMoveComplete(
779                        $this->oldTitle,
780                        $this->newTitle,
781                        $user,
782                        $pageid,
783                        $redirid,
784                        $reason,
785                        $dummyRevision
786                    );
787                }
788            )
789        );
790
791        return $moveAttemptResult;
792    }
793
794    /**
795     * Move a file associated with a page to a new location.
796     * Can also be used to revert after a DB failure.
797     *
798     * @internal
799     * @param Title $oldTitle Old location to move the file from.
800     * @param Title $newTitle New location to move the file to.
801     * @return Status<mixed>
802     */
803    private function moveFile( $oldTitle, $newTitle ) {
804        $file = $this->repoGroup->getLocalRepo()->newFile( $oldTitle );
805        $file->load( IDBAccessObject::READ_LATEST );
806        if ( $file->exists() ) {
807            $status = $file->move( $newTitle );
808        } else {
809            $status = Status::newGood();
810        }
811
812        // Clear RepoGroup process cache
813        $this->repoGroup->clearCache( $oldTitle );
814        $this->repoGroup->clearCache( $newTitle ); # clear false negative cache
815        return $status;
816    }
817
818    /**
819     * Move page to a title which is either a redirect to the
820     * source page or nonexistent
821     *
822     * @todo This was basically directly moved from Title, it should be split into smaller functions
823     *
824     * @param UserIdentity $user doing the move
825     * @param Title &$nt The page to move to, which should be a redirect or non-existent
826     * @param string $reason The reason for the move
827     * @param bool $createRedirect Whether to leave a redirect at the old title. Does not check
828     *  if the user has the suppressredirect right
829     * @param string[] $changeTags Change tags to apply to the entry in the move log
830     * @phpcs:ignore Generic.Files.LineLength.TooLong
831     * @return Status<array{nullRevision: RevisionRecord, redirectRevision: ?RevisionRecord, redirectPage: ?ExistingPageRecord}>
832     *  Status object. On success, the value is an array with the following keys:
833     *  - nullRevision: The dummy revision created by the move (`RevisionRecord`)
834     *  - redirectRevision: The initial revision of the redirect if it was created (`RevisionRecord|null`)
835     *  - redirectPage: The redirect page if it was created (`ExistingPageRecord|null`)
836     */
837    private function moveToInternal(
838        UserIdentity $user,
839        &$nt,
840        $reason = '',
841        $createRedirect = true,
842        array $changeTags = []
843    ): Status {
844        if ( $nt->getArticleID( IDBAccessObject::READ_LATEST ) ) {
845            $moveOverRedirect = true;
846            $logType = 'move_redir';
847        } else {
848            $moveOverRedirect = false;
849            $logType = 'move';
850        }
851
852        if ( $moveOverRedirect ) {
853            $overwriteMessage = wfMessage(
854                    'delete_and_move_reason',
855                    $this->oldTitle->getPrefixedText()
856                )->inContentLanguage()->text();
857            $newpage = $this->wikiPageFactory->newFromTitle( $nt );
858            // TODO The public methods of this class should take an Authority.
859            $moverAuthority = $this->userFactory->newFromUserIdentity( $user );
860            $deletePage = $this->deletePageFactory->newDeletePage( $newpage, $moverAuthority );
861            $status = $deletePage
862                ->setTags( $changeTags )
863                ->setLogSubtype( 'delete_redir' )
864                ->deleteUnsafe( $overwriteMessage );
865            if ( $status->isGood() && $deletePage->deletionsWereScheduled()[DeletePage::PAGE_BASE] ) {
866                // FIXME Scheduled deletion not properly handled here -- it should probably either ensure an
867                // immediate deletion or not fail if it was scheduled.
868                $status->warning( 'delete-scheduled', wfEscapeWikiText( $nt->getPrefixedText() ) );
869            }
870
871            if ( !$status->isGood() ) {
872                return $status;
873            }
874
875            $nt->resetArticleID( false );
876        }
877
878        if ( $createRedirect ) {
879            if ( $this->oldTitle->getNamespace() === NS_CATEGORY
880                && !wfMessage( 'category-move-redirect-override' )->inContentLanguage()->isDisabled()
881            ) {
882                $redirectContent = new WikitextContent(
883                    wfMessage( 'category-move-redirect-override' )
884                        ->params( $nt->getPrefixedText() )->inContentLanguage()->plain() );
885            } else {
886                $redirectContent = $this->contentHandlerFactory
887                    ->getContentHandler( $this->oldTitle->getContentModel() )
888                    ->makeRedirectContent(
889                        $nt,
890                        wfMessage( 'move-redirect-text', $nt->getPrefixedText() )->inContentLanguage()->plain()
891                    );
892            }
893
894            // NOTE: If this page's content model does not support redirects, $redirectContent will be null.
895        } else {
896            $redirectContent = null;
897        }
898
899        // T59084: log_page should be the ID of the *moved* page
900        $oldid = $this->oldTitle->getArticleID();
901        $logTitle = clone $this->oldTitle;
902
903        $logEntry = new ManualLogEntry( 'move', $logType );
904        $logEntry->setPerformer( $user );
905        $logEntry->setTarget( $logTitle );
906        $logEntry->setComment( $reason );
907        $logEntry->setParameters( [
908            '4::target' => $nt->getPrefixedText(),
909            '5::noredir' => $redirectContent ? '0' : '1',
910        ] );
911
912        $formatter = $this->logFormatterFactory->newFromEntry( $logEntry );
913        $formatter->setContext( RequestContext::newExtraneousContext( $this->oldTitle ) );
914        $comment = $formatter->getPlainActionText();
915        if ( $reason ) {
916            $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
917        }
918
919        $dbw = $this->dbProvider->getPrimaryDatabase();
920
921        $oldpage = $this->wikiPageFactory->newFromTitle( $this->oldTitle );
922        $oldcountable = $oldpage->isCountable();
923
924        $newpage = $this->wikiPageFactory->newFromTitle( $nt );
925
926        # Change the name of the target page:
927        $update = $dbw->newUpdateQueryBuilder()
928            ->update( 'page' )
929            ->set( [
930                'page_namespace' => $nt->getNamespace(),
931                'page_title' => $nt->getDBkey(),
932            ] )
933            ->where( [ 'page_id' => $oldid ] )
934            ->caller( __METHOD__ );
935        $update->execute();
936        $this->linkWriteDuplicator->duplicate( $update );
937
938        // Reset $nt before using it to create the dummy revision (T248789).
939        // But not $this->oldTitle yet, see below (T47348).
940        $nt->resetArticleID( $oldid );
941
942        // NOTE: Page moves should contribute to user edit count (T163966).
943        //       The dummy revision created below will otherwise not be counted.
944        $this->userEditTracker->incrementUserEditCount( $user );
945
946        // Get the old redirect state before clean up
947        if ( !$redirectContent ) {
948            // Clean up the old title *before* reset article id - T47348
949            WikiPage::onArticleDelete( $this->oldTitle );
950        }
951
952        $this->oldTitle->resetArticleID( 0 ); // 0 == non existing
953        $newpage->loadPageData( IDBAccessObject::READ_LOCKING ); // T48397
954
955        // Generate updates for the new dummy revision under the new title.
956        // NOTE: The dummy revision will not be counted as a user contribution.
957        // NOTE: Use FLAG_SILENT to avoid redundant RecentChanges entry.
958        //       The move log already generates one.
959        $dummyRevision = $newpage->newPageUpdater( $user )
960            ->setCause( PageLatestRevisionChangedEvent::CAUSE_MOVE )
961            ->setHints( [
962                'oldtitle' => $this->oldTitle,
963                'oldcountable' => $oldcountable,
964            ] )
965            ->saveDummyRevision( $comment, EDIT_SILENT | EDIT_MINOR );
966
967        $logEntry->setAssociatedRevId( $dummyRevision->getId() );
968
969        WikiPage::onArticleCreate( $nt );
970
971        // Recreate the redirect, this time in the other direction.
972        $redirectRevision = null;
973        $redirectArticle = null;
974        if ( $redirectContent ) {
975            $redirectArticle = $this->wikiPageFactory->newFromTitle( $this->oldTitle );
976            $redirectArticle->loadFromRow( false, IDBAccessObject::READ_LOCKING ); // T48397
977            $redirectRevision = $redirectArticle->newPageUpdater( $user )
978                ->setContent( SlotRecord::MAIN, $redirectContent )
979                ->addTags( $changeTags )
980                ->addSoftwareTag( 'mw-new-redirect' )
981                ->setUsePageCreationLog( false )
982                ->setFlags( EDIT_SILENT | EDIT_INTERNAL | EDIT_IMPLICIT )
983                ->saveRevision( $comment );
984        } else {
985            // Show log excerpt on 404 pages rather than just a link
986            $key = $this->recentMovesCache->makeKey(
987                'page-recent-delete',
988                md5( $logTitle->getPrefixedText() )
989            );
990            $this->recentMovesCache->set( $key, 1, BagOStuff::TTL_DAY );
991        }
992
993        // Log the move
994        $logid = $logEntry->insert();
995
996        $logEntry->addTags( $changeTags );
997        $logEntry->publish( $logid );
998
999        return Status::newGood( [
1000            'nullRevision' => $dummyRevision,
1001            'redirectRevision' => $redirectRevision,
1002            'redirectPage' => $redirectArticle?->toPageRecord()
1003        ] );
1004    }
1005}