Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.54% |
119 / 122 |
|
71.43% |
5 / 7 |
CRAP | |
0.00% |
0 / 1 |
Client | |
97.54% |
119 / 122 |
|
71.43% |
5 / 7 |
30 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
call | |
100.00% |
45 / 45 |
|
100.00% |
1 / 1 |
5 | |||
forwardLog | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
sendRequest | |
96.67% |
58 / 60 |
|
0.00% |
0 / 1 |
18 | |||
computeHmac | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
areUrlFilesAllowed | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace Shellbox; |
4 | |
5 | use GuzzleHttp\Psr7\MultipartStream; |
6 | use GuzzleHttp\Psr7\Request; |
7 | use Psr\Http\Client\ClientInterface; |
8 | use Psr\Http\Message\StreamInterface; |
9 | use Psr\Http\Message\UriInterface; |
10 | use Psr\Log\LoggerInterface; |
11 | use Psr\Log\NullLogger; |
12 | use ReflectionClass; |
13 | use Shellbox\Command\OutputFile; |
14 | use Shellbox\Command\OutputFileWithContents; |
15 | use Shellbox\Command\OutputGlob; |
16 | use Shellbox\Multipart\MultipartReader; |
17 | use Shellbox\Multipart\MultipartUtils; |
18 | use Shellbox\RPC\RpcClient; |
19 | |
20 | /** |
21 | * A generic client which executes actions on the Shellbox server |
22 | */ |
23 | class 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 | } |