• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 8586246081

07 Apr 2024 04:43AM UTC coverage: 86.602% (+1.0%) from 85.607%
8586246081

push

github

web-flow
Merge pull request #8720 from codeigniter4/4.5

Merge 4.5 into develop

2273 of 2603 new or added lines in 188 files covered. (87.32%)

53 existing lines in 18 files now uncovered.

19947 of 23033 relevant lines covered (86.6%)

189.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.52
/system/HTTP/CURLRequest.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\HTTP;
15

16
use CodeIgniter\HTTP\Exceptions\HTTPException;
17
use Config\App;
18
use Config\CURLRequest as ConfigCURLRequest;
19
use InvalidArgumentException;
20

21
/**
22
 * A lightweight HTTP client for sending synchronous HTTP requests via cURL.
23
 *
24
 * @see \CodeIgniter\HTTP\CURLRequestTest
25
 */
26
class CURLRequest extends OutgoingRequest
27
{
28
    /**
29
     * The response object associated with this request
30
     *
31
     * @var ResponseInterface|null
32
     */
33
    protected $response;
34

35
    /**
36
     * The original response object associated with this request
37
     *
38
     * @var ResponseInterface|null
39
     */
40
    protected $responseOrig;
41

42
    /**
43
     * The URI associated with this request
44
     *
45
     * @var URI
46
     */
47
    protected $baseURI;
48

49
    /**
50
     * The setting values
51
     *
52
     * @var array
53
     */
54
    protected $config;
55

56
    /**
57
     * The default setting values
58
     *
59
     * @var array
60
     */
61
    protected $defaultConfig = [
62
        'timeout'         => 0.0,
63
        'connect_timeout' => 150,
64
        'debug'           => false,
65
        'verify'          => true,
66
    ];
67

68
    /**
69
     * Default values for when 'allow_redirects'
70
     * option is true.
71
     *
72
     * @var array
73
     */
74
    protected $redirectDefaults = [
75
        'max'       => 5,
76
        'strict'    => true,
77
        'protocols' => [
78
            'http',
79
            'https',
80
        ],
81
    ];
82

83
    /**
84
     * The number of milliseconds to delay before
85
     * sending the request.
86
     *
87
     * @var float
88
     */
89
    protected $delay = 0.0;
90

91
    /**
92
     * The default options from the constructor. Applied to all requests.
93
     */
94
    private readonly array $defaultOptions;
95

96
    /**
97
     * Whether share options between requests or not.
98
     *
99
     * If true, all the options won't be reset between requests.
100
     * It may cause an error request with unnecessary headers.
101
     */
102
    private readonly bool $shareOptions;
103

104
    /**
105
     * Takes an array of options to set the following possible class properties:
106
     *
107
     *  - baseURI
108
     *  - timeout
109
     *  - any other request options to use as defaults.
110
     */
111
    public function __construct(App $config, URI $uri, ?ResponseInterface $response = null, array $options = [])
112
    {
113
        if (! function_exists('curl_version')) {
131✔
114
            throw HTTPException::forMissingCurl(); // @codeCoverageIgnore
115
        }
116

117
        parent::__construct(Method::GET, $uri);
131✔
118

119
        $this->responseOrig   = $response ?? new Response(config(App::class));
131✔
120
        $this->baseURI        = $uri->useRawQueryString();
131✔
121
        $this->defaultOptions = $options;
131✔
122

123
        /** @var ConfigCURLRequest|null $configCURLRequest */
124
        $configCURLRequest  = config(ConfigCURLRequest::class);
131✔
125
        $this->shareOptions = $configCURLRequest->shareOptions ?? true;
131✔
126

127
        $this->config = $this->defaultConfig;
131✔
128
        $this->parseOptions($options);
131✔
129
    }
130

131
    /**
132
     * Sends an HTTP request to the specified $url. If this is a relative
133
     * URL, it will be merged with $this->baseURI to form a complete URL.
134
     *
135
     * @param string $method HTTP method
136
     */
137
    public function request($method, string $url, array $options = []): ResponseInterface
138
    {
139
        $this->response = clone $this->responseOrig;
115✔
140

141
        $this->parseOptions($options);
115✔
142

143
        $url = $this->prepareURL($url);
115✔
144

145
        $method = esc(strip_tags($method));
115✔
146

147
        $this->send($method, $url);
115✔
148

149
        if ($this->shareOptions === false) {
111✔
150
            $this->resetOptions();
56✔
151
        }
152

153
        return $this->response;
111✔
154
    }
155

156
    /**
157
     * Reset all options to default.
158
     *
159
     * @return void
160
     */
161
    protected function resetOptions()
162
    {
163
        // Reset headers
164
        $this->headers   = [];
56✔
165
        $this->headerMap = [];
56✔
166

167
        // Reset body
168
        $this->body = null;
56✔
169

170
        // Reset configs
171
        $this->config = $this->defaultConfig;
56✔
172

173
        // Set the default options for next request
174
        $this->parseOptions($this->defaultOptions);
56✔
175
    }
176

177
    /**
178
     * Convenience method for sending a GET request.
179
     */
180
    public function get(string $url, array $options = []): ResponseInterface
181
    {
182
        return $this->request(Method::GET, $url, $options);
33✔
183
    }
184

185
    /**
186
     * Convenience method for sending a DELETE request.
187
     */
188
    public function delete(string $url, array $options = []): ResponseInterface
189
    {
190
        return $this->request('DELETE', $url, $options);
2✔
191
    }
192

193
    /**
194
     * Convenience method for sending a HEAD request.
195
     */
196
    public function head(string $url, array $options = []): ResponseInterface
197
    {
198
        return $this->request('HEAD', $url, $options);
2✔
199
    }
200

201
    /**
202
     * Convenience method for sending an OPTIONS request.
203
     */
204
    public function options(string $url, array $options = []): ResponseInterface
205
    {
206
        return $this->request('OPTIONS', $url, $options);
2✔
207
    }
208

209
    /**
210
     * Convenience method for sending a PATCH request.
211
     */
212
    public function patch(string $url, array $options = []): ResponseInterface
213
    {
214
        return $this->request('PATCH', $url, $options);
2✔
215
    }
216

217
    /**
218
     * Convenience method for sending a POST request.
219
     */
220
    public function post(string $url, array $options = []): ResponseInterface
221
    {
222
        return $this->request(Method::POST, $url, $options);
14✔
223
    }
224

225
    /**
226
     * Convenience method for sending a PUT request.
227
     */
228
    public function put(string $url, array $options = []): ResponseInterface
229
    {
230
        return $this->request(Method::PUT, $url, $options);
2✔
231
    }
232

233
    /**
234
     * Set the HTTP Authentication.
235
     *
236
     * @param string $type basic or digest
237
     *
238
     * @return $this
239
     */
240
    public function setAuth(string $username, string $password, string $type = 'basic')
241
    {
242
        $this->config['auth'] = [
4✔
243
            $username,
4✔
244
            $password,
4✔
245
            $type,
4✔
246
        ];
4✔
247

248
        return $this;
4✔
249
    }
250

251
    /**
252
     * Set form data to be sent.
253
     *
254
     * @param bool $multipart Set TRUE if you are sending CURLFiles
255
     *
256
     * @return $this
257
     */
258
    public function setForm(array $params, bool $multipart = false)
259
    {
260
        if ($multipart) {
2✔
261
            $this->config['multipart'] = $params;
2✔
262
        } else {
263
            $this->config['form_params'] = $params;
2✔
264
        }
265

266
        return $this;
2✔
267
    }
268

269
    /**
270
     * Set JSON data to be sent.
271
     *
272
     * @param array|bool|float|int|object|string|null $data
273
     *
274
     * @return $this
275
     */
276
    public function setJSON($data)
277
    {
278
        $this->config['json'] = $data;
2✔
279

280
        return $this;
2✔
281
    }
282

283
    /**
284
     * Sets the correct settings based on the options array
285
     * passed in.
286
     *
287
     * @return void
288
     */
289
    protected function parseOptions(array $options)
290
    {
291
        if (array_key_exists('baseURI', $options)) {
132✔
292
            $this->baseURI = $this->baseURI->setURI($options['baseURI']);
2✔
293
            unset($options['baseURI']);
2✔
294
        }
295

296
        if (array_key_exists('headers', $options) && is_array($options['headers'])) {
132✔
297
            foreach ($options['headers'] as $name => $value) {
6✔
298
                $this->setHeader($name, $value);
6✔
299
            }
300

301
            unset($options['headers']);
6✔
302
        }
303

304
        if (array_key_exists('delay', $options)) {
132✔
305
            // Convert from the milliseconds passed in
306
            // to the seconds that sleep requires.
307
            $this->delay = (float) $options['delay'] / 1000;
20✔
308
            unset($options['delay']);
20✔
309
        }
310

311
        if (array_key_exists('body', $options)) {
132✔
312
            $this->setBody($options['body']);
2✔
313
            unset($options['body']);
2✔
314
        }
315

316
        foreach ($options as $key => $value) {
132✔
317
            $this->config[$key] = $value;
94✔
318
        }
319
    }
320

321
    /**
322
     * If the $url is a relative URL, will attempt to create
323
     * a full URL by prepending $this->baseURI to it.
324
     */
325
    protected function prepareURL(string $url): string
326
    {
327
        // If it's a full URI, then we have nothing to do here...
328
        if (str_contains($url, '://')) {
117✔
329
            return $url;
66✔
330
        }
331

332
        $uri = $this->baseURI->resolveRelativeURI($url);
51✔
333

334
        // Create the string instead of casting to prevent baseURL muddling
335
        return URI::createURIString(
51✔
336
            $uri->getScheme(),
51✔
337
            $uri->getAuthority(),
51✔
338
            $uri->getPath(),
51✔
339
            $uri->getQuery(),
51✔
340
            $uri->getFragment()
51✔
341
        );
51✔
342
    }
343

344
    /**
345
     * Fires the actual cURL request.
346
     *
347
     * @return ResponseInterface
348
     */
349
    public function send(string $method, string $url)
350
    {
351
        // Reset our curl options so we're on a fresh slate.
352
        $curlOptions = [];
117✔
353

354
        if (! empty($this->config['query']) && is_array($this->config['query'])) {
117✔
355
            // This is likely too naive a solution.
356
            // Should look into handling when $url already
357
            // has query vars on it.
358
            $url .= '?' . http_build_query($this->config['query']);
2✔
359
            unset($this->config['query']);
2✔
360
        }
361

362
        $curlOptions[CURLOPT_URL]            = $url;
117✔
363
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
117✔
364
        $curlOptions[CURLOPT_HEADER]         = true;
117✔
365
        $curlOptions[CURLOPT_FRESH_CONNECT]  = true;
117✔
366
        // Disable @file uploads in post data.
367
        $curlOptions[CURLOPT_SAFE_UPLOAD] = true;
117✔
368

369
        $curlOptions = $this->setCURLOptions($curlOptions, $this->config);
117✔
370
        $curlOptions = $this->applyMethod($method, $curlOptions);
113✔
371
        $curlOptions = $this->applyRequestHeaders($curlOptions);
113✔
372

373
        // Do we need to delay this request?
374
        if ($this->delay > 0) {
113✔
375
            usleep((int) $this->delay * 1_000_000);
18✔
376
        }
377

378
        $output = $this->sendRequest($curlOptions);
113✔
379

380
        // Set the string we want to break our response from
381
        $breakString = "\r\n\r\n";
113✔
382

383
        while (str_starts_with($output, 'HTTP/1.1 100 Continue')) {
113✔
384
            $output = substr($output, strpos($output, $breakString) + 4);
5✔
385
        }
386

387
        if (str_starts_with($output, 'HTTP/1.1 200 Connection established')) {
113✔
388
            $output = substr($output, strpos($output, $breakString) + 4);
1✔
389
        }
390

391
        // If request and response have Digest
392
        if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest' && str_contains($output, 'WWW-Authenticate: Digest')) {
113✔
393
            $output = substr($output, strpos($output, $breakString) + 4);
4✔
394
        }
395

396
        // Split out our headers and body
397
        $break = strpos($output, $breakString);
113✔
398

399
        if ($break !== false) {
113✔
400
            // Our headers
401
            $headers = explode("\n", substr($output, 0, $break));
18✔
402

403
            $this->setResponseHeaders($headers);
18✔
404

405
            // Our body
406
            $body = substr($output, $break + 4);
18✔
407
            $this->response->setBody($body);
18✔
408
        } else {
409
            $this->response->setBody($output);
95✔
410
        }
411

412
        return $this->response;
113✔
413
    }
414

415
    /**
416
     * Adds $this->headers to the cURL request.
417
     */
418
    protected function applyRequestHeaders(array $curlOptions = []): array
419
    {
420
        if (empty($this->headers)) {
113✔
421
            return $curlOptions;
61✔
422
        }
423

424
        $set = [];
55✔
425

426
        foreach (array_keys($this->headers) as $name) {
55✔
427
            $set[] = $name . ': ' . $this->getHeaderLine($name);
55✔
428
        }
429

430
        $curlOptions[CURLOPT_HTTPHEADER] = $set;
55✔
431

432
        return $curlOptions;
55✔
433
    }
434

435
    /**
436
     * Apply method
437
     */
438
    protected function applyMethod(string $method, array $curlOptions): array
439
    {
440
        $this->method                       = $method;
113✔
441
        $curlOptions[CURLOPT_CUSTOMREQUEST] = $method;
113✔
442

443
        $size = strlen($this->body ?? '');
113✔
444

445
        // Have content?
446
        if ($size > 0) {
113✔
447
            return $this->applyBody($curlOptions);
9✔
448
        }
449

450
        if ($method === Method::PUT || $method === Method::POST) {
105✔
451
            // See http://tools.ietf.org/html/rfc7230#section-3.3.2
452
            if ($this->header('content-length') === null && ! isset($this->config['multipart'])) {
25✔
453
                $this->setHeader('Content-Length', '0');
14✔
454
            }
455
        } elseif ($method === 'HEAD') {
83✔
456
            $curlOptions[CURLOPT_NOBODY] = 1;
2✔
457
        }
458

459
        return $curlOptions;
105✔
460
    }
461

462
    /**
463
     * Apply body
464
     */
465
    protected function applyBody(array $curlOptions = []): array
466
    {
467
        if (! empty($this->body)) {
9✔
468
            $curlOptions[CURLOPT_POSTFIELDS] = (string) $this->getBody();
9✔
469
        }
470

471
        return $curlOptions;
9✔
472
    }
473

474
    /**
475
     * Parses the header retrieved from the cURL response into
476
     * our Response object.
477
     *
478
     * @return void
479
     */
480
    protected function setResponseHeaders(array $headers = [])
481
    {
482
        foreach ($headers as $header) {
18✔
483
            if (($pos = strpos($header, ':')) !== false) {
18✔
484
                $title = trim(substr($header, 0, $pos));
17✔
485
                $value = trim(substr($header, $pos + 1));
17✔
486

487
                if ($this->response instanceof Response) {
17✔
488
                    $this->response->addHeader($title, $value);
17✔
489
                } else {
NEW
490
                    $this->response->setHeader($title, $value);
×
491
                }
492
            } elseif (str_starts_with($header, 'HTTP')) {
16✔
493
                preg_match('#^HTTP\/([12](?:\.[01])?) (\d+) (.+)#', $header, $matches);
16✔
494

495
                if (isset($matches[1])) {
16✔
496
                    $this->response->setProtocolVersion($matches[1]);
15✔
497
                }
498

499
                if (isset($matches[2])) {
16✔
500
                    $this->response->setStatusCode((int) $matches[2], $matches[3] ?? null);
15✔
501
                }
502
            }
503
        }
504
    }
505

506
    /**
507
     * Set CURL options
508
     *
509
     * @return array
510
     *
511
     * @throws InvalidArgumentException
512
     */
513
    protected function setCURLOptions(array $curlOptions = [], array $config = [])
514
    {
515
        // Auth Headers
516
        if (! empty($config['auth'])) {
117✔
517
            $curlOptions[CURLOPT_USERPWD] = $config['auth'][0] . ':' . $config['auth'][1];
10✔
518

519
            if (! empty($config['auth'][2]) && strtolower($config['auth'][2]) === 'digest') {
10✔
520
                $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_DIGEST;
4✔
521
            } else {
522
                $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
6✔
523
            }
524
        }
525

526
        // Certificate
527
        if (! empty($config['cert'])) {
117✔
528
            $cert = $config['cert'];
6✔
529

530
            if (is_array($cert)) {
6✔
531
                $curlOptions[CURLOPT_SSLCERTPASSWD] = $cert[1];
2✔
532
                $cert                               = $cert[0];
2✔
533
            }
534

535
            if (! is_file($cert)) {
6✔
536
                throw HTTPException::forSSLCertNotFound($cert);
2✔
537
            }
538

539
            $curlOptions[CURLOPT_SSLCERT] = $cert;
4✔
540
        }
541

542
        // SSL Verification
543
        if (isset($config['verify'])) {
115✔
544
            if (is_string($config['verify'])) {
115✔
545
                $file = realpath($config['verify']) ?: $config['verify'];
4✔
546

547
                if (! is_file($file)) {
4✔
548
                    throw HTTPException::forInvalidSSLKey($config['verify']);
2✔
549
                }
550

551
                $curlOptions[CURLOPT_CAINFO]         = $file;
2✔
552
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
2✔
553
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
2✔
554
            } elseif (is_bool($config['verify'])) {
111✔
555
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = $config['verify'];
111✔
556
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = $config['verify'] ? 2 : 0;
111✔
557
            }
558
        }
559

560
        // Proxy
561
        if (isset($config['proxy'])) {
113✔
562
            $curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true;
2✔
563
            $curlOptions[CURLOPT_PROXY]           = $config['proxy'];
2✔
564
        }
565

566
        // Debug
567
        if ($config['debug']) {
113✔
568
            $curlOptions[CURLOPT_VERBOSE] = 1;
4✔
569
            $curlOptions[CURLOPT_STDERR]  = is_string($config['debug']) ? fopen($config['debug'], 'a+b') : fopen('php://stderr', 'wb');
4✔
570
        }
571

572
        // Decode Content
573
        if (! empty($config['decode_content'])) {
113✔
574
            $accept = $this->getHeaderLine('Accept-Encoding');
4✔
575

576
            if ($accept !== '') {
4✔
577
                $curlOptions[CURLOPT_ENCODING] = $accept;
2✔
578
            } else {
579
                $curlOptions[CURLOPT_ENCODING]   = '';
2✔
580
                $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding';
2✔
581
            }
582
        }
583

584
        // Allow Redirects
585
        if (array_key_exists('allow_redirects', $config)) {
113✔
586
            $settings = $this->redirectDefaults;
8✔
587

588
            if (is_array($config['allow_redirects'])) {
8✔
589
                $settings = array_merge($settings, $config['allow_redirects']);
2✔
590
            }
591

592
            if ($config['allow_redirects'] === false) {
8✔
593
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 0;
2✔
594
            } else {
595
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 1;
6✔
596
                $curlOptions[CURLOPT_MAXREDIRS]      = $settings['max'];
6✔
597

598
                if ($settings['strict'] === true) {
6✔
599
                    $curlOptions[CURLOPT_POSTREDIR] = 1 | 2 | 4;
6✔
600
                }
601

602
                $protocols = 0;
6✔
603

604
                foreach ($settings['protocols'] as $proto) {
6✔
605
                    $protocols += constant('CURLPROTO_' . strtoupper($proto));
6✔
606
                }
607

608
                $curlOptions[CURLOPT_REDIR_PROTOCOLS] = $protocols;
6✔
609
            }
610
        }
611

612
        // Timeout
613
        $curlOptions[CURLOPT_TIMEOUT_MS] = (float) $config['timeout'] * 1000;
113✔
614

615
        // Connection Timeout
616
        $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) $config['connect_timeout'] * 1000;
113✔
617

618
        // Post Data - application/x-www-form-urlencoded
619
        if (! empty($config['form_params']) && is_array($config['form_params'])) {
113✔
620
            $postFields                      = http_build_query($config['form_params']);
9✔
621
            $curlOptions[CURLOPT_POSTFIELDS] = $postFields;
9✔
622

623
            // Ensure content-length is set, since CURL doesn't seem to
624
            // calculate it when HTTPHEADER is set.
625
            $this->setHeader('Content-Length', (string) strlen($postFields));
9✔
626
            $this->setHeader('Content-Type', 'application/x-www-form-urlencoded');
9✔
627
        }
628

629
        // Post Data - multipart/form-data
630
        if (! empty($config['multipart']) && is_array($config['multipart'])) {
113✔
631
            // setting the POSTFIELDS option automatically sets multipart
632
            $curlOptions[CURLOPT_POSTFIELDS] = $config['multipart'];
4✔
633
        }
634

635
        // HTTP Errors
636
        $curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true;
113✔
637

638
        // JSON
639
        if (isset($config['json'])) {
113✔
640
            // Will be set as the body in `applyBody()`
641
            $json = json_encode($config['json']);
4✔
642
            $this->setBody($json);
4✔
643
            $this->setHeader('Content-Type', 'application/json');
4✔
644
            $this->setHeader('Content-Length', (string) strlen($json));
4✔
645
        }
646

647
        // version
648
        if (! empty($config['version'])) {
113✔
649
            if ($config['version'] === 1.0) {
5✔
650
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
2✔
651
            } elseif ($config['version'] === 1.1) {
3✔
652
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
2✔
653
            } elseif ($config['version'] === 2.0) {
1✔
654
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0;
1✔
655
            }
656
        }
657

658
        // Cookie
659
        if (isset($config['cookie'])) {
113✔
660
            $curlOptions[CURLOPT_COOKIEJAR]  = $config['cookie'];
2✔
661
            $curlOptions[CURLOPT_COOKIEFILE] = $config['cookie'];
2✔
662
        }
663

664
        // User Agent
665
        if (isset($config['user_agent'])) {
113✔
666
            $curlOptions[CURLOPT_USERAGENT] = $config['user_agent'];
4✔
667
        }
668

669
        return $curlOptions;
113✔
670
    }
671

672
    /**
673
     * Does the actual work of initializing cURL, setting the options,
674
     * and grabbing the output.
675
     *
676
     * @codeCoverageIgnore
677
     */
678
    protected function sendRequest(array $curlOptions = []): string
679
    {
680
        $ch = curl_init();
681

682
        curl_setopt_array($ch, $curlOptions);
683

684
        // Send the request and wait for a response.
685
        $output = curl_exec($ch);
686

687
        if ($output === false) {
688
            throw HTTPException::forCurlError((string) curl_errno($ch), curl_error($ch));
689
        }
690

691
        curl_close($ch);
692

693
        return $output;
694
    }
695
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc