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