veConfig = $config; $this->serviceClient = new VirtualRESTServiceClient( new MultiHttpClient( [] ) ); $this->serviceClient->mount( '/restbase/', $this->getVRSObject() ); } /** * 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 */ private function getVRSObject() { // the params array to create the service object with $params = []; // the VRS class to use, defaults to Parsoid $class = 'ParsoidVirtualRESTService'; $config = $this->veConfig; // 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']; $params['parsoidCompat'] = false; // backward compatibility $class = 'RestbaseVirtualRESTService'; } 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, fall back to old defaults $params = [ 'URL' => $config->get( 'VisualEditorParsoidURL' ), 'prefix' => $config->get( 'VisualEditorParsoidPrefix' ), 'domain' => $config->get( 'VisualEditorParsoidDomain' ), 'timeout' => $config->get( 'VisualEditorParsoidTimeout' ), 'HTTPProxy' => $config->get( 'VisualEditorParsoidHTTPProxy' ), 'forwardCookies' => $config->get( 'VisualEditorParsoidForwardCookies' ), 'restbaseCompat' => true ]; } // merge the global and service-specific params if ( isset( $vrs['global'] ) ) { $params = array_merge( $vrs['global'], $params ); } // set up cookie forwarding if ( $params['forwardCookies'] && !User::isEveryoneAllowed( 'read' ) ) { $params['forwardCookies'] = RequestContext::getMain()->getRequest()->getHeader( 'Cookie' ); } else { $params['forwardCookies'] = false; } // create the VRS object and return it return new $class( $params ); } private function requestRestbase( $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="mediawiki.org/specs/html/1.2.0"'; $reqheaders['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->dieUsage( 'docserver-http-error: ' . $response['error'], $response['error'] ); } else { // error null, code not 200 $this->dieUsage( 'docserver-http: HTTP ' . $response['code'], $response['code'] ); } return $response['body']; } protected function storeInSerializationCache( $title, $oldid, $html, $etag ) { global $wgMemc; // Convert the VE HTML to wikitext $text = $this->postHTML( $title, $html, [ 'oldid' => $oldid ], $etag ); if ( $text === false ) { return false; } // Store the corresponding wikitext, referenceable by a new key $hash = md5( $text ); $key = wfMemcKey( 'visualeditor', 'serialization', $hash ); $wgMemc->set( $key, $text, $this->veConfig->get( 'VisualEditorSerializationCacheTimeout' ) ); // Also parse and prepare the edit in case it might be saved later $page = WikiPage::factory( $title ); $content = ContentHandler::makeContent( $text, $title, CONTENT_MODEL_WIKITEXT ); $status = ApiStashEdit::parseAndStash( $page, $content, $this->getUser(), '' ); if ( $status === ApiStashEdit::ERROR_NONE ) { $logger = LoggerFactory::getInstance( 'StashEdit' ); $logger->debug( 'StashEdit', "Cached parser output for VE content key '$key'." ); } $this->getStats()->increment( "editstash.ve_cache_stores.$status" ); return $hash; } protected function trySerializationCache( $hash ) { global $wgMemc; $key = wfMemcKey( 'visualeditor', 'serialization', $hash ); return $wgMemc->get( $key ); } protected function postHTML( $title, $html, $parserParams, $etag ) { if ( $parserParams['oldid'] === 0 ) { $parserParams['oldid'] = ''; } $path = 'transform/html/to/wikitext/' . urlencode( $title->getPrefixedDBkey() ); if ( $parserParams['oldid'] ) { $path .= '/' . $parserParams['oldid']; } if ( !is_string( $etag ) || $etag === '' ) { wfDebugLog( 'AdHocDebug', 'VisualEditr T135171 - bad etag: ' . var_export( $etag, true ) ); } return $this->requestRestbase( 'POST', $path, [ 'html' => $html, 'scrub_wikitext' => 1, ], [ 'If-Match' => $etag ] ); } protected function pstWikitext( $title, $wikitext ) { return ContentHandler::makeContent( $wikitext, $title, CONTENT_MODEL_WIKITEXT ) ->preSaveTransform( $title, $this->getUser(), WikiPage::factory( $title )->makeParserOptions( $this->getContext() ) ) ->serialize( 'text/x-wiki' ); } protected function parseWikitextFragment( $title, $wikitext ) { return $this->requestRestbase( 'POST', 'transform/wikitext/to/html/' . urlencode( $title->getPrefixedDBkey() ), [ 'wikitext' => $wikitext, 'body_only' => 1, ] ); } protected function diffWikitext( $title, $wikitext ) { $apiParams = [ 'action' => 'query', 'prop' => 'revisions', 'titles' => $title->getPrefixedDBkey(), 'rvdifftotext' => $this->pstWikitext( $title, $wikitext ) ]; $api = new ApiMain( new DerivativeRequest( $this->getRequest(), $apiParams, false // was posted? ), false // enable write? ); $api->execute(); if ( defined( 'ApiResult::META_CONTENT' ) ) { $result = $api->getResult()->getResultData( null, [ 'BC' => [], // Transform content nodes to '*' 'Types' => [], // Add back-compat subelements ] ); } else { $result = $api->getResultData(); } if ( !isset( $result['query']['pages'][$title->getArticleID()]['revisions'][0]['diff']['*'] ) ) { return [ 'result' => 'fail' ]; } $diffRows = $result['query']['pages'][$title->getArticleID()]['revisions'][0]['diff']['*']; if ( $diffRows !== '' ) { $context = new DerivativeContext( $this->getContext() ); $context->setTitle( $title ); $engine = new DifferenceEngine( $context ); return [ 'result' => 'success', 'diff' => $engine->addHeader( $diffRows, $context->msg( 'currentrev' )->parse(), $context->msg( 'yourtext' )->parse() ) ]; } else { return [ 'result' => 'nochanges' ]; } } protected function getLangLinks( $title ) { $apiParams = [ 'action' => 'query', 'prop' => 'langlinks', 'lllimit' => 500, 'titles' => $title->getPrefixedDBkey(), 'indexpageids' => 1, ]; $api = new ApiMain( new DerivativeRequest( $this->getRequest(), $apiParams, false // was posted? ), true // enable write? ); $api->execute(); if ( defined( 'ApiResult::META_CONTENT' ) ) { $result = $api->getResult()->getResultData( null, [ 'BC' => [], // Backwards-compatible structure transformations 'Types' => [], // Backwards-compatible structure transformations 'Strip' => 'all', // Remove any metadata keys from the langlinks array ] ); } else { $result = $api->getResultData(); } if ( !isset( $result['query']['pages'][$title->getArticleID()]['langlinks'] ) ) { return false; } $langlinks = $result['query']['pages'][$title->getArticleID()]['langlinks']; $langnames = Language::fetchLanguageNames(); foreach ( $langlinks as $i => $lang ) { $langlinks[$i]['langname'] = $langnames[$langlinks[$i]['lang']]; } return $langlinks; } public function execute() { $user = $this->getUser(); $params = $this->extractRequestParams(); $title = Title::newFromText( $params['page'] ); if ( !$title ) { $this->dieUsageMsg( 'invalidtitle', $params['page'] ); } $isSafeAction = in_array( $params['paction'], self::$SAFE_ACTIONS, true ); if ( !$isSafeAction ) { $this->checkAllowedNamespace( $title->getNamespace() ); } $parserParams = []; if ( isset( $params['oldid'] ) ) { $parserParams['oldid'] = $params['oldid']; } $html = $params['html']; if ( substr( $html, 0, 11 ) === 'rawdeflate,' ) { $deflated = base64_decode( substr( $html, 11 ) ); wfSuppressWarnings(); $html = gzinflate( $deflated ); wfRestoreWarnings(); if ( $deflated === $html || $html === false ) { $this->dieUsage( "HTML provided is not properly deflated", 'invaliddeflate' ); } } wfDebugLog( 'visualeditor', "called on '$title' with paction: '{$params['paction']}'" ); switch ( $params['paction'] ) { case 'parse': case 'metadata': // Dirty hack to provide the correct context for edit notices global $wgTitle; // FIXME NOOOOOOOOES $wgTitle = $title; RequestContext::getMain()->setTitle( $title ); // Get information about current revision if ( $title->exists() ) { $latestRevision = Revision::newFromTitle( $title ); if ( $latestRevision === null ) { $this->dieUsage( 'Could not find latest revision for title', '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->dieUsage( 'Could not find revision ID ' . $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' ) { $content = $this->requestRestbase( 'GET', 'page/html/' . urlencode( $title->getPrefixedDBkey() ) . '/' . $oldid . '?redirect=false', [] ); if ( $content === false ) { $this->dieUsage( 'Error contacting the document server', 'docserver' ); } } } else { $content = ''; $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(); } // Old revision notice if ( $restoring ) { $notices[] = $this->msg( 'editingold' )->parseAsBlock(); } if ( wfReadOnly() ) { $notices[] = $this->msg( 'readonlywarning', wfReadOnlyReason() ); } // New page notices if ( !$title->exists() ) { $notices[] = $this->msg( $user->isLoggedIn() ? 'newarticletext' : 'newarticletextanon', wfExpandUrl( Skin::makeInternalOrExternalUrl( $this->msg( 'helppage' )->inContentLanguage()->text() ) ) )->parseAsBlock(); // Page protected from creation if ( $title->getRestrictions( 'create' ) ) { $notices[] = $this->msg( 'titleprotectedwarning' )->parseAsBlock(); } } // Look at protection status to set up notices + surface class(es) $protectedClasses = []; if ( MWNamespace::getRestrictionLevels( $title->getNamespace() ) !== [ '' ] ) { // Page protected from editing if ( $title->isProtected( 'edit' ) ) { # Is the title semi-protected? if ( $title->isSemiProtected() ) { $protectedClasses[] = 'mw-textarea-sprotected'; $noticeMsg = 'semiprotectedpagewarning'; } else { $protectedClasses[] = 'mw-textarea-protected'; # Then it must be protected based on static groups (regular) $noticeMsg = 'protectedpagewarning'; } $notices[] = $this->msg( $noticeMsg )->parseAsBlock() . $this->getLastLogEntry( $title, 'protect' ); } // Deal with cascading edit protection list( $sources, $restrictions ) = $title->getCascadeProtectionSources(); if ( isset( $restrictions['edit'] ) ) { $protectedClasses[] = ' mw-textarea-cprotected'; $notice = $this->msg( 'cascadeprotectedwarning' )->parseAsBlock() . '