Файловый менеджер - Редактировать - /var/www/html/Handler.zip
Ðазад
PK ! ���qO O RedirectHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use MediaWiki\Config\ConfigException; use MediaWiki\Rest\Handler; use MediaWiki\Rest\Response; use MediaWiki\Rest\RouteDefinitionException; /** * A generic redirect handler for the REST API. * * To declare a redirect in a route file, use the following structure: * @code * { * "path": "/path/to/trigger/a/redirect/{foo}", * "redirect": { * "path": "/redirect/target/{foo}", * "code": 302 * } * } * @endcode * * It is not necessary to specify the handler class. * The default status code is 308. * Path parameters and query parameters will be looped through. * * @since 1.43 * @package MediaWiki\Rest\Handler */ class RedirectHandler extends Handler { /** * @return Response * @throws ConfigException */ public function execute() { $path = $this->getConfig()['redirect']['path'] ?? ''; if ( $path === '' ) { throw new RouteDefinitionException( 'No registered redirect for this path' ); } $code = $this->getConfig()['redirect']['code'] ?? 308; $pathParams = $this->getRequest()->getPathParams(); $queryParams = $this->getRequest()->getQueryParams(); $locationPath = $this->getRouter()->getRoutePath( $path, $pathParams, $queryParams ); $response = $this->getResponseFactory()->createRedirect( $locationPath, $code ); return $response; } } PK ! ���� PageSourceHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use LogicException; use MediaWiki\Page\PageReference; use MediaWiki\Rest\Handler\Helper\PageContentHelper; use MediaWiki\Rest\Handler\Helper\PageRedirectHelper; use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Rest\SimpleHandler; use MediaWiki\Title\TitleFormatter; use Wikimedia\Message\MessageValue; /** * Handler class for Core REST API Page Source endpoint with the following routes: * - /page/{title} * - /page/{title}/bare */ class PageSourceHandler extends SimpleHandler { private TitleFormatter $titleFormatter; private PageRestHelperFactory $helperFactory; private PageContentHelper $contentHelper; public function __construct( TitleFormatter $titleFormatter, PageRestHelperFactory $helperFactory ) { $this->titleFormatter = $titleFormatter; $this->contentHelper = $helperFactory->newPageContentHelper(); $this->helperFactory = $helperFactory; } private function getRedirectHelper(): PageRedirectHelper { return $this->helperFactory->newPageRedirectHelper( $this->getResponseFactory(), $this->getRouter(), $this->getPath(), $this->getRequest() ); } protected function postValidationSetup() { $this->contentHelper->init( $this->getAuthority(), $this->getValidatedParams() ); } /** * @param PageReference $page * @return string */ private function constructHtmlUrl( PageReference $page ): string { // TODO: once legacy "v1" routes are removed, just use the path prefix from the module. $pathPrefix = $this->getModule()->getPathPrefix(); if ( strlen( $pathPrefix ) == 0 ) { $pathPrefix = 'v1'; } return $this->getRouter()->getRouteUrl( '/' . $pathPrefix . '/page/{title}/html', [ 'title' => $this->titleFormatter->getPrefixedText( $page ) ] ); } /** * @return Response * @throws LocalizedHttpException */ public function run(): Response { $this->contentHelper->checkAccess(); $page = $this->contentHelper->getPageIdentity(); if ( !$page->exists() ) { // We may get here for "known" but non-existing pages, such as // message pages. Since there is no page, we should still return // a 404. See T349677 for discussion. $titleText = $this->contentHelper->getTitleText() ?? '(unknown)'; throw new LocalizedHttpException( MessageValue::new( 'rest-nonexistent-title' ) ->plaintextParams( $titleText ), 404 ); } $redirectHelper = $this->getRedirectHelper(); '@phan-var \MediaWiki\Page\ExistingPageRecord $page'; $redirectResponse = $redirectHelper->createNormalizationRedirectResponseIfNeeded( $page, $this->contentHelper->getTitleText() ); if ( $redirectResponse !== null ) { return $redirectResponse; } $outputMode = $this->getOutputMode(); switch ( $outputMode ) { case 'restbase': // compatibility for restbase migration $body = [ 'items' => [ $this->contentHelper->constructRestbaseCompatibleMetadata() ] ]; break; case 'bare': $body = $this->contentHelper->constructMetadata(); $body['html_url'] = $this->constructHtmlUrl( $page ); break; case 'source': $content = $this->contentHelper->getContent(); $body = $this->contentHelper->constructMetadata(); $body['source'] = $content->getText(); break; default: throw new LogicException( "Unknown HTML type $outputMode" ); } // If param redirect=no is present, that means this page can be a redirect // check for a redirectTargetUrl and send it to the body as `redirect_target` '@phan-var \MediaWiki\Page\ExistingPageRecord $page'; $redirectTargetUrl = $redirectHelper->getWikiRedirectTargetUrl( $page ); if ( $redirectTargetUrl ) { $body['redirect_target'] = $redirectTargetUrl; } $response = $this->getResponseFactory()->createJson( $body ); $this->contentHelper->setCacheControl( $response ); return $response; } /** * Returns an ETag representing a page's source. The ETag assumes a page's source has changed * if the latest revision of a page has been made private, un-readable for another reason, * or a newer revision exists. * @return string|null */ protected function getETag(): ?string { return $this->contentHelper->getETag(); } /** * @return string|null */ protected function getLastModified(): ?string { return $this->contentHelper->getLastModified(); } private function getOutputMode(): string { if ( $this->getRouter()->isRestbaseCompatEnabled( $this->getRequest() ) ) { return 'restbase'; } return $this->getConfig()['format']; } public function needsWriteAccess(): bool { return false; } public function getParamSettings(): array { return $this->contentHelper->getParamSettings(); } /** * @return bool */ protected function hasRepresentation() { return $this->contentHelper->hasContent(); } } PK ! ��a�8 �8 SearchHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use InvalidArgumentException; use ISearchResultSet; use MediaWiki\Cache\CacheKeyHelper; use MediaWiki\Config\Config; use MediaWiki\MainConfigNames; use MediaWiki\Page\PageIdentity; use MediaWiki\Page\PageStore; use MediaWiki\Page\RedirectLookup; use MediaWiki\Permissions\PermissionManager; use MediaWiki\Rest\Handler; use MediaWiki\Rest\Handler\Helper\RestStatusTrait; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Search\Entity\SearchResultThumbnail; use MediaWiki\Search\SearchResultThumbnailProvider; use MediaWiki\Title\TitleFormatter; use SearchEngine; use SearchEngineConfig; use SearchEngineFactory; use SearchResult; use SearchSuggestion; use StatusValue; use Wikimedia\ParamValidator\ParamValidator; use Wikimedia\ParamValidator\TypeDef\IntegerDef; /** * Handler class for Core REST API endpoint that handles basic search */ class SearchHandler extends Handler { use RestStatusTrait; private SearchEngineFactory $searchEngineFactory; private SearchEngineConfig $searchEngineConfig; private SearchResultThumbnailProvider $searchResultThumbnailProvider; private PermissionManager $permissionManager; private RedirectLookup $redirectLookup; private PageStore $pageStore; private TitleFormatter $titleFormatter; /** * Search page body and titles. */ public const FULLTEXT_MODE = 'fulltext'; /** * Search title completion matches. */ public const COMPLETION_MODE = 'completion'; /** * Supported modes */ private const SUPPORTED_MODES = [ self::FULLTEXT_MODE, self::COMPLETION_MODE ]; /** * @var string */ private $mode = null; /** Limit results to 50 pages by default */ private const LIMIT = 50; /** Hard limit results to 100 pages */ private const MAX_LIMIT = 100; /** Default to first page */ private const OFFSET = 0; /** * Expiry time for use as max-age value in the cache-control header * of completion search responses. * @see $wgSearchSuggestCacheExpiry * @var int|null */ private $completionCacheExpiry; public function __construct( Config $config, SearchEngineFactory $searchEngineFactory, SearchEngineConfig $searchEngineConfig, SearchResultThumbnailProvider $searchResultThumbnailProvider, PermissionManager $permissionManager, RedirectLookup $redirectLookup, PageStore $pageStore, TitleFormatter $titleFormatter ) { $this->searchEngineFactory = $searchEngineFactory; $this->searchEngineConfig = $searchEngineConfig; $this->searchResultThumbnailProvider = $searchResultThumbnailProvider; $this->permissionManager = $permissionManager; $this->redirectLookup = $redirectLookup; $this->pageStore = $pageStore; $this->titleFormatter = $titleFormatter; // @todo Avoid injecting the entire config, see T246377 $this->completionCacheExpiry = $config->get( MainConfigNames::SearchSuggestCacheExpiry ); } protected function postInitSetup() { $this->mode = $this->getConfig()['mode'] ?? self::FULLTEXT_MODE; if ( !in_array( $this->mode, self::SUPPORTED_MODES ) ) { throw new InvalidArgumentException( "Unsupported search mode `{$this->mode}` configured. Supported modes: " . implode( ', ', self::SUPPORTED_MODES ) ); } } /** * @return SearchEngine */ private function createSearchEngine() { $limit = $this->getValidatedParams()['limit']; $searchEngine = $this->searchEngineFactory->create(); $searchEngine->setNamespaces( $this->searchEngineConfig->defaultNamespaces() ); $searchEngine->setLimitOffset( $limit, self::OFFSET ); return $searchEngine; } public function needsWriteAccess() { return false; } /** * Get SearchResults when results are either SearchResultSet or Status objects * @param ISearchResultSet|StatusValue|null $results * @return SearchResult[] * @throws LocalizedHttpException */ private function getSearchResultsOrThrow( $results ) { if ( $results ) { if ( $results instanceof StatusValue ) { $status = $results; if ( !$status->isOK() ) { if ( $status->getMessages( 'error' ) ) { // Only throw for errors, suppress warnings (for now) $this->throwExceptionForStatus( $status, 'rest-search-error', 500 ); } } $statusValue = $status->getValue(); if ( $statusValue instanceof ISearchResultSet ) { return $statusValue->extractResults(); } } else { return $results->extractResults(); } } return []; } /** * Execute search and return info about pages for further processing. * * @param SearchEngine $searchEngine * @return array[] * @throws LocalizedHttpException */ private function doSearch( $searchEngine ) { $query = $this->getValidatedParams()['q']; if ( $this->mode == self::COMPLETION_MODE ) { $completionSearch = $searchEngine->completionSearchWithVariants( $query ); return $this->buildPageObjects( $completionSearch->getSuggestions() ); } else { $titleSearch = $searchEngine->searchTitle( $query ); $textSearch = $searchEngine->searchText( $query ); $titleSearchResults = $this->getSearchResultsOrThrow( $titleSearch ); $textSearchResults = $this->getSearchResultsOrThrow( $textSearch ); $mergedResults = array_merge( $titleSearchResults, $textSearchResults ); return $this->buildPageObjects( $mergedResults ); } } /** * Build an array of pageInfo objects. * @param SearchSuggestion[]|SearchResult[] $searchResponse * * @phpcs:ignore Generic.Files.LineLength * @phan-return array{int:array{pageIdentity:PageIdentity,suggestion:?SearchSuggestion,result:?SearchResult,redirect:?PageIdentity}} $pageInfos * @return array Associative array mapping pageID to pageInfo objects: * - pageIdentity: PageIdentity of page to return as the match * - suggestion: SearchSuggestion or null if $searchResponse is SearchResults[] * - result: SearchResult or null if $searchResponse is SearchSuggestions[] * - redirect: PageIdentity or null if the SearchResult|SearchSuggestion was not a redirect */ private function buildPageObjects( array $searchResponse ): array { $pageInfos = []; foreach ( $searchResponse as $response ) { $isSearchResult = $response instanceof SearchResult; if ( $isSearchResult ) { if ( $response->isBrokenTitle() || $response->isMissingRevision() ) { continue; } $title = $response->getTitle(); } else { $title = $response->getSuggestedTitle(); } $pageObj = $this->buildSinglePage( $title, $response ); if ( $pageObj ) { $pageNsAndID = CacheKeyHelper::getKeyForPage( $pageObj['pageIdentity'] ); // This handles the edge case where we have both the redirect source and redirect target page come back // in our search results. In such event, we prefer (and thus replace) with the redirect target page. if ( isset( $pageInfos[$pageNsAndID] ) ) { if ( $pageInfos[$pageNsAndID]['redirect'] !== null ) { $pageInfos[$pageNsAndID]['result'] = $isSearchResult ? $response : null; $pageInfos[$pageNsAndID]['suggestion'] = $isSearchResult ? null : $response; } continue; } $pageInfos[$pageNsAndID] = $pageObj; } } return $pageInfos; } /** * Build one pageInfo object from either a SearchResult or SearchSuggestion. * @param PageIdentity $title * @param SearchResult|SearchSuggestion $result * * @phpcs:ignore Generic.Files.LineLength * @phan-return (false|array{pageIdentity:PageIdentity,suggestion:?SearchSuggestion,result:?SearchResult,redirect:?PageIdentity}) $pageInfos * @return bool|array Objects representing a given page: * - pageIdentity: PageIdentity of page to return as the match * - suggestion: SearchSuggestion or null if $searchResponse is SearchResults * - result: SearchResult or null if $searchResponse is SearchSuggestions * - redirect: PageIdentity|null depending on if the SearchResult|SearchSuggestion was a redirect */ private function buildSinglePage( $title, $result ) { $redirectTarget = $title->canExist() ? $this->redirectLookup->getRedirectTarget( $title ) : null; // Our page has a redirect that is not in a virtual namespace and is not an interwiki link. // See T301346, T303352 if ( $redirectTarget && $redirectTarget->getNamespace() > -1 && !$redirectTarget->isExternal() ) { $redirectSource = $title; $title = $this->pageStore->getPageForLink( $redirectTarget ); } else { $redirectSource = null; } if ( !$title || !$this->getAuthority()->probablyCan( 'read', $title ) ) { return false; } return [ 'pageIdentity' => $title, 'suggestion' => $result instanceof SearchSuggestion ? $result : null, 'result' => $result instanceof SearchResult ? $result : null, 'redirect' => $redirectSource ]; } /** * Turn array of page info into serializable array with common information about the page * @param array $pageInfos Page Info objects * @param array $thumbsAndDesc Associative array mapping pageId to array of description and thumbnail * @phpcs:ignore Generic.Files.LineLength * @phan-param array<int,array{pageIdentity:PageIdentity,suggestion:SearchSuggestion,result:SearchResult,redirect:?PageIdentity}> $pageInfos * @phan-param array<int,array{description:array,thumbnail:array}> $thumbsAndDesc * * @phpcs:ignore Generic.Files.LineLength * @phan-return array<int,array{id:int,key:string,title:string,excerpt:?string,matched_title:?string, description:?array, thumbnail:?array}> $pages * @return array[] of [ id, key, title, excerpt, matched_title ] */ private function buildResultFromPageInfos( array $pageInfos, array $thumbsAndDesc ): array { $pages = []; foreach ( $pageInfos as $pageInfo ) { [ 'pageIdentity' => $page, 'suggestion' => $sugg, 'result' => $result, 'redirect' => $redirect ] = $pageInfo; $excerpt = $sugg ? $sugg->getText() : $result->getTextSnippet(); $id = ( $page instanceof PageIdentity && $page->canExist() ) ? $page->getId() : 0; $pages[] = [ 'id' => $id, 'key' => $this->titleFormatter->getPrefixedDBkey( $page ), 'title' => $this->titleFormatter->getPrefixedText( $page ), 'excerpt' => $excerpt ?: null, 'matched_title' => $redirect ? $this->titleFormatter->getPrefixedText( $redirect ) : null, 'description' => $id > 0 ? $thumbsAndDesc[$id]['description'] : null, 'thumbnail' => $id > 0 ? $thumbsAndDesc[$id]['thumbnail'] : null, ]; } return $pages; } /** * Converts SearchResultThumbnail object into serializable array * * @param SearchResultThumbnail|null $thumbnail * * @return array|null */ private function serializeThumbnail( ?SearchResultThumbnail $thumbnail ): ?array { if ( $thumbnail == null ) { return null; } return [ 'mimetype' => $thumbnail->getMimeType(), 'width' => $thumbnail->getWidth(), 'height' => $thumbnail->getHeight(), 'duration' => $thumbnail->getDuration(), 'url' => $thumbnail->getUrl(), ]; } /** * Turn page info into serializable array with description field for the page. * * The information about description should be provided by extension by implementing * 'SearchResultProvideDescription' hook. Description is set to null if no extensions * implement the hook. * @param PageIdentity[] $pageIdentities * * @return array */ private function buildDescriptionsFromPageIdentities( array $pageIdentities ) { $descriptions = array_fill_keys( array_keys( $pageIdentities ), null ); $this->getHookRunner()->onSearchResultProvideDescription( $pageIdentities, $descriptions ); return array_map( static function ( $description ) { return [ 'description' => $description ]; }, $descriptions ); } /** * Turn page info into serializable array with thumbnail information for the page. * * The information about thumbnail should be provided by extension by implementing * 'SearchResultProvideThumbnail' hook. Thumbnail is set to null if no extensions implement * the hook. * * @param PageIdentity[] $pageIdentities * * @return array */ private function buildThumbnailsFromPageIdentities( array $pageIdentities ) { $thumbnails = $this->searchResultThumbnailProvider->getThumbnails( $pageIdentities ); $thumbnails += array_fill_keys( array_keys( $pageIdentities ), null ); return array_map( function ( $thumbnail ) { return [ 'thumbnail' => $this->serializeThumbnail( $thumbnail ) ]; }, $thumbnails ); } /** * @return Response * @throws LocalizedHttpException */ public function execute() { $searchEngine = $this->createSearchEngine(); $pageInfos = $this->doSearch( $searchEngine ); // We can only pass validated "real" PageIdentities to our hook handlers below $pageIdentities = array_reduce( array_values( $pageInfos ), static function ( $realPages, $item ) { $page = $item['pageIdentity']; if ( $page instanceof PageIdentity && $page->exists() ) { $realPages[$item['pageIdentity']->getId()] = $item['pageIdentity']; } return $realPages; }, [] ); $descriptions = $this->buildDescriptionsFromPageIdentities( $pageIdentities ); $thumbs = $this->buildThumbnailsFromPageIdentities( $pageIdentities ); $thumbsAndDescriptions = []; foreach ( $descriptions as $pageId => $description ) { $thumbsAndDescriptions[$pageId] = $description + $thumbs[$pageId]; } $result = $this->buildResultFromPageInfos( $pageInfos, $thumbsAndDescriptions ); $response = $this->getResponseFactory()->createJson( [ 'pages' => $result ] ); if ( $this->mode === self::COMPLETION_MODE && $this->completionCacheExpiry ) { // Type-ahead completion matches should be cached by the client and // in the CDN, especially for short prefixes. // See also $wgSearchSuggestCacheExpiry and ApiOpenSearch if ( $this->permissionManager->isEveryoneAllowed( 'read' ) ) { $response->setHeader( 'Cache-Control', 'public, max-age=' . $this->completionCacheExpiry ); } else { $response->setHeader( 'Cache-Control', 'no-store, max-age=0' ); } } return $response; } public function getParamSettings() { return [ 'q' => [ self::PARAM_SOURCE => 'query', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], 'limit' => [ self::PARAM_SOURCE => 'query', ParamValidator::PARAM_TYPE => 'integer', ParamValidator::PARAM_REQUIRED => false, ParamValidator::PARAM_DEFAULT => self::LIMIT, IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => self::MAX_LIMIT, ], ]; } } PK ! @�td: d: PageHistoryHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use ChangeTags; use MediaWiki\Page\ExistingPageRecord; use MediaWiki\Page\PageLookup; use MediaWiki\Permissions\GroupPermissionsLookup; use MediaWiki\Rest\Handler\Helper\PageRedirectHelper; use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Rest\SimpleHandler; use MediaWiki\Revision\RevisionRecord; use MediaWiki\Revision\RevisionStore; use MediaWiki\Storage\NameTableAccessException; use MediaWiki\Storage\NameTableStore; use MediaWiki\Storage\NameTableStoreFactory; use MediaWiki\Title\TitleFormatter; use Wikimedia\Message\MessageValue; use Wikimedia\Message\ParamType; use Wikimedia\Message\ScalarParam; use Wikimedia\ParamValidator\ParamValidator; use Wikimedia\Rdbms\IConnectionProvider; use Wikimedia\Rdbms\IDBAccessObject; use Wikimedia\Rdbms\IResultWrapper; use Wikimedia\Rdbms\RawSQLExpression; /** * Handler class for Core REST API endpoints that perform operations on revisions */ class PageHistoryHandler extends SimpleHandler { private const REVISIONS_RETURN_LIMIT = 20; private const ALLOWED_FILTER_TYPES = [ 'anonymous', 'bot', 'reverted', 'minor' ]; private RevisionStore $revisionStore; private NameTableStore $changeTagDefStore; private GroupPermissionsLookup $groupPermissionsLookup; private IConnectionProvider $dbProvider; private PageLookup $pageLookup; private TitleFormatter $titleFormatter; private PageRestHelperFactory $helperFactory; /** * @var ExistingPageRecord|false|null */ private $page = false; /** * RevisionStore $revisionStore * * @param RevisionStore $revisionStore * @param NameTableStoreFactory $nameTableStoreFactory * @param GroupPermissionsLookup $groupPermissionsLookup * @param IConnectionProvider $dbProvider * @param PageLookup $pageLookup * @param TitleFormatter $titleFormatter * @param PageRestHelperFactory $helperFactory */ public function __construct( RevisionStore $revisionStore, NameTableStoreFactory $nameTableStoreFactory, GroupPermissionsLookup $groupPermissionsLookup, IConnectionProvider $dbProvider, PageLookup $pageLookup, TitleFormatter $titleFormatter, PageRestHelperFactory $helperFactory ) { $this->revisionStore = $revisionStore; $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef(); $this->groupPermissionsLookup = $groupPermissionsLookup; $this->dbProvider = $dbProvider; $this->pageLookup = $pageLookup; $this->titleFormatter = $titleFormatter; $this->helperFactory = $helperFactory; } private function getRedirectHelper(): PageRedirectHelper { return $this->helperFactory->newPageRedirectHelper( $this->getResponseFactory(), $this->getRouter(), $this->getPath(), $this->getRequest() ); } /** * @return ExistingPageRecord|null */ private function getPage(): ?ExistingPageRecord { if ( $this->page === false ) { $this->page = $this->pageLookup->getExistingPageByText( $this->getValidatedParams()['title'] ); } return $this->page; } /** * At most one of older_than and newer_than may be specified. Keep in mind that revision ids * are not monotonically increasing, so a revision may be older than another but have a * higher revision id. * * @param string $title * @return Response * @throws LocalizedHttpException */ public function run( $title ) { $params = $this->getValidatedParams(); if ( $params['older_than'] !== null && $params['newer_than'] !== null ) { throw new LocalizedHttpException( new MessageValue( 'rest-pagehistory-incompatible-params' ), 400 ); } if ( ( $params['older_than'] !== null && $params['older_than'] < 1 ) || ( $params['newer_than'] !== null && $params['newer_than'] < 1 ) ) { throw new LocalizedHttpException( new MessageValue( 'rest-pagehistory-param-range-error' ), 400 ); } $tagIds = []; if ( $params['filter'] === 'reverted' ) { foreach ( ChangeTags::REVERT_TAGS as $tagName ) { try { $tagIds[] = $this->changeTagDefStore->getId( $tagName ); } catch ( NameTableAccessException $exception ) { // If no revisions are tagged with a name, no tag id will be present } } } $page = $this->getPage(); if ( !$page ) { throw new LocalizedHttpException( new MessageValue( 'rest-nonexistent-title', [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] ), 404 ); } if ( !$this->getAuthority()->authorizeRead( 'read', $page ) ) { throw new LocalizedHttpException( new MessageValue( 'rest-permission-denied-title', [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] ), 403 ); } '@phan-var \MediaWiki\Page\ExistingPageRecord $page'; $redirectResponse = $this->getRedirectHelper()->createNormalizationRedirectResponseIfNeeded( $page, $params['title'] ?? null ); if ( $redirectResponse !== null ) { return $redirectResponse; } $relativeRevId = $params['older_than'] ?? $params['newer_than'] ?? 0; if ( $relativeRevId ) { // Confirm the relative revision exists for this page. If so, get its timestamp. $rev = $this->revisionStore->getRevisionByPageId( $page->getId(), $relativeRevId ); if ( !$rev ) { throw new LocalizedHttpException( new MessageValue( 'rest-nonexistent-title-revision', [ $relativeRevId, new ScalarParam( ParamType::PLAINTEXT, $title ) ] ), 404 ); } $ts = $rev->getTimestamp(); if ( $ts === null ) { throw new LocalizedHttpException( new MessageValue( 'rest-pagehistory-timestamp-error', [ $relativeRevId ] ), 500 ); } } else { $ts = 0; } $res = $this->getDbResults( $page, $params, $relativeRevId, $ts, $tagIds ); $response = $this->processDbResults( $res, $page, $params ); return $this->getResponseFactory()->createJson( $response ); } /** * @param ExistingPageRecord $page object identifying the page to load history for * @param array $params request parameters * @param int $relativeRevId relative revision id for paging, or zero if none * @param int $ts timestamp for paging, or zero if none * @param array $tagIds validated tags ids, or empty array if not needed for this query * @return IResultWrapper|bool the results, or false if no query was executed */ private function getDbResults( ExistingPageRecord $page, array $params, $relativeRevId, $ts, $tagIds ) { $dbr = $this->dbProvider->getReplicaDatabase(); $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $dbr ) ->joinComment() ->where( [ 'rev_page' => $page->getId() ] ) // Select one more than the return limit, to learn if there are additional revisions. ->limit( self::REVISIONS_RETURN_LIMIT + 1 ); if ( $params['filter'] ) { // The validator ensures this value, if present, is one of the expected values switch ( $params['filter'] ) { case 'bot': $subquery = $queryBuilder->newSubquery() ->select( '1' ) ->from( 'user_groups' ) ->where( [ 'actor_rev_user.actor_user = ug_user', 'ug_group' => $this->groupPermissionsLookup->getGroupsWithPermission( 'bot' ), $dbr->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $dbr->timestamp() ) ] ); $queryBuilder->andWhere( new RawSQLExpression( 'EXISTS(' . $subquery->getSQL() . ')' ) ); $bitmask = $this->getBitmask(); if ( $bitmask ) { $queryBuilder->andWhere( $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" ); } break; case 'anonymous': $queryBuilder->andWhere( [ 'actor_user' => null ] ); $bitmask = $this->getBitmask(); if ( $bitmask ) { $queryBuilder->andWhere( $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" ); } break; case 'reverted': if ( !$tagIds ) { return false; } $subquery = $queryBuilder->newSubquery() ->select( '1' ) ->from( 'change_tag' ) ->where( [ 'ct_rev_id = rev_id', 'ct_tag_id' => $tagIds ] ); $queryBuilder->andWhere( new RawSQLExpression( 'EXISTS(' . $subquery->getSQL() . ')' ) ); break; case 'minor': $queryBuilder->andWhere( $dbr->expr( 'rev_minor_edit', '!=', 0 ) ); break; } } if ( $relativeRevId ) { $op = $params['older_than'] ? '<' : '>'; $sort = $params['older_than'] ? 'DESC' : 'ASC'; $queryBuilder->andWhere( $dbr->buildComparison( $op, [ 'rev_timestamp' => $dbr->timestamp( $ts ), 'rev_id' => $relativeRevId, ] ) ); $queryBuilder->orderBy( [ 'rev_timestamp', 'rev_id' ], $sort ); } else { $queryBuilder->orderBy( [ 'rev_timestamp', 'rev_id' ], 'DESC' ); } return $queryBuilder->caller( __METHOD__ )->fetchResultSet(); } /** * Helper function for rev_deleted/user rights query conditions * * @todo Factor out rev_deleted logic per T233222 * * @return int */ private function getBitmask() { if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) { $bitmask = RevisionRecord::DELETED_USER; } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED; } else { $bitmask = 0; } return $bitmask; } /** * @param IResultWrapper|bool $res database results, or false if no query was executed * @param ExistingPageRecord $page object identifying the page to load history for * @param array $params request parameters * @return array response data */ private function processDbResults( $res, $page, $params ) { $revisions = []; if ( $res ) { $sizes = []; foreach ( $res as $row ) { $rev = $this->revisionStore->newRevisionFromRow( $row, IDBAccessObject::READ_NORMAL, $page ); if ( !$revisions ) { $firstRevId = $row->rev_id; } $lastRevId = $row->rev_id; $revision = [ 'id' => $rev->getId(), 'timestamp' => wfTimestamp( TS_ISO_8601, $rev->getTimestamp() ), 'minor' => $rev->isMinor(), 'size' => $rev->getSize() ]; // Remember revision sizes and parent ids for calculating deltas. If a revision's // parent id is unknown, we will be unable to supply the delta for that revision. $sizes[$rev->getId()] = $rev->getSize(); $parentId = $rev->getParentId(); if ( $parentId ) { $revision['parent_id'] = $parentId; } $comment = $rev->getComment( RevisionRecord::FOR_THIS_USER, $this->getAuthority() ); $revision['comment'] = $comment ? $comment->text : null; $revUser = $rev->getUser( RevisionRecord::FOR_THIS_USER, $this->getAuthority() ); if ( $revUser ) { $revision['user'] = [ 'id' => $revUser->isRegistered() ? $revUser->getId() : null, 'name' => $revUser->getName() ]; } else { $revision['user'] = null; } $revisions[] = $revision; // Break manually at the return limit. We may have more results than we can return. if ( count( $revisions ) == self::REVISIONS_RETURN_LIMIT ) { break; } } // Request any parent sizes that we do not already know, then calculate deltas $unknownSizes = []; foreach ( $revisions as $revision ) { if ( isset( $revision['parent_id'] ) && !isset( $sizes[$revision['parent_id']] ) ) { $unknownSizes[] = $revision['parent_id']; } } if ( $unknownSizes ) { $sizes += $this->revisionStore->getRevisionSizes( $unknownSizes ); } foreach ( $revisions as &$revision ) { $revision['delta'] = null; if ( isset( $revision['parent_id'] ) ) { if ( isset( $sizes[$revision['parent_id']] ) ) { $revision['delta'] = $revision['size'] - $sizes[$revision['parent_id']]; } // We only remembered this for delta calculations. We do not want to return it. unset( $revision['parent_id'] ); } } if ( $revisions && $params['newer_than'] ) { $revisions = array_reverse( $revisions ); // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable // $lastRevId is declared because $res has one element $temp = $lastRevId; // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable // $firstRevId is declared because $res has one element $lastRevId = $firstRevId; $firstRevId = $temp; } } $response = [ 'revisions' => $revisions ]; // Omit newer/older if there are no additional corresponding revisions. // This facilitates clients doing "paging" style api operations. if ( $revisions ) { if ( $params['newer_than'] || $res->numRows() > self::REVISIONS_RETURN_LIMIT ) { // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable // $lastRevId is declared because $res has one element $older = $lastRevId; } if ( $params['older_than'] || ( $params['newer_than'] && $res->numRows() > self::REVISIONS_RETURN_LIMIT ) ) { // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable // $firstRevId is declared because $res has one element $newer = $firstRevId; } } $queryParts = []; if ( isset( $params['filter'] ) ) { $queryParts['filter'] = $params['filter']; } $pathParams = [ 'title' => $this->titleFormatter->getPrefixedDBkey( $page ) ]; $response['latest'] = $this->getRouteUrl( $pathParams, $queryParts ); if ( isset( $older ) ) { $response['older'] = $this->getRouteUrl( $pathParams, $queryParts + [ 'older_than' => $older ] ); } if ( isset( $newer ) ) { $response['newer'] = $this->getRouteUrl( $pathParams, $queryParts + [ 'newer_than' => $newer ] ); } return $response; } public function needsWriteAccess() { return false; } public function getParamSettings() { return [ 'title' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], 'older_than' => [ self::PARAM_SOURCE => 'query', ParamValidator::PARAM_TYPE => 'integer', ParamValidator::PARAM_REQUIRED => false, ], 'newer_than' => [ self::PARAM_SOURCE => 'query', ParamValidator::PARAM_TYPE => 'integer', ParamValidator::PARAM_REQUIRED => false, ], 'filter' => [ self::PARAM_SOURCE => 'query', ParamValidator::PARAM_TYPE => self::ALLOWED_FILTER_TYPES, ParamValidator::PARAM_REQUIRED => false, ], ]; } /** * Returns an ETag representing a page's latest revision. * * @return string|null */ protected function getETag(): ?string { $page = $this->getPage(); if ( !$page ) { return null; } return '"' . $page->getLatest() . '"'; } /** * Returns the time of the last change to the page. * * @return string|null */ protected function getLastModified(): ?string { $page = $this->getPage(); if ( !$page ) { return null; } $rev = $this->revisionStore->getKnownCurrentRevision( $page ); return $rev->getTimestamp(); } /** * @return bool */ protected function hasRepresentation() { return (bool)$this->getPage(); } } PK ! 4ޑ PageHTMLHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use LogicException; use MediaWiki\Rest\Handler\Helper\HtmlOutputHelper; use MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper; use MediaWiki\Rest\Handler\Helper\PageContentHelper; use MediaWiki\Rest\Handler\Helper\PageRedirectHelper; use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Rest\SimpleHandler; use MediaWiki\Rest\StringStream; use Wikimedia\Assert\Assert; /** * A handler that returns Parsoid HTML for the following routes: * - /page/{title}/html, * - /page/{title}/with_html * * @package MediaWiki\Rest\Handler */ class PageHTMLHandler extends SimpleHandler { private HtmlOutputHelper $htmlHelper; private PageContentHelper $contentHelper; private PageRestHelperFactory $helperFactory; public function __construct( PageRestHelperFactory $helperFactory ) { $this->contentHelper = $helperFactory->newPageContentHelper(); $this->helperFactory = $helperFactory; } private function getRedirectHelper(): PageRedirectHelper { return $this->helperFactory->newPageRedirectHelper( $this->getResponseFactory(), $this->getRouter(), $this->getPath(), $this->getRequest() ); } protected function postValidationSetup() { $authority = $this->getAuthority(); $this->contentHelper->init( $authority, $this->getValidatedParams() ); $page = $this->contentHelper->getPageIdentity(); $isSystemMessage = $this->contentHelper->useDefaultSystemMessage(); if ( $page ) { if ( $isSystemMessage ) { $this->htmlHelper = $this->helperFactory->newHtmlMessageOutputHelper( $page ); } else { $revision = $this->contentHelper->getTargetRevision(); $this->htmlHelper = $this->helperFactory->newHtmlOutputRendererHelper( $page, $this->getValidatedParams(), $authority, $revision ); $request = $this->getRequest(); $acceptLanguage = $request->getHeaderLine( 'Accept-Language' ) ?: null; if ( $acceptLanguage ) { $this->htmlHelper->setVariantConversionLanguage( $acceptLanguage ); } } } } /** * @return Response * @throws LocalizedHttpException */ public function run(): Response { $this->contentHelper->checkAccessPermission(); $page = $this->contentHelper->getPageIdentity(); $params = $this->getRequest()->getQueryParams(); if ( array_key_exists( 'redirect', $params ) ) { $followWikiRedirects = $params['redirect'] !== 'no'; } else { $followWikiRedirects = true; } // The call to $this->contentHelper->getPage() should not return null if // $this->contentHelper->checkAccess() did not throw. Assert::invariant( $page !== null, 'Page should be known' ); $redirectHelper = $this->getRedirectHelper(); $redirectHelper->setFollowWikiRedirects( $followWikiRedirects ); // Should treat variant redirects a special case as wiki redirects // if ?redirect=no language variant should do nothing and fall into the 404 path $redirectResponse = $redirectHelper->createRedirectResponseIfNeeded( $page, $this->contentHelper->getTitleText() ); if ( $redirectResponse !== null ) { return $redirectResponse; } // We could have a missing page at this point, check and return 404 if that's the case $this->contentHelper->checkHasContent(); $parserOutput = $this->htmlHelper->getHtml(); $parserOutputHtml = $parserOutput->getRawText(); $outputMode = $this->getOutputMode(); switch ( $outputMode ) { case 'html': $response = $this->getResponseFactory()->create(); $this->contentHelper->setCacheControl( $response, $parserOutput->getCacheExpiry() ); $response->setBody( new StringStream( $parserOutputHtml ) ); break; case 'with_html': $body = $this->contentHelper->constructMetadata(); $body['html'] = $parserOutputHtml; $redirectTargetUrl = $redirectHelper->getWikiRedirectTargetUrl( $page ); if ( $redirectTargetUrl ) { $body['redirect_target'] = $redirectTargetUrl; } $response = $this->getResponseFactory()->createJson( $body ); $this->contentHelper->setCacheControl( $response, $parserOutput->getCacheExpiry() ); break; default: throw new LogicException( "Unknown HTML type $outputMode" ); } $setContentLanguageHeader = ( $outputMode === 'html' ); $this->htmlHelper->putHeaders( $response, $setContentLanguageHeader ); return $response; } /** * Returns an ETag representing a page's source. The ETag assumes a page's source has changed * if the latest revision of a page has been made private, un-readable for another reason, * or a newer revision exists. * @return string|null */ protected function getETag(): ?string { if ( !$this->contentHelper->isAccessible() || !$this->contentHelper->hasContent() ) { return null; } // Vary eTag based on output mode return $this->htmlHelper->getETag( $this->getOutputMode() ); } /** * @return string|null */ protected function getLastModified(): ?string { if ( !$this->contentHelper->isAccessible() || !$this->contentHelper->hasContent() ) { return null; } return $this->htmlHelper->getLastModified(); } private function getOutputMode(): string { return $this->getConfig()['format']; } public function needsWriteAccess(): bool { return false; } public function getParamSettings(): array { return array_merge( $this->contentHelper->getParamSettings(), // Note that postValidation we might end up using // a HtmlMessageOutputHelper, but the param settings // for that are a subset of those for HtmlOutputRendererHelper HtmlOutputRendererHelper::getParamSettings() ); } } PK ! ^�CJG� G� ParsoidHandler.phpnu �Iw�� <?php /** * Copyright (C) 2011-2020 Wikimedia Foundation and others. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ namespace MediaWiki\Rest\Handler; use Composer\Semver\Semver; use InvalidArgumentException; use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; use LogicException; use MediaWiki\Content\WikitextContent; use MediaWiki\Context\RequestContext; use MediaWiki\Language\LanguageCode; use MediaWiki\Logger\LoggerFactory; use MediaWiki\MainConfigNames; use MediaWiki\MediaWikiServices; use MediaWiki\Page\PageIdentity; use MediaWiki\Page\ProperPageIdentity; use MediaWiki\Parser\ParserOutput; use MediaWiki\Parser\Parsoid\Config\SiteConfig; use MediaWiki\Registration\ExtensionRegistry; use MediaWiki\Rest\Handler; use MediaWiki\Rest\Handler\Helper\HtmlInputTransformHelper; use MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper; use MediaWiki\Rest\Handler\Helper\ParsoidFormatHelper; use MediaWiki\Rest\HttpException; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Revision\MutableRevisionRecord; use MediaWiki\Revision\RevisionAccessException; use MediaWiki\Revision\RevisionLookup; use MediaWiki\Revision\SlotRecord; use MediaWiki\Revision\SuppressedDataException; use MediaWiki\Title\MalformedTitleException; use MediaWiki\Title\Title; use MediaWiki\WikiMap\WikiMap; use MobileContext; use Wikimedia\Http\HttpAcceptParser; use Wikimedia\Message\DataMessageValue; use Wikimedia\Message\MessageValue; use Wikimedia\Parsoid\Config\DataAccess; use Wikimedia\Parsoid\Config\PageConfig; use Wikimedia\Parsoid\Config\PageConfigFactory; use Wikimedia\Parsoid\Core\ClientError; use Wikimedia\Parsoid\Core\PageBundle; use Wikimedia\Parsoid\Core\ResourceLimitExceededException; use Wikimedia\Parsoid\DOM\Document; use Wikimedia\Parsoid\Parsoid; use Wikimedia\Parsoid\Utils\ContentUtils; use Wikimedia\Parsoid\Utils\DOMCompat; use Wikimedia\Parsoid\Utils\DOMUtils; use Wikimedia\Parsoid\Utils\Timing; // TODO logging, timeouts(?), CORS // TODO content negotiation (routes.js routes.acceptable) // TODO handle MaxConcurrentCallsError (pool counter?) /** * Base class for Parsoid handlers. * @internal For use by the Parsoid extension */ abstract class ParsoidHandler extends Handler { private RevisionLookup $revisionLookup; protected SiteConfig $siteConfig; protected PageConfigFactory $pageConfigFactory; protected DataAccess $dataAccess; /** @var ExtensionRegistry */ protected $extensionRegistry; /** @var ?StatsdDataFactoryInterface A statistics aggregator */ protected $metrics; /** @var array */ private $requestAttributes; /** * @return static */ public static function factory(): ParsoidHandler { $services = MediaWikiServices::getInstance(); // @phan-suppress-next-line PhanTypeInstantiateAbstractStatic return new static( $services->getRevisionLookup(), $services->getParsoidSiteConfig(), $services->getParsoidPageConfigFactory(), $services->getParsoidDataAccess() ); } public function __construct( RevisionLookup $revisionLookup, SiteConfig $siteConfig, PageConfigFactory $pageConfigFactory, DataAccess $dataAccess ) { $this->revisionLookup = $revisionLookup; $this->siteConfig = $siteConfig; $this->pageConfigFactory = $pageConfigFactory; $this->dataAccess = $dataAccess; $this->extensionRegistry = ExtensionRegistry::getInstance(); $this->metrics = $siteConfig->metrics(); } public function getSupportedRequestTypes(): array { return array_merge( parent::getSupportedRequestTypes(), [ 'application/x-www-form-urlencoded', 'multipart/form-data' ] ); } /** * Verify that the {domain} path parameter matches the actual domain. * @todo Remove this when we no longer need to support the {domain} * parameter with backwards compatibility with the parsoid * extension. * @param string $domain Domain name parameter to validate */ protected function assertDomainIsCorrect( $domain ): void { // We are cutting some corners here (IDN, non-ASCII casing) // since domain name support is provisional. // TODO use a proper validator instead $server = RequestContext::getMain()->getConfig()->get( MainConfigNames::Server ); $expectedDomain = parse_url( $server, PHP_URL_HOST ); if ( !$expectedDomain ) { throw new LogicException( 'Cannot parse $wgServer' ); } if ( strcasecmp( $expectedDomain, $domain ) === 0 ) { return; } // TODO: This should really go away! It's only acceptable because // this entire method is going to be removed once we no longer // need the parsoid extension endpoints with the {domain} parameter. if ( $this->extensionRegistry->isLoaded( 'MobileFrontend' ) ) { // @phan-suppress-next-line PhanUndeclaredClassMethod $mobileServer = MobileContext::singleton()->getMobileUrl( $server ); $expectedMobileDomain = parse_url( $mobileServer, PHP_URL_HOST ); if ( $expectedMobileDomain && strcasecmp( $expectedMobileDomain, $domain ) === 0 ) { return; } } $msg = new DataMessageValue( 'mwparsoid-invalid-domain', [], 'invalid-domain', [ 'expected' => $expectedDomain, 'actual' => $domain, ] ); throw new LocalizedHttpException( $msg, 400, [ 'error' => 'parameter-validation-failed', 'name' => 'domain', 'value' => $domain, 'failureCode' => $msg->getCode(), 'failureData' => $msg->getData(), ] ); } /** * Get the parsed body by content-type * * @return array */ protected function getParsedBody(): array { $request = $this->getRequest(); [ $contentType ] = explode( ';', $request->getHeader( 'Content-Type' )[0] ?? '', 2 ); switch ( $contentType ) { case 'application/x-www-form-urlencoded': case 'multipart/form-data': return $request->getPostParams(); case 'application/json': $json = json_decode( $request->getBody()->getContents(), true ); if ( !is_array( $json ) ) { throw new LocalizedHttpException( new MessageValue( "rest-json-body-parse-error", [ 'not a valid JSON object' ] ), 400 ); } return $json; default: throw new LocalizedHttpException( new MessageValue( "rest-unsupported-content-type", [ $contentType ?? '(null)' ] ), 415 ); } } /** * Rough equivalent of req.local from Parsoid-JS. * FIXME most of these should be replaced with more native ways of handling the request. * @return array */ protected function &getRequestAttributes(): array { if ( $this->requestAttributes ) { return $this->requestAttributes; } $request = $this->getRequest(); $body = ( $request->getMethod() === 'POST' ) ? $this->getParsedBody() : []; $opts = array_merge( $body, array_intersect_key( $request->getPathParams(), [ 'from' => true, 'format' => true ] ) ); '@phan-var array<string,array|bool|string> $opts'; // @var array<string,array|bool|string> $opts $contentLanguage = $request->getHeaderLine( 'Content-Language' ) ?: null; if ( $contentLanguage ) { $contentLanguage = LanguageCode::normalizeNonstandardCodeAndWarn( $contentLanguage ); } $attribs = [ 'pageName' => $request->getPathParam( 'title' ) ?? '', 'oldid' => $request->getPathParam( 'revision' ), // "body_only" flag to return just the body (instead of the entire HTML doc) // We would like to deprecate use of this flag: T181657 'body_only' => $request->getQueryParams()['body_only'] ?? $body['body_only'] ?? null, 'errorEnc' => ParsoidFormatHelper::ERROR_ENCODING[$opts['format']] ?? 'plain', 'iwp' => WikiMap::getCurrentWikiId(), // PORT-FIXME verify 'offsetType' => $body['offsetType'] ?? $request->getQueryParams()['offsetType'] // Lint requests should return UCS2 offsets by default ?? ( $opts['format'] === ParsoidFormatHelper::FORMAT_LINT ? 'ucs2' : 'byte' ), 'pagelanguage' => $contentLanguage, ]; // For use in getHtmlOutputRendererHelper $opts['stash'] = $request->getQueryParams()['stash'] ?? false; if ( $request->getMethod() === 'POST' ) { if ( isset( $opts['original']['revid'] ) ) { $attribs['oldid'] = $opts['original']['revid']; } if ( isset( $opts['original']['title'] ) ) { $attribs['pageName'] = $opts['original']['title']; } } if ( $attribs['oldid'] !== null ) { if ( $attribs['oldid'] === '' ) { $attribs['oldid'] = null; } else { $attribs['oldid'] = (int)$attribs['oldid']; } } // For use in getHtmlOutputRendererHelper $opts['accept-language'] = $request->getHeaderLine( 'Accept-Language' ) ?: null; $acceptLanguage = null; if ( $opts['accept-language'] !== null ) { $acceptLanguage = LanguageCode::normalizeNonstandardCodeAndWarn( $opts['accept-language'] ); } // Init pageName if oldid is provided and is a valid revision if ( ( $attribs['pageName'] === '' ) && $attribs['oldid'] ) { $rev = $this->revisionLookup->getRevisionById( $attribs['oldid'] ); if ( $rev ) { $attribs['pageName'] = $rev->getPage()->getDBkey(); } } $attribs['envOptions'] = [ // We use `prefix` but ought to use `domain` (T206764) 'prefix' => $attribs['iwp'], // For the legacy "domain" path parameter used by the endpoints exposed // by the parsoid extension. Will be null for core endpoints. 'domain' => $request->getPathParam( 'domain' ), 'pageName' => $attribs['pageName'], 'cookie' => $request->getHeaderLine( 'Cookie' ), 'reqId' => $request->getHeaderLine( 'X-Request-Id' ), 'userAgent' => $request->getHeaderLine( 'User-Agent' ), 'htmlVariantLanguage' => $acceptLanguage, // Semver::satisfies checks below expect a valid outputContentVersion value. // Better to set it here instead of adding the default value at every check. 'outputContentVersion' => Parsoid::defaultHTMLVersion(), ]; # Convert language codes in $opts['updates']['variant'] if present $sourceVariant = $opts['updates']['variant']['source'] ?? null; if ( $sourceVariant ) { $sourceVariant = LanguageCode::normalizeNonstandardCodeAndWarn( $sourceVariant ); $opts['updates']['variant']['source'] = $sourceVariant; } $targetVariant = $opts['updates']['variant']['target'] ?? null; if ( $targetVariant ) { $targetVariant = LanguageCode::normalizeNonstandardCodeAndWarn( $targetVariant ); $opts['updates']['variant']['target'] = $targetVariant; } if ( isset( $opts['wikitext']['headers']['content-language'] ) ) { $contentLanguage = $opts['wikitext']['headers']['content-language']; $contentLanguage = LanguageCode::normalizeNonstandardCodeAndWarn( $contentLanguage ); $opts['wikitext']['headers']['content-language'] = $contentLanguage; } if ( isset( $opts['original']['wikitext']['headers']['content-language'] ) ) { $contentLanguage = $opts['original']['wikitext']['headers']['content-language']; $contentLanguage = LanguageCode::normalizeNonstandardCodeAndWarn( $contentLanguage ); $opts['original']['wikitext']['headers']['content-language'] = $contentLanguage; } $attribs['opts'] = $opts; // TODO: Remove assertDomainIsCorrect() once we no longer need to support the {domain} // parameter for the endpoints exposed by the parsoid extension. if ( $attribs['envOptions']['domain'] !== null ) { $this->assertDomainIsCorrect( $attribs['envOptions']['domain'] ); } $this->requestAttributes = $attribs; return $this->requestAttributes; } /** * @param array $attribs * @param ?string $source * @param PageIdentity $page * @param ?int $revId * * @return HtmlOutputRendererHelper */ private function getHtmlOutputRendererHelper( array $attribs, ?string $source, PageIdentity $page, ?int $revId ): HtmlOutputRendererHelper { $services = MediaWikiServices::getInstance(); // Request lenient rev handling $lenientRevHandling = true; $authority = $this->getAuthority(); $params = []; $helper = $services->getPageRestHelperFactory()->newHtmlOutputRendererHelper( $page, $params, $authority, $revId, $lenientRevHandling ); // XXX: should default to the page's content model? $model = $attribs['opts']['contentmodel'] ?? ( $attribs['envOptions']['contentmodel'] ?? CONTENT_MODEL_WIKITEXT ); if ( $source !== null ) { $helper->setContentSource( $source, $model ); } if ( isset( $attribs['opts']['stash'] ) ) { $helper->setStashingEnabled( $attribs['opts']['stash'] ); } if ( isset( $attribs['envOptions']['outputContentVersion'] ) ) { $helper->setOutputProfileVersion( $attribs['envOptions']['outputContentVersion'] ); } if ( isset( $attribs['pagelanguage'] ) ) { $helper->setPageLanguage( $attribs['pagelanguage'] ); } if ( isset( $attribs['opts']['accept-language'] ) ) { $helper->setVariantConversionLanguage( $attribs['opts']['accept-language'] ); } return $helper; } /** * @param array $attribs * @param string $html * @param PageIdentity $page * * @return HtmlInputTransformHelper */ protected function getHtmlInputTransformHelper( array $attribs, string $html, PageIdentity $page ): HtmlInputTransformHelper { $services = MediaWikiServices::getInstance(); $parameters = $attribs['opts'] + $attribs; $body = $attribs['opts']; $body['html'] = $html; $helper = $services->getPageRestHelperFactory()->newHtmlInputTransformHelper( $attribs['envOptions'] + [ 'offsetType' => $attribs['offsetType'], ], $page, $body, $parameters ); $helper->setMetrics( $this->siteConfig->prefixedStatsFactory() ); return $helper; } /** * FIXME: Combine with ParsoidFormatHelper::parseContentTypeHeader */ private const NEW_SPEC = '#^https://www.mediawiki.org/wiki/Specs/(HTML|pagebundle)/(\d+\.\d+\.\d+)$#D'; /** * This method checks if we support the requested content formats * As a side-effect, it updates $attribs to set outputContentVersion * that Parsoid should generate based on request headers. * * @param array &$attribs Request attributes from getRequestAttributes() * @return bool */ protected function acceptable( array &$attribs ): bool { $request = $this->getRequest(); $format = $attribs['opts']['format']; if ( $format === ParsoidFormatHelper::FORMAT_WIKITEXT ) { return true; } $acceptHeader = $request->getHeader( 'Accept' ); if ( !$acceptHeader ) { return true; } $parser = new HttpAcceptParser(); $acceptableTypes = $parser->parseAccept( $acceptHeader[0] ); // FIXME: Multiple headers valid? if ( !$acceptableTypes ) { return true; } // `acceptableTypes` is already sorted by quality. foreach ( $acceptableTypes as $t ) { $type = "{$t['type']}/{$t['subtype']}"; $profile = $t['params']['profile'] ?? null; if ( ( $format === ParsoidFormatHelper::FORMAT_HTML && $type === 'text/html' ) || ( $format === ParsoidFormatHelper::FORMAT_PAGEBUNDLE && $type === 'application/json' ) ) { if ( $profile ) { preg_match( self::NEW_SPEC, $profile, $matches ); if ( $matches && strtolower( $matches[1] ) === $format ) { $contentVersion = Parsoid::resolveContentVersion( $matches[2] ); if ( $contentVersion ) { // $attribs mutated here! $attribs['envOptions']['outputContentVersion'] = $contentVersion; return true; } else { continue; } } else { continue; } } else { return true; } } elseif ( ( $type === '*/*' ) || ( $format === ParsoidFormatHelper::FORMAT_HTML && $type === 'text/*' ) ) { return true; } } return false; } /** * Try to create a PageConfig object. If we get an exception (because content * may be missing or inaccessible), throw an appropriate HTTP response object * for callers to handle. * * @param array $attribs * @param ?string $wikitextOverride * Custom wikitext to use instead of the real content of the page. * @param bool $html2WtMode * @return PageConfig * @throws HttpException */ protected function tryToCreatePageConfig( array $attribs, ?string $wikitextOverride = null, bool $html2WtMode = false ): PageConfig { $revId = $attribs['oldid']; $pagelanguageOverride = $attribs['pagelanguage']; $title = $attribs['pageName']; $title = ( $title !== '' ) ? Title::newFromText( $title ) : Title::newMainPage(); if ( !$title ) { // TODO use proper validation throw new LogicException( 'Title not found!' ); } $user = RequestContext::getMain()->getUser(); if ( $wikitextOverride === null ) { $revisionRecord = null; } else { // Create a mutable revision record point to the same revision // and set to the desired wikitext. $revisionRecord = new MutableRevisionRecord( $title ); // Don't set id to $revId if we have $wikitextOverride // A revision corresponds to specific wikitext, which $wikitextOverride // might not be. $revisionRecord->setId( 0 ); $revisionRecord->setSlot( SlotRecord::newUnsaved( SlotRecord::MAIN, new WikitextContent( $wikitextOverride ) ) ); } $hasOldId = ( $revId !== null ); $ensureAccessibleContent = !$html2WtMode || $hasOldId; try { // Note: Parsoid by design isn't supposed to use the user // context right now, and all user state is expected to be // introduced as a post-parse transform. So although we pass a // User here, it only currently affects the output in obscure // corner cases; see PageConfigFactory::create() for more. // @phan-suppress-next-line PhanUndeclaredMethod method defined in subtype $pageConfig = $this->pageConfigFactory->create( $title, $user, $revisionRecord ?? $revId, null, $pagelanguageOverride, $ensureAccessibleContent ); } catch ( SuppressedDataException $e ) { throw new LocalizedHttpException( new MessageValue( "rest-permission-denied-revision", [ $e->getMessage() ] ), 403 ); } catch ( RevisionAccessException $e ) { throw new LocalizedHttpException( new MessageValue( "rest-specified-revision-unavailable", [ $e->getMessage() ] ), 404 ); } // All good! return $pageConfig; } /** * Try to create a PageIdentity object. * If no page is specified in the request, this will return the wiki's main page. * If an invalid page is requested, this throws an appropriate HTTPException. * * @param array $attribs * @return PageIdentity * @throws HttpException */ protected function tryToCreatePageIdentity( array $attribs ): PageIdentity { if ( $attribs['pageName'] === '' ) { return Title::newMainPage(); } // XXX: Should be injected, but the Parsoid extension relies on the // constructor signature. Also, ParsoidHandler should go away soon anyway. $pageStore = MediaWikiServices::getInstance()->getPageStore(); $page = $pageStore->getPageByText( $attribs['pageName'] ); if ( !$page ) { throw new LocalizedHttpException( new MessageValue( "rest-invalid-title", [ 'pageName' ] ), 400 ); } return $page; } /** * Get the path for the transform endpoint. May be overwritten to override the path. * * This is done in the parsoid extension, for backwards compatibility * with the old endpoint URLs. * * @stable to override * * @param string $format The format the endpoint is expected to return. * * @return string */ protected function getTransformEndpoint( string $format = ParsoidFormatHelper::FORMAT_HTML ): string { return '/coredev/v0/transform/{from}/to/{format}/{title}/{revision}'; } /** * Get the path for the page content endpoint. May be overwritten to override the path. * * This is done in the parsoid extension, for backwards compatibility * with the old endpoint URLs. * * @stable to override * * @param string $format The format the endpoint is expected to return. * * @return string */ protected function getPageContentEndpoint( string $format = ParsoidFormatHelper::FORMAT_HTML ): string { if ( $format !== ParsoidFormatHelper::FORMAT_HTML ) { throw new InvalidArgumentException( 'Unsupported page content format: ' . $format ); } return '/v1/page/{title}/html'; } /** * Get the path for the page content endpoint. May be overwritten to override the path. * * This is done in the parsoid extension, for backwards compatibility * with the old endpoint URLs. * * @stable to override * * @param string $format The format the endpoint is expected to return. * * @return string */ protected function getRevisionContentEndpoint( string $format = ParsoidFormatHelper::FORMAT_HTML ): string { if ( $format !== ParsoidFormatHelper::FORMAT_HTML ) { throw new InvalidArgumentException( 'Unsupported revision content format: ' . $format ); } return '/v1/revision/{revision}/html'; } private function wtLint( PageConfig $pageConfig, array $attribs, ?array $linterOverrides = [] ) { $envOptions = $attribs['envOptions'] + [ 'linterOverrides' => $linterOverrides, 'offsetType' => $attribs['offsetType'], ]; try { $parsoid = $this->newParsoid(); $parserOutput = new ParserOutput(); return $parsoid->wikitext2lint( $pageConfig, $envOptions, $parserOutput ); } catch ( ClientError $e ) { throw new LocalizedHttpException( new MessageValue( "rest-parsoid-error", [ $e->getMessage() ] ), 400 ); } catch ( ResourceLimitExceededException $e ) { throw new LocalizedHttpException( new MessageValue( "rest-parsoid-resource-exceeded", [ $e->getMessage() ] ), 413 ); } } /** * Wikitext -> HTML helper. * Spec'd in https://phabricator.wikimedia.org/T75955 and the API tests. * * @param PageConfig $pageConfig * @param array $attribs Request attributes from getRequestAttributes() * @param ?string $wikitext Wikitext to transform (or null to use the * page specified in the request attributes). * * @return Response */ protected function wt2html( PageConfig $pageConfig, array $attribs, ?string $wikitext = null ) { $request = $this->getRequest(); $opts = $attribs['opts']; $format = $opts['format']; $oldid = $attribs['oldid']; $stash = $opts['stash'] ?? false; if ( $format === ParsoidFormatHelper::FORMAT_LINT ) { $linterOverrides = []; if ( $this->extensionRegistry->isLoaded( 'Linter' ) ) { // T360809 $disabled = []; $services = MediaWikiServices::getInstance(); $linterCategories = $services->getMainConfig()->get( 'LinterCategories' ); foreach ( $linterCategories as $name => $cat ) { if ( $cat['priority'] === 'none' ) { $disabled[] = $name; } } $linterOverrides['disabled'] = $disabled; } $lints = $this->wtLint( $pageConfig, $attribs, $linterOverrides ); $response = $this->getResponseFactory()->createJson( $lints ); return $response; } // TODO: This method should take a PageIdentity + revId, // to reduce the usage of PageConfig in MW core. $helper = $this->getHtmlOutputRendererHelper( $attribs, $wikitext, $this->pageConfigToPageIdentity( $pageConfig ), // Id will be 0 if we have $wikitext but that isn't valid // to call $helper->setRevision with. In any case, the revision // will be reset when $helper->setContent is called with $wikitext. // Ideally, the revision would be pass through here instead of // the id and wikitext. $pageConfig->getRevisionId() ?: null ); $needsPageBundle = ( $format === ParsoidFormatHelper::FORMAT_PAGEBUNDLE ); if ( $attribs['body_only'] ) { $helper->setFlavor( 'fragment' ); } elseif ( !$needsPageBundle ) { // Inline data-parsoid. This will happen when no special params are set. $helper->setFlavor( 'edit' ); } if ( $wikitext === null && $oldid !== null ) { $mstr = 'pageWithOldid'; } else { $mstr = 'wt'; } $parseTiming = Timing::start(); if ( $needsPageBundle ) { $pb = $helper->getPageBundle(); // Handle custom offset requests as a pb2pb transform if ( $attribs['offsetType'] !== 'byte' ) { $parsoid = $this->newParsoid(); $pb = $parsoid->pb2pb( $pageConfig, 'convertoffsets', $pb, [ 'inputOffsetType' => 'byte', 'outputOffsetType' => $attribs['offsetType'] ] ); } $response = $this->getResponseFactory()->createJson( $pb->responseData() ); $helper->putHeaders( $response, false ); ParsoidFormatHelper::setContentType( $response, ParsoidFormatHelper::FORMAT_PAGEBUNDLE, $pb->version ); } else { $out = $helper->getHtml(); // TODO: offsetType conversion isn't supported right now for non-pagebundle endpoints // Once the OutputTransform framework lands, we might revisit this. $response = $this->getResponseFactory()->create(); $response->getBody()->write( $out->getRawText() ); $helper->putHeaders( $response, true ); // Emit an ETag only if stashing is enabled. It's not reliably useful otherwise. if ( $stash ) { $eTag = $helper->getETag(); if ( $eTag ) { $response->setHeader( 'ETag', $eTag ); } } } // XXX: For pagebundle requests, this can be somewhat inflated // because of pagebundle json-encoding overheads $outSize = $response->getBody()->getSize(); $parseTime = $parseTiming->end(); // Ignore slow parse metrics for non-oldid parses if ( $mstr === 'pageWithOldid' ) { if ( $parseTime > 3000 ) { LoggerFactory::getInstance( 'slow-parsoid' ) ->info( 'Parsing {title} was slow, took {time} seconds', [ 'time' => number_format( $parseTime / 1000, 2 ), 'title' => Title::newFromLinkTarget( $pageConfig->getLinkTarget() )->getPrefixedText(), ] ); } if ( $parseTime > 10 && $outSize > 100 ) { // * Don't bother with this metric for really small parse times // p99 for initialization time is ~7ms according to grafana. // So, 10ms ensures that startup overheads don't skew the metrics // * For body_only=false requests, <head> section isn't generated // and if the output is small, per-request overheads can skew // the timePerKB metrics. // NOTE: This is slightly misleading since there are fixed costs // for generating output like the <head> section and should be factored in, // but this is good enough for now as a useful first degree of approxmation. $timePerKB = $parseTime * 1024 / $outSize; if ( $timePerKB > 500 ) { // At 100ms/KB, even a 100KB page which isn't that large will take 10s. // So, we probably want to shoot for a threshold under 100ms. // But, let's start with 500ms+ outliers first and see what we uncover. LoggerFactory::getInstance( 'slow-parsoid' ) ->info( 'Parsing {title} was slow, timePerKB took {timePerKB} ms, total: {time} seconds', [ 'time' => number_format( $parseTime / 1000, 2 ), 'timePerKB' => number_format( $timePerKB, 1 ), 'title' => Title::newFromLinkTarget( $pageConfig->getLinkTarget() )->getPrefixedText(), ] ); } } } if ( $wikitext !== null ) { // Don't cache requests when wt is set in case somebody uses // GET for wikitext parsing // XXX: can we just refuse to do wikitext parsing in a GET request? $response->setHeader( 'Cache-Control', 'private,no-cache,s-maxage=0' ); } elseif ( $oldid !== null ) { // XXX: can this go away? Parsoid's PageContent class doesn't expose supressed revision content. if ( $request->getHeaderLine( 'Cookie' ) || $request->getHeaderLine( 'Authorization' ) ) { // Don't cache requests with a session. $response->setHeader( 'Cache-Control', 'private,no-cache,s-maxage=0' ); } } return $response; } protected function newParsoid(): Parsoid { return new Parsoid( $this->siteConfig, $this->dataAccess ); } protected function parseHTML( string $html, bool $validateXMLNames = false ): Document { return DOMUtils::parseHTML( $html, $validateXMLNames ); } /** * @param PageConfig|PageIdentity $page * @param array $attribs Attributes gotten from requests * @param string $html Original HTML * * @return Response * @throws HttpException */ protected function html2wt( $page, array $attribs, string $html ) { if ( $page instanceof PageConfig ) { // TODO: Deprecate passing a PageConfig. // Ideally, callers would use HtmlToContentTransform directly. $page = Title::newFromLinkTarget( $page->getLinkTarget() ); } try { $transform = $this->getHtmlInputTransformHelper( $attribs, $html, $page ); $response = $this->getResponseFactory()->create(); $transform->putContent( $response ); return $response; } catch ( ClientError $e ) { throw new LocalizedHttpException( new MessageValue( "rest-parsoid-error", [ $e->getMessage() ] ), 400 ); } } /** * Pagebundle -> pagebundle helper. * * @param array<string,array|string> $attribs * @return Response * @throws HttpException */ protected function pb2pb( array $attribs ) { $opts = $attribs['opts']; $revision = $opts['previous'] ?? $opts['original'] ?? null; if ( !isset( $revision['html'] ) ) { throw new LocalizedHttpException( new MessageValue( "rest-missing-revision-html" ), 400 ); } $vOriginal = ParsoidFormatHelper::parseContentTypeHeader( $revision['html']['headers']['content-type'] ?? '' ); if ( $vOriginal === null ) { throw new LocalizedHttpException( new MessageValue( "rest-missing-revision-html-content-type" ), 400 ); } $attribs['envOptions']['inputContentVersion'] = $vOriginal; '@phan-var array<string,array|string> $attribs'; // @var array<string,array|string> $attribs $this->metrics->increment( 'pb2pb.original.version.' . $attribs['envOptions']['inputContentVersion'] ); if ( !empty( $opts['updates'] ) ) { // FIXME: Handling missing revisions uniformly for all update types // is not probably the right thing to do but probably okay for now. // This might need revisiting as we add newer types. $pageConfig = $this->tryToCreatePageConfig( $attribs, null, true ); // If we're only updating parts of the original version, it should // satisfy the requested content version, since we'll be returning // that same one. // FIXME: Since this endpoint applies the acceptable middleware, // `getOutputContentVersion` is not what's been passed in, but what // can be produced. Maybe that should be selectively applied so // that we can update older versions where it makes sense? // Uncommenting below implies that we can only update the latest // version, since carrot semantics is applied in both directions. // if ( !Semver::satisfies( // $attribs['envOptions']['inputContentVersion'], // "^{$attribs['envOptions']['outputContentVersion']}" // ) ) { // throw new HttpException( // 'We do not know how to do this conversion.', 415 // ); // } if ( !empty( $opts['updates']['redlinks'] ) ) { // Q(arlolra): Should redlinks be more complex than a bool? // See gwicke's proposal at T114413#2240381 return $this->updateRedLinks( $pageConfig, $attribs, $revision ); } elseif ( isset( $opts['updates']['variant'] ) ) { return $this->languageConversion( $pageConfig, $attribs, $revision ); } else { throw new LocalizedHttpException( new MessageValue( "rest-unknown-parsoid-transformation" ), 400 ); } } // TODO(arlolra): subbu has some sage advice in T114413#2365456 that // we should probably be more explicit about the pb2pb conversion // requested rather than this increasingly complex fallback logic. $downgrade = Parsoid::findDowngrade( $attribs['envOptions']['inputContentVersion'], $attribs['envOptions']['outputContentVersion'] ); if ( $downgrade ) { $pb = new PageBundle( $revision['html']['body'], $revision['data-parsoid']['body'] ?? null, $revision['data-mw']['body'] ?? null ); $this->validatePb( $pb, $attribs['envOptions']['inputContentVersion'] ); Parsoid::downgrade( $downgrade, $pb ); if ( !empty( $attribs['body_only'] ) ) { $doc = $this->parseHTML( $pb->html ); $body = DOMCompat::getBody( $doc ); $pb->html = ContentUtils::toXML( $body, [ 'innerXML' => true ] ); } $response = $this->getResponseFactory()->createJson( $pb->responseData() ); ParsoidFormatHelper::setContentType( $response, ParsoidFormatHelper::FORMAT_PAGEBUNDLE, $pb->version ); return $response; // Ensure we only reuse from semantically similar content versions. } elseif ( Semver::satisfies( $attribs['envOptions']['outputContentVersion'], '^' . $attribs['envOptions']['inputContentVersion'] ) ) { $pageConfig = $this->tryToCreatePageConfig( $attribs ); return $this->wt2html( $pageConfig, $attribs ); } else { throw new LocalizedHttpException( new MessageValue( "rest-unsupported-profile-conversion" ), 415 ); } } /** * Update red links on a document. * * @param PageConfig $pageConfig * @param array $attribs * @param array $revision * @return Response */ protected function updateRedLinks( PageConfig $pageConfig, array $attribs, array $revision ) { $parsoid = $this->newParsoid(); $pb = new PageBundle( $revision['html']['body'], $revision['data-parsoid']['body'] ?? null, $revision['data-mw']['body'] ?? null, $attribs['envOptions']['inputContentVersion'], $revision['html']['headers'] ?? null, $revision['contentmodel'] ?? null ); $out = $parsoid->pb2pb( $pageConfig, 'redlinks', $pb, [] ); $this->validatePb( $out, $attribs['envOptions']['inputContentVersion'] ); $response = $this->getResponseFactory()->createJson( $out->responseData() ); ParsoidFormatHelper::setContentType( $response, ParsoidFormatHelper::FORMAT_PAGEBUNDLE, $out->version ); return $response; } /** * Do variant conversion on a document. * * @param PageConfig $pageConfig * @param array $attribs * @param array $revision * @return Response * @throws HttpException */ protected function languageConversion( PageConfig $pageConfig, array $attribs, array $revision ) { $opts = $attribs['opts']; $target = $opts['updates']['variant']['target'] ?? $attribs['envOptions']['htmlVariantLanguage']; $source = $opts['updates']['variant']['source'] ?? null; if ( !$target ) { throw new LocalizedHttpException( new MessageValue( "rest-target-variant-required" ), 400 ); } $pageIdentity = $this->tryToCreatePageIdentity( $attribs ); $pb = new PageBundle( $revision['html']['body'], $revision['data-parsoid']['body'] ?? null, $revision['data-mw']['body'] ?? null, $attribs['envOptions']['inputContentVersion'], $revision['html']['headers'] ?? null, $revision['contentmodel'] ?? null ); // XXX: DI should inject HtmlTransformFactory $languageVariantConverter = MediaWikiServices::getInstance() ->getHtmlTransformFactory() ->getLanguageVariantConverter( $pageIdentity ); $languageVariantConverter->setPageConfig( $pageConfig ); $httpContentLanguage = $attribs['pagelanguage' ] ?? null; if ( $httpContentLanguage ) { $languageVariantConverter->setPageLanguageOverride( $httpContentLanguage ); } try { $out = $languageVariantConverter->convertPageBundleVariant( $pb, $target, $source ); } catch ( InvalidArgumentException $e ) { throw new LocalizedHttpException( new MessageValue( "rest-unsupported-language-conversion", [ $source ?? '(unspecified)', $target ] ), 400, [ 'reason' => $e->getMessage() ] ); } $response = $this->getResponseFactory()->createJson( $out->responseData() ); ParsoidFormatHelper::setContentType( $response, ParsoidFormatHelper::FORMAT_PAGEBUNDLE, $out->version ); return $response; } /** @inheritDoc */ abstract public function execute(): Response; /** * Validate a PageBundle against the given contentVersion, and throw * an HttpException if it does not match. * @param PageBundle $pb * @param string $contentVersion * @throws HttpException */ private function validatePb( PageBundle $pb, string $contentVersion ): void { $errorMessage = ''; if ( !$pb->validate( $contentVersion, $errorMessage ) ) { throw new LocalizedHttpException( new MessageValue( "rest-page-bundle-validation-error", [ $errorMessage ] ), 400 ); } } /** * @param PageConfig $page * * @return ProperPageIdentity * @throws HttpException */ private function pageConfigToPageIdentity( PageConfig $page ): ProperPageIdentity { $services = MediaWikiServices::getInstance(); $title = $page->getLinkTarget(); try { $page = $services->getPageStore()->getPageForLink( $title ); } catch ( MalformedTitleException | InvalidArgumentException $e ) { // Note that even some well-formed links are still invalid // parameters for getPageForLink(), e.g. interwiki links or special pages. throw new HttpException( "Bad title: $title", # uses LinkTarget::__toString() 400 ); } return $page; } } PK ! �@B�T �T PageHistoryCountHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use ChangeTags; use MediaWiki\Page\ExistingPageRecord; use MediaWiki\Page\PageLookup; use MediaWiki\Permissions\GroupPermissionsLookup; use MediaWiki\Rest\Handler\Helper\PageRedirectHelper; use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Rest\SimpleHandler; use MediaWiki\Revision\RevisionRecord; use MediaWiki\Revision\RevisionStore; use MediaWiki\Storage\NameTableAccessException; use MediaWiki\Storage\NameTableStore; use MediaWiki\Storage\NameTableStoreFactory; use MediaWiki\User\TempUser\TempUserConfig; use Wikimedia\Message\MessageValue; use Wikimedia\Message\ParamType; use Wikimedia\Message\ScalarParam; use Wikimedia\ObjectCache\WANObjectCache; use Wikimedia\ParamValidator\ParamValidator; use Wikimedia\Rdbms\IConnectionProvider; use Wikimedia\Rdbms\IExpression; use Wikimedia\Rdbms\RawSQLExpression; /** * Handler class for Core REST API endpoints that perform operations on revisions */ class PageHistoryCountHandler extends SimpleHandler { /** The maximum number of counts to return per type of revision */ private const COUNT_LIMITS = [ 'anonymous' => 10000, 'temporary' => 10000, 'bot' => 10000, 'editors' => 25000, 'edits' => 30000, 'minor' => 1000, 'reverted' => 30000 ]; private const DEPRECATED_COUNT_TYPES = [ 'anonedits' => 'anonymous', 'botedits' => 'bot', 'revertededits' => 'reverted' ]; private const MAX_AGE_200 = 60; private RevisionStore $revisionStore; private NameTableStore $changeTagDefStore; private GroupPermissionsLookup $groupPermissionsLookup; private IConnectionProvider $dbProvider; private PageLookup $pageLookup; private WANObjectCache $cache; private PageRestHelperFactory $helperFactory; private TempUserConfig $tempUserConfig; /** @var RevisionRecord|false|null */ private $revision = false; /** @var array|null */ private $lastModifiedTimes; /** @var ExistingPageRecord|false|null */ private $page = false; /** * @param RevisionStore $revisionStore * @param NameTableStoreFactory $nameTableStoreFactory * @param GroupPermissionsLookup $groupPermissionsLookup * @param IConnectionProvider $dbProvider * @param WANObjectCache $cache * @param PageLookup $pageLookup * @param PageRestHelperFactory $helperFactory * @param TempUserConfig $tempUserConfig */ public function __construct( RevisionStore $revisionStore, NameTableStoreFactory $nameTableStoreFactory, GroupPermissionsLookup $groupPermissionsLookup, IConnectionProvider $dbProvider, WANObjectCache $cache, PageLookup $pageLookup, PageRestHelperFactory $helperFactory, TempUserConfig $tempUserConfig ) { $this->revisionStore = $revisionStore; $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef(); $this->groupPermissionsLookup = $groupPermissionsLookup; $this->dbProvider = $dbProvider; $this->cache = $cache; $this->pageLookup = $pageLookup; $this->helperFactory = $helperFactory; $this->tempUserConfig = $tempUserConfig; } private function getRedirectHelper(): PageRedirectHelper { return $this->helperFactory->newPageRedirectHelper( $this->getResponseFactory(), $this->getRouter(), $this->getPath(), $this->getRequest() ); } private function normalizeType( $type ) { return self::DEPRECATED_COUNT_TYPES[$type] ?? $type; } /** * Validates that the provided parameter combination is supported. * * @param string $type * @throws LocalizedHttpException */ private function validateParameterCombination( $type ) { $params = $this->getValidatedParams(); if ( !$params ) { return; } if ( $params['from'] || $params['to'] ) { if ( $type === 'edits' || $type === 'editors' ) { if ( !$params['from'] || !$params['to'] ) { throw new LocalizedHttpException( new MessageValue( 'rest-pagehistorycount-parameters-invalid' ), 400 ); } } else { throw new LocalizedHttpException( new MessageValue( 'rest-pagehistorycount-parameters-invalid' ), 400 ); } } } /** * @param string $title the title of the page to load history for * @param string $type the validated count type * @return Response * @throws LocalizedHttpException */ public function run( $title, $type ) { $normalizedType = $this->normalizeType( $type ); $this->validateParameterCombination( $normalizedType ); $params = $this->getValidatedParams(); $page = $this->getPage(); if ( !$page ) { throw new LocalizedHttpException( new MessageValue( 'rest-nonexistent-title', [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] ), 404 ); } if ( !$this->getAuthority()->authorizeRead( 'read', $page ) ) { throw new LocalizedHttpException( new MessageValue( 'rest-permission-denied-title', [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] ), 403 ); } '@phan-var \MediaWiki\Page\ExistingPageRecord $page'; $redirectResponse = $this->getRedirectHelper()->createNormalizationRedirectResponseIfNeeded( $page, $params['title'] ?? null ); if ( $redirectResponse !== null ) { return $redirectResponse; } $count = $this->getCount( $normalizedType ); $countLimit = self::COUNT_LIMITS[$normalizedType]; $response = $this->getResponseFactory()->createJson( [ 'count' => $count > $countLimit ? $countLimit : $count, 'limit' => $count > $countLimit ] ); $response->setHeader( 'Cache-Control', 'max-age=' . self::MAX_AGE_200 ); // Inform clients who use a deprecated "type" value, so they can adjust if ( isset( self::DEPRECATED_COUNT_TYPES[$type] ) ) { $docs = '<https://www.mediawiki.org/wiki/API:REST/History_API' . '#Get_page_history_counts>; rel="deprecation"'; $response->setHeader( 'Deprecation', 'version="v1"' ); $response->setHeader( 'Link', $docs ); } return $response; } /** * @param string $type the validated count type * @return int the article count * @throws LocalizedHttpException */ private function getCount( $type ) { $pageId = $this->getPage()->getId(); switch ( $type ) { case 'anonymous': return $this->getCachedCount( $type, function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) { return $this->getAnonCount( $pageId, $fromRev ); } ); case 'temporary': return $this->getCachedCount( $type, function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) { return $this->getTempCount( $pageId, $fromRev ); } ); case 'bot': return $this->getCachedCount( $type, function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) { return $this->getBotCount( $pageId, $fromRev ); } ); case 'editors': $from = $this->getValidatedParams()['from'] ?? null; $to = $this->getValidatedParams()['to'] ?? null; if ( $from || $to ) { return $this->getEditorsCount( $pageId, $from ? $this->getRevisionOrThrow( $from ) : null, $to ? $this->getRevisionOrThrow( $to ) : null ); } else { return $this->getCachedCount( $type, function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) { return $this->getEditorsCount( $pageId, $fromRev ); } ); } case 'edits': $from = $this->getValidatedParams()['from'] ?? null; $to = $this->getValidatedParams()['to'] ?? null; if ( $from || $to ) { return $this->getEditsCount( $pageId, $from ? $this->getRevisionOrThrow( $from ) : null, $to ? $this->getRevisionOrThrow( $to ) : null ); } else { return $this->getCachedCount( $type, function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) { return $this->getEditsCount( $pageId, $fromRev ); } ); } case 'reverted': return $this->getCachedCount( $type, function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) { return $this->getRevertedCount( $pageId, $fromRev ); } ); case 'minor': // The query for minor counts is inefficient for the database for pages with many revisions. // If the specified title contains more revisions than allowed, we will return an error. $editsCount = $this->getCachedCount( 'edits', function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) { return $this->getEditsCount( $pageId, $fromRev ); } ); if ( $editsCount > self::COUNT_LIMITS[$type] * 2 ) { throw new LocalizedHttpException( new MessageValue( 'rest-pagehistorycount-too-many-revisions' ), 500 ); } return $this->getCachedCount( $type, function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) { return $this->getMinorCount( $pageId, $fromRev ); } ); default: throw new LocalizedHttpException( new MessageValue( 'rest-pagehistorycount-type-unrecognized', [ new ScalarParam( ParamType::PLAINTEXT, $type ) ] ), 500 ); } } /** * @return RevisionRecord|null current revision or false if unable to retrieve revision */ private function getCurrentRevision(): ?RevisionRecord { if ( $this->revision === false ) { $page = $this->getPage(); if ( $page ) { $this->revision = $this->revisionStore->getKnownCurrentRevision( $page ) ?: null; } else { $this->revision = null; } } return $this->revision; } /** * @return ExistingPageRecord|null */ private function getPage(): ?ExistingPageRecord { if ( $this->page === false ) { $this->page = $this->pageLookup->getExistingPageByText( $this->getValidatedParams()['title'] ); } return $this->page; } /** * Returns latest of 2 timestamps: * 1. Current revision * 2. OR entry from the DB logging table for the given page * @return int|null */ protected function getLastModified() { $lastModifiedTimes = $this->getLastModifiedTimes(); if ( $lastModifiedTimes ) { return max( array_values( $lastModifiedTimes ) ); } return null; } /** * Returns array with 2 timestamps: * 1. Current revision * 2. OR entry from the DB logging table for the given page * @return array|null */ protected function getLastModifiedTimes() { $currentRev = $this->getCurrentRevision(); if ( !$currentRev ) { return null; } if ( $this->lastModifiedTimes === null ) { $currentRevTime = (int)wfTimestampOrNull( TS_UNIX, $currentRev->getTimestamp() ); $loggingTableTime = $this->loggingTableTime( $currentRev->getPageId() ); $this->lastModifiedTimes = [ 'currentRevTS' => $currentRevTime, 'dependencyModTS' => $loggingTableTime ]; } return $this->lastModifiedTimes; } /** * Return timestamp of latest entry in logging table for given page id * @param int $pageId * @return int|null */ private function loggingTableTime( $pageId ) { $res = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder() ->select( 'MAX(log_timestamp)' ) ->from( 'logging' ) ->where( [ 'log_page' => $pageId ] ) ->caller( __METHOD__ )->fetchField(); return $res ? (int)wfTimestamp( TS_UNIX, $res ) : null; } /** * Choosing to not implement etags in this handler. * Generating an etag when getting revision counts must account for things like visibility settings * (e.g. rev_deleted bit) which requires hitting the database anyway. The response for these * requests are so small that we wouldn't be gaining much efficiency. * Etags are strong validators and if provided would take precedence over * last modified time, a weak validator. We want to ensure only last modified time is used * since it is more efficient than using etags for this particular case. * @return null */ protected function getEtag() { return null; } /** * @param string $type * @param callable $fetchCount * @return int */ private function getCachedCount( $type, callable $fetchCount ) { $pageId = $this->getPage()->getId(); return $this->cache->getWithSetCallback( $this->cache->makeKey( 'rest', 'pagehistorycount', $pageId, $type ), WANObjectCache::TTL_WEEK, function ( $oldValue ) use ( $fetchCount ) { $currentRev = $this->getCurrentRevision(); if ( $oldValue ) { // Last modified timestamp was NOT a dependency change (e.g. revdel) $doIncrementalUpdate = ( // @phan-suppress-next-line PhanTypeArraySuspiciousNullable $this->getLastModified() != $this->getLastModifiedTimes()['dependencyModTS'] ); if ( $doIncrementalUpdate ) { $rev = $this->revisionStore->getRevisionById( $oldValue['revision'] ); if ( $rev ) { $additionalCount = $fetchCount( $rev ); return [ 'revision' => $currentRev->getId(), 'count' => $oldValue['count'] + $additionalCount, // @phan-suppress-next-line PhanTypeArraySuspiciousNullable 'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS'] ]; } } } // Nothing was previously stored, or incremental update was done for too long, // recalculate from scratch. return [ 'revision' => $currentRev->getId(), 'count' => $fetchCount(), // @phan-suppress-next-line PhanTypeArraySuspiciousNullable 'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS'] ]; }, [ 'touchedCallback' => function (){ return $this->getLastModified(); }, 'version' => 2, 'lockTSE' => WANObjectCache::TTL_MINUTE * 5 ] )['count']; } /** * @param int $pageId the id of the page to load history for * @param RevisionRecord|null $fromRev * @return int the count */ protected function getAnonCount( $pageId, ?RevisionRecord $fromRev = null ) { $dbr = $this->dbProvider->getReplicaDatabase(); $queryBuilder = $dbr->newSelectQueryBuilder() ->select( '1' ) ->from( 'revision' ) ->join( 'actor', null, 'rev_actor = actor_id' ) ->where( [ 'rev_page' => $pageId, 'actor_user' => null, $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) => 0, ] ) ->limit( self::COUNT_LIMITS['anonymous'] + 1 ); // extra to detect truncation if ( $fromRev ) { $queryBuilder->andWhere( $dbr->buildComparison( '>', [ 'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ), 'rev_id' => $fromRev->getId(), ] ) ); } return $queryBuilder->caller( __METHOD__ )->fetchRowCount(); } /** * @param int $pageId the id of the page to load history for * @param RevisionRecord|null $fromRev * @return int the count */ protected function getTempCount( $pageId, ?RevisionRecord $fromRev = null ) { if ( !$this->tempUserConfig->isKnown() ) { return 0; } $dbr = $this->dbProvider->getReplicaDatabase(); $queryBuilder = $dbr->newSelectQueryBuilder() ->select( '1' ) ->from( 'revision' ) ->join( 'actor', null, 'rev_actor = actor_id' ) ->where( [ 'rev_page' => $pageId, $this->tempUserConfig->getMatchCondition( $dbr, 'actor_name', IExpression::LIKE ), ] ) ->andWhere( [ $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) => 0 ] ) ->limit( self::COUNT_LIMITS['temporary'] + 1 ); // extra to detect truncation if ( $fromRev ) { $queryBuilder->andWhere( $dbr->buildComparison( '>', [ 'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ), 'rev_id' => $fromRev->getId(), ] ) ); } return $queryBuilder->caller( __METHOD__ )->fetchRowCount(); } /** * @param int $pageId the id of the page to load history for * @param RevisionRecord|null $fromRev * @return int the count */ protected function getBotCount( $pageId, ?RevisionRecord $fromRev = null ) { $dbr = $this->dbProvider->getReplicaDatabase(); $queryBuilder = $dbr->newSelectQueryBuilder() ->select( '1' ) ->from( 'revision' ) ->join( 'actor', 'actor_rev_user', 'actor_rev_user.actor_id = rev_actor' ) ->where( [ 'rev_page' => intval( $pageId ) ] ) ->andWhere( [ $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) => 0 ] ) ->limit( self::COUNT_LIMITS['bot'] + 1 ); // extra to detect truncation $subquery = $queryBuilder->newSubquery() ->select( '1' ) ->from( 'user_groups' ) ->where( [ 'actor_rev_user.actor_user = ug_user', 'ug_group' => $this->groupPermissionsLookup->getGroupsWithPermission( 'bot' ), $dbr->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $dbr->timestamp() ) ] ); $queryBuilder->andWhere( new RawSQLExpression( 'EXISTS(' . $subquery->getSQL() . ')' ) ); if ( $fromRev ) { $queryBuilder->andWhere( $dbr->buildComparison( '>', [ 'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ), 'rev_id' => $fromRev->getId(), ] ) ); } return $queryBuilder->caller( __METHOD__ )->fetchRowCount(); } /** * @param int $pageId the id of the page to load history for * @param RevisionRecord|null $fromRev * @param RevisionRecord|null $toRev * @return int the count */ protected function getEditorsCount( $pageId, ?RevisionRecord $fromRev = null, ?RevisionRecord $toRev = null ) { [ $fromRev, $toRev ] = $this->orderRevisions( $fromRev, $toRev ); return $this->revisionStore->countAuthorsBetween( $pageId, $fromRev, $toRev, $this->getAuthority(), self::COUNT_LIMITS['editors'] ); } /** * @param int $pageId the id of the page to load history for * @param RevisionRecord|null $fromRev * @return int the count */ protected function getRevertedCount( $pageId, ?RevisionRecord $fromRev = null ) { $tagIds = []; foreach ( ChangeTags::REVERT_TAGS as $tagName ) { try { $tagIds[] = $this->changeTagDefStore->getId( $tagName ); } catch ( NameTableAccessException $e ) { // If no revisions are tagged with a name, no tag id will be present } } if ( !$tagIds ) { return 0; } $dbr = $this->dbProvider->getReplicaDatabase(); $queryBuilder = $dbr->newSelectQueryBuilder() ->select( '1' ) ->from( 'revision' ) ->join( 'change_tag', null, 'ct_rev_id = rev_id' ) ->where( [ 'rev_page' => $pageId, 'ct_tag_id' => $tagIds, $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0" ] ) ->groupBy( 'rev_id' ) ->limit( self::COUNT_LIMITS['reverted'] + 1 ); // extra to detect truncation if ( $fromRev ) { $queryBuilder->andWhere( $dbr->buildComparison( '>', [ 'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ), 'rev_id' => $fromRev->getId(), ] ) ); } return $queryBuilder->caller( __METHOD__ )->fetchRowCount(); } /** * @param int $pageId the id of the page to load history for * @param RevisionRecord|null $fromRev * @return int the count */ protected function getMinorCount( $pageId, ?RevisionRecord $fromRev = null ) { $dbr = $this->dbProvider->getReplicaDatabase(); $queryBuilder = $dbr->newSelectQueryBuilder() ->select( '1' ) ->from( 'revision' ) ->where( [ 'rev_page' => $pageId, $dbr->expr( 'rev_minor_edit', '!=', 0 ), $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0" ] ) ->limit( self::COUNT_LIMITS['minor'] + 1 ); // extra to detect truncation if ( $fromRev ) { $queryBuilder->andWhere( $dbr->buildComparison( '>', [ 'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ), 'rev_id' => $fromRev->getId(), ] ) ); } return $queryBuilder->caller( __METHOD__ )->fetchRowCount(); } /** * @param int $pageId the id of the page to load history for * @param RevisionRecord|null $fromRev * @param RevisionRecord|null $toRev * @return int the count */ protected function getEditsCount( $pageId, ?RevisionRecord $fromRev = null, ?RevisionRecord $toRev = null ) { [ $fromRev, $toRev ] = $this->orderRevisions( $fromRev, $toRev ); return $this->revisionStore->countRevisionsBetween( $pageId, $fromRev, $toRev, self::COUNT_LIMITS['edits'] // Will be increased by 1 to detect truncation ); } /** * @param int $revId * @return RevisionRecord * @throws LocalizedHttpException */ private function getRevisionOrThrow( $revId ) { $rev = $this->revisionStore->getRevisionById( $revId ); if ( !$rev ) { throw new LocalizedHttpException( new MessageValue( 'rest-nonexistent-revision', [ $revId ] ), 404 ); } return $rev; } /** * Reorders revisions if they are present * @param RevisionRecord|null $fromRev * @param RevisionRecord|null $toRev * @return array * @phan-return array{0:RevisionRecord|null,1:RevisionRecord|null} */ private function orderRevisions( ?RevisionRecord $fromRev = null, ?RevisionRecord $toRev = null ) { if ( $fromRev && $toRev && ( $fromRev->getTimestamp() > $toRev->getTimestamp() || ( $fromRev->getTimestamp() === $toRev->getTimestamp() && $fromRev->getId() > $toRev->getId() ) ) ) { return [ $toRev, $fromRev ]; } return [ $fromRev, $toRev ]; } public function needsWriteAccess() { return false; } public function getParamSettings() { return [ 'title' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], 'type' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => array_merge( array_keys( self::COUNT_LIMITS ), array_keys( self::DEPRECATED_COUNT_TYPES ) ), ParamValidator::PARAM_REQUIRED => true, ], 'from' => [ self::PARAM_SOURCE => 'query', ParamValidator::PARAM_TYPE => 'integer', ParamValidator::PARAM_REQUIRED => false ], 'to' => [ self::PARAM_SOURCE => 'query', ParamValidator::PARAM_TYPE => 'integer', ParamValidator::PARAM_REQUIRED => false ] ]; } } PK ! � � � LanguageLinksHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use MediaWiki\Languages\LanguageNameUtils; use MediaWiki\Page\ExistingPageRecord; use MediaWiki\Page\PageLookup; use MediaWiki\Rest\Handler\Helper\PageRedirectHelper; use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Rest\SimpleHandler; use MediaWiki\Title\MalformedTitleException; use MediaWiki\Title\TitleFormatter; use MediaWiki\Title\TitleParser; use Wikimedia\Message\MessageValue; use Wikimedia\Message\ParamType; use Wikimedia\Message\ScalarParam; use Wikimedia\ParamValidator\ParamValidator; use Wikimedia\Rdbms\IConnectionProvider; /** * Class LanguageLinksHandler * REST API handler for /page/{title}/links/language endpoint. * * @package MediaWiki\Rest\Handler */ class LanguageLinksHandler extends SimpleHandler { private IConnectionProvider $dbProvider; private LanguageNameUtils $languageNameUtils; private TitleFormatter $titleFormatter; private TitleParser $titleParser; private PageLookup $pageLookup; private PageRestHelperFactory $helperFactory; /** * @var ExistingPageRecord|false|null */ private $page = false; /** * @param IConnectionProvider $dbProvider * @param LanguageNameUtils $languageNameUtils * @param TitleFormatter $titleFormatter * @param TitleParser $titleParser * @param PageLookup $pageLookup * @param PageRestHelperFactory $helperFactory */ public function __construct( IConnectionProvider $dbProvider, LanguageNameUtils $languageNameUtils, TitleFormatter $titleFormatter, TitleParser $titleParser, PageLookup $pageLookup, PageRestHelperFactory $helperFactory ) { $this->dbProvider = $dbProvider; $this->languageNameUtils = $languageNameUtils; $this->titleFormatter = $titleFormatter; $this->titleParser = $titleParser; $this->pageLookup = $pageLookup; $this->helperFactory = $helperFactory; } private function getRedirectHelper(): PageRedirectHelper { return $this->helperFactory->newPageRedirectHelper( $this->getResponseFactory(), $this->getRouter(), $this->getPath(), $this->getRequest() ); } /** * @return ExistingPageRecord|null */ private function getPage(): ?ExistingPageRecord { if ( $this->page === false ) { $this->page = $this->pageLookup->getExistingPageByText( $this->getValidatedParams()['title'] ); } return $this->page; } /** * @param string $title * @return Response * @throws LocalizedHttpException */ public function run( $title ) { $page = $this->getPage(); $params = $this->getValidatedParams(); if ( !$page ) { throw new LocalizedHttpException( new MessageValue( 'rest-nonexistent-title', [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] ), 404 ); } '@phan-var \MediaWiki\Page\ExistingPageRecord $page'; $redirectResponse = $this->getRedirectHelper()->createNormalizationRedirectResponseIfNeeded( $page, $params['title'] ?? null ); if ( $redirectResponse !== null ) { return $redirectResponse; } if ( !$this->getAuthority()->authorizeRead( 'read', $page ) ) { throw new LocalizedHttpException( new MessageValue( 'rest-permission-denied-title', [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] ), 403 ); } return $this->getResponseFactory() ->createJson( $this->fetchLinks( $page->getId() ) ); } private function fetchLinks( $pageId ) { $result = []; $res = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder() ->select( [ 'll_title', 'll_lang' ] ) ->from( 'langlinks' ) ->where( [ 'll_from' => $pageId ] ) ->orderBy( 'll_lang' ) ->caller( __METHOD__ )->fetchResultSet(); foreach ( $res as $item ) { try { $targetTitle = $this->titleParser->parseTitle( $item->ll_title ); $result[] = [ 'code' => $item->ll_lang, 'name' => $this->languageNameUtils->getLanguageName( $item->ll_lang ), 'key' => $this->titleFormatter->getPrefixedDBkey( $targetTitle ), 'title' => $this->titleFormatter->getPrefixedText( $targetTitle ) ]; } catch ( MalformedTitleException $e ) { // skip malformed titles } } return $result; } public function needsWriteAccess() { return false; } public function getParamSettings() { return [ 'title' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], ]; } /** * @return string|null */ protected function getETag(): ?string { $page = $this->getPage(); if ( !$page ) { return null; } // XXX: use hash of the rendered HTML? return '"' . $page->getLatest() . '@' . wfTimestamp( TS_MW, $page->getTouched() ) . '"'; } /** * @return string|null */ protected function getLastModified(): ?string { $page = $this->getPage(); return $page ? $page->getTouched() : null; } /** * @return bool */ protected function hasRepresentation() { return (bool)$this->getPage(); } } PK ! R�� � EditHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use MediaWiki\Api\IApiMessage; use MediaWiki\Config\Config; use MediaWiki\Content\IContentHandlerFactory; use MediaWiki\MainConfigNames; use MediaWiki\Request\WebResponse; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Rest\TokenAwareHandlerTrait; use MediaWiki\Rest\Validator\Validator; use MediaWiki\Revision\RevisionLookup; use MediaWiki\Revision\SlotRecord; use MediaWiki\Title\TitleFormatter; use MediaWiki\Title\TitleParser; use RuntimeException; use Wikimedia\Message\MessageValue; /** * Base class for REST API handlers that perform page edits (main slot only). */ abstract class EditHandler extends ActionModuleBasedHandler { use TokenAwareHandlerTrait; protected Config $config; protected IContentHandlerFactory $contentHandlerFactory; protected TitleParser $titleParser; protected TitleFormatter $titleFormatter; protected RevisionLookup $revisionLookup; public function __construct( Config $config, IContentHandlerFactory $contentHandlerFactory, TitleParser $titleParser, TitleFormatter $titleFormatter, RevisionLookup $revisionLookup ) { $this->config = $config; $this->contentHandlerFactory = $contentHandlerFactory; $this->titleParser = $titleParser; $this->titleFormatter = $titleFormatter; $this->revisionLookup = $revisionLookup; } public function needsWriteAccess() { return true; } /** * Returns the requested title. * * @return string */ abstract protected function getTitleParameter(); /** * @inheritDoc */ public function validate( Validator $restValidator ) { parent::validate( $restValidator ); $this->validateToken( true ); } /** * @inheritDoc */ protected function mapActionModuleResult( array $data ) { if ( isset( $data['error'] ) ) { throw new LocalizedHttpException( new MessageValue( 'apierror-' . $data['error'] ), 400 ); } if ( !isset( $data['edit'] ) || !$data['edit']['result'] ) { throw new RuntimeException( 'Bad result structure received from ApiEditPage' ); } if ( $data['edit']['result'] !== 'Success' ) { // Probably an edit conflict // TODO: which code for null edits? throw new LocalizedHttpException( new MessageValue( "rest-edit-conflict", [ $data['edit']['result'] ] ), 409 ); } $title = $this->titleParser->parseTitle( $data['edit']['title'] ); // This seems wasteful. This is the downside of delegating to the action API module: // if we need additional data in the response, we have to load it. $revision = $this->revisionLookup->getRevisionById( (int)$data['edit']['newrevid'] ); $content = $revision->getContent( SlotRecord::MAIN ); return [ 'id' => $data['edit']['pageid'], 'title' => $this->titleFormatter->getPrefixedText( $title ), 'key' => $this->titleFormatter->getPrefixedDBkey( $title ), 'latest' => [ 'id' => $data['edit']['newrevid'], 'timestamp' => $data['edit']['newtimestamp'], ], 'license' => [ 'url' => $this->config->get( MainConfigNames::RightsUrl ), 'title' => $this->config->get( MainConfigNames::RightsText ) ], 'content_model' => $data['edit']['contentmodel'], 'source' => $content->serialize(), ]; } /** * @inheritDoc */ protected function throwHttpExceptionForActionModuleError( IApiMessage $msg, $statusCode = 400 ) { $code = $msg->getApiCode(); if ( $code === 'protectedpage' ) { throw new LocalizedHttpException( $this->makeMessageValue( $msg ), 403 ); } if ( $code === 'badtoken' ) { throw new LocalizedHttpException( $this->makeMessageValue( $msg ), 403 ); } if ( $code === 'missingtitle' ) { throw new LocalizedHttpException( $this->makeMessageValue( $msg ), 404 ); } if ( $code === 'articleexists' ) { throw new LocalizedHttpException( $this->makeMessageValue( $msg ), 409 ); } if ( $code === 'editconflict' ) { throw new LocalizedHttpException( $this->makeMessageValue( $msg ), 409 ); } if ( $code === 'ratelimited' ) { throw new LocalizedHttpException( $this->makeMessageValue( $msg ), 429 ); } // Fall through to generic handling of the error (status 400). parent::throwHttpExceptionForActionModuleError( $msg, $statusCode ); } protected function mapActionModuleResponse( WebResponse $actionModuleResponse, array $actionModuleResult, Response $response ) { parent::mapActionModuleResponse( $actionModuleResponse, $actionModuleResult, $response ); if ( $actionModuleResult['edit']['new'] ?? false ) { $response->setStatus( 201 ); } } } PK ! �\CPs s RevisionSourceHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use LogicException; use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory; use MediaWiki\Rest\Handler\Helper\RevisionContentHelper; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Rest\SimpleHandler; use MediaWiki\Revision\RevisionRecord; /** * A handler that returns page source and metadata for the following routes: * - /revision/{revision} * - /revision/{revision}/bare */ class RevisionSourceHandler extends SimpleHandler { private RevisionContentHelper $contentHelper; public function __construct( PageRestHelperFactory $helperFactory ) { $this->contentHelper = $helperFactory->newRevisionContentHelper(); } protected function postValidationSetup() { $this->contentHelper->init( $this->getAuthority(), $this->getValidatedParams() ); } /** * @param RevisionRecord $rev * @return string */ private function constructHtmlUrl( RevisionRecord $rev ): string { // TODO: once legacy "v1" routes are removed, just use the path prefix from the module. $pathPrefix = $this->getModule()->getPathPrefix(); if ( strlen( $pathPrefix ) == 0 ) { $pathPrefix = 'v1'; } return $this->getRouter()->getRouteUrl( '/' . $pathPrefix . '/revision/{id}/html', [ 'id' => $rev->getId() ] ); } /** * @return Response * @throws LocalizedHttpException */ public function run() { $this->contentHelper->checkAccess(); $outputMode = $this->getOutputMode(); switch ( $outputMode ) { case 'restbase': // compatibility for restbase migration $body = [ 'items' => [ $this->contentHelper->constructRestbaseCompatibleMetadata() ] ]; break; case 'bare': $revisionRecord = $this->contentHelper->getTargetRevision(); $body = $this->contentHelper->constructMetadata(); // @phan-suppress-next-line PhanTypeMismatchArgumentNullable revisionRecord is set when used $body['html_url'] = $this->constructHtmlUrl( $revisionRecord ); $response = $this->getResponseFactory()->createJson( $body ); $this->contentHelper->setCacheControl( $response ); break; case 'source': $content = $this->contentHelper->getContent(); $body = $this->contentHelper->constructMetadata(); $body['source'] = $content->getText(); break; default: throw new LogicException( "Unknown output mode $outputMode" ); } $response = $this->getResponseFactory()->createJson( $body ); $this->contentHelper->setCacheControl( $response ); return $response; } protected function getResponseBodySchemaFileName( string $method ): ?string { // TODO: add fields based on the output mode to the schema return 'includes/Rest/Handler/Schema/RevisionMetaData.json'; } /** * @return string|null */ protected function getETag(): ?string { return $this->contentHelper->getETag(); } /** * @return string|null */ protected function getLastModified(): ?string { return $this->contentHelper->getLastModified(); } private function getOutputMode(): string { if ( $this->getRouter()->isRestbaseCompatEnabled( $this->getRequest() ) ) { return 'restbase'; } return $this->getConfig()['format']; } public function needsWriteAccess(): bool { return false; } public function getParamSettings(): array { return $this->contentHelper->getParamSettings(); } /** * @return bool */ protected function hasRepresentation() { return $this->contentHelper->hasContent(); } } PK ! �&� � CreationHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use MediaWiki\Request\WebResponse; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use Wikimedia\Message\MessageValue; use Wikimedia\ParamValidator\ParamValidator; /** * Core REST API endpoint that handles page creation (main slot only) */ class CreationHandler extends EditHandler { /** * @inheritDoc */ protected function getTitleParameter() { $body = $this->getValidatedBody(); '@phan-var array $body'; return $body['title']; } /** * @inheritDoc * @return array */ public function getBodyParamSettings(): array { return [ 'source' => [ self::PARAM_SOURCE => 'body', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, self::PARAM_DESCRIPTION => 'The intended content of the page', ], 'title' => [ self::PARAM_SOURCE => 'body', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, self::PARAM_DESCRIPTION => 'The title of the page to create', ], 'comment' => [ self::PARAM_SOURCE => 'body', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, self::PARAM_DESCRIPTION => 'A comment descripting the reason for creating the page', ], 'content_model' => [ self::PARAM_SOURCE => 'body', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => false, self::PARAM_DESCRIPTION => 'The content model to use to interpret the source', ], ] + $this->getTokenParamDefinition(); } /** * @inheritDoc */ protected function getActionModuleParameters() { $body = $this->getValidatedBody(); '@phan-var array $body'; $title = $this->getTitleParameter(); $contentmodel = $body['content_model'] ?: null; if ( $contentmodel !== null && !$this->contentHandlerFactory->isDefinedModel( $contentmodel ) ) { throw new LocalizedHttpException( new MessageValue( 'rest-bad-content-model', [ $body['content_model'] ] ), 400 ); } // Use a known good CSRF token if a token is not needed because we are // using a method of authentication that protects against CSRF, like OAuth. $token = $this->needsToken() ? $this->getToken() : $this->getUser()->getEditToken(); $params = [ 'action' => 'edit', 'title' => $title, 'text' => $body['source'], 'summary' => $body['comment'], 'token' => $token, 'createonly' => true, ]; if ( $contentmodel !== null ) { $params['contentmodel'] = $contentmodel; } return $params; } protected function mapActionModuleResponse( WebResponse $actionModuleResponse, array $actionModuleResult, Response $response ) { parent::mapActionModuleResponse( $actionModuleResponse, $actionModuleResult, $response ); $title = $this->urlEncodeTitle( $actionModuleResult['edit']['title'] ); $url = $this->getRouter()->getRouteUrl( '/v1/page/' . $title ); $response->setHeader( 'Location', $url ); } } PK ! �� � DiscoveryHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use MediaWiki\Config\Config; use MediaWiki\Config\ServiceOptions; use MediaWiki\MainConfigNames; use MediaWiki\Rest\Handler; use MediaWiki\Rest\Module\Module; /** * Core REST API endpoint that outputs discovery information, including a * list of registered modules. * Inspired by Google's API directory, see https://developers.google.com/discovery/v1/reference. */ class DiscoveryHandler extends Handler { /** * @internal */ private const CONSTRUCTOR_OPTIONS = [ MainConfigNames::RightsUrl, MainConfigNames::RightsText, MainConfigNames::EmergencyContact, MainConfigNames::Sitename, MainConfigNames::Server, ]; /** @var ServiceOptions */ private ServiceOptions $options; /** * @param Config $config */ public function __construct( Config $config ) { $options = new ServiceOptions( self::CONSTRUCTOR_OPTIONS, $config ); $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); $this->options = $options; } public function execute() { // NOTE: must match docs/rest/discovery-1.0.json return [ 'mw-discovery' => '1.0', '$schema' => 'https://www.mediawiki.org/schema/discovery-1.0', 'info' => $this->getInfoSpec(), 'servers' => $this->getServerList(), 'modules' => $this->getModuleMap(), // TODO: link to aggregated spec // TODO: list of component schemas ]; } private function getModuleMap(): array { $modules = []; foreach ( $this->getRouter()->getModuleIds() as $moduleName ) { $module = $this->getRouter()->getModule( $moduleName ); if ( $module ) { $modules[$moduleName] = $this->getModuleSpec( $moduleName, $module ); } } return $modules; } private function getServerList(): array { // See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#server-object return [ [ 'url' => $this->getRouter()->getRouteUrl( '' ), ] ]; } private function getInfoSpec() { return [ 'title' => $this->options->get( MainConfigNames::Sitename ), 'mediawiki' => MW_VERSION, 'license' => $this->getLicenseSpec(), 'contact' => $this->getContactSpec(), // TODO: terms of service // TODO: owner/operator // TODO: link to Special:RestSandbox // TODO: link to https://www.mediawiki.org/wiki/API:REST_API ]; } private function getLicenseSpec(): array { // See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#license-object // TODO: get terms-of-use URL, not content license. return [ 'name' => $this->options->get( MainConfigNames::RightsText ), 'url' => $this->options->get( MainConfigNames::RightsUrl ), ]; } private function getContactSpec(): array { // https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#contact-object return [ 'email' => $this->options->get( MainConfigNames::EmergencyContact ), ]; } private function getModuleSpec( string $moduleId, Module $module ): array { return $module->getModuleDescription(); } } PK ! ���n n TransformHandler.phpnu �Iw�� <?php /** * Copyright (C) 2011-2020 Wikimedia Foundation and others. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ namespace MediaWiki\Rest\Handler; use MediaWiki\Rest\Handler\Helper\ParsoidFormatHelper; use MediaWiki\Rest\HttpException; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use Wikimedia\Message\MessageValue; use Wikimedia\ParamValidator\ParamValidator; /** * Handler for transforming content given in the request. * - /v1/transform/{from}/to/{format} * - /v1/transform/{from}/to/{format}/{title} * - /v1/transform/{from}/to/{format}/{title}/{revision} * * @see https://www.mediawiki.org/wiki/Parsoid/API#POST */ class TransformHandler extends ParsoidHandler { /** @inheritDoc */ public function getParamSettings() { return [ 'from' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], 'format' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], 'title' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => false, ], 'revision' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => false, ], ]; } /** * @inheritDoc */ public function needsWriteAccess() { return false; } public function checkPreconditions() { // NOTE: disable all precondition checks. // If-(not)-Modified-Since is not supported by the /transform/ handler. // If-None-Match is not supported by the /transform/ handler. // If-Match for wt2html is handled in getRequestAttributes. } protected function &getRequestAttributes(): array { $attribs =& parent::getRequestAttributes(); $request = $this->getRequest(); // NOTE: If there is more than one ETag, this will break. // We don't have a good way to test multiple ETag to see if one of them is a working stash key. $ifMatch = $request->getHeaderLine( 'If-Match' ); if ( $ifMatch ) { $attribs['opts']['original']['etag'] = $ifMatch; } return $attribs; } /** * Transform content given in the request from or to wikitext. * * @return Response * @throws HttpException */ public function execute(): Response { $request = $this->getRequest(); $from = $request->getPathParam( 'from' ); $format = $request->getPathParam( 'format' ); // XXX: Fallback to the default valid transforms in case the request is // coming from a legacy client (restbase) that supports everything // in the default valid transforms. $validTransformations = $this->getConfig()['transformations'] ?? ParsoidFormatHelper::VALID_TRANSFORM; if ( !isset( $validTransformations[$from] ) || !in_array( $format, $validTransformations[$from], true ) ) { throw new LocalizedHttpException( new MessageValue( "rest-invalid-transform", [ $from, $format ] ), 404 ); } $attribs = &$this->getRequestAttributes(); if ( !$this->acceptable( $attribs ) ) { // mutates $attribs throw new LocalizedHttpException( new MessageValue( "rest-unsupported-target-format" ), 406 ); } if ( $from === ParsoidFormatHelper::FORMAT_WIKITEXT ) { // Accept wikitext as a string or object{body,headers} $wikitext = $attribs['opts']['wikitext'] ?? null; if ( is_array( $wikitext ) ) { $wikitext = $wikitext['body']; // We've been given a pagelanguage for this page. if ( isset( $attribs['opts']['wikitext']['headers']['content-language'] ) ) { $attribs['pagelanguage'] = $attribs['opts']['wikitext']['headers']['content-language']; } } // We've been given source for this page if ( $wikitext === null && isset( $attribs['opts']['original']['wikitext'] ) ) { $wikitext = $attribs['opts']['original']['wikitext']['body']; // We've been given a pagelanguage for this page. if ( isset( $attribs['opts']['original']['wikitext']['headers']['content-language'] ) ) { $attribs['pagelanguage'] = $attribs['opts']['original']['wikitext']['headers']['content-language']; } } // Abort if no wikitext or title. if ( $wikitext === null && empty( $attribs['pageName'] ) ) { throw new LocalizedHttpException( new MessageValue( "rest-transform-missing-title" ), 400 ); } $pageConfig = $this->tryToCreatePageConfig( $attribs, $wikitext ); return $this->wt2html( $pageConfig, $attribs, $wikitext ); } elseif ( $format === ParsoidFormatHelper::FORMAT_WIKITEXT ) { $html = $attribs['opts']['html'] ?? null; // Accept html as a string or object{body,headers} if ( is_array( $html ) ) { $html = $html['body']; } if ( $html === null ) { throw new LocalizedHttpException( new MessageValue( "rest-transform-missing-html" ), 400 ); } // TODO: use ETag from If-Match header, for compat! $page = $this->tryToCreatePageIdentity( $attribs ); return $this->html2wt( $page, $attribs, $html ); } else { return $this->pb2pb( $attribs ); } } } PK ! /{� � CompareHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use MediaWiki\Content\TextContent; use MediaWiki\Parser\ParserFactory; use MediaWiki\Rest\Handler; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\StringStream; use MediaWiki\Revision\RevisionAccessException; use MediaWiki\Revision\RevisionLookup; use MediaWiki\Revision\RevisionRecord; use MediaWiki\Revision\SlotRecord; use MediaWiki\Revision\SuppressedDataException; use Wikimedia\Message\MessageValue; use Wikimedia\ParamValidator\ParamValidator; class CompareHandler extends Handler { private RevisionLookup $revisionLookup; private ParserFactory $parserFactory; /** @var RevisionRecord[] */ private $revisions = []; /** @var string[] */ private $textCache = []; public function __construct( RevisionLookup $revisionLookup, ParserFactory $parserFactory ) { $this->revisionLookup = $revisionLookup; $this->parserFactory = $parserFactory; } public function execute() { $fromRev = $this->getRevisionOrThrow( 'from' ); $toRev = $this->getRevisionOrThrow( 'to' ); if ( $fromRev->getPageId() !== $toRev->getPageId() ) { throw new LocalizedHttpException( new MessageValue( 'rest-compare-page-mismatch' ), 400 ); } if ( !$this->getAuthority()->authorizeRead( 'read', $toRev->getPage() ) ) { throw new LocalizedHttpException( new MessageValue( 'rest-compare-permission-denied' ), 403 ); } $data = [ 'from' => [ 'id' => $fromRev->getId(), 'slot_role' => $this->getRole(), 'sections' => $this->getSectionInfo( 'from' ) ], 'to' => [ 'id' => $toRev->getId(), 'slot_role' => $this->getRole(), 'sections' => $this->getSectionInfo( 'to' ) ], 'diff' => [ 'PLACEHOLDER' => null ] ]; $rf = $this->getResponseFactory(); $wrapperJson = $rf->encodeJson( $data ); $diff = $this->getJsonDiff(); $response = $rf->create(); $response->setHeader( 'Content-Type', 'application/json' ); // A hack until getJsonDiff() is moved to SlotDiffRenderer and only nested inner diff is returned $innerDiff = substr( $diff, 1, -1 ); $response->setBody( new StringStream( str_replace( '"diff":{"PLACEHOLDER":null}', $innerDiff, $wrapperJson ) ) ); return $response; } /** * @param string $paramName * @return RevisionRecord|null */ private function getRevision( $paramName ) { if ( !isset( $this->revisions[$paramName] ) ) { $this->revisions[$paramName] = $this->revisionLookup->getRevisionById( $this->getValidatedParams()[$paramName] ); } return $this->revisions[$paramName]; } /** * @param string $paramName * @return RevisionRecord * @throws LocalizedHttpException */ private function getRevisionOrThrow( $paramName ) { $rev = $this->getRevision( $paramName ); if ( !$rev ) { throw new LocalizedHttpException( new MessageValue( 'rest-compare-nonexistent', [ $paramName ] ), 404 ); } if ( !$this->isAccessible( $rev ) ) { throw new LocalizedHttpException( new MessageValue( 'rest-compare-inaccessible', [ $paramName ] ), 403 ); } return $rev; } /** * @param RevisionRecord $rev * @return bool */ private function isAccessible( $rev ) { return $rev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ); } private function getRole() { return SlotRecord::MAIN; } private function getRevisionText( $paramName ) { if ( !isset( $this->textCache[$paramName] ) ) { $revision = $this->getRevision( $paramName ); try { $content = $revision ->getSlot( $this->getRole(), RevisionRecord::FOR_THIS_USER, $this->getAuthority() ) ->getContent() ->convert( CONTENT_MODEL_TEXT ); if ( $content instanceof TextContent ) { $this->textCache[$paramName] = $content->getText(); } else { throw new LocalizedHttpException( new MessageValue( 'rest-compare-wrong-content', [ $this->getRole(), $paramName ] ), 400 ); } } catch ( SuppressedDataException $e ) { throw new LocalizedHttpException( new MessageValue( 'rest-compare-inaccessible', [ $paramName ] ), 403 ); } catch ( RevisionAccessException $e ) { throw new LocalizedHttpException( new MessageValue( 'rest-compare-nonexistent', [ $paramName ] ), 404 ); } } return $this->textCache[$paramName]; } /** * @return string */ private function getJsonDiff() { // TODO: properly implement // This is a prototype only. SlotDiffRenderer should be extended to support this use case. $fromText = $this->getRevisionText( 'from' ); $toText = $this->getRevisionText( 'to' ); if ( !function_exists( 'wikidiff2_inline_json_diff' ) ) { throw new LocalizedHttpException( new MessageValue( 'rest-compare-wikidiff2' ), 500 ); } return wikidiff2_inline_json_diff( $fromText, $toText, 2 ); } /** * @param string $paramName * @return array */ private function getSectionInfo( $paramName ) { $text = $this->getRevisionText( $paramName ); $parserSections = $this->parserFactory->getInstance()->getFlatSectionInfo( $text ); $sections = []; foreach ( $parserSections as $i => $parserSection ) { // Skip section zero, which comes before the first heading, since // its offset is always zero, so the client can assume its location. if ( $i !== 0 ) { $sections[] = [ 'level' => $parserSection['level'], 'heading' => $parserSection['heading'], 'offset' => $parserSection['offset'], ]; } } return $sections; } /** * @inheritDoc */ public function needsWriteAccess() { return false; } public function getParamSettings() { return [ 'from' => [ ParamValidator::PARAM_TYPE => 'integer', ParamValidator::PARAM_REQUIRED => true, Handler::PARAM_SOURCE => 'path', ], 'to' => [ ParamValidator::PARAM_TYPE => 'integer', ParamValidator::PARAM_REQUIRED => true, Handler::PARAM_SOURCE => 'path', ], ]; } } PK ! Ǒ㬉 � ModuleSpecHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use MediaWiki\Config\Config; use MediaWiki\Config\ServiceOptions; use MediaWiki\MainConfigNames; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Module\Module; use MediaWiki\Rest\RequestData; use MediaWiki\Rest\ResponseFactory; use MediaWiki\Rest\SimpleHandler; use MediaWiki\Rest\Validator\Validator; use Wikimedia\Message\MessageValue; use Wikimedia\ParamValidator\ParamValidator; /** * Core REST API endpoint that outputs an OpenAPI spec of a set of routes. */ class ModuleSpecHandler extends SimpleHandler { public const MODULE_SPEC_PATH = '/coredev/v0/specs/module/{module}'; /** * @internal */ private const CONSTRUCTOR_OPTIONS = [ MainConfigNames::RightsUrl, MainConfigNames::RightsText, MainConfigNames::EmergencyContact, MainConfigNames::Sitename, ]; private ServiceOptions $options; public function __construct( Config $config ) { $options = new ServiceOptions( self::CONSTRUCTOR_OPTIONS, $config ); $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); $this->options = $options; } public function run( $moduleName, $version = '' ): array { // TODO: implement caching, get cache key from Router. if ( $version !== '' ) { $moduleName .= '/' . $version; } if ( $moduleName === '-' ) { // Hack that allows us to fetch a spec for the empty module prefix $moduleName = ''; } $module = $this->getRouter()->getModule( $moduleName ); if ( !$module ) { throw new LocalizedHttpException( MessageValue::new( 'rest-unknown-module' )->params( $moduleName ), 404 ); } return [ 'openapi' => '3.0.0', 'info' => $this->getInfoSpec( $module ), 'servers' => $this->getServerSpec( $module ), 'paths' => $this->getPathsSpec( $module ), 'components' => $this->getComponentsSpec( $module ), ]; } /** * @see https://spec.openapis.org/oas/v3.0.0#info-object */ private function getInfoSpec( Module $module ): array { // TODO: Let Modules provide their name, description, version, etc $prefix = $module->getPathPrefix(); if ( $prefix === '' ) { $title = "Default Module"; } else { $title = "$prefix Module"; } return $module->getOpenApiInfo() + [ 'title' => $title, 'version' => 'undefined', 'license' => $this->getLicenseSpec(), 'contact' => $this->getContactSpec(), ]; } private function getLicenseSpec(): array { // TODO: get terms-of-use URL, not content license. return [ 'name' => $this->options->get( MainConfigNames::RightsText ), 'url' => $this->options->get( MainConfigNames::RightsUrl ), ]; } private function getContactSpec(): array { return [ 'email' => $this->options->get( MainConfigNames::EmergencyContact ), ]; } private function getServerSpec( Module $module ): array { $prefix = $module->getPathPrefix(); if ( $prefix !== '' ) { $prefix = "/$prefix"; } return [ [ 'url' => $this->getRouter()->getRouteUrl( $prefix ), ] ]; } private function getPathsSpec( Module $module ): array { $specs = []; foreach ( $module->getDefinedPaths() as $path => $methods ) { foreach ( $methods as $mth ) { $key = strtolower( $mth ); $mth = strtoupper( $mth ); $specs[ $path ][ $key ] = $this->getRouteSpec( $module, $path, $mth ); } } return $specs; } private function getRouteSpec( Module $module, string $path, string $method ): array { $request = new RequestData( [ 'method' => $method ] ); $handler = $module->getHandlerForPath( $path, $request, false ); $operationSpec = $handler->getOpenApiSpec( $method ); return $operationSpec; } private function getComponentsSpec( Module $module ) { $components = []; // XXX: also collect reusable components from handler specs (but how to avoid name collisions?). $componentsSources = [ [ 'schemas' => Validator::getParameterTypeSchemas() ], ResponseFactory::getResponseComponents() ]; // 2D merge foreach ( $componentsSources as $cmps ) { foreach ( $cmps as $name => $cmp ) { $components[$name] = array_merge( $components[$name] ?? [], $cmp ); } } return $components; } public function getParamSettings() { return [ 'module' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], 'version' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_DEFAULT => '', ], ]; } } PK ! ���� � ActionModuleBasedHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use MediaWiki\Api\ApiBase; use MediaWiki\Api\ApiMain; use MediaWiki\Api\ApiMessage; use MediaWiki\Api\ApiUsageException; use MediaWiki\Api\IApiMessage; use MediaWiki\Context\RequestContext; use MediaWiki\Request\FauxRequest; use MediaWiki\Request\WebResponse; use MediaWiki\Rest\Handler; use MediaWiki\Rest\Handler\Helper\RestStatusTrait; use MediaWiki\Rest\HttpException; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use Wikimedia\Message\MessageValue; /** * Base class for REST handlers that are implemented by mapping to an existing ApiModule. * * @stable to extend */ abstract class ActionModuleBasedHandler extends Handler { use RestStatusTrait; /** * @var ApiMain|null */ private $apiMain = null; protected function getUser() { return $this->getApiMain()->getUser(); } /** * Set main action API entry point for testing. * * @param ApiMain $apiMain */ public function setApiMain( ApiMain $apiMain ) { $this->apiMain = $apiMain; } /** * @return ApiMain */ public function getApiMain() { if ( $this->apiMain ) { return $this->apiMain; } $context = RequestContext::getMain(); $session = $context->getRequest()->getSession(); // NOTE: This being a MediaWiki\Request\FauxRequest instance triggers special case behavior // in ApiMain, causing ApiMain::isInternalMode() to return true. Among other things, // this causes ApiMain to throw errors rather than encode them in the result data. $fauxRequest = new FauxRequest( [], true, $session ); $fauxRequest->setSessionId( $session->getSessionId() ); $fauxContext = new RequestContext(); $fauxContext->setRequest( $fauxRequest ); $fauxContext->setUser( $context->getUser() ); $fauxContext->setLanguage( $context->getLanguage() ); $this->apiMain = new ApiMain( $fauxContext, true ); return $this->apiMain; } /** * Overrides an action API module. Used for testing. * * @param string $name * @param string $group * @param ApiBase $module */ public function overrideActionModule( string $name, string $group, ApiBase $module ) { $this->getApiMain()->getModuleManager()->addModule( $name, $group, [ 'class' => get_class( $module ), 'factory' => static function () use ( $module ) { return $module; } ] ); } /** * Main execution method, implemented to delegate execution to ApiMain. * Which action API module gets called is controlled by the parameter array returned * by getActionModuleParameters(). The response from the action module is passed to * mapActionModuleResult(), any ApiUsageException thrown will be converted to a * HttpException by throwHttpExceptionForActionModuleError(). * * @return mixed */ public function execute() { $apiMain = $this->getApiMain(); $params = $this->getActionModuleParameters(); $request = $apiMain->getRequest(); foreach ( $params as $key => $value ) { $request->setVal( $key, $value ); } try { // NOTE: ApiMain detects this to be an internal call, so it will throw // ApiUsageException rather than putting error messages into the result. $apiMain->execute(); } catch ( ApiUsageException $ex ) { // use a fake loop to throw the first error foreach ( $ex->getStatusValue()->getMessages( 'error' ) as $msg ) { $msg = ApiMessage::create( $msg ); $this->throwHttpExceptionForActionModuleError( $msg, $ex->getCode() ?: 400 ); } // This should never happen, since ApiUsageExceptions should always // have errors in their Status object. throw new LocalizedHttpException( new MessageValue( "rest-unmapped-action-error", [ $ex->getMessage() ] ), $ex->getCode() ); } $actionModuleResult = $apiMain->getResult()->getResultData( null, [ 'Strip' => 'all' ] ); // construct result $resultData = $this->mapActionModuleResult( $actionModuleResult ); $response = $this->getResponseFactory()->createFromReturnValue( $resultData ); $this->mapActionModuleResponse( $apiMain->getRequest()->response(), $actionModuleResult, $response ); return $response; } /** * Maps a REST API request to an action API request. * Implementations typically use information returned by $this->getValidatedBody() * and $this->getValidatedParams() to construct the return value. * * The return value of this method controls which action module is called by execute(). * * @return array Emulated request parameters to be passed to the ApiModule. */ abstract protected function getActionModuleParameters(); /** * Maps an action API result to a REST API result. * * @param array $data Data structure retrieved from the ApiResult returned by the ApiModule * * @return mixed Data structure to be converted to JSON and wrapped in a REST Response. * Will be processed by ResponseFactory::createFromReturnValue(). */ abstract protected function mapActionModuleResult( array $data ); /** * Transfers relevant information, such as header values, from the WebResponse constructed * by the action API call to a REST Response object. * * Subclasses may override this to provide special case handling for header fields. * For mapping the response body, override mapActionModuleResult() instead. * * Subclasses overriding this method should call this method in the parent class, * to preserve baseline behavior. * * @stable to override * * @param WebResponse $actionModuleResponse * @param array $actionModuleResult * @param Response $response */ protected function mapActionModuleResponse( WebResponse $actionModuleResponse, array $actionModuleResult, Response $response ) { // TODO: map status, headers, cookies, etc } /** * Throws a HttpException for a given IApiMessage that represents an error. * Never returns normally. * * Subclasses may override this to provide mappings for specific error codes, * typically based on $msg->getApiCode(). Subclasses overriding this method must * always either throw an exception, or call this method in the parent class, * which then throws an exception. * * @stable to override * * @param IApiMessage $msg A message object representing an error in an action module, * typically from calling getStatusValue()->getMessages( 'error' ) on * an ApiUsageException. * @param int $statusCode The HTTP status indicated by the original exception * * @throws HttpException always. */ protected function throwHttpExceptionForActionModuleError( IApiMessage $msg, $statusCode = 400 ) { // override to supply mappings throw new LocalizedHttpException( $this->makeMessageValue( $msg ), $statusCode, // Include the original error code in the response. // This makes it easier to track down the original cause of the error, // and allows more specific mappings to be added to // implementations of throwHttpExceptionForActionModuleError() provided by // subclasses [ 'actionModuleErrorCode' => $msg->getApiCode() ] ); } /** * Constructs a MessageValue from an IApiMessage. * * @param IApiMessage $msg * * @return MessageValue */ protected function makeMessageValue( IApiMessage $msg ) { return $this->getMessageValueConverter()->convertMessage( $msg ); } } PK ! ����� � MediaFileHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use File; use MediaFileTrait; use MediaWiki\Page\ExistingPageRecord; use MediaWiki\Page\PageLookup; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Rest\SimpleHandler; use RepoGroup; use Wikimedia\Message\MessageValue; use Wikimedia\ParamValidator\ParamValidator; /** * Handler class for media meta-data */ class MediaFileHandler extends SimpleHandler { use MediaFileTrait; private RepoGroup $repoGroup; private PageLookup $pageLookup; /** * @var ExistingPageRecord|false|null */ private $page = false; /** * @var File|false|null */ private $file = false; public function __construct( RepoGroup $repoGroup, PageLookup $pageLookup ) { $this->repoGroup = $repoGroup; $this->pageLookup = $pageLookup; } /** * @return ExistingPageRecord|null */ private function getPage(): ?ExistingPageRecord { if ( $this->page === false ) { $this->page = $this->pageLookup->getExistingPageByText( $this->getValidatedParams()['title'], NS_FILE ); } return $this->page; } /** * @return File|null */ private function getFile(): ?File { if ( $this->file === false ) { $page = $this->getPage(); $this->file = // @phan-suppress-next-line PhanTypeMismatchArgumentNullable $this->repoGroup->findFile( $page, [ 'private' => $this->getAuthority() ] ) ?: null; } return $this->file; } /** * @param string $title * @return Response * @throws LocalizedHttpException */ public function run( $title ) { $page = $this->getPage(); if ( !$page ) { throw new LocalizedHttpException( MessageValue::new( 'rest-nonexistent-title' )->plaintextParams( $title ), 404 ); } if ( !$this->getAuthority()->authorizeRead( 'read', $page ) ) { throw new LocalizedHttpException( MessageValue::new( 'rest-permission-denied-title' )->plaintextParams( $title ), 403 ); } $fileObj = $this->getFile(); if ( !$fileObj || !$fileObj->exists() ) { throw new LocalizedHttpException( MessageValue::new( 'rest-cannot-load-file' )->plaintextParams( $title ), 404 ); } $response = $this->getResponse( $fileObj ); return $this->getResponseFactory()->createJson( $response ); } /** * @param File $file the file to load media links for * @return array response data */ private function getResponse( File $file ): array { [ $maxWidth, $maxHeight ] = self::getImageLimitsFromOption( $this->getAuthority()->getUser(), 'imagesize' ); [ $maxThumbWidth, $maxThumbHeight ] = self::getImageLimitsFromOption( $this->getAuthority()->getUser(), 'thumbsize' ); $transforms = [ 'preferred' => [ 'maxWidth' => $maxWidth, 'maxHeight' => $maxHeight ], 'thumbnail' => [ 'maxWidth' => $maxThumbWidth, 'maxHeight' => $maxThumbHeight ] ]; return $this->getFileInfo( $file, $this->getAuthority(), $transforms ); } public function needsWriteAccess() { return false; } public function getParamSettings() { return [ 'title' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], ]; } /** * @return string|null * @throws LocalizedHttpException */ protected function getETag(): ?string { $file = $this->getFile(); if ( !$file || !$file->exists() ) { return null; } return '"' . $file->getSha1() . '"'; } /** * @return string|null * @throws LocalizedHttpException */ protected function getLastModified(): ?string { $file = $this->getFile(); if ( !$file || !$file->exists() ) { return null; } return $file->getTimestamp(); } /** * @return bool */ protected function hasRepresentation() { $file = $this->getFile(); return $file && $file->exists(); } } PK ! J� )q q UpdateHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use MediaWiki\Api\IApiMessage; use MediaWiki\Content\TextContent; use MediaWiki\Json\FormatJson; use MediaWiki\ParamValidator\TypeDef\ArrayDef; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Revision\RevisionRecord; use MediaWiki\Revision\SlotRecord; use MediaWiki\Utils\MWTimestamp; use Wikimedia\Message\MessageValue; use Wikimedia\ParamValidator\ParamValidator; /** * Core REST API endpoint that handles page updates (main slot only) */ class UpdateHandler extends EditHandler { /** * @var callable */ private $jsonDiffFunction; /** * @inheritDoc */ protected function getTitleParameter() { return $this->getValidatedParams()['title']; } /** * Sets the function to use for JSON diffs, for testing. * * @param callable $jsonDiffFunction */ public function setJsonDiffFunction( callable $jsonDiffFunction ) { $this->jsonDiffFunction = $jsonDiffFunction; } /** * @inheritDoc */ public function getParamSettings() { return [ 'title' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], ] + parent::getParamSettings(); } /** * @inheritDoc */ public function getBodyParamSettings(): array { return [ 'source' => [ self::PARAM_SOURCE => 'body', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], 'comment' => [ self::PARAM_SOURCE => 'body', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], 'content_model' => [ self::PARAM_SOURCE => 'body', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => false, ], 'latest' => [ self::PARAM_SOURCE => 'body', ParamValidator::PARAM_TYPE => 'array', ParamValidator::PARAM_REQUIRED => false, ArrayDef::PARAM_SCHEMA => ArrayDef::makeObjectSchema( [ 'id' => 'integer' ], [ 'timestamp' => 'string' ], // from GET response, will be ignored ), ], ] + $this->getTokenParamDefinition(); } /** * @inheritDoc */ protected function getActionModuleParameters() { $body = $this->getValidatedBody(); '@phan-var array $body'; $title = $this->getTitleParameter(); $baseRevId = $body['latest']['id'] ?? 0; $contentmodel = $body['content_model'] ?: null; if ( $contentmodel !== null && !$this->contentHandlerFactory->isDefinedModel( $contentmodel ) ) { throw new LocalizedHttpException( new MessageValue( 'rest-bad-content-model', [ $contentmodel ] ), 400 ); } // Use a known good CSRF token if a token is not needed because we are // using a method of authentication that protects against CSRF, like OAuth. $token = $this->needsToken() ? $this->getToken() : $this->getUser()->getEditToken(); $params = [ 'action' => 'edit', 'title' => $title, 'text' => $body['source'], 'summary' => $body['comment'], 'token' => $token ]; if ( $contentmodel !== null ) { $params['contentmodel'] = $contentmodel; } if ( $baseRevId > 0 ) { $params['baserevid'] = $baseRevId; $params['nocreate'] = true; } else { $params['createonly'] = true; } return $params; } /** * @inheritDoc */ protected function mapActionModuleResult( array $data ) { if ( isset( $data['edit']['nochange'] ) ) { // Null-edit, no new revision was created. The new revision is the same as the old. // We may want to signal this more explicitly to the client in the future. $title = $this->titleParser->parseTitle( $this->getValidatedParams()['title'] ); $currentRev = $this->revisionLookup->getRevisionByTitle( $title ); $data['edit']['newrevid'] = $currentRev->getId(); $data['edit']['newtimestamp'] = MWTimestamp::convert( TS_ISO_8601, $currentRev->getTimestamp() ); } return parent::mapActionModuleResult( $data ); } /** * @inheritDoc */ protected function throwHttpExceptionForActionModuleError( IApiMessage $msg, $statusCode = 400 ) { $code = $msg->getApiCode(); // Provide a message instructing the client to provide the base revision ID for updates. if ( $code === 'articleexists' ) { $title = $this->getTitleParameter(); throw new LocalizedHttpException( new MessageValue( 'rest-update-cannot-create-page', [ $title ] ), 409 ); } if ( $code === 'editconflict' ) { $data = $this->getConflictData(); throw new LocalizedHttpException( $this->makeMessageValue( $msg ), 409, $data ); } parent::throwHttpExceptionForActionModuleError( $msg, $statusCode ); } /** * Returns an associative array to be used in the response in the event of edit conflicts. * * The resulting array contains the following keys: * - base: revision ID of the base revision * - current: revision ID of the current revision (new base after resolving the conflict) * - local: the difference between the content submitted and the base revision * - remote: the difference between the latest revision of the page and the base revision * * If the differences cannot be determined, an empty array is returned. * * @return array */ private function getConflictData() { $body = $this->getValidatedBody(); '@phan-var array $body'; $baseRevId = $body['latest']['id'] ?? 0; $title = $this->titleParser->parseTitle( $this->getValidatedParams()['title'] ); $baseRev = $this->revisionLookup->getRevisionById( $baseRevId ); $currentRev = $this->revisionLookup->getRevisionByTitle( $title ); if ( !$baseRev || !$currentRev ) { return []; } $baseContent = $baseRev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $this->getAuthority() ); $currentContent = $currentRev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $this->getAuthority() ); if ( !$baseContent || !$currentContent ) { return []; } $model = $body['content_model'] ?: $baseContent->getModel(); $contentHandler = $this->contentHandlerFactory->getContentHandler( $model ); $newContent = $contentHandler->unserializeContent( $body['source'] ); if ( !$baseContent instanceof TextContent || !$currentContent instanceof TextContent || !$newContent instanceof TextContent ) { return []; } $localDiff = $this->getDiff( $baseContent, $newContent ); $remoteDiff = $this->getDiff( $baseContent, $currentContent ); if ( !$localDiff || !$remoteDiff ) { return []; } return [ 'base' => $baseRev->getId(), 'current' => $currentRev->getId(), 'local' => $localDiff, 'remote' => $remoteDiff, ]; } /** * Returns a text diff encoded as an array, to be included in the response data. * * @param TextContent $from * @param TextContent $to * * @return array|null */ private function getDiff( TextContent $from, TextContent $to ) { if ( !is_callable( $this->jsonDiffFunction ) ) { return null; } $json = ( $this->jsonDiffFunction )( $from->getText(), $to->getText(), 2 ); return FormatJson::decode( $json, true ); } } PK ! !��� � Schema/RevisionMetaData.jsonnu �Iw�� { "description": "revision meta-data", "required": [ "id", "size", "delta", "comment", "minor", "timestamp", "content_model", "page", "license" ], "properties": { "id": { "type": "integer", "description": "Revision id" }, "size": { "type": "integer", "description": "The size of the revision, in no particular measure." }, "delta": { "type": "integer", "nullable": true, "description": "The difference in size compared to the previous revision." }, "comment": { "type": "string", "nullable": true, "description": "The comment the author associated with the revision" }, "minor": { "type": "boolean", "description": "Whether the author of the revision conidered it minor." }, "timestamp": { "type": "string", "format": "date-time" }, "content_model": { "type": "string", "format": "mw-content-model" }, "page": { "description": "the page the revision belongs to", "required": [ "id", "key", "title" ], "properties": { "id": { "type": "integer", "description": "the page ID" }, "key": { "type": "string", "format": "mw-title", "description": "the page title in URL form (unencoded)" }, "title": { "type": "string", "format": "mw-title", "description": "the page title in human readable form" } } }, "license": { "description": "license information for the revision content", "required": [ "url", "title" ], "properties": { "url": { "type": "string", "format": "url" }, "title": { "type": "string", "description": "the name of the license" } } } } } PK ! �pf� � OpenSearchDescriptionHandler.phpnu �Iw�� <?php /** * Copyright (C) 2011-2020 Wikimedia Foundation and others. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ namespace MediaWiki\Rest\Handler; use MediaWiki\Api\ApiOpenSearch; use MediaWiki\Config\Config; use MediaWiki\HookContainer\HookRunner; use MediaWiki\MainConfigNames; use MediaWiki\MainConfigSchema; use MediaWiki\Rest\Handler; use MediaWiki\Rest\Response; use MediaWiki\Rest\StringStream; use MediaWiki\SpecialPage\SpecialPage; use MediaWiki\Utils\UrlUtils; use MediaWiki\Xml\Xml; use Wikimedia\Http\HttpAcceptParser; /** * Handler for generating an OpenSearch description document. * In a nutshell, this tells browsers how and where * to submit search queries to get a search results page back, * as well as how to get typeahead suggestions (see ApiOpenSearch). * * This class handles the following routes: * - /v1/search * * @see https://github.com/dewitt/opensearch * @see https://www.opensearch.org */ class OpenSearchDescriptionHandler extends Handler { private UrlUtils $urlUtils; /** @see MainConfigSchema::Favicon */ private string $favicon; /** @see MainConfigSchema::OpenSearchTemplates */ private array $templates; public function __construct( Config $config, UrlUtils $urlUtils ) { $this->favicon = $config->get( MainConfigNames::Favicon ); $this->templates = $config->get( MainConfigNames::OpenSearchTemplates ); $this->urlUtils = $urlUtils; } public function execute(): Response { $ctype = $this->getContentType(); $response = $this->getResponseFactory()->create(); $response->setHeader( 'Content-type', $ctype ); // Set an Expires header so that CDN can cache it for a short time // Short enough so that the sysadmin barely notices when $wgSitename is changed $expiryTime = 600; # 10 minutes $response->setHeader( 'Expires', gmdate( 'D, d M Y H:i:s', time() + $expiryTime ) . ' GMT' ); $response->setHeader( 'Cache-control', 'max-age=600' ); $body = new StringStream(); $body->write( '<?xml version="1.0"?>' ); $body->write( Xml::openElement( 'OpenSearchDescription', [ 'xmlns' => 'http://a9.com/-/spec/opensearch/1.1/', 'xmlns:moz' => 'http://www.mozilla.org/2006/browser/search/' ] ) ); // The spec says the ShortName must be no longer than 16 characters, // but 16 is *realllly* short. In practice, browsers don't appear to care // when we give them a longer string, so we're no longer attempting to trim. // // Note: ShortName and the <link title=""> need to match; they are used as // a key for identifying if the search engine has been added already, *and* // as the display name presented to the end-user. // // Behavior seems about the same between Firefox and IE 7/8 here. // 'Description' doesn't appear to be used by either. $fullName = wfMessage( 'opensearch-desc' )->inContentLanguage()->text(); $body->write( Xml::element( 'ShortName', null, $fullName ) ); $body->write( Xml::element( 'Description', null, $fullName ) ); // By default we'll use the site favicon. // Double-check if IE supports this properly? $body->write( Xml::element( 'Image', [ 'height' => 16, 'width' => 16, 'type' => 'image/x-icon' ], (string)$this->urlUtils->expand( $this->favicon, PROTO_CURRENT ) ) ); $urls = []; // General search template. Given an input term, this should bring up // search results or a specific found page. // At least Firefox and IE 7 support this. $searchPage = SpecialPage::getTitleFor( 'Search' ); $urls[] = [ 'type' => 'text/html', 'method' => 'get', 'template' => $searchPage->getCanonicalURL( 'search={searchTerms}' ) ]; // TODO: add v1/search/ endpoints? foreach ( $this->templates as $type => $template ) { if ( !$template ) { $template = ApiOpenSearch::getOpenSearchTemplate( $type ); } if ( $template ) { $urls[] = [ 'type' => $type, 'method' => 'get', 'template' => $template, ]; } } // Allow hooks to override the suggestion URL settings in a more // general way than overriding the whole search engine... ( new HookRunner( $this->getHookContainer() ) )->onOpenSearchUrls( $urls ); foreach ( $urls as $attribs ) { $body->write( Xml::element( 'Url', $attribs ) ); } // And for good measure, add a link to the straight search form. // This is a custom format extension for Firefox, which otherwise // sends you to the domain root if you hit "enter" with an empty // search box. $body->write( Xml::element( 'moz:SearchForm', null, $searchPage->getCanonicalURL() ) ); $body->write( Xml::closeElement( 'OpenSearchDescription' ) ); $response->setBody( $body ); return $response; } /** * Returns the content-type to use for the response. * Will be either 'application/xml' or 'application/opensearchdescription+xml', * depending on the client's preference. * * @return string */ private function getContentType(): string { $params = $this->getValidatedParams(); if ( $params['ctype'] == 'application/xml' ) { // Makes testing tweaks about a billion times easier return 'application/xml'; } $acceptHeader = $this->getRequest()->getHeader( 'accept' ); if ( $acceptHeader ) { $parser = new HttpAcceptParser(); $acceptableTypes = $parser->parseAccept( $acceptHeader[0] ); foreach ( $acceptableTypes as $acc ) { if ( $acc['type'] === 'application/xml' ) { return 'application/xml'; } } } return 'application/opensearchdescription+xml'; } public function getParamSettings() { return [ 'ctype' => [ self::PARAM_SOURCE => 'query', ] ]; } } PK ! �:cp� � RevisionHTMLHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use LogicException; use MediaWiki\Rest\Handler\Helper\HtmlOutputRendererHelper; use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory; use MediaWiki\Rest\Handler\Helper\RevisionContentHelper; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Rest\SimpleHandler; use MediaWiki\Rest\StringStream; use Wikimedia\Assert\Assert; /** * A handler that returns Parsoid HTML for the following routes: * - /revision/{revision}/html, * - /revision/{revision}/with_html */ class RevisionHTMLHandler extends SimpleHandler { private ?HtmlOutputRendererHelper $htmlHelper = null; private PageRestHelperFactory $helperFactory; private RevisionContentHelper $contentHelper; public function __construct( PageRestHelperFactory $helperFactory ) { $this->helperFactory = $helperFactory; $this->contentHelper = $helperFactory->newRevisionContentHelper(); } protected function postValidationSetup() { $authority = $this->getAuthority(); $this->contentHelper->init( $authority, $this->getValidatedParams() ); $page = $this->contentHelper->getPage(); $revision = $this->contentHelper->getTargetRevision(); if ( $page && $revision ) { $this->htmlHelper = $this->helperFactory->newHtmlOutputRendererHelper( $page, $this->getValidatedParams(), $authority, $revision ); $request = $this->getRequest(); $acceptLanguage = $request->getHeaderLine( 'Accept-Language' ) ?: null; if ( $acceptLanguage ) { $this->htmlHelper->setVariantConversionLanguage( $acceptLanguage ); } } } /** * @return Response * @throws LocalizedHttpException */ public function run(): Response { $this->contentHelper->checkAccess(); $page = $this->contentHelper->getPage(); $revisionRecord = $this->contentHelper->getTargetRevision(); // The call to $this->contentHelper->getPage() should not return null if // $this->contentHelper->checkAccess() did not throw. Assert::invariant( $page !== null, 'Page should be known' ); // The call to $this->contentHelper->getTargetRevision() should not return null if // $this->contentHelper->checkAccess() did not throw. Assert::invariant( $revisionRecord !== null, 'Revision should be known' ); $outputMode = $this->getOutputMode(); $setContentLanguageHeader = true; switch ( $outputMode ) { case 'html': $parserOutput = $this->htmlHelper->getHtml(); $response = $this->getResponseFactory()->create(); // TODO: need to respect content-type returned by Parsoid. $response->setHeader( 'Content-Type', 'text/html' ); $this->htmlHelper->putHeaders( $response, $setContentLanguageHeader ); $this->contentHelper->setCacheControl( $response, $parserOutput->getCacheExpiry() ); $response->setBody( new StringStream( $parserOutput->getRawText() ) ); break; case 'with_html': $parserOutput = $this->htmlHelper->getHtml(); $body = $this->contentHelper->constructMetadata(); $body['html'] = $parserOutput->getRawText(); $response = $this->getResponseFactory()->createJson( $body ); // For JSON content, it doesn't make sense to set content language header $this->htmlHelper->putHeaders( $response, !$setContentLanguageHeader ); $this->contentHelper->setCacheControl( $response, $parserOutput->getCacheExpiry() ); break; default: throw new LogicException( "Unknown HTML type $outputMode" ); } return $response; } /** * Returns an ETag representing a page's source. The ETag assumes a page's source has changed * if the latest revision of a page has been made private, un-readable for another reason, * or a newer revision exists. * @return string|null */ protected function getETag(): ?string { if ( !$this->contentHelper->isAccessible() ) { return null; } // Vary eTag based on output mode return $this->htmlHelper->getETag( $this->getOutputMode() ); } /** * @return string|null */ protected function getLastModified(): ?string { if ( !$this->contentHelper->isAccessible() ) { return null; } return $this->htmlHelper->getLastModified(); } private function getOutputMode(): string { return $this->getConfig()['format']; } public function needsWriteAccess(): bool { return false; } public function getParamSettings(): array { return array_merge( $this->contentHelper->getParamSettings(), HtmlOutputRendererHelper::getParamSettings() ); } /** * @return bool */ protected function hasRepresentation() { return $this->contentHelper->hasContent(); } } PK ! J�w w MediaLinksHandler.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler; use MediaFileTrait; use MediaWiki\Page\ExistingPageRecord; use MediaWiki\Page\PageLookup; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\Response; use MediaWiki\Rest\SimpleHandler; use RepoGroup; use Wikimedia\Message\MessageValue; use Wikimedia\ParamValidator\ParamValidator; use Wikimedia\Rdbms\IConnectionProvider; /** * Handler class for Core REST API endpoints that perform operations on revisions */ class MediaLinksHandler extends SimpleHandler { use MediaFileTrait; /** int The maximum number of media links to return */ private const MAX_NUM_LINKS = 100; private IConnectionProvider $dbProvider; private RepoGroup $repoGroup; private PageLookup $pageLookup; /** * @var ExistingPageRecord|false|null */ private $page = false; public function __construct( IConnectionProvider $dbProvider, RepoGroup $repoGroup, PageLookup $pageLookup ) { $this->dbProvider = $dbProvider; $this->repoGroup = $repoGroup; $this->pageLookup = $pageLookup; } /** * @return ExistingPageRecord|null */ private function getPage(): ?ExistingPageRecord { if ( $this->page === false ) { $this->page = $this->pageLookup->getExistingPageByText( $this->getValidatedParams()['title'] ); } return $this->page; } /** * @param string $title * @return Response * @throws LocalizedHttpException */ public function run( $title ) { $page = $this->getPage(); if ( !$page ) { throw new LocalizedHttpException( MessageValue::new( 'rest-nonexistent-title' )->plaintextParams( $title ), 404 ); } if ( !$this->getAuthority()->authorizeRead( 'read', $page ) ) { throw new LocalizedHttpException( MessageValue::new( 'rest-permission-denied-title' )->plaintextParams( $title ), 403 ); } // @todo: add continuation if too many links are found $results = $this->getDbResults( $page->getId() ); if ( count( $results ) > $this->getMaxNumLinks() ) { throw new LocalizedHttpException( MessageValue::new( 'rest-media-too-many-links' ) ->plaintextParams( $title ) ->numParams( $this->getMaxNumLinks() ), 400 ); } $response = $this->processDbResults( $results ); return $this->getResponseFactory()->createJson( $response ); } /** * @param int $pageId the id of the page to load media links for * @return array the results */ private function getDbResults( int $pageId ) { return $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder() ->select( 'il_to' ) ->from( 'imagelinks' ) ->where( [ 'il_from' => $pageId ] ) ->orderBy( 'il_to' ) ->limit( $this->getMaxNumLinks() + 1 ) ->caller( __METHOD__ )->fetchFieldValues(); } /** * @param array $results database results, or an empty array if none * @return array response data */ private function processDbResults( $results ) { // Using "private" here means an equivalent of the Action API's "anon-public-user-private" // caching model would be necessary, if caching is ever added to this endpoint. $performer = $this->getAuthority(); $findTitles = array_map( static function ( $title ) use ( $performer ) { return [ 'title' => $title, 'private' => $performer, ]; }, $results ); $files = $this->repoGroup->findFiles( $findTitles ); [ $maxWidth, $maxHeight ] = self::getImageLimitsFromOption( $this->getAuthority()->getUser(), 'imagesize' ); $transforms = [ 'preferred' => [ 'maxWidth' => $maxWidth, 'maxHeight' => $maxHeight, ] ]; $response = []; foreach ( $files as $file ) { $response[] = $this->getFileInfo( $file, $performer, $transforms ); } $response = [ 'files' => $response ]; return $response; } public function needsWriteAccess() { return false; } public function getParamSettings() { return [ 'title' => [ self::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_REQUIRED => true, ], ]; } /** * @return string|null * @throws LocalizedHttpException */ protected function getETag(): ?string { $page = $this->getPage(); if ( !$page ) { return null; } // XXX: use hash of the rendered HTML? return '"' . $page->getLatest() . '@' . wfTimestamp( TS_MW, $page->getTouched() ) . '"'; } /** * @return string|null * @throws LocalizedHttpException */ protected function getLastModified(): ?string { $page = $this->getPage(); return $page ? $page->getTouched() : null; } /** * @return bool */ protected function hasRepresentation() { return (bool)$this->getPage(); } /** * For testing * * @unstable */ protected function getMaxNumLinks(): int { return self::MAX_NUM_LINKS; } } PK ! �'�Ei Ei # Helper/HtmlInputTransformHelper.phpnu �Iw�� <?php /** * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html * * @file */ namespace MediaWiki\Rest\Handler\Helper; use InvalidArgumentException; use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; use MediaWiki\Content\Content; use MediaWiki\Edit\ParsoidOutputStash; use MediaWiki\Edit\ParsoidRenderID; use MediaWiki\Edit\SelserContext; use MediaWiki\Language\LanguageCode; use MediaWiki\MainConfigNames; use MediaWiki\Page\PageIdentity; use MediaWiki\Page\PageLookup; use MediaWiki\Page\PageRecord; use MediaWiki\Page\ParserOutputAccess; use MediaWiki\Parser\ParserOptions; use MediaWiki\Parser\ParserOutput; use MediaWiki\Parser\Parsoid\HtmlToContentTransform; use MediaWiki\Parser\Parsoid\HtmlTransformFactory; use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter; use MediaWiki\Rest\Handler; use MediaWiki\Rest\HttpException; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\ResponseInterface; use MediaWiki\Revision\RevisionAccessException; use MediaWiki\Revision\RevisionLookup; use MediaWiki\Revision\RevisionRecord; use MediaWiki\Status\Status; use MWUnknownContentModelException; use Wikimedia\Bcp47Code\Bcp47Code; use Wikimedia\Message\MessageValue; use Wikimedia\ParamValidator\ParamValidator; use Wikimedia\Parsoid\Core\ClientError; use Wikimedia\Parsoid\Core\PageBundle; use Wikimedia\Parsoid\Core\ResourceLimitExceededException; use Wikimedia\Parsoid\Parsoid; use Wikimedia\Stats\StatsFactory; /** * REST helper for converting HTML to page content source (e.g. wikitext). * * @since 1.40 * * @unstable Pending consolidation of the Parsoid extension with core code. */ class HtmlInputTransformHelper { /** * @internal */ public const CONSTRUCTOR_OPTIONS = [ MainConfigNames::ParsoidCacheConfig ]; /** @var PageIdentity|null */ private $page = null; /** * @var HtmlToContentTransform */ private $transform; /** * @var array */ private $envOptions; private StatsFactory $statsFactory; private HtmlTransformFactory $htmlTransformFactory; private ParsoidOutputStash $parsoidOutputStash; private ParserOutputAccess $parserOutputAccess; private PageLookup $pageLookup; private RevisionLookup $revisionLookup; /** * @param StatsFactory $statsFactory * @param HtmlTransformFactory $htmlTransformFactory * @param ParsoidOutputStash $parsoidOutputStash * @param ParserOutputAccess $parserOutputAccess * @param PageLookup $pageLookup * @param RevisionLookup $revisionLookup * @param array $envOptions * @param ?PageIdentity $page * @param array|string $body Body structure, or an HTML string * @param array $parameters * @param RevisionRecord|null $originalRevision * @param Bcp47Code|null $pageLanguage */ public function __construct( StatsFactory $statsFactory, HtmlTransformFactory $htmlTransformFactory, ParsoidOutputStash $parsoidOutputStash, ParserOutputAccess $parserOutputAccess, PageLookup $pageLookup, RevisionLookup $revisionLookup, array $envOptions = [], ?PageIdentity $page = null, $body = '', array $parameters = [], ?RevisionRecord $originalRevision = null, ?Bcp47Code $pageLanguage = null ) { $this->statsFactory = $statsFactory; $this->htmlTransformFactory = $htmlTransformFactory; $this->parsoidOutputStash = $parsoidOutputStash; $this->envOptions = $envOptions + [ 'outputContentVersion' => Parsoid::defaultHTMLVersion(), 'offsetType' => 'byte', ]; $this->parserOutputAccess = $parserOutputAccess; $this->pageLookup = $pageLookup; $this->revisionLookup = $revisionLookup; if ( $page === null ) { wfDeprecated( __METHOD__ . ' without $page', '1.43' ); } else { $this->initInternal( $page, $body, $parameters, $originalRevision, $pageLanguage ); } } /** * @return array */ public function getParamSettings(): array { // JSON body schema: /* doc: properties: headers: type: array items: type: string body: type: [ string, object ] required: [ body ] body: properties: offsetType: type: string revid: type: integer renderid: type: string etag: type: string html: type: [ doc, string ] data-mw: type: doc original: properties: html: type: doc source: type: doc data-mw: type: doc data-parsoid: type: doc required: [ html ] */ // FUTURE: more params // - slot (for loading the base content) return [ // XXX: should we really declare this here? Or should end endpoint do this? // We are not reading this property... 'title' => [ Handler::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_DEFAULT => '', ParamValidator::PARAM_REQUIRED => false, ], // XXX: Needed for compatibility with the parsoid transform endpoint. // But revid should just be part of the info about the original data // in the body. 'oldid' => [ Handler::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'int', ParamValidator::PARAM_DEFAULT => 0, ParamValidator::PARAM_REQUIRED => false, ], // XXX: Supported for compatibility with the parsoid transform endpoint. // If given, it should be 'html' or 'pagebundle'. 'from' => [ Handler::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_DEFAULT => '', ParamValidator::PARAM_REQUIRED => false, ], // XXX: Supported for compatibility with the parsoid transform endpoint. // Ignored. 'format' => [ Handler::PARAM_SOURCE => 'path', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_DEFAULT => '', ParamValidator::PARAM_REQUIRED => false, ], 'contentmodel' => [ // XXX: get this from the Accept header? Handler::PARAM_SOURCE => 'query', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_DEFAULT => '', ParamValidator::PARAM_REQUIRED => false, ], 'language' => [ // TODO: get this from Accept-Language header?! Handler::PARAM_SOURCE => 'query', ParamValidator::PARAM_TYPE => 'string', ParamValidator::PARAM_DEFAULT => '', ParamValidator::PARAM_REQUIRED => false, ] ]; } /** * Modify body and parameters to provide compatibility with legacy endpoints. * * @see ParsoidHandler::getRequestAttributes * * @param array<string,mixed> &$body * @param array<string,mixed> &$parameters * * @throws HttpException * * @return void */ private static function normalizeParameters( array &$body, array &$parameters ) { // If the revision ID is given in the path, pretend it was given in the body. if ( isset( $parameters['oldid'] ) && (int)$parameters['oldid'] > 0 ) { $body['original']['revid'] = (int)$parameters['oldid']; } // If an etag is given in the body, use it as the render ID. // Note that we support ETag format in the renderid field. if ( !empty( $body['original']['etag'] ) ) { // @phan-suppress-next-line PhanTypeInvalidDimOffset false positive $body['original']['renderid'] = $body['original']['etag']; } // Accept 'wikitext' as an alias for 'source'. if ( isset( $body['original']['wikitext'] ) ) { // @phan-suppress-next-line PhanTypeInvalidDimOffset false positive $body['original']['source'] = $body['original']['wikitext']; unset( $body['original']['wikitext'] ); } // If 'from' is not set, we accept page bundle style input as well as full HTML. // If 'from' is set, we only accept page bundle style input if it is set to FORMAT_PAGEBUNDLE. if ( isset( $parameters['from'] ) && $parameters['from'] !== '' && $parameters['from'] !== ParsoidFormatHelper::FORMAT_PAGEBUNDLE ) { unset( $body['original']['data-parsoid']['body'] ); unset( $body['original']['data-mw']['body'] ); unset( $body['data-mw']['body'] ); } // If 'from' is given, it must be html or pagebundle. if ( isset( $parameters['from'] ) && $parameters['from'] !== '' && $parameters['from'] !== ParsoidFormatHelper::FORMAT_HTML && $parameters['from'] !== ParsoidFormatHelper::FORMAT_PAGEBUNDLE ) { throw new LocalizedHttpException( new MessageValue( "rest-unsupported-transform-input", [ $parameters['from'] ] ), 400 ); } if ( isset( $body['contentmodel'] ) && $body['contentmodel'] !== '' ) { $parameters['contentmodel'] = $body['contentmodel']; } elseif ( isset( $parameters['format'] ) && $parameters['format'] !== '' ) { $parameters['contentmodel'] = $parameters['format']; } } /** * @param PageIdentity $page * @param array|string $body Body structure, or an HTML string * @param array $parameters * @param RevisionRecord|null $originalRevision * @param Bcp47Code|null $pageLanguage * * @throws HttpException * @deprecated since 1.43; pass arguments to constructor instead */ public function init( PageIdentity $page, $body, array $parameters, ?RevisionRecord $originalRevision = null, ?Bcp47Code $pageLanguage = null ) { wfDeprecated( __METHOD__, '1.43' ); $this->initInternal( $page, $body, $parameters, $originalRevision, $pageLanguage ); } /** * @param PageIdentity $page * @param array|string $body Body structure, or an HTML string * @param array $parameters * @param RevisionRecord|null $originalRevision * @param Bcp47Code|null $pageLanguage * * @throws HttpException */ private function initInternal( PageIdentity $page, $body, array $parameters, ?RevisionRecord $originalRevision = null, ?Bcp47Code $pageLanguage = null ) { if ( is_string( $body ) ) { $body = [ 'html' => $body ]; } self::normalizeParameters( $body, $parameters ); $this->page = $page; if ( !isset( $body['html'] ) ) { throw new LocalizedHttpException( new MessageValue( "rest-missing-body-field", [ 'html' ] ) ); } $html = is_array( $body['html'] ) ? $body['html']['body'] : $body['html']; // TODO: validate $body against a proper schema. $this->transform = $this->htmlTransformFactory->getHtmlToContentTransform( $html, $this->page ); $this->transform->setMetrics( $this->statsFactory ); // NOTE: Env::getContentModel will fall back to the page's recorded content model // if none is set here. $this->transform->setOptions( [ 'contentmodel' => $parameters['contentmodel'] ?? null, 'offsetType' => $body['offsetType'] ?? $this->envOptions['offsetType'], ] ); $original = $body['original'] ?? []; $originalRendering = null; if ( !isset( $original['html'] ) && !empty( $original['renderid'] ) ) { $key = $original['renderid']; if ( preg_match( '!^(W/)?".*"$!', $key ) ) { $originalRendering = ParsoidRenderID::newFromETag( $key ); if ( !$originalRendering ) { throw new LocalizedHttpException( new MessageValue( "rest-bad-etag", [ $key ] ), 400 ); } } else { try { $originalRendering = ParsoidRenderID::newFromKey( $key ); } catch ( InvalidArgumentException $e ) { throw new LocalizedHttpException( new MessageValue( 'rest-parsoid-bad-render-id', [ $key ] ), 400 ); } } } elseif ( !empty( $original['html'] ) || !empty( $original['data-parsoid'] ) ) { // NOTE: We might have an incomplete PageBundle here, with no HTML but with data-parsoid! // XXX: Do we need to support that, or can that just be a 400? $originalRendering = new PageBundle( $original['html']['body'] ?? '', $original['data-parsoid']['body'] ?? null, $original['data-mw']['body'] ?? null, null, // will be derived from $original['html']['headers']['content-type'] $original['html']['headers'] ?? [] ); } if ( !$originalRevision && !empty( $original['revid'] ) ) { $originalRevision = (int)$original['revid']; } if ( $originalRevision || $originalRendering ) { $this->setOriginal( $originalRevision, $originalRendering ); } else { if ( $this->page->exists() ) { $this->statsFactory ->getCounter( 'html_input_transform_total' ) ->setLabel( 'original_html_given', 'false' ) ->setLabel( 'page_exists', 'true' ) ->setLabel( 'status', 'unknown' ) ->copyToStatsdAt( 'html_input_transform.original_html.not_given.page_exists' ) ->increment(); } else { $this->statsFactory ->getCounter( 'html_input_transform_total' ) ->setLabel( 'original_html_given', 'false' ) ->setLabel( 'page_exists', 'false' ) ->setLabel( 'status', 'unknown' ) ->copyToStatsdAt( 'html_input_transform.original_html.not_given.page_not_exist' ) ->increment(); } } if ( isset( $body['data-mw']['body'] ) ) { $this->transform->setModifiedDataMW( $body['data-mw']['body'] ); } if ( $pageLanguage ) { $this->transform->setContentLanguage( $pageLanguage ); } elseif ( isset( $parameters['language'] ) && $parameters['language'] !== '' ) { $pageLanguage = LanguageCode::normalizeNonstandardCodeAndWarn( $parameters['language'] ); $this->transform->setContentLanguage( $pageLanguage ); } if ( isset( $original['source']['body'] ) ) { // XXX: do we really have to support wikitext overrides? $this->transform->setOriginalText( $original['source']['body'] ); } } /** * Return HTMLTransform object, so additional context can be provided by calling setters on it. * @return HtmlToContentTransform */ public function getTransform(): HtmlToContentTransform { return $this->transform; } /** * Set metrics sink. * * @note Passing a StatsdDataFactoryInterface here has been deprecated * since 1.43. * * @param StatsFactory|StatsdDataFactoryInterface $statsFactory */ public function setMetrics( $statsFactory ) { if ( $statsFactory instanceof StatsdDataFactoryInterface ) { // Uncomment this once all WMF code has been transitioned, but // leave it in for the 1.43 release. wfDeprecated( __METHOD__ . ' with StatsdDataFactoryInterface', '1.43' ); return; } $this->statsFactory = $statsFactory; if ( $this->transform ) { $this->transform->setMetrics( $statsFactory ); } } /** * Supply information about the revision and rendering that was the original basis of * the input HTML. This is used to apply selective serialization (selser), if possible. * * @param RevisionRecord|int|null $rev * @param ParsoidRenderID|PageBundle|ParserOutput|null $originalRendering */ public function setOriginal( $rev, $originalRendering ) { if ( $originalRendering instanceof ParsoidRenderID ) { $renderId = $originalRendering; // If the client asked for a render ID, load original data from stash try { $selserContext = $this->fetchSelserContextFromStash( $renderId ); } catch ( InvalidArgumentException $ex ) { $this->statsFactory ->getCounter( 'html_input_transform_total' ) ->setLabel( 'original_html_given', 'as_renderid' ) ->setLabel( 'page_exists', 'unknown' ) ->setLabel( 'status', 'bad_renderid' ) ->copyToStatsdAt( 'html_input_transform.original_html.given.as_renderid.bad' ) ->increment(); throw new LocalizedHttpException( new MessageValue( "rest-bad-stash-key" ), 400, [ 'reason' => $ex->getMessage(), 'key' => "$renderId" ] ); } if ( !$selserContext ) { // NOTE: When the client asked for a specific stash key (resp. etag), // we should fail with a 412 if we don't have the specific rendering. // On the other hand, of the client only provided a base revision ID, // we can re-parse and hope for the best. throw new LocalizedHttpException( new MessageValue( "rest-no-stashed-content", [ $renderId->getKey() ] ), 412 ); // TODO: This class should provide getETag and getLastModified methods for use by // the REST endpoint, to provide proper support for conditionals. // However, that requires some refactoring of how HTTP conditional checks // work in the Handler base class. } if ( !$rev ) { $rev = $renderId->getRevisionID(); } $originalRendering = $selserContext->getPageBundle(); $content = $selserContext->getContent(); if ( $content ) { $this->transform->setOriginalContent( $content ); } } elseif ( !$originalRendering && $rev ) { // The client provided a revision ID, but not stash key. // Try to get a rendering for the given revision, and use it as the basis for selser. // Chances are good that the resulting diff will be reasonably clean. // NOTE: If we don't have a revision ID, we should not attempt selser! $originalRendering = $this->fetchParserOutputFromParsoid( $this->page, $rev, true ); if ( $originalRendering ) { $this->statsFactory->getCounter( 'html_input_transform_total' ) ->setLabel( 'original_html_given', 'as_revid' ) ->setLabel( 'page_exists', 'unknown' ) ->setLabel( 'status', 'found' ) ->copyToStatsdAt( 'html_input_transform.original_html.given.as_revid.found' ) ->increment(); } else { $this->statsFactory->getCounter( 'html_input_transform_total' ) ->setLabel( 'original_html_given', 'as_revid' ) ->setLabel( 'page_exists', 'unknown' ) ->setLabel( 'status', 'not_found' ) ->copyToStatsdAt( 'html_input_transform.original_html.given.as_revid.not_found' ) ->increment(); } } elseif ( $originalRendering ) { $this->statsFactory->getCounter( 'html_input_transform_total' ) ->setLabel( 'original_html_given', 'true' ) ->setLabel( 'page_exists', 'unknown' ) ->setLabel( 'status', 'verbatim' ) ->copyToStatsdAt( 'html_input_transform.original_html.given.verbatim' ) ->increment(); } if ( $originalRendering instanceof ParserOutput ) { $originalRendering = PageBundleParserOutputConverter::pageBundleFromParserOutput( $originalRendering ); // NOTE: Use the default if we got a ParserOutput object. // Don't apply the default if we got passed a PageBundle, // in that case, we want to require the version to be explicit. if ( $originalRendering->version === null && !isset( $originalRendering->headers['content-type'] ) ) { $originalRendering->version = Parsoid::defaultHTMLVersion(); } } if ( !$originalRendering instanceof PageBundle ) { return; } if ( $originalRendering->version !== null ) { $this->transform->setOriginalSchemaVersion( $originalRendering->version ); } elseif ( !empty( $originalRendering->headers['content-type'] ) ) { $vOriginal = ParsoidFormatHelper::parseContentTypeHeader( // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Silly Phan, we just checked. $originalRendering->headers['content-type'] ); if ( $vOriginal ) { $this->transform->setOriginalSchemaVersion( $vOriginal ); } } if ( $rev instanceof RevisionRecord ) { $this->transform->setOriginalRevision( $rev ); } elseif ( $rev && is_int( $rev ) ) { $this->transform->setOriginalRevisionId( $rev ); } // NOTE: We might have an incomplete PageBundle here, with no HTML. // PageBundle::$html is declared to not be nullable, so it would be set to the empty // string if not given. Note however that it might also be null, since it's a public field. if ( $originalRendering->html !== null && $originalRendering->html !== '' ) { $this->transform->setOriginalHtml( $originalRendering->html ); } if ( $originalRendering->parsoid !== null ) { $this->transform->setOriginalDataParsoid( $originalRendering->parsoid ); } if ( $originalRendering->mw !== null ) { $this->transform->setOriginalDataMW( $originalRendering->mw ); } } /** * @return Content the content derived from the input HTML. * @throws HttpException */ public function getContent(): Content { try { return $this->transform->htmlToContent(); } catch ( ClientError $e ) { throw new LocalizedHttpException( new MessageValue( 'rest-html-backend-error', [ $e->getMessage() ] ), 400, [ 'reason' => $e->getMessage() ] ); } catch ( ResourceLimitExceededException $e ) { throw new LocalizedHttpException( new MessageValue( 'rest-resource-limit-exceeded' ), 413, [ 'reason' => $e->getMessage() ] ); } catch ( MWUnknownContentModelException $e ) { throw new LocalizedHttpException( new MessageValue( "rest-unknown-content-model", [ $e->getModelId() ] ), 400 ); } } /** * Creates a response containing the content derived from the input HTML. * This will set the appropriate Content-Type header. * * @param ResponseInterface $response */ public function putContent( ResponseInterface $response ) { $content = $this->getContent(); $data = $content->serialize(); try { $contentType = ParsoidFormatHelper::getContentType( $content->getModel(), $this->envOptions['outputContentVersion'] ); } catch ( InvalidArgumentException $e ) { // If Parsoid doesn't know the content type, // ask the ContentHandler! $contentType = $content->getDefaultFormat(); } $response->setHeader( 'Content-Type', $contentType ); $response->getBody()->write( $data ); } /** * @param PageIdentity $page * @param RevisionRecord|int $revision * @param bool $mayParse * * @return ParserOutput|null * @throws HttpException */ private function fetchParserOutputFromParsoid( PageIdentity $page, $revision, bool $mayParse ): ?ParserOutput { $parserOptions = ParserOptions::newFromAnon(); $parserOptions->setUseParsoid(); try { if ( !$page instanceof PageRecord ) { $name = "$page"; $page = $this->pageLookup->getPageByReference( $page ); if ( !$page ) { throw new RevisionAccessException( 'Page {name} not found', [ 'name' => $name ] ); } } if ( is_int( $revision ) ) { $revId = $revision; $revision = $this->revisionLookup->getRevisionById( $revId, 0, $page ); if ( !$revision ) { throw new RevisionAccessException( 'Revision {revId} not found', [ 'revId' => $revId ] ); } } if ( $page->getId() !== $revision->getPageId() ) { throw new RevisionAccessException( 'Revision {revId} does not belong to page {name}', [ 'name' => $page->getDBkey(), 'revId' => $revision->getId() ] ); } if ( $mayParse ) { try { $status = $this->parserOutputAccess->getParserOutput( $page, $parserOptions, $revision ); } catch ( ClientError $e ) { $status = Status::newFatal( 'parsoid-client-error', $e->getMessage() ); } catch ( ResourceLimitExceededException $e ) { $status = Status::newFatal( 'parsoid-resource-limit-exceeded', $e->getMessage() ); } if ( !$status->isOK() ) { $this->throwHttpExceptionForStatus( $status ); } $parserOutput = $status->getValue(); } else { $parserOutput = $this->parserOutputAccess->getCachedParserOutput( $page, $parserOptions, $revision ); } } catch ( RevisionAccessException $e ) { // The client supplied bad revision ID, or the revision was deleted or suppressed. throw new LocalizedHttpException( new MessageValue( "rest-specified-revision-unavailable" ), 404, [ 'reason' => $e->getMessage() ] ); } return $parserOutput; } /** * @param ParsoidRenderID $renderID * * @return SelserContext|null */ private function fetchSelserContextFromStash( $renderID ): ?SelserContext { $selserContext = $this->parsoidOutputStash->get( $renderID ); $labels = [ 'original_html_given' => 'as_renderid', 'page_exists' => 'unknown', 'status' => 'hit-stashed' ]; $counter = $this->statsFactory->getCounter( 'html_input_transform_total' ); if ( $selserContext ) { $counter->setLabels( $labels ) ->copyToStatsdAt( 'html_input_transform.original_html.given.as_renderid.stash_hit.found.hit' ) ->increment(); return $selserContext; } else { // Looks like the rendering is gone from stash (or the client send us a bogus key). // Try to load it from the parser cache instead. // On a wiki with low edit frequency, there is a good chance that it's still there. try { $parserOutput = $this->fetchParserOutputFromParsoid( $this->page, $renderID->getRevisionID(), false ); if ( !$parserOutput ) { $labels[ 'status' ] = 'miss-fallback_not_found'; $counter->setLabels( $labels )->copyToStatsdAt( 'html_input_transform.original_html.given.as_renderid.stash_miss_pc_fallback.not_found.miss' )->increment(); return null; } $cachedRenderID = ParsoidRenderID::newFromParserOutput( $parserOutput ); if ( $cachedRenderID->getKey() !== $renderID->getKey() ) { $labels[ 'status' ] = 'mismatch-fallback_not_found'; $counter->setLabels( $labels ) ->copyToStatsdAt( 'html_input_transform.original_html.given.as_renderid.' . 'stash_miss_pc_fallback.not_found.mismatch' ) ->increment(); // It's not the correct rendering. return null; } $labels[ 'status' ] = 'hit-fallback_found'; $counter->setLabels( $labels ) ->copyToStatsdAt( 'html_input_transform.original_html.given.as_renderid.' . 'stash_miss_pc_fallback.found.hit' ) ->increment(); $pb = PageBundleParserOutputConverter::pageBundleFromParserOutput( $parserOutput ); return new SelserContext( $pb, $renderID->getRevisionID() ); } catch ( HttpException $e ) { $labels[ 'status' ] = 'failed-fallback_not_found'; $counter->setLabels( $labels ) ->copyToStatsdAt( 'html_input_transform.original_html.given.as_renderid.' . 'stash_miss_pc_fallback.not_found.failed' ) ->increment(); // If the revision isn't found, don't trigger a 404. Return null to trigger a 412. return null; } } } /** * @param Status $status * * @return never * @throws HttpException */ private function throwHttpExceptionForStatus( Status $status ) { // TODO: make this nicer. if ( $status->hasMessage( 'parsoid-resource-limit-exceeded' ) ) { throw new LocalizedHttpException( new MessageValue( "rest-parsoid-resource-exceeded" ), 413, [ 'reason' => $status->getHTML() ] ); } else { throw new LocalizedHttpException( new MessageValue( "rest-parsoid-error" ), 400, [ 'reason' => $status->getHTML() ] ); } } } PK ! �I��s s Helper/ParsoidFormatHelper.phpnu �Iw�� <?php /** * Copyright (C) 2011-2020 Wikimedia Foundation and others. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ namespace MediaWiki\Rest\Handler\Helper; use InvalidArgumentException; use MediaWiki\Rest\ResponseInterface; /** * Format-related REST API helper. * Probably should be turned into an object encapsulating format and content version at some point. */ class ParsoidFormatHelper { public const FORMAT_WIKITEXT = 'wikitext'; public const FORMAT_HTML = 'html'; public const FORMAT_PAGEBUNDLE = 'pagebundle'; public const FORMAT_LINT = 'lint'; public const ERROR_ENCODING = [ self::FORMAT_WIKITEXT => 'plain', self::FORMAT_HTML => 'html', self::FORMAT_PAGEBUNDLE => 'json', self::FORMAT_LINT => 'json', ]; public const VALID_PAGE = [ self::FORMAT_WIKITEXT, self::FORMAT_HTML, self::FORMAT_PAGEBUNDLE, self::FORMAT_LINT ]; public const VALID_TRANSFORM = [ self::FORMAT_WIKITEXT => [ self::FORMAT_HTML, self::FORMAT_PAGEBUNDLE, self::FORMAT_LINT ], self::FORMAT_HTML => [ self::FORMAT_WIKITEXT ], self::FORMAT_PAGEBUNDLE => [ self::FORMAT_WIKITEXT, self::FORMAT_PAGEBUNDLE ], ]; /** * Get the content type appropriate for a given response format. * @param string $format One of the FORMAT_* constants * @param ?string $contentVersion Output version, only for HTML and pagebundle * formats. See Env::getcontentVersion(). * @return string */ public static function getContentType( string $format, ?string $contentVersion = null ): string { if ( $format !== self::FORMAT_WIKITEXT && !$contentVersion ) { throw new InvalidArgumentException( '$contentVersion is required for this format' ); } switch ( $format ) { case self::FORMAT_WIKITEXT: $contentType = 'text/plain'; // PORT-FIXME in the original the version number is from MWParserEnvironment.wikitextVersion // but it did not seem to be used anywhere $profile = 'https://www.mediawiki.org/wiki/Specs/wikitext/1.0.0'; break; case self::FORMAT_HTML: $contentType = 'text/html'; $profile = 'https://www.mediawiki.org/wiki/Specs/HTML/' . $contentVersion; break; case self::FORMAT_PAGEBUNDLE: $contentType = 'application/json'; $profile = 'https://www.mediawiki.org/wiki/Specs/pagebundle/' . $contentVersion; break; default: throw new InvalidArgumentException( "Invalid format $format" ); } return "$contentType; charset=utf-8; profile=\"$profile\""; } /** * Set the Content-Type header appropriate for a given response format. * @param ResponseInterface $response * @param string $format One of the FORMAT_* constants * @param ?string $contentVersion Output version, only for HTML and pagebundle * formats. See Env::getcontentVersion(). */ public static function setContentType( ResponseInterface $response, string $format, ?string $contentVersion = null ): void { $response->setHeader( 'Content-Type', self::getContentType( $format, $contentVersion ) ); } /** * Parse a Content-Type header and return the format type and version. * Mostly the inverse of getContentType() but also accounts for legacy formats. * @param string $contentTypeHeader The value of the Content-Type header. * @param ?string &$format Format type will be set here (as a FORMAT_* constant). * @return ?string Format version, or null if it couldn't be identified. * @see Env::getInputContentVersion() */ public static function parseContentTypeHeader( string $contentTypeHeader, ?string &$format = null ): ?string { $newProfileSyntax = 'https://www.mediawiki.org/wiki/Specs/(HTML|pagebundle)/'; $oldProfileSyntax = 'mediawiki.org/specs/(html)/'; $profileRegex = "#\bprofile=\"(?:$newProfileSyntax|$oldProfileSyntax)(\d+\.\d+\.\d+)\"#"; preg_match( $profileRegex, $contentTypeHeader, $m ); if ( $m ) { switch ( $m[1] ?: $m[2] ) { case 'HTML': case 'html': $format = self::FORMAT_HTML; break; case 'pagebundle': $format = self::FORMAT_PAGEBUNDLE; break; } return $m[3]; } return null; } } PK ! A��X� � Helper/PageRestHelperFactory.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler\Helper; use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface; use MediaWiki\ChangeTags\ChangeTagsStore; use MediaWiki\Config\ServiceOptions; use MediaWiki\Content\IContentHandlerFactory; use MediaWiki\Edit\ParsoidOutputStash; use MediaWiki\Languages\LanguageConverterFactory; use MediaWiki\Languages\LanguageFactory; use MediaWiki\Page\PageIdentity; use MediaWiki\Page\PageLookup; use MediaWiki\Page\ParserOutputAccess; use MediaWiki\Page\RedirectStore; use MediaWiki\Parser\Parsoid\Config\SiteConfig as ParsoidSiteConfig; use MediaWiki\Parser\Parsoid\HtmlTransformFactory; use MediaWiki\Permissions\Authority; use MediaWiki\Rest\RequestInterface; use MediaWiki\Rest\ResponseFactory; use MediaWiki\Rest\Router; use MediaWiki\Revision\RevisionLookup; use MediaWiki\Revision\RevisionRecord; use MediaWiki\Revision\RevisionRenderer; use MediaWiki\Title\TitleFactory; use MediaWiki\Title\TitleFormatter; use Wikimedia\Bcp47Code\Bcp47Code; use Wikimedia\Rdbms\IConnectionProvider; use Wikimedia\Stats\StatsFactory; /** * @since 1.40 Factory for helper objects designed for sharing logic between REST handlers that deal with page content. * @unstable during Parsoid migration */ class PageRestHelperFactory { /** * @internal */ public const CONSTRUCTOR_OPTIONS = PageContentHelper::CONSTRUCTOR_OPTIONS; private ServiceOptions $options; private RevisionLookup $revisionLookup; private RevisionRenderer $revisionRenderer; private TitleFormatter $titleFormatter; private PageLookup $pageLookup; private ParsoidOutputStash $parsoidOutputStash; private StatsdDataFactoryInterface $stats; private ParserOutputAccess $parserOutputAccess; private ParsoidSiteConfig $parsoidSiteConfig; private HtmlTransformFactory $htmlTransformFactory; private IContentHandlerFactory $contentHandlerFactory; private LanguageFactory $languageFactory; private RedirectStore $redirectStore; private LanguageConverterFactory $languageConverterFactory; private TitleFactory $titleFactory; private IConnectionProvider $connectionProvider; private ChangeTagsStore $changeTagStore; private StatsFactory $statsFactory; public function __construct( ServiceOptions $options, RevisionLookup $revisionLookup, RevisionRenderer $revisionRenderer, TitleFormatter $titleFormatter, PageLookup $pageLookup, ParsoidOutputStash $parsoidOutputStash, StatsdDataFactoryInterface $statsDataFactory, ParserOutputAccess $parserOutputAccess, ParsoidSiteConfig $parsoidSiteConfig, HtmlTransformFactory $htmlTransformFactory, IContentHandlerFactory $contentHandlerFactory, LanguageFactory $languageFactory, RedirectStore $redirectStore, LanguageConverterFactory $languageConverterFactory, TitleFactory $titleFactory, IConnectionProvider $connectionProvider, ChangeTagsStore $changeTagStore, StatsFactory $statsFactory ) { $this->options = $options; $this->revisionLookup = $revisionLookup; $this->revisionRenderer = $revisionRenderer; $this->titleFormatter = $titleFormatter; $this->pageLookup = $pageLookup; $this->parsoidOutputStash = $parsoidOutputStash; $this->stats = $statsDataFactory; $this->parserOutputAccess = $parserOutputAccess; $this->parsoidSiteConfig = $parsoidSiteConfig; $this->htmlTransformFactory = $htmlTransformFactory; $this->contentHandlerFactory = $contentHandlerFactory; $this->languageFactory = $languageFactory; $this->redirectStore = $redirectStore; $this->languageConverterFactory = $languageConverterFactory; $this->statsFactory = $statsFactory; $this->titleFactory = $titleFactory; $this->connectionProvider = $connectionProvider; $this->changeTagStore = $changeTagStore; } public function newRevisionContentHelper(): RevisionContentHelper { return new RevisionContentHelper( $this->options, $this->revisionLookup, $this->titleFormatter, $this->pageLookup, $this->titleFactory, $this->connectionProvider, $this->changeTagStore ); } public function newPageContentHelper(): PageContentHelper { return new PageContentHelper( $this->options, $this->revisionLookup, $this->titleFormatter, $this->pageLookup, $this->titleFactory, $this->connectionProvider, $this->changeTagStore ); } /** * Should we ignore page id mismatches between page and revision objects * in HTML/pagebundle requests? Mismatches arise because of page moves. * This is recommended only for handling calls to internal APIs. * @note Since 1.43, passing 'null' for $page has been deprecated. * @note Since 1.43, passing 'null' for $authority has been deprecated. * @note Since 1.43, passing $lenientRevHandling as the first parameter * has been deprecated. * @param bool|PageIdentity|null $page * If `false`, this argument is used as the value for $lenientRevHandling, * for backward-compatibility. * @param array $parameters * @param ?Authority $authority * @param int|RevisionRecord|null $revision * @param bool $lenientRevHandling */ public function newHtmlOutputRendererHelper( $page = null, array $parameters = [], ?Authority $authority = null, $revision = null, bool $lenientRevHandling = false ): HtmlOutputRendererHelper { if ( is_bool( $page ) ) { // Backward compatibility w/ pre-1.43 (deprecated) $lenientRevHandling = $page; $page = null; wfDeprecated( __METHOD__ . ' with boolean first parameter', '1.43' ); } if ( $page === null ) { wfDeprecated( __METHOD__ . ' with null $page', '1.43' ); } if ( $authority === null ) { wfDeprecated( __METHOD__ . ' with null $authority', '1.43' ); } return new HtmlOutputRendererHelper( $this->parsoidOutputStash, $this->statsFactory, $this->parserOutputAccess, $this->pageLookup, $this->revisionLookup, $this->revisionRenderer, $this->parsoidSiteConfig, $this->htmlTransformFactory, $this->contentHandlerFactory, $this->languageFactory, $page, $parameters, $authority, $revision, $lenientRevHandling ); } /** * @note Since 1.43, passing a null $page is deprecated. */ public function newHtmlMessageOutputHelper( ?PageIdentity $page = null ): HtmlMessageOutputHelper { if ( $page === null ) { wfDeprecated( __METHOD__ . ' with null $page', '1.43' ); } return new HtmlMessageOutputHelper( $page ); } public function newHtmlInputTransformHelper( $envOptions = [], ?PageIdentity $page = null, $body = null, array $parameters = [], ?RevisionRecord $originalRevision = null, ?Bcp47Code $pageLanguage = null ): HtmlInputTransformHelper { if ( $page === null || $body === null ) { wfDeprecated( __METHOD__ . ' without $page or $body' ); } return new HtmlInputTransformHelper( $this->statsFactory, $this->htmlTransformFactory, $this->parsoidOutputStash, $this->parserOutputAccess, $this->pageLookup, $this->revisionLookup, $envOptions, $page, $body ?? '', $parameters, $originalRevision, $pageLanguage ); } /** * @since 1.41 */ public function newPageRedirectHelper( ResponseFactory $responseFactory, Router $router, string $route, RequestInterface $request ): PageRedirectHelper { return new PageRedirectHelper( $this->redirectStore, $this->titleFormatter, $responseFactory, $router, $route, $request, $this->languageConverterFactory ); } } PK ! ?��� � Helper/PageRedirectHelper.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler\Helper; use MediaWiki\Languages\LanguageConverterFactory; use MediaWiki\Linker\LinkTarget; use MediaWiki\Page\PageIdentity; use MediaWiki\Page\PageReference; use MediaWiki\Page\RedirectStore; use MediaWiki\Rest\RequestInterface; use MediaWiki\Rest\Response; use MediaWiki\Rest\ResponseFactory; use MediaWiki\Rest\Router; use MediaWiki\Title\TitleFormatter; use MediaWiki\Title\TitleValue; /** * Helper class for handling page redirects, for use with REST Handlers that provide access * to resources bound to MediaWiki pages. * * @since 1.41 */ class PageRedirectHelper { private RedirectStore $redirectStore; private TitleFormatter $titleFormatter; private ResponseFactory $responseFactory; private Router $router; private string $path; private RequestInterface $request; private LanguageConverterFactory $languageConverterFactory; private bool $followWikiRedirects = false; private string $titleParamName = 'title'; private bool $useRelativeRedirects = true; public function __construct( RedirectStore $redirectStore, TitleFormatter $titleFormatter, ResponseFactory $responseFactory, Router $router, string $path, RequestInterface $request, LanguageConverterFactory $languageConverterFactory ) { $this->redirectStore = $redirectStore; $this->titleFormatter = $titleFormatter; $this->responseFactory = $responseFactory; $this->router = $router; $this->path = $path; $this->request = $request; $this->languageConverterFactory = $languageConverterFactory; } /** * @param bool $useRelativeRedirects */ public function setUseRelativeRedirects( bool $useRelativeRedirects ): void { $this->useRelativeRedirects = $useRelativeRedirects; } /** * @param bool $followWikiRedirects */ public function setFollowWikiRedirects( bool $followWikiRedirects ): void { $this->followWikiRedirects = $followWikiRedirects; } /** * Check for Page Normalization Redirects and create a Permanent Redirect Response * @param PageIdentity $page * @param ?string $titleAsRequested * @return Response|null */ public function createNormalizationRedirectResponseIfNeeded( PageIdentity $page, ?string $titleAsRequested ): ?Response { if ( $titleAsRequested === null ) { return null; } $normalizedTitle = $this->titleFormatter->getPrefixedDBkey( $page ); // Check for normalization redirects if ( $titleAsRequested !== $normalizedTitle ) { $redirectTargetUrl = $this->getTargetUrl( $normalizedTitle ); return $this->responseFactory->createPermanentRedirect( $redirectTargetUrl ); } return null; } /** * Check for Page Wiki Redirects and create a Temporary Redirect Response * @param PageIdentity $page * @return Response|null */ public function createWikiRedirectResponseIfNeeded( PageIdentity $page ): ?Response { $redirectTargetUrl = $this->getWikiRedirectTargetUrl( $page ); if ( $redirectTargetUrl === null ) { return null; } return $this->responseFactory->createTemporaryRedirect( $redirectTargetUrl ); } /** * @param PageIdentity $page * @return string|null */ public function getWikiRedirectTargetUrl( PageIdentity $page ): ?string { $redirectTarget = $this->redirectStore->getRedirectTarget( $page ); if ( $redirectTarget === null ) { return null; } if ( $redirectTarget->isSameLinkAs( TitleValue::newFromPage( $page ) ) ) { // This can happen if the current page is virtual file description // page backed by a remote file repo (T353688). return null; } return $this->getTargetUrl( $redirectTarget ); } /** * Check if a page with a variant title exists and create a Temporary Redirect Response * if needed. * * @param PageIdentity $page * @param string|null $titleAsRequested * * @return Response|null */ private function createVariantRedirectResponseIfNeeded( PageIdentity $page, ?string $titleAsRequested ): ?Response { if ( $page->exists() ) { // If the page exists, there is no need to generate a redirect. return null; } $redirectTargetUrl = $this->getVariantRedirectTargetUrl( $page, $titleAsRequested ); if ( $redirectTargetUrl === null ) { return null; } return $this->responseFactory->createTemporaryRedirect( $redirectTargetUrl ); } /** * @param PageIdentity $page * @param string $titleAsRequested * * @return string|null */ private function getVariantRedirectTargetUrl( PageIdentity $page, string $titleAsRequested ): ?string { $variantPage = null; if ( $this->hasVariants() ) { $variantPage = $this->findVariantPage( $titleAsRequested, $page ); } if ( !$variantPage ) { return null; } return $this->getTargetUrl( $variantPage ); } /** * @param string|LinkTarget|PageReference $title * @return string The target to use in the Location header. Will be relative, * unless setUseRelativeRedirects( false ) was called. */ public function getTargetUrl( $title ): string { if ( !is_string( $title ) ) { $title = $this->titleFormatter->getPrefixedDBkey( $title ); } $pathParams = [ $this->titleParamName => $title ]; if ( $this->useRelativeRedirects ) { return $this->router->getRoutePath( $this->path, $pathParams, $this->request->getQueryParams() ); } else { return $this->router->getRouteUrl( $this->path, $pathParams, $this->request->getQueryParams() ); } } /** * Use this function for endpoints that check for both * normalizations and wiki redirects. * * @param PageIdentity $page * @param string|null $titleAsRequested * @return Response|null */ public function createRedirectResponseIfNeeded( PageIdentity $page, ?string $titleAsRequested ): ?Response { if ( $titleAsRequested !== null ) { $normalizationRedirectResponse = $this->createNormalizationRedirectResponseIfNeeded( $page, $titleAsRequested ); if ( $normalizationRedirectResponse !== null ) { return $normalizationRedirectResponse; } } if ( $this->followWikiRedirects ) { $variantRedirectResponse = $this->createVariantRedirectResponseIfNeeded( $page, $titleAsRequested ); if ( $variantRedirectResponse !== null ) { return $variantRedirectResponse; } $wikiRedirectResponse = $this->createWikiRedirectResponseIfNeeded( $page ); if ( $wikiRedirectResponse !== null ) { return $wikiRedirectResponse; } } return null; } private function hasVariants(): bool { return $this->languageConverterFactory->getLanguageConverter()->hasVariants(); } /** * @param string $titleAsRequested * @param PageReference $page * * @return ?PageReference */ private function findVariantPage( string $titleAsRequested, PageReference $page ): ?PageReference { $originalPage = $page; $languageConverter = $this->languageConverterFactory->getLanguageConverter(); // @phan-suppress-next-line PhanTypeMismatchArgumentSuperType $languageConverter->findVariantLink( $titleAsRequested, $page, true ); if ( $page === $originalPage ) { // No variant link found, $page was not updated. return null; } return $page; } } PK ! 4��Mx Mx # Helper/HtmlOutputRendererHelper.phpnu �Iw�� <?php /** * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along * with this program; if not, write to the Free Software Foundation, Inc., * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. * http://www.gnu.org/copyleft/gpl.html * * @file */ namespace MediaWiki\Rest\Handler\Helper; use HttpError; use InvalidArgumentException; use MediaWiki\Content\Content; use MediaWiki\Content\IContentHandlerFactory; use MediaWiki\Edit\ParsoidOutputStash; use MediaWiki\Edit\ParsoidRenderID; use MediaWiki\Edit\SelserContext; use MediaWiki\Language\LanguageCode; use MediaWiki\Languages\LanguageFactory; use MediaWiki\Logger\LoggerFactory; use MediaWiki\MainConfigNames; use MediaWiki\Page\PageIdentity; use MediaWiki\Page\PageLookup; use MediaWiki\Page\PageRecord; use MediaWiki\Page\ParserOutputAccess; use MediaWiki\Parser\ParserOptions; use MediaWiki\Parser\ParserOutput; use MediaWiki\Parser\Parsoid\Config\SiteConfig as ParsoidSiteConfig; use MediaWiki\Parser\Parsoid\HtmlTransformFactory; use MediaWiki\Parser\Parsoid\PageBundleParserOutputConverter; use MediaWiki\Permissions\Authority; use MediaWiki\Rest\Handler; use MediaWiki\Rest\HttpException; use MediaWiki\Rest\LocalizedHttpException; use MediaWiki\Rest\ResponseInterface; use MediaWiki\Revision\MutableRevisionRecord; use MediaWiki\Revision\RevisionAccessException; use MediaWiki\Revision\RevisionLookup; use MediaWiki\Revision\RevisionRecord; use MediaWiki\Revision\RevisionRenderer; use MediaWiki\Revision\SlotRecord; use MediaWiki\Status\Status; use MediaWiki\Title\Title; use MWUnknownContentModelException; use Wikimedia\Assert\Assert; use Wikimedia\Bcp47Code\Bcp47Code; use Wikimedia\Bcp47Code\Bcp47CodeValue; use Wikimedia\Message\MessageValue; use Wikimedia\ParamValidator\ParamValidator; use Wikimedia\Parsoid\Core\ClientError; use Wikimedia\Parsoid\Core\PageBundle; use Wikimedia\Parsoid\Core\ResourceLimitExceededException; use Wikimedia\Parsoid\DOM\Element; use Wikimedia\Parsoid\Parsoid; use Wikimedia\Parsoid\Utils\ContentUtils; use Wikimedia\Parsoid\Utils\DOMCompat; use Wikimedia\Parsoid\Utils\DOMUtils; use Wikimedia\Parsoid\Utils\WTUtils; use Wikimedia\Stats\StatsFactory; /** * Helper for getting output of a given wikitext page rendered by parsoid. * * @since 1.36 * * @unstable Pending consolidation of the Parsoid extension with core code. */ class HtmlOutputRendererHelper implements HtmlOutputHelper { use RestAuthorizeTrait; use RestStatusTrait; /** * @internal */ public const CONSTRUCTOR_OPTIONS = [ MainConfigNames::ParsoidCacheConfig ]; private const OUTPUT_FLAVORS = [ 'view', 'stash', 'fragment', 'edit' ]; /** @var PageIdentity|null */ private $page = null; /** @var RevisionRecord|int|null */ private $revisionOrId = null; /** @var Bcp47Code|null */ private $pageLanguage = null; /** @var ?string One of the flavors from OUTPUT_FLAVORS */ private $flavor = null; /** @var bool */ private $stash = false; /** @var Authority */ private $authority; /** @var ParserOutput */ private $parserOutput; /** @var ParserOutput */ private $processedParserOutput; /** @var ?Bcp47Code */ private $sourceLanguage = null; /** @var ?Bcp47Code */ private $targetLanguage = null; /** * Should we ignore mismatches between $page and the page that $revision belongs to? * Usually happens because of page moves. This should be set to true only for internal API calls. */ private bool $lenientRevHandling = false; /** * Flags to be passed as $options to ParserOutputAccess::getParserOutput, * to control parser cache access. * * @var int Use ParserOutputAccess::OPT_* */ private $parserOutputAccessOptions = 0; /** * @see the $options parameter on Parsoid::wikitext2html * @var array */ private $parsoidOptions = []; /** * Whether the result can be cached in the parser cache and the web cache. * Set to false when bespoke options are set. * * @var bool */ private $isCacheable = true; private ParsoidOutputStash $parsoidOutputStash; private StatsFactory $statsFactory; private ParserOutputAccess $parserOutputAccess; private PageLookup $pageLookup; private RevisionLookup $revisionLookup; private RevisionRenderer $revisionRenderer; private ParsoidSiteConfig $parsoidSiteConfig; private HtmlTransformFactory $htmlTransformFactory; private IContentHandlerFactory $contentHandlerFactory; private LanguageFactory $languageFactory; /** * @param ParsoidOutputStash $parsoidOutputStash * @param StatsFactory $statsFactory * @param ParserOutputAccess $parserOutputAccess * @param PageLookup $pageLookup * @param RevisionLookup $revisionLookup * @param RevisionRenderer $revisionRenderer * @param ParsoidSiteConfig $parsoidSiteConfig * @param HtmlTransformFactory $htmlTransformFactory * @param IContentHandlerFactory $contentHandlerFactory * @param LanguageFactory $languageFactory * @param PageIdentity|null $page * @param array $parameters * @param Authority|null $authority * @param RevisionRecord|int|null $revision * @param bool $lenientRevHandling Should we ignore mismatches between * $page and the page that $revision belongs to? Usually happens * because of page moves. This should be set to true only for * internal API calls. * @note Since 1.43, setting $page and $authority arguments to null * has been deprecated. */ public function __construct( ParsoidOutputStash $parsoidOutputStash, StatsFactory $statsFactory, ParserOutputAccess $parserOutputAccess, PageLookup $pageLookup, RevisionLookup $revisionLookup, RevisionRenderer $revisionRenderer, ParsoidSiteConfig $parsoidSiteConfig, HtmlTransformFactory $htmlTransformFactory, IContentHandlerFactory $contentHandlerFactory, LanguageFactory $languageFactory, ?PageIdentity $page = null, array $parameters = [], ?Authority $authority = null, $revision = null, bool $lenientRevHandling = false ) { $this->parsoidOutputStash = $parsoidOutputStash; $this->statsFactory = $statsFactory; $this->parserOutputAccess = $parserOutputAccess; $this->pageLookup = $pageLookup; $this->revisionLookup = $revisionLookup; $this->revisionRenderer = $revisionRenderer; $this->parsoidSiteConfig = $parsoidSiteConfig; $this->htmlTransformFactory = $htmlTransformFactory; $this->contentHandlerFactory = $contentHandlerFactory; $this->languageFactory = $languageFactory; $this->lenientRevHandling = $lenientRevHandling; if ( $page === null || $authority === null ) { // Constructing without $page and $authority parameters // is deprecated since 1.43. wfDeprecated( __METHOD__ . ' without $page or $authority', '1.43' ); } else { $this->initInternal( $page, $parameters, $authority, $revision ); } } /** * Sets the given flavor to use for Wikitext -> HTML transformations. * * Flavors may influence parser options, parsoid options, and DOM transformations. * They will be reflected by the ETag returned by getETag(). * * @note This method should not be called if stashing mode is enabled. * @see setStashingEnabled * @see getFlavor() * * @param string $flavor * * @return void */ public function setFlavor( string $flavor ): void { if ( !in_array( $flavor, self::OUTPUT_FLAVORS ) ) { throw new InvalidArgumentException( 'Invalid flavor supplied' ); } if ( $this->stash ) { // XXX: throw? $flavor = 'stash'; } $this->flavor = $flavor; } /** * Returns the flavor of HTML that will be generated. * @see setFlavor() * @return string */ public function getFlavor(): string { return $this->flavor; } /** * Set the desired Parsoid profile version for the output. * The actual output version is selected to be compatible with the one given here, * per the rules of semantic versioning. * * @note Will disable caching if the effective output version is different from the default. * * @param string $version * * @throws HttpException If the given version is not supported (status 406) */ public function setOutputProfileVersion( $version ) { $outputContentVersion = Parsoid::resolveContentVersion( $version ); if ( !$outputContentVersion ) { throw new LocalizedHttpException( new MessageValue( "rest-unsupported-profile-version", [ $version ] ), 406 ); } // Only set the option if the value isn't the default! if ( $outputContentVersion !== Parsoid::defaultHTMLVersion() ) { throw new LocalizedHttpException( new MessageValue( "rest-unsupported-profile-version", [ $version ] ), 406 ); // TODO: (T347426) At some later point, we may reintroduce support for // non-default content versions as part of work on the content // negotiation protocol. // // // See Parsoid::wikitext2html // $this->parsoidOptions['outputContentVersion'] = $outputContentVersion; // $this->isCacheable = false; } } /** * Determine whether stashing should be applied. * * @param bool $stash * * @return void */ public function setStashingEnabled( bool $stash ): void { $this->stash = $stash; if ( $stash ) { $this->setFlavor( 'stash' ); } elseif ( $this->flavor === 'stash' ) { $this->setFlavor( 'view' ); } } /** * Set the revision to render. * * This can take a fake RevisionRecord when rendering for previews * or when switching the editor from source mode to visual mode. * * In that case, $revisionOrId->getId() must return 0 to indicate * that the ParserCache should be bypassed. Stashing may still apply. * * @param RevisionRecord|int $revisionOrId */ public function setRevision( $revisionOrId ): void { Assert::parameterType( [ RevisionRecord::class, 'integer' ], $revisionOrId, '$revision' ); if ( is_int( $revisionOrId ) && $revisionOrId <= 0 ) { throw new HttpError( 400, "Bad revision ID: $revisionOrId" ); } $this->revisionOrId = $revisionOrId; if ( $this->getRevisionId() === null ) { // If we have a RevisionRecord but no revision ID, we are dealing with a fake // revision used for editor previews or mode switches. The wikitext is coming // from the request, not the database, so the result is not cacheable for re-use // by others (though it can be stashed for use by the same client). $this->isCacheable = false; } } /** * Set the content to render. Useful when rendering for previews * or when switching the editor from source mode to visual mode. * * This will create a fake revision for rendering, the revision ID will be 0. * * @see setRevision * @see setContentSource * * @param Content $content */ public function setContent( Content $content ): void { $rev = new MutableRevisionRecord( $this->page ); $rev->setId( 0 ); $rev->setPageId( $this->page->getId() ); $rev->setContent( SlotRecord::MAIN, $content ); $this->setRevision( $rev ); } /** * Set the content to render. Useful when rendering for previews * or when switching the editor from source mode to visual mode. * * This will create a fake revision for rendering. The revision ID will be 0. * * @param string $source The source data, e.g. wikitext * @param string $model The content model indicating how to interpret $source, e.g. CONTENT_MODEL_WIKITEXT * * @see setRevision * @see setContent */ public function setContentSource( string $source, string $model ): void { try { $handler = $this->contentHandlerFactory->getContentHandler( $model ); $content = $handler->unserializeContent( $source ); $this->setContent( $content ); } catch ( MWUnknownContentModelException $ex ) { throw new LocalizedHttpException( new MessageValue( "rest-bad-content-model", [ $model ] ), 400 ); } } /** * This is equivalent to 'pageLanguageOverride' in PageConfigFactory * For example, when clients call the REST API with the 'content-language' * header to affect language variant conversion. * * @param Bcp47Code|string $pageLanguage the page language, as a Bcp47Code * or a BCP-47 string. */ public function setPageLanguage( $pageLanguage ): void { if ( is_string( $pageLanguage ) ) { $pageLanguage = new Bcp47CodeValue( $pageLanguage ); } $this->pageLanguage = $pageLanguage; } /** * Initializes the helper with the given parameters like the page * we're dealing with, parameters gotten from the request inputs, * and the revision if any is available. * * @param PageIdentity $page * @param array $parameters * @param Authority $authority * @param RevisionRecord|int|null $revision * @deprecated since 1.43, use parameters in constructor instead */ public function init( PageIdentity $page, array $parameters, Authority $authority, $revision = null ) { wfDeprecated( __METHOD__, '1.43' ); $this->initInternal( $page, $parameters, $authority, $revision ); } private function initInternal( PageIdentity $page, array $parameters, Authority $authority, $revision = null ) { $this->page = $page; $this->authority = $authority; $this->stash = $parameters['stash'] ?? false; if ( $revision !== null ) { $this->setRevision( $revision ); } if ( $this->stash ) { $this->setFlavor( 'stash' ); } else { $this->setFlavor( $parameters['flavor'] ?? 'view' ); } } /** * @inheritDoc */ public function setVariantConversionLanguage( $targetLanguage, $sourceLanguage = null ): void { if ( is_string( $targetLanguage ) ) { $targetLanguage = $this->getAcceptedTargetLanguage( $targetLanguage ); $targetLanguage = LanguageCode::normalizeNonstandardCodeAndWarn( $targetLanguage ); } if ( is_string( $sourceLanguage ) ) { $sourceLanguage = LanguageCode::normalizeNonstandardCodeAndWarn( $sourceLanguage ); } $this->targetLanguage = $targetLanguage; $this->sourceLanguage = $sourceLanguage; } /** * Get a target language from an accept header */ private function getAcceptedTargetLanguage( string $targetLanguage ): string { // We could try to identify the most desirable language here, // following the rules for Accept-Language headers in RFC9100. // For now, just take the first language code. if ( preg_match( '/^\s*([-\w]+)/', $targetLanguage, $m ) ) { return $m[1]; } else { // "undetermined" per RFC5646 return 'und'; } } /** * @inheritDoc */ public function getHtml(): ParserOutput { if ( $this->processedParserOutput ) { return $this->processedParserOutput; } $parserOutput = $this->getParserOutput(); if ( $this->stash ) { $this->authorizeWriteOrThrow( $this->authority, 'stashbasehtml', $this->page ); $isFakeRevision = $this->getRevisionId() === null; $parsoidStashKey = ParsoidRenderID::newFromParserOutput( $parserOutput ); $stashSuccess = $this->parsoidOutputStash->set( $parsoidStashKey, new SelserContext( PageBundleParserOutputConverter::pageBundleFromParserOutput( $parserOutput ), $parsoidStashKey->getRevisionID(), $isFakeRevision ? $this->revisionOrId->getContent( SlotRecord::MAIN ) : null ) ); if ( !$stashSuccess ) { $this->statsFactory->getCounter( 'htmloutputrendererhelper_stash_total' ) ->setLabel( 'status', 'fail' ) ->copyToStatsdAt( 'htmloutputrendererhelper.stash.fail' ) ->increment(); $errorData = [ 'parsoid-stash-key' => $parsoidStashKey ]; LoggerFactory::getInstance( 'HtmlOutputRendererHelper' )->error( "Parsoid stash failure", $errorData ); throw new LocalizedHttpException( MessageValue::new( 'rest-html-stash-failure' ), 500, $errorData ); } $this->statsFactory->getCounter( 'htmloutputrendererhelper_stash_total' ) ->setLabel( 'status', 'save' ) ->copyToStatsdAt( 'htmloutputrendererhelper.stash.save' ) ->increment(); } if ( $this->flavor === 'edit' ) { $pb = $this->getPageBundle(); // Inject data-parsoid and data-mw attributes. // XXX: Would be nice if we had a DOM handy. $doc = DOMUtils::parseHTML( $parserOutput->getRawText() ); PageBundle::apply( $doc, $pb ); $parserOutput->setRawText( ContentUtils::toXML( $doc ) ); } // Check if variant conversion has to be performed // NOTE: Variant conversion is performed on the fly, and kept outside the stash. if ( $this->targetLanguage ) { $languageVariantConverter = $this->htmlTransformFactory->getLanguageVariantConverter( $this->page ); $parserOutput = $languageVariantConverter->convertParserOutputVariant( $parserOutput, $this->targetLanguage, $this->sourceLanguage ); } $this->processedParserOutput = $parserOutput; return $parserOutput; } /** * @inheritDoc */ public function getETag( string $suffix = '' ): ?string { $parserOutput = $this->getParserOutput(); $renderID = ParsoidRenderID::newFromParserOutput( $parserOutput )->getKey(); if ( $suffix !== '' ) { $eTag = "$renderID/{$this->flavor}/$suffix"; } else { $eTag = "$renderID/{$this->flavor}"; } if ( $this->targetLanguage ) { $eTag .= "+lang:{$this->targetLanguage->toBcp47Code()}"; } return "\"{$eTag}\""; } /** * @inheritDoc */ public function getLastModified(): ?string { return $this->getParserOutput()->getCacheTime(); } /** * @inheritDoc */ public static function getParamSettings(): array { return [ 'stash' => [ Handler::PARAM_SOURCE => 'query', ParamValidator::PARAM_TYPE => 'boolean', ParamValidator::PARAM_DEFAULT => false, ParamValidator::PARAM_REQUIRED => false, ], 'flavor' => [ Handler::PARAM_SOURCE => 'query', ParamValidator::PARAM_TYPE => self::OUTPUT_FLAVORS, ParamValidator::PARAM_DEFAULT => 'view', ParamValidator::PARAM_REQUIRED => false, ], ]; } private function getDefaultPageLanguage( ParserOptions $options ): Bcp47Code { // NOTE: keep in sync with Parser::getTargetLanguage! // XXX: Inject a TitleFactory just for this?! We need a better way to determine the page language... $title = Title::castFromPageIdentity( $this->page ); if ( $options->getInterfaceMessage() ) { return $options->getUserLangObj(); } return $title->getPageLanguage(); } /** * @return ParserOutput */ private function getParserOutput(): ParserOutput { if ( !$this->parserOutput ) { $parserOptions = ParserOptions::newFromAnon(); $parserOptions->setRenderReason( __METHOD__ ); $defaultLanguage = $this->getDefaultPageLanguage( $parserOptions ); if ( $this->pageLanguage && $this->pageLanguage->toBcp47Code() !== $defaultLanguage->toBcp47Code() ) { $languageObj = $this->languageFactory->getLanguage( $this->pageLanguage ); $parserOptions->setTargetLanguage( $languageObj ); // Ensure target language splits the parser cache, when // non-default; targetLangauge is not in // ParserOptions::$cacheVaryingOptionsHash for the legacy // parser. $parserOptions->addExtraKey( 'target=' . $languageObj->getCode() ); } try { $status = $this->getParserOutputInternal( $parserOptions ); } catch ( RevisionAccessException $e ) { throw new LocalizedHttpException( MessageValue::new( 'rest-nonexistent-title' ), 404, [ 'reason' => $e->getMessage() ] ); } if ( !$status->isOK() ) { if ( $status->hasMessage( 'parsoid-client-error' ) ) { $this->throwExceptionForStatus( $status, 'rest-html-backend-error', 400 ); } elseif ( $status->hasMessage( 'parsoid-resource-limit-exceeded' ) ) { $this->throwExceptionForStatus( $status, 'rest-resource-limit-exceeded', 413 ); } elseif ( $status->hasMessage( 'missing-revision-permission' ) ) { $this->throwExceptionForStatus( $status, 'rest-permission-denied-revision', 403 ); } elseif ( $status->hasMessage( 'parsoid-revision-access' ) ) { $this->throwExceptionForStatus( $status, 'rest-specified-revision-unavailable', 404 ); } else { $this->logStatusError( $status, 'Parsoid backend error', 'HtmlOutputRendererHelper' ); $this->throwExceptionForStatus( $status, 'rest-html-backend-error', 500 ); } } $this->parserOutput = $status->getValue(); } Assert::invariant( $this->parserOutput->getRenderId() !== null, "no render id" ); return $this->parserOutput; } /** * The content language of the HTML output after parsing. * * @return Bcp47Code The language, as a BCP-47 code */ public function getHtmlOutputContentLanguage(): Bcp47Code { $contentLanguage = $this->getHtml()->getLanguage(); // This shouldn't happen, but don't crash if it does: if ( !$contentLanguage ) { if ( $this->pageLanguage ) { LoggerFactory::getInstance( 'HtmlOutputRendererHelper' )->warning( "ParserOutput does not specify a language" ); $contentLanguage = $this->pageLanguage; } else { LoggerFactory::getInstance( 'HtmlOutputRendererHelper' )->warning( "ParserOutput does not specify a language and no page language set in helper." ); $title = Title::newFromPageIdentity( $this->page ); $contentLanguage = $title->getPageLanguage(); } } return $contentLanguage; } /** * @inheritDoc */ public function putHeaders( ResponseInterface $response, bool $forHtml = true ): void { if ( $forHtml ) { // For HTML, we want to set the Content-Language. For JSON, we probably don't. $response->setHeader( 'Content-Language', $this->getHtmlOutputContentLanguage()->toBcp47Code() ); $pb = $this->getPageBundle(); ParsoidFormatHelper::setContentType( $response, ParsoidFormatHelper::FORMAT_HTML, $pb->version ); } if ( $this->targetLanguage ) { $response->addHeader( 'Vary', 'Accept-Language' ); } // XXX: if Parsoid returns Vary headers, set them here?! if ( !$this->isCacheable ) { $response->setHeader( 'Cache-Control', 'private,no-cache,s-maxage=0' ); } // TODO: cache control for stable HTML? See ContentHelper::setCacheControl if ( $this->getRevisionId() ) { $response->setHeader( 'Content-Revision-Id', (string)$this->getRevisionId() ); } } /** * Returns the rendered HTML as a PageBundle object. * * @return PageBundle */ public function getPageBundle(): PageBundle { // XXX: converting between PageBundle and ParserOutput is inefficient! $parserOutput = $this->getParserOutput(); $pb = PageBundleParserOutputConverter::pageBundleFromParserOutput( $parserOutput ); // Check if variant conversion has to be performed // NOTE: Variant conversion is performed on the fly, and kept outside the stash. if ( $this->targetLanguage ) { $languageVariantConverter = $this->htmlTransformFactory->getLanguageVariantConverter( $this->page ); $pb = $languageVariantConverter->convertPageBundleVariant( $pb, $this->targetLanguage, $this->sourceLanguage ); } return $pb; } /** * Returns the ID of the revision that is being rendered. * * This will return 0 if no revision has been specified, so the current revision * will be rendered. * * This wil return null if RevisionRecord has been set but that RevisionRecord * does not have a revision ID, e.g. when rendering a preview. * * @return ?int */ public function getRevisionId(): ?int { if ( !$this->revisionOrId ) { // If we don't have a revision set, or it's 0, we are rendering the current revision. return 0; } if ( is_object( $this->revisionOrId ) ) { // NOTE: return null even if getId() gave us 0 return $this->revisionOrId->getId() ?: null; } // It's a revision ID, just return it return (int)$this->revisionOrId; } /** * Strip Parsoid's section wrappers * * TODO: Should we move this to Parsoid's ContentUtils class? * There already is a stripUnnecessaryWrappersAndSyntheticNodes but * it targets html2wt and does a lot more than just section unwrapping. * * @param Element $elt */ private function stripParsoidSectionTags( Element $elt ): void { $n = $elt->firstChild; while ( $n ) { $next = $n->nextSibling; if ( $n instanceof Element ) { // Recurse into subtree before stripping this $this->stripParsoidSectionTags( $n ); // Strip <section> tags and synthetic extended-annotation-region wrappers if ( WTUtils::isParsoidSectionTag( $n ) ) { $parent = $n->parentNode; // Help out phan '@phan-var Element $parent'; DOMUtils::migrateChildren( $n, $parent, $n ); $parent->removeChild( $n ); } } $n = $next; } } /** * @param ParserOptions $parserOptions * * @return Status */ private function getParserOutputInternal( ParserOptions $parserOptions ): Status { // NOTE: ParserOutputAccess::getParserOutput() should be used for revisions // that come from the database. Either this revision is null to indicate // the current revision or the revision must have an ID. // If we have a revision and the ID is 0 or null, then it's a fake revision // representing a preview. $parsoidOptions = $this->parsoidOptions; // NOTE: VisualEditor would set this flavor when transforming from Wikitext to HTML // for the purpose of editing when doing parsefragment (in body only mode). if ( $this->flavor === 'fragment' || $this->getRevisionId() === null ) { $this->isCacheable = false; } // TODO: Decide whether we want to allow stale content for speed for the // 'view' flavor. In that case, we would want to use PoolCounterWork, // either directly or through ParserOutputAccess. $flags = $this->parserOutputAccessOptions; // Resolve revision $page = $this->page; $revision = $this->revisionOrId; if ( $page === null ) { throw new RevisionAccessException( "No page" ); } // NOTE: If we have a RevisionRecord already and this is // not cacheable, just use it, there is no need to // resolve $page to a PageRecord (and it may not be // possible if the page doesn't exist). if ( $this->isCacheable || !$revision instanceof RevisionRecord ) { if ( !$page instanceof PageRecord ) { $name = "$page"; $page = $this->pageLookup->getPageByReference( $page ); if ( !$page ) { throw new RevisionAccessException( 'Page {name} not found', [ 'name' => $name ] ); } } $revision ??= $page->getLatest(); if ( is_int( $revision ) ) { $revId = $revision; $revision = $this->revisionLookup->getRevisionById( $revId ); if ( !$revision ) { throw new RevisionAccessException( 'Revision {revId} not found', [ 'revId' => $revId ] ); } } if ( $page->getId() !== $revision->getPageId() ) { if ( $this->lenientRevHandling ) { $page = $this->pageLookup->getPageById( $revision->getPageId() ); if ( !$page ) { // This should ideally never trigger! throw new \RuntimeException( "Unexpected NULL page for pageid " . $revision->getPageId() . " from revision " . $revision->getId() ); } // Don't cache this! $flags |= ParserOutputAccess::OPT_NO_UPDATE_CACHE; } else { throw new RevisionAccessException( 'Revision {revId} does not belong to page {name}', [ 'name' => $page->getDBkey(), 'revId' => $revision->getId() ] ); } } } $mainSlot = $revision->getSlot( SlotRecord::MAIN ); $contentModel = $mainSlot->getModel(); if ( $this->parsoidSiteConfig->supportsContentModel( $contentModel ) ) { $parserOptions->setUseParsoid(); } if ( $this->isCacheable ) { // phan can't tell that we must have used the block above to // resolve $page to a PageRecord if we've made it to this block. '@phan-var PageRecord $page'; try { $status = $this->parserOutputAccess->getParserOutput( $page, $parserOptions, $revision, $flags ); } catch ( ClientError $e ) { $status = Status::newFatal( 'parsoid-client-error', $e->getMessage() ); } catch ( ResourceLimitExceededException $e ) { $status = Status::newFatal( 'parsoid-resource-limit-exceeded', $e->getMessage() ); } Assert::invariant( $status->isOK() ? $status->getValue()->getRenderId() !== null : true, "no render id" ); } else { $status = $this->parseUncacheable( $page, $parserOptions, $revision, $this->lenientRevHandling ); // @phan-suppress-next-line PhanSuspiciousValueComparison if ( $status->isOK() && $this->flavor === 'fragment' ) { // Unwrap sections and return body_only content // NOTE: This introduces an extra html -> dom -> html roundtrip // This will get addressed once HtmlHolder work is complete $parserOutput = $status->getValue(); $body = DOMCompat::getBody( DOMUtils::parseHTML( $parserOutput->getRawText() ) ); if ( $body ) { $this->stripParsoidSectionTags( $body ); $parserOutput->setText( DOMCompat::getInnerHTML( $body ) ); } } Assert::invariant( $status->isOK() ? $status->getValue()->getRenderId() !== null : true, "no render id" ); } return $status; } // See ParserOutputAccess::renderRevision() -- but of course this method // bypasses any caching. private function parseUncacheable( PageIdentity $page, ParserOptions $parserOptions, RevisionRecord $revision, bool $lenientRevHandling = false ): Status { // Enforce caller expectation $revId = $revision->getId(); if ( $revId !== 0 && $revId !== null ) { return Status::newFatal( 'parsoid-revision-access', "parseUncacheable should not be called for a real revision" ); } try { $renderedRev = $this->revisionRenderer->getRenderedRevision( $revision, $parserOptions, // ParserOutputAccess uses 'null' for the authority and // 'audience' => RevisionRecord::RAW, presumably because // the access checks are already handled by the // RestAuthorizeTrait $this->authority, [ 'audience' => RevisionRecord::RAW ] ); if ( $renderedRev === null ) { return Status::newFatal( 'parsoid-revision-access' ); } $parserOutput = $renderedRev->getRevisionParserOutput(); // Ensure this isn't accidentally cached $parserOutput->updateCacheExpiry( 0 ); return Status::newGood( $parserOutput ); } catch ( ClientError $e ) { return Status::newFatal( 'parsoid-client-error', $e->getMessage() ); } catch ( ResourceLimitExceededException $e ) { return Status::newFatal( 'parsoid-resource-limit-exceeded', $e->getMessage() ); } } } PK ! �.h� Helper/RestAuthorizeTrait.phpnu �Iw�� <?php namespace MediaWiki\Rest\Handler\Helper; use MediaWiki\Page\PageIdentity; use MediaWiki\Permissions\Authority; use MediaWiki\Permissions\PermissionStatus; use MediaWiki\Rest\HttpException; use Wikimedia\Message\MessageValue; trait RestAuthorizeTrait { use RestStatusTrait; /** * Authorize an action * * @see Authroity::authorizeWrite * @throws HttpException */ private function authorizeActionOrThrow( Authority $authority, string $action ): void { $status = PermissionStatus::newEmpty(); if ( !$authority->authorizeAction( $action, $status ) ) { $this->handleStatus( $status ); } } /** * Authorize a read operation * * @see Authroity::authorizeWrite * @throws HttpException */ private function authorizeReadOrThrow( Authority $authority, string $action, PageIdentity $target ): void { $status = PermissionStatus::newEmpty(); if ( !$authority->authorizeRead( $action, $target, $status ) ) { $this->handleStatus( $status ); } } /** * Authorize a write operation * * @see Authroity::authorizeWrite * @throws HttpException */ private function authorizeWriteOrThrow( Authority $authority, string $action, PageIdentity $target ): void { $status = PermissionStatus::newEmpty(); if ( !$authority->authorizeWrite( $action, $target, $status ) ) { $this->handleStatus( $status ); } } /** * Throw an exception if the status contains an error. * * @throws HttpException * @return never */ private function handleStatus( PermissionStatus $status ): void { // The permission name should always be set, but don't explode if it isn't. $permission = $status->getPermission() ?: '(unknown)'; if ( $status->isRateLimitExceeded() ) { $this->throwExceptionForStatus( $status, MessageValue::new( 'rest-rate-limit-exceeded', [ $permission ] ), 429 // See https://www.rfc-editor.org/rfc/rfc6585#section-4 ); } $this->throwExceptionForStatus( $status, MessageValue::new( 'rest-permission-error', [ $permission ] ), 403 ); } } PK ! �| "