// CTL = // separators = "(" | ")" | "<" | ">" | "@" // | "," | ";" | ":" | "\" | <"> // | "/" | "[" | "]" | "?" | "=" // | "{" | "}" | SP | HT // SP = // HT = // <"> = '!', '#', '$', '%', '&', "'", '*', '+', '-', '.', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '|', '~', ]; public static $RFC6265 = [ // RFC 6265: "US-ASCII characters excluding CTLs, whitespace DQUOTE, comma, semicolon, and backslash". // %x21 '!', // %x23-2B '#', '$', '%', '&', "'", '(', ')', '*', '+', // %x2D-3A '-', '.', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ':', // %x3C-5B '<', '=', '>', '?', '@', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '[', // %x5D-7E ']', '^', '_', '`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '{', '|', '}', '~', ]; public $curlErrorCodeConstant; public $curlErrorCodeConstants; public $curlOptionCodeConstants; public $effectiveUrl; public $rfc2616; public $rfc6265; public $totalTime; private static $deferredProperties = [ 'curlErrorCodeConstant', 'curlErrorCodeConstants', 'curlOptionCodeConstants', 'effectiveUrl', 'rfc2616', 'rfc6265', 'totalTime', ]; /** * Construct * * @access public * @param $base_url * @throws \ErrorException */ public function __construct($base_url = null, $options = []) { if (!extension_loaded('curl')) { throw new \ErrorException('cURL library is not loaded'); } unset($this->curlErrorCodeConstant); unset($this->curlErrorCodeConstants); unset($this->curlOptionCodeConstants); unset($this->effectiveUrl); unset($this->rfc2616); unset($this->rfc6265); unset($this->totalTime); $this->curl = curl_init(); $this->initialize($base_url, $options); } /** * Build Post Data * * @access public * @param $data * * @return array|string * @throws \ErrorException */ public function buildPostData($data) { $binary_data = false; // Return JSON-encoded string when the request's content-type is JSON and the data is serializable. if (isset($this->headers['Content-Type']) && preg_match($this->jsonPattern, $this->headers['Content-Type']) && ( is_array($data) || ( is_object($data) && interface_exists('JsonSerializable', false) && $data instanceof \JsonSerializable ) )) { $data = \Curl\Encoder::encodeJson($data); } elseif (is_array($data)) { // Manually build a single-dimensional array from a multi-dimensional array as using curl_setopt($ch, // CURLOPT_POSTFIELDS, $data) doesn't correctly handle multi-dimensional arrays when files are // referenced. if (ArrayUtil::isArrayMultidim($data)) { $data = ArrayUtil::arrayFlattenMultidim($data); } // Modify array values to ensure any referenced files are properly handled depending on the support of // the @filename API or CURLFile usage. This also fixes the warning "curl_setopt(): The usage of the // @filename API for file uploading is deprecated. Please use the CURLFile class instead". Ignore // non-file values prefixed with the @ character. foreach ($data as $key => $value) { if (is_string($value) && strpos($value, '@') === 0 && is_file(substr($value, 1))) { $binary_data = true; if (class_exists('CURLFile')) { $data[$key] = new \CURLFile(substr($value, 1)); } } elseif ($value instanceof \CURLFile) { $binary_data = true; } } } if (!$binary_data && (is_array($data) || is_object($data)) && ( !isset($this->headers['Content-Type']) || !preg_match('/^multipart\/form-data/', $this->headers['Content-Type']) )) { // Avoid using http_build_query() as keys with null values are // unexpectedly excluded from the resulting string. // // $ php -a // php > echo http_build_query(['a' => '1', 'b' => null, 'c' => '3']); // a=1&c=3 // php > echo http_build_query(['a' => '1', 'b' => '', 'c' => '3']); // a=1&b=&c=3 // // $data = http_build_query($data, '', '&'); $data = implode('&', array_map(function ($k, $v) { // Encode keys and values using urlencode() to match the default // behavior http_build_query() where $encoding_type is // PHP_QUERY_RFC1738. // // Use strval() as urlencode() expects a string parameter: // TypeError: urlencode() expects parameter 1 to be string, integer given // TypeError: urlencode() expects parameter 1 to be string, null given // // php_raw_url_encode() // php_url_encode() // https://github.com/php/php-src/blob/master/ext/standard/http.c return urlencode(strval($k)) . '=' . urlencode(strval($v)); }, array_keys((array)$data), array_values((array)$data))); } return $data; } /** * Call * * @access public */ public function call() { $args = func_get_args(); $function = array_shift($args); if (is_callable($function)) { array_unshift($args, $this); call_user_func_array($function, $args); } } /** * Close * * @access public */ public function close() { if (is_resource($this->curl) || $this->curl instanceof \CurlHandle) { curl_close($this->curl); } $this->curl = null; $this->options = null; $this->jsonDecoder = null; $this->jsonDecoderArgs = null; $this->xmlDecoder = null; $this->xmlDecoderArgs = null; $this->headerCallbackData = null; $this->defaultDecoder = null; } /** * Progress * * @access public * @param $callback callable|null */ public function progress($callback) { $this->setOpt(CURLOPT_PROGRESSFUNCTION, $callback); $this->setOpt(CURLOPT_NOPROGRESS, false); } /** * Delete * * @access public * @param $url * @param $query_parameters * @param $data * * @return mixed Returns the value provided by exec. */ public function delete($url, $query_parameters = [], $data = []) { if (is_array($url)) { $data = $query_parameters; $query_parameters = $url; $url = (string)$this->url; } $this->setUrl($url, $query_parameters); $this->setOpt(CURLOPT_CUSTOMREQUEST, 'DELETE'); // Avoid including a content-length header in DELETE requests unless there is a message body. The following // would include "Content-Length: 0" in the request header: // curl_setopt($ch, CURLOPT_POSTFIELDS, []); // RFC 2616 4.3 Message Body: // The presence of a message-body in a request is signaled by the // inclusion of a Content-Length or Transfer-Encoding header field in // the request's message-headers. if (!empty($data)) { $this->setOpt(CURLOPT_POSTFIELDS, $this->buildPostData($data)); } return $this->exec(); } /** * Download * * @access public * @param $url * @param $mixed_filename * * @return boolean */ public function download($url, $mixed_filename) { // Use tmpfile() or php://temp to avoid "Too many open files" error. if (is_callable($mixed_filename)) { $this->downloadCompleteCallback = $mixed_filename; $this->downloadFileName = null; $this->fileHandle = tmpfile(); } else { $filename = $mixed_filename; // Use a temporary file when downloading. Not using a temporary file can cause an error when an existing // file has already fully completed downloading and a new download is started with the same destination save // path. The download request will include header "Range: bytes=$filesize-" which is syntactically valid, // but unsatisfiable. $download_filename = $filename . '.pccdownload'; $this->downloadFileName = $download_filename; // Attempt to resume download only when a temporary download file exists and is not empty. if (is_file($download_filename) && $filesize = filesize($download_filename)) { $first_byte_position = $filesize; $range = $first_byte_position . '-'; $this->setRange($range); $this->fileHandle = fopen($download_filename, 'ab'); } else { $this->fileHandle = fopen($download_filename, 'wb'); } // Move the downloaded temporary file to the destination save path. $this->downloadCompleteCallback = function ($instance, $fh) use ($download_filename, $filename) { // Close the open file handle before renaming the file. if (is_resource($fh)) { fclose($fh); } rename($download_filename, $filename); }; } $this->setFile($this->fileHandle); $this->get($url); return ! $this->error; } /** * Fast download * * @access private * @param $url * @param $filename * @param $connections * * @return boolean */ public function _fastDownload($url, $filename, $connections = 4) { // First we need to retrive the 'Content-Length' header. // Use GET because not all hosts support HEAD requests. $this->setOpts([ CURLOPT_CUSTOMREQUEST => 'GET', CURLOPT_NOBODY => true, CURLOPT_HEADER => true, CURLOPT_ENCODING => '', ]); $this->setUrl($url); $this->exec(); $content_length = isset($this->responseHeaders['Content-Length']) ? $this->responseHeaders['Content-Length'] : null; // If content length header is missing, use the normal download. if (!$content_length) { return $this->download($url, $filename); } // Try to divide chunk_size equally. $chunkSize = ceil($content_length / $connections); // First bytes. $offset = 0; $nextChunk = $chunkSize; // We need this later. $file_parts = []; $multi_curl = new MultiCurl(); $multi_curl->setConcurrency($connections); $multi_curl->error(function ($instance) { return false; }); for ($i = 1; $i <= $connections; $i++) { // If last chunk then no need to supply it. // Range starts with 0, so subtract 1. $nextChunk = $i === $connections ? '' : $nextChunk - 1; // Create part file. $fpath = "$filename.part$i"; if (is_file($fpath)) { unlink($fpath); } $fp = fopen($fpath, 'w'); // Track all fileparts names; we need this later. $file_parts[] = $fpath; $curl = new Curl(); $curl->setOpt(CURLOPT_ENCODING, ''); $curl->setRange("$offset-$nextChunk"); $curl->setFile($fp); $curl->disableTimeout(); // otherwise download may fail. $curl->setUrl($url); $curl->complete(function () use ($fp) { fclose($fp); }); $multi_curl->addCurl($curl); if ($i !== $connections) { $offset = $nextChunk + 1; // Add 1 to match offset. $nextChunk = $nextChunk + $chunkSize; } } // let the magic begin. $multi_curl->start(); // Concatenate chunks to single. if (is_file($filename)) { unlink($filename); } $mainfp = fopen($filename, 'w'); foreach ($file_parts as $part) { $fp = fopen($part, 'r'); stream_copy_to_stream($fp, $mainfp); fclose($fp); unlink($part); } fclose($mainfp); return true; } /** * Exec * * @access public * @param $ch * * @return mixed Returns the value provided by parseResponse. */ public function exec($ch = null) { $this->attempts += 1; if ($this->jsonDecoder === null) { $this->setDefaultJsonDecoder(); } if ($this->xmlDecoder === null) { $this->setDefaultXmlDecoder(); } if ($ch === null) { $this->responseCookies = []; $this->call($this->beforeSendCallback); $this->rawResponse = curl_exec($this->curl); $this->curlErrorCode = curl_errno($this->curl); $this->curlErrorMessage = curl_error($this->curl); } else { $this->rawResponse = curl_multi_getcontent($ch); $this->curlErrorMessage = curl_error($ch); } $this->curlError = $this->curlErrorCode !== 0; // Ensure Curl::rawResponse is a string as curl_exec() can return false. // Without this, calling strlen($curl->rawResponse) will error when the // strict types setting is enabled. if (!is_string($this->rawResponse)) { $this->rawResponse = ''; } // Transfer the header callback data and release the temporary store to avoid memory leak. $this->rawResponseHeaders = $this->headerCallbackData->rawResponseHeaders; $this->responseCookies = $this->headerCallbackData->responseCookies; $this->headerCallbackData->rawResponseHeaders = ''; $this->headerCallbackData->responseCookies = []; $this->headerCallbackData->stopRequestDecider = null; $this->headerCallbackData->stopRequest = false; // Include additional error code information in error message when possible. if ($this->curlError) { $curl_error_message = curl_strerror($this->curlErrorCode); if ($this->curlErrorCodeConstant !== '') { $curl_error_message .= ' (' . $this->curlErrorCodeConstant . ')'; } if (!empty($this->curlErrorMessage)) { $curl_error_message .= ': ' . $this->curlErrorMessage; } $this->curlErrorMessage = $curl_error_message; } $this->httpStatusCode = $this->getInfo(CURLINFO_HTTP_CODE); $this->httpError = in_array((int) floor($this->httpStatusCode / 100), [4, 5], true); $this->error = $this->curlError || $this->httpError; $this->errorCode = $this->error ? ($this->curlError ? $this->curlErrorCode : $this->httpStatusCode) : 0; // NOTE: CURLINFO_HEADER_OUT set to true is required for requestHeaders // to not be empty (e.g. $curl->setOpt(CURLINFO_HEADER_OUT, true);). if ($this->getOpt(CURLINFO_HEADER_OUT) === true) { $this->requestHeaders = $this->parseRequestHeaders($this->getInfo(CURLINFO_HEADER_OUT)); } $this->responseHeaders = $this->parseResponseHeaders($this->rawResponseHeaders); $this->response = $this->parseResponse($this->responseHeaders, $this->rawResponse); $this->httpErrorMessage = ''; if ($this->error) { if (isset($this->responseHeaders['Status-Line'])) { $this->httpErrorMessage = $this->responseHeaders['Status-Line']; } } $this->errorMessage = $this->curlError ? $this->curlErrorMessage : $this->httpErrorMessage; // Reset select deferred properties so that they may be recalculated. unset($this->curlErrorCodeConstant); unset($this->effectiveUrl); unset($this->totalTime); // Reset content-length header possibly set from a PUT or SEARCH request. $this->unsetHeader('Content-Length'); // Reset nobody setting possibly set from a HEAD request. $this->setOpt(CURLOPT_NOBODY, false); // Allow multicurl to attempt retry as needed. if ($this->isChildOfMultiCurl()) { return; } if ($this->attemptRetry()) { return $this->exec($ch); } $this->execDone(); return $this->response; } public function execDone() { if ($this->error) { $this->call($this->errorCallback); } else { $this->call($this->successCallback); } $this->call($this->completeCallback); // Close open file handles and reset the curl instance. if ($this->fileHandle !== null) { $this->downloadComplete($this->fileHandle); } } /** * Get * * @access public * @param $url * @param $data * * @return mixed Returns the value provided by exec. */ public function get($url, $data = []) { if (is_array($url)) { $data = $url; $url = (string)$this->url; } $this->setUrl($url, $data); $this->setOpt(CURLOPT_CUSTOMREQUEST, 'GET'); $this->setOpt(CURLOPT_HTTPGET, true); return $this->exec(); } /** * Get Info * * @access public * @param $opt * * @return mixed */ public function getInfo($opt = null) { $args = []; $args[] = $this->curl; if (func_num_args()) { $args[] = $opt; } return call_user_func_array('curl_getinfo', $args); } /** * Head * * @access public * @param $url * @param $data * * @return mixed Returns the value provided by exec. */ public function head($url, $data = []) { if (is_array($url)) { $data = $url; $url = (string)$this->url; } $this->setUrl($url, $data); $this->setOpt(CURLOPT_CUSTOMREQUEST, 'HEAD'); $this->setOpt(CURLOPT_NOBODY, true); return $this->exec(); } /** * Options * * @access public * @param $url * @param $data * * @return mixed Returns the value provided by exec. */ public function options($url, $data = []) { if (is_array($url)) { $data = $url; $url = (string)$this->url; } $this->setUrl($url, $data); $this->setOpt(CURLOPT_CUSTOMREQUEST, 'OPTIONS'); return $this->exec(); } /** * Patch * * @access public * @param $url * @param $data * * @return mixed Returns the value provided by exec. */ public function patch($url, $data = []) { if (is_array($url)) { $data = $url; $url = (string)$this->url; } if (is_array($data) && empty($data)) { $this->removeHeader('Content-Length'); } $this->setUrl($url); $this->setOpt(CURLOPT_CUSTOMREQUEST, 'PATCH'); $this->setOpt(CURLOPT_POSTFIELDS, $this->buildPostData($data)); return $this->exec(); } /** * Post * * @access public * @param $url * @param $data * @param $follow_303_with_post * If true, will cause 303 redirections to be followed using a POST request (default: false). * Notes: * - Redirections are only followed if the CURLOPT_FOLLOWLOCATION option is set to true. * - According to the HTTP specs (see [1]), a 303 redirection should be followed using * the GET method. 301 and 302 must not. * - In order to force a 303 redirection to be performed using the same method, the * underlying cURL object must be set in a special state (the CURLOPT_CUSTOMREQUEST * option must be set to the method to use after the redirection). Due to a limitation * of the cURL extension of PHP < 5.5.11 ([2], [3]), it is not possible to reset this * option. Using these PHP engines, it is therefore impossible to restore this behavior * on an existing php-curl-class Curl object. * * @return mixed Returns the value provided by exec. * * [1] https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.2 * [2] https://github.com/php/php-src/pull/531 * [3] http://php.net/ChangeLog-5.php#5.5.11 */ public function post($url, $data = '', $follow_303_with_post = false) { if (is_array($url)) { $follow_303_with_post = (bool)$data; $data = $url; $url = (string)$this->url; } $this->setUrl($url); // Set the request method to "POST" when following a 303 redirect with // an additional POST request is desired. This is equivalent to setting // the -X, --request command line option where curl won't change the // request method according to the HTTP 30x response code. if ($follow_303_with_post) { $this->setOpt(CURLOPT_CUSTOMREQUEST, 'POST'); } elseif (isset($this->options[CURLOPT_CUSTOMREQUEST])) { // Unset the CURLOPT_CUSTOMREQUEST option so that curl does not use // a POST request after a post/redirect/get redirection. Without // this, curl will use the method string specified for all requests. $this->setOpt(CURLOPT_CUSTOMREQUEST, null); } $this->setOpt(CURLOPT_POST, true); $this->setOpt(CURLOPT_POSTFIELDS, $this->buildPostData($data)); return $this->exec(); } /** * Put * * @access public * @param $url * @param $data * * @return mixed Returns the value provided by exec. */ public function put($url, $data = []) { if (is_array($url)) { $data = $url; $url = (string)$this->url; } $this->setUrl($url); $this->setOpt(CURLOPT_CUSTOMREQUEST, 'PUT'); $put_data = $this->buildPostData($data); if (empty($this->options[CURLOPT_INFILE]) && empty($this->options[CURLOPT_INFILESIZE])) { if (is_string($put_data)) { $this->setHeader('Content-Length', strlen($put_data)); } } if (!empty($put_data)) { $this->setOpt(CURLOPT_POSTFIELDS, $put_data); } return $this->exec(); } /** * Search * * @access public * @param $url * @param $data * * @return mixed Returns the value provided by exec. */ public function search($url, $data = []) { if (is_array($url)) { $data = $url; $url = (string)$this->url; } $this->setUrl($url); $this->setOpt(CURLOPT_CUSTOMREQUEST, 'SEARCH'); $put_data = $this->buildPostData($data); if (empty($this->options[CURLOPT_INFILE]) && empty($this->options[CURLOPT_INFILESIZE])) { if (is_string($put_data)) { $this->setHeader('Content-Length', strlen($put_data)); } } if (!empty($put_data)) { $this->setOpt(CURLOPT_POSTFIELDS, $put_data); } return $this->exec(); } /** * Set Cookie * * @access public * @param $key * @param $value */ public function setCookie($key, $value) { $this->setEncodedCookie($key, $value); $this->buildCookies(); } /** * Set Cookies * * @access public * @param $cookies */ public function setCookies($cookies) { foreach ($cookies as $key => $value) { $this->setEncodedCookie($key, $value); } $this->buildCookies(); } /** * Get Cookie * * @access public * @param $key * * @return mixed */ public function getCookie($key) { return $this->getResponseCookie($key); } /** * Get Response Cookie * * @access public * @param $key * * @return mixed */ public function getResponseCookie($key) { return isset($this->responseCookies[$key]) ? $this->responseCookies[$key] : null; } /** * Set Max Filesize * * @access public * @param $bytes */ public function setMaxFilesize($bytes) { $callback = function ($resource, $download_size, $downloaded, $upload_size, $uploaded) use ($bytes) { // Abort the transfer when $downloaded bytes exceeds maximum $bytes by returning a non-zero value. return $downloaded > $bytes ? 1 : 0; }; $this->progress($callback); } /** * Set Cookie String * * @access public * @param $string * * @return bool */ public function setCookieString($string) { return $this->setOpt(CURLOPT_COOKIE, $string); } /** * Set Cookie File * * @access public * @param $cookie_file * * @return boolean */ public function setCookieFile($cookie_file) { return $this->setOpt(CURLOPT_COOKIEFILE, $cookie_file); } /** * Set Cookie Jar * * @access public * @param $cookie_jar * * @return boolean */ public function setCookieJar($cookie_jar) { return $this->setOpt(CURLOPT_COOKIEJAR, $cookie_jar); } /** * Set Default JSON Decoder * * @access public * @param $assoc * @param $depth * @param $options */ public function setDefaultJsonDecoder() { $this->jsonDecoder = '\Curl\Decoder::decodeJson'; $this->jsonDecoderArgs = func_get_args(); } /** * Set Default XML Decoder * * @access public * @param $class_name * @param $options * @param $ns * @param $is_prefix */ public function setDefaultXmlDecoder() { $this->xmlDecoder = '\Curl\Decoder::decodeXml'; $this->xmlDecoderArgs = func_get_args(); } /** * Set Default Decoder * * @access public * @param $mixed boolean|callable|string */ public function setDefaultDecoder($mixed = 'json') { if ($mixed === false) { $this->defaultDecoder = false; } elseif ($mixed === 'json') { $this->defaultDecoder = '\Curl\Decoder::decodeJson'; } elseif ($mixed === 'xml') { $this->defaultDecoder = '\Curl\Decoder::decodeXml'; } elseif (is_callable($mixed)) { $this->defaultDecoder = $mixed; } } /** * Set Default Header Out * * @access public */ public function setDefaultHeaderOut() { $this->setOpt(CURLINFO_HEADER_OUT, true); } /** * Set Default Timeout * * @access public */ public function setDefaultTimeout() { $this->setTimeout(self::DEFAULT_TIMEOUT); } /** * Set Default User Agent * * @access public */ public function setDefaultUserAgent() { $user_agent = 'PHP-Curl-Class/' . self::VERSION . ' (+https://github.com/php-curl-class/php-curl-class)'; $user_agent .= ' PHP/' . PHP_VERSION; $curl_version = curl_version(); $user_agent .= ' curl/' . $curl_version['version']; $this->setUserAgent($user_agent); } /** * Set Header * * Add extra header to include in the request. * * @access public * @param $key * @param $value */ public function setHeader($key, $value) { $this->headers[$key] = $value; $headers = []; foreach ($this->headers as $key => $value) { $headers[] = $key . ': ' . $value; } $this->setOpt(CURLOPT_HTTPHEADER, $headers); } /** * Set Headers * * Add extra headers to include in the request. * * @access public * @param $headers */ public function setHeaders($headers) { if (ArrayUtil::isArrayAssoc($headers)) { foreach ($headers as $key => $value) { $key = trim($key); $value = trim($value); $this->headers[$key] = $value; } } else { foreach ($headers as $header) { list($key, $value) = explode(':', $header, 2); $key = trim($key); $value = trim($value); $this->headers[$key] = $value; } } $headers = []; foreach ($this->headers as $key => $value) { $headers[] = $key . ': ' . $value; } $this->setOpt(CURLOPT_HTTPHEADER, $headers); } /** * Set JSON Decoder * * @access public * @param $mixed boolean|callable */ public function setJsonDecoder($mixed) { if ($mixed === false || is_callable($mixed)) { $this->jsonDecoder = $mixed; $this->jsonDecoderArgs = []; } } /** * Set XML Decoder * * @access public * @param $mixed boolean|callable */ public function setXmlDecoder($mixed) { if ($mixed === false || is_callable($mixed)) { $this->xmlDecoder = $mixed; $this->xmlDecoderArgs = []; } } /** * Set Opt * * @access public * @param $option * @param $value * * @return boolean */ public function setOpt($option, $value) { $required_options = [ CURLOPT_RETURNTRANSFER => 'CURLOPT_RETURNTRANSFER', ]; if (in_array($option, array_keys($required_options), true) && $value !== true) { trigger_error($required_options[$option] . ' is a required option', E_USER_WARNING); } $success = curl_setopt($this->curl, $option, $value); if ($success) { $this->options[$option] = $value; } return $success; } /** * Set Opts * * @access public * @param $options * * @return boolean * Returns true if all options were successfully set. If an option could not be successfully set, false is * immediately returned, ignoring any future options in the options array. Similar to curl_setopt_array(). */ public function setOpts($options) { foreach ($options as $option => $value) { if (!$this->setOpt($option, $value)) { return false; } } return true; } /** * Set Retry * * Number of retries to attempt or decider callable. * * When using a number of retries to attempt, the maximum number of attempts * for the request is $maximum_number_of_retries + 1. * * When using a callable decider, the request will be retried until the * function returns a value which evaluates to false. * * @access public * @param $mixed */ public function setRetry($mixed) { if (is_callable($mixed)) { $this->retryDecider = $mixed; } elseif (is_int($mixed)) { $maximum_number_of_retries = $mixed; $this->remainingRetries = $maximum_number_of_retries; } } /** * Set Url * * @access public * @param $url * @param $mixed_data */ public function setUrl($url, $mixed_data = '') { $built_url = Url::buildUrl($url, $mixed_data); if ($this->url === null) { $this->url = (string)new Url($built_url); } else { $this->url = (string)new Url($this->url, $built_url); } $this->setOpt(CURLOPT_URL, $this->url); } /** * Attempt Retry * * @access public */ public function attemptRetry() { $attempt_retry = false; if ($this->error) { if ($this->retryDecider === null) { $attempt_retry = $this->remainingRetries >= 1; } else { $attempt_retry = call_user_func($this->retryDecider, $this); } if ($attempt_retry) { $this->retries += 1; if ($this->remainingRetries) { $this->remainingRetries -= 1; } } } return $attempt_retry; } /** * Unset Header * * Remove extra header previously set using Curl::setHeader(). * * @access public * @param $key */ public function unsetHeader($key) { unset($this->headers[$key]); $headers = []; foreach ($this->headers as $key => $value) { $headers[] = $key . ': ' . $value; } $this->setOpt(CURLOPT_HTTPHEADER, $headers); } /** * Diagnose * * @access public * @param bool $return */ public function diagnose($return = false) { if ($return) { ob_start(); } echo "\n"; echo '--- Begin PHP Curl Class diagnostic output ---' . "\n"; echo 'PHP Curl Class version: ' . self::VERSION . "\n"; echo 'PHP version: ' . PHP_VERSION . "\n"; $curl_version = curl_version(); echo 'Curl version: ' . $curl_version['version'] . "\n"; if ($this->attempts === 0) { echo 'No HTTP requests have been made.' . "\n"; } else { $request_types = array( 'DELETE' => $this->getOpt(CURLOPT_CUSTOMREQUEST) === 'DELETE', 'GET' => $this->getOpt(CURLOPT_CUSTOMREQUEST) === 'GET' || $this->getOpt(CURLOPT_HTTPGET), 'HEAD' => $this->getOpt(CURLOPT_CUSTOMREQUEST) === 'HEAD', 'OPTIONS' => $this->getOpt(CURLOPT_CUSTOMREQUEST) === 'OPTIONS', 'PATCH' => $this->getOpt(CURLOPT_CUSTOMREQUEST) === 'PATCH', 'POST' => $this->getOpt(CURLOPT_CUSTOMREQUEST) === 'POST' || $this->getOpt(CURLOPT_POST), 'PUT' => $this->getOpt(CURLOPT_CUSTOMREQUEST) === 'PUT', 'SEARCH' => $this->getOpt(CURLOPT_CUSTOMREQUEST) === 'SEARCH', ); $request_method = ''; foreach ($request_types as $http_method_name => $http_method_used) { if ($http_method_used) { $request_method = $http_method_name; break; } } $request_url = $this->getOpt(CURLOPT_URL); $request_options_count = count($this->options); $request_headers_count = count($this->requestHeaders); $request_body_empty = empty($this->getOpt(CURLOPT_POSTFIELDS)); $response_header_length = isset($this->responseHeaders['Content-Length']) ? $this->responseHeaders['Content-Length'] : '(not specified in response header)'; $response_calculated_length = is_string($this->rawResponse) ? strlen($this->rawResponse) : '(' . var_export($this->rawResponse, true) . ')'; $response_headers_count = count($this->responseHeaders); echo 'Request contained ' . $request_options_count . ' ' . ( $request_options_count === 1 ? 'option:' : 'options:' ) . "\n"; if ($request_options_count) { $i = 1; foreach ($this->options as $option => $value) { echo ' ' . $i . ' '; if (isset($this->curlOptionCodeConstants[$option])) { echo $this->curlOptionCodeConstants[$option] . ':'; } else { echo $option . ':'; } if (is_string($value)) { echo ' ' . $value . "\n"; } elseif (is_int($value)) { echo ' ' . $value . "\n"; } elseif (is_bool($value)) { echo ' ' . ($value ? 'true' : 'false') . "\n"; } elseif (is_callable($value)) { echo ' (callable)' . "\n"; } else { echo ' ' . gettype($value) . ':' . "\n"; var_dump($value); } $i += 1; } } echo 'Sent an HTTP ' . $request_method . ' request to "' . $request_url . '".' . "\n" . 'Request contained ' . $request_headers_count . ' ' . ( $request_headers_count === 1 ? 'header:' : 'headers:' ) . "\n"; if ($request_headers_count) { $i = 1; foreach ($this->requestHeaders as $key => $value) { echo ' ' . $i . ' ' . $key . ': ' . $value . "\n"; $i += 1; } } echo 'Request contained ' . ($request_body_empty ? 'no body' : 'a body') . '.' . "\n"; if ($request_headers_count === 0 && ( $this->getOpt(CURLOPT_VERBOSE) || $this->getOpt(CURLINFO_HEADER_OUT) !== true )) { echo 'Warning: Request headers (Curl::requestHeaders) are expected to be empty ' . '(CURLOPT_VERBOSE was enabled or CURLINFO_HEADER_OUT was disabled).' . "\n"; } if (isset($this->responseHeaders['allow'])) { $allowed_request_types = array_map(function ($v) { return trim($v); }, explode(',', strtoupper($this->responseHeaders['allow']))); foreach ($request_types as $http_method_name => $http_method_used) { if ($http_method_used && !in_array($http_method_name, $allowed_request_types, true)) { echo 'Warning: A ' . $http_method_name . ' request was made, but only the following request ' . 'types are allowed: ' . implode(', ', $allowed_request_types) . "\n"; } } } echo 'Response contains ' . $response_headers_count . ' ' . ( $response_headers_count === 1 ? 'header:' : 'headers:' ) . "\n"; if ($this->responseHeaders !== null) { $i = 1; foreach ($this->responseHeaders as $key => $value) { echo ' ' . $i . ' ' . $key . ': ' . $value . "\n"; $i += 1; } } if (!isset($this->responseHeaders['Content-Type'])) { echo 'Response did not set a content type' . "\n"; } elseif (preg_match($this->jsonPattern, $this->responseHeaders['Content-Type'])) { echo 'Response appears to be JSON' . "\n"; } elseif (preg_match($this->xmlPattern, $this->responseHeaders['Content-Type'])) { echo 'Response appears to be XML' . "\n"; } if ($this->curlError) { echo 'A curl error (' . $this->curlErrorCode . ') occurred ' . 'with message "' . $this->curlErrorMessage . '".' . "\n"; } if (!empty($this->httpStatusCode)) { echo 'Received an HTTP status code of ' . $this->httpStatusCode . '.' . "\n"; } if ($this->httpError) { echo 'Received an HTTP ' . $this->httpStatusCode . ' error response ' . 'with message "' . $this->httpErrorMessage . '".' . "\n"; } if ($this->rawResponse === null) { echo 'Received no response body (response=null).' . "\n"; } elseif ($this->rawResponse === '') { echo 'Received an empty response body (response="").' . "\n"; } else { echo 'Received a non-empty response body.' . "\n"; if (isset($this->responseHeaders['Content-Length'])) { echo 'Response content length (from content-length header): ' . $response_header_length . "\n"; } else { echo 'Response content length (calculated): ' . $response_calculated_length . "\n"; } if (preg_match($this->jsonPattern, $this->responseHeaders['Content-Type'])) { $parsed_response = json_decode($this->rawResponse, true); if ($parsed_response !== null) { $messages = []; array_walk_recursive($parsed_response, function ($value, $key) use (&$messages) { if (in_array($key, ['code', 'error', 'message'], true)) { $message = $key . ': ' . $value; $messages[] = $message; } }); $messages = array_unique($messages); $messages_count = count($messages); if ($messages_count) { echo 'Found ' . $messages_count . ' ' . ($messages_count === 1 ? 'message' : 'messages') . ' in response:' . "\n"; $i = 1; foreach ($messages as $message) { echo ' ' . $i . ' ' . $message . "\n"; $i += 1; } } } } } } echo '--- End PHP Curl Class diagnostic output ---' . "\n"; echo "\n"; if ($return) { $output = ob_get_contents(); ob_end_clean(); return $output; } } /** * Reset * * @access public */ public function reset() { if (is_resource($this->curl) || $this->curl instanceof \CurlHandle) { curl_reset($this->curl); } else { $this->curl = curl_init(); } $this->setDefaultUserAgent(); $this->setDefaultTimeout(); $this->setDefaultHeaderOut(); $this->initialize(); } public function getCurl() { return $this->curl; } public function getId() { return $this->id; } public function isError() { return $this->error; } public function getErrorCode() { return $this->errorCode; } public function getErrorMessage() { return $this->errorMessage; } public function isCurlError() { return $this->curlError; } public function getCurlErrorCode() { return $this->curlErrorCode; } public function getCurlErrorMessage() { return $this->curlErrorMessage; } public function isHttpError() { return $this->httpError; } public function getHttpStatusCode() { return $this->httpStatusCode; } public function getHttpErrorMessage() { return $this->httpErrorMessage; } public function getUrl() { return $this->url; } public function getRequestHeaders() { return $this->requestHeaders; } public function getResponseHeaders() { return $this->responseHeaders; } public function getRawResponseHeaders() { return $this->rawResponseHeaders; } public function getResponseCookies() { return $this->responseCookies; } public function getResponse() { return $this->response; } public function getRawResponse() { return $this->rawResponse; } public function getBeforeSendCallback() { return $this->beforeSendCallback; } public function getDownloadCompleteCallback() { return $this->downloadCompleteCallback; } public function getDownloadFileName() { return $this->downloadFileName; } public function getSuccessCallback() { return $this->successCallback; } public function getErrorCallback() { return $this->errorCallback; } public function getCompleteCallback() { return $this->completeCallback; } public function getFileHandle() { return $this->fileHandle; } public function getAttempts() { return $this->attempts; } public function getRetries() { return $this->retries; } public function isChildOfMultiCurl() { return $this->childOfMultiCurl; } public function getRemainingRetries() { return $this->remainingRetries; } public function getRetryDecider() { return $this->retryDecider; } public function getJsonDecoder() { return $this->jsonDecoder; } public function getXmlDecoder() { return $this->xmlDecoder; } /** * Destruct * * @access public */ public function __destruct() { $this->close(); } public function __get($name) { $return = null; if (in_array($name, self::$deferredProperties, true) && is_callable([$this, $getter = '_get_' . $name])) { $return = $this->$name = $this->$getter(); } return $return; } /** * Get Curl Error Code Constants * * @access private */ private function _get_curlErrorCodeConstants() { $constants = get_defined_constants(true); $filtered_array = array_filter( $constants['curl'], function ($key) { return strpos($key, 'CURLE_') !== false; }, ARRAY_FILTER_USE_KEY ); $curl_const_by_code = array_flip($filtered_array); return $curl_const_by_code; } /** * Get Curl Error Code Constant * * @access private */ private function _get_curlErrorCodeConstant() { $curl_const_by_code = $this->curlErrorCodeConstants; if (isset($curl_const_by_code[$this->curlErrorCode])) { return $curl_const_by_code[$this->curlErrorCode]; } return ''; } /** * Get Curl Option Code Constants * * @access private */ private function _get_curlOptionCodeConstants() { $constants = get_defined_constants(true); $filtered_array = array_filter( $constants['curl'], function ($key) { return strpos($key, 'CURLOPT_') !== false; }, ARRAY_FILTER_USE_KEY ); $curl_const_by_code = array_flip($filtered_array); if (!isset($curl_const_by_code[CURLINFO_HEADER_OUT])) { $curl_const_by_code[CURLINFO_HEADER_OUT] = 'CURLINFO_HEADER_OUT'; } return $curl_const_by_code; } /** * Get Effective Url * * @access private */ private function _get_effectiveUrl() { return $this->getInfo(CURLINFO_EFFECTIVE_URL); } /** * Get RFC 2616 * * @access private */ private function _get_rfc2616() { return array_fill_keys(self::$RFC2616, true); } /** * Get RFC 6265 * * @access private */ private function _get_rfc6265() { return array_fill_keys(self::$RFC6265, true); } /** * Get Total Time * * @access private */ private function _get_totalTime() { return $this->getInfo(CURLINFO_TOTAL_TIME); } /** * Build Cookies * * @access private */ private function buildCookies() { // Avoid changing CURLOPT_COOKIE if there are no cookies set. if (count($this->cookies)) { // Avoid using http_build_query() as unnecessary encoding is performed. // http_build_query($this->cookies, '', '; '); $this->setOpt(CURLOPT_COOKIE, implode('; ', array_map(function ($k, $v) { return $k . '=' . $v; }, array_keys($this->cookies), array_values($this->cookies)))); } } /** * Download Complete * * @access private * @param $fh */ private function downloadComplete($fh) { if ($this->error && is_file((string) $this->downloadFileName)) { @unlink($this->downloadFileName); } elseif (!$this->error && $this->downloadCompleteCallback) { rewind($fh); $this->call($this->downloadCompleteCallback, $fh); $this->downloadCompleteCallback = null; } if (is_resource($fh)) { fclose($fh); } // Fix "PHP Notice: Use of undefined constant STDOUT" when reading the // PHP script from stdin. Using null causes "Warning: curl_setopt(): // supplied argument is not a valid File-Handle resource". if (!defined('STDOUT')) { define('STDOUT', fopen('php://stdout', 'w')); } // Reset CURLOPT_FILE with STDOUT to avoid: "curl_exec(): CURLOPT_FILE // resource has gone away, resetting to default". $this->setFile(STDOUT); // Reset CURLOPT_RETURNTRANSFER to tell cURL to return subsequent // responses as the return value of curl_exec(). Without this, // curl_exec() will revert to returning boolean values. $this->setOpt(CURLOPT_RETURNTRANSFER, true); } /** * Parse Headers * * @access private * @param $raw_headers * * @return array */ private function parseHeaders($raw_headers) { $raw_headers = preg_split('/\r\n/', (string) $raw_headers, -1, PREG_SPLIT_NO_EMPTY); $http_headers = new CaseInsensitiveArray(); $raw_headers_count = count($raw_headers); for ($i = 1; $i < $raw_headers_count; $i++) { if (strpos($raw_headers[$i], ':') !== false) { list($key, $value) = explode(':', $raw_headers[$i], 2); $key = trim($key); $value = trim($value); // Use isset() as array_key_exists() and ArrayAccess are not compatible. if (isset($http_headers[$key])) { $http_headers[$key] .= ',' . $value; } else { $http_headers[$key] = $value; } } } return [isset($raw_headers['0']) ? $raw_headers['0'] : '', $http_headers]; } /** * Parse Request Headers * * @access private * @param $raw_headers * * @return \Curl\CaseInsensitiveArray */ private function parseRequestHeaders($raw_headers) { $request_headers = new CaseInsensitiveArray(); list($first_line, $headers) = $this->parseHeaders($raw_headers); $request_headers['Request-Line'] = $first_line; foreach ($headers as $key => $value) { $request_headers[$key] = $value; } return $request_headers; } /** * Parse Response * * @access private * @param $response_headers * @param $raw_response * * @return mixed * If the response content-type is json: * Returns the json decoder's return value: A stdClass object when the default json decoder is used. * If the response content-type is xml: * Returns the xml decoder's return value: A SimpleXMLElement object when the default xml decoder is used. * If the response content-type is something else: * Returns the original raw response unless a default decoder has been set. * If the response content-type cannot be determined: * Returns the original raw response. * If the response content-encoding is gzip: * Returns the response gzip-decoded. */ private function parseResponse($response_headers, $raw_response) { $response = $raw_response; if (isset($response_headers['Content-Type'])) { if (preg_match($this->jsonPattern, $response_headers['Content-Type'])) { if ($this->jsonDecoder) { $args = $this->jsonDecoderArgs; array_unshift($args, $response); $response = call_user_func_array($this->jsonDecoder, $args); } } elseif (preg_match($this->xmlPattern, $response_headers['Content-Type'])) { if ($this->xmlDecoder) { $args = $this->xmlDecoderArgs; array_unshift($args, $response); $response = call_user_func_array($this->xmlDecoder, $args); } } else { if ($this->defaultDecoder) { $response = call_user_func($this->defaultDecoder, $response); } } } if (isset($response_headers['Content-Encoding']) && $response_headers['Content-Encoding'] === 'gzip' && is_string($response)) { // Use @ to suppress message "Warning gzdecode(): data error". $decoded_response = @gzdecode($response); if ($decoded_response !== false) { $response = $decoded_response; } } return $response; } /** * Parse Response Headers * * @access private * @param $raw_response_headers * * @return \Curl\CaseInsensitiveArray */ private function parseResponseHeaders($raw_response_headers) { $response_header_array = explode("\r\n\r\n", $raw_response_headers); $response_header = ''; for ($i = count($response_header_array) - 1; $i >= 0; $i--) { if (isset($response_header_array[$i]) && stripos($response_header_array[$i], 'HTTP/') === 0) { $response_header = $response_header_array[$i]; break; } } $response_headers = new CaseInsensitiveArray(); list($first_line, $headers) = $this->parseHeaders($response_header); $response_headers['Status-Line'] = $first_line; foreach ($headers as $key => $value) { $response_headers[$key] = $value; } return $response_headers; } /** * Set Encoded Cookie * * @access private * @param $key * @param $value */ private function setEncodedCookie($key, $value) { $name_chars = []; foreach (str_split($key) as $name_char) { if (isset($this->rfc2616[$name_char])) { $name_chars[] = $name_char; } else { $name_chars[] = rawurlencode($name_char); } } $value_chars = []; foreach (str_split($value) as $value_char) { if (isset($this->rfc6265[$value_char])) { $value_chars[] = $value_char; } else { $value_chars[] = rawurlencode($value_char); } } $this->cookies[implode('', $name_chars)] = implode('', $value_chars); } /** * Initialize * * @access private * @param $base_url */ private function initialize($base_url = null, $options = []) { if (isset($options)) { $this->setOpts($options); } $this->id = uniqid('', true); // Only set default user agent if not already set. if (!array_key_exists(CURLOPT_USERAGENT, $this->options)) { $this->setDefaultUserAgent(); } // Only set default timeout if not already set. if (!array_key_exists(CURLOPT_TIMEOUT, $this->options)) { $this->setDefaultTimeout(); } if (!array_key_exists(CURLINFO_HEADER_OUT, $this->options)) { $this->setDefaultHeaderOut(); } // Create a placeholder to temporarily store the header callback data. $header_callback_data = new \stdClass(); $header_callback_data->rawResponseHeaders = ''; $header_callback_data->responseCookies = []; $header_callback_data->stopRequestDecider = null; $header_callback_data->stopRequest = false; $this->headerCallbackData = $header_callback_data; $this->setStop(); $this->setOpt(CURLOPT_HEADERFUNCTION, createHeaderCallback($header_callback_data)); $this->setOpt(CURLOPT_RETURNTRANSFER, true); $this->headers = new CaseInsensitiveArray(); if ($base_url !== null) { $this->setUrl($base_url); } } /** * Set Stop * * Specify a callable decider to stop the request early without waiting for * the full response to be received. * * The callable is passed two parameters. The first is the cURL resource, * the second is a string with header data. Both parameters match the * parameters in the CURLOPT_HEADERFUNCTION callback. * * The callable must return a truthy value for the request to be stopped * early. * * The callable may be set to null to avoid calling the stop request decider * callback and instead just check the value of stopRequest for attempting * to stop the request as used by Curl::stop(). * * @access public * @param $callback callable|null */ public function setStop($callback = null) { $this->headerCallbackData->stopRequestDecider = $callback; $this->headerCallbackData->stopRequest = false; $header_callback_data = $this->headerCallbackData; $this->progress(createStopRequestFunction($header_callback_data)); } /** * Stop * * Attempt to stop request. * * Used by MultiCurl::stop() when making multiple parallel requests. * * @access public */ public function stop() { $this->headerCallbackData->stopRequest = true; } } /** * Create Header Callback * * Gather headers and parse cookies as response headers are received. Keep this function separate from the class so that * unset($curl) automatically calls __destruct() as expected. Otherwise, manually calling $curl->close() will be * necessary to prevent a memory leak. * * @param $header_callback_data * * @return callable */ function createHeaderCallback($header_callback_data) { return function ($ch, $header) use ($header_callback_data) { if (preg_match('/^Set-Cookie:\s*([^=]+)=([^;]+)/mi', $header, $cookie) === 1) { $header_callback_data->responseCookies[$cookie[1]] = trim($cookie[2], " \n\r\t\0\x0B"); } if ($header_callback_data->stopRequestDecider !== null) { $stop_request_decider = $header_callback_data->stopRequestDecider; if ($stop_request_decider($ch, $header)) { $header_callback_data->stopRequest = true; } } $header_callback_data->rawResponseHeaders .= $header; return strlen($header); }; } /** * Create Stop Request Function * * Create a function for Curl::progress() that stops a request early when the * stopRequest flag is on. Keep this function separate from the class to prevent * a memory leak. * * @param $header_callback_data * * @return callable */ function createStopRequestFunction($header_callback_data) { return function ( $resource, $download_size, $downloaded, $upload_size, $uploaded ) use ( $header_callback_data ) { // Abort the transfer when the stop request flag has been set by returning a non-zero value. return $header_callback_data->stopRequest ? 1 : 0; }; }