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