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