Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 376
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiVisualEditorEdit
0.00% covered (danger)
0.00%
0 / 376
0.00% covered (danger)
0.00%
0 / 15
3660
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getParsoidClient
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 saveWikitext
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
72
 parseWikitext
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
30
 getWikitext
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getWikitextNoCache
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 storeInSerializationCache
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 pruneExcessStashedEntries
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 trySerializationCache
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 diffWikitext
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
12
 execute
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 1
506
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 1
2
 needsToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isWriteMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Parsoid/RESTBase+MediaWiki API wrapper.
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2011-2021 VisualEditor Team and others; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\VisualEditor;
12
13use FlaggablePageView;
14use MediaWiki\Api\ApiBase;
15use MediaWiki\Api\ApiMain;
16use MediaWiki\Content\ContentHandler;
17use MediaWiki\Context\DerivativeContext;
18use MediaWiki\Context\RequestContext;
19use MediaWiki\Diff\DifferenceEngine;
20use MediaWiki\HookContainer\HookContainer;
21use MediaWiki\Logger\LoggerFactory;
22use MediaWiki\MediaWikiServices;
23use MediaWiki\Page\WikiPageFactory;
24use MediaWiki\Parser\Sanitizer;
25use MediaWiki\Registration\ExtensionRegistry;
26use MediaWiki\Request\DerivativeRequest;
27use MediaWiki\Revision\SlotRecord;
28use MediaWiki\Skin\SkinFactory;
29use MediaWiki\SpecialPage\SpecialPageFactory;
30use MediaWiki\Storage\PageEditStash;
31use MediaWiki\Title\Title;
32use MediaWiki\User\UserIdentity;
33use Wikimedia\Deflate;
34use Wikimedia\ObjectCache\BagOStuff;
35use Wikimedia\ParamValidator\ParamValidator;
36use Wikimedia\Rdbms\IDBAccessObject;
37use Wikimedia\Stats\StatsFactory;
38
39class ApiVisualEditorEdit extends ApiBase {
40    use ApiParsoidTrait;
41
42    private const MAX_CACHE_RECENT = 2;
43    private const MAX_CACHE_TTL = 900;
44
45    private readonly VisualEditorHookRunner $hookRunner;
46
47    public function __construct(
48        ApiMain $main,
49        string $name,
50        HookContainer $hookContainer,
51        StatsFactory $statsFactory,
52        private readonly PageEditStash $pageEditStash,
53        private readonly SkinFactory $skinFactory,
54        private readonly WikiPageFactory $wikiPageFactory,
55        private readonly SpecialPageFactory $specialPageFactory,
56        private readonly VisualEditorParsoidClientFactory $parsoidClientFactory
57    ) {
58        parent::__construct( $main, $name );
59        $this->setLogger( LoggerFactory::getInstance( 'VisualEditor' ) );
60        $this->setStatsFactory( $statsFactory );
61        $this->hookRunner = new VisualEditorHookRunner( $hookContainer );
62    }
63
64    /**
65     * @inheritDoc
66     */
67    protected function getParsoidClient(): ParsoidClient {
68        return $this->parsoidClientFactory->createParsoidClient(
69            $this->getRequest()->getHeader( 'Cookie' )
70        );
71    }
72
73    /**
74     * Attempt to save a given page's wikitext to MediaWiki's storage layer via its API
75     *
76     * @param Title $title The title of the page to write
77     * @param string $wikitext The wikitext to write
78     * @param array $params The edit parameters
79     * @return mixed The result of the save attempt
80     */
81    protected function saveWikitext( Title $title, $wikitext, $params ) {
82        $apiParams = [
83            'action' => 'edit',
84            'title' => $title->getPrefixedDBkey(),
85            'text' => $wikitext,
86            'summary' => $params['summary'],
87            'basetimestamp' => $params['basetimestamp'],
88            'starttimestamp' => $params['starttimestamp'],
89            'token' => $params['token'],
90            'watchlist' => $params['watchlist'],
91            // NOTE: Must use getText() to work; PHP array from $params['tags'] is not understood
92            // by the edit API.
93            'tags' => $this->getRequest()->getText( 'tags' ),
94            'section' => $params['section'],
95            'sectiontitle' => $params['sectiontitle'],
96            'captchaid' => $params['captchaid'],
97            'captchaword' => $params['captchaword'],
98            'returnto' => $params['returnto'],
99            'returntoquery' => $params['returntoquery'],
100            'returntoanchor' => $params['returntoanchor'],
101            'errorformat' => 'html',
102            ( $params['minor'] !== null ? 'minor' : 'notminor' ) => true,
103        ];
104
105        // Pass any unrecognized query parameters to the internal action=edit API request. This is
106        // necessary to support extensions that add extra stuff to the edit form (e.g. FlaggedRevs)
107        // and allows passing any other query parameters to be used for edit tagging (e.g. T209132).
108        // Exclude other known params from here and ApiMain.
109        // TODO: This doesn't exclude params from the formatter
110        $allParams = $this->getRequest()->getValues();
111        if ( isset( $allParams['wpWatchlistLabels'] ) ) {
112            $allParams['wpWatchlistLabelsSubmitted'] = '1';
113            if ( is_array( $allParams['wpWatchlistLabels'] ) ) {
114                $allParams['wpWatchlistLabels'] = array_values( array_filter(
115                    array_map( static fn ( $id ) => (int)$id, $allParams['wpWatchlistLabels'] ),
116                    static fn ( int $id ) => $id > 0
117                ) );
118            } elseif ( is_string( $allParams['wpWatchlistLabels'] ) && $allParams['wpWatchlistLabels'] !== '' ) {
119                $allParams['wpWatchlistLabels'] = array_values( array_filter(
120                    array_map(
121                        static fn ( string $id ) => (int)trim( $id ),
122                        preg_split( '/[|,]/', $allParams['wpWatchlistLabels'] ) ?: []
123                    ),
124                    static fn ( int $id ) => $id > 0
125                ) );
126            }
127        }
128        $knownParams = array_keys( $this->getAllowedParams() + $this->getMain()->getAllowedParams() );
129        foreach ( $knownParams as $knownParam ) {
130            unset( $allParams[ $knownParam ] );
131        }
132
133        $context = new DerivativeContext( $this->getContext() );
134        $context->setRequest(
135            new DerivativeRequest(
136                $context->getRequest(),
137                $apiParams + $allParams,
138                wasPosted: true
139            )
140        );
141        $api = new ApiMain( $context, enableWrite: true );
142
143        $api->execute();
144
145        return $api->getResult()->getResultData();
146    }
147
148    /**
149     * Load into an array the output of MediaWiki's parser for a given revision
150     *
151     * @param int $newRevId The revision to load
152     * @param array $params Original request params
153     * @return array Some properties haphazardly extracted from an action=parse API response
154     */
155    protected function parseWikitext( $newRevId, array $params ) {
156        $apiParams = [
157            'action' => 'parse',
158            'oldid' => $newRevId,
159            'prop' => 'text|revid|categorieshtml|sections|displaytitle|subtitle|modules|jsconfigvars',
160            'usearticle' => true,
161            'useskin' => $params['useskin'],
162        ];
163        // Boolean parameters must be omitted completely to be treated as false.
164        // Param is added by hook in MobileFrontend, so it may be unset.
165        if ( isset( $params['mobileformat'] ) && $params['mobileformat'] ) {
166            $apiParams['mobileformat'] = '1';
167        }
168
169        $context = new DerivativeContext( $this->getContext() );
170        $context->setRequest(
171            new DerivativeRequest(
172                $context->getRequest(),
173                $apiParams,
174                wasPosted: true
175            )
176        );
177        $api = new ApiMain( $context, enableWrite: true );
178
179        $api->execute();
180        $result = $api->getResult()->getResultData( null, [
181            /* Transform content nodes to '*' */ 'BC' => [],
182            /* Add back-compat subelements */ 'Types' => [],
183            /* Remove any metadata keys from the links array */ 'Strip' => 'all',
184        ] );
185        $content = $result['parse']['text']['*'] ?? false;
186        $categorieshtml = $result['parse']['categorieshtml']['*'] ?? false;
187        $sections = isset( $result['parse']['showtoc'] ) ? $result['parse']['sections'] : [];
188        $displaytitle = $result['parse']['displaytitle'] ?? false;
189        $subtitle = $result['parse']['subtitle'] ?? false;
190        $modules = array_merge(
191            $result['parse']['modules'] ?? [],
192            $result['parse']['modulestyles'] ?? []
193        );
194        $jsconfigvars = $result['parse']['jsconfigvars'] ?? [];
195
196        if ( $displaytitle !== false ) {
197            // Escape entities as in OutputPage::setPageTitle()
198            $displaytitle = Sanitizer::removeSomeTags( $displaytitle );
199        }
200
201        return [
202            'content' => $content,
203            'categorieshtml' => $categorieshtml,
204            'sections' => $sections,
205            'displayTitleHtml' => $displaytitle,
206            'contentSub' => $subtitle,
207            'modules' => $modules,
208            'jsconfigvars' => $jsconfigvars
209        ];
210    }
211
212    /**
213     * Create and load the parsed wikitext of an edit, or from the serialisation cache if available.
214     *
215     * @param Title $title The title of the page
216     * @param array $params The edit parameters
217     * @param array $parserParams The parser parameters
218     * @return string The wikitext of the edit
219     */
220    protected function getWikitext( Title $title, $params, $parserParams ) {
221        if ( $params['cachekey'] !== null ) {
222            $wikitext = $this->trySerializationCache( $params['cachekey'] );
223            if ( !is_string( $wikitext ) ) {
224                $this->dieWithError( 'apierror-visualeditor-badcachekey', 'badcachekey' );
225            }
226        } else {
227            $wikitext = $this->getWikitextNoCache( $title, $params, $parserParams );
228        }
229        '@phan-var string $wikitext';
230        return $wikitext;
231    }
232
233    /**
234     * Create and load the parsed wikitext of an edit, ignoring the serialisation cache.
235     *
236     * @param Title $title The title of the page
237     * @param array $params The edit parameters
238     * @param array $parserParams The parser parameters
239     * @return string The wikitext of the edit
240     */
241    protected function getWikitextNoCache( Title $title, $params, $parserParams ) {
242        $this->requireOnlyOneParameter( $params, 'html' );
243        if ( Deflate::isDeflated( $params['html'] ) ) {
244            $status = Deflate::inflate( $params['html'] );
245            if ( !$status->isGood() ) {
246                $this->dieWithError( 'deflate-invaliddeflate', 'invaliddeflate' );
247            }
248            $html = $status->getValue();
249        } else {
250            $html = $params['html'];
251        }
252        $wikitext = $this->transformHTML(
253            $title, $html, $parserParams['oldid'] ?? null, $params['etag'] ?? null
254        )['body'];
255        return $wikitext;
256    }
257
258    /**
259     * Load the parsed wikitext of an edit into the serialisation cache.
260     *
261     * @param Title $title The title of the page
262     * @param string $wikitext The wikitext of the edit
263     * @return string|false The key of the wikitext in the serialisation cache
264     */
265    protected function storeInSerializationCache( Title $title, $wikitext ) {
266        if ( $wikitext === false ) {
267            return false;
268        }
269
270        $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
271
272        // Store the corresponding wikitext, referenceable by a new key
273        $hash = md5( $wikitext );
274        $key = $cache->makeKey( 'visualeditor', 'serialization', $hash );
275        $ok = $cache->set( $key, $wikitext, self::MAX_CACHE_TTL );
276        if ( $ok ) {
277            $this->pruneExcessStashedEntries( $cache, $this->getUser(), $key );
278        }
279
280        $status = $ok ? 'ok' : 'failed';
281        $this->getStatsFactory()->getCounter( 'VE_editstash_serialization_cache_set_total' )
282            ->setLabel( 'status', $status )
283            ->increment();
284
285        // Also parse and prepare the edit in case it might be saved later
286        $pageUpdater = $this->wikiPageFactory->newFromTitle( $title )->newPageUpdater( $this->getUser() );
287        $content = ContentHandler::makeContent( $wikitext, $title, CONTENT_MODEL_WIKITEXT );
288
289        $status = $this->pageEditStash->parseAndCache( $pageUpdater, $content, $this->getUser(), '' );
290        if ( $status === $this->pageEditStash::ERROR_NONE ) {
291            $logger = LoggerFactory::getInstance( 'StashEdit' );
292            $logger->debug( "Cached parser output for VE content key '$key'." );
293        }
294        $this->getStatsFactory()->getCounter( 'VE_editstash_cache_store_total' )
295            ->setLabel( 'status', $status )
296            ->increment();
297
298        return $hash;
299    }
300
301    private function pruneExcessStashedEntries( BagOStuff $cache, UserIdentity $user, string $newKey ): void {
302        $key = $cache->makeKey( 'visualeditor-serialization-recent', $user->getName() );
303
304        $keyList = $cache->get( $key ) ?: [];
305        if ( count( $keyList ) >= self::MAX_CACHE_RECENT ) {
306            $oldestKey = array_shift( $keyList );
307            $cache->delete( $oldestKey );
308        }
309
310        $keyList[] = $newKey;
311        $cache->set( $key, $keyList, 2 * self::MAX_CACHE_TTL );
312    }
313
314    /**
315     * Load some parsed wikitext of an edit from the serialisation cache.
316     *
317     * @param string $hash The key of the wikitext in the serialisation cache
318     * @return string|false The wikitext
319     */
320    protected function trySerializationCache( $hash ) {
321        $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
322        $key = $cache->makeKey( 'visualeditor', 'serialization', $hash );
323        $value = $cache->get( $key );
324
325        $status = ( $value !== false ) ? 'hit' : 'miss';
326        $this->getStatsFactory()->getCounter( 'VE_editstash_serialization_cache_get_total' )
327            ->setLabel( 'status', $status )
328            ->increment();
329
330        return $value;
331    }
332
333    /**
334     * Calculate the different between the wikitext of an edit and an existing revision.
335     *
336     * @param Title $title The title of the page
337     * @param int|null $fromId The existing revision of the page to compare with
338     * @param string $wikitext The wikitext to compare against
339     * @param int|null $section Whether the wikitext refers to a given section or the whole page
340     * @return array The comparison, or `[ 'result' => 'nochanges' ]` if there are none
341     */
342    protected function diffWikitext( Title $title, ?int $fromId, $wikitext, $section = null ) {
343        $apiParams = [
344            'action' => 'compare',
345            'prop' => 'diff',
346            // Because we're just providing wikitext, we only care about the main slot
347            'slots' => SlotRecord::MAIN,
348            'fromtitle' => $title->getPrefixedDBkey(),
349            'fromrev' => $fromId,
350            'fromsection' => $section,
351            'toslots' => SlotRecord::MAIN,
352            'totext-main' => $wikitext,
353            'topst' => true,
354        ];
355
356        $context = new DerivativeContext( $this->getContext() );
357        $context->setRequest(
358            new DerivativeRequest(
359                $context->getRequest(),
360                $apiParams,
361                wasPosted: true
362            )
363        );
364        $api = new ApiMain( $context, enableWrite: false );
365        $api->execute();
366        $result = $api->getResult()->getResultData();
367
368        if ( !isset( $result['compare']['bodies'][SlotRecord::MAIN] ) ) {
369            $this->dieWithError( 'apierror-visualeditor-difffailed', 'difffailed' );
370        }
371        $diffRows = $result['compare']['bodies'][SlotRecord::MAIN];
372
373        $context = new DerivativeContext( $this->getContext() );
374        $context->setTitle( $title );
375        $engine = new DifferenceEngine( $context );
376        return [
377            'result' => 'success',
378            'diff' => $diffRows ? $engine->addHeader(
379                $diffRows,
380                $context->msg( 'currentrev' )->parse(),
381                $context->msg( 'yourtext' )->parse()
382            ) : ''
383        ];
384    }
385
386    /**
387     * @inheritDoc
388     */
389    public function execute() {
390        $user = $this->getUser();
391        $params = $this->extractRequestParams();
392
393        $result = [];
394        $title = Title::newFromText( $params['page'] );
395        if ( $title && $title->isSpecialPage() ) {
396            // Convert Special:CollabPad/MyPage to MyPage so we can serialize properly
397            [ $special, $subPage ] = $this->specialPageFactory->resolveAlias( $title->getDBkey() );
398            if ( $special === 'CollabPad' ) {
399                $title = Title::newFromText( $subPage );
400            }
401        }
402        if ( !$title ) {
403            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] );
404        }
405        if ( !$title->canExist() ) {
406            $this->dieWithError( 'apierror-pagecannotexist' );
407        }
408        $this->getErrorFormatter()->setContextTitle( $title );
409
410        $parserParams = [];
411        if ( isset( $params['oldid'] ) ) {
412            $parserParams['oldid'] = $params['oldid'];
413        }
414
415        if ( isset( $params['wikitext'] ) ) {
416            $wikitext = str_replace( "\r\n", "\n", $params['wikitext'] );
417        } else {
418            $wikitext = $this->getWikitext( $title, $params, $parserParams );
419        }
420
421        if ( $params['paction'] === 'serialize' ) {
422            $result = [ 'result' => 'success', 'content' => $wikitext ];
423        } elseif ( $params['paction'] === 'serializeforcache' ) {
424            $key = $this->storeInSerializationCache(
425                $title,
426                $wikitext
427            );
428            $result = [ 'result' => 'success', 'cachekey' => $key ];
429        } elseif ( $params['paction'] === 'diff' ) {
430            $section = $params['section'] ?? null;
431            $result = $this->diffWikitext( $title, $params['oldid'], $wikitext, $section );
432        } elseif ( $params['paction'] === 'save' ) {
433            $pluginData = [];
434            foreach ( $params['plugins'] ?? [] as $plugin ) {
435                $pluginData[$plugin] = $params['data-' . $plugin];
436            }
437            $presaveHook = $this->hookRunner->onVisualEditorApiVisualEditorEditPreSave(
438                $title->toPageIdentity(),
439                $user,
440                $wikitext,
441                $params,
442                $pluginData,
443                $result
444            );
445
446            if ( $presaveHook === false ) {
447                $this->dieWithError( $result['message'], 'hookaborted', $result );
448            }
449
450            $saveresult = $this->saveWikitext( $title, $wikitext, $params );
451            $editStatus = $saveresult['edit']['result'];
452
453            // Error
454            if ( $editStatus !== 'Success' ) {
455                $result['result'] = 'error';
456                $result['edit'] = $saveresult['edit'];
457            } else {
458                // Success
459                $result['result'] = 'success';
460
461                if ( $params['nocontent'] ) {
462                    $result['nocontent'] = true;
463                } else {
464                    if ( isset( $saveresult['edit']['newrevid'] ) ) {
465                        $newRevId = intval( $saveresult['edit']['newrevid'] );
466                    } else {
467                        $newRevId = $title->getLatestRevID();
468                    }
469
470                    // Return result of parseWikitext instead of saveWikitext so that the
471                    // frontend can update the page rendering without a refresh.
472                    $parseWikitextResult = $this->parseWikitext( $newRevId, $params );
473
474                    $result = array_merge( $result, $parseWikitextResult );
475                }
476
477                $result['isRedirect'] = (string)$title->isRedirect();
478
479                if ( ExtensionRegistry::getInstance()->isLoaded( 'FlaggedRevs' ) ) {
480                    $newContext = new DerivativeContext( RequestContext::getMain() );
481                    // Defeat !$this->isPageView( $request ) || $request->getVal( 'oldid' ) check in setPageContent
482                    $newRequest = new DerivativeRequest(
483                        $this->getRequest(),
484                        [
485                            'diff' => null,
486                            'oldid' => '',
487                            'title' => $title->getPrefixedText(),
488                            'action' => 'view'
489                        ] + $this->getRequest()->getValues()
490                    );
491                    $newContext->setRequest( $newRequest );
492                    $newContext->setTitle( $title );
493
494                    // Must be after $globalContext->setTitle since FlaggedRevs constructor
495                    // inspects global Title
496                    $view = FlaggablePageView::newFromTitle( $title );
497                    // Most likely identical to $globalState, but not our concern
498                    $originalContext = $view->getContext();
499                    $view->setContext( $newContext );
500
501                    // The two parameters here are references but we don't care
502                    // about what FlaggedRevs does with them.
503                    $outputDone = null;
504                    $useParserCache = null;
505                    // @phan-suppress-next-line PhanTypeMismatchArgument
506                    $view->setPageContent( $outputDone, $useParserCache );
507                    $view->displayTag();
508                    $view->setContext( $originalContext );
509                }
510
511                $lang = $this->getLanguage();
512
513                if ( isset( $saveresult['edit']['newtimestamp'] ) ) {
514                    $ts = $saveresult['edit']['newtimestamp'];
515
516                    $date = $lang->userDate( $ts, $user );
517                    $time = $lang->userTime( $ts, $user );
518
519                    $result['lastModified'] = [
520                        'date' => $date,
521                        'time' => $time,
522                        'message' => $this->msg( 'lastmodifiedat', $date, $time )->parse(),
523                    ];
524                }
525
526                if ( isset( $saveresult['edit']['newrevid'] ) ) {
527                    $result['newrevid'] = intval( $saveresult['edit']['newrevid'] );
528                }
529
530                if ( isset( $saveresult['edit']['tempusercreated'] ) ) {
531                    $result['tempusercreated'] = $saveresult['edit']['tempusercreated'];
532                }
533                if ( isset( $saveresult['edit']['tempusercreatedredirect'] ) ) {
534                    $result['tempusercreatedredirect'] = $saveresult['edit']['tempusercreatedredirect'];
535                }
536
537                $result['watched'] = $saveresult['edit']['watched'] ?? false;
538                $result['watchlistexpiry'] = $saveresult['edit']['watchlistexpiry'] ?? null;
539            }
540
541            // Refresh article ID (which is used by toPageIdentity()) in case we just created the page.
542            // Maybe it's not great to rely on this side-effect…
543            $title->getArticleID( IDBAccessObject::READ_LATEST );
544
545            $this->hookRunner->onVisualEditorApiVisualEditorEditPostSave(
546                $title->toPageIdentity(),
547                $user,
548                $wikitext,
549                $params,
550                $pluginData,
551                $saveresult,
552                $result
553            );
554        }
555        $this->getResult()->addValue( null, $this->getModuleName(), $result );
556    }
557
558    /**
559     * @inheritDoc
560     */
561    public function getAllowedParams() {
562        return [
563            'paction' => [
564                ParamValidator::PARAM_REQUIRED => true,
565                ParamValidator::PARAM_TYPE => [
566                    'serialize',
567                    'serializeforcache',
568                    'diff',
569                    'save',
570                ],
571            ],
572            'page' => [
573                ParamValidator::PARAM_REQUIRED => true,
574            ],
575            'token' => [
576                ParamValidator::PARAM_REQUIRED => true,
577            ],
578            'wikitext' => [
579                ParamValidator::PARAM_TYPE => 'text',
580                ParamValidator::PARAM_DEFAULT => null,
581            ],
582            'section' => null,
583            'sectiontitle' => null,
584            'basetimestamp' => [
585                ParamValidator::PARAM_TYPE => 'timestamp',
586            ],
587            'starttimestamp' => [
588                ParamValidator::PARAM_TYPE => 'timestamp',
589            ],
590            'oldid' => [
591                ParamValidator::PARAM_TYPE => 'integer',
592            ],
593            'minor' => null,
594            'watchlist' => null,
595            'html' => [
596                // Use the 'raw' type to avoid Unicode NFC normalization.
597                // This makes the parameter binary safe, so that (a) if
598                // we use client-side compression it is not mangled, and/or
599                // (b) deprecated Unicode sequences explicitly encoded in
600                // wikitext (ie, &#x2001;) are not mangled.  Wikitext is
601                // in Unicode Normal Form C, but because of explicit entities
602                // the output HTML is not guaranteed to be.
603                ParamValidator::PARAM_TYPE => 'raw',
604                ParamValidator::PARAM_DEFAULT => null,
605            ],
606            'etag' => null,
607            'summary' => null,
608            'captchaid' => null,
609            'captchaword' => null,
610            'cachekey' => null,
611            'nocontent' => false,
612            'returnto' => [
613                ParamValidator::PARAM_TYPE => 'title',
614                ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returnto',
615            ],
616            'returntoquery' => [
617                ParamValidator::PARAM_TYPE => 'string',
618                ParamValidator::PARAM_DEFAULT => '',
619                ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returntoquery',
620            ],
621            'returntoanchor' => [
622                ParamValidator::PARAM_TYPE => 'string',
623                ParamValidator::PARAM_DEFAULT => '',
624                ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returntoanchor',
625            ],
626            'useskin' => [
627                ParamValidator::PARAM_TYPE => array_keys( $this->skinFactory->getInstalledSkins() ),
628                ApiBase::PARAM_HELP_MSG => 'apihelp-parse-param-useskin',
629            ],
630            'tags' => [
631                ParamValidator::PARAM_ISMULTI => true,
632            ],
633            'plugins' => [
634                ParamValidator::PARAM_ISMULTI => true,
635                ParamValidator::PARAM_TYPE => 'string',
636            ],
637            // Additional data sent by the client. Not used directly in the ApiVisualEditorEdit workflows, but
638            // is passed alongside the other parameters to implementations of onApiVisualEditorEditPostSave and
639            // onApiVisualEditorEditPreSave
640            'data-{plugin}' => [
641                ApiBase::PARAM_TEMPLATE_VARS => [ 'plugin' => 'plugins' ]
642            ]
643        ];
644    }
645
646    /**
647     * @inheritDoc
648     */
649    public function needsToken() {
650        return 'csrf';
651    }
652
653    /**
654     * @inheritDoc
655     */
656    public function isInternal() {
657        return true;
658    }
659
660    /**
661     * @inheritDoc
662     */
663    public function isWriteMode() {
664        return true;
665    }
666
667}