Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.91% covered (success)
90.91%
360 / 396
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiEditPage
90.91% covered (success)
90.91%
360 / 396
60.00% covered (warning)
60.00%
6 / 10
132.00
0.00% covered (danger)
0.00%
0 / 1
 persistGlobalSession
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getUserForPermissions
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 execute
92.55% covered (success)
92.55%
261 / 282
0.00% covered (danger)
0.00%
0 / 1
116.09
 mustBePosted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWriteMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedParams
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
1 / 1
1
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
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
23use MediaWiki\Content\IContentHandlerFactory;
24use MediaWiki\Context\RequestContext;
25use MediaWiki\EditPage\EditPage;
26use MediaWiki\MainConfigNames;
27use MediaWiki\MediaWikiServices;
28use MediaWiki\Page\RedirectLookup;
29use MediaWiki\Page\WikiPageFactory;
30use MediaWiki\Request\DerivativeRequest;
31use MediaWiki\Revision\RevisionLookup;
32use MediaWiki\Revision\RevisionRecord;
33use MediaWiki\Revision\SlotRecord;
34use MediaWiki\Title\Title;
35use MediaWiki\User\Options\UserOptionsLookup;
36use MediaWiki\User\TempUser\TempUserCreator;
37use MediaWiki\User\User;
38use MediaWiki\User\UserFactory;
39use MediaWiki\Watchlist\WatchlistManager;
40use Wikimedia\ParamValidator\ParamValidator;
41use 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 */
59class 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}