| <?php |
| /** |
| * MediaWiki math extension |
| * |
| * @copyright 2002-2015 various MediaWiki contributors |
| * @license GPL-2.0-or-later |
| */ |
| |
| namespace MediaWiki\Extension\Math; |
| |
| use Exception; |
| use MediaWiki\Logger\LoggerFactory; |
| use MediaWiki\MediaWikiServices; |
| use Psr\Log\LoggerInterface; |
| use stdClass; |
| use Wikimedia\Http\MultiHttpClient; |
| |
| class MathRestbaseInterface { |
| /** @var string|false */ |
| private $hash = false; |
| /** @var string */ |
| private $tex; |
| /** @var string */ |
| private $type; |
| private ?string $checkedTex = null; |
| /** @var bool|null */ |
| private $success; |
| /** @var array */ |
| private $identifiers; |
| /** @var stdClass|null */ |
| private $error; |
| /** @var string|null */ |
| private $mathoidStyle; |
| /** @var string|null */ |
| private $mml; |
| /** @var array */ |
| private $warnings = []; |
| /** @var bool is there a request to purge the existing mathematical content */ |
| private $purge = false; |
| /** @var LoggerInterface */ |
| private $logger; |
| |
| /** |
| * @param string $tex |
| * @param string $type |
| */ |
| public function __construct( $tex = '', $type = 'tex' ) { |
| $this->tex = $tex; |
| $this->type = $type; |
| $this->logger = LoggerFactory::getInstance( 'Math' ); |
| } |
| |
| /** |
| * Bundles several requests for fetching MathML. |
| * Does not send requests, if the input TeX is invalid. |
| * @param MathRestbaseInterface[] $rbis |
| * @param MultiHttpClient $multiHttpClient |
| */ |
| private static function batchGetMathML( array $rbis, MultiHttpClient $multiHttpClient ) { |
| $requests = []; |
| $skips = []; |
| $i = 0; |
| foreach ( $rbis as $rbi ) { |
| /** @var MathRestbaseInterface $rbi */ |
| if ( $rbi->getSuccess() ) { |
| $requests[] = $rbi->getContentRequest( 'mml' ); |
| } else { |
| $skips[] = $i; |
| } |
| $i++; |
| } |
| $results = $multiHttpClient->runMulti( $requests ); |
| $lenRbis = count( $rbis ); |
| $j = 0; |
| for ( $i = 0; $i < $lenRbis; $i++ ) { |
| if ( !in_array( $i, $skips, true ) ) { |
| /** @var MathRestbaseInterface $rbi */ |
| $rbi = $rbis[$i]; |
| try { |
| $response = $results[ $j ][ 'response' ]; |
| $mml = $rbi->evaluateContentResponse( 'mml', $response, $requests[$j] ); |
| $rbi->mml = $mml; |
| } catch ( MathRestbaseException ) { |
| // FIXME: Why is this silenced? Doesn't this leave invalid data behind? |
| } |
| $j++; |
| } |
| } |
| } |
| |
| /** |
| * Lets this instance know if this is a purge request. When set to true, |
| * it will cause the object to issue the first content request with a |
| * 'Cache-Control: no-cache' header to prompt the regeneration of the |
| * renders. |
| * |
| * @param bool $purge whether this is a purge request |
| */ |
| public function setPurge( $purge = true ) { |
| $this->purge = $purge; |
| } |
| |
| /** |
| * @return string MathML code |
| * @throws MathRestbaseException |
| */ |
| public function getMathML() { |
| if ( !$this->mml ) { |
| $this->mml = $this->getContent( 'mml' ); |
| } |
| return $this->mml; |
| } |
| |
| /** |
| * @param string $type |
| * @return string |
| * @throws MathRestbaseException |
| */ |
| private function getContent( $type ) { |
| $request = $this->getContentRequest( $type ); |
| $multiHttpClient = $this->getMultiHttpClient(); |
| $response = $multiHttpClient->run( $request ); |
| return $this->evaluateContentResponse( $type, $response, $request ); |
| } |
| |
| /** |
| * @throws InvalidTeXException |
| */ |
| private function calculateHash() { |
| if ( !$this->hash ) { |
| if ( !$this->checkTeX() ) { |
| throw new InvalidTeXException( "TeX input is invalid." ); |
| } |
| } |
| } |
| |
| /** @return bool */ |
| public function checkTeX() { |
| $request = $this->getCheckRequest(); |
| $requestResult = $this->executeRestbaseCheckRequest( $request ); |
| return $this->evaluateRestbaseCheckResponse( $requestResult ); |
| } |
| |
| /** |
| * Performs a service request |
| * Generates error messages on failure |
| * @see MediaWiki\Http\HttpRequestFactory::post() |
| * |
| * @param array $request |
| * @return array |
| */ |
| private function executeRestbaseCheckRequest( $request ) { |
| $multiHttpClient = $this->getMultiHttpClient(); |
| $response = $multiHttpClient->run( $request ); |
| if ( $response['code'] !== 200 ) { |
| $this->logger->info( 'Tex check failed', [ |
| 'post' => $request['body'], |
| 'error' => $response['error'], |
| 'urlparams' => $request['url'] |
| ] ); |
| } |
| return $response; |
| } |
| |
| /** |
| * @param MathRestbaseInterface[] $rbis |
| */ |
| public static function batchEvaluate( array $rbis ) { |
| if ( count( $rbis ) == 0 ) { |
| return; |
| } |
| $requests = []; |
| /** @var MathRestbaseInterface $first */ |
| $first = $rbis[0]; |
| $multiHttpClient = $first->getMultiHttpClient(); |
| foreach ( $rbis as $rbi ) { |
| /** @var MathRestbaseInterface $rbi */ |
| $requests[] = $rbi->getCheckRequest(); |
| } |
| $results = $multiHttpClient->runMulti( $requests ); |
| $i = 0; |
| foreach ( $results as $requestResponse ) { |
| /** @var MathRestbaseInterface $rbi */ |
| $rbi = $rbis[$i++]; |
| try { |
| $response = $requestResponse[ 'response' ]; |
| $rbi->evaluateRestbaseCheckResponse( $response ); |
| } catch ( Exception ) { |
| } |
| } |
| self::batchGetMathML( $rbis, $multiHttpClient ); |
| } |
| |
| private function getMultiHttpClient(): MultiHttpClient { |
| global $wgMathConcurrentReqs; |
| $multiHttpClient = MediaWikiServices::getInstance()->getHttpRequestFactory()->createMultiClient( |
| [ 'maxConnsPerHost' => $wgMathConcurrentReqs ] ); |
| |
| return $multiHttpClient; |
| } |
| |
| /** |
| * The URL is generated according to the following logic: |
| * |
| * Case A: <code>$internal = false</code>, which means one needs a URL that is accessible from |
| * outside: |
| * |
| * --> Use <code>$wgMathFullRestbaseURL</code>. It must always be configured. |
| * |
| * Case B: <code>$internal = true</code>, which means one needs to access content from Restbase |
| * which does not need to be accessible from outside: |
| * |
| * --> Use the mount point when it is available and <code>$wgMathUseInternalRestbasePath = |
| * true</code>. If not, use <code>$wgMathFullRestbaseURL</code>. |
| * |
| * @param string $path |
| * @param bool|true $internal |
| * @return string |
| */ |
| public function getUrl( $path, $internal = true ) { |
| global $wgMathInternalRestbaseURL, $wgMathFullRestbaseURL; |
| if ( $internal ) { |
| return "{$wgMathInternalRestbaseURL}v1/$path"; |
| } else { |
| return "{$wgMathFullRestbaseURL}v1/$path"; |
| } |
| } |
| |
| /** |
| * @return string |
| * @throws MathRestbaseException |
| */ |
| public function getSvg() { |
| return $this->getContent( 'svg' ); |
| } |
| |
| /** |
| * Generates a unique TeX string, renders it and gets it via a public URL. |
| * The method fails, if the public URL does not point to the same server, who did render |
| * the unique TeX input in the first place. |
| * @return bool |
| */ |
| private function checkConfig() { |
| // Generates a TeX string that probably has not been generated before |
| $uniqueTeX = uniqid( 't=', true ); |
| $testInterface = new MathRestbaseInterface( $uniqueTeX ); |
| if ( !$testInterface->checkTeX() ) { |
| $this->logger->warning( 'Config check failed, since test expression was considered as invalid.', |
| [ 'uniqueTeX' => $uniqueTeX ] ); |
| return false; |
| } |
| |
| try { |
| $url = $testInterface->getFullSvgUrl(); |
| $req = MediaWikiServices::getInstance()->getHttpRequestFactory()->create( $url, [], __METHOD__ ); |
| $status = $req->execute(); |
| if ( $status->isOK() ) { |
| return true; |
| } |
| |
| $this->logger->warning( 'Config check failed, due to an invalid response code.', |
| [ 'responseCode' => $status ] ); |
| } catch ( Exception $e ) { |
| $this->logger->warning( 'Config check failed, due to an exception.', [ $e ] ); |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Gets a publicly accessible link to the generated SVG image. |
| * @return string |
| * @throws InvalidTeXException |
| */ |
| public function getFullSvgUrl() { |
| $this->calculateHash(); |
| return $this->getUrl( "media/math/render/svg/{$this->hash}", false ); |
| } |
| |
| public function getCheckedTex(): ?string { |
| return $this->checkedTex; |
| } |
| |
| public function getSuccess(): bool { |
| if ( $this->success === null ) { |
| $this->checkTeX(); |
| } |
| return $this->success; |
| } |
| |
| public function getIdentifiers(): ?array { |
| return $this->identifiers; |
| } |
| |
| public function getError(): ?stdClass { |
| return $this->error; |
| } |
| |
| public function getTex(): string { |
| return $this->tex; |
| } |
| |
| public function getType(): string { |
| return $this->type; |
| } |
| |
| private function setErrorMessage( string $msg ) { |
| $this->error = (object)[ 'error' => (object)[ 'message' => $msg ] ]; |
| } |
| |
| public function getWarnings(): array { |
| return $this->warnings; |
| } |
| |
| public function getCheckRequest(): array { |
| return [ |
| 'method' => 'POST', |
| 'body' => [ |
| 'type' => $this->type, |
| 'q' => $this->tex |
| ], |
| 'url' => $this->getUrl( "media/math/check/{$this->type}" ) |
| ]; |
| } |
| |
| public function evaluateRestbaseCheckResponse( array $response ): bool { |
| $json = json_decode( $response['body'] ); |
| if ( $response['code'] === 200 && |
| isset( $json->success ) && |
| isset( $json->checked ) && |
| isset( $json->identifiers ) ) { |
| $headers = $response['headers']; |
| $this->hash = $headers['x-resource-location']; |
| $this->success = $json->success; |
| $this->checkedTex = $json->checked; |
| $this->identifiers = $json->identifiers; |
| if ( isset( $json->warnings ) ) { |
| $this->warnings = $json->warnings; |
| } |
| return true; |
| } |
| if ( isset( $json->detail->success ) ) { |
| $this->success = $json->detail->success; |
| $this->error = $json->detail; |
| return false; |
| } |
| $this->success = false; |
| $this->setErrorMessage( 'Math extension cannot connect to Restbase.' ); |
| $this->logger->error( 'Received invalid response from restbase.', [ |
| 'body' => $response['body'], |
| 'code' => $response['code'] ] ); |
| return false; |
| } |
| |
| public function getMathoidStyle(): ?string { |
| return $this->mathoidStyle; |
| } |
| |
| /** |
| * @param string $type |
| * @return array |
| * @throws InvalidTeXException |
| */ |
| private function getContentRequest( $type ) { |
| $this->calculateHash(); |
| $request = [ |
| 'method' => 'GET', |
| 'url' => $this->getUrl( "media/math/render/$type/{$this->hash}" ) |
| ]; |
| if ( $this->purge ) { |
| $request['headers'] = [ |
| 'Cache-Control' => 'no-cache' |
| ]; |
| $this->purge = false; |
| } |
| return $request; |
| } |
| |
| /** |
| * @param string $type |
| * @param array $response |
| * @param array $request |
| * @return string |
| * @throws MathRestbaseException |
| */ |
| private function evaluateContentResponse( $type, array $response, array $request ) { |
| if ( $response['code'] === 200 ) { |
| if ( array_key_exists( 'x-mathoid-style', $response['headers'] ) ) { |
| $this->mathoidStyle = $response['headers']['x-mathoid-style']; |
| } |
| return $response['body']; |
| } |
| // Remove "convenience" duplicate keys put in place by MultiHttpClient |
| unset( $response[0], $response[1], $response[2], $response[3], $response[4] ); |
| $this->logger->error( 'Restbase math server problem', [ |
| 'urlparams' => $request['url'], |
| 'response' => [ 'code' => $response['code'], 'body' => $response['body'] ], |
| 'math_type' => $type, |
| 'tex' => $this->tex |
| ] ); |
| self::throwContentError( $type, $response['body'] ); |
| } |
| |
| /** |
| * @param string $type |
| * @param string $body |
| * @throws MathRestbaseException |
| * @return never |
| */ |
| public static function throwContentError( $type, $body ) { |
| $detail = 'Server problem.'; |
| $json = json_decode( $body ); |
| if ( isset( $json->detail ) ) { |
| if ( is_array( $json->detail ) ) { |
| $detail = $json->detail[0]; |
| } elseif ( is_string( $json->detail ) ) { |
| $detail = $json->detail; |
| } |
| } |
| throw new MathRestbaseException( "Cannot get $type. $detail" ); |
| } |
| } |