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