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