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