CurlResponse.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\HttpClient\Response;
  11. use Psr\Log\LoggerInterface;
  12. use Symfony\Component\HttpClient\Chunk\FirstChunk;
  13. use Symfony\Component\HttpClient\Chunk\InformationalChunk;
  14. use Symfony\Component\HttpClient\Exception\TransportException;
  15. use Symfony\Component\HttpClient\Internal\Canary;
  16. use Symfony\Component\HttpClient\Internal\ClientState;
  17. use Symfony\Component\HttpClient\Internal\CurlClientState;
  18. use Symfony\Contracts\HttpClient\ResponseInterface;
  19. /**
  20. * @author Nicolas Grekas <p@tchwork.com>
  21. *
  22. * @internal
  23. */
  24. final class CurlResponse implements ResponseInterface, StreamableInterface
  25. {
  26. use CommonResponseTrait {
  27. getContent as private doGetContent;
  28. }
  29. use TransportResponseTrait;
  30. /**
  31. * @var \Symfony\Component\HttpClient\Internal\CurlClientState
  32. */
  33. private $multi;
  34. /**
  35. * @var resource
  36. */
  37. private $debugBuffer;
  38. /**
  39. * @internal
  40. * @param \CurlHandle|string $ch
  41. * @param \Symfony\Component\HttpClient\Internal\CurlClientState $multi
  42. * @param mixed[]|null $options
  43. * @param \Psr\Log\LoggerInterface|null $logger
  44. * @param string $method
  45. * @param callable|null $resolveRedirect
  46. * @param int|null $curlVersion
  47. * @param string|null $originalUrl
  48. */
  49. public function __construct($multi, $ch, $options = null, $logger = null, $method = 'GET', $resolveRedirect = null, $curlVersion = null, $originalUrl = null)
  50. {
  51. $this->multi = $multi;
  52. if (is_resource($ch) || $ch instanceof \CurlHandle) {
  53. $this->handle = $ch;
  54. $this->debugBuffer = fopen('php://temp', 'w+');
  55. if (0x074000 === $curlVersion) {
  56. fwrite($this->debugBuffer, 'Due to a bug in curl 7.64.0, the debug log is disabled; use another version to work around the issue.');
  57. } else {
  58. curl_setopt($ch, \CURLOPT_VERBOSE, true);
  59. curl_setopt($ch, \CURLOPT_STDERR, $this->debugBuffer);
  60. }
  61. } else {
  62. $this->info['url'] = $ch;
  63. $ch = $this->handle;
  64. }
  65. $this->id = $id = (int) $ch;
  66. $this->logger = $logger;
  67. $this->shouldBuffer = $options['buffer'] ?? true;
  68. $this->timeout = $options['timeout'] ?? null;
  69. $this->info['http_method'] = $method;
  70. $this->info['user_data'] = $options['user_data'] ?? null;
  71. $this->info['max_duration'] = $options['max_duration'] ?? null;
  72. $this->info['start_time'] = $this->info['start_time'] ?? microtime(true);
  73. $this->info['original_url'] = $originalUrl ?? $this->info['url'] ?? curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL);
  74. $info = &$this->info;
  75. $headers = &$this->headers;
  76. $debugBuffer = $this->debugBuffer;
  77. if (!$info['response_headers']) {
  78. // Used to keep track of what we're waiting for
  79. curl_setopt($ch, \CURLOPT_PRIVATE, \in_array($method, ['GET', 'HEAD', 'OPTIONS', 'TRACE'], true) && 1.0 < (float) ($options['http_version'] ?? 1.1) ? 'H2' : 'H0'); // H = headers + retry counter
  80. }
  81. curl_setopt($ch, \CURLOPT_HEADERFUNCTION, static function ($ch, string $data) use (&$info, &$headers, $options, $multi, $id, &$location, $resolveRedirect, $logger): int {
  82. return self::parseHeaderLine($ch, $data, $info, $headers, $options, $multi, $id, $location, $resolveRedirect, $logger);
  83. });
  84. if (null === $options) {
  85. // Pushed response: buffer until requested
  86. curl_setopt($ch, \CURLOPT_WRITEFUNCTION, static function ($ch, string $data) use ($multi, $id): int {
  87. $multi->handlesActivity[$id][] = $data;
  88. curl_pause($ch, \CURLPAUSE_RECV);
  89. return \strlen($data);
  90. });
  91. return;
  92. }
  93. $execCounter = $multi->execCounter;
  94. $this->info['pause_handler'] = static function (float $duration) use ($ch, $multi, $execCounter) {
  95. if (0 < $duration) {
  96. if ($execCounter === $multi->execCounter) {
  97. $multi->execCounter = !\is_float($execCounter) ? 1 + $execCounter : \PHP_INT_MIN;
  98. curl_multi_remove_handle($multi->handle, $ch);
  99. }
  100. $lastExpiry = end($multi->pauseExpiries);
  101. $multi->pauseExpiries[(int) $ch] = $duration += microtime(true);
  102. if (false !== $lastExpiry && $lastExpiry > $duration) {
  103. asort($multi->pauseExpiries);
  104. }
  105. curl_pause($ch, \CURLPAUSE_ALL);
  106. } else {
  107. unset($multi->pauseExpiries[(int) $ch]);
  108. curl_pause($ch, \CURLPAUSE_CONT);
  109. curl_multi_add_handle($multi->handle, $ch);
  110. }
  111. };
  112. $this->inflate = !isset($options['normalized_headers']['accept-encoding']);
  113. curl_pause($ch, \CURLPAUSE_CONT);
  114. if ($onProgress = $options['on_progress']) {
  115. $url = isset($info['url']) ? ['url' => $info['url']] : [];
  116. curl_setopt($ch, \CURLOPT_NOPROGRESS, false);
  117. curl_setopt($ch, \CURLOPT_PROGRESSFUNCTION, static function ($ch, $dlSize, $dlNow) use ($onProgress, &$info, $url, $multi, $debugBuffer) {
  118. try {
  119. rewind($debugBuffer);
  120. $debug = ['debug' => stream_get_contents($debugBuffer)];
  121. $onProgress($dlNow, $dlSize, $url + curl_getinfo($ch) + $info + $debug);
  122. } catch (\Throwable $e) {
  123. $multi->handlesActivity[(int) $ch][] = null;
  124. $multi->handlesActivity[(int) $ch][] = $e;
  125. return 1; // Abort the request
  126. }
  127. return null;
  128. });
  129. }
  130. curl_setopt($ch, \CURLOPT_WRITEFUNCTION, static function ($ch, string $data) use ($multi, $id): int {
  131. if ('H' === (curl_getinfo($ch, \CURLINFO_PRIVATE)[0] ?? null)) {
  132. $multi->handlesActivity[$id][] = null;
  133. $multi->handlesActivity[$id][] = new TransportException(sprintf('Unsupported protocol for "%s"', curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL)));
  134. return 0;
  135. }
  136. curl_setopt($ch, \CURLOPT_WRITEFUNCTION, static function ($ch, string $data) use ($multi, $id): int {
  137. $multi->handlesActivity[$id][] = $data;
  138. return \strlen($data);
  139. });
  140. $multi->handlesActivity[$id][] = $data;
  141. return \strlen($data);
  142. });
  143. $this->initializer = static function (self $response) {
  144. $waitFor = curl_getinfo($response->handle, \CURLINFO_PRIVATE);
  145. return 'H' === $waitFor[0];
  146. };
  147. // Schedule the request in a non-blocking way
  148. $multi->lastTimeout = null;
  149. $multi->openHandles[$id] = [$ch, $options];
  150. curl_multi_add_handle($multi->handle, $ch);
  151. $this->canary = new Canary(static function () use ($ch, $multi, $id) {
  152. unset($multi->pauseExpiries[$id], $multi->openHandles[$id], $multi->handlesActivity[$id]);
  153. curl_setopt($ch, \CURLOPT_PRIVATE, '_0');
  154. if ($multi->performing) {
  155. return;
  156. }
  157. curl_multi_remove_handle($multi->handle, $ch);
  158. curl_setopt_array($ch, [
  159. \CURLOPT_NOPROGRESS => true,
  160. \CURLOPT_PROGRESSFUNCTION => null,
  161. \CURLOPT_HEADERFUNCTION => null,
  162. \CURLOPT_WRITEFUNCTION => null,
  163. \CURLOPT_READFUNCTION => null,
  164. \CURLOPT_INFILE => null,
  165. ]);
  166. if (!$multi->openHandles) {
  167. // Schedule DNS cache eviction for the next request
  168. $multi->dnsCache->evictions = $multi->dnsCache->evictions ?: $multi->dnsCache->removals;
  169. $multi->dnsCache->removals = $multi->dnsCache->hostnames = [];
  170. }
  171. });
  172. }
  173. /**
  174. * @return mixed
  175. * @param string|null $type
  176. */
  177. public function getInfo($type = null)
  178. {
  179. if (!$info = $this->finalInfo) {
  180. $info = array_merge($this->info, curl_getinfo($this->handle));
  181. $info['url'] = $this->info['url'] ?? $info['url'];
  182. $info['redirect_url'] = $this->info['redirect_url'] ?? null;
  183. // workaround curl not subtracting the time offset for pushed responses
  184. if (isset($this->info['url']) && $info['start_time'] / 1000 < $info['total_time']) {
  185. $info['total_time'] -= $info['starttransfer_time'] ?: $info['total_time'];
  186. $info['starttransfer_time'] = 0.0;
  187. }
  188. rewind($this->debugBuffer);
  189. $info['debug'] = stream_get_contents($this->debugBuffer);
  190. $waitFor = curl_getinfo($this->handle, \CURLINFO_PRIVATE);
  191. if ('H' !== $waitFor[0] && 'C' !== $waitFor[0]) {
  192. curl_setopt($this->handle, \CURLOPT_VERBOSE, false);
  193. rewind($this->debugBuffer);
  194. ftruncate($this->debugBuffer, 0);
  195. $this->finalInfo = $info;
  196. }
  197. }
  198. return null !== $type ? $info[$type] ?? null : $info;
  199. }
  200. /**
  201. * @param bool $throw
  202. */
  203. public function getContent($throw = true)
  204. {
  205. $performing = $this->multi->performing;
  206. $this->multi->performing = $performing || '_0' === curl_getinfo($this->handle, \CURLINFO_PRIVATE);
  207. try {
  208. return $this->doGetContent($throw);
  209. } finally {
  210. $this->multi->performing = $performing;
  211. }
  212. }
  213. public function __destruct()
  214. {
  215. try {
  216. if (null === $this->timeout) {
  217. return; // Unused pushed response
  218. }
  219. $this->doDestruct();
  220. } finally {
  221. if (\is_resource($this->handle) || $this->handle instanceof \CurlHandle) {
  222. curl_setopt($this->handle, \CURLOPT_VERBOSE, false);
  223. }
  224. }
  225. }
  226. /**
  227. * @param $this $response
  228. * @param mixed[] $runningResponses
  229. */
  230. private static function schedule($response, &$runningResponses)
  231. {
  232. if (isset($runningResponses[$i = (int) $response->multi->handle])) {
  233. $runningResponses[$i][1][$response->id] = $response;
  234. } else {
  235. $runningResponses[$i] = [$response->multi, [$response->id => $response]];
  236. }
  237. if ('_0' === curl_getinfo($response->handle, \CURLINFO_PRIVATE)) {
  238. // Response already completed
  239. $response->multi->handlesActivity[$response->id][] = null;
  240. $response->multi->handlesActivity[$response->id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
  241. }
  242. }
  243. /**
  244. * @param \Symfony\Component\HttpClient\Internal\ClientState $multi
  245. * @param mixed[]|null $responses
  246. */
  247. private static function perform($multi, &$responses = null)
  248. {
  249. if ($multi->performing) {
  250. if ($responses) {
  251. $response = current($responses);
  252. $multi->handlesActivity[(int) $response->handle][] = null;
  253. $multi->handlesActivity[(int) $response->handle][] = new TransportException(sprintf('Userland callback cannot use the client nor the response while processing "%s".', curl_getinfo($response->handle, \CURLINFO_EFFECTIVE_URL)));
  254. }
  255. return;
  256. }
  257. try {
  258. $multi->performing = true;
  259. ++$multi->execCounter;
  260. $active = 0;
  261. while (\CURLM_CALL_MULTI_PERFORM === ($err = curl_multi_exec($multi->handle, $active))) {
  262. }
  263. if (\CURLM_OK !== $err) {
  264. throw new TransportException(curl_multi_strerror($err));
  265. }
  266. while ($info = curl_multi_info_read($multi->handle)) {
  267. if (\CURLMSG_DONE !== $info['msg']) {
  268. continue;
  269. }
  270. $result = $info['result'];
  271. $id = (int) $ch = $info['handle'];
  272. $waitFor = @curl_getinfo($ch, \CURLINFO_PRIVATE) ?: '_0';
  273. if (\in_array($result, [\CURLE_SEND_ERROR, \CURLE_RECV_ERROR, /* CURLE_HTTP2 */ 16, /* CURLE_HTTP2_STREAM */ 92], true) && $waitFor[1] && 'C' !== $waitFor[0]) {
  274. curl_multi_remove_handle($multi->handle, $ch);
  275. $waitFor[1] = (string) ((int) $waitFor[1] - 1); // decrement the retry counter
  276. curl_setopt($ch, \CURLOPT_PRIVATE, $waitFor);
  277. curl_setopt($ch, \CURLOPT_FORBID_REUSE, true);
  278. if (0 === curl_multi_add_handle($multi->handle, $ch)) {
  279. continue;
  280. }
  281. }
  282. if (\CURLE_RECV_ERROR === $result && 'H' === $waitFor[0] && 400 <= ($responses[(int) $ch]->info['http_code'] ?? 0)) {
  283. $multi->handlesActivity[$id][] = new FirstChunk();
  284. }
  285. $multi->handlesActivity[$id][] = null;
  286. $multi->handlesActivity[$id][] = \in_array($result, [\CURLE_OK, \CURLE_TOO_MANY_REDIRECTS], true) || '_0' === $waitFor || curl_getinfo($ch, \CURLINFO_SIZE_DOWNLOAD) === curl_getinfo($ch, \CURLINFO_CONTENT_LENGTH_DOWNLOAD) ? null : new TransportException(ucfirst(curl_error($ch) ?: curl_strerror($result)).sprintf(' for "%s".', curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL)));
  287. }
  288. } finally {
  289. $multi->performing = false;
  290. }
  291. }
  292. /**
  293. * @param \Symfony\Component\HttpClient\Internal\ClientState $multi
  294. * @param float $timeout
  295. */
  296. private static function select($multi, $timeout)
  297. {
  298. if ($multi->pauseExpiries) {
  299. $now = microtime(true);
  300. foreach ($multi->pauseExpiries as $id => $pauseExpiry) {
  301. if ($now < $pauseExpiry) {
  302. $timeout = min($timeout, $pauseExpiry - $now);
  303. break;
  304. }
  305. unset($multi->pauseExpiries[$id]);
  306. curl_pause($multi->openHandles[$id][0], \CURLPAUSE_CONT);
  307. curl_multi_add_handle($multi->handle, $multi->openHandles[$id][0]);
  308. }
  309. }
  310. if (0 !== $selected = curl_multi_select($multi->handle, $timeout)) {
  311. return $selected;
  312. }
  313. if ($multi->pauseExpiries && 0 < $timeout -= microtime(true) - $now) {
  314. usleep((int) (1E6 * $timeout));
  315. }
  316. return 0;
  317. }
  318. /**
  319. * Parses header lines as curl yields them to us.
  320. * @param string $data
  321. * @param mixed[] $info
  322. * @param mixed[] $headers
  323. * @param mixed[]|null $options
  324. * @param \Symfony\Component\HttpClient\Internal\CurlClientState $multi
  325. * @param int $id
  326. * @param string|null $location
  327. * @param callable|null $resolveRedirect
  328. * @param \Psr\Log\LoggerInterface|null $logger
  329. */
  330. private static function parseHeaderLine($ch, $data, &$info, &$headers, $options, $multi, $id, &$location, $resolveRedirect, $logger)
  331. {
  332. if (substr_compare($data, "\r\n", -strlen("\r\n")) !== 0) {
  333. return 0;
  334. }
  335. $waitFor = @curl_getinfo($ch, \CURLINFO_PRIVATE) ?: '_0';
  336. if ('H' !== $waitFor[0]) {
  337. return \strlen($data); // Ignore HTTP trailers
  338. }
  339. $statusCode = curl_getinfo($ch, \CURLINFO_RESPONSE_CODE);
  340. if ($statusCode !== $info['http_code'] && !preg_match("#^HTTP/\d+(?:\.\d+)? {$statusCode}(?: |\r\n$)#", $data)) {
  341. return \strlen($data); // Ignore headers from responses to CONNECT requests
  342. }
  343. if ("\r\n" !== $data) {
  344. // Regular header line: add it to the list
  345. self::addResponseHeaders([substr($data, 0, -2)], $info, $headers);
  346. if (strncmp($data, 'HTTP/', strlen('HTTP/')) !== 0) {
  347. if (0 === stripos($data, 'Location:')) {
  348. $location = trim(substr($data, 9, -2));
  349. }
  350. return \strlen($data);
  351. }
  352. if (\function_exists('openssl_x509_read') && $certinfo = curl_getinfo($ch, \CURLINFO_CERTINFO)) {
  353. $info['peer_certificate_chain'] = array_map('openssl_x509_read', array_column($certinfo, 'Cert'));
  354. }
  355. if (300 <= $info['http_code'] && $info['http_code'] < 400) {
  356. if (curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) {
  357. curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, false);
  358. } elseif (303 === $info['http_code'] || ('POST' === $info['http_method'] && \in_array($info['http_code'], [301, 302], true))) {
  359. curl_setopt($ch, \CURLOPT_POSTFIELDS, '');
  360. }
  361. }
  362. return \strlen($data);
  363. }
  364. // End of headers: handle informational responses, redirects, etc.
  365. if (200 > $statusCode) {
  366. $multi->handlesActivity[$id][] = new InformationalChunk($statusCode, $headers);
  367. $location = null;
  368. return \strlen($data);
  369. }
  370. $info['redirect_url'] = null;
  371. if (300 <= $statusCode && $statusCode < 400 && null !== $location) {
  372. if ($noContent = 303 === $statusCode || ('POST' === $info['http_method'] && \in_array($statusCode, [301, 302], true))) {
  373. $info['http_method'] = 'HEAD' === $info['http_method'] ? 'HEAD' : 'GET';
  374. curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $info['http_method']);
  375. }
  376. if (null === $info['redirect_url'] = $resolveRedirect($ch, $location, $noContent)) {
  377. $options['max_redirects'] = curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT);
  378. curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, false);
  379. curl_setopt($ch, \CURLOPT_MAXREDIRS, $options['max_redirects']);
  380. } else {
  381. $url = parse_url($location ?? ':');
  382. if (isset($url['host']) && null !== $ip = $multi->dnsCache->hostnames[$url['host'] = strtolower($url['host'])] ?? null) {
  383. // Populate DNS cache for redirects if needed
  384. $port = $url['port'] ?? ('http' === ($url['scheme'] ?? parse_url(curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL), \PHP_URL_SCHEME)) ? 80 : 443);
  385. curl_setopt($ch, \CURLOPT_RESOLVE, ["{$url['host']}:$port:$ip"]);
  386. $multi->dnsCache->removals["-{$url['host']}:$port"] = "-{$url['host']}:$port";
  387. }
  388. }
  389. }
  390. if (401 === $statusCode && isset($options['auth_ntlm']) && 0 === strncasecmp($headers['www-authenticate'][0] ?? '', 'NTLM ', 5)) {
  391. // Continue with NTLM auth
  392. } elseif ($statusCode < 300 || 400 <= $statusCode || null === $location || curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) {
  393. // Headers and redirects completed, time to get the response's content
  394. $multi->handlesActivity[$id][] = new FirstChunk();
  395. if ('HEAD' === $info['http_method'] || \in_array($statusCode, [204, 304], true)) {
  396. $waitFor = '_0'; // no content expected
  397. $multi->handlesActivity[$id][] = null;
  398. $multi->handlesActivity[$id][] = null;
  399. } else {
  400. $waitFor[0] = 'C'; // C = content
  401. }
  402. curl_setopt($ch, \CURLOPT_PRIVATE, $waitFor);
  403. } elseif (null !== $info['redirect_url'] && $logger) {
  404. $logger->info(sprintf('Redirecting: "%s %s"', $info['http_code'], $info['redirect_url']));
  405. }
  406. $location = null;
  407. return \strlen($data);
  408. }
  409. }