Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
94.64% |
106 / 112 |
|
75.00% |
6 / 8 |
CRAP | |
0.00% |
0 / 1 |
UpdateHandler | |
94.64% |
106 / 112 |
|
75.00% |
6 / 8 |
28.12 | |
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% |
29 / 29 |
|
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 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Rest\Handler; |
4 | |
5 | use FormatJson; |
6 | use IApiMessage; |
7 | use MediaWiki\Rest\LocalizedHttpException; |
8 | use MediaWiki\Revision\RevisionRecord; |
9 | use MediaWiki\Revision\SlotRecord; |
10 | use MediaWiki\Utils\MWTimestamp; |
11 | use TextContent; |
12 | use Wikimedia\Message\MessageValue; |
13 | use Wikimedia\ParamValidator\ParamValidator; |
14 | |
15 | /** |
16 | * Core REST API endpoint that handles page updates (main slot only) |
17 | */ |
18 | class 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 | } |