Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.73% covered (warning)
76.73%
343 / 447
47.06% covered (danger)
47.06%
8 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
MovePage
76.91% covered (warning)
76.91%
343 / 446
47.06% covered (danger)
47.06%
8 / 17
202.83
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
75.81% covered (warning)
75.81%
94 / 124
0.00% covered (danger)
0.00%
0 / 1
16.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 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            $dbw->newInsertQueryBuilder()
679                ->insertInto( 'page_restrictions' )
680                ->ignore()
681                ->rows( $rowsInsert )
682                ->caller( __METHOD__ )->execute();
683
684            // Build comment for log
685            $comment = wfMessage(
686                'prot_1movedto2',
687                $this->oldTitle->getPrefixedText(),
688                $this->newTitle->getPrefixedText()
689            )->inContentLanguage()->text();
690            if ( $reason ) {
691                $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
692            }
693
694            // reread inserted pr_ids for log relation
695            $logRelationsValues = $dbw->newSelectQueryBuilder()
696                ->select( 'pr_id' )
697                ->from( 'page_restrictions' )
698                ->where( [ 'pr_page' => $redirid ] )
699                ->caller( __METHOD__ )->fetchFieldValues();
700
701            // Update the protection log
702            $logEntry = new ManualLogEntry( 'protect', 'move_prot' );
703            $logEntry->setTarget( $this->newTitle );
704            $logEntry->setComment( $comment );
705            $logEntry->setPerformer( $user );
706            $logEntry->setParameters( [
707                '4::oldtitle' => $this->oldTitle->getPrefixedText(),
708            ] );
709            $logEntry->setRelations( [ 'pr_id' => $logRelationsValues ] );
710            $logEntry->addTags( $changeTags );
711            $logId = $logEntry->insert();
712            $logEntry->publish( $logId );
713        }
714
715        # Update watchlists
716        $oldtitle = $this->oldTitle->getDBkey();
717        $newtitle = $this->newTitle->getDBkey();
718        $oldsnamespace = $this->nsInfo->getSubject( $this->oldTitle->getNamespace() );
719        $newsnamespace = $this->nsInfo->getSubject( $this->newTitle->getNamespace() );
720        if ( $oldsnamespace != $newsnamespace || $oldtitle != $newtitle ) {
721            $this->watchedItems->duplicateAllAssociatedEntries( $this->oldTitle, $this->newTitle );
722        }
723
724        // If it is a file then move it last.
725        // This is done after all database changes so that file system errors cancel the transaction.
726        if ( $this->oldTitle->getNamespace() === NS_FILE ) {
727            $status = $this->moveFile( $this->oldTitle, $this->newTitle );
728            if ( !$status->isOK() ) {
729                $dbw->cancelAtomic( __METHOD__ );
730                return $status;
731            }
732        }
733
734        $this->hookRunner->onPageMoveCompleting(
735            $this->oldTitle, $this->newTitle,
736            $user, $pageid, $redirid, $reason, $nullRevision
737        );
738
739        $dbw->endAtomic( __METHOD__ );
740
741        // Keep each single hook handler atomic
742        DeferredUpdates::addUpdate(
743            new AtomicSectionUpdate(
744                $dbw,
745                __METHOD__,
746                function () use ( $user, $pageid, $redirid, $reason, $nullRevision ) {
747                    $this->hookRunner->onPageMoveComplete(
748                        $this->oldTitle,
749                        $this->newTitle,
750                        $user,
751                        $pageid,
752                        $redirid,
753                        $reason,
754                        $nullRevision
755                    );
756                }
757            )
758        );
759
760        return $moveAttemptResult;
761    }
762
763    /**
764     * Move a file associated with a page to a new location.
765     * Can also be used to revert after a DB failure.
766     *
767     * @internal
768     * @param Title $oldTitle Old location to move the file from.
769     * @param Title $newTitle New location to move the file to.
770     * @return Status
771     */
772    private function moveFile( $oldTitle, $newTitle ) {
773        $file = $this->repoGroup->getLocalRepo()->newFile( $oldTitle );
774        $file->load( IDBAccessObject::READ_LATEST );
775        if ( $file->exists() ) {
776            $status = $file->move( $newTitle );
777        } else {
778            $status = Status::newGood();
779        }
780
781        // Clear RepoGroup process cache
782        $this->repoGroup->clearCache( $oldTitle );
783        $this->repoGroup->clearCache( $newTitle ); # clear false negative cache
784        return $status;
785    }
786
787    /**
788     * Move page to a title which is either a redirect to the
789     * source page or nonexistent
790     *
791     * @todo This was basically directly moved from Title, it should be split into
792     *   smaller functions
793     * @param UserIdentity $user doing the move
794     * @param Title &$nt The page to move to, which should be a redirect or non-existent
795     * @param string $reason The reason for the move
796     * @param bool $createRedirect Whether to leave a redirect at the old title. Does not check
797     *   if the user has the suppressredirect right
798     * @param string[] $changeTags Change tags to apply to the entry in the move log
799     * @return Status Status object with the following value on success:
800     *   [
801     *     'nullRevision' => The ("null") revision created by the move (RevisionRecord)
802     *     'redirectRevision' => The initial revision of the redirect if it was created (RevisionRecord|null)
803     *   ]
804     */
805    private function moveToInternal(
806        UserIdentity $user,
807        &$nt,
808        $reason = '',
809        $createRedirect = true,
810        array $changeTags = []
811    ): Status {
812        if ( $nt->getArticleID( IDBAccessObject::READ_LATEST ) ) {
813            $moveOverRedirect = true;
814            $logType = 'move_redir';
815        } else {
816            $moveOverRedirect = false;
817            $logType = 'move';
818        }
819
820        if ( $moveOverRedirect ) {
821            $overwriteMessage = wfMessage(
822                    'delete_and_move_reason',
823                    $this->oldTitle->getPrefixedText()
824                )->inContentLanguage()->text();
825            $newpage = $this->wikiPageFactory->newFromTitle( $nt );
826            // TODO The public methods of this class should take an Authority.
827            $moverAuthority = $this->userFactory->newFromUserIdentity( $user );
828            $deletePage = $this->deletePageFactory->newDeletePage( $newpage, $moverAuthority );
829            $status = $deletePage
830                ->setTags( $changeTags )
831                ->setLogSubtype( 'delete_redir' )
832                ->deleteUnsafe( $overwriteMessage );
833            if ( $status->isGood() && $deletePage->deletionsWereScheduled()[DeletePage::PAGE_BASE] ) {
834                // FIXME Scheduled deletion not properly handled here -- it should probably either ensure an
835                // immediate deletion or not fail if it was scheduled.
836                $status->warning( 'delete-scheduled', wfEscapeWikiText( $nt->getPrefixedText() ) );
837            }
838
839            if ( !$status->isGood() ) {
840                return $status;
841            }
842
843            $nt->resetArticleID( false );
844        }
845
846        if ( $createRedirect ) {
847            if ( $this->oldTitle->getNamespace() === NS_CATEGORY
848                && !wfMessage( 'category-move-redirect-override' )->inContentLanguage()->isDisabled()
849            ) {
850                $redirectContent = new WikitextContent(
851                    wfMessage( 'category-move-redirect-override' )
852                        ->params( $nt->getPrefixedText() )->inContentLanguage()->plain() );
853            } else {
854                $redirectContent = $this->contentHandlerFactory
855                    ->getContentHandler( $this->oldTitle->getContentModel() )
856                    ->makeRedirectContent(
857                        $nt,
858                        wfMessage( 'move-redirect-text' )->inContentLanguage()->plain()
859                    );
860            }
861
862            // NOTE: If this page's content model does not support redirects, $redirectContent will be null.
863        } else {
864            $redirectContent = null;
865        }
866
867        // T59084: log_page should be the ID of the *moved* page
868        $oldid = $this->oldTitle->getArticleID();
869        $logTitle = clone $this->oldTitle;
870
871        $logEntry = new ManualLogEntry( 'move', $logType );
872        $logEntry->setPerformer( $user );
873        $logEntry->setTarget( $logTitle );
874        $logEntry->setComment( $reason );
875        $logEntry->setParameters( [
876            '4::target' => $nt->getPrefixedText(),
877            '5::noredir' => $redirectContent ? '0' : '1',
878        ] );
879
880        $formatter = $this->logFormatterFactory->newFromEntry( $logEntry );
881        $formatter->setContext( RequestContext::newExtraneousContext( $this->oldTitle ) );
882        $comment = $formatter->getPlainActionText();
883        if ( $reason ) {
884            $comment .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $reason;
885        }
886
887        $dbw = $this->dbProvider->getPrimaryDatabase();
888
889        $oldpage = $this->wikiPageFactory->newFromTitle( $this->oldTitle );
890        $oldcountable = $oldpage->isCountable();
891
892        $newpage = $this->wikiPageFactory->newFromTitle( $nt );
893
894        # Change the name of the target page:
895        $dbw->newUpdateQueryBuilder()
896            ->update( 'page' )
897            ->set( [
898                'page_namespace' => $nt->getNamespace(),
899                'page_title' => $nt->getDBkey(),
900            ] )
901            ->where( [ 'page_id' => $oldid ] )
902            ->caller( __METHOD__ )->execute();
903
904        // Reset $nt before using it to create the null revision (T248789).
905        // But not $this->oldTitle yet, see below (T47348).
906        $nt->resetArticleID( $oldid );
907
908        $commentObj = CommentStoreComment::newUnsavedComment( $comment );
909        # Save a null revision in the page's history notifying of the move
910        $nullRevision = $this->revisionStore->newNullRevision(
911            $dbw,
912            $nt,
913            $commentObj,
914            true,
915            $user
916        );
917        if ( $nullRevision === null ) {
918            $id = $nt->getArticleID( IDBAccessObject::READ_EXCLUSIVE );
919            // XXX This should be handled more gracefully
920            throw new NormalizedException( 'Failed to create null revision while ' .
921                'moving page ID {oldId} to {prefixedDBkey} (page ID {id})',
922                [
923                    'oldId' => $oldid,
924                    'prefixedDBkey' => $nt->getPrefixedDBkey(),
925                    'id' => $id,
926                ]
927            );
928        }
929
930        $nullRevision = $this->revisionStore->insertRevisionOn( $nullRevision, $dbw );
931        $logEntry->setAssociatedRevId( $nullRevision->getId() );
932
933        // NOTE: Page moves should contribute to user edit count (T163966).
934        //       The dummy revision created below will otherwise not be counted.
935        $this->userEditTracker->incrementUserEditCount( $user );
936
937        // Get the old redirect state before clean up
938        $isRedirect = $this->oldTitle->isRedirect();
939        if ( !$redirectContent ) {
940            // Clean up the old title *before* reset article id - T47348
941            WikiPage::onArticleDelete( $this->oldTitle );
942        }
943
944        $this->oldTitle->resetArticleID( 0 ); // 0 == non existing
945        $newpage->loadPageData( IDBAccessObject::READ_LOCKING ); // T48397
946
947        $updater = $this->pageUpdaterFactory->newDerivedPageDataUpdater( $newpage );
948        $updater->grabCurrentRevision();
949
950        $newpage->updateRevisionOn( $dbw, $nullRevision, null, $isRedirect );
951
952        $fakeTags = [];
953        $this->hookRunner->onRevisionFromEditComplete(
954            $newpage, $nullRevision, $nullRevision->getParentId(), $user, $fakeTags );
955
956        // Generate updates for the new dummy revision under the new title.
957        // NOTE: The dummy revision will not be counted as a user contribution.
958        // NOTE: Use FLAG_SILENT to avoid redundant RecentChanges entry.
959        //       The move log already generates one.
960        $options = [
961            PageUpdatedEvent::FLAG_MOVED => true,
962            PageUpdatedEvent::FLAG_SILENT => true,
963            'oldtitle' => $this->oldTitle,
964            'oldcountable' => $oldcountable,
965            'causeAction' => 'MovePage',
966            'causeAgent' => $user->getName(),
967        ];
968
969        $updater->prepareUpdate( $nullRevision, $options );
970        $updater->doUpdates();
971
972        WikiPage::onArticleCreate( $nt );
973
974        # Recreate the redirect, this time in the other direction.
975        $redirectRevision = null;
976        if ( $redirectContent ) {
977            $redirectArticle = $this->wikiPageFactory->newFromTitle( $this->oldTitle );
978            $redirectArticle->loadFromRow( false, IDBAccessObject::READ_LOCKING ); // T48397
979            $redirectRevision = $redirectArticle->newPageUpdater( $user )
980                ->setContent( SlotRecord::MAIN, $redirectContent )
981                ->addTags( $changeTags )
982                ->addSoftwareTag( 'mw-new-redirect' )
983                ->setUsePageCreationLog( false )
984                ->setFlags( EDIT_SUPPRESS_RC | EDIT_INTERNAL )
985                ->saveRevision( $commentObj );
986        }
987
988        # Log the move
989        $logid = $logEntry->insert();
990
991        $logEntry->addTags( $changeTags );
992        $logEntry->publish( $logid );
993
994        return Status::newGood( [
995            'nullRevision' => $nullRevision,
996            'redirectRevision' => $redirectRevision,
997        ] );
998    }
999}
1000
1001/** @deprecated class alias since 1.40 */
1002class_alias( MovePage::class, 'MovePage' );