Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
90.91% |
360 / 396 |
|
60.00% |
6 / 10 |
CRAP | |
0.00% |
0 / 1 |
ApiEditPage | |
90.91% |
360 / 396 |
|
60.00% |
6 / 10 |
132.00 | |
0.00% |
0 / 1 |
persistGlobalSession | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
__construct | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
getUserForPermissions | |
50.00% |
3 / 6 |
|
0.00% |
0 / 1 |
2.50 | |||
execute | |
92.55% |
261 / 282 |
|
0.00% |
0 / 1 |
116.09 | |||
mustBePosted | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isWriteMode | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getAllowedParams | |
100.00% |
78 / 78 |
|
100.00% |
1 / 1 |
1 | |||
needsToken | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getExamplesMessages | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
2 | |||
getHelpUrls | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * Copyright © 2007 Iker Labarga "<Firstname><Lastname>@gmail.com" |
4 | * |
5 | * This program is free software; you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation; either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License along |
16 | * with this program; if not, write to the Free Software Foundation, Inc., |
17 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
18 | * http://www.gnu.org/copyleft/gpl.html |
19 | * |
20 | * @file |
21 | */ |
22 | |
23 | use MediaWiki\Content\IContentHandlerFactory; |
24 | use MediaWiki\Context\RequestContext; |
25 | use MediaWiki\EditPage\EditPage; |
26 | use MediaWiki\MainConfigNames; |
27 | use MediaWiki\MediaWikiServices; |
28 | use MediaWiki\Page\RedirectLookup; |
29 | use MediaWiki\Page\WikiPageFactory; |
30 | use MediaWiki\Request\DerivativeRequest; |
31 | use MediaWiki\Revision\RevisionLookup; |
32 | use MediaWiki\Revision\RevisionRecord; |
33 | use MediaWiki\Revision\SlotRecord; |
34 | use MediaWiki\Title\Title; |
35 | use MediaWiki\User\Options\UserOptionsLookup; |
36 | use MediaWiki\User\TempUser\TempUserCreator; |
37 | use MediaWiki\User\User; |
38 | use MediaWiki\User\UserFactory; |
39 | use MediaWiki\Watchlist\WatchlistManager; |
40 | use Wikimedia\ParamValidator\ParamValidator; |
41 | use Wikimedia\ParamValidator\TypeDef\IntegerDef; |
42 | |
43 | /** |
44 | * A module that allows for editing and creating pages. |
45 | * |
46 | * Currently, this wraps around the EditPage class in an ugly way, |
47 | * EditPage.php should be rewritten to provide a cleaner interface, |
48 | * see T20654 if you're inspired to fix this. |
49 | * |
50 | * WARNING: This class is //not// stable to extend. However, it is |
51 | * currently extended by the ApiThreadAction class in the LiquidThreads |
52 | * extension, which is deployed on WMF servers. Changes that would |
53 | * break LiquidThreads will likely be reverted. See T264200 for context |
54 | * and T264213 for removing LiquidThreads' unsupported extending of this |
55 | * class. |
56 | * |
57 | * @ingroup API |
58 | */ |
59 | class ApiEditPage extends ApiBase { |
60 | use ApiCreateTempUserTrait; |
61 | use ApiWatchlistTrait; |
62 | |
63 | private IContentHandlerFactory $contentHandlerFactory; |
64 | private RevisionLookup $revisionLookup; |
65 | private WatchedItemStoreInterface $watchedItemStore; |
66 | private WikiPageFactory $wikiPageFactory; |
67 | private RedirectLookup $redirectLookup; |
68 | private TempUserCreator $tempUserCreator; |
69 | private UserFactory $userFactory; |
70 | |
71 | /** |
72 | * Sends a cookie so anons get talk message notifications, mirroring SubmitAction (T295910) |
73 | */ |
74 | private function persistGlobalSession() { |
75 | MediaWiki\Session\SessionManager::getGlobalSession()->persist(); |
76 | } |
77 | |
78 | /** |
79 | * @param ApiMain $mainModule |
80 | * @param string $moduleName |
81 | * @param IContentHandlerFactory|null $contentHandlerFactory |
82 | * @param RevisionLookup|null $revisionLookup |
83 | * @param WatchedItemStoreInterface|null $watchedItemStore |
84 | * @param WikiPageFactory|null $wikiPageFactory |
85 | * @param WatchlistManager|null $watchlistManager |
86 | * @param UserOptionsLookup|null $userOptionsLookup |
87 | * @param RedirectLookup|null $redirectLookup |
88 | * @param TempUserCreator|null $tempUserCreator |
89 | * @param UserFactory|null $userFactory |
90 | */ |
91 | public function __construct( |
92 | ApiMain $mainModule, |
93 | $moduleName, |
94 | IContentHandlerFactory $contentHandlerFactory = null, |
95 | RevisionLookup $revisionLookup = null, |
96 | WatchedItemStoreInterface $watchedItemStore = null, |
97 | WikiPageFactory $wikiPageFactory = null, |
98 | WatchlistManager $watchlistManager = null, |
99 | UserOptionsLookup $userOptionsLookup = null, |
100 | RedirectLookup $redirectLookup = null, |
101 | TempUserCreator $tempUserCreator = null, |
102 | UserFactory $userFactory = null |
103 | ) { |
104 | parent::__construct( $mainModule, $moduleName ); |
105 | |
106 | // This class is extended and therefor fallback to global state - T264213 |
107 | $services = MediaWikiServices::getInstance(); |
108 | $this->contentHandlerFactory = $contentHandlerFactory ?? $services->getContentHandlerFactory(); |
109 | $this->revisionLookup = $revisionLookup ?? $services->getRevisionLookup(); |
110 | $this->watchedItemStore = $watchedItemStore ?? $services->getWatchedItemStore(); |
111 | $this->wikiPageFactory = $wikiPageFactory ?? $services->getWikiPageFactory(); |
112 | |
113 | // Variables needed in ApiWatchlistTrait trait |
114 | $this->watchlistExpiryEnabled = $this->getConfig()->get( MainConfigNames::WatchlistExpiry ); |
115 | $this->watchlistMaxDuration = |
116 | $this->getConfig()->get( MainConfigNames::WatchlistExpiryMaxDuration ); |
117 | $this->watchlistManager = $watchlistManager ?? $services->getWatchlistManager(); |
118 | $this->userOptionsLookup = $userOptionsLookup ?? $services->getUserOptionsLookup(); |
119 | $this->redirectLookup = $redirectLookup ?? $services->getRedirectLookup(); |
120 | $this->tempUserCreator = $tempUserCreator ?? $services->getTempUserCreator(); |
121 | $this->userFactory = $userFactory ?? $services->getUserFactory(); |
122 | } |
123 | |
124 | /** |
125 | * @see EditPage::getUserForPermissions |
126 | * @return User |
127 | */ |
128 | private function getUserForPermissions() { |
129 | $user = $this->getUser(); |
130 | if ( $this->tempUserCreator->shouldAutoCreate( $user, 'edit' ) ) { |
131 | return $this->userFactory->newUnsavedTempUser( |
132 | $this->tempUserCreator->getStashedName( $this->getRequest()->getSession() ) |
133 | ); |
134 | } |
135 | return $user; |
136 | } |
137 | |
138 | public function execute() { |
139 | $this->useTransactionalTimeLimit(); |
140 | |
141 | $user = $this->getUser(); |
142 | $params = $this->extractRequestParams(); |
143 | |
144 | $this->requireAtLeastOneParameter( $params, 'text', 'appendtext', 'prependtext', 'undo' ); |
145 | |
146 | $pageObj = $this->getTitleOrPageId( $params ); |
147 | $titleObj = $pageObj->getTitle(); |
148 | $this->getErrorFormatter()->setContextTitle( $titleObj ); |
149 | $apiResult = $this->getResult(); |
150 | |
151 | if ( $params['redirect'] ) { |
152 | if ( $params['prependtext'] === null |
153 | && $params['appendtext'] === null |
154 | && $params['section'] !== 'new' |
155 | ) { |
156 | $this->dieWithError( 'apierror-redirect-appendonly' ); |
157 | } |
158 | if ( $titleObj->isRedirect() ) { |
159 | $oldTarget = $titleObj; |
160 | $redirTarget = $this->redirectLookup->getRedirectTarget( $oldTarget ); |
161 | $redirTarget = Title::castFromLinkTarget( $redirTarget ); |
162 | |
163 | $redirValues = [ |
164 | 'from' => $titleObj->getPrefixedText(), |
165 | 'to' => $redirTarget->getPrefixedText() |
166 | ]; |
167 | |
168 | // T239428: Check whether the new title is valid |
169 | if ( $redirTarget->isExternal() || !$redirTarget->canExist() ) { |
170 | $redirValues['to'] = $redirTarget->getFullText(); |
171 | $this->dieWithError( |
172 | [ |
173 | 'apierror-edit-invalidredirect', |
174 | Message::plaintextParam( $oldTarget->getPrefixedText() ), |
175 | Message::plaintextParam( $redirTarget->getFullText() ), |
176 | ], |
177 | 'edit-invalidredirect', |
178 | [ 'redirects' => $redirValues ] |
179 | ); |
180 | } |
181 | |
182 | ApiResult::setIndexedTagName( $redirValues, 'r' ); |
183 | $apiResult->addValue( null, 'redirects', $redirValues ); |
184 | |
185 | // Since the page changed, update $pageObj and $titleObj |
186 | $pageObj = $this->wikiPageFactory->newFromTitle( $redirTarget ); |
187 | $titleObj = $pageObj->getTitle(); |
188 | |
189 | $this->getErrorFormatter()->setContextTitle( $redirTarget ); |
190 | } |
191 | } |
192 | |
193 | if ( $params['contentmodel'] ) { |
194 | $contentHandler = $this->contentHandlerFactory->getContentHandler( $params['contentmodel'] ); |
195 | } else { |
196 | $contentHandler = $pageObj->getContentHandler(); |
197 | } |
198 | $contentModel = $contentHandler->getModelID(); |
199 | |
200 | $name = $titleObj->getPrefixedDBkey(); |
201 | |
202 | if ( $params['undo'] > 0 ) { |
203 | // allow undo via api |
204 | } elseif ( $contentHandler->supportsDirectApiEditing() === false ) { |
205 | $this->dieWithError( [ 'apierror-no-direct-editing', $contentModel, $name ] ); |
206 | } |
207 | |
208 | $contentFormat = $params['contentformat'] ?: $contentHandler->getDefaultFormat(); |
209 | |
210 | if ( !$contentHandler->isSupportedFormat( $contentFormat ) ) { |
211 | $this->dieWithError( [ 'apierror-badformat', $contentFormat, $contentModel, $name ] ); |
212 | } |
213 | |
214 | if ( $params['createonly'] && $titleObj->exists() ) { |
215 | $this->dieWithError( 'apierror-articleexists' ); |
216 | } |
217 | if ( $params['nocreate'] && !$titleObj->exists() ) { |
218 | $this->dieWithError( 'apierror-missingtitle' ); |
219 | } |
220 | |
221 | // Now let's check whether we're even allowed to do this |
222 | $this->checkTitleUserPermissions( |
223 | $titleObj, |
224 | 'edit', |
225 | [ 'autoblock' => true, 'user' => $this->getUserForPermissions() ] |
226 | ); |
227 | |
228 | $toMD5 = $params['text']; |
229 | if ( $params['appendtext'] !== null || $params['prependtext'] !== null ) { |
230 | $content = $pageObj->getContent(); |
231 | |
232 | if ( !$content ) { |
233 | if ( $titleObj->getNamespace() === NS_MEDIAWIKI ) { |
234 | # If this is a MediaWiki:x message, then load the messages |
235 | # and return the message value for x. |
236 | $text = $titleObj->getDefaultMessageText(); |
237 | if ( $text === false ) { |
238 | $text = ''; |
239 | } |
240 | |
241 | try { |
242 | $content = ContentHandler::makeContent( $text, $titleObj ); |
243 | } catch ( MWContentSerializationException $ex ) { |
244 | $this->dieWithException( $ex, [ |
245 | 'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' ) |
246 | ] ); |
247 | } |
248 | } else { |
249 | # Otherwise, make a new empty content. |
250 | $content = $contentHandler->makeEmptyContent(); |
251 | } |
252 | } |
253 | |
254 | // @todo Add support for appending/prepending to the Content interface |
255 | |
256 | if ( !( $content instanceof TextContent ) ) { |
257 | $this->dieWithError( [ 'apierror-appendnotsupported', $contentModel ] ); |
258 | } |
259 | |
260 | if ( $params['section'] !== null ) { |
261 | if ( !$contentHandler->supportsSections() ) { |
262 | $this->dieWithError( [ 'apierror-sectionsnotsupported', $contentModel ] ); |
263 | } |
264 | |
265 | if ( $params['section'] == 'new' ) { |
266 | // DWIM if they're trying to prepend/append to a new section. |
267 | $content = null; |
268 | } else { |
269 | // Process the content for section edits |
270 | $section = $params['section']; |
271 | $content = $content->getSection( $section ); |
272 | |
273 | if ( !$content ) { |
274 | $this->dieWithError( [ 'apierror-nosuchsection', wfEscapeWikiText( $section ) ] ); |
275 | } |
276 | } |
277 | } |
278 | |
279 | if ( !$content ) { |
280 | $text = ''; |
281 | } else { |
282 | $text = $content->serialize( $contentFormat ); |
283 | } |
284 | |
285 | $params['text'] = $params['prependtext'] . $text . $params['appendtext']; |
286 | $toMD5 = $params['prependtext'] . $params['appendtext']; |
287 | } |
288 | |
289 | if ( $params['undo'] > 0 ) { |
290 | $undoRev = $this->revisionLookup->getRevisionById( $params['undo'] ); |
291 | if ( $undoRev === null || $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ) { |
292 | $this->dieWithError( [ 'apierror-nosuchrevid', $params['undo'] ] ); |
293 | } |
294 | |
295 | if ( $params['undoafter'] > 0 ) { |
296 | $undoafterRev = $this->revisionLookup->getRevisionById( $params['undoafter'] ); |
297 | } else { |
298 | // undoafter=0 or null |
299 | $undoafterRev = $this->revisionLookup->getPreviousRevision( $undoRev ); |
300 | } |
301 | if ( $undoafterRev === null || $undoafterRev->isDeleted( RevisionRecord::DELETED_TEXT ) ) { |
302 | $this->dieWithError( [ 'apierror-nosuchrevid', $params['undoafter'] ] ); |
303 | } |
304 | |
305 | if ( $undoRev->getPageId() != $pageObj->getId() ) { |
306 | $this->dieWithError( [ 'apierror-revwrongpage', $undoRev->getId(), |
307 | $titleObj->getPrefixedText() ] ); |
308 | } |
309 | if ( $undoafterRev->getPageId() != $pageObj->getId() ) { |
310 | $this->dieWithError( [ 'apierror-revwrongpage', $undoafterRev->getId(), |
311 | $titleObj->getPrefixedText() ] ); |
312 | } |
313 | |
314 | $newContent = $contentHandler->getUndoContent( |
315 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Content is for public use here |
316 | $pageObj->getRevisionRecord()->getContent( SlotRecord::MAIN ), |
317 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Content is for public use here |
318 | $undoRev->getContent( SlotRecord::MAIN ), |
319 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Content is for public use here |
320 | $undoafterRev->getContent( SlotRecord::MAIN ), |
321 | $pageObj->getRevisionRecord()->getId() === $undoRev->getId() |
322 | ); |
323 | |
324 | if ( !$newContent ) { |
325 | $this->dieWithError( 'undo-failure', 'undofailure' ); |
326 | } |
327 | if ( !$params['contentmodel'] && !$params['contentformat'] ) { |
328 | // If we are reverting content model, the new content model |
329 | // might not support the current serialization format, in |
330 | // which case go back to the old serialization format, |
331 | // but only if the user hasn't specified a format/model |
332 | // parameter. |
333 | if ( !$newContent->isSupportedFormat( $contentFormat ) ) { |
334 | $undoafterRevMainSlot = $undoafterRev->getSlot( |
335 | SlotRecord::MAIN, |
336 | RevisionRecord::RAW |
337 | ); |
338 | $contentFormat = $undoafterRevMainSlot->getFormat(); |
339 | if ( !$contentFormat ) { |
340 | // fall back to default content format for the model |
341 | // of $undoafterRev |
342 | $contentFormat = $this->contentHandlerFactory |
343 | ->getContentHandler( $undoafterRevMainSlot->getModel() ) |
344 | ->getDefaultFormat(); |
345 | } |
346 | } |
347 | // Override content model with model of undid revision. |
348 | $contentModel = $newContent->getModel(); |
349 | $undoContentModel = true; |
350 | } |
351 | $params['text'] = $newContent->serialize( $contentFormat ); |
352 | // If no summary was given and we only undid one rev, |
353 | // use an autosummary |
354 | |
355 | if ( $params['summary'] === null ) { |
356 | $nextRev = $this->revisionLookup->getNextRevision( $undoafterRev ); |
357 | if ( $nextRev && $nextRev->getId() == $params['undo'] ) { |
358 | $undoRevUser = $undoRev->getUser(); |
359 | $params['summary'] = $this->msg( 'undo-summary' ) |
360 | ->params( $params['undo'], $undoRevUser ? $undoRevUser->getName() : '' ) |
361 | ->inContentLanguage()->text(); |
362 | } |
363 | } |
364 | } |
365 | |
366 | // See if the MD5 hash checks out |
367 | if ( $params['md5'] !== null && md5( $toMD5 ) !== $params['md5'] ) { |
368 | $this->dieWithError( 'apierror-badmd5' ); |
369 | } |
370 | |
371 | // EditPage wants to parse its stuff from a WebRequest |
372 | // That interface kind of sucks, but it's workable |
373 | $requestArray = [ |
374 | // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive |
375 | 'wpTextbox1' => $params['text'], |
376 | 'format' => $contentFormat, |
377 | 'model' => $contentModel, |
378 | 'wpEditToken' => $params['token'], |
379 | 'wpIgnoreBlankSummary' => true, |
380 | 'wpIgnoreBlankArticle' => true, |
381 | 'wpIgnoreSelfRedirect' => true, |
382 | 'bot' => $params['bot'], |
383 | 'wpUnicodeCheck' => EditPage::UNICODE_CHECK, |
384 | ]; |
385 | |
386 | // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive |
387 | if ( $params['summary'] !== null ) { |
388 | $requestArray['wpSummary'] = $params['summary']; |
389 | } |
390 | |
391 | if ( $params['sectiontitle'] !== null ) { |
392 | $requestArray['wpSectionTitle'] = $params['sectiontitle']; |
393 | } |
394 | |
395 | if ( $params['undo'] > 0 ) { |
396 | $requestArray['wpUndidRevision'] = $params['undo']; |
397 | } |
398 | if ( $params['undoafter'] > 0 ) { |
399 | $requestArray['wpUndoAfter'] = $params['undoafter']; |
400 | } |
401 | |
402 | // Skip for baserevid == null or '' or '0' or 0 |
403 | if ( !empty( $params['baserevid'] ) ) { |
404 | $requestArray['editRevId'] = $params['baserevid']; |
405 | } |
406 | |
407 | // Watch out for basetimestamp == '' or '0' |
408 | // It gets treated as NOW, almost certainly causing an edit conflict |
409 | if ( $params['basetimestamp'] !== null && (bool)$this->getMain()->getVal( 'basetimestamp' ) ) { |
410 | $requestArray['wpEdittime'] = $params['basetimestamp']; |
411 | } elseif ( empty( $params['baserevid'] ) ) { |
412 | // Only set if baserevid is not set. Otherwise, conflicts would be ignored, |
413 | // due to the way userWasLastToEdit() works. |
414 | $requestArray['wpEdittime'] = $pageObj->getTimestamp(); |
415 | } |
416 | |
417 | if ( $params['starttimestamp'] !== null ) { |
418 | $requestArray['wpStarttime'] = $params['starttimestamp']; |
419 | } else { |
420 | $requestArray['wpStarttime'] = wfTimestampNow(); // Fake wpStartime |
421 | } |
422 | |
423 | if ( $params['minor'] || ( !$params['notminor'] && |
424 | $this->userOptionsLookup->getOption( $user, 'minordefault' ) ) |
425 | ) { |
426 | $requestArray['wpMinoredit'] = ''; |
427 | } |
428 | |
429 | if ( $params['recreate'] ) { |
430 | $requestArray['wpRecreate'] = ''; |
431 | } |
432 | |
433 | if ( $params['section'] !== null ) { |
434 | $section = $params['section']; |
435 | if ( !preg_match( '/^((T-)?\d+|new)$/', $section ) ) { |
436 | $this->dieWithError( 'apierror-invalidsection' ); |
437 | } |
438 | $content = $pageObj->getContent(); |
439 | if ( $section !== '0' |
440 | && $section != 'new' |
441 | && ( !$content || !$content->getSection( $section ) ) |
442 | ) { |
443 | $this->dieWithError( [ 'apierror-nosuchsection', $section ] ); |
444 | } |
445 | $requestArray['wpSection'] = $params['section']; |
446 | } else { |
447 | $requestArray['wpSection'] = ''; |
448 | } |
449 | |
450 | $watch = $this->getWatchlistValue( $params['watchlist'], $titleObj, $user ); |
451 | |
452 | // Deprecated parameters |
453 | if ( $params['watch'] ) { |
454 | $watch = true; |
455 | } elseif ( $params['unwatch'] ) { |
456 | $watch = false; |
457 | } |
458 | |
459 | if ( $watch ) { |
460 | $requestArray['wpWatchthis'] = true; |
461 | $watchlistExpiry = $this->getExpiryFromParams( $params ); |
462 | |
463 | if ( $watchlistExpiry ) { |
464 | $requestArray['wpWatchlistExpiry'] = $watchlistExpiry; |
465 | } |
466 | } |
467 | |
468 | // Apply change tags |
469 | if ( $params['tags'] ) { |
470 | $tagStatus = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $this->getAuthority() ); |
471 | if ( $tagStatus->isOK() ) { |
472 | $requestArray['wpChangeTags'] = implode( ',', $params['tags'] ); |
473 | } else { |
474 | $this->dieStatus( $tagStatus ); |
475 | } |
476 | } |
477 | |
478 | // Pass through anything else we might have been given, to support extensions |
479 | // This is kind of a hack but it's the best we can do to make extensions work |
480 | $requestArray += $this->getRequest()->getValues(); |
481 | |
482 | global $wgTitle, $wgRequest; |
483 | |
484 | $req = new DerivativeRequest( $this->getRequest(), $requestArray, true ); |
485 | |
486 | // Some functions depend on $wgTitle == $ep->mTitle |
487 | // TODO: Make them not or check if they still do |
488 | $wgTitle = $titleObj; |
489 | |
490 | $articleContext = new RequestContext; |
491 | $articleContext->setRequest( $req ); |
492 | $articleContext->setWikiPage( $pageObj ); |
493 | $articleContext->setUser( $this->getUser() ); |
494 | |
495 | /** @var Article $articleObject */ |
496 | $articleObject = Article::newFromWikiPage( $pageObj, $articleContext ); |
497 | |
498 | $ep = new EditPage( $articleObject ); |
499 | |
500 | $ep->setApiEditOverride( true ); |
501 | $ep->setContextTitle( $titleObj ); |
502 | $ep->importFormData( $req ); |
503 | $tempUserCreateStatus = $ep->maybeActivateTempUserCreate( true ); |
504 | if ( !$tempUserCreateStatus->isOK() ) { |
505 | $this->dieWithError( 'apierror-tempuseracquirefailed', 'tempuseracquirefailed' ); |
506 | } |
507 | |
508 | // T255700: Ensure content models of the base content |
509 | // and fetched revision remain the same before attempting to save. |
510 | $editRevId = $requestArray['editRevId'] ?? false; |
511 | $baseRev = $this->revisionLookup->getRevisionByTitle( $titleObj, $editRevId ); |
512 | $baseContentModel = null; |
513 | |
514 | if ( $baseRev ) { |
515 | $baseContent = $baseRev->getContent( SlotRecord::MAIN ); |
516 | $baseContentModel = $baseContent ? $baseContent->getModel() : null; |
517 | } |
518 | |
519 | $baseContentModel ??= $pageObj->getContentModel(); |
520 | |
521 | // However, allow the content models to possibly differ if we are intentionally |
522 | // changing them or we are doing an undo edit that is reverting content model change. |
523 | $contentModelsCanDiffer = $params['contentmodel'] || isset( $undoContentModel ); |
524 | |
525 | if ( !$contentModelsCanDiffer && $contentModel !== $baseContentModel ) { |
526 | $this->dieWithError( [ 'apierror-contentmodel-mismatch', $contentModel, $baseContentModel ] ); |
527 | } |
528 | |
529 | // Do the actual save |
530 | $oldRevId = $articleObject->getRevIdFetched(); |
531 | $result = null; |
532 | |
533 | // Fake $wgRequest for some hooks inside EditPage |
534 | // @todo FIXME: This interface SUCKS |
535 | $oldRequest = $wgRequest; |
536 | $wgRequest = $req; |
537 | |
538 | $status = $ep->attemptSave( $result ); |
539 | $statusValue = is_int( $status->value ) ? $status->value : 0; |
540 | $wgRequest = $oldRequest; |
541 | |
542 | $r = []; |
543 | switch ( $statusValue ) { |
544 | case EditPage::AS_HOOK_ERROR: |
545 | case EditPage::AS_HOOK_ERROR_EXPECTED: |
546 | if ( $status->statusData !== null ) { |
547 | $r = $status->statusData; |
548 | $r['result'] = 'Failure'; |
549 | $apiResult->addValue( null, $this->getModuleName(), $r ); |
550 | return; |
551 | } |
552 | if ( !$status->getErrors() ) { |
553 | // This appears to be unreachable right now, because all |
554 | // code paths will set an error. Could change, though. |
555 | $status->fatal( 'hookaborted' ); // @codeCoverageIgnore |
556 | } |
557 | $this->dieStatus( $status ); |
558 | |
559 | // These two cases will normally have been caught earlier, and will |
560 | // only occur if something blocks the user between the earlier |
561 | // check and the check in EditPage (presumably a hook). It's not |
562 | // obvious that this is even possible. |
563 | // @codeCoverageIgnoreStart |
564 | case EditPage::AS_BLOCKED_PAGE_FOR_USER: |
565 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null |
566 | $this->dieBlocked( $user->getBlock() ); |
567 | // dieBlocked prevents continuation |
568 | |
569 | case EditPage::AS_READ_ONLY_PAGE: |
570 | $this->dieReadOnly(); |
571 | // @codeCoverageIgnoreEnd |
572 | |
573 | case EditPage::AS_SUCCESS_NEW_ARTICLE: |
574 | $r['new'] = true; |
575 | // fall-through |
576 | |
577 | case EditPage::AS_SUCCESS_UPDATE: |
578 | $r['result'] = 'Success'; |
579 | $r['pageid'] = (int)$titleObj->getArticleID(); |
580 | $r['title'] = $titleObj->getPrefixedText(); |
581 | $r['contentmodel'] = $articleObject->getPage()->getContentModel(); |
582 | $newRevId = $articleObject->getPage()->getLatest(); |
583 | if ( $newRevId == $oldRevId ) { |
584 | $r['nochange'] = true; |
585 | } else { |
586 | $r['oldrevid'] = (int)$oldRevId; |
587 | $r['newrevid'] = (int)$newRevId; |
588 | $r['newtimestamp'] = wfTimestamp( TS_ISO_8601, |
589 | $pageObj->getTimestamp() ); |
590 | } |
591 | |
592 | if ( $watch ) { |
593 | $r['watched'] = true; |
594 | |
595 | $watchlistExpiry = $this->getWatchlistExpiry( |
596 | $this->watchedItemStore, |
597 | $titleObj, |
598 | $user |
599 | ); |
600 | |
601 | if ( $watchlistExpiry ) { |
602 | $r['watchlistexpiry'] = $watchlistExpiry; |
603 | } |
604 | } |
605 | $this->persistGlobalSession(); |
606 | |
607 | if ( isset( $result['savedTempUser'] ) ) { |
608 | $r['tempusercreated'] = true; |
609 | $params['returnto'] ??= $titleObj->getPrefixedDBkey(); |
610 | $redirectUrl = $this->getTempUserRedirectUrl( |
611 | $params, |
612 | $result['savedTempUser'] |
613 | ); |
614 | if ( $redirectUrl ) { |
615 | $r['tempusercreatedredirect'] = $redirectUrl; |
616 | } |
617 | } |
618 | |
619 | break; |
620 | |
621 | default: |
622 | if ( !$status->getErrors() ) { |
623 | // EditPage sometimes only sets the status code without setting |
624 | // any actual error messages. Supply defaults for those cases. |
625 | switch ( $statusValue ) { |
626 | // Currently needed |
627 | case EditPage::AS_IMAGE_REDIRECT_ANON: |
628 | $status->fatal( 'apierror-noimageredirect-anon' ); |
629 | break; |
630 | case EditPage::AS_IMAGE_REDIRECT_LOGGED: |
631 | $status->fatal( 'apierror-noimageredirect' ); |
632 | break; |
633 | case EditPage::AS_CONTENT_TOO_BIG: |
634 | case EditPage::AS_MAX_ARTICLE_SIZE_EXCEEDED: |
635 | $status->fatal( 'apierror-contenttoobig', |
636 | $this->getConfig()->get( MainConfigNames::MaxArticleSize ) ); |
637 | break; |
638 | case EditPage::AS_READ_ONLY_PAGE_ANON: |
639 | $status->fatal( 'apierror-noedit-anon' ); |
640 | break; |
641 | case EditPage::AS_NO_CHANGE_CONTENT_MODEL: |
642 | $status->fatal( 'apierror-cantchangecontentmodel' ); |
643 | break; |
644 | case EditPage::AS_ARTICLE_WAS_DELETED: |
645 | $status->fatal( 'apierror-pagedeleted' ); |
646 | break; |
647 | case EditPage::AS_CONFLICT_DETECTED: |
648 | $status->fatal( 'edit-conflict' ); |
649 | break; |
650 | |
651 | // Currently shouldn't be needed, but here in case |
652 | // hooks use them without setting appropriate |
653 | // errors on the status. |
654 | // @codeCoverageIgnoreStart |
655 | case EditPage::AS_SPAM_ERROR: |
656 | // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset |
657 | $status->fatal( 'apierror-spamdetected', $result['spam'] ); |
658 | break; |
659 | case EditPage::AS_READ_ONLY_PAGE_LOGGED: |
660 | $status->fatal( 'apierror-noedit' ); |
661 | break; |
662 | case EditPage::AS_RATE_LIMITED: |
663 | $status->fatal( 'apierror-ratelimited' ); |
664 | break; |
665 | case EditPage::AS_NO_CREATE_PERMISSION: |
666 | $status->fatal( 'nocreate-loggedin' ); |
667 | break; |
668 | case EditPage::AS_BLANK_ARTICLE: |
669 | $status->fatal( 'apierror-emptypage' ); |
670 | break; |
671 | case EditPage::AS_TEXTBOX_EMPTY: |
672 | $status->fatal( 'apierror-emptynewsection' ); |
673 | break; |
674 | case EditPage::AS_SUMMARY_NEEDED: |
675 | $status->fatal( 'apierror-summaryrequired' ); |
676 | break; |
677 | default: |
678 | wfWarn( __METHOD__ . ": Unknown EditPage code $statusValue with no message" ); |
679 | $status->fatal( 'apierror-unknownerror-editpage', $statusValue ); |
680 | break; |
681 | // @codeCoverageIgnoreEnd |
682 | } |
683 | } |
684 | $this->dieStatus( $status ); |
685 | } |
686 | $apiResult->addValue( null, $this->getModuleName(), $r ); |
687 | } |
688 | |
689 | public function mustBePosted() { |
690 | return true; |
691 | } |
692 | |
693 | public function isWriteMode() { |
694 | return true; |
695 | } |
696 | |
697 | public function getAllowedParams() { |
698 | $params = [ |
699 | 'title' => [ |
700 | ParamValidator::PARAM_TYPE => 'string', |
701 | ], |
702 | 'pageid' => [ |
703 | ParamValidator::PARAM_TYPE => 'integer', |
704 | ], |
705 | 'section' => null, |
706 | 'sectiontitle' => [ |
707 | ParamValidator::PARAM_TYPE => 'string', |
708 | ], |
709 | 'text' => [ |
710 | ParamValidator::PARAM_TYPE => 'text', |
711 | ], |
712 | 'summary' => null, |
713 | 'tags' => [ |
714 | ParamValidator::PARAM_TYPE => 'tags', |
715 | ParamValidator::PARAM_ISMULTI => true, |
716 | ], |
717 | 'minor' => false, |
718 | 'notminor' => false, |
719 | 'bot' => false, |
720 | 'baserevid' => [ |
721 | ParamValidator::PARAM_TYPE => 'integer', |
722 | ], |
723 | 'basetimestamp' => [ |
724 | ParamValidator::PARAM_TYPE => 'timestamp', |
725 | ], |
726 | 'starttimestamp' => [ |
727 | ParamValidator::PARAM_TYPE => 'timestamp', |
728 | ], |
729 | 'recreate' => false, |
730 | 'createonly' => false, |
731 | 'nocreate' => false, |
732 | 'watch' => [ |
733 | ParamValidator::PARAM_DEFAULT => false, |
734 | ParamValidator::PARAM_DEPRECATED => true, |
735 | ], |
736 | 'unwatch' => [ |
737 | ParamValidator::PARAM_DEFAULT => false, |
738 | ParamValidator::PARAM_DEPRECATED => true, |
739 | ], |
740 | ]; |
741 | |
742 | // Params appear in the docs in the order they are defined, |
743 | // which is why this is here and not at the bottom. |
744 | $params += $this->getWatchlistParams(); |
745 | |
746 | $params += [ |
747 | 'md5' => null, |
748 | 'prependtext' => [ |
749 | ParamValidator::PARAM_TYPE => 'text', |
750 | ], |
751 | 'appendtext' => [ |
752 | ParamValidator::PARAM_TYPE => 'text', |
753 | ], |
754 | 'undo' => [ |
755 | ParamValidator::PARAM_TYPE => 'integer', |
756 | IntegerDef::PARAM_MIN => 0, |
757 | ApiBase::PARAM_RANGE_ENFORCE => true, |
758 | ], |
759 | 'undoafter' => [ |
760 | ParamValidator::PARAM_TYPE => 'integer', |
761 | IntegerDef::PARAM_MIN => 0, |
762 | ApiBase::PARAM_RANGE_ENFORCE => true, |
763 | ], |
764 | 'redirect' => [ |
765 | ParamValidator::PARAM_TYPE => 'boolean', |
766 | ParamValidator::PARAM_DEFAULT => false, |
767 | ], |
768 | 'contentformat' => [ |
769 | ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getAllContentFormats(), |
770 | ], |
771 | 'contentmodel' => [ |
772 | ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getContentModels(), |
773 | ], |
774 | 'token' => [ |
775 | // Standard definition automatically inserted |
776 | ApiBase::PARAM_HELP_MSG_APPEND => [ 'apihelp-edit-param-token' ], |
777 | ], |
778 | ]; |
779 | |
780 | $params += $this->getCreateTempUserParams(); |
781 | |
782 | return $params; |
783 | } |
784 | |
785 | public function needsToken() { |
786 | return 'csrf'; |
787 | } |
788 | |
789 | protected function getExamplesMessages() { |
790 | return [ |
791 | 'action=edit&title=Test&summary=test%20summary&' . |
792 | 'text=article%20content&baserevid=1234567&token=123ABC' |
793 | => 'apihelp-edit-example-edit', |
794 | 'action=edit&title=Test&summary=NOTOC&minor=&' . |
795 | 'prependtext=__NOTOC__%0A&basetimestamp=2007-08-24T12:34:54Z&token=123ABC' |
796 | => 'apihelp-edit-example-prepend', |
797 | 'action=edit&title=Test&undo=13585&undoafter=13579&' . |
798 | 'basetimestamp=2007-08-24T12:34:54Z&token=123ABC' |
799 | => 'apihelp-edit-example-undo', |
800 | ]; |
801 | } |
802 | |
803 | public function getHelpUrls() { |
804 | return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Edit'; |
805 | } |
806 | } |