Passed
Push — main ( 01771e...4cb28c )
by smiley
01:36
created

CurlHandle::getCurlOptions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 2
rs 10
c 0
b 0
f 0
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_errno, curl_error, curl_exec, curl_init, curl_setopt_array,
17
	explode, in_array, is_resource, strlen, strtolower, strtoupper, substr, trim;
18
19
use const CURL_HTTP_VERSION_2TLS, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_RESOLVE_PROXY,
20
	CURLE_GOT_NOTHING, CURLE_OPERATION_TIMEOUTED, CURLE_SSL_CONNECT_ERROR, CURLOPT_CAINFO, CURLOPT_CAPATH,
21
	CURLOPT_CONNECTTIMEOUT, CURLOPT_CUSTOMREQUEST, CURLOPT_FOLLOWLOCATION, CURLOPT_HEADER, CURLOPT_HEADERFUNCTION,
22
	CURLOPT_HTTP_VERSION, CURLOPT_HTTPHEADER, CURLOPT_INFILESIZE, CURLOPT_MAXREDIRS, CURLOPT_NOBODY, CURLOPT_POSTFIELDS,
23
	CURLOPT_PROTOCOLS, CURLOPT_READFUNCTION, CURLOPT_REDIR_PROTOCOLS, CURLOPT_RETURNTRANSFER, CURLOPT_SSL_VERIFYHOST,
24
	CURLOPT_SSL_VERIFYPEER, CURLOPT_SSL_VERIFYSTATUS, CURLOPT_TIMEOUT, CURLOPT_UPLOAD, CURLOPT_URL, CURLOPT_USERAGENT,
25
	CURLOPT_USERPWD, CURLOPT_WRITEFUNCTION, CURLPROTO_HTTP, CURLPROTO_HTTPS;
26
27
class CurlHandle{
28
29
	public 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
	public 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
	 * @phan-suppress PhanUndeclaredTypeProperty
80
	 * @var resource|\CurlHandle|null
81
	 */
82
	protected $curl;
83
84
	protected array $curlOptions = [];
85
86
	protected bool $initialized = false;
87
88
	/**
89
	 * @var \chillerlan\Settings\SettingsContainerInterface|\chillerlan\HTTP\HTTPOptions
90
	 */
91
	protected SettingsContainerInterface $options;
92
93
	protected RequestInterface $request;
94
95
	protected ResponseInterface $response;
96
97
	/**
98
	 * CurlHandle constructor.
99
	 */
100
	public function __construct(RequestInterface $request, ResponseInterface $response, SettingsContainerInterface $options){
101
		$this->request  = $request;
102
		$this->response = $response;
103
		$this->options  = $options;
104
		$this->curl     = curl_init();
105
	}
106
107
	/**
108
	 * close an existing cURL handle on exit
109
	 */
110
	public function __destruct(){
111
		$this->close();
112
	}
113
114
	/**
115
	 *
116
	 */
117
	public function close():CurlHandle{
118
119
		if(is_resource($this->curl)){
120
			/** @phan-suppress-next-line PhanTypeMismatchArgumentInternalReal */
121
			curl_close($this->curl);
122
		}
123
124
		return $this;
125
	}
126
127
	/**
128
	 * @phan-suppress PhanUndeclaredTypeReturnType
129
	 * @return resource|\CurlHandle|null
130
	 * @codeCoverageIgnore
131
	 */
132
	public function getCurlResource(){
133
		return $this->curl;
134
	}
135
136
	/**
137
	 * @codeCoverageIgnore
138
	 */
139
	public function getRequest():RequestInterface{
140
		return $this->request;
141
	}
142
143
	/**
144
	 * @codeCoverageIgnore
145
	 */
146
	public function getResponse():ResponseInterface{
147
		return $this->response;
148
	}
149
150
	/**
151
	 * @codeCoverageIgnore
152
	 */
153
	public function getCurlOptions():array{
154
		return $this->curlOptions;
155
	}
156
157
	/**
158
	 * @link https://php.watch/articles/php-curl-security-hardening
159
	 */
160
	protected function initCurlOptions():array{
161
		$this->curlOptions = [
162
			CURLOPT_HEADER           => false,
163
			CURLOPT_RETURNTRANSFER   => true,
164
			CURLOPT_FOLLOWLOCATION   => false,
165
			CURLOPT_MAXREDIRS        => 5,
166
			CURLOPT_URL              => (string)$this->request->getUri()->withFragment(''),
167
			CURLOPT_HTTP_VERSION     => CURL_HTTP_VERSION_2TLS,
168
			CURLOPT_USERAGENT        => $this->options->user_agent,
169
			CURLOPT_PROTOCOLS        => CURLPROTO_HTTP | CURLPROTO_HTTPS,
170
			CURLOPT_REDIR_PROTOCOLS  => CURLPROTO_HTTPS,
171
			CURLOPT_TIMEOUT          => $this->options->timeout,
172
			CURLOPT_CONNECTTIMEOUT   => 30,
173
		];
174
175
		$this->setSSLOptions();
176
		$this->setRequestOptions();
177
		$this->setHeaderOptions();
178
179
		return $this->curlOptions;
180
	}
181
182
	/**
183
	 *
184
	 */
185
	protected function setBodyOptions():void{
186
		$body     = $this->request->getBody();
187
		$bodySize = $body->getSize();
188
189
		if($bodySize === 0){
190
			return;
191
		}
192
193
		if($body->isSeekable()){
194
			$body->rewind();
195
		}
196
197
		// Message has non empty body.
198
		if($bodySize === null || $bodySize > (1 << 20)){
199
			// Avoid full loading large or unknown size body into memory
200
			$this->curlOptions[CURLOPT_UPLOAD] = true;
201
202
			if($bodySize !== null){
203
				$this->curlOptions[CURLOPT_INFILESIZE] = $bodySize;
204
			}
205
206
			$this->curlOptions[CURLOPT_READFUNCTION] = [$this, 'readfunction'];
207
		}
208
		// Small body can be loaded into memory
209
		else{
210
			$this->curlOptions[CURLOPT_POSTFIELDS] = (string)$body;
211
		}
212
	}
213
214
	/**
215
	 *
216
	 */
217
	protected function setSSLOptions():void{
218
		$this->curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
219
		$this->curlOptions[CURLOPT_SSL_VERIFYPEER] = false;
220
221
		if($this->options->ca_info !== null){
222
			$opt                     = $this->options->ca_info_is_path ? CURLOPT_CAPATH : CURLOPT_CAINFO;
223
			$this->curlOptions[$opt] = $this->options->ca_info;
224
225
			if($this->options->ssl_verifypeer){
226
				$this->curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
227
			}
228
229
			if($this->options->curl_check_OCSP){
230
				$this->curlOptions[CURLOPT_SSL_VERIFYSTATUS] = true;
231
			}
232
		}
233
234
	}
235
236
	/**
237
	 *
238
	 */
239
	protected function setHeaderOptions():void{
240
		$headers = [];
241
242
		foreach($this->request->getHeaders() as $name => $values){
243
			$header = strtolower($name);
244
245
			// curl-client does not support "Expect-Continue", so dropping "expect" headers
246
			if($header === 'expect'){
247
				continue;
248
			}
249
250
			if($header === 'content-length'){
251
252
				// Small body content length can be calculated here.
253
				if(array_key_exists(CURLOPT_POSTFIELDS, $this->curlOptions)){
254
					$values = [strlen($this->curlOptions[CURLOPT_POSTFIELDS])];
255
				}
256
				// Else if there is no body, forcing "Content-length" to 0
257
				elseif(!array_key_exists(CURLOPT_READFUNCTION, $this->curlOptions)){
258
					$values = ['0'];
259
				}
260
261
			}
262
263
			foreach($values as $value){
264
				$value = (string)$value;
265
266
				// cURL requires a special format for empty headers.
267
				// See https://github.com/guzzle/guzzle/issues/1882 for more details.
268
				$headers[] = $value === '' ? $name.';' : $name.': '.$value;
269
			}
270
271
		}
272
273
		// If the Expect header is not present (it isn't), prevent curl from adding it
274
		$headers[] = 'Expect:';
275
276
		// cURL sometimes adds a content-type by default. Prevent this.
277
		if(!$this->request->hasHeader('Content-Type')){
278
			$headers[] = 'Content-Type:';
279
		}
280
281
		$this->curlOptions[CURLOPT_HTTPHEADER] = $headers;
282
	}
283
284
	/**
285
	 *
286
	 */
287
	public function setRequestOptions():void{
288
		$userinfo = $this->request->getUri()->getUserInfo();
289
		$method   = $this->request->getMethod();
290
291
		if(!empty($userinfo)){
292
			$this->curlOptions[CURLOPT_USERPWD] = $userinfo;
293
		}
294
295
		/*
296
		 * Some HTTP methods cannot have payload:
297
		 *
298
		 * - GET   — cURL will automatically change the method to PUT or POST
299
		 *           if we set CURLOPT_UPLOAD or CURLOPT_POSTFIELDS.
300
		 * - HEAD  — cURL treats HEAD as a GET request with same restrictions.
301
		 * - TRACE — According to RFC7231: a client MUST NOT send a message body in a TRACE request.
302
		 */
303
304
		if(in_array($method, ['DELETE', 'PATCH', 'POST', 'PUT'], true)){
305
			$this->setBodyOptions();
306
		}
307
308
		// This will set HTTP method to "HEAD".
309
		if($method === 'HEAD'){
310
			$this->curlOptions[CURLOPT_NOBODY] = true;
311
		}
312
313
		// GET is a default method. Other methods should be specified explicitly.
314
		if($method !== 'GET'){
315
			$this->curlOptions[CURLOPT_CUSTOMREQUEST] = $method;
316
		}
317
318
		// overwrite the default values with $curl_options
319
		foreach($this->options->curl_options as $k => $v){
320
			// skip some options that are only set automatically
321
			if(in_array($k, [CURLOPT_HTTPHEADER, CURLOPT_CUSTOMREQUEST, CURLOPT_NOBODY], true)){
322
				continue;
323
			}
324
325
			$this->curlOptions[$k] = $v;
326
		}
327
328
	}
329
330
	/**
331
	 * @phan-suppress PhanUndeclaredTypeReturnType
332
	 * @return resource|\CurlHandle|null
333
	 */
334
	public function init(){
335
		$options = $this->initCurlOptions();
336
337
		if(!isset($options[CURLOPT_HEADERFUNCTION])){
338
			$options[CURLOPT_HEADERFUNCTION] = [$this, 'headerfunction'];
339
		}
340
341
		if(!isset($options[CURLOPT_WRITEFUNCTION])){
342
			$options[CURLOPT_WRITEFUNCTION] = [$this, 'writefunction'];
343
		}
344
345
		curl_setopt_array($this->curl, $options);
346
347
		$this->initialized = true;
348
349
		return $this->curl;
350
	}
351
352
	/**
353
	 *
354
	 */
355
	public function exec():int{
356
357
		if(!$this->initialized){
358
			$this->init();
359
		}
360
361
		curl_exec($this->curl);
362
363
		return curl_errno($this->curl);
364
	}
365
366
	/**
367
	 *
368
	 */
369
	public function getError():string{
370
		return curl_error($this->curl);
371
	}
372
373
	/**
374
	 * @internal
375
	 *
376
	 * @param resource $curl
377
	 * @param resource $stream
378
	 * @param int      $length
379
	 *
380
	 * @return string
381
	 * @noinspection PhpUnusedParameterInspection
382
	 */
383
	public function readfunction($curl, $stream, int $length):string{
384
		return $this->request->getBody()->read($length);
385
	}
386
387
	/**
388
	 * @internal
389
	 *
390
	 * @param resource $curl
391
	 * @param string   $data
392
	 *
393
	 * @return int
394
	 * @noinspection PhpUnusedParameterInspection
395
	 */
396
	public function writefunction($curl, string $data):int{
397
		return $this->response->getBody()->write($data);
398
	}
399
400
	/**
401
	 * @internal
402
	 *
403
	 * @param resource $curl
404
	 * @param string   $line
405
	 *
406
	 * @return int
407
	 * @noinspection PhpUnusedParameterInspection
408
	 */
409
	public function headerfunction($curl, string $line):int{
410
		$str    = trim($line);
411
		$header = explode(':', $str, 2);
412
413
		if(count($header) === 2){
414
			$this->response = $this->response
415
				->withAddedHeader(trim($header[0]), trim($header[1]));
416
		}
417
		elseif(substr(strtoupper($str), 0, 5) === 'HTTP/'){
418
			$status = explode(' ', $str, 3);
419
			$reason = count($status) > 2 ? trim($status[2]) : '';
420
421
			$this->response = $this->response
422
				->withStatus((int)$status[1], $reason)
423
				->withProtocolVersion(substr($status[0], 5))
424
			;
425
		}
426
427
		return strlen($line);
428
	}
429
430
}
431