veConfig = $config; $this->serviceClient = new VirtualRESTServiceClient( new MultiHttpClient( [] ) ); $this->logger = LoggerFactory::getInstance( 'VisualEditor' ); } /** * Creates the virtual REST service object to be used in VE's API calls. The * method determines whether to instantiate a ParsoidVirtualRESTService or a * RestbaseVirtualRESTService object based on configuration directives: if * $wgVirtualRestConfig['modules']['restbase'] is defined, RESTBase is chosen, * otherwise Parsoid is used (either by using the MW Core config, or the * VE-local one). * * @return VirtualRESTService the VirtualRESTService object to use */ protected function getVRSObject() { // the params array to create the service object with $params = []; // the VRS class to use, defaults to Parsoid $class = ParsoidVirtualRESTService::class; // The global virtual rest service config object, if any $vrs = $this->getConfig()->get( 'VirtualRestConfig' ); if ( isset( $vrs['modules'] ) && isset( $vrs['modules']['restbase'] ) ) { // if restbase is available, use it $params = $vrs['modules']['restbase']; // backward compatibility $params['parsoidCompat'] = false; $class = RestbaseVirtualRESTService::class; } elseif ( isset( $vrs['modules'] ) && isset( $vrs['modules']['parsoid'] ) ) { // there's a global parsoid config, use it next $params = $vrs['modules']['parsoid']; $params['restbaseCompat'] = true; } else { // No global modules defined, so no way to contact the document server. $this->dieWithError( 'apierror-visualeditor-docserver-unconfigured', 'no_vrs' ); } // merge the global and service-specific params if ( isset( $vrs['global'] ) ) { $params = array_merge( $vrs['global'], $params ); } // set up cookie forwarding if ( $params['forwardCookies'] ) { $params['forwardCookies'] = $this->getRequest()->getHeader( 'Cookie' ); } else { $params['forwardCookies'] = false; } // create the VRS object and return it return new $class( $params ); } /** * Accessor function for all RESTbase requests * * @param Title $title The title of the page to use as the parsing context * @param string $method The HTTP method, either 'GET' or 'POST' * @param string $path The RESTbase api path * @param array $params Request parameters * @param array $reqheaders Request headers * @return array The RESTbase server's response, 'code', 'reason', 'headers' and 'body' */ protected function requestRestbase( Title $title, $method, $path, $params, $reqheaders = [] ) { global $wgVersion; $request = [ 'method' => $method, 'url' => '/restbase/local/v1/' . $path ]; if ( $method === 'GET' ) { $request['query'] = $params; } else { $request['body'] = $params; } // Should be synchronised with modules/ve-mw/init/ve.init.mw.ArticleTargetLoader.js $reqheaders['Accept'] = 'text/html; charset=utf-8;' . ' profile="https://www.mediawiki.org/wiki/Specs/HTML/2.0.0"'; $reqheaders['Accept-Language'] = self::getPageLanguage( $title )->getCode(); $reqheaders['User-Agent'] = 'VisualEditor-MediaWiki/' . $wgVersion; $reqheaders['Api-User-Agent'] = 'VisualEditor-MediaWiki/' . $wgVersion; $request['headers'] = $reqheaders; $response = $this->serviceClient->run( $request ); if ( $response['code'] === 200 && $response['error'] === "" ) { // If response was served directly from Varnish, use the response // (RP) header to declare the cache hit and pass the data to the client. $headers = $response['headers']; $rp = null; if ( isset( $headers['x-cache'] ) && strpos( $headers['x-cache'], 'hit' ) !== false ) { $rp = 'cached-response=true'; } if ( $rp !== null ) { $resp = $this->getRequest()->response(); $resp->header( 'X-Cache: ' . $rp ); } } elseif ( $response['error'] !== '' ) { $this->dieWithError( [ 'apierror-visualeditor-docserver-http-error', wfEscapeWikiText( $response['error'] ) ], 'apierror-visualeditor-docserver-http-error' ); } else { // error null, code not 200 $trace = ( new Exception )->getTraceAsString(); $this->logger->warning( __METHOD__ . ": Received HTTP {$response['code']} from RESTBase for $path." . " Trace: {$trace}" . " Response: {$response['body']}" . /** @phan-suppress-next-line PhanTypeInvalidDimOffset */ " Request If-Match: {$reqheaders['If-Match']}" ); $this->dieWithError( [ 'apierror-visualeditor-docserver-http', $response['code'] ], 'apierror-visualeditor-docserver-http' ); } return $response; } /** * Run wikitext through the parser's Pre-Save-Transform * * @param Title $title The title of the page to use as the parsing context * @param string $wikitext The wikitext to transform * @return string The transformed wikitext */ protected function pstWikitext( Title $title, $wikitext ) { return ContentHandler::makeContent( $wikitext, $title, CONTENT_MODEL_WIKITEXT ) ->preSaveTransform( $title, $this->getUser(), WikiPage::factory( $title )->makeParserOptions( $this->getContext() ) ) ->serialize( 'text/x-wiki' ); } /** * Provide the RESTbase-parsed HTML of a given fragment of wikitext * * @param Title $title The title of the page to use as the parsing context * @param string $wikitext The wikitext fragment to parse * @param bool $bodyOnly Whether to provide only the contents of the `
` tag * @param int|null $oldid What oldid revision, if any, to base the request from (default: `null`) * @param bool $stash Whether to stash the result in the server-side cache (default: `false`) * @return array The RESTbase server's response, 'code', 'reason', 'headers' and 'body' */ protected function parseWikitextFragment( Title $title, $wikitext, $bodyOnly, $oldid = null, $stash = false ) { return $this->requestRestbase( $title, 'POST', 'transform/wikitext/to/html/' . urlencode( $title->getPrefixedDBkey() ) . ( $oldid === null ? '' : '/' . $oldid ), [ 'wikitext' => $wikitext, 'body_only' => $bodyOnly ? 1 : 0, 'stash' => $stash ? 1 : 0 ] ); } /** * Provide the preload content for a page being created from another page * * @param string $preload The title of the page to use as the preload content * @param string[] $params The preloadTransform parameters to pass in, if any * @param Title $contextTitle The contextual page title against which to parse the preload * @param bool $parse Whether to parse the preload content * @return string The parsed content */ protected function getPreloadContent( $preload, $params, Title $contextTitle, $parse = false ) { $content = ''; $preloadTitle = Title::newFromText( $preload ); // Check for existence to avoid getting MediaWiki:Noarticletext if ( $preloadTitle instanceof Title && $preloadTitle->exists() && $preloadTitle->userCan( 'read' ) ) { $preloadPage = WikiPage::factory( $preloadTitle ); if ( $preloadPage->isRedirect() ) { $preloadTitle = $preloadPage->getRedirectTarget(); $preloadPage = WikiPage::factory( $preloadTitle ); } $content = $preloadPage->getContent( Revision::RAW ); $parserOptions = ParserOptions::newFromUser( $this->getUser() ); $content = $content->preloadTransform( $preloadTitle, $parserOptions, (array)$params )->serialize(); if ( $parse ) { // We need to turn this transformed wikitext into parsoid html $content = $this->parseWikitextFragment( $contextTitle, $content, true )['body']; } } return $content; } /** * @inheritDoc */ public function execute() { $this->serviceClient->mount( '/restbase/', $this->getVRSObject() ); $user = $this->getUser(); $params = $this->extractRequestParams(); $title = Title::newFromText( $params['page'] ); if ( $title && $title->isSpecial( 'CollabPad' ) ) { // Convert Special:CollabPad/MyPage to MyPage so we can parsefragment properly $title = SpecialCollabPad::getSubPage( $title ); } if ( !$title ) { $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $params['page'] ) ] ); } $parserParams = []; if ( isset( $params['oldid'] ) ) { $parserParams['oldid'] = $params['oldid']; } wfDebugLog( 'visualeditor', "called on '$title' with paction: '{$params['paction']}'" ); switch ( $params['paction'] ) { case 'parse': case 'wikitext': case 'metadata': // Dirty hack to provide the correct context for edit notices // FIXME Don't write to globals! Eww. global $wgTitle; $wgTitle = $title; RequestContext::getMain()->setTitle( $title ); $preloaded = false; $restbaseHeaders = null; // Get information about current revision if ( $title->exists() ) { $latestRevision = Revision::newFromTitle( $title ); if ( $latestRevision === null ) { $this->dieWithError( 'apierror-visualeditor-latestnotfound', 'latestnotfound' ); } $revision = null; if ( !isset( $parserParams['oldid'] ) || $parserParams['oldid'] === 0 ) { $parserParams['oldid'] = $latestRevision->getId(); $revision = $latestRevision; } else { $revision = Revision::newFromId( $parserParams['oldid'] ); if ( $revision === null ) { $this->dieWithError( [ 'apierror-nosuchrevid', $parserParams['oldid'] ], 'oldidnotfound' ); } } $restoring = $revision && !$revision->isCurrent(); $baseTimestamp = $latestRevision->getTimestamp(); $oldid = intval( $parserParams['oldid'] ); // If requested, request HTML from Parsoid/RESTBase if ( $params['paction'] === 'parse' ) { $wikitext = $params['wikitext'] ?? null; if ( $wikitext !== null ) { $stash = $params['stash']; $section = $section = $params['section'] ?? null; if ( $params['pst'] ) { $wikitext = $this->pstWikitext( $title, $wikitext ); } if ( $section !== null ) { $sectionContent = new WikitextContent( $wikitext ); $page = WikiPage::factory( $title ); $newSectionContent = $page->replaceSectionAtRev( $section, $sectionContent, '', $oldid ); '@phan-var WikitextContent $newSectionContent'; $wikitext = $newSectionContent->getText(); } $response = $this->parseWikitextFragment( $title, $wikitext, false, $oldid, $stash ); $content = $response['body']; $restbaseHeaders = $response['headers']; } else { $content = $this->requestRestbase( $title, 'GET', 'page/html/' . urlencode( $title->getPrefixedDBkey() ) . '/' . $oldid . '?redirect=false&stash=true', [] )['body']; } if ( $content === false ) { $this->dieWithError( 'apierror-visualeditor-docserver', 'docserver' ); } } elseif ( $params['paction'] === 'wikitext' ) { $apiParams = [ 'action' => 'query', 'revids' => $oldid, 'prop' => 'revisions', 'rvprop' => 'content|ids' ]; $section = $params['section'] ?? null; if ( $section === 'new' ) { $content = ''; if ( !empty( $params['preload'] ) ) { $content = $this->getPreloadContent( $params['preload'], $params['preloadparams'], $title, $params['paction'] !== 'wikitext' ); $preloaded = true; } } else { $apiParams['rvsection'] = $section; $api = new ApiMain( new DerivativeRequest( $this->getRequest(), $apiParams, /* was posted? */ false ), /* enable write? */ true ); $api->execute(); $result = $api->getResult()->getResultData(); $pid = $title->getArticleID(); $content = false; if ( isset( $result['query']['pages'][$pid]['revisions'] ) ) { foreach ( $result['query']['pages'][$pid]['revisions'] as $revArr ) { // Check 'revisions' is an array (T193718) if ( is_array( $revArr ) && $revArr['revid'] === $oldid ) { $content = $revArr['content']; } } } if ( $content === false ) { $this->dieWithError( 'apierror-visualeditor-docserver', 'docserver' ); } } } } else { $content = ''; Hooks::run( 'EditFormPreloadText', [ &$content, &$title ] ); if ( $content !== '' && $params['paction'] !== 'wikitext' ) { $content = $this->parseWikitextFragment( $title, $content, true )['body']; } if ( $content === '' && !empty( $params['preload'] ) ) { $content = $this->getPreloadContent( $params['preload'], $params['preloadparams'], $title, $params['paction'] !== 'wikitext' ); $preloaded = true; } $baseTimestamp = wfTimestampNow(); $oldid = 0; $restoring = false; } // Get edit notices $notices = $title->getEditNotices(); // Anonymous user notice if ( $user->isAnon() ) { $notices[] = $this->msg( 'anoneditwarning', // Log-in link '{{fullurl:Special:UserLogin|returnto={{FULLPAGENAMEE}}}}', // Sign-up link '{{fullurl:Special:UserLogin/signup|returnto={{FULLPAGENAMEE}}}}' )->parseAsBlock(); } // From EditPage#showCustomIntro if ( $params['editintro'] ) { $eiTitle = Title::newFromText( $params['editintro'] ); if ( $eiTitle instanceof Title && $eiTitle->exists() && $eiTitle->userCan( 'read' ) ) { global $wgParser; $notices[] = $wgParser->parse( '