Passed
Push — main ( de635b...8881a7 )
by smiley
02:13
created

src/CurlUtils/CurlHandle.php (1 issue)

1
<?php
2
/**
3
 * Class CurlHandle
4
 *
5
 * @created      30.08.2018
6
 * @author       smiley <[email protected]>
7
 * @copyright    2018 smiley
8
 * @license      MIT
9
 */
10
11
namespace chillerlan\HTTP\CurlUtils;
12
13
use chillerlan\Settings\SettingsContainerInterface;
14
use Psr\Http\Message\{RequestInterface, ResponseInterface};
15
16
use function array_key_exists, count, curl_close, curl_init, curl_setopt_array,
17
	explode, in_array, is_resource, strlen, strtolower, strtoupper, substr, trim;
18
19
use const CURLOPT_CAINFO, CURLOPT_CONNECTTIMEOUT, CURLOPT_CUSTOMREQUEST, CURLOPT_FOLLOWLOCATION, CURLOPT_HEADER,
20
	CURLOPT_HEADERFUNCTION, CURLOPT_HTTP_VERSION, CURLOPT_HTTPHEADER, CURLOPT_INFILESIZE, CURLOPT_NOBODY,
21
	CURLOPT_POSTFIELDS, CURLOPT_PROTOCOLS, CURLOPT_READFUNCTION, CURLOPT_RETURNTRANSFER,
22
	CURLOPT_SSL_VERIFYHOST, CURLOPT_SSL_VERIFYPEER, CURLOPT_TIMEOUT, CURLOPT_UPLOAD, CURLOPT_URL, CURLOPT_USERAGENT,
23
	CURLOPT_USERPWD, CURLOPT_WRITEFUNCTION, CURLPROTO_HTTP, CURLPROTO_HTTPS, CURL_HTTP_VERSION_2TLS,
24
	CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_RESOLVE_PROXY,
25
	CURLE_GOT_NOTHING, CURLE_OPERATION_TIMEOUTED, CURLE_SSL_CONNECT_ERROR;
26
27
class CurlHandle{
28
29
	const CURL_NETWORK_ERRORS = [
30
		CURLE_COULDNT_RESOLVE_PROXY,
31
		CURLE_COULDNT_RESOLVE_HOST,
32
		CURLE_COULDNT_CONNECT,
33
		CURLE_OPERATION_TIMEOUTED,
34
		CURLE_SSL_CONNECT_ERROR,
35
		CURLE_GOT_NOTHING,
36
	];
37
38
	// https://www.php.net/manual/function.curl-getinfo.php#111678
39
	// https://www.openssl.org/docs/manmaster/man1/verify.html#VERIFY_OPERATION
40
	// https://github.com/openssl/openssl/blob/91cb81d40a8102c3d8667629661be8d6937db82b/include/openssl/x509_vfy.h#L99-L189
41
	const CURLINFO_SSL_VERIFYRESULT = [
42
		0  => 'ok the operation was successful.',
43
		2  => 'unable to get issuer certificate',
44
		3  => 'unable to get certificate CRL',
45
		4  => 'unable to decrypt certificate\'s signature',
46
		5  => 'unable to decrypt CRL\'s signature',
47
		6  => 'unable to decode issuer public key',
48
		7  => 'certificate signature failure',
49
		8  => 'CRL signature failure',
50
		9  => 'certificate is not yet valid',
51
		10 => 'certificate has expired',
52
		11 => 'CRL is not yet valid',
53
		12 => 'CRL has expired',
54
		13 => 'format error in certificate\'s notBefore field',
55
		14 => 'format error in certificate\'s notAfter field',
56
		15 => 'format error in CRL\'s lastUpdate field',
57
		16 => 'format error in CRL\'s nextUpdate field',
58
		17 => 'out of memory',
59
		18 => 'self signed certificate',
60
		19 => 'self signed certificate in certificate chain',
61
		20 => 'unable to get local issuer certificate',
62
		21 => 'unable to verify the first certificate',
63
		22 => 'certificate chain too long',
64
		23 => 'certificate revoked',
65
		24 => 'invalid CA certificate',
66
		25 => 'path length constraint exceeded',
67
		26 => 'unsupported certificate purpose',
68
		27 => 'certificate not trusted',
69
		28 => 'certificate rejected',
70
		29 => 'subject issuer mismatch',
71
		30 => 'authority and subject key identifier mismatch',
72
		31 => 'authority and issuer serial number mismatch',
73
		32 => 'key usage does not include certificate signing',
74
		50 => 'application verification failure',
75
	];
76
77
	/**
78
	 * The cURL handle
79
	 *
80
	 * @var resource|null
81
	 */
82
	protected $curl;
83
84
	/**
85
	 * @var \chillerlan\Settings\SettingsContainerInterface|\chillerlan\HTTP\HTTPOptions
86
	 */
87
	protected SettingsContainerInterface $options;
88
89
	protected RequestInterface $request;
90
91
	protected ResponseInterface $response;
92
93
	/**
94
	 * CurlHandle constructor.
95
	 */
96
	public function __construct(RequestInterface $request, ResponseInterface $response, SettingsContainerInterface $options){
97
		$this->request  = $request;
98
		$this->response = $response;
99
		$this->options  = $options;
100
		$this->curl     = curl_init();
0 ignored issues
show
Documentation Bug introduced by
It seems like curl_init() can also be of type CurlHandle. However, the property $curl is declared as type null|resource. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
101
	}
102
103
	/**
104
	 * close an existing cURL handle on exit
105
	 */
106
	public function __destruct(){
107
		$this->close();
108
	}
109
110
	/**
111
	 * @return \chillerlan\HTTP\CurlUtils\CurlHandle
112
	 */
113
	public function close():CurlHandle{
114
115
		if(is_resource($this->curl)){
116
			curl_close($this->curl);
117
		}
118
119
		return $this;
120
	}
121
122
	/**
123
	 * @return resource|null
124
	 * @codeCoverageIgnore
125
	 */
126
	public function getCurlResource(){
127
		return $this->curl;
128
	}
129
130
	/**
131
	 * @codeCoverageIgnore
132
	 */
133
	public function getRequest():RequestInterface{
134
		return $this->request;
135
	}
136
137
	/**
138
	 * @codeCoverageIgnore
139
	 */
140
	public function getResponse():ResponseInterface{
141
		return $this->response;
142
	}
143
144
	/**
145
	 * @return array
146
	 */
147
	protected function initCurlOptions():array{
148
		return [
149
			CURLOPT_HEADER         => false,
150
			CURLOPT_RETURNTRANSFER => true,
151
			CURLOPT_FOLLOWLOCATION => false,
152
			CURLOPT_URL            => (string)$this->request->getUri()->withFragment(''),
153
			CURLOPT_HTTP_VERSION   => CURL_HTTP_VERSION_2TLS,
154
			CURLOPT_USERAGENT      => $this->options->user_agent,
155
			CURLOPT_PROTOCOLS      => CURLPROTO_HTTP | CURLPROTO_HTTPS,
156
			CURLOPT_SSL_VERIFYPEER => true,
157
			CURLOPT_SSL_VERIFYHOST => 2,
158
			CURLOPT_CAINFO         => $this->options->ca_info,
159
			CURLOPT_TIMEOUT        => $this->options->timeout,
160
			CURLOPT_CONNECTTIMEOUT => 30,
161
			CURLOPT_WRITEFUNCTION  => [$this, 'writefunction'],
162
			CURLOPT_HEADERFUNCTION => [$this, 'headerfunction'],
163
		];
164
	}
165
166
	/**
167
	 * @param array $options
168
	 *
169
	 * @return array
170
	 */
171
	protected function setBodyOptions(array $options):array{
172
		$body     = $this->request->getBody();
173
		$bodySize = $body->getSize();
174
175
		if($bodySize === 0){
176
			return $options;
177
		}
178
179
		if($body->isSeekable()){
180
			$body->rewind();
181
		}
182
183
		// Message has non empty body.
184
		if($bodySize === null || $bodySize > 1 << 20){
185
			// Avoid full loading large or unknown size body into memory
186
			$options[CURLOPT_UPLOAD] = true;
187
188
			if($bodySize !== null){
189
				$options[CURLOPT_INFILESIZE] = $bodySize;
190
			}
191
192
			$options[CURLOPT_READFUNCTION] = [$this, 'readfunction'];
193
		}
194
		// Small body can be loaded into memory
195
		else{
196
			$options[CURLOPT_POSTFIELDS] = (string)$body;
197
		}
198
199
		return $options;
200
	}
201
202
	/**
203
	 * @param array $options
204
	 *
205
	 * @return array
206
	 */
207
	protected function setHeaderOptions(array $options):array{
208
		$headers = [];
209
210
		foreach($this->request->getHeaders() as $name => $values){
211
			$header = strtolower($name);
212
213
			// curl-client does not support "Expect-Continue", so dropping "expect" headers
214
			if($header === 'expect'){
215
				continue;
216
			}
217
218
			if($header === 'content-length'){
219
220
				// Small body content length can be calculated here.
221
				if(array_key_exists(CURLOPT_POSTFIELDS, $options)){
222
					$values = [strlen($options[CURLOPT_POSTFIELDS])];
223
				}
224
				// Else if there is no body, forcing "Content-length" to 0
225
				elseif(!array_key_exists(CURLOPT_READFUNCTION, $options)){
226
					$values = ['0'];
227
				}
228
229
			}
230
231
			foreach($values as $value){
232
				$value = (string)$value;
233
234
				// cURL requires a special format for empty headers.
235
				// See https://github.com/guzzle/guzzle/issues/1882 for more details.
236
				$headers[] = $value === '' ? $name.';' : $name.': '.$value;
237
			}
238
239
		}
240
241
		$options[CURLOPT_HTTPHEADER] = $headers;
242
243
		// If the Expect header is not present, prevent curl from adding it
244
		if(!$this->request->hasHeader('Expect')){
245
			$options[CURLOPT_HTTPHEADER][] = 'Expect:';
246
		}
247
248
		// cURL sometimes adds a content-type by default. Prevent this.
249
		if(!$this->request->hasHeader('Content-Type')){
250
			$options[CURLOPT_HTTPHEADER][] = 'Content-Type:';
251
		}
252
253
		return $options;
254
	}
255
256
	/**
257
	 * @return \chillerlan\HTTP\CurlUtils\CurlHandle
258
	 */
259
	public function init():CurlHandle{
260
		$options  = $this->initCurlOptions();
261
		$userinfo = $this->request->getUri()->getUserInfo();
262
		$method   = $this->request->getMethod();
263
264
		if(!empty($userinfo)){
265
			$options[CURLOPT_USERPWD] = $userinfo;
266
		}
267
268
		/*
269
		 * Some HTTP methods cannot have payload:
270
		 *
271
		 * - GET   — cURL will automatically change the method to PUT or POST
272
		 *           if we set CURLOPT_UPLOAD or CURLOPT_POSTFIELDS.
273
		 * - HEAD  — cURL treats HEAD as a GET request with same restrictions.
274
		 * - TRACE — According to RFC7231: a client MUST NOT send a message body in a TRACE request.
275
		 */
276
277
		if(in_array($method, ['DELETE', 'PATCH', 'POST', 'PUT'], true)){
278
			$options = $this->setBodyOptions($options);
279
		}
280
281
		// This will set HTTP method to "HEAD".
282
		if($method === 'HEAD'){
283
			$options[CURLOPT_NOBODY] = true;
284
		}
285
286
		// GET is a default method. Other methods should be specified explicitly.
287
		if($method !== 'GET'){
288
			$options[CURLOPT_CUSTOMREQUEST] = $method;
289
		}
290
291
		// overwrite the default values with $curl_options
292
		foreach($this->options->curl_options as $k => $v){
293
			// skip some options that are only set automatically
294
			if(in_array($k, [CURLOPT_HTTPHEADER, CURLOPT_CUSTOMREQUEST, CURLOPT_NOBODY], true)){
295
				continue;
296
			}
297
298
			$options[$k] = $v;
299
		}
300
301
		$options = $this->setHeaderOptions($options);
302
303
		curl_setopt_array($this->curl, $options);
304
305
		return $this;
306
	}
307
308
	/**
309
	 * @internal
310
	 *
311
	 * @param resource $curl
312
	 * @param resource $stream
313
	 * @param int      $length
314
	 *
315
	 * @return string
316
	 */
317
	public function readfunction($curl, $stream, int $length):string{
318
		return $this->request->getBody()->read($length);
319
	}
320
321
	/**
322
	 * @internal
323
	 *
324
	 * @param resource $curl
325
	 * @param string   $data
326
	 *
327
	 * @return int
328
	 */
329
	public function writefunction($curl, string $data):int{
330
		return $this->response->getBody()->write($data);
331
	}
332
333
	/**
334
	 * @internal
335
	 *
336
	 * @param resource $curl
337
	 * @param string   $line
338
	 *
339
	 * @return int
340
	 */
341
	public function headerfunction($curl, string $line):int{
342
		$str    = trim($line);
343
		$header = explode(':', $str, 2);
344
345
		if(count($header) === 2){
346
			$this->response = $this->response
347
				->withAddedHeader(trim($header[0]), trim($header[1]));
348
		}
349
		elseif(substr(strtoupper($str), 0, 5) === 'HTTP/'){
350
			$status = explode(' ', $str, 3);
351
			$reason = count($status) > 2 ? trim($status[2]) : '';
352
353
			$this->response = $this->response
354
				->withStatus((int)$status[1], $reason)
355
				->withProtocolVersion(substr($status[0], 5))
356
			;
357
		}
358
359
		return strlen($line);
360
	}
361
362
}
363