diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php index 41d2d0a3e0136..e5c22ca5fa826 100644 --- a/src/Symfony/Component/HttpClient/CurlHttpClient.php +++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php @@ -421,9 +421,8 @@ private static function createRedirectResolver(array $options, string $host): \C } } - return static function ($ch, string $location, bool $noContent, bool &$locationHasHost) use (&$redirectHeaders, $options) { + return static function ($ch, string $location, bool $noContent) use (&$redirectHeaders, $options) { try { - $locationHasHost = false; $location = self::parseUrl($location); $url = self::parseUrl(curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL)); $url = self::resolveUrl($location, $url); @@ -439,9 +438,7 @@ private static function createRedirectResolver(array $options, string $host): \C $redirectHeaders['with_auth'] = array_filter($redirectHeaders['with_auth'], $filterContentHeaders); } - $locationHasHost = isset($location['authority']); - - if ($redirectHeaders && $locationHasHost) { + if ($redirectHeaders && isset($location['authority'])) { $requestHeaders = parse_url($location['authority'], \PHP_URL_HOST) === $redirectHeaders['host'] ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth']; curl_setopt($ch, \CURLOPT_HTTPHEADER, $requestHeaders); } elseif ($noContent && $redirectHeaders) { diff --git a/src/Symfony/Component/HttpClient/NativeHttpClient.php b/src/Symfony/Component/HttpClient/NativeHttpClient.php index 71879db0352ed..f3d2b9739aaa7 100644 --- a/src/Symfony/Component/HttpClient/NativeHttpClient.php +++ b/src/Symfony/Component/HttpClient/NativeHttpClient.php @@ -156,6 +156,7 @@ public function request(string $method, string $url, array $options = []): Respo $progressInfo = $info; $progressInfo['url'] = implode('', $info['url']); + $progressInfo['resolve'] = $resolve; unset($progressInfo['size_body']); if ($progress && -1 === $progress[0]) { @@ -165,7 +166,7 @@ public function request(string $method, string $url, array $options = []): Respo $lastProgress = $progress ?: $lastProgress; } - $onProgress($lastProgress[0], $lastProgress[1], $progressInfo, $resolve); + $onProgress($lastProgress[0], $lastProgress[1], $progressInfo); }; } elseif (0 < $options['max_duration']) { $maxDuration = $options['max_duration']; @@ -348,6 +349,7 @@ private static function dnsResolve($host, NativeClientState $multi, array &$info $multi->dnsCache[$host] = $ip = $ip[0]; $info['debug'] .= "* Added {$host}:0:{$ip} to DNS cache\n"; + $host = $ip; } else { $info['debug'] .= "* Hostname was found in DNS cache\n"; $host = str_contains($ip, ':') ? "[$ip]" : $ip; diff --git a/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php b/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php index eb4ac7a8aacc6..8e255c8c79b51 100644 --- a/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php +++ b/src/Symfony/Component/HttpClient/NoPrivateNetworkHttpClient.php @@ -80,24 +80,17 @@ public function request(string $method, string $url, array $options = []): Respo $lastUrl = ''; $lastPrimaryIp = ''; - $options['on_progress'] = function (int $dlNow, int $dlSize, array $info, ?\Closure $resolve = null) use ($onProgress, $subnets, &$lastUrl, &$lastPrimaryIp): void { + $options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use ($onProgress, $subnets, &$lastUrl, &$lastPrimaryIp): void { if ($info['url'] !== $lastUrl) { - $host = trim(parse_url($info['url'], PHP_URL_HOST) ?: '', '[]'); + $host = parse_url($info['url'], PHP_URL_HOST) ?: ''; + $resolve = $info['resolve'] ?? static function () { return null; }; - if (null === $resolve) { - $resolve = static function () { return null; }; - } - - if (($ip = $host) - && !filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6) - && !filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4) - && !$ip = $resolve($host) + if (($ip = trim($host, '[]')) + && !filter_var($ip, \FILTER_VALIDATE_IP) + && !($ip = $resolve($host)) + && $ip = @(gethostbynamel($host)[0] ?? dns_get_record($host, \DNS_AAAA)[0]['ipv6'] ?? null) ) { - if ($ip = @(dns_get_record($host, \DNS_A)[0]['ip'] ?? null)) { - $resolve($host, $ip); - } elseif ($ip = @(dns_get_record($host, \DNS_AAAA)[0]['ipv6'] ?? null)) { - $resolve($host, '['.$ip.']'); - } + $resolve($host, $ip); } if ($ip && IpUtils::checkIp($ip, $subnets ?? self::PRIVATE_SUBNETS)) { diff --git a/src/Symfony/Component/HttpClient/Response/AmpResponse.php b/src/Symfony/Component/HttpClient/Response/AmpResponse.php index a9cc4d6a11c24..6304abcae15f1 100644 --- a/src/Symfony/Component/HttpClient/Response/AmpResponse.php +++ b/src/Symfony/Component/HttpClient/Response/AmpResponse.php @@ -99,7 +99,8 @@ public function __construct(AmpClientState $multi, Request $request, array $opti $onProgress = $options['on_progress'] ?? static function () {}; $onProgress = $this->onProgress = static function () use (&$info, $onProgress, $resolve) { $info['total_time'] = microtime(true) - $info['start_time']; - $onProgress((int) $info['size_download'], ((int) (1 + $info['download_content_length']) ?: 1) - 1, (array) $info, $resolve); + $info['resolve'] = $resolve; + $onProgress((int) $info['size_download'], ((int) (1 + $info['download_content_length']) ?: 1) - 1, (array) $info); }; $pauseDeferred = new Deferred(); diff --git a/src/Symfony/Component/HttpClient/Response/AsyncContext.php b/src/Symfony/Component/HttpClient/Response/AsyncContext.php index de1562df640cb..3c5397c873845 100644 --- a/src/Symfony/Component/HttpClient/Response/AsyncContext.php +++ b/src/Symfony/Component/HttpClient/Response/AsyncContext.php @@ -156,8 +156,8 @@ public function replaceRequest(string $method, string $url, array $options = []) $this->info['previous_info'][] = $info = $this->response->getInfo(); if (null !== $onProgress = $options['on_progress'] ?? null) { $thisInfo = &$this->info; - $options['on_progress'] = static function (int $dlNow, int $dlSize, array $info, ?\Closure $resolve = null) use (&$thisInfo, $onProgress) { - $onProgress($dlNow, $dlSize, $thisInfo + $info, $resolve); + $options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use (&$thisInfo, $onProgress) { + $onProgress($dlNow, $dlSize, $thisInfo + $info); }; } if (0 < ($info['max_duration'] ?? 0) && 0 < ($info['total_time'] ?? 0)) { diff --git a/src/Symfony/Component/HttpClient/Response/AsyncResponse.php b/src/Symfony/Component/HttpClient/Response/AsyncResponse.php index de52ce075976a..93774ba1afcf4 100644 --- a/src/Symfony/Component/HttpClient/Response/AsyncResponse.php +++ b/src/Symfony/Component/HttpClient/Response/AsyncResponse.php @@ -51,8 +51,8 @@ public function __construct(HttpClientInterface $client, string $method, string if (null !== $onProgress = $options['on_progress'] ?? null) { $thisInfo = &$this->info; - $options['on_progress'] = static function (int $dlNow, int $dlSize, array $info, ?\Closure $resolve = null) use (&$thisInfo, $onProgress) { - $onProgress($dlNow, $dlSize, $thisInfo + $info, $resolve); + $options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use (&$thisInfo, $onProgress) { + $onProgress($dlNow, $dlSize, $thisInfo + $info); }; } $this->response = $client->request($method, $url, ['buffer' => false] + $options); @@ -117,11 +117,20 @@ public function getHeaders(bool $throw = true): array public function getInfo(?string $type = null) { + if ('debug' === ($type ?? 'debug')) { + $debug = implode('', array_column($this->info['previous_info'] ?? [], 'debug')); + $debug .= $this->response->getInfo('debug'); + + if ('debug' === $type) { + return $debug; + } + } + if (null !== $type) { return $this->info[$type] ?? $this->response->getInfo($type); } - return $this->info + $this->response->getInfo(); + return array_merge($this->info + $this->response->getInfo(), ['debug' => $debug]); } public function toStream(bool $throw = true) @@ -249,6 +258,7 @@ public static function stream(iterable $responses, ?float $timeout = null, ?stri return; } + $chunk = null; foreach ($client->stream($wrappedResponses, $timeout) as $response => $chunk) { $r = $asyncMap[$response]; @@ -291,6 +301,9 @@ public static function stream(iterable $responses, ?float $timeout = null, ?stri } } + if (null === $chunk) { + throw new \LogicException(\sprintf('"%s" is not compliant with HttpClientInterface: its "stream()" method didn\'t yield any chunks when it should have.', get_debug_type($client))); + } if (null === $chunk->getError() && $chunk->isLast()) { $r->yieldedState = self::LAST_CHUNK_YIELDED; } diff --git a/src/Symfony/Component/HttpClient/Response/CurlResponse.php b/src/Symfony/Component/HttpClient/Response/CurlResponse.php index cb947f4f2be2f..5cdac10255cf5 100644 --- a/src/Symfony/Component/HttpClient/Response/CurlResponse.php +++ b/src/Symfony/Component/HttpClient/Response/CurlResponse.php @@ -128,7 +128,7 @@ public function __construct(CurlClientState $multi, $ch, ?array $options = null, try { rewind($debugBuffer); $debug = ['debug' => stream_get_contents($debugBuffer)]; - $onProgress($dlNow, $dlSize, $url + curl_getinfo($ch) + $info + $debug, $resolve); + $onProgress($dlNow, $dlSize, $url + curl_getinfo($ch) + $info + $debug + ['resolve' => $resolve]); } catch (\Throwable $e) { $multi->handlesActivity[(int) $ch][] = null; $multi->handlesActivity[(int) $ch][] = $e; @@ -436,21 +436,11 @@ private static function parseHeaderLine($ch, string $data, array &$info, array & $info['http_method'] = 'HEAD' === $info['http_method'] ? 'HEAD' : 'GET'; curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $info['http_method']); } - $locationHasHost = false; - if (null === $info['redirect_url'] = $resolveRedirect($ch, $location, $noContent, $locationHasHost)) { + if (null === $info['redirect_url'] = $resolveRedirect($ch, $location, $noContent)) { $options['max_redirects'] = curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT); curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, false); curl_setopt($ch, \CURLOPT_MAXREDIRS, $options['max_redirects']); - } elseif ($locationHasHost) { - $url = parse_url($info['redirect_url']); - - if (null !== $ip = $multi->dnsCache->hostnames[$url['host'] = strtolower($url['host'])] ?? null) { - // Populate DNS cache for redirects if needed - $port = $url['port'] ?? ('http' === $url['scheme'] ? 80 : 443); - curl_setopt($ch, \CURLOPT_RESOLVE, ["{$url['host']}:$port:$ip"]); - $multi->dnsCache->removals["-{$url['host']}:$port"] = "-{$url['host']}:$port"; - } } } diff --git a/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php index 7130c097a2565..0eba5d6345277 100644 --- a/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/NoPrivateNetworkHttpClientTest.php @@ -75,7 +75,7 @@ public function testExcludeByIp(string $ipAddr, $subnets, bool $mustThrow) $this->expectExceptionMessage(sprintf('IP "%s" is blocked for "%s".', $ipAddr, $url)); } - $previousHttpClient = $this->getHttpClientMock($url, $ipAddr, $content); + $previousHttpClient = $this->getMockHttpClient($ipAddr, $content); $client = new NoPrivateNetworkHttpClient($previousHttpClient, $subnets); $response = $client->request('GET', $url); @@ -91,14 +91,15 @@ public function testExcludeByIp(string $ipAddr, $subnets, bool $mustThrow) public function testExcludeByHost(string $ipAddr, $subnets, bool $mustThrow) { $content = 'foo'; - $url = sprintf('http://%s/', str_contains($ipAddr, ':') ? sprintf('[%s]', $ipAddr) : $ipAddr); + $host = str_contains($ipAddr, ':') ? sprintf('[%s]', $ipAddr) : $ipAddr; + $url = sprintf('http://%s/', $host); if ($mustThrow) { $this->expectException(TransportException::class); - $this->expectExceptionMessage(sprintf('Host "%s" is blocked for "%s".', $ipAddr, $url)); + $this->expectExceptionMessage(sprintf('Host "%s" is blocked for "%s".', $host, $url)); } - $previousHttpClient = $this->getHttpClientMock($url, $ipAddr, $content); + $previousHttpClient = $this->getMockHttpClient($ipAddr, $content); $client = new NoPrivateNetworkHttpClient($previousHttpClient, $subnets); $response = $client->request('GET', $url); @@ -119,7 +120,7 @@ public function testCustomOnProgressCallback() ++$executionCount; }; - $previousHttpClient = $this->getHttpClientMock($url, $ipAddr, $content); + $previousHttpClient = $this->getMockHttpClient($ipAddr, $content); $client = new NoPrivateNetworkHttpClient($previousHttpClient); $response = $client->request('GET', $url, ['on_progress' => $customCallback]); @@ -132,7 +133,6 @@ public function testNonCallableOnProgressCallback() { $ipAddr = '104.26.14.6'; $url = sprintf('http://%s/', $ipAddr); - $content = 'bar'; $customCallback = sprintf('cb_%s', microtime(true)); $this->expectException(InvalidArgumentException::class); @@ -150,38 +150,8 @@ public function testConstructor() new NoPrivateNetworkHttpClient(new MockHttpClient(), 3); } - private function getHttpClientMock(string $url, string $ipAddr, string $content) + private function getMockHttpClient(string $ipAddr, string $content) { - $previousHttpClient = $this - ->getMockBuilder(HttpClientInterface::class) - ->getMock(); - - $previousHttpClient - ->expects($this->once()) - ->method('request') - ->with( - 'GET', - $url, - $this->callback(function ($options) { - $this->assertArrayHasKey('on_progress', $options); - $onProgress = $options['on_progress']; - $this->assertIsCallable($onProgress); - - return true; - }) - ) - ->willReturnCallback(function ($method, $url, $options) use ($ipAddr, $content): ResponseInterface { - $info = [ - 'primary_ip' => $ipAddr, - 'url' => $url, - ]; - - $onProgress = $options['on_progress']; - $onProgress(0, 0, $info); - - return MockResponse::fromRequest($method, $url, [], new MockResponse($content)); - }); - - return $previousHttpClient; + return new MockHttpClient(new MockResponse($content, ['primary_ip' => $ipAddr])); } } diff --git a/src/Symfony/Component/HttpClient/TraceableHttpClient.php b/src/Symfony/Component/HttpClient/TraceableHttpClient.php index f83a5cadb1759..0c1f05adf7736 100644 --- a/src/Symfony/Component/HttpClient/TraceableHttpClient.php +++ b/src/Symfony/Component/HttpClient/TraceableHttpClient.php @@ -58,11 +58,11 @@ public function request(string $method, string $url, array $options = []): Respo $content = false; } - $options['on_progress'] = function (int $dlNow, int $dlSize, array $info, ?\Closure $resolve = null) use (&$traceInfo, $onProgress) { + $options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use (&$traceInfo, $onProgress) { $traceInfo = $info; if (null !== $onProgress) { - $onProgress($dlNow, $dlSize, $info, $resolve); + $onProgress($dlNow, $dlSize, $info); } }; diff --git a/src/Symfony/Contracts/HttpClient/HttpClientInterface.php b/src/Symfony/Contracts/HttpClient/HttpClientInterface.php index c0d839f30e30d..dac97ba414b68 100644 --- a/src/Symfony/Contracts/HttpClient/HttpClientInterface.php +++ b/src/Symfony/Contracts/HttpClient/HttpClientInterface.php @@ -48,11 +48,9 @@ interface HttpClientInterface 'buffer' => true, // bool|resource|\Closure - whether the content of the response should be buffered or not, // or a stream resource where the response body should be written, // or a closure telling if/where the response should be buffered based on its headers - 'on_progress' => null, // callable(int $dlNow, int $dlSize, array $info, ?Closure $resolve = null) - throwing any - // exceptions MUST abort the request; it MUST be called on connection, on headers and on - // completion; it SHOULD be called on upload/download of data and at least 1/s; - // if passed, $resolve($host) / $resolve($host, $ip) can be called to read / populate - // the DNS cache respectively + 'on_progress' => null, // callable(int $dlNow, int $dlSize, array $info) - throwing any exceptions MUST abort the + // request; it MUST be called on connection, on headers and on completion; it SHOULD be + // called on upload/download of data and at least 1/s 'resolve' => [], // string[] - a map of host to IP address that SHOULD replace DNS resolution 'proxy' => null, // string - by default, the proxy-related env vars handled by curl SHOULD be honored 'no_proxy' => null, // string - a comma separated list of hosts that do not require a proxy to be reached