Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.76% |
131 / 134 |
|
88.89% |
8 / 9 |
CRAP | |
0.00% |
0 / 1 |
ApiStashEdit | |
97.76% |
131 / 134 |
|
88.89% |
8 / 9 |
27 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
100.00% |
79 / 79 |
|
100.00% |
1 / 1 |
18 | |||
getUserForPreview | |
50.00% |
3 / 6 |
|
0.00% |
0 / 1 |
2.50 | |||
getAllowedParams | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
1 | |||
needsToken | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
mustBePosted | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isWriteMode | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isInternal | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getHelpUrls | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | use MediaWiki\Content\IContentHandlerFactory; |
22 | use MediaWiki\Page\WikiPageFactory; |
23 | use MediaWiki\Revision\RevisionLookup; |
24 | use MediaWiki\Revision\SlotRecord; |
25 | use MediaWiki\Storage\PageEditStash; |
26 | use MediaWiki\User\TempUser\TempUserCreator; |
27 | use MediaWiki\User\UserFactory; |
28 | use Wikimedia\ParamValidator\ParamValidator; |
29 | |
30 | /** |
31 | * Prepare an edit in shared cache so that it can be reused on edit |
32 | * |
33 | * This endpoint can be called via AJAX as the user focuses on the edit |
34 | * summary box. By the time of submission, the parse may have already |
35 | * finished, and can be immediately used on page save. Certain parser |
36 | * functions like {{REVISIONID}} or {{CURRENTTIME}} may cause the cache |
37 | * to not be used on edit. Template and files used are checked for changes |
38 | * since the output was generated. The cache TTL is also kept low. |
39 | * |
40 | * @ingroup API |
41 | * @since 1.25 |
42 | */ |
43 | class ApiStashEdit extends ApiBase { |
44 | |
45 | private IContentHandlerFactory $contentHandlerFactory; |
46 | private PageEditStash $pageEditStash; |
47 | private RevisionLookup $revisionLookup; |
48 | private IBufferingStatsdDataFactory $statsdDataFactory; |
49 | private WikiPageFactory $wikiPageFactory; |
50 | private TempUserCreator $tempUserCreator; |
51 | private UserFactory $userFactory; |
52 | |
53 | /** |
54 | * @param ApiMain $main |
55 | * @param string $action |
56 | * @param IContentHandlerFactory $contentHandlerFactory |
57 | * @param PageEditStash $pageEditStash |
58 | * @param RevisionLookup $revisionLookup |
59 | * @param IBufferingStatsdDataFactory $statsdDataFactory |
60 | * @param WikiPageFactory $wikiPageFactory |
61 | * @param TempUserCreator $tempUserCreator |
62 | * @param UserFactory $userFactory |
63 | */ |
64 | public function __construct( |
65 | ApiMain $main, |
66 | $action, |
67 | IContentHandlerFactory $contentHandlerFactory, |
68 | PageEditStash $pageEditStash, |
69 | RevisionLookup $revisionLookup, |
70 | IBufferingStatsdDataFactory $statsdDataFactory, |
71 | WikiPageFactory $wikiPageFactory, |
72 | TempUserCreator $tempUserCreator, |
73 | UserFactory $userFactory |
74 | ) { |
75 | parent::__construct( $main, $action ); |
76 | |
77 | $this->contentHandlerFactory = $contentHandlerFactory; |
78 | $this->pageEditStash = $pageEditStash; |
79 | $this->revisionLookup = $revisionLookup; |
80 | $this->statsdDataFactory = $statsdDataFactory; |
81 | $this->wikiPageFactory = $wikiPageFactory; |
82 | $this->tempUserCreator = $tempUserCreator; |
83 | $this->userFactory = $userFactory; |
84 | } |
85 | |
86 | public function execute() { |
87 | $user = $this->getUser(); |
88 | $params = $this->extractRequestParams(); |
89 | |
90 | if ( $user->isBot() ) { |
91 | $this->dieWithError( 'apierror-botsnotsupported' ); |
92 | } |
93 | |
94 | $page = $this->getTitleOrPageId( $params ); |
95 | $title = $page->getTitle(); |
96 | $this->getErrorFormatter()->setContextTitle( $title ); |
97 | |
98 | if ( !$this->contentHandlerFactory |
99 | ->getContentHandler( $params['contentmodel'] ) |
100 | ->isSupportedFormat( $params['contentformat'] ) |
101 | ) { |
102 | $this->dieWithError( |
103 | [ 'apierror-badformat-generic', $params['contentformat'], $params['contentmodel'] ], |
104 | 'badmodelformat' |
105 | ); |
106 | } |
107 | |
108 | $this->requireOnlyOneParameter( $params, 'stashedtexthash', 'text' ); |
109 | |
110 | if ( $params['stashedtexthash'] !== null ) { |
111 | // Load from cache since the client indicates the text is the same as last stash |
112 | $textHash = $params['stashedtexthash']; |
113 | if ( !preg_match( '/^[0-9a-f]{40}$/', $textHash ) ) { |
114 | $this->dieWithError( 'apierror-stashedit-missingtext', 'missingtext' ); |
115 | } |
116 | $text = $this->pageEditStash->fetchInputText( $textHash ); |
117 | if ( !is_string( $text ) ) { |
118 | $this->dieWithError( 'apierror-stashedit-missingtext', 'missingtext' ); |
119 | } |
120 | } else { |
121 | // 'text' was passed. Trim and fix newlines so the key SHA1's |
122 | // match (see WebRequest::getText()) |
123 | $text = rtrim( str_replace( "\r\n", "\n", $params['text'] ) ); |
124 | $textHash = sha1( $text ); |
125 | } |
126 | |
127 | $textContent = $this->contentHandlerFactory |
128 | ->getContentHandler( $params['contentmodel'] ) |
129 | ->unserializeContent( $text, $params['contentformat'] ); |
130 | |
131 | $page = $this->wikiPageFactory->newFromTitle( $title ); |
132 | if ( $page->exists() ) { |
133 | // Page exists: get the merged content with the proposed change |
134 | $baseRev = $this->revisionLookup->getRevisionByPageId( |
135 | $page->getId(), |
136 | $params['baserevid'] |
137 | ); |
138 | if ( !$baseRev ) { |
139 | $this->dieWithError( [ 'apierror-nosuchrevid', $params['baserevid'] ] ); |
140 | } |
141 | $currentRev = $page->getRevisionRecord(); |
142 | if ( !$currentRev ) { |
143 | $this->dieWithError( [ 'apierror-missingrev-pageid', $page->getId() ], 'missingrev' ); |
144 | } |
145 | // Merge in the new version of the section to get the proposed version |
146 | $editContent = $page->replaceSectionAtRev( |
147 | $params['section'], |
148 | $textContent, |
149 | $params['sectiontitle'], |
150 | $baseRev->getId() |
151 | ); |
152 | if ( !$editContent ) { |
153 | $this->dieWithError( 'apierror-sectionreplacefailed', 'replacefailed' ); |
154 | } |
155 | if ( $currentRev->getId() == $baseRev->getId() ) { |
156 | // Base revision was still the latest; nothing to merge |
157 | $content = $editContent; |
158 | } else { |
159 | // Merge the edit into the current version |
160 | $baseContent = $baseRev->getContent( SlotRecord::MAIN ); |
161 | $currentContent = $currentRev->getContent( SlotRecord::MAIN ); |
162 | if ( !$baseContent || !$currentContent ) { |
163 | $this->dieWithError( [ 'apierror-missingcontent-pageid', $page->getId() ], 'missingrev' ); |
164 | } |
165 | |
166 | $baseModel = $baseContent->getModel(); |
167 | $currentModel = $currentContent->getModel(); |
168 | |
169 | // T255700: Put this in try-block because if the models of these three Contents |
170 | // happen to not be identical, the ContentHandler may throw exception here. |
171 | try { |
172 | $content = $this->contentHandlerFactory |
173 | ->getContentHandler( $baseModel ) |
174 | ->merge3( $baseContent, $editContent, $currentContent ); |
175 | } catch ( Exception $e ) { |
176 | $this->dieWithException( $e, [ |
177 | 'wrap' => ApiMessage::create( |
178 | [ 'apierror-contentmodel-mismatch', $currentModel, $baseModel ] |
179 | ) |
180 | ] ); |
181 | } |
182 | |
183 | } |
184 | } else { |
185 | // New pages: use the user-provided content model |
186 | $content = $textContent; |
187 | } |
188 | |
189 | if ( !$content ) { // merge3() failed |
190 | $this->getResult()->addValue( null, |
191 | $this->getModuleName(), [ 'status' => 'editconflict' ] ); |
192 | return; |
193 | } |
194 | |
195 | if ( !$user->authorizeWrite( 'stashedit', $title ) ) { |
196 | $status = 'ratelimited'; |
197 | } else { |
198 | $user = $this->getUserForPreview(); |
199 | $updater = $page->newPageUpdater( $user ); |
200 | $status = $this->pageEditStash->parseAndCache( $updater, $content, $user, $params['summary'] ); |
201 | $this->pageEditStash->stashInputText( $text, $textHash ); |
202 | } |
203 | |
204 | $this->statsdDataFactory->increment( "editstash.cache_stores.$status" ); |
205 | |
206 | $ret = [ 'status' => $status ]; |
207 | // If we were rate-limited, we still return the pre-existing valid hash if one was passed |
208 | if ( $status !== 'ratelimited' || $params['stashedtexthash'] !== null ) { |
209 | $ret['texthash'] = $textHash; |
210 | } |
211 | |
212 | $this->getResult()->addValue( null, $this->getModuleName(), $ret ); |
213 | } |
214 | |
215 | private function getUserForPreview() { |
216 | $user = $this->getUser(); |
217 | if ( $this->tempUserCreator->shouldAutoCreate( $user, 'edit' ) ) { |
218 | return $this->userFactory->newUnsavedTempUser( |
219 | $this->tempUserCreator->getStashedName( $this->getRequest()->getSession() ) |
220 | ); |
221 | } |
222 | return $user; |
223 | } |
224 | |
225 | public function getAllowedParams() { |
226 | return [ |
227 | 'title' => [ |
228 | ParamValidator::PARAM_TYPE => 'string', |
229 | ParamValidator::PARAM_REQUIRED => true |
230 | ], |
231 | 'section' => [ |
232 | ParamValidator::PARAM_TYPE => 'string', |
233 | ], |
234 | 'sectiontitle' => [ |
235 | ParamValidator::PARAM_TYPE => 'string' |
236 | ], |
237 | 'text' => [ |
238 | ParamValidator::PARAM_TYPE => 'text', |
239 | ParamValidator::PARAM_DEFAULT => null |
240 | ], |
241 | 'stashedtexthash' => [ |
242 | ParamValidator::PARAM_TYPE => 'string', |
243 | ParamValidator::PARAM_DEFAULT => null |
244 | ], |
245 | 'summary' => [ |
246 | ParamValidator::PARAM_TYPE => 'string', |
247 | ParamValidator::PARAM_DEFAULT => '' |
248 | ], |
249 | 'contentmodel' => [ |
250 | ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getContentModels(), |
251 | ParamValidator::PARAM_REQUIRED => true |
252 | ], |
253 | 'contentformat' => [ |
254 | ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getAllContentFormats(), |
255 | ParamValidator::PARAM_REQUIRED => true |
256 | ], |
257 | 'baserevid' => [ |
258 | ParamValidator::PARAM_TYPE => 'integer', |
259 | ParamValidator::PARAM_REQUIRED => true |
260 | ] |
261 | ]; |
262 | } |
263 | |
264 | public function needsToken() { |
265 | return 'csrf'; |
266 | } |
267 | |
268 | public function mustBePosted() { |
269 | return true; |
270 | } |
271 | |
272 | public function isWriteMode() { |
273 | return true; |
274 | } |
275 | |
276 | public function isInternal() { |
277 | return true; |
278 | } |
279 | |
280 | public function getHelpUrls() { |
281 | return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Stashedit'; |
282 | } |
283 | } |