Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 375
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 / 375
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 / 49
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 Deflate;
14use FlaggablePageView;
15use MediaWiki\Api\ApiBase;
16use MediaWiki\Api\ApiMain;
17use MediaWiki\Content\ContentHandler;
18use MediaWiki\Context\DerivativeContext;
19use MediaWiki\Context\RequestContext;
20use MediaWiki\Diff\DifferenceEngine;
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\Skin\SkinFactory;
30use MediaWiki\SpecialPage\SpecialPageFactory;
31use MediaWiki\Storage\PageEditStash;
32use MediaWiki\Title\Title;
33use MediaWiki\User\UserIdentity;
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            if ( is_array( $allParams['wpWatchlistLabels'] ) ) {
113                $allParams['wpWatchlistLabels'] = array_values( array_filter(
114                    array_map( static fn ( $id ) => (int)$id, $allParams['wpWatchlistLabels'] ),
115                    static fn ( int $id ) => $id > 0
116                ) );
117            } elseif ( is_string( $allParams['wpWatchlistLabels'] ) && $allParams['wpWatchlistLabels'] !== '' ) {
118                $allParams['wpWatchlistLabels'] = array_values( array_filter(
119                    array_map(
120                        static fn ( string $id ) => (int)trim( $id ),
121                        preg_split( '/[|,]/', $allParams['wpWatchlistLabels'] ) ?: []
122                    ),
123                    static fn ( int $id ) => $id > 0
124                ) );
125            }
126        }
127        $knownParams = array_keys( $this->getAllowedParams() + $this->getMain()->getAllowedParams() );
128        foreach ( $knownParams as $knownParam ) {
129            unset( $allParams[ $knownParam ] );
130        }
131
132        $context = new DerivativeContext( $this->getContext() );
133        $context->setRequest(
134            new DerivativeRequest(
135                $context->getRequest(),
136                $apiParams + $allParams,
137                wasPosted: true
138            )
139        );
140        $api = new ApiMain( $context, enableWrite: true );
141
142        $api->execute();
143
144        return $api->getResult()->getResultData();
145    }
146
147    /**
148     * Load into an array the output of MediaWiki's parser for a given revision
149     *
150     * @param int $newRevId The revision to load
151     * @param array $params Original request params
152     * @return array Some properties haphazardly extracted from an action=parse API response
153     */
154    protected function parseWikitext( $newRevId, array $params ) {
155        $apiParams = [
156            'action' => 'parse',
157            'oldid' => $newRevId,
158            'prop' => 'text|revid|categorieshtml|sections|displaytitle|subtitle|modules|jsconfigvars',
159            'usearticle' => true,
160            'useskin' => $params['useskin'],
161        ];
162        // Boolean parameters must be omitted completely to be treated as false.
163        // Param is added by hook in MobileFrontend, so it may be unset.
164        if ( isset( $params['mobileformat'] ) && $params['mobileformat'] ) {
165            $apiParams['mobileformat'] = '1';
166        }
167
168        $context = new DerivativeContext( $this->getContext() );
169        $context->setRequest(
170            new DerivativeRequest(
171                $context->getRequest(),
172                $apiParams,
173                wasPosted: true
174            )
175        );
176        $api = new ApiMain( $context, enableWrite: true );
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->getStatsFactory()->getCounter( 'VE_editstash_serialization_cache_set_total' )
281            ->setLabel( 'status', $status )
282            ->increment();
283
284        // Also parse and prepare the edit in case it might be saved later
285        $pageUpdater = $this->wikiPageFactory->newFromTitle( $title )->newPageUpdater( $this->getUser() );
286        $content = ContentHandler::makeContent( $wikitext, $title, CONTENT_MODEL_WIKITEXT );
287
288        $status = $this->pageEditStash->parseAndCache( $pageUpdater, $content, $this->getUser(), '' );
289        if ( $status === $this->pageEditStash::ERROR_NONE ) {
290            $logger = LoggerFactory::getInstance( 'StashEdit' );
291            $logger->debug( "Cached parser output for VE content key '$key'." );
292        }
293        $this->getStatsFactory()->getCounter( 'VE_editstash_cache_store_total' )
294            ->setLabel( 'status', $status )
295            ->increment();
296
297        return $hash;
298    }
299
300    private function pruneExcessStashedEntries( BagOStuff $cache, UserIdentity $user, string $newKey ): void {
301        $key = $cache->makeKey( 'visualeditor-serialization-recent', $user->getName() );
302
303        $keyList = $cache->get( $key ) ?: [];
304        if ( count( $keyList ) >= self::MAX_CACHE_RECENT ) {
305            $oldestKey = array_shift( $keyList );
306            $cache->delete( $oldestKey );
307        }
308
309        $keyList[] = $newKey;
310        $cache->set( $key, $keyList, 2 * self::MAX_CACHE_TTL );
311    }
312
313    /**
314     * Load some parsed wikitext of an edit from the serialisation cache.
315     *
316     * @param string $hash The key of the wikitext in the serialisation cache
317     * @return string|false The wikitext
318     */
319    protected function trySerializationCache( $hash ) {
320        $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
321        $key = $cache->makeKey( 'visualeditor', 'serialization', $hash );
322        $value = $cache->get( $key );
323
324        $status = ( $value !== false ) ? 'hit' : 'miss';
325        $this->getStatsFactory()->getCounter( 'VE_editstash_serialization_cache_get_total' )
326            ->setLabel( 'status', $status )
327            ->increment();
328
329        return $value;
330    }
331
332    /**
333     * Calculate the different between the wikitext of an edit and an existing revision.
334     *
335     * @param Title $title The title of the page
336     * @param int|null $fromId The existing revision of the page to compare with
337     * @param string $wikitext The wikitext to compare against
338     * @param int|null $section Whether the wikitext refers to a given section or the whole page
339     * @return array The comparison, or `[ 'result' => 'nochanges' ]` if there are none
340     */
341    protected function diffWikitext( Title $title, ?int $fromId, $wikitext, $section = null ) {
342        $apiParams = [
343            'action' => 'compare',
344            'prop' => 'diff',
345            // Because we're just providing wikitext, we only care about the main slot
346            'slots' => SlotRecord::MAIN,
347            'fromtitle' => $title->getPrefixedDBkey(),
348            'fromrev' => $fromId,
349            'fromsection' => $section,
350            'toslots' => SlotRecord::MAIN,
351            'totext-main' => $wikitext,
352            'topst' => true,
353        ];
354
355        $context = new DerivativeContext( $this->getContext() );
356        $context->setRequest(
357            new DerivativeRequest(
358                $context->getRequest(),
359                $apiParams,
360                wasPosted: true
361            )
362        );
363        $api = new ApiMain( $context, enableWrite: false );
364        $api->execute();
365        $result = $api->getResult()->getResultData();
366
367        if ( !isset( $result['compare']['bodies'][SlotRecord::MAIN] ) ) {
368            $this->dieWithError( 'apierror-visualeditor-difffailed', 'difffailed' );
369        }
370        $diffRows = $result['compare']['bodies'][SlotRecord::MAIN];
371
372        $context = new DerivativeContext( $this->getContext() );
373        $context->setTitle( $title );
374        $engine = new DifferenceEngine( $context );
375        return [
376            'result' => 'success',
377            'diff' => $diffRows ? $engine->addHeader(
378                $diffRows,
379                $context->msg( 'currentrev' )->parse(),
380                $context->msg( 'yourtext' )->parse()
381            ) : ''
382        ];
383    }
384
385    /**
386     * @inheritDoc
387     */
388    public function execute() {
389        $user = $this->getUser();
390        $params = $this->extractRequestParams();
391
392        $result = [];
393        $title = Title::newFromText( $params['page'] );
394        if ( $title && $title->isSpecialPage() ) {
395            // Convert Special:CollabPad/MyPage to MyPage so we can serialize properly
396            [ $special, $subPage ] = $this->specialPageFactory->resolveAlias( $title->getDBkey() );
397            if ( $special === 'CollabPad' ) {
398                $title = Title::newFromText( $subPage );
399            }
400        }
401        if ( !$title ) {
402            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] );
403        }
404        if ( !$title->canExist() ) {
405            $this->dieWithError( 'apierror-pagecannotexist' );
406        }
407        $this->getErrorFormatter()->setContextTitle( $title );
408
409        $parserParams = [];
410        if ( isset( $params['oldid'] ) ) {
411            $parserParams['oldid'] = $params['oldid'];
412        }
413
414        if ( isset( $params['wikitext'] ) ) {
415            $wikitext = str_replace( "\r\n", "\n", $params['wikitext'] );
416        } else {
417            $wikitext = $this->getWikitext( $title, $params, $parserParams );
418        }
419
420        if ( $params['paction'] === 'serialize' ) {
421            $result = [ 'result' => 'success', 'content' => $wikitext ];
422        } elseif ( $params['paction'] === 'serializeforcache' ) {
423            $key = $this->storeInSerializationCache(
424                $title,
425                $wikitext
426            );
427            $result = [ 'result' => 'success', 'cachekey' => $key ];
428        } elseif ( $params['paction'] === 'diff' ) {
429            $section = $params['section'] ?? null;
430            $result = $this->diffWikitext( $title, $params['oldid'], $wikitext, $section );
431        } elseif ( $params['paction'] === 'save' ) {
432            $pluginData = [];
433            foreach ( $params['plugins'] ?? [] as $plugin ) {
434                $pluginData[$plugin] = $params['data-' . $plugin];
435            }
436            $presaveHook = $this->hookRunner->onVisualEditorApiVisualEditorEditPreSave(
437                $title->toPageIdentity(),
438                $user,
439                $wikitext,
440                $params,
441                $pluginData,
442                $result
443            );
444
445            if ( $presaveHook === false ) {
446                $this->dieWithError( $result['message'], 'hookaborted', $result );
447            }
448
449            $saveresult = $this->saveWikitext( $title, $wikitext, $params );
450            $editStatus = $saveresult['edit']['result'];
451
452            // Error
453            if ( $editStatus !== 'Success' ) {
454                $result['result'] = 'error';
455                $result['edit'] = $saveresult['edit'];
456            } else {
457                // Success
458                $result['result'] = 'success';
459
460                if ( $params['nocontent'] ) {
461                    $result['nocontent'] = true;
462                } else {
463                    if ( isset( $saveresult['edit']['newrevid'] ) ) {
464                        $newRevId = intval( $saveresult['edit']['newrevid'] );
465                    } else {
466                        $newRevId = $title->getLatestRevID();
467                    }
468
469                    // Return result of parseWikitext instead of saveWikitext so that the
470                    // frontend can update the page rendering without a refresh.
471                    $parseWikitextResult = $this->parseWikitext( $newRevId, $params );
472
473                    $result = array_merge( $result, $parseWikitextResult );
474                }
475
476                $result['isRedirect'] = (string)$title->isRedirect();
477
478                if ( ExtensionRegistry::getInstance()->isLoaded( 'FlaggedRevs' ) ) {
479                    $newContext = new DerivativeContext( RequestContext::getMain() );
480                    // Defeat !$this->isPageView( $request ) || $request->getVal( 'oldid' ) check in setPageContent
481                    $newRequest = new DerivativeRequest(
482                        $this->getRequest(),
483                        [
484                            'diff' => null,
485                            'oldid' => '',
486                            'title' => $title->getPrefixedText(),
487                            'action' => 'view'
488                        ] + $this->getRequest()->getValues()
489                    );
490                    $newContext->setRequest( $newRequest );
491                    $newContext->setTitle( $title );
492
493                    // Must be after $globalContext->setTitle since FlaggedRevs constructor
494                    // inspects global Title
495                    $view = FlaggablePageView::newFromTitle( $title );
496                    // Most likely identical to $globalState, but not our concern
497                    $originalContext = $view->getContext();
498                    $view->setContext( $newContext );
499
500                    // The two parameters here are references but we don't care
501                    // about what FlaggedRevs does with them.
502                    $outputDone = null;
503                    $useParserCache = null;
504                    // @phan-suppress-next-line PhanTypeMismatchArgument
505                    $view->setPageContent( $outputDone, $useParserCache );
506                    $view->displayTag();
507                    $view->setContext( $originalContext );
508                }
509
510                $lang = $this->getLanguage();
511
512                if ( isset( $saveresult['edit']['newtimestamp'] ) ) {
513                    $ts = $saveresult['edit']['newtimestamp'];
514
515                    $date = $lang->userDate( $ts, $user );
516                    $time = $lang->userTime( $ts, $user );
517
518                    $result['lastModified'] = [
519                        'date' => $date,
520                        'time' => $time,
521                        'message' => $this->msg( 'lastmodifiedat', $date, $time )->parse(),
522                    ];
523                }
524
525                if ( isset( $saveresult['edit']['newrevid'] ) ) {
526                    $result['newrevid'] = intval( $saveresult['edit']['newrevid'] );
527                }
528
529                if ( isset( $saveresult['edit']['tempusercreated'] ) ) {
530                    $result['tempusercreated'] = $saveresult['edit']['tempusercreated'];
531                }
532                if ( isset( $saveresult['edit']['tempusercreatedredirect'] ) ) {
533                    $result['tempusercreatedredirect'] = $saveresult['edit']['tempusercreatedredirect'];
534                }
535
536                $result['watched'] = $saveresult['edit']['watched'] ?? false;
537                $result['watchlistexpiry'] = $saveresult['edit']['watchlistexpiry'] ?? null;
538            }
539
540            // Refresh article ID (which is used by toPageIdentity()) in case we just created the page.
541            // Maybe it's not great to rely on this side-effect…
542            $title->getArticleID( IDBAccessObject::READ_LATEST );
543
544            $this->hookRunner->onVisualEditorApiVisualEditorEditPostSave(
545                $title->toPageIdentity(),
546                $user,
547                $wikitext,
548                $params,
549                $pluginData,
550                $saveresult,
551                $result
552            );
553        }
554        $this->getResult()->addValue( null, $this->getModuleName(), $result );
555    }
556
557    /**
558     * @inheritDoc
559     */
560    public function getAllowedParams() {
561        return [
562            'paction' => [
563                ParamValidator::PARAM_REQUIRED => true,
564                ParamValidator::PARAM_TYPE => [
565                    'serialize',
566                    'serializeforcache',
567                    'diff',
568                    'save',
569                ],
570            ],
571            'page' => [
572                ParamValidator::PARAM_REQUIRED => true,
573            ],
574            'token' => [
575                ParamValidator::PARAM_REQUIRED => true,
576            ],
577            'wikitext' => [
578                ParamValidator::PARAM_TYPE => 'text',
579                ParamValidator::PARAM_DEFAULT => null,
580            ],
581            'section' => null,
582            'sectiontitle' => null,
583            'basetimestamp' => [
584                ParamValidator::PARAM_TYPE => 'timestamp',
585            ],
586            'starttimestamp' => [
587                ParamValidator::PARAM_TYPE => 'timestamp',
588            ],
589            'oldid' => [
590                ParamValidator::PARAM_TYPE => 'integer',
591            ],
592            'minor' => null,
593            'watchlist' => null,
594            'html' => [
595                // Use the 'raw' type to avoid Unicode NFC normalization.
596                // This makes the parameter binary safe, so that (a) if
597                // we use client-side compression it is not mangled, and/or
598                // (b) deprecated Unicode sequences explicitly encoded in
599                // wikitext (ie, &#x2001;) are not mangled.  Wikitext is
600                // in Unicode Normal Form C, but because of explicit entities
601                // the output HTML is not guaranteed to be.
602                ParamValidator::PARAM_TYPE => 'raw',
603                ParamValidator::PARAM_DEFAULT => null,
604            ],
605            'etag' => null,
606            'summary' => null,
607            'captchaid' => null,
608            'captchaword' => null,
609            'cachekey' => null,
610            'nocontent' => false,
611            'returnto' => [
612                ParamValidator::PARAM_TYPE => 'title',
613                ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returnto',
614            ],
615            'returntoquery' => [
616                ParamValidator::PARAM_TYPE => 'string',
617                ParamValidator::PARAM_DEFAULT => '',
618                ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returntoquery',
619            ],
620            'returntoanchor' => [
621                ParamValidator::PARAM_TYPE => 'string',
622                ParamValidator::PARAM_DEFAULT => '',
623                ApiBase::PARAM_HELP_MSG => 'apihelp-edit-param-returntoanchor',
624            ],
625            'useskin' => [
626                ParamValidator::PARAM_TYPE => array_keys( $this->skinFactory->getInstalledSkins() ),
627                ApiBase::PARAM_HELP_MSG => 'apihelp-parse-param-useskin',
628            ],
629            'tags' => [
630                ParamValidator::PARAM_ISMULTI => true,
631            ],
632            'plugins' => [
633                ParamValidator::PARAM_ISMULTI => true,
634                ParamValidator::PARAM_TYPE => 'string',
635            ],
636            // Additional data sent by the client. Not used directly in the ApiVisualEditorEdit workflows, but
637            // is passed alongside the other parameters to implementations of onApiVisualEditorEditPostSave and
638            // onApiVisualEditorEditPreSave
639            'data-{plugin}' => [
640                ApiBase::PARAM_TEMPLATE_VARS => [ 'plugin' => 'plugins' ]
641            ]
642        ];
643    }
644
645    /**
646     * @inheritDoc
647     */
648    public function needsToken() {
649        return 'csrf';
650    }
651
652    /**
653     * @inheritDoc
654     */
655    public function isInternal() {
656        return true;
657    }
658
659    /**
660     * @inheritDoc
661     */
662    public function isWriteMode() {
663        return true;
664    }
665
666}