Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.72% covered (warning)
84.72%
122 / 144
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiMove
85.31% covered (warning)
85.31%
122 / 143
60.00% covered (warning)
60.00%
6 / 10
45.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getRestHelper
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 execute
91.67% covered (success)
91.67%
77 / 84
0.00% covered (danger)
0.00%
0 / 1
28.45
 moveSubpages
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 mustBePosted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWriteMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedParams
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2007 Roan Kattouw <roan.kattouw@gmail.com>
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Api;
10
11use LogicException;
12use MediaWiki\FileRepo\RepoGroup;
13use MediaWiki\MainConfigNames;
14use MediaWiki\Page\MovePageFactory;
15use MediaWiki\Status\Status;
16use MediaWiki\Title\Title;
17use MediaWiki\User\Options\UserOptionsLookup;
18use MediaWiki\Watchlist\WatchedItemStoreInterface;
19use MediaWiki\Watchlist\WatchlistManager;
20use Wikimedia\ParamValidator\ParamValidator;
21
22/**
23 * API Module to move pages
24 * @ingroup API
25 */
26class ApiMove extends ApiBase implements ApiRestHybrid {
27
28    use ApiWatchlistTrait;
29
30    public function __construct(
31        ApiMain $mainModule,
32        string $moduleName,
33        private readonly MovePageFactory $movePageFactory,
34        private readonly RepoGroup $repoGroup,
35        WatchlistManager $watchlistManager,
36        WatchedItemStoreInterface $watchedItemStore,
37        UserOptionsLookup $userOptionsLookup,
38    ) {
39        parent::__construct( $mainModule, $moduleName );
40
41        // Variables needed in ApiWatchlistTrait trait
42        $this->watchlistExpiryEnabled = $this->getConfig()->get( MainConfigNames::WatchlistExpiry );
43        $this->watchlistMaxDuration =
44            $this->getConfig()->get( MainConfigNames::WatchlistExpiryMaxDuration );
45        $this->watchlistManager = $watchlistManager;
46        $this->watchedItemStore = $watchedItemStore;
47        $this->userOptionsLookup = $userOptionsLookup;
48    }
49
50    public function getRestHelper(): ApiRestHelper {
51        return new class extends ApiRestHelper {
52            public function getStatusForErrorMessage( IApiMessage $msg ): int {
53                return match ( $msg->getApiCode() ) {
54                    'fileexists-sharedrepo-perm' => 403,
55                    'cantoverwrite-sharedfile' => 403,
56                    default => 0
57                };
58            }
59
60            public function getResponseBodySchemaFileName(): ?string {
61                return __DIR__ . '/Schema/Move-response.json';
62            }
63        };
64    }
65
66    public function execute() {
67        $this->useTransactionalTimeLimit();
68
69        $user = $this->getUser();
70        $params = $this->extractRequestParams();
71
72        $this->requireOnlyOneParameter( $params, 'from', 'fromid' );
73
74        if ( isset( $params['from'] ) ) {
75            $fromTitle = Title::newFromText( $params['from'] );
76            if ( !$fromTitle || $fromTitle->isExternal() ) {
77                $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['from'] ) ] );
78            }
79        } elseif ( isset( $params['fromid'] ) ) {
80            $fromTitle = Title::newFromID( $params['fromid'] );
81            if ( !$fromTitle ) {
82                $this->dieWithError( [ 'apierror-nosuchpageid', $params['fromid'] ] );
83            }
84        } else {
85            throw new LogicException( 'Unreachable due to requireOnlyOneParameter' );
86        }
87
88        if ( !$fromTitle->exists() ) {
89            $this->dieWithError( 'apierror-missingtitle' );
90        }
91        $fromTalk = $fromTitle->getTalkPage();
92
93        $toTitle = Title::newFromText( $params['to'] );
94        if ( !$toTitle || $toTitle->isExternal() ) {
95            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['to'] ) ] );
96        }
97        $toTalk = $toTitle->getTalkPageIfDefined();
98
99        if ( $toTitle->getNamespace() === NS_FILE
100            && !$this->repoGroup->getLocalRepo()->findFile( $toTitle )
101            && $this->repoGroup->findFile( $toTitle )
102        ) {
103            if ( !$params['ignorewarnings'] &&
104                $this->getAuthority()->isAllowed( 'reupload-shared' ) ) {
105                $this->dieWithError( 'apierror-fileexists-sharedrepo-perm' );
106            } elseif ( !$this->getAuthority()->isAllowed( 'reupload-shared' ) ) {
107                $this->dieWithError( 'apierror-cantoverwrite-sharedfile' );
108            }
109        }
110
111        // Move the page
112        $toTitleExists = $toTitle->exists();
113        $mp = $this->movePageFactory->newMovePage( $fromTitle, $toTitle );
114        $status = $mp->moveIfAllowed(
115            $this->getAuthority(),
116            $params['reason'],
117            !$params['noredirect'],
118            $params['tags'] ?: []
119        );
120        if ( !$status->isOK() ) {
121            $this->dieStatus( $status );
122        }
123
124        $r = [
125            'from' => $fromTitle->getPrefixedText(),
126            'to' => $toTitle->getPrefixedText(),
127            'reason' => $params['reason']
128        ];
129
130        // NOTE: we assume that if the old title exists, it's because it was re-created as
131        // a redirect to the new title. This is not safe, but what we did before was
132        // even worse: we just determined whether a redirect should have been created,
133        // and reported that it was created if it should have, without any checks.
134        $r['redirectcreated'] = $fromTitle->exists();
135
136        $r['moveoverredirect'] = $toTitleExists;
137
138        // Move the talk page
139        if ( $params['movetalk'] && $toTalk && $fromTalk->exists() && !$fromTitle->isTalkPage() ) {
140            $toTalkExists = $toTalk->exists();
141            $mp = $this->movePageFactory->newMovePage( $fromTalk, $toTalk );
142            $status = $mp->moveIfAllowed(
143                $this->getAuthority(),
144                $params['reason'],
145                !$params['noredirect'],
146                $params['tags'] ?: []
147            );
148            if ( $status->isOK() ) {
149                $r['talkfrom'] = $fromTalk->getPrefixedText();
150                $r['talkto'] = $toTalk->getPrefixedText();
151                $r['talkmoveoverredirect'] = $toTalkExists;
152            } else {
153                // We're not going to dieWithError() on failure, since we already changed something
154                $r['talkmove-errors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
155            }
156        }
157
158        $result = $this->getResult();
159
160        // Move subpages
161        if ( $params['movesubpages'] ) {
162            $r['subpages'] = $this->moveSubpages(
163                $fromTitle,
164                $toTitle,
165                $params['reason'],
166                $params['noredirect'],
167                $params['tags'] ?: []
168            );
169            ApiResult::setIndexedTagName( $r['subpages'], 'subpage' );
170
171            if ( $params['movetalk'] && $toTalk ) {
172                $r['subpages-talk'] = $this->moveSubpages(
173                    $fromTalk,
174                    $toTalk,
175                    $params['reason'],
176                    $params['noredirect'],
177                    $params['tags'] ?: []
178                );
179                ApiResult::setIndexedTagName( $r['subpages-talk'], 'subpage' );
180            }
181        }
182
183        $watch = $params['watchlist'] ?? 'preferences';
184        $watchlistExpiryFrom = $this->getExpiryFromParams( $params, $fromTitle, $user );
185        $watchlistExpiryTo = $this->getExpiryFromParams( $params, $toTitle, $user );
186
187        // Watch pages
188        $this->setWatch( $watch, $fromTitle, $user, 'watchmoves', $watchlistExpiryFrom );
189        $this->setWatch( $watch, $toTitle, $user, 'watchmoves', $watchlistExpiryTo );
190
191        $result->addValue( null, $this->getModuleName(), $r );
192    }
193
194    /**
195     * @param Title $fromTitle
196     * @param Title $toTitle
197     * @param string $reason
198     * @param bool $noredirect
199     * @param string[] $changeTags Applied to the entry in the move log and redirect page revisions
200     * @return array
201     */
202    public function moveSubpages( $fromTitle, $toTitle, $reason, $noredirect, $changeTags = [] ) {
203        $retval = [];
204
205        $mp = $this->movePageFactory->newMovePage( $fromTitle, $toTitle );
206        $result =
207            $mp->moveSubpagesIfAllowed( $this->getAuthority(), $reason, !$noredirect, $changeTags );
208        if ( !$result->isOK() ) {
209            // This means the whole thing failed
210            return [ 'errors' => $this->getErrorFormatter()->arrayFromStatus( $result ) ];
211        }
212
213        // At least some pages could be moved
214        // Report each of them separately
215        foreach ( $result->getValue() as $oldTitle => $status ) {
216            /** @var Status $status */
217            $r = [ 'from' => $oldTitle ];
218            if ( $status->isOK() ) {
219                $r['to'] = $status->getValue();
220            } else {
221                $r['errors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
222            }
223            $retval[] = $r;
224        }
225
226        return $retval;
227    }
228
229    /** @inheritDoc */
230    public function mustBePosted() {
231        return true;
232    }
233
234    /** @inheritDoc */
235    public function isWriteMode() {
236        return true;
237    }
238
239    /** @inheritDoc */
240    public function getAllowedParams() {
241        $params = [
242            'from' => null,
243            'fromid' => [
244                ParamValidator::PARAM_TYPE => 'integer'
245            ],
246            'to' => [
247                ParamValidator::PARAM_TYPE => 'string',
248                ParamValidator::PARAM_REQUIRED => true
249            ],
250            'reason' => '',
251            'movetalk' => false,
252            'movesubpages' => false,
253            'noredirect' => false,
254        ];
255
256        // Params appear in the docs in the order they are defined,
257        // which is why this is here and not at the bottom.
258        $params += $this->getWatchlistParams();
259
260        return $params + [
261            'ignorewarnings' => false,
262            'tags' => [
263                ParamValidator::PARAM_TYPE => 'tags',
264                ParamValidator::PARAM_ISMULTI => true,
265            ],
266        ];
267    }
268
269    /** @inheritDoc */
270    public function needsToken() {
271        return 'csrf';
272    }
273
274    /** @inheritDoc */
275    protected function getExamplesMessages() {
276        return [
277            'action=move&from=Badtitle&to=Goodtitle&token=123ABC&' .
278                'reason=Misspelled%20title&movetalk=&noredirect='
279                => 'apihelp-move-example-move',
280        ];
281    }
282
283    /** @inheritDoc */
284    public function getHelpUrls() {
285        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Move';
286    }
287}
288
289/** @deprecated class alias since 1.43 */
290class_alias( ApiMove::class, 'ApiMove' );