PluginProbe
Plausible Analytics / trunk
Plausible Analytics vtrunk
2.6.1 2.6.0 trunk 1.0.0 1.0.1 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 All 52 releases
plausible-analytics / src / Client / lib / Lib / GuzzleHttp / Handler / StreamHandler.php

StreamHandler.php in Plausible Analytics trunk, at src/Client/lib/Lib/GuzzleHttp/Handler/StreamHandler.php

616 lines 21.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Handler;
4
5 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\ConnectException;
6 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Exception\RequestException;
7 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise as P;
8 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\FulfilledPromise;
9 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Promise\PromiseInterface;
10 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Psr7;
11 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\TransferStats;
12 use Plausible\Analytics\WP\Client\Lib\GuzzleHttp\Utils;
13 use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\RequestInterface;
14 use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\ResponseInterface;
15 use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\StreamInterface;
16 use Plausible\Analytics\WP\Client\Lib\Psr\Http\Message\UriInterface;
17
18 /**
19 * HTTP handler that uses PHP's HTTP stream wrapper.
20 *
21 * @final
22 */
23 class StreamHandler
24 {
25 /**
26 * @var array
27 */
28 private $lastHeaders = [];
29
30 /**
31 * Sends an HTTP request.
32 *
33 * @param RequestInterface $request Request to send.
34 * @param array $options Request transfer options.
35 */
36 public function __invoke(RequestInterface $request, array $options): PromiseInterface
37 {
38 // Sleep if there is a delay specified.
39 if (isset($options['delay'])) {
40 \usleep($options['delay'] * 1000);
41 }
42
43 $startTime = isset($options['on_stats']) ? Utils::currentTime() : null;
44
45 try {
46 // Does not support the expect header.
47 $request = $request->withoutHeader('Expect');
48
49 // Append a content-length header if body size is zero to match
50 // cURL's behavior.
51 if (0 === $request->getBody()->getSize()) {
52 $request = $request->withHeader('Content-Length', '0');
53 }
54
55 return $this->createResponse(
56 $request,
57 $options,
58 $this->createStream($request, $options),
59 $startTime
60 );
61 } catch (\InvalidArgumentException $e) {
62 throw $e;
63 } catch (\Exception $e) {
64 // Determine if the error was a networking error.
65 $message = $e->getMessage();
66 // This list can probably get more comprehensive.
67 if (false !== \strpos($message, 'getaddrinfo') // DNS lookup failed
68 || false !== \strpos($message, 'Connection refused')
69 || false !== \strpos($message, "couldn't connect to host") // error on HHVM
70 || false !== \strpos($message, 'connection attempt failed')
71 ) {
72 $e = new ConnectException($e->getMessage(), $request, $e);
73 } else {
74 $e = RequestException::wrapException($request, $e);
75 }
76 $this->invokeStats($options, $request, $startTime, null, $e);
77
78 return P\Create::rejectionFor($e);
79 }
80 }
81
82 private function invokeStats(
83 array $options,
84 RequestInterface $request,
85 ?float $startTime,
86 ?ResponseInterface $response = null,
87 ?\Throwable $error = null
88 ): void {
89 if (isset($options['on_stats'])) {
90 $stats = new TransferStats($request, $response, Utils::currentTime() - $startTime, $error, []);
91 ($options['on_stats'])($stats);
92 }
93 }
94
95 /**
96 * @param resource $stream
97 */
98 private function createResponse(RequestInterface $request, array $options, $stream, ?float $startTime): PromiseInterface
99 {
100 $hdrs = $this->lastHeaders;
101 $this->lastHeaders = [];
102
103 try {
104 [$ver, $status, $reason, $headers] = HeaderProcessor::parseHeaders($hdrs);
105 } catch (\Exception $e) {
106 return P\Create::rejectionFor(
107 new RequestException('An error was encountered while creating the response', $request, null, $e)
108 );
109 }
110
111 [$stream, $headers] = $this->checkDecode($options, $headers, $stream);
112 $stream = Psr7\Utils::streamFor($stream);
113 $sink = $stream;
114
115 if (\strcasecmp('HEAD', $request->getMethod())) {
116 $sink = $this->createSink($stream, $options);
117 }
118
119 try {
120 $response = new Psr7\Response($status, $headers, $sink, $ver, $reason);
121 } catch (\Exception $e) {
122 return P\Create::rejectionFor(
123 new RequestException('An error was encountered while creating the response', $request, null, $e)
124 );
125 }
126
127 if (isset($options['on_headers'])) {
128 try {
129 $options['on_headers']($response);
130 } catch (\Exception $e) {
131 return P\Create::rejectionFor(
132 new RequestException('An error was encountered during the on_headers event', $request, $response, $e)
133 );
134 }
135 }
136
137 // Do not drain when the request is a HEAD request because they have
138 // no body.
139 if ($sink !== $stream) {
140 $this->drain($stream, $sink, $response->getHeaderLine('Content-Length'));
141 }
142
143 $this->invokeStats($options, $request, $startTime, $response, null);
144
145 return new FulfilledPromise($response);
146 }
147
148 private function createSink(StreamInterface $stream, array $options): StreamInterface
149 {
150 if (!empty($options['stream'])) {
151 return $stream;
152 }
153
154 $sink = $options['sink'] ?? Psr7\Utils::tryFopen('php://temp', 'r+');
155
156 return \is_string($sink) ? new Psr7\LazyOpenStream($sink, 'w+') : Psr7\Utils::streamFor($sink);
157 }
158
159 /**
160 * @param resource $stream
161 */
162 private function checkDecode(array $options, array $headers, $stream): array
163 {
164 // Automatically decode responses when instructed.
165 if (!empty($options['decode_content'])) {
166 $normalizedKeys = Utils::normalizeHeaderKeys($headers);
167 if (isset($normalizedKeys['content-encoding'])) {
168 $encoding = $headers[$normalizedKeys['content-encoding']];
169 if ($encoding[0] === 'gzip' || $encoding[0] === 'deflate') {
170 $stream = new Psr7\InflateStream(Psr7\Utils::streamFor($stream));
171 $headers['x-encoded-content-encoding'] = $headers[$normalizedKeys['content-encoding']];
172
173 // Remove content-encoding header
174 unset($headers[$normalizedKeys['content-encoding']]);
175
176 // Fix content-length header
177 if (isset($normalizedKeys['content-length'])) {
178 $headers['x-encoded-content-length'] = $headers[$normalizedKeys['content-length']];
179 $length = (int) $stream->getSize();
180 if ($length === 0) {
181 unset($headers[$normalizedKeys['content-length']]);
182 } else {
183 $headers[$normalizedKeys['content-length']] = [$length];
184 }
185 }
186 }
187 }
188 }
189
190 return [$stream, $headers];
191 }
192
193 /**
194 * Drains the source stream into the "sink" client option.
195 *
196 * @param string $contentLength Header specifying the amount of
197 * data to read.
198 *
199 * @throws \RuntimeException when the sink option is invalid.
200 */
201 private function drain(StreamInterface $source, StreamInterface $sink, string $contentLength): StreamInterface
202 {
203 // If a content-length header is provided, then stop reading once
204 // that number of bytes has been read. This can prevent infinitely
205 // reading from a stream when dealing with servers that do not honor
206 // Connection: Close headers.
207 Psr7\Utils::copyToStream(
208 $source,
209 $sink,
210 (\strlen($contentLength) > 0 && (int) $contentLength > 0) ? (int) $contentLength : -1
211 );
212
213 $sink->seek(0);
214 $source->close();
215
216 return $sink;
217 }
218
219 /**
220 * Create a resource and check to ensure it was created successfully
221 *
222 * @param callable $callback Callable that returns stream resource
223 *
224 * @return resource
225 *
226 * @throws \RuntimeException on error
227 */
228 private function createResource(callable $callback)
229 {
230 $errors = [];
231 \set_error_handler(static function ($_, $msg, $file, $line) use (&$errors): bool {
232 $errors[] = [
233 'message' => $msg,
234 'file' => $file,
235 'line' => $line,
236 ];
237
238 return true;
239 });
240
241 try {
242 $resource = $callback();
243 } finally {
244 \restore_error_handler();
245 }
246
247 if (!$resource) {
248 $message = 'Error creating resource: ';
249 foreach ($errors as $err) {
250 foreach ($err as $key => $value) {
251 $message .= "[$key] $value".\PHP_EOL;
252 }
253 }
254 throw new \RuntimeException(\trim($message));
255 }
256
257 return $resource;
258 }
259
260 /**
261 * @return resource
262 */
263 private function createStream(RequestInterface $request, array $options)
264 {
265 static $methods;
266 if (!$methods) {
267 $methods = \array_flip(\get_class_methods(__CLASS__));
268 }
269
270 if (!\in_array($request->getUri()->getScheme(), ['http', 'https'])) {
271 throw new RequestException(\sprintf("The scheme '%s' is not supported.", $request->getUri()->getScheme()), $request);
272 }
273
274 // HTTP/1.1 streams using the PHP stream wrapper require a
275 // Connection: close header
276 if ($request->getProtocolVersion() == '1.1'
277 && !$request->hasHeader('Connection')
278 ) {
279 $request = $request->withHeader('Connection', 'close');
280 }
281
282 // Ensure SSL is verified by default
283 if (!isset($options['verify'])) {
284 $options['verify'] = true;
285 }
286
287 $params = [];
288 $context = $this->getDefaultContext($request);
289
290 if (isset($options['on_headers']) && !\is_callable($options['on_headers'])) {
291 throw new \InvalidArgumentException('on_headers must be callable');
292 }
293
294 if (!empty($options)) {
295 foreach ($options as $key => $value) {
296 $method = "add_{$key}";
297 if (isset($methods[$method])) {
298 $this->{$method}($request, $context, $value, $params);
299 }
300 }
301 }
302
303 if (isset($options['stream_context'])) {
304 if (!\is_array($options['stream_context'])) {
305 throw new \InvalidArgumentException('stream_context must be an array');
306 }
307 $context = \array_replace_recursive($context, $options['stream_context']);
308 }
309
310 // Microsoft NTLM authentication only supported with curl handler
311 if (isset($options['auth'][2]) && 'ntlm' === $options['auth'][2]) {
312 throw new \InvalidArgumentException('Microsoft NTLM authentication only supported with curl handler');
313 }
314
315 $uri = $this->resolveHost($request, $options);
316
317 $contextResource = $this->createResource(
318 static function () use ($context, $params) {
319 return \stream_context_create($context, $params);
320 }
321 );
322
323 return $this->createResource(
324 function () use ($uri, &$http_response_header, $contextResource, $context, $options, $request) {
325 $resource = @\fopen((string) $uri, 'r', false, $contextResource);
326 $this->lastHeaders = $http_response_header ?? [];
327
328 if (false === $resource) {
329 throw new ConnectException(sprintf('Connection refused for URI %s', $uri), $request, null, $context);
330 }
331
332 if (isset($options['read_timeout'])) {
333 $readTimeout = $options['read_timeout'];
334 $sec = (int) $readTimeout;
335 $usec = ($readTimeout - $sec) * 100000;
336 \stream_set_timeout($resource, $sec, $usec);
337 }
338
339 return $resource;
340 }
341 );
342 }
343
344 private function resolveHost(RequestInterface $request, array $options): UriInterface
345 {
346 $uri = $request->getUri();
347
348 if (isset($options['force_ip_resolve']) && !\filter_var($uri->getHost(), \FILTER_VALIDATE_IP)) {
349 if ('v4' === $options['force_ip_resolve']) {
350 $records = \dns_get_record($uri->getHost(), \DNS_A);
351 if (false === $records || !isset($records[0]['ip'])) {
352 throw new ConnectException(\sprintf("Could not resolve IPv4 address for host '%s'", $uri->getHost()), $request);
353 }
354
355 return $uri->withHost($records[0]['ip']);
356 }
357 if ('v6' === $options['force_ip_resolve']) {
358 $records = \dns_get_record($uri->getHost(), \DNS_AAAA);
359 if (false === $records || !isset($records[0]['ipv6'])) {
360 throw new ConnectException(\sprintf("Could not resolve IPv6 address for host '%s'", $uri->getHost()), $request);
361 }
362
363 return $uri->withHost('['.$records[0]['ipv6'].']');
364 }
365 }
366
367 return $uri;
368 }
369
370 private function getDefaultContext(RequestInterface $request): array
371 {
372 $headers = '';
373 foreach ($request->getHeaders() as $name => $value) {
374 foreach ($value as $val) {
375 $headers .= "$name: $val\r\n";
376 }
377 }
378
379 $context = [
380 'http' => [
381 'method' => $request->getMethod(),
382 'header' => $headers,
383 'protocol_version' => $request->getProtocolVersion(),
384 'ignore_errors' => true,
385 'follow_location' => 0,
386 ],
387 'ssl' => [
388 'peer_name' => $request->getUri()->getHost(),
389 ],
390 ];
391
392 $body = (string) $request->getBody();
393
394 if ('' !== $body) {
395 $context['http']['content'] = $body;
396 // Prevent the HTTP handler from adding a Content-Type header.
397 if (!$request->hasHeader('Content-Type')) {
398 $context['http']['header'] .= "Content-Type:\r\n";
399 }
400 }
401
402 $context['http']['header'] = \rtrim($context['http']['header']);
403
404 return $context;
405 }
406
407 /**
408 * @param mixed $value as passed via Request transfer options.
409 */
410 private function add_proxy(RequestInterface $request, array &$options, $value, array &$params): void
411 {
412 $uri = null;
413
414 if (!\is_array($value)) {
415 $uri = $value;
416 } else {
417 $scheme = $request->getUri()->getScheme();
418 if (isset($value[$scheme])) {
419 if (!isset($value['no']) || !Utils::isHostInNoProxy($request->getUri()->getHost(), $value['no'])) {
420 $uri = $value[$scheme];
421 }
422 }
423 }
424
425 if (!$uri) {
426 return;
427 }
428
429 $parsed = $this->parse_proxy($uri);
430 $options['http']['proxy'] = $parsed['proxy'];
431
432 if ($parsed['auth']) {
433 if (!isset($options['http']['header'])) {
434 $options['http']['header'] = [];
435 }
436 $options['http']['header'] .= "\r\nProxy-Authorization: {$parsed['auth']}";
437 }
438 }
439
440 /**
441 * Parses the given proxy URL to make it compatible with the format PHP's stream context expects.
442 */
443 private function parse_proxy(string $url): array
444 {
445 $parsed = \parse_url($url);
446
447 if ($parsed !== false && isset($parsed['scheme']) && $parsed['scheme'] === 'http') {
448 if (isset($parsed['host']) && isset($parsed['port'])) {
449 $auth = null;
450 if (isset($parsed['user']) && isset($parsed['pass'])) {
451 $auth = \base64_encode("{$parsed['user']}:{$parsed['pass']}");
452 }
453
454 return [
455 'proxy' => "tcp://{$parsed['host']}:{$parsed['port']}",
456 'auth' => $auth ? "Basic {$auth}" : null,
457 ];
458 }
459 }
460
461 // Return proxy as-is.
462 return [
463 'proxy' => $url,
464 'auth' => null,
465 ];
466 }
467
468 /**
469 * @param mixed $value as passed via Request transfer options.
470 */
471 private function add_timeout(RequestInterface $request, array &$options, $value, array &$params): void
472 {
473 if ($value > 0) {
474 $options['http']['timeout'] = $value;
475 }
476 }
477
478 /**
479 * @param mixed $value as passed via Request transfer options.
480 */
481 private function add_crypto_method(RequestInterface $request, array &$options, $value, array &$params): void
482 {
483 if (
484 $value === \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT
485 || $value === \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT
486 || $value === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT
487 || (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') && $value === \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT)
488 ) {
489 $options['http']['crypto_method'] = $value;
490
491 return;
492 }
493
494 throw new \InvalidArgumentException('Invalid crypto_method request option: unknown version provided');
495 }
496
497 /**
498 * @param mixed $value as passed via Request transfer options.
499 */
500 private function add_verify(RequestInterface $request, array &$options, $value, array &$params): void
501 {
502 if ($value === false) {
503 $options['ssl']['verify_peer'] = false;
504 $options['ssl']['verify_peer_name'] = false;
505
506 return;
507 }
508
509 if (\is_string($value)) {
510 $options['ssl']['cafile'] = $value;
511 if (!\file_exists($value)) {
512 throw new \RuntimeException("SSL CA bundle not found: $value");
513 }
514 } elseif ($value !== true) {
515 throw new \InvalidArgumentException('Invalid verify request option');
516 }
517
518 $options['ssl']['verify_peer'] = true;
519 $options['ssl']['verify_peer_name'] = true;
520 $options['ssl']['allow_self_signed'] = false;
521 }
522
523 /**
524 * @param mixed $value as passed via Request transfer options.
525 */
526 private function add_cert(RequestInterface $request, array &$options, $value, array &$params): void
527 {
528 if (\is_array($value)) {
529 $options['ssl']['passphrase'] = $value[1];
530 $value = $value[0];
531 }
532
533 if (!\file_exists($value)) {
534 throw new \RuntimeException("SSL certificate not found: {$value}");
535 }
536
537 $options['ssl']['local_cert'] = $value;
538 }
539
540 /**
541 * @param mixed $value as passed via Request transfer options.
542 */
543 private function add_progress(RequestInterface $request, array &$options, $value, array &$params): void
544 {
545 self::addNotification(
546 $params,
547 static function ($code, $a, $b, $c, $transferred, $total) use ($value) {
548 if ($code == \STREAM_NOTIFY_PROGRESS) {
549 // The upload progress cannot be determined. Use 0 for cURL compatibility:
550 // https://curl.se/libcurl/c/CURLOPT_PROGRESSFUNCTION.html
551 $value($total, $transferred, 0, 0);
552 }
553 }
554 );
555 }
556
557 /**
558 * @param mixed $value as passed via Request transfer options.
559 */
560 private function add_debug(RequestInterface $request, array &$options, $value, array &$params): void
561 {
562 if ($value === false) {
563 return;
564 }
565
566 static $map = [
567 \STREAM_NOTIFY_CONNECT => 'CONNECT',
568 \STREAM_NOTIFY_AUTH_REQUIRED => 'AUTH_REQUIRED',
569 \STREAM_NOTIFY_AUTH_RESULT => 'AUTH_RESULT',
570 \STREAM_NOTIFY_MIME_TYPE_IS => 'MIME_TYPE_IS',
571 \STREAM_NOTIFY_FILE_SIZE_IS => 'FILE_SIZE_IS',
572 \STREAM_NOTIFY_REDIRECTED => 'REDIRECTED',
573 \STREAM_NOTIFY_PROGRESS => 'PROGRESS',
574 \STREAM_NOTIFY_FAILURE => 'FAILURE',
575 \STREAM_NOTIFY_COMPLETED => 'COMPLETED',
576 \STREAM_NOTIFY_RESOLVE => 'RESOLVE',
577 ];
578 static $args = ['severity', 'message', 'message_code', 'bytes_transferred', 'bytes_max'];
579
580 $value = Utils::debugResource($value);
581 $ident = $request->getMethod().' '.$request->getUri()->withFragment('');
582 self::addNotification(
583 $params,
584 static function (int $code, ...$passed) use ($ident, $value, $map, $args): void {
585 \fprintf($value, '<%s> [%s] ', $ident, $map[$code]);
586 foreach (\array_filter($passed) as $i => $v) {
587 \fwrite($value, $args[$i].': "'.$v.'" ');
588 }
589 \fwrite($value, "\n");
590 }
591 );
592 }
593
594 private static function addNotification(array &$params, callable $notify): void
595 {
596 // Wrap the existing function if needed.
597 if (!isset($params['notification'])) {
598 $params['notification'] = $notify;
599 } else {
600 $params['notification'] = self::callArray([
601 $params['notification'],
602 $notify,
603 ]);
604 }
605 }
606
607 private static function callArray(array $functions): callable
608 {
609 return static function (...$args) use ($functions) {
610 foreach ($functions as $fn) {
611 $fn(...$args);
612 }
613 };
614 }
615 }
616