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