Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
94.12% |
112 / 119 |
|
70.00% |
7 / 10 |
CRAP | |
0.00% |
0 / 1 |
UpdateHandler | |
94.12% |
112 / 119 |
|
70.00% |
7 / 10 |
30.18 | |
0.00% |
0 / 1 |
getTitleParameter | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setJsonDiffFunction | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getParamSettings | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
getBodyParamSettings | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
1 | |||
getActionModuleParameters | |
86.96% |
20 / 23 |
|
0.00% |
0 / 1 |
7.11 | |||
mapActionModuleResult | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
throwHttpExceptionForActionModuleError | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
getConflictData | |
91.67% |
33 / 36 |
|
0.00% |
0 / 1 |
11.07 | |||
getDiff | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getResponseBodySchemaFileName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Rest\Handler; |
4 | |
5 | use MediaWiki\Api\IApiMessage; |
6 | use MediaWiki\Content\TextContent; |
7 | use MediaWiki\Json\FormatJson; |
8 | use MediaWiki\ParamValidator\TypeDef\ArrayDef; |
9 | use MediaWiki\Rest\Handler; |
10 | use MediaWiki\Rest\LocalizedHttpException; |
11 | use MediaWiki\Revision\RevisionRecord; |
12 | use MediaWiki\Revision\SlotRecord; |
13 | use MediaWiki\Utils\MWTimestamp; |
14 | use Wikimedia\Message\MessageValue; |
15 | use Wikimedia\ParamValidator\ParamValidator; |
16 | |
17 | /** |
18 | * Core REST API endpoint that handles page updates (main slot only) |
19 | */ |
20 | class 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 | } |