Code Coverage |
||||||||||
Classes and Traits |
Functions and Methods |
Lines |
||||||||
Total | |
0.00% |
0 / 1 |
|
66.67% |
4 / 6 |
CRAP | |
96.19% |
101 / 105 |
Client | |
0.00% |
0 / 1 |
|
66.67% |
4 / 6 |
27 | |
96.19% |
101 / 105 |
__construct | |
100.00% |
1 / 1 |
1 | |
100.00% |
5 / 5 |
|||
setLogger | |
0.00% |
0 / 1 |
2 | |
0.00% |
0 / 2 |
|||
call | |
100.00% |
1 / 1 |
5 | |
100.00% |
33 / 33 |
|||
forwardLog | |
100.00% |
1 / 1 |
2 | |
100.00% |
6 / 6 |
|||
sendRequest | |
0.00% |
0 / 1 |
16 | |
96.36% |
53 / 55 |
|||
computeHmac | |
100.00% |
1 / 1 |
2 | |
100.00% |
4 / 4 |
<?php | |
namespace Shellbox; | |
use GuzzleHttp\Psr7\MultipartStream; | |
use GuzzleHttp\Psr7\Request; | |
use Psr\Http\Client\ClientInterface; | |
use Psr\Http\Message\StreamInterface; | |
use Psr\Http\Message\UriInterface; | |
use Psr\Log\LoggerInterface; | |
use Psr\Log\NullLogger; | |
use ReflectionClass; | |
use Shellbox\Command\OutputFile; | |
use Shellbox\Command\OutputGlob; | |
use Shellbox\Multipart\MultipartReader; | |
use Shellbox\Multipart\MultipartUtils; | |
use Shellbox\RPC\RpcClient; | |
/** | |
* A generic client which executes actions on the Shellbox server | |
*/ | |
class Client implements RPCClient { | |
/** @var ClientInterface */ | |
private $httpClient; | |
/** @var UriInterface */ | |
private $uri; | |
/** @var string */ | |
private $key; | |
/** @var LoggerInterface */ | |
private $logger; | |
/** | |
* @param ClientInterface $httpClient An object which requests an HTTP resource. | |
* It is permissible to throw an exception for propagation back to the | |
* caller. However, a successfully received response with a status code | |
* of >=400 should ideally be returned to Shellbox as a ResponseInterface, | |
* so that Shellbox can parse and rethrow its own error messages. With Guzzle | |
* this could be achieved by passing setting RequestOptions::HTTP_ERROR option | |
* to false when creating the client. | |
* | |
* @param UriInterface $uri The base URI of the server | |
* @param string $key The key for HMAC authentication | |
*/ | |
public function __construct( ClientInterface $httpClient, UriInterface $uri, $key ) { | |
$this->httpClient = $httpClient; | |
$this->uri = $uri; | |
$this->key = $key; | |
$this->logger = new NullLogger; | |
} | |
/** | |
* @param LoggerInterface $logger | |
*/ | |
public function setLogger( LoggerInterface $logger ) { | |
$this->logger = $logger; | |
} | |
public function call( $routeName, $functionName, $params = [], $options = [] ) { | |
$sources = $options['sources'] ?? []; | |
$binary = !empty( $options['binary'] ); | |
foreach ( $options['classes'] ?? [] as $class ) { | |
$rc = new ReflectionClass( $class ); | |
$sources[] = $rc->getFileName(); | |
} | |
$sources = array_unique( $sources ); | |
$parts = []; | |
$remoteSourceNames = []; | |
foreach ( $sources as $i => $source ) { | |
$stream = FileUtils::openInputFileStream( $source ); | |
$remoteSourceName = $i . '_' . basename( $source ); | |
$parts[] = [ | |
'name' => $remoteSourceName, | |
'headers' => [ | |
'Content-Type' => 'application/x-php', | |
'Content-Disposition' => | |
"attachment; name=\"$remoteSourceName\"; filename=\"$remoteSourceName\"", | |
], | |
'contents' => $stream | |
]; | |
$remoteSourceNames[] = $remoteSourceName; | |
} | |
$inputData = [ | |
'action' => 'call', | |
'functionName' => $functionName, | |
'sources' => $remoteSourceNames, | |
'binary' => $binary | |
]; | |
if ( $binary ) { | |
foreach ( $params as $i => $param ) { | |
$parts[] = [ | |
'name' => "param$i", | |
'contents' => (string)$param | |
]; | |
} | |
} else { | |
$inputData['params'] = $params; | |
} | |
$parts[] = [ | |
'name' => 'json-data', | |
'headers' => [ | |
'Content-Type' => 'application/json', | |
'Content-Disposition' => 'json-data', | |
], | |
'contents' => Shellbox::jsonEncode( $inputData ) | |
]; | |
$outputData = $this->sendRequest( "call/$routeName", $parts ); | |
$this->forwardLog( $outputData['log'] ); | |
return $outputData['returnValue']; | |
} | |
/** | |
* Forward log entries which came to the server to the client's logger. | |
* | |
* @param array $entries | |
*/ | |
private function forwardLog( $entries ) { | |
foreach ( $entries as $entry ) { | |
$this->logger->log( | |
$entry['level'], | |
$entry['message'], | |
$entry['context'] | |
); | |
} | |
} | |
/** | |
* Send an arbitrary request to the server. | |
* | |
* @param string $path The URL path relative to the server's base URL | |
* @param array $parts An array of multipart parts to send, in the format | |
* specified by MultipartStream. Each part is an associative array | |
* which for our purposes may contain: | |
* - "name": The part name. Required but ignored when there is a | |
* Content-Disposition header. | |
* - "contents": Here always a StreamInterface or string | |
* - "headers": An associative array of part headers. | |
* @param OutputFile[] $outputFiles Output files. The objects will have | |
* their contents populated with data received from the server. | |
* @param OutputGlob[] $outputGlobs Output globs. The objects will have | |
* be populated with data received from the server. | |
* @return array An associative array of output data | |
* @throws ShellboxError | |
*/ | |
public function sendRequest( $path, $parts, $outputFiles = [], $outputGlobs = [] ) { | |
$boundary = Shellbox::getUniqueString(); | |
$bodyStream = new MultipartStream( $parts, $boundary ); | |
$hmac = $this->computeHmac( $bodyStream ); | |
$bodyStream->rewind(); | |
$request = new Request( | |
'POST', | |
$this->uri->withPath( $this->uri->getPath() . '/' . $path ), | |
[ | |
'Content-Type' => "multipart/mixed; boundary=\"$boundary\"", | |
'Authorization' => "sha256 $hmac" | |
], | |
$bodyStream | |
); | |
$response = $this->httpClient->sendRequest( $request ); | |
$contentType = $response->getHeaderLine( 'Content-Type' ); | |
if ( $response->getStatusCode() !== 200 ) { | |
if ( $contentType === 'application/json' ) { | |
$data = Shellbox::jsonDecode( $response->getBody()->getContents() ); | |
if ( isset( $data['message'] ) && isset( $data['log'] ) ) { | |
$this->forwardLog( $data['log'] ); | |
throw new ShellboxError( 'Shellbox server error: ' . $data['message'] ); | |
} | |
} | |
throw new ShellboxError( "Shellbox server returned status code " . | |
$response->getStatusCode() ); | |
} | |
$boundary = MultipartUtils::extractBoundary( $contentType ); | |
if ( $boundary === false ) { | |
throw new ShellboxError( "Shellbox server returned incorrect Content-Type" ); | |
} | |
$multipartReader = new MultipartReader( $response->getBody(), $boundary ); | |
$multipartReader->readPreamble(); | |
$data = []; | |
$outputStrings = []; | |
$partIndex = 0; | |
// phpcs:ignore MediaWiki.ControlStructures.AssignmentInControlStructures | |
while ( ( $headers = $multipartReader->readPartHeaders() ) !== false ) { | |
if ( !isset( $headers['content-disposition'] ) ) { | |
throw new ShellboxError( "Part #$partIndex has no Content-Disposition" ); | |
} | |
$disposition = $headers['content-disposition']; | |
if ( $disposition['type'] === 'json-data' ) { | |
$data = $multipartReader->readPartAsJson( $headers ); | |
} elseif ( $disposition['type'] === 'form-data' ) { | |
if ( !isset( $disposition['name'] ) ) { | |
throw new ShellboxError( "Part #$partIndex has no name" ); | |
} | |
$outputStrings[$disposition['name']] = $multipartReader->readPartAsString(); | |
} elseif ( $disposition['type'] === 'attachment' ) { | |
$name = $disposition['name'] ?? ''; | |
if ( isset( $outputFiles[$name] ) ) { | |
$outputFiles[$name]->readFromMultipart( $multipartReader ); | |
} else { | |
$found = false; | |
foreach ( $outputGlobs as $glob ) { | |
if ( $glob->isMatch( $name ) ) { | |
$found = true; | |
$instance = $glob->getInstance( $name ); | |
$instance->readFromMultipart( $multipartReader ); | |
break; | |
} | |
} | |
if ( !$found ) { | |
throw new ShellboxError( "Server returned an unexpected file \"$name\"" ); | |
} | |
} | |
} else { | |
throw new ShellboxError( "Unknown content disposition type" ); | |
} | |
$partIndex++; | |
} | |
$multipartReader->readEpilogue(); | |
return $data + $outputStrings; | |
} | |
/** | |
* Read all data from a stream and return its HMAC. | |
* | |
* @param StreamInterface $stream | |
* @return string | |
*/ | |
private function computeHmac( StreamInterface $stream ) { | |
$hashContext = hash_init( 'sha256', HASH_HMAC, $this->key ); | |
while ( !$stream->eof() ) { | |
hash_update( $hashContext, $stream->read( 8192 ) ); | |
} | |
return hash_final( $hashContext ); | |
} | |
} |