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