Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 370
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 / 370
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 / 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 / 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 / 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 / 39
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 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\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        $knownParams = array_keys( $this->getAllowedParams() + $this->getMain()->getAllowedParams() );
112        foreach ( $knownParams as $knownParam ) {
113            unset( $allParams[ $knownParam ] );
114        }
115
116        $context = new DerivativeContext( $this->getContext() );
117        $context->setRequest(
118            new DerivativeRequest(
119                $context->getRequest(),
120                $apiParams + $allParams,
121                /* was posted? */ true
122            )
123        );
124        $api = new ApiMain(
125            $context,
126            /* enable write? */ true
127        );
128
129        $api->execute();
130
131        return $api->getResult()->getResultData();
132    }
133
134    /**
135     * Load into an array the output of MediaWiki's parser for a given revision
136     *
137     * @param int $newRevId The revision to load
138     * @param array $params Original request params
139     * @return array Some properties haphazardly extracted from an action=parse API response
140     */
141    protected function parseWikitext( $newRevId, array $params ) {
142        $apiParams = [
143            'action' => 'parse',
144            'oldid' => $newRevId,
145            'prop' => 'text|revid|categorieshtml|sections|displaytitle|subtitle|modules|jsconfigvars',
146            'usearticle' => true,
147            'useskin' => $params['useskin'],
148        ];
149        // Boolean parameters must be omitted completely to be treated as false.
150        // Param is added by hook in MobileFrontend, so it may be unset.
151        if ( isset( $params['mobileformat'] ) && $params['mobileformat'] ) {
152            $apiParams['mobileformat'] = '1';
153        }
154
155        $context = new DerivativeContext( $this->getContext() );
156        $context->setRequest(
157            new DerivativeRequest(
158                $context->getRequest(),
159                $apiParams,
160                /* was posted? */ true
161            )
162        );
163        $api = new ApiMain(
164            $context,
165            /* enable write? */ true
166        );
167
168        $api->execute();
169        $result = $api->getResult()->getResultData( null, [
170            /* Transform content nodes to '*' */ 'BC' => [],
171            /* Add back-compat subelements */ 'Types' => [],
172            /* Remove any metadata keys from the links array */ 'Strip' => 'all',
173        ] );
174        $content = $result['parse']['text']['*'] ?? false;
175        $categorieshtml = $result['parse']['categorieshtml']['*'] ?? false;
176        $sections = isset( $result['parse']['showtoc'] ) ? $result['parse']['sections'] : [];
177        $displaytitle = $result['parse']['displaytitle'] ?? false;
178        $subtitle = $result['parse']['subtitle'] ?? false;
179        $modules = array_merge(
180            $result['parse']['modules'] ?? [],
181            $result['parse']['modulestyles'] ?? []
182        );
183        $jsconfigvars = $result['parse']['jsconfigvars'] ?? [];
184
185        if ( $displaytitle !== false ) {
186            // Escape entities as in OutputPage::setPageTitle()
187            $displaytitle = Sanitizer::removeSomeTags( $displaytitle );
188        }
189
190        return [
191            'content' => $content,
192            'categorieshtml' => $categorieshtml,
193            'sections' => $sections,
194            'displayTitleHtml' => $displaytitle,
195            'contentSub' => $subtitle,
196            'modules' => $modules,
197            'jsconfigvars' => $jsconfigvars
198        ];
199    }
200
201    /**
202     * Create and load the parsed wikitext of an edit, or from the serialisation cache if available.
203     *
204     * @param Title $title The title of the page
205     * @param array $params The edit parameters
206     * @param array $parserParams The parser parameters
207     * @return string The wikitext of the edit
208     */
209    protected function getWikitext( Title $title, $params, $parserParams ) {
210        if ( $params['cachekey'] !== null ) {
211            $wikitext = $this->trySerializationCache( $params['cachekey'] );
212            if ( !is_string( $wikitext ) ) {
213                $this->dieWithError( 'apierror-visualeditor-badcachekey', 'badcachekey' );
214            }
215        } else {
216            $wikitext = $this->getWikitextNoCache( $title, $params, $parserParams );
217        }
218        '@phan-var string $wikitext';
219        return $wikitext;
220    }
221
222    /**
223     * Create and load the parsed wikitext of an edit, ignoring the serialisation cache.
224     *
225     * @param Title $title The title of the page
226     * @param array $params The edit parameters
227     * @param array $parserParams The parser parameters
228     * @return string The wikitext of the edit
229     */
230    protected function getWikitextNoCache( Title $title, $params, $parserParams ) {
231        $this->requireOnlyOneParameter( $params, 'html' );
232        if ( Deflate::isDeflated( $params['html'] ) ) {
233            $status = Deflate::inflate( $params['html'] );
234            if ( !$status->isGood() ) {
235                $this->dieWithError( 'deflate-invaliddeflate', 'invaliddeflate' );
236            }
237            $html = $status->getValue();
238        } else {
239            $html = $params['html'];
240        }
241        $wikitext = $this->transformHTML(
242            $title, $html, $parserParams['oldid'] ?? null, $params['etag'] ?? null
243        )['body'];
244        return $wikitext;
245    }
246
247    /**
248     * Load the parsed wikitext of an edit into the serialisation cache.
249     *
250     * @param Title $title The title of the page
251     * @param string $wikitext The wikitext of the edit
252     * @return string|false The key of the wikitext in the serialisation cache
253     */
254    protected function storeInSerializationCache( Title $title, $wikitext ) {
255        if ( $wikitext === false ) {
256            return false;
257        }
258
259        $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
260
261        // Store the corresponding wikitext, referenceable by a new key
262        $hash = md5( $wikitext );
263        $key = $cache->makeKey( 'visualeditor', 'serialization', $hash );
264        $ok = $cache->set( $key, $wikitext, self::MAX_CACHE_TTL );
265        if ( $ok ) {
266            $this->pruneExcessStashedEntries( $cache, $this->getUser(), $key );
267        }
268
269        $status = $ok ? 'ok' : 'failed';
270        $this->getStatsFactory()->getCounter( 'VE_editstash_serialization_cache_set_total' )
271            ->setLabel( 'status', $status )
272            ->increment();
273
274        // Also parse and prepare the edit in case it might be saved later
275        $pageUpdater = $this->wikiPageFactory->newFromTitle( $title )->newPageUpdater( $this->getUser() );
276        $content = ContentHandler::makeContent( $wikitext, $title, CONTENT_MODEL_WIKITEXT );
277
278        $status = $this->pageEditStash->parseAndCache( $pageUpdater, $content, $this->getUser(), '' );
279        if ( $status === $this->pageEditStash::ERROR_NONE ) {
280            $logger = LoggerFactory::getInstance( 'StashEdit' );
281            $logger->debug( "Cached parser output for VE content key '$key'." );
282        }
283        $this->getStatsFactory()->getCounter( 'VE_editstash_cache_store_total' )
284            ->setLabel( 'status', $status )
285            ->increment();
286
287        return $hash;
288    }
289
290    private function pruneExcessStashedEntries( BagOStuff $cache, UserIdentity $user, string $newKey ): void {
291        $key = $cache->makeKey( 'visualeditor-serialization-recent', $user->getName() );
292
293        $keyList = $cache->get( $key ) ?: [];
294        if ( count( $keyList ) >= self::MAX_CACHE_RECENT ) {
295            $oldestKey = array_shift( $keyList );
296            $cache->delete( $oldestKey );
297        }
298
299        $keyList[] = $newKey;
300        $cache->set( $key, $keyList, 2 * self::MAX_CACHE_TTL );
301    }
302
303    /**
304     * Load some parsed wikitext of an edit from the serialisation cache.
305     *
306     * @param string $hash The key of the wikitext in the serialisation cache
307     * @return string|false The wikitext
308     */
309    protected function trySerializationCache( $hash ) {
310        $cache = MediaWikiServices::getInstance()->getObjectCacheFactory()->getLocalClusterInstance();
311        $key = $cache->makeKey( 'visualeditor', 'serialization', $hash );
312        $value = $cache->get( $key );
313
314        $status = ( $value !== false ) ? 'hit' : 'miss';
315        $this->getStatsFactory()->getCounter( 'VE_editstash_serialization_cache_get_total' )
316            ->setLabel( 'status', $status )
317            ->increment();
318
319        return $value;
320    }
321
322    /**
323     * Calculate the different between the wikitext of an edit and an existing revision.
324     *
325     * @param Title $title The title of the page
326     * @param int|null $fromId The existing revision of the page to compare with
327     * @param string $wikitext The wikitext to compare against
328     * @param int|null $section Whether the wikitext refers to a given section or the whole page
329     * @return array The comparison, or `[ 'result' => 'nochanges' ]` if there are none
330     */
331    protected function diffWikitext( Title $title, ?int $fromId, $wikitext, $section = null ) {
332        $apiParams = [
333            'action' => 'compare',
334            'prop' => 'diff',
335            // Because we're just providing wikitext, we only care about the main slot
336            'slots' => SlotRecord::MAIN,
337            'fromtitle' => $title->getPrefixedDBkey(),
338            'fromrev' => $fromId,
339            'fromsection' => $section,
340            'toslots' => SlotRecord::MAIN,
341            'totext-main' => $wikitext,
342            'topst' => true,
343        ];
344
345        $context = new DerivativeContext( $this->getContext() );
346        $context->setRequest(
347            new DerivativeRequest(
348                $context->getRequest(),
349                $apiParams,
350                /* was posted? */ true
351            )
352        );
353        $api = new ApiMain(
354            $context,
355            /* enable write? */ false
356        );
357        $api->execute();
358        $result = $api->getResult()->getResultData();
359
360        if ( !isset( $result['compare']['bodies'][SlotRecord::MAIN] ) ) {
361            $this->dieWithError( 'apierror-visualeditor-difffailed', 'difffailed' );
362        }
363        $diffRows = $result['compare']['bodies'][SlotRecord::MAIN];
364
365        $context = new DerivativeContext( $this->getContext() );
366        $context->setTitle( $title );
367        $engine = new DifferenceEngine( $context );
368        return [
369            'result' => 'success',
370            'diff' => $diffRows ? $engine->addHeader(
371                $diffRows,
372                $context->msg( 'currentrev' )->parse(),
373                $context->msg( 'yourtext' )->parse()
374            ) : ''
375        ];
376    }
377
378    /**
379     * @inheritDoc
380     */
381    public function execute() {
382        $user = $this->getUser();
383        $params = $this->extractRequestParams();
384
385        $result = [];
386        $title = Title::newFromText( $params['page'] );
387        if ( $title && $title->isSpecialPage() ) {
388            // Convert Special:CollabPad/MyPage to MyPage so we can serialize properly
389            [ $special, $subPage ] = $this->specialPageFactory->resolveAlias( $title->getDBkey() );
390            if ( $special === 'CollabPad' ) {
391                $title = Title::newFromText( $subPage );
392            }
393        }
394        if ( !$title ) {
395            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] );
396        }
397        if ( !$title->canExist() ) {
398            $this->dieWithError( 'apierror-pagecannotexist' );
399        }
400        $this->getErrorFormatter()->setContextTitle( $title );
401
402        $parserParams = [];
403        if ( isset( $params['oldid'] ) ) {
404            $parserParams['oldid'] = $params['oldid'];
405        }
406
407        if ( isset( $params['wikitext'] ) ) {
408            $wikitext = str_replace( "\r\n", "\n", $params['wikitext'] );
409        } else {
410            $wikitext = $this->getWikitext( $title, $params, $parserParams );
411        }
412
413        if ( $params['paction'] === 'serialize' ) {
414            $result = [ 'result' => 'success', 'content' => $wikitext ];
415        } elseif ( $params['paction'] === 'serializeforcache' ) {
416            $key = $this->storeInSerializationCache(
417                $title,
418                $wikitext
419            );
420            $result = [ 'result' => 'success', 'cachekey' => $key ];
421        } elseif ( $params['paction'] === 'diff' ) {
422            $section = $params['section'] ?? null;
423            $result = $this->diffWikitext( $title, $params['oldid'], $wikitext, $section );
424        } elseif ( $params['paction'] === 'save' ) {
425            $pluginData = [];
426            foreach ( $params['plugins'] ?? [] as $plugin ) {
427                $pluginData[$plugin] = $params['data-' . $plugin];
428            }
429            $presaveHook = $this->hookRunner->onVisualEditorApiVisualEditorEditPreSave(
430                $title->toPageIdentity(),
431                $user,
432                $wikitext,
433                $params,
434                $pluginData,
435                $result
436            );
437
438            if ( $presaveHook === false ) {
439                $this->dieWithError( $result['message'], 'hookaborted', $result );
440            }
441
442            $saveresult = $this->saveWikitext( $title, $wikitext, $params );
443            $editStatus = $saveresult['edit']['result'];
444
445            // Error
446            if ( $editStatus !== 'Success' ) {
447                $result['result'] = 'error';
448                $result['edit'] = $saveresult['edit'];
449            } else {
450                // Success
451                $result['result'] = 'success';
452
453                if ( $params['nocontent'] ) {
454                    $result['nocontent'] = true;
455                } else {
456                    if ( isset( $saveresult['edit']['newrevid'] ) ) {
457                        $newRevId = intval( $saveresult['edit']['newrevid'] );
458                    } else {
459                        $newRevId = $title->getLatestRevID();
460                    }
461
462                    // Return result of parseWikitext instead of saveWikitext so that the
463                    // frontend can update the page rendering without a refresh.
464                    $parseWikitextResult = $this->parseWikitext( $newRevId, $params );
465
466                    $result = array_merge( $result, $parseWikitextResult );
467                }
468
469                $result['isRedirect'] = (string)$title->isRedirect();
470
471                if ( ExtensionRegistry::getInstance()->isLoaded( 'FlaggedRevs' ) ) {
472                    $newContext = new DerivativeContext( RequestContext::getMain() );
473                    // Defeat !$this->isPageView( $request ) || $request->getVal( 'oldid' ) check in setPageContent
474                    $newRequest = new DerivativeRequest(
475                        $this->getRequest(),
476                        [
477                            'diff' => null,
478                            'oldid' => '',
479                            'title' => $title->getPrefixedText(),
480                            'action' => 'view'
481                        ] + $this->getRequest()->getValues()
482                    );
483                    $newContext->setRequest( $newRequest );
484                    $newContext->setTitle( $title );
485
486                    // Must be after $globalContext->setTitle since FlaggedRevs constructor
487                    // inspects global Title
488                    $view = FlaggablePageView::newFromTitle( $title );
489                    // Most likely identical to $globalState, but not our concern
490                    $originalContext = $view->getContext();
491                    $view->setContext( $newContext );
492
493                    // The two parameters here are references but we don't care
494                    // about what FlaggedRevs does with them.
495                    $outputDone = null;
496                    $useParserCache = null;
497                    // @phan-suppress-next-line PhanTypeMismatchArgument
498                    $view->setPageContent( $outputDone, $useParserCache );
499                    $view->displayTag();
500                    $view->setContext( $originalContext );
501                }
502
503                $lang = $this->getLanguage();
504
505                if ( isset( $saveresult['edit']['newtimestamp'] ) ) {
506                    $ts = $saveresult['edit']['newtimestamp'];
507
508                    $date = $lang->userDate( $ts, $user );
509                    $time = $lang->userTime( $ts, $user );
510
511                    $result['lastModified'] = [
512                        'date' => $date,
513                        'time' => $time,
514                        'message' => $this->msg( 'lastmodifiedat', $date, $time )->parse(),
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}