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