Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.54% covered (success)
97.54%
119 / 122
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Client
97.54% covered (success)
97.54%
119 / 122
71.43% covered (warning)
71.43%
5 / 7
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 call
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
1 / 1
5
 forwardLog
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 sendRequest
96.67% covered (success)
96.67%
58 / 60
0.00% covered (danger)
0.00%
0 / 1
18
 computeHmac
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 areUrlFilesAllowed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Shellbox;
4
5use GuzzleHttp\Psr7\MultipartStream;
6use GuzzleHttp\Psr7\Request;
7use Psr\Http\Client\ClientInterface;
8use Psr\Http\Message\StreamInterface;
9use Psr\Http\Message\UriInterface;
10use Psr\Log\LoggerInterface;
11use Psr\Log\NullLogger;
12use ReflectionClass;
13use Shellbox\Command\OutputFile;
14use Shellbox\Command\OutputFileWithContents;
15use Shellbox\Command\OutputGlob;
16use Shellbox\Multipart\MultipartReader;
17use Shellbox\Multipart\MultipartUtils;
18use Shellbox\RPC\RpcClient;
19
20/**
21 * A generic client which executes actions on the Shellbox server
22 */
23class Client implements RPCClient {
24    /** @var ClientInterface */
25    private $httpClient;
26    /** @var UriInterface */
27    private $uri;
28    /** @var string */
29    private $key;
30    /** @var LoggerInterface */
31    private $logger;
32    /** @var bool */
33    private $allowUrlFiles;
34
35    /**
36     * @param ClientInterface $httpClient An object which requests an HTTP resource.
37     *   It is permissible to throw an exception for propagation back to the
38     *   caller. However, a successfully received response with a status code
39     *   of >=400 should ideally be returned to Shellbox as a ResponseInterface,
40     *   so that Shellbox can parse and rethrow its own error messages. With Guzzle
41     *   this could be achieved by passing setting RequestOptions::HTTP_ERROR option
42     *   to false when creating the client.
43     *
44     * @param UriInterface $uri The base URI of the server
45     * @param string $key The key for HMAC authentication
46     * @param array $options An associative array of options, which may contain:
47     *   - allowUrlFiles: Set this to true to allow input files to be downloaded,
48     *     and output files to be uploaded, on the server side. If this is set,
49     *     the server configuration variable allowUrlFiles must also be set to true.
50     */
51    public function __construct(
52        ClientInterface $httpClient,
53        UriInterface $uri,
54        string $key,
55        array $options = []
56    ) {
57        $this->httpClient = $httpClient;
58        $this->uri = $uri;
59        $this->key = $key;
60        $this->logger = new NullLogger;
61        $this->allowUrlFiles = $options['allowUrlFiles'] ?? false;
62    }
63
64    /**
65     * @param LoggerInterface $logger
66     */
67    public function setLogger( LoggerInterface $logger ) {
68        $this->logger = $logger;
69    }
70
71    public function call( $routeName, $functionName, $params = [], $options = [] ) {
72        $sources = $options['sources'] ?? [];
73        $binary = !empty( $options['binary'] );
74        foreach ( $options['classes'] ?? [] as $class ) {
75            $rc = new ReflectionClass( $class );
76            $sources[] = $rc->getFileName();
77        }
78        $sources = array_unique( $sources );
79        $parts = [];
80        $remoteSourceNames = [];
81        foreach ( $sources as $i => $source ) {
82            $stream = FileUtils::openInputFileStream( $source );
83            $remoteSourceName = $i . '_' . basename( $source );
84            $parts[] = [
85                'name' => $remoteSourceName,
86                'headers' => [
87                    'Content-Type' => 'application/x-php',
88                    'Content-Disposition' =>
89                        "attachment; name=\"$remoteSourceName\"; filename=\"$remoteSourceName\"",
90                ],
91                'contents' => $stream
92            ];
93            $remoteSourceNames[] = $remoteSourceName;
94        }
95        $inputData = [
96            'action' => 'call',
97            'functionName' => $functionName,
98            'sources' => $remoteSourceNames,
99            'binary' => $binary
100        ];
101        if ( $binary ) {
102            foreach ( $params as $i => $param ) {
103                $parts[] = [
104                    'name' => "param$i",
105                    'contents' => (string)$param
106                ];
107            }
108        } else {
109            $inputData['params'] = $params;
110        }
111
112        $parts[] = [
113            'name' => 'json-data',
114            'headers' => [
115                'Content-Type' => 'application/json',
116                'Content-Disposition' => 'json-data',
117            ],
118            'contents' => Shellbox::jsonEncode( $inputData )
119        ];
120        $outputData = $this->sendRequest( "call/$routeName", $parts );
121        $this->forwardLog( $outputData['log'] );
122        return $outputData['returnValue'];
123    }
124
125    /**
126     * Forward log entries which came to the server to the client's logger.
127     *
128     * @param array $entries
129     */
130    private function forwardLog( $entries ) {
131        foreach ( $entries as $entry ) {
132            $this->logger->log(
133                $entry['level'],
134                $entry['message'],
135                $entry['context']
136            );
137        }
138    }
139
140    /**
141     * Send an arbitrary request to the server.
142     *
143     * @param string $path The URL path relative to the server's base URL
144     * @param array $parts An array of multipart parts to send, in the format
145     *   specified by MultipartStream. Each part is an associative array
146     *   which for our purposes may contain:
147     *     - "name": The part name. Required but ignored when there is a
148     *       Content-Disposition header.
149     *     - "contents": Here always a StreamInterface or string
150     *     - "headers": An associative array of part headers.
151     * @param OutputFile[] $outputFiles Output files. The objects will have
152     *   their contents populated with data received from the server.
153     * @param OutputGlob[] $outputGlobs Output globs. The objects will
154     *   be populated with data received from the server.
155     * @return array An associative array of output data
156     * @throws ShellboxError
157     */
158    public function sendRequest( $path, $parts, $outputFiles = [], $outputGlobs = [] ) {
159        $boundary = Shellbox::getUniqueString();
160        $bodyStream = new MultipartStream( $parts, $boundary );
161
162        $hmac = $this->computeHmac( $bodyStream );
163        $bodyStream->rewind();
164
165        $request = new Request(
166            'POST',
167            $this->uri->withPath( $this->uri->getPath() . '/' . $path ),
168            [
169                'Content-Type' => "multipart/mixed; boundary=\"$boundary\"",
170                'Authorization' => "sha256 $hmac"
171            ],
172            $bodyStream
173        );
174
175        $response = $this->httpClient->sendRequest( $request );
176        $contentType = $response->getHeaderLine( 'Content-Type' );
177        if ( $response->getStatusCode() !== 200 ) {
178            if ( $contentType === 'application/json' ) {
179                $data = Shellbox::jsonDecode( $response->getBody()->getContents() );
180                if ( isset( $data['message'] ) && isset( $data['log'] ) ) {
181                    $this->forwardLog( $data['log'] );
182                    throw new ShellboxError( 'Shellbox server error: ' . $data['message'] );
183                }
184            }
185            throw new ShellboxError( "Shellbox server returned status code " .
186                $response->getStatusCode() );
187        }
188
189        $boundary = MultipartUtils::extractBoundary( $contentType );
190        if ( $boundary === false ) {
191            throw new ShellboxError( "Shellbox server returned incorrect Content-Type" );
192        }
193        $multipartReader = new MultipartReader( $response->getBody(), $boundary );
194        $multipartReader->readPreamble();
195
196        $data = [];
197        $outputStrings = [];
198        $partIndex = 0;
199        // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
200        while ( ( $headers = $multipartReader->readPartHeaders() ) !== false ) {
201            if ( !isset( $headers['content-disposition'] ) ) {
202                throw new ShellboxError( "Part #$partIndex has no Content-Disposition" );
203            }
204            $disposition = $headers['content-disposition'];
205            if ( $disposition['type'] === 'json-data' ) {
206                $data = $multipartReader->readPartAsJson( $headers );
207            } elseif ( $disposition['type'] === 'form-data' ) {
208                if ( !isset( $disposition['name'] ) ) {
209                    throw new ShellboxError( "Part #$partIndex has no name" );
210                }
211                $outputStrings[$disposition['name']] = $multipartReader->readPartAsString();
212            } elseif ( $disposition['type'] === 'attachment' ) {
213                $name = $disposition['name'] ?? '';
214                if ( isset( $outputFiles[$name] )
215                    && $outputFiles[$name] instanceof OutputFileWithContents
216                ) {
217                    $outputFiles[$name]->readFromMultipart( $multipartReader );
218                } else {
219                    $found = false;
220                    foreach ( $outputGlobs as $glob ) {
221                        if ( $glob->isMatch( $name ) ) {
222                            $instance = $glob->getOutputFile( $name );
223                            if ( $instance instanceof OutputFileWithContents ) {
224                                $instance->readFromMultipart( $multipartReader );
225                                $found = true;
226                                break;
227                            }
228                        }
229                    }
230                    if ( !$found ) {
231                        throw new ShellboxError( "Server returned an unexpected file \"$name\"" );
232                    }
233                }
234            } else {
235                throw new ShellboxError( "Unknown content disposition type" );
236            }
237            $partIndex++;
238        }
239        $multipartReader->readEpilogue();
240
241        return $data + $outputStrings;
242    }
243
244    /**
245     * Read all data from a stream and return its HMAC.
246     *
247     * @param StreamInterface $stream
248     * @return string
249     */
250    private function computeHmac( StreamInterface $stream ) {
251        $hashContext = hash_init( 'sha256', HASH_HMAC, $this->key );
252        while ( !$stream->eof() ) {
253            hash_update( $hashContext, $stream->read( 8192 ) );
254        }
255        return hash_final( $hashContext );
256    }
257
258    /**
259     * Whether the client can download input files and upload output files
260     * specified with BoxedCommand::inputFileFromUrl and the like.
261     *
262     * @since 4.1.0
263     * @return bool
264     */
265    public function areUrlFilesAllowed() {
266        return $this->allowUrlFiles;
267    }
268
269}