Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.18% covered (danger)
49.18%
180 / 366
31.25% covered (danger)
31.25%
5 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiVisualEditor
49.18% covered (danger)
49.18%
180 / 366
31.25% covered (danger)
31.25%
5 / 16
1056.72
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getParsoidClient
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getUserForPermissions
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 getUserForPreview
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 addWatchlistLabelsDefinition
6.06% covered (danger)
6.06%
2 / 33
0.00% covered (danger)
0.00%
0 / 1
111.31
 pstWikitext
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 execute
53.14% covered (warning)
53.14%
127 / 239
0.00% covered (danger)
0.00%
0 / 1
354.09
 updateCheckboxOptionsMessage
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 makeSafeHtmlForNfc
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isAllowedNamespace
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getAvailableNamespaceIds
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 isAllowedContentType
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getAllowedParams
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
1
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isWriteMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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 MediaWiki\Api\ApiBase;
14use MediaWiki\Api\ApiBlockInfoTrait;
15use MediaWiki\Api\ApiMain;
16use MediaWiki\Api\ApiResult;
17use MediaWiki\Config\Config;
18use MediaWiki\Content\ContentHandler;
19use MediaWiki\Content\Transform\ContentTransformer;
20use MediaWiki\Content\WikitextContent;
21use MediaWiki\Context\DerivativeContext;
22use MediaWiki\Context\RequestContext;
23use MediaWiki\EditPage\EditPage;
24use MediaWiki\EditPage\IntroMessageBuilder;
25use MediaWiki\EditPage\PreloadedContentBuilder;
26use MediaWiki\EditPage\TemplatesOnThisPageFormatter;
27use MediaWiki\EditPage\TextboxBuilder;
28use MediaWiki\Html\Html;
29use MediaWiki\Language\MessageLocalizer;
30use MediaWiki\Language\RawMessage;
31use MediaWiki\Linker\LinkRenderer;
32use MediaWiki\Logger\LoggerFactory;
33use MediaWiki\MainConfigNames;
34use MediaWiki\MediaWikiServices;
35use MediaWiki\Message\Message;
36use MediaWiki\Page\Article;
37use MediaWiki\Page\LinkBatchFactory;
38use MediaWiki\Page\PageReference;
39use MediaWiki\Page\WikiPageFactory;
40use MediaWiki\Permissions\PermissionManager;
41use MediaWiki\Permissions\RestrictionStore;
42use MediaWiki\Registration\ExtensionRegistry;
43use MediaWiki\Request\DerivativeRequest;
44use MediaWiki\Revision\RevisionLookup;
45use MediaWiki\SpecialPage\SpecialPageFactory;
46use MediaWiki\Title\Title;
47use MediaWiki\User\Options\UserOptionsLookup;
48use MediaWiki\User\TempUser\TempUserCreator;
49use MediaWiki\User\User;
50use MediaWiki\User\UserFactory;
51use MediaWiki\User\UserIdentity;
52use MediaWiki\Watchlist\WatchedItem;
53use MediaWiki\Watchlist\WatchedItemStoreInterface;
54use MediaWiki\Watchlist\WatchlistLabelStore;
55use MediaWiki\Watchlist\WatchlistManager;
56use Wikimedia\Assert\Assert;
57use Wikimedia\ParamValidator\ParamValidator;
58use Wikimedia\Stats\StatsFactory;
59
60class ApiVisualEditor extends ApiBase {
61    use ApiBlockInfoTrait;
62    use ApiParsoidTrait;
63
64    public function __construct(
65        ApiMain $main,
66        string $name,
67        private readonly RevisionLookup $revisionLookup,
68        private readonly TempUserCreator $tempUserCreator,
69        private readonly UserFactory $userFactory,
70        private readonly UserOptionsLookup $userOptionsLookup,
71        private readonly WatchlistManager $watchlistManager,
72        private readonly WatchlistLabelStore $watchlistLabelStore,
73        private readonly WatchedItemStoreInterface $watchedItemStore,
74        private readonly ContentTransformer $contentTransformer,
75        private readonly StatsFactory $statsFactory,
76        private readonly WikiPageFactory $wikiPageFactory,
77        private readonly IntroMessageBuilder $introMessageBuilder,
78        private readonly PreloadedContentBuilder $preloadedContentBuilder,
79        private readonly SpecialPageFactory $specialPageFactory,
80        private readonly LinkRenderer $linkRenderer,
81        private readonly LinkBatchFactory $linkBatchFactory,
82        private readonly RestrictionStore $restrictionStore,
83        private readonly TextboxBuilder $textboxBuilder,
84        private readonly VisualEditorParsoidClientFactory $parsoidClientFactory,
85    ) {
86        parent::__construct( $main, $name );
87        $this->setLogger( LoggerFactory::getInstance( 'VisualEditor' ) );
88        $this->setStatsFactory( $statsFactory );
89    }
90
91    /**
92     * @inheritDoc
93     */
94    protected function getParsoidClient(): ParsoidClient {
95        return $this->parsoidClientFactory->createParsoidClient(
96            $this->getRequest()->getHeader( 'Cookie' )
97        );
98    }
99
100    /**
101     * @see EditPage::getUserForPermissions
102     */
103    private function getUserForPermissions(): User {
104        $user = $this->getUser();
105        if ( $this->tempUserCreator->shouldAutoCreate( $user, 'edit' ) ) {
106            return $this->userFactory->newUnsavedTempUser(
107                $this->tempUserCreator->getStashedName( $this->getRequest()->getSession() )
108            );
109        }
110        return $user;
111    }
112
113    /**
114     * @see ApiParse::getUserForPreview
115     */
116    private function getUserForPreview(): UserIdentity {
117        $user = $this->getUser();
118        if ( $this->tempUserCreator->shouldAutoCreate( $user, 'edit' ) ) {
119            return $this->userFactory->newUnsavedTempUser(
120                $this->tempUserCreator->getStashedName( $this->getRequest()->getSession() )
121            );
122        }
123        return $user;
124    }
125
126    /**
127     * Add a watchlist labels MenuTagMultiselectWidget definition for VE's publish dialog.
128     *
129     * @param array[] &$checkboxesDef
130     * @param User $user
131     * @param Title $title
132     */
133    private function addWatchlistLabelsDefinition( array &$checkboxesDef, User $user, Title $title ): void {
134        if ( !$this->getConfig()->get( MainConfigNames::EnableWatchlistLabels ) || !$user->isNamed() ) {
135            return;
136        }
137
138        $userLabels = $this->watchlistLabelStore->loadAllForUser( $user );
139        if ( !$userLabels ) {
140            return;
141        }
142
143        $options = [];
144        foreach ( $userLabels as $label ) {
145            $labelId = $label->getId();
146            if ( $labelId !== null ) {
147                $options[] = [ 'data' => (string)$labelId, 'label' => $label->getName() ];
148            }
149        }
150        if ( !$options ) {
151            return;
152        }
153
154        $selectedLabelIds = [];
155        $requestLabels = $this->getRequest()->getIntArray( 'wpWatchlistLabels', [] );
156        if ( $requestLabels ) {
157            $selectedLabelIds = $requestLabels;
158        } else {
159            $watchedItem = $this->watchedItemStore->getWatchedItem( $user, $title );
160            if ( $watchedItem instanceof WatchedItem ) {
161                foreach ( $watchedItem->getLabels() as $label ) {
162                    $labelId = $label->getId();
163                    if ( $labelId !== null ) {
164                        $selectedLabelIds[] = $labelId;
165                    }
166                }
167            }
168        }
169
170        $checkboxesDef['wpWatchlistLabels'] = [
171            'id' => 'wpWatchlistLabelsWidget',
172            'label-message' => 'watchlistlabels-editpage-label',
173            'help-message' => 'watchlistlabels-editpage-help',
174            'placeholder-message' => 'watchlistlabels-editpage-placeholder',
175            'class' => 'MediaWiki\\Widget\\MenuTagMultiselectWidget',
176            'options' => [ '' => $options ],
177            'default' => array_map( strval( ... ), $selectedLabelIds ),
178            'allowReordering' => false,
179            'align' => 'top',
180        ];
181    }
182
183    /**
184     * Run wikitext through the parser's Pre-Save-Transform
185     *
186     * @param Title $title The title of the page to use as the parsing context
187     * @param string $wikitext The wikitext to transform
188     * @return string The transformed wikitext
189     */
190    protected function pstWikitext( Title $title, $wikitext ) {
191        $content = ContentHandler::makeContent( $wikitext, $title, CONTENT_MODEL_WIKITEXT );
192        return $this->contentTransformer->preSaveTransform(
193            $content,
194            $title,
195            $this->getUserForPreview(),
196            $this->wikiPageFactory->newFromTitle( $title )->makeParserOptions( $this->getContext() )
197        )
198        ->serialize( 'text/x-wiki' );
199    }
200
201    /**
202     * @inheritDoc
203     * @suppress PhanPossiblyUndeclaredVariable False positives
204     */
205    public function execute() {
206        $user = $this->getUser();
207        $params = $this->extractRequestParams();
208        $permissionManager = $this->getPermissionManager();
209
210        $title = Title::newFromText( $params['page'] );
211        if ( $title && $title->isSpecialPage() ) {
212            // Convert Special:CollabPad/MyPage to MyPage so we can parsefragment properly
213            [ $special, $subPage ] = $this->specialPageFactory->resolveAlias( $title->getDBkey() );
214            if ( $special === 'CollabPad' ) {
215                $title = Title::newFromText( $subPage );
216            }
217        }
218        if ( !$title ) {
219            $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] );
220        }
221        if ( !$title->canExist() ) {
222            $this->dieWithError( 'apierror-pagecannotexist' );
223        }
224
225        wfDebugLog( 'visualeditor', "called on '$title' with paction: '{$params['paction']}'" );
226        switch ( $params['paction'] ) {
227            case 'parse':
228            case 'wikitext':
229            case 'metadata':
230                // Dirty hack to provide the correct context for FlaggedRevs when it generates edit notices
231                // and save dialog checkboxes. (T307852)
232                // FIXME Don't write to globals! Eww.
233                RequestContext::getMain()->setTitle( $title );
234
235                $preloaded = false;
236                $restbaseHeaders = null;
237
238                $section = $params['section'] ?? null;
239
240                // Get information about current revision
241                if ( $title->exists() ) {
242                    $latestRevision = $this->revisionLookup->getRevisionByTitle( $title );
243                    if ( !$latestRevision ) {
244                        $this->dieWithError(
245                            [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ],
246                            'nosuchrevid'
247                        );
248                    }
249                    if ( isset( $params['oldid'] ) ) {
250                        $revision = $this->revisionLookup->getRevisionById( $params['oldid'] );
251                        if ( !$revision ) {
252                            $this->dieWithError( [ 'apierror-nosuchrevid', $params['oldid'] ] );
253                        }
254                    } else {
255                        $revision = $latestRevision;
256                    }
257
258                    $baseTimestamp = $latestRevision->getTimestamp();
259                    $oldid = $revision->getId();
260
261                    // If requested, request HTML from Parsoid/RESTBase
262                    if ( $params['paction'] === 'parse' ) {
263                        $wikitext = $params['wikitext'] ?? null;
264                        if ( $wikitext !== null ) {
265                            $stash = $params['stash'];
266                            if ( $params['pst'] ) {
267                                $wikitext = $this->pstWikitext( $title, $wikitext );
268                            }
269                            if ( $section !== null ) {
270                                $sectionContent = new WikitextContent( $wikitext );
271                                $page = $this->wikiPageFactory->newFromTitle( $title );
272                                $newSectionContent = $page->replaceSectionAtRev(
273                                    $section, $sectionContent, '', $oldid
274                                );
275                                '@phan-var WikitextContent $newSectionContent';
276                                $wikitext = $newSectionContent->getText();
277                            }
278                            $response = $this->transformWikitext(
279                                $title, $wikitext, false, $oldid, $stash
280                            );
281                        } else {
282                            $response = $this->requestRestbasePageHtml( $revision );
283                        }
284                        $content = $response['body'];
285                        $restbaseHeaders = $response['headers'];
286                    } elseif ( $params['paction'] === 'wikitext' ) {
287                        $apiParams = [
288                            'action' => 'query',
289                            'revids' => $oldid,
290                            'prop' => 'revisions',
291                            'rvprop' => 'content|ids',
292                            'rvsection' => $section,
293                        ];
294
295                        $context = new DerivativeContext( $this->getContext() );
296                        $context->setRequest(
297                            new DerivativeRequest(
298                                $context->getRequest(),
299                                $apiParams,
300                                wasPosted: true
301                            )
302                        );
303                        $api = new ApiMain( $context, enableWrite: true );
304                        $api->execute();
305                        $result = $api->getResult()->getResultData();
306                        $pid = $title->getArticleID();
307                        $content = false;
308                        if ( isset( $result['query']['pages'][$pid]['revisions'] ) ) {
309                            foreach ( $result['query']['pages'][$pid]['revisions'] as $revArr ) {
310                                // Check 'revisions' is an array (T193718)
311                                if ( is_array( $revArr ) && $revArr['revid'] === $oldid ) {
312                                    $content = $revArr['content'];
313                                }
314                            }
315                        }
316                    }
317                } else {
318                    $revision = null;
319                }
320
321                // Use $title as the context page in every processed message (T300184)
322                $localizerWithTitle = new class( $this, $title ) implements MessageLocalizer {
323                    private MessageLocalizer $base;
324                    private PageReference $page;
325
326                    public function __construct( MessageLocalizer $base, PageReference $page ) {
327                        $this->base = $base;
328                        $this->page = $page;
329                    }
330
331                    /**
332                     * @inheritDoc
333                     */
334                    public function msg( $key, ...$params ) {
335                        return $this->base->msg( $key, ...$params )->page( $this->page );
336                    }
337                };
338
339                if ( !$title->exists() || $section === 'new' ) {
340                    if ( isset( $params['wikitext'] ) ) {
341                        $content = $params['wikitext'];
342                        if ( $params['pst'] ) {
343                            $content = $this->pstWikitext( $title, $content );
344                        }
345                    } else {
346                        $contentObj = $this->preloadedContentBuilder->getPreloadedContent(
347                            $title->toPageIdentity(),
348                            $user,
349                            $params['preload'],
350                            $params['preloadparams'] ?? [],
351                            $section
352                        );
353                        $dfltContent = $section === 'new' ? null :
354                            $this->preloadedContentBuilder->getDefaultContent( $title->toPageIdentity() );
355                        $preloaded = $dfltContent ? !$contentObj->equals( $dfltContent ) : !$contentObj->isEmpty();
356                        $content = $contentObj->serialize();
357                    }
358
359                    if ( $content !== '' && $params['paction'] !== 'wikitext' ) {
360                        $response = $this->transformWikitext( $title, $content, false, null, true );
361                        $content = $response['body'];
362                        $restbaseHeaders = $response['headers'];
363                    }
364                    $baseTimestamp = wfTimestampNow();
365                    $oldid = 0;
366                }
367
368                // Look at protection status to set up notices + surface class(es)
369                $protectedClasses = $this->textboxBuilder->getTextboxProtectionCSSClasses( $title );
370
371                // Simplified EditPage::getEditPermissionStatus()
372                // TODO: Use API
373                // action=query&prop=info&intestactions=edit&intestactionsdetail=full&errorformat=html&errorsuselocal=1
374                $status = $permissionManager->getPermissionStatus(
375                    'edit', $this->getUserForPermissions(), $title, PermissionManager::RIGOR_FULL );
376                if ( !$status->isGood() ) {
377                    // Show generic permission errors, including page protection, user blocks, etc.
378                    $notice = $this->getOutput()->formatPermissionStatus( $status, 'edit' );
379                    // That method returns wikitext (eww), hack to get it parsed:
380                    $notice = ( new RawMessage( '$1', [ $notice ] ) )->page( $title )->parseAsBlock();
381                    // Invent a message key 'permissions-error' to store in $notices
382                    // (This probably shouldn't use the notices system…)
383                    $notices = [ 'permissions-error' => $notice ];
384                } else {
385                    $notices = $this->introMessageBuilder->getIntroMessages(
386                        IntroMessageBuilder::LESS_FRAMES,
387                        [
388                            // This message was not shown by VisualEditor before it was switched to use
389                            // IntroMessageBuilder, and it may be unexpected to display it now, so skip it.
390                            'editpage-head-copy-warn',
391                            // This message was not shown by VisualEditor previously, and on many Wikipedias it's
392                            // technically non-empty but hidden with CSS, and not a real edit notice (T337633).
393                            'editnotice-notext',
394                        ],
395                        $localizerWithTitle,
396                        $title->toPageIdentity(),
397                        $revision,
398                        $user,
399                        $params['editintro'],
400                        $params['paction'] === 'wikitext' ? 'veaction=editsource' : 'veaction=edit',
401                        false,
402                        $section
403                    );
404                }
405
406                // Will be false e.g. if user is blocked or page is protected
407                $canEdit = $status->isGood();
408
409                $blockinfo = null;
410                // Blocked user notice
411                if ( $permissionManager->isBlockedFrom( $user, $title, true ) ) {
412                    $block = $user->getBlock();
413                    if ( $block ) {
414                        // Already added to $notices via #getPermissionStatus above.
415                        // Add block info for MobileFrontend:
416                        $blockinfo = $this->getBlockDetails( $block );
417                    }
418                }
419
420                // HACK: Build a fake EditPage so we can get checkboxes from it
421                $context = new DerivativeContext( $this->getContext() );
422                // Deliberately omitting ,0 so oldid comes from request
423                $article = new Article( $title );
424                $article->setContext( $context );
425                $editPage = new EditPage( $article );
426                $req = $this->getRequest();
427                $req->setVal( 'format', $editPage->contentFormat );
428                $context->setRequest( $req );
429                $editPage->importFormData();
430                $states = [
431                    'minor' => $this->userOptionsLookup->getOption( $user, 'minordefault' ) && $title->exists(),
432                    'watch' => $this->userOptionsLookup->getOption( $user, 'watchdefault' ) ||
433                        ( $this->userOptionsLookup->getOption( $user, 'watchcreations' ) && !$title->exists() ) ||
434                        $this->watchlistManager->isWatched( $user, $title ),
435                ];
436                $checkboxesDef = $editPage->getCheckboxesDefinition( $states );
437                $this->addWatchlistLabelsDefinition( $checkboxesDef, $user, $title );
438                $checkboxesMessagesList = [];
439                foreach ( $checkboxesDef as &$options ) {
440                    if ( isset( $options['tooltip'] ) ) {
441                        $checkboxesMessagesList[] = "accesskey-{$options['tooltip']}";
442                        $checkboxesMessagesList[] = "tooltip-{$options['tooltip']}";
443                    }
444                    $this->updateCheckboxOptionsMessage( 'title-message', $options, $checkboxesMessagesList );
445                    $this->updateCheckboxOptionsMessage( 'label-message', $options, $checkboxesMessagesList );
446                    $this->updateCheckboxOptionsMessage( 'help-message', $options, $checkboxesMessagesList );
447                    $this->updateCheckboxOptionsMessage( 'placeholder-message', $options, $checkboxesMessagesList );
448                }
449                $checkboxesMessages = [];
450                foreach ( $checkboxesMessagesList as $messageSpecifier ) {
451                    // $messageSpecifier may be a string or a Message object
452                    $message = $this->msg( $messageSpecifier );
453                    $checkboxesMessages[ $message->getKey() ] = $message->plain();
454                }
455
456                foreach ( $checkboxesDef as &$value ) {
457                    // Don't convert the boolean to empty string with formatversion=1
458                    $value[ApiResult::META_BC_BOOLS] = [ 'default' ];
459                }
460
461                $copyrightWarning = EditPage::getCopyrightWarning(
462                    $title,
463                    'parse',
464                    $this
465                );
466
467                // Copied from EditPage::maybeActivateTempUserCreate
468                // Used by code in MobileFrontend and DiscussionTools.
469                // TODO Make them use API
470                // action=query&prop=info&intestactions=edit&intestactionsautocreate=1
471                $wouldautocreate =
472                    !$user->isRegistered()
473                        && $this->tempUserCreator->isAutoCreateAction( 'edit' )
474                        && $permissionManager->userHasRight( $user, 'createaccount' );
475
476                // phpcs:disable MediaWiki.WhiteSpace.SpaceBeforeSingleLineComment.NewLineComment
477                /** @phpcs-require-sorted-array */
478                $result = [
479                    // --------------------------------------------------------------------------------
480                    // This should match ArticleTarget#getWikitextDataPromiseForDoc and ArticleTarget#storeDocState
481                    // --------------------------------------------------------------------------------
482                    'basetimestamp' => $baseTimestamp,
483                    'blockinfo' => $blockinfo, // only used by MobileFrontend EditorGateway
484                    'canEdit' => $canEdit,
485                    'checkboxesDef' => $checkboxesDef,
486                    'checkboxesMessages' => $checkboxesMessages,
487                    // 'content' => ..., // optional, see below
488                    'copyrightWarning' => $copyrightWarning,
489                    // 'etag' => ..., // optional, see below
490                    'notices' => $notices,
491                    'oldid' => $oldid,
492                    // 'preloaded' => ..., // optional, see below
493                    'protectedClasses' => implode( ' ', $protectedClasses ),
494                    'result' => 'success', // probably unused?
495                    'starttimestamp' => wfTimestampNow(),
496                    'wouldautocreate' => $wouldautocreate,
497                ];
498                // phpcs:enable
499                if ( isset( $restbaseHeaders['etag'] ) ) {
500                    $result['etag'] = $restbaseHeaders['etag'];
501                }
502                if ( isset( $params['badetag'] ) ) {
503                    $badetag = $params['badetag'];
504                    $goodetag = $result['etag'] ?? '';
505                    $this->getLogger()->info(
506                        __METHOD__ . ": Client reported bad ETag: {badetag}, expected: {goodetag}",
507                        [
508                            'badetag' => $badetag,
509                            'goodetag' => $goodetag,
510                        ]
511                    );
512                }
513
514                if ( isset( $content ) ) {
515                    Assert::postcondition( is_string( $content ), 'Content expected' );
516                    $result['content'] = $content;
517                    $result['preloaded'] = $preloaded;
518                }
519                break;
520
521            case 'templatesused':
522                $templateListFormatter = new TemplatesOnThisPageFormatter(
523                    $this->getContext(),
524                    $this->linkRenderer,
525                    $this->linkBatchFactory,
526                    $this->restrictionStore,
527                );
528                $result = Html::rawElement(
529                    'div',
530                    [ 'class' => 'templatesUsed' ],
531                    $templateListFormatter->format( $title->getTemplateLinksFrom() )
532                );
533                break;
534
535            case 'parsefragment':
536                $wikitext = $params['wikitext'];
537                if ( $wikitext === null ) {
538                    $this->dieWithError( [ 'apierror-missingparam', 'wikitext' ] );
539                }
540                if ( $params['pst'] ) {
541                    $wikitext = $this->pstWikitext( $title, $wikitext );
542                }
543                $content = $this->transformWikitext(
544                    $title, $wikitext, true
545                )['body'];
546                Assert::postcondition( is_string( $content ), 'Content expected' );
547                $result = [
548                    'result' => 'success',
549                    'content' => $content
550                ];
551                break;
552        }
553
554        if (
555            is_array( $result ) &&
556            isset( $result['content'] ) &&
557            is_string( $result['content'] )
558        ) {
559            // Protect content from being corrupted by conversion to Unicode NFC.
560            // Without this, MediaWiki::Api::ApiResult::addValue can break html tags.
561            // See T382756
562            $result['content'] = $this->makeSafeHtmlForNfc( $result['content'] );
563        }
564
565        $this->getResult()->addValue( null, $this->getModuleName(), $result );
566    }
567
568    /**
569     * Update a checkbox's messages (label, placeholder, help, etc.) based on
570     * the structure of the config and whether the message is disabled or not.
571     *
572     * @param string $optionKey
573     * @param array<string,string|Message> &$options Map from internal keys to messages, which may
574     *   be string message keys or Message objects. The latter will be converted to the former.
575     * @param list<string|Message> &$checkboxesMessagesList List of messages that are actually used,
576     *   as string message keys or Message objects
577     */
578    private function updateCheckboxOptionsMessage(
579        string $optionKey,
580        array &$options,
581        array &$checkboxesMessagesList
582    ): void {
583        if ( !isset( $options[$optionKey] ) ) {
584            return;
585        }
586        // $options[$optionKey] may be a string or a Message object; convert to string
587        $message = $this->msg( $options[$optionKey] );
588        if ( !is_string( $options[$optionKey] ) ) {
589            $options[$optionKey] = $message->getKey();
590        }
591        if ( $message->isDisabled() ) {
592            // Remove the message if it's disabled, so it isn't sent to the frontend unnecessarily.
593            unset( $options[$optionKey] );
594        } else {
595            // Otherwise, add it to the list of messages in use.
596            $checkboxesMessagesList[] = $message;
597        }
598    }
599
600    /**
601     * Protect html-like content from being corrupted by conversion to Unicode NFC.
602     *
603     * Encodes U+0338 COMBINING LONG SOLIDUS OVERLAY as an html numeric character reference.
604     * Otherwise, conversion to Unicode NFC can break html tags by converting
605     * '>' + U+0338 to U+226F (NOT GREATER THAN), and
606     * '<' + U+0338 to U+226E (NOT LESS THAN)
607     *
608     * Note we cannot just search for those two combinations, because sequences of combining
609     * characters can get reordered, e.g. '>' + U+0339 + U+0338 will become U+226F + U+0339.
610     * See https://unicode.org/reports/tr15/
611     *
612     * @param string $html
613     * @return string
614     */
615    public static function makeSafeHtmlForNfc( string $html ) {
616        $html = str_replace( "\u{0338}", '&#x338;', $html );
617        return $html;
618    }
619
620    /**
621     * Check if the configured allowed namespaces include the specified namespace
622     *
623     * @deprecated Since 1.45. Use {@link VisualEditorAvailabilityLookup::isAllowedNamespace} instead
624     * @param Config $config
625     * @param int $namespaceId Namespace ID
626     * @return bool
627     */
628    public static function isAllowedNamespace( Config $config, int $namespaceId ): bool {
629        wfDeprecated( __METHOD__, '1.45' );
630        return in_array( $namespaceId, self::getAvailableNamespaceIds( $config ), true );
631    }
632
633    /**
634     * Get a list of allowed namespace IDs
635     *
636     * @deprecated Since 1.45. Use {@link VisualEditorAvailabilityLookup::getAvailableNamespaceIds} instead
637     * @param Config $config
638     * @return int[]
639     */
640    public static function getAvailableNamespaceIds( Config $config ): array {
641        wfDeprecated( __METHOD__, '1.45' );
642        $namespaceInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
643        $configuredNamespaces = array_replace(
644            ExtensionRegistry::getInstance()->getAttribute( 'VisualEditorAvailableNamespaces' ),
645            $config->get( 'VisualEditorAvailableNamespaces' )
646        );
647        $normalized = [];
648        foreach ( $configuredNamespaces as $id => $enabled ) {
649            // Convert canonical namespace names to IDs
650            $id = $namespaceInfo->getCanonicalIndex( strtolower( $id ) ) ?? $id;
651            $normalized[$id] = $enabled && $namespaceInfo->exists( $id );
652        }
653        ksort( $normalized );
654        return array_keys( array_filter( $normalized ) );
655    }
656
657    /**
658     * Check if the configured allowed content models include the specified content model
659     *
660     * @deprecated Since 1.45. Use {@link VisualEditorAvailabilityLookup::isAllowedContentType} instead
661     * @param Config $config
662     * @param string $contentModel Content model ID
663     * @return bool
664     */
665    public static function isAllowedContentType( Config $config, string $contentModel ): bool {
666        wfDeprecated( __METHOD__, '1.45' );
667        $availableContentModels = array_merge(
668            ExtensionRegistry::getInstance()->getAttribute( 'VisualEditorAvailableContentModels' ),
669            $config->get( 'VisualEditorAvailableContentModels' )
670        );
671        return (bool)( $availableContentModels[$contentModel] ?? false );
672    }
673
674    /**
675     * @inheritDoc
676     */
677    public function getAllowedParams() {
678        return [
679            'page' => [
680                ParamValidator::PARAM_REQUIRED => true,
681            ],
682            'badetag' => null,
683            'format' => [
684                ParamValidator::PARAM_DEFAULT => 'jsonfm',
685                ParamValidator::PARAM_TYPE => [ 'json', 'jsonfm' ],
686            ],
687            'paction' => [
688                ParamValidator::PARAM_REQUIRED => true,
689                ParamValidator::PARAM_TYPE => [
690                    'parse',
691                    'metadata',
692                    'templatesused',
693                    'wikitext',
694                    'parsefragment',
695                ],
696            ],
697            'wikitext' => [
698                ParamValidator::PARAM_TYPE => 'text',
699                ParamValidator::PARAM_DEFAULT => null,
700            ],
701            'section' => null,
702            'stash' => false,
703            'oldid' => [
704                ParamValidator::PARAM_TYPE => 'integer',
705            ],
706            'editintro' => null,
707            'pst' => false,
708            'preload' => null,
709            'preloadparams' => [
710                ParamValidator::PARAM_ISMULTI => true,
711            ],
712        ];
713    }
714
715    /**
716     * @inheritDoc
717     */
718    public function needsToken() {
719        return false;
720    }
721
722    /**
723     * @inheritDoc
724     */
725    public function isInternal() {
726        return true;
727    }
728
729    /**
730     * @inheritDoc
731     */
732    public function isWriteMode() {
733        return false;
734    }
735}