Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.00% covered (success)
95.00%
228 / 240
71.43% covered (success)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Command
95.00% covered (success)
95.00%
228 / 240
71.43% covered (success)
71.43%
5 / 7
141
0.00% covered (danger)
0.00%
0 / 1
 clientToCommand
89.04% covered (success)
89.04%
65 / 73
0.00% covered (danger)
0.00%
0 / 1
54.42
 commandToClient
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 parseCommandOptions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 extractCommandOptionValues
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
15
 convertCommandOptions
96.04% covered (success)
96.04%
97 / 101
0.00% covered (danger)
0.00%
0 / 1
54
 trimQuotes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 addQuotes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Pop PHP Framework (http://www.popphp.org/)
4 *
5 * @link       https://github.com/popphp/popphp-framework
6 * @author     Nick Sagona, III <dev@nolainteractive.com>
7 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
8 * @license    http://www.popphp.org/license     New BSD License
9 */
10
11/**
12 * @namespace
13 */
14namespace Pop\Http\Client\Handler\Curl;
15
16use Pop\Http\Auth;
17use Pop\Http\Client;
18use Pop\Http\Client\Request;
19use Pop\Http\Client\Handler\Curl;
20use Pop\Http\Promise;
21
22/**
23 * HTTP client curl command class
24 *
25 * @category   Pop
26 * @package    Pop\Http
27 * @author     Nick Sagona, III <dev@nolainteractive.com>
28 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
29 * @license    http://www.popphp.org/license     New BSD License
30 * @version    5.2.0
31 */
32class Command
33{
34
35    /**
36     * Create a compatible command string to execute with the curl CLI application
37     *
38     * @param  Client $client
39     * @return string
40     */
41    public static function clientToCommand(Client $client): string
42    {
43        $command        = 'curl';
44        $currentOptions = [];
45
46        // If client has a Curl handler, get current options before reset
47        if (($client->hasHandler()) && ($client->getHandler() instanceof Curl) && ($client->getHandler()->hasOptions())) {
48            $currentOptions = $client->getHandler()->getOptions();
49        }
50
51        $client->prepare();
52
53        if (!($client->getHandler() instanceof Curl)) {
54            throw new Exception('Error: The client object must use a Curl handler.');
55        }
56
57        // Set return header
58        if ($client->getHandler()->isReturnHeader()) {
59            $command .= ' -i';
60        }
61
62        // Set method
63        $method   = $client->getRequest()->getMethod();
64        $command .= ' -X ' . $method;
65
66        // Handle insecure settings
67        if (($client->hasOption('verify_peer') && ($client->getOption('verify_peer'))) ||
68            ($client->hasOption('allow_self_signed') && ($client->getOption('allow_self_signed'))) ||
69            ($client->getHandler()->hasOption(CURLOPT_SSL_VERIFYHOST) && (!$client->getHandler()->getOption(CURLOPT_SSL_VERIFYHOST))) ||
70            ($client->getHandler()->hasOption(CURLOPT_SSL_VERIFYPEER) && (!$client->getHandler()->getOption(CURLOPT_SSL_VERIFYPEER)))) {
71            $command .= ' --insecure';
72        }
73
74        // Handle basic auth
75        if (($client->hasAuth()) && ($client->getAuth()->isBasic())) {
76            $command .= ' --basic -u "' . $client->getAuth()->getUsername() . ':' .  $client->getAuth()->getPassword() . '"';
77            if ($client->getRequest()->hasHeader('Authorization')) {
78                $client->getRequest()->removeHeader('Authorization');
79            }
80        }
81
82        // Handle headers
83        if ($client->getRequest()->hasHeaders()) {
84            foreach ($client->getRequest()->getHeaders() as $header) {
85                if ((!str_contains($header->getValueAsString(), 'multipart/form-data')) &&
86                    (!str_contains($header->getValueAsString(), 'x-www-form-urlencoded')) && ($header->getName() != 'Content-Length')) {
87                    $command .= ' --header "' . $header  . '"';
88                }
89            }
90        }
91
92        // Handle data
93        if ($client->getRequest()->hasData()) {
94            // Multipart form data
95            if ($client->getRequest()->isMultipart()) {
96                $data = $client->getRequest()->getData()->toArray();
97                foreach ($data as $key => $value) {
98                    $command .= (isset($value['filename']) && file_exists($value['filename'])) ?
99                        ' -F "' . $key . '=@' . $value['filename'] . '"' :
100                        ' -F "' . http_build_query([$key => $value]) . '"';
101                }
102            // JSON data
103            } else if ($client->getRequest()->isJson()) {
104                $data = $client->getRequest()->getData()->toArray();
105                foreach ($data as $key => $datum) {
106                    if (isset($datum['filename']) && file_exists($datum['filename'])) {
107                        $command .= ' --data @' . $datum['filename'];
108                        unset($data[$key]);
109                    }
110                }
111                if (!empty($data)) {
112                    $json = json_encode($data);
113                    if (str_contains($json, "'")) {
114                        $json = str_replace("'", "\\'", $json);
115                    }
116                    $command .= " --data '" . $json . "'";
117                }
118            // XML data
119            } else if ($client->getRequest()->isXml()) {
120                $data = $client->getRequest()->getData()->toArray();
121                foreach ($data as $key => $datum) {
122                    if (isset($datum['filename']) && file_exists($datum['filename'])) {
123                        $command .= ' --data @' . $datum['filename'];
124                        unset($data[$key]);
125                    }
126                }
127
128                if (!empty($data)) {
129                    foreach ($data as $datum) {
130                        if (str_contains($datum, "'")) {
131                            $datum = str_replace("'", "\\'", $datum);
132                        }
133                        $command .= " --data '" . $datum . "'";
134                    }
135                }
136            // URL-encoded data
137            } else if (($client->getRequest()->getMethod() == 'GET') || ($client->getRequest()->isUrlEncoded()) ||
138                !($client->getRequest()->hasRequestType())) {
139                $command .= ' --data "' . $client->getRequest()->getData()->prepare()->getDataContent()  . '"';
140            }
141        }
142
143        // Add body content as data
144        if ($client->getRequest()->hasBody()) {
145            $body = $client->getRequest()->getBodyContent();
146            if (str_contains($body, "'")) {
147                $body = str_replace("'", "\\'", $body);
148            }
149            $command .= " --data '" . $body . "'";
150        }
151
152        // Handle all other options
153        $curlOptions =  $client->getHandler()->getOptions() + $currentOptions;
154        foreach ($curlOptions as $curlOption => $curlOptionValue) {
155            $curlOptionName = Options::getOptionNameByValue($curlOption);
156            if (!Options::isOmitOption($curlOptionName)) {
157                $commandOption = Options::getPhpOption($curlOptionName);
158                $command .= (is_array($commandOption) && isset($commandOption[0])) ?
159                    ' ' . $commandOption[0] : ' ' . $commandOption;
160                if (Options::isValueOption($curlOptionName) && !empty($curlOptionValue)) {
161                    $command .= ' ' . self::addQuotes($curlOptionValue);
162                }
163            }
164        }
165
166        $command .= ' ' . self::addQuotes($client->getRequest()->getUriAsString());
167
168        return $command;
169    }
170
171    /**
172     * Create a client object from a command string from the Curl CLI application
173     *
174     * @param  string $command
175     * @return Client
176     */
177    public static function commandToClient(string $command): Client
178    {
179        $command = trim($command);
180
181        if (!str_starts_with($command, 'curl')) {
182            throw new Exception("Error: The command isn't a valid cURL command.");
183        }
184
185        $command = substr($command, 4);
186        $options = [];
187
188        // No options
189        if (!str_contains($command, '-')) {
190            $requestUri = trim($command);
191        // Else, parse options
192        } else {
193            $optionString = substr($command, 0, strrpos($command, ' '));
194            $requestUri   = substr($command, (strrpos($command, ' ') + 1));
195            $options      = self::parseCommandOptions($optionString);
196        }
197
198        $request = new Request(self::trimQuotes($requestUri));
199        $curl    = new Curl();
200        $files   = null;
201
202        if (!empty($options)) {
203            [$auth, $files] = self::convertCommandOptions($options, $curl, $request);
204        }
205
206        $client = new Client($request, $curl);
207
208        if (!empty($auth)) {
209            $client->setAuth($auth);
210        }
211        if (!empty($files)) {
212            $client->setFiles($files, false);
213        }
214
215        return $client;
216    }
217
218    /**
219     * Parse the CLI command options string
220     *
221     * @param  string $optionString
222     * @return array
223     */
224    public static function parseCommandOptions(string $optionString): array
225    {
226        $options = [];
227        $matches = [];
228
229        preg_match_all('/\s[\-]{1,2}/', $optionString, $matches, PREG_OFFSET_CAPTURE);
230
231        if (isset($matches[0]) && isset($matches[0][0])) {
232            foreach ($matches[0] as $i => $match) {
233                if (isset($matches[0][$i + 1])) {
234                    $length    = ($matches[0][$i + 1][1]) - $match[1] - 1;
235                    $options[] = substr($optionString, $match[1] + 1, $length);
236                } else {
237                    $options[] = substr($optionString, $match[1] + 1);
238                }
239            }
240        }
241
242        return $options;
243    }
244
245    /**
246     * Extract command option values
247     *
248     * @param  array $options
249     * @return array
250     */
251    public static function extractCommandOptionValues(array $options): array
252    {
253        $optionValues = [];
254
255        foreach ($options as $option) {
256            $opt = null;
257            $val = null;
258            if (str_starts_with($option, '--')) {
259                if (str_contains($option, ' ')) {
260                    $opt = substr($option, 0, strpos($option, ' '));
261                    $val = substr($option, (strpos($option, ' ') + 1));
262                } else {
263                    $opt = $option;
264                }
265            } else {
266                if (strlen($option) > 2) {
267                    if (substr($option, 2, 1) == ' ') {
268                        $opt = substr($option, 0, 2);
269                        $val = substr($option, 3);
270                    } else {
271                        $opt = substr($option, 0, 2);
272                        $val = substr($option, 2);
273                    }
274                } else {
275                    $opt = $option;
276                }
277            }
278
279            if (($opt == '-d') || ($opt == '--data') || ($opt == '-F') || ($opt == '--form')) {
280                $val = self::trimQuotes($val);
281                if (str_contains($val, '=') && !str_contains($val, '<?xml')) {
282                    parse_str(self::trimQuotes($val), $val);
283                }
284            }
285
286            if (isset($optionValues[$opt])) {
287                if (!is_array($optionValues[$opt])) {
288                    $optionValues[$opt] = [$optionValues[$opt]];
289                }
290                if (is_array($val)) {
291                    $optionValues[$opt] = array_merge($optionValues[$opt], $val);
292                } else {
293                    $optionValues[$opt][] = $val;
294                }
295            } else {
296                $optionValues[$opt] = $val;
297            }
298        }
299
300        return $optionValues;
301    }
302
303    /**
304     * Convert CLI options to usable values for the Curl handler and request
305     *
306     * @param  array   $options
307     * @param  Curl    $curl
308     * @param  Request $request
309     * @return array
310     */
311    public static function convertCommandOptions(array $options, Curl $curl, Request $request): array
312    {
313        $optionValues = self::extractCommandOptionValues($options);
314        $auth         = null;
315        $files        = [];
316
317        // Handle method
318        // If forced GET method
319        if (array_key_exists('-G', $optionValues) || array_key_exists('--get', $optionValues)) {
320            $request->setMethod('GET');
321            if (array_key_exists('-G', $optionValues)) {
322                unset($optionValues['-G']);
323            } else {
324                unset($optionValues['--get']);
325            }
326            // If HEAD method
327        } else if (array_key_exists('-I', $optionValues) || array_key_exists('--head', $optionValues)) {
328            $request->setMethod('HEAD');
329            if (array_key_exists('-I', $optionValues)) {
330                unset($optionValues['-I']);
331            } else {
332                unset($optionValues['--head']);
333            }
334        // All other methods
335        } else if (isset($optionValues['-X']) || isset($optionValues['--request'])) {
336            $request->setMethod(($optionValues['-X'] ?? $optionValues['--request']));
337            if (isset($optionValues['-X'])) {
338                unset($optionValues['-X']);
339            } else {
340                unset($optionValues['--request']);
341            }
342        }
343
344        // Handle insecure settings
345        if (array_key_exists('-k', $optionValues) || array_key_exists('--insecure', $optionValues)) {
346            $curl->setOption(CURLOPT_SSL_VERIFYHOST, 0);
347            $curl->setOption(CURLOPT_SSL_VERIFYPEER, 0);
348        }
349
350        // Handle headers
351        if (isset($optionValues['-H']) || isset($optionValues['--header'])) {
352            $headerOpts = ($optionValues['-H'] ?? $optionValues['--header']);
353            if (is_array($headerOpts)) {
354                $headers = array_map(function ($value) {
355                    return Command::trimQuotes($value);
356                }, $headerOpts);
357            } else {
358                $headers = [Command::trimQuotes($headerOpts)];
359            }
360
361            $request->addHeaders($headers);
362
363            if (isset($optionValues['-H'])) {
364                unset($optionValues['-H']);
365            } else {
366                unset($optionValues['--header']);
367            }
368        }
369
370        // Handle basic auth
371        if ((!array_key_exists('--digest', $optionValues) || array_key_exists('--basic', $optionValues) || array_key_exists('--anyauth', $optionValues)) &&
372            (isset($optionValues['-u']) || isset($optionValues['--user']))) {
373            $userData = ($optionValues['-u'] ?? $optionValues['--user']);
374            if (str_contains($userData, ':')) {
375                [$username, $password] = explode(':', self::trimQuotes($userData), 2);
376                $auth = Auth::createBasic($username, $password);
377                if (isset($optionValues['-u'])) {
378                    unset($optionValues['-u']);
379                } else {
380                    unset($optionValues['--user']);
381                }
382                if (array_key_exists('--basic', $optionValues)) {
383                    unset($optionValues['--basic']);
384                }
385            }
386        }
387
388        // Handle JSON request
389        if (array_key_exists('--json', $optionValues)) {
390            $request->addHeaders([
391                'Content-Type: application/json',
392                'Accept: application/json'
393            ]);
394            unset($optionValues['--json']);
395        }
396
397        // Handle data
398        if (isset($optionValues['-d']) || isset($optionValues['--data'])) {
399            $data = ($optionValues['-d'] ?? $optionValues['--data']);
400
401            if ($request->hasHeader('Content-Type') && is_string($data)) {
402                if (str_starts_with($data, '@')) {
403                    //&& file_exists(getcwd() . DIRECTORY_SEPARATOR . substr($data, 1))) {
404                    $file = substr($data, 1);
405                    if (!str_starts_with($file, '/')) {
406                        $file = getcwd() . DIRECTORY_SEPARATOR . substr($data, 1);
407                    }
408                    if (file_exists($file)) {
409                        $files[] = $file;
410                    }
411                } else {
412                    $contentType = $request->getHeaderValueAsString('Content-Type');
413                    if ($contentType ==  Request::JSON) {
414                        $data = json_decode($data, true);
415                    } else if ($contentType == Request::URLENCODED) {
416                        parse_str($data, $data);
417                    }
418                }
419            }
420            $request->setData($data);
421            if (isset($optionValues['-d'])) {
422                unset($optionValues['-d']);
423            } else {
424                unset($optionValues['--data']);
425            }
426        }
427
428        // Handle form data
429        if (isset($optionValues['-F']) || isset($optionValues['--form'])) {
430            $data     = [];
431            $formData = ($optionValues['-F'] ?? $optionValues['--form']);
432            if (is_array($formData)) {
433                foreach ($formData as $key => $formDatum) {
434                    if (str_starts_with($formDatum, '@')) {
435                        $data[$key] = [
436                            'filename'    => getcwd() . DIRECTORY_SEPARATOR . substr($formDatum, 1),
437                            'contentType' => Client\Data::getMimeTypeFromFilename(substr($formDatum, 1))
438                        ];
439                    } else {
440                        $data[$key] = $formDatum;
441                    }
442                }
443            }
444
445            $request->setData($data)
446                ->setRequestType(Request::MULTIPART);
447
448            if (isset($optionValues['-F'])) {
449                unset($optionValues['-F']);
450            } else {
451                unset($optionValues['--form']);
452            }
453        }
454
455        // Handle all other options
456        foreach ($optionValues as $option => $value) {
457            if (!Options::isOmitOption($option)) {
458                foreach (Options::getPhpOptions() as $phpOption => $curlOption) {
459                    if (is_array($curlOption)) {
460                        foreach ($curlOption as $cOpt) {
461                            if ((str_starts_with($option, '--') && str_contains($cOpt, $option)) || str_starts_with($cOpt, $option)) {
462                                if (Options::isValueOption($option)) {
463                                    $optionValue = Options::getValueOption($option) ?? self::trimQuotes($value);
464                                } else {
465                                    $optionValue = true;
466                                }
467                                $curl->setOption(constant($phpOption), $optionValue);
468                                break;
469                            }
470                        }
471                    } else if ((str_starts_with($option, '--') && str_contains($curlOption, $option)) || str_starts_with($curlOption, $option)) {
472                        if (Options::isValueOption($option)) {
473                            $optionValue = Options::getValueOption($option) ?? self::trimQuotes($value);
474                        } else {
475                            $optionValue = true;
476                        }
477                        $curl->setOption(constant($phpOption), $optionValue);
478                        break;
479                    }
480                }
481            }
482        }
483
484        return [$auth, $files];
485    }
486
487    /**
488     * Trim quotes from value
489     *
490     * @param  string  $value
491     * @return string
492     */
493    public static function trimQuotes(string $value): string
494    {
495        if ((str_starts_with($value, '"') && str_ends_with($value, '"')) || (str_starts_with($value, "'") && str_ends_with($value, "'"))) {
496            $value = substr($value, 1);
497            $value = substr($value, 0, -1);
498        }
499
500        return $value;
501    }
502
503    /**
504     * Trim quotes from value
505     *
506     * @param  string $value
507     * @param  string $quote
508     * @return string
509     */
510    public static function addQuotes(string $value, string $quote = '"'): string
511    {
512        if (!str_starts_with($value, '"') && !str_ends_with($value, '"') && !str_starts_with($value, "'") && !str_ends_with($value, "'")) {
513            $value = $quote . $value . $quote;
514        }
515
516        return $value;
517    }
518
519}