Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.64% covered (success)
94.64%
106 / 112
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpdateHandler
94.64% covered (success)
94.64%
106 / 112
75.00% covered (warning)
75.00%
6 / 8
28.12
0.00% covered (danger)
0.00%
0 / 1
 getTitleParameter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setJsonDiffFunction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParamSettings
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 getActionModuleParameters
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
7.11
 mapActionModuleResult
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 throwHttpExceptionForActionModuleError
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getConflictData
91.67% covered (success)
91.67%
33 / 36
0.00% covered (danger)
0.00%
0 / 1
11.07
 getDiff
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Rest\Handler;
4
5use FormatJson;
6use IApiMessage;
7use MediaWiki\Rest\LocalizedHttpException;
8use MediaWiki\Revision\RevisionRecord;
9use MediaWiki\Revision\SlotRecord;
10use MediaWiki\Utils\MWTimestamp;
11use TextContent;
12use Wikimedia\Message\MessageValue;
13use Wikimedia\ParamValidator\ParamValidator;
14
15/**
16 * Core REST API endpoint that handles page updates (main slot only)
17 */
18class UpdateHandler extends EditHandler {
19
20    /**
21     * @var callable
22     */
23    private $jsonDiffFunction;
24
25    /**
26     * @inheritDoc
27     */
28    protected function getTitleParameter() {
29        return $this->getValidatedParams()['title'];
30    }
31
32    /**
33     * Sets the function to use for JSON diffs, for testing.
34     *
35     * @param callable $jsonDiffFunction
36     */
37    public function setJsonDiffFunction( callable $jsonDiffFunction ) {
38        $this->jsonDiffFunction = $jsonDiffFunction;
39    }
40
41    /**
42     * @inheritDoc
43     */
44    public function getParamSettings() {
45        return [
46            'title' => [
47                self::PARAM_SOURCE => 'path',
48                ParamValidator::PARAM_TYPE => 'string',
49                ParamValidator::PARAM_REQUIRED => true,
50            ],
51            'source' => [
52                self::PARAM_SOURCE => 'body',
53                ParamValidator::PARAM_TYPE => 'string',
54                ParamValidator::PARAM_REQUIRED => true,
55            ],
56            'comment' => [
57                self::PARAM_SOURCE => 'body',
58                ParamValidator::PARAM_TYPE => 'string',
59                ParamValidator::PARAM_REQUIRED => true,
60            ],
61            'content_model' => [
62                self::PARAM_SOURCE => 'body',
63                ParamValidator::PARAM_TYPE => 'string',
64                ParamValidator::PARAM_REQUIRED => false,
65            ],
66            'latest' => [
67                self::PARAM_SOURCE => 'body',
68                ParamValidator::PARAM_TYPE => 'array',
69                ParamValidator::PARAM_REQUIRED => false,
70            ],
71
72        ]
73            + $this->getTokenParamDefinition()
74            + parent::getParamSettings();
75    }
76
77    /**
78     * @inheritDoc
79     */
80    protected function getActionModuleParameters() {
81        $body = $this->getValidatedBody();
82        '@phan-var array $body';
83
84        $title = $this->getTitleParameter();
85        $baseRevId = $body['latest']['id'] ?? 0;
86
87        $contentmodel = $body['content_model'] ?: null;
88
89        if ( $contentmodel !== null && !$this->contentHandlerFactory->isDefinedModel( $contentmodel ) ) {
90            throw new LocalizedHttpException(
91                new MessageValue( 'rest-bad-content-model', [ $contentmodel ] ), 400
92            );
93        }
94
95        // Use a known good CSRF token if a token is not needed because we are
96        // using a method of authentication that protects against CSRF, like OAuth.
97        $token = $this->needsToken() ? $this->getToken() : $this->getUser()->getEditToken();
98
99        $params = [
100            'action' => 'edit',
101            'title' => $title,
102            'text' => $body['source'],
103            'summary' => $body['comment'],
104            'token' => $token
105        ];
106
107        if ( $contentmodel !== null ) {
108            $params['contentmodel'] = $contentmodel;
109        }
110
111        if ( $baseRevId > 0 ) {
112            $params['baserevid'] = $baseRevId;
113            $params['nocreate'] = true;
114        } else {
115            $params['createonly'] = true;
116        }
117
118        return $params;
119    }
120
121    /**
122     * @inheritDoc
123     */
124    protected function mapActionModuleResult( array $data ) {
125        if ( isset( $data['edit']['nochange'] ) ) {
126            // Null-edit, no new revision was created. The new revision is the same as the old.
127            // We may want to signal this more explicitly to the client in the future.
128
129            $title = $this->titleParser->parseTitle( $this->getValidatedParams()['title'] );
130            $currentRev = $this->revisionLookup->getRevisionByTitle( $title );
131
132            $data['edit']['newrevid'] = $currentRev->getId();
133            $data['edit']['newtimestamp']
134                = MWTimestamp::convert( TS_ISO_8601, $currentRev->getTimestamp() );
135        }
136
137        return parent::mapActionModuleResult( $data );
138    }
139
140    /**
141     * @inheritDoc
142     */
143    protected function throwHttpExceptionForActionModuleError( IApiMessage $msg, $statusCode = 400 ) {
144        $code = $msg->getApiCode();
145
146        // Provide a message instructing the client to provide the base revision ID for updates.
147        if ( $code === 'articleexists' ) {
148            $title = $this->getTitleParameter();
149            throw new LocalizedHttpException(
150                new MessageValue( 'rest-update-cannot-create-page', [ $title ] ),
151                409
152            );
153        }
154
155        if ( $code === 'editconflict' ) {
156            $data = $this->getConflictData();
157            throw new LocalizedHttpException( $this->makeMessageValue( $msg ), 409, $data );
158        }
159
160        parent::throwHttpExceptionForActionModuleError( $msg, $statusCode );
161    }
162
163    /**
164     * Returns an associative array to be used in the response in the event of edit conflicts.
165     *
166     * The resulting array contains the following keys:
167     * - base: revision ID of the base revision
168     * - current: revision ID of the current revision (new base after resolving the conflict)
169     * - local: the difference between the content submitted and the base revision
170     * - remote: the difference between the latest revision of the page and the base revision
171     *
172     * If the differences cannot be determined, an empty array is returned.
173     *
174     * @return array
175     */
176    private function getConflictData() {
177        $body = $this->getValidatedBody();
178        '@phan-var array $body';
179        $baseRevId = $body['latest']['id'] ?? 0;
180        $title = $this->titleParser->parseTitle( $this->getValidatedParams()['title'] );
181
182        $baseRev = $this->revisionLookup->getRevisionById( $baseRevId );
183        $currentRev = $this->revisionLookup->getRevisionByTitle( $title );
184
185        if ( !$baseRev || !$currentRev ) {
186            return [];
187        }
188
189        $baseContent = $baseRev->getContent(
190            SlotRecord::MAIN,
191            RevisionRecord::FOR_THIS_USER,
192            $this->getAuthority()
193        );
194        $currentContent = $currentRev->getContent(
195            SlotRecord::MAIN,
196            RevisionRecord::FOR_THIS_USER,
197            $this->getAuthority()
198        );
199
200        if ( !$baseContent || !$currentContent ) {
201            return [];
202        }
203
204        $model = $body['content_model'] ?: $baseContent->getModel();
205        $contentHandler = $this->contentHandlerFactory->getContentHandler( $model );
206        $newContent = $contentHandler->unserializeContent( $body['source'] );
207
208        if ( !$baseContent instanceof TextContent
209            || !$currentContent instanceof TextContent
210            || !$newContent instanceof TextContent
211        ) {
212            return [];
213        }
214
215        $localDiff = $this->getDiff( $baseContent, $newContent );
216        $remoteDiff = $this->getDiff( $baseContent, $currentContent );
217
218        if ( !$localDiff || !$remoteDiff ) {
219            return [];
220        }
221
222        return [
223            'base' => $baseRev->getId(),
224            'current' => $currentRev->getId(),
225            'local' => $localDiff,
226            'remote' => $remoteDiff,
227        ];
228    }
229
230    /**
231     * Returns a text diff encoded as an array, to be included in the response data.
232     *
233     * @param TextContent $from
234     * @param TextContent $to
235     *
236     * @return array|null
237     */
238    private function getDiff( TextContent $from, TextContent $to ) {
239        if ( !is_callable( $this->jsonDiffFunction ) ) {
240            return null;
241        }
242
243        $json = ( $this->jsonDiffFunction )( $from->getText(), $to->getText(), 2 );
244        return FormatJson::decode( $json, true );
245    }
246}