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