├── CHANGELOG.md ├── LICENSE ├── README.md ├── certificates ├── cacert.pem └── cacert.pem.sha256 ├── composer.json ├── library ├── Deprecated.php ├── README.md └── Requests.php ├── scripts └── proxy │ ├── proxy.py │ ├── start.sh │ └── stop.sh └── src ├── Auth.php ├── Auth └── Basic.php ├── Autoload.php ├── Capability.php ├── Cookie.php ├── Cookie └── Jar.php ├── Exception.php ├── Exception ├── ArgumentCount.php ├── Http.php ├── Http │ ├── Status304.php │ ├── Status305.php │ ├── Status306.php │ ├── Status400.php │ ├── Status401.php │ ├── Status402.php │ ├── Status403.php │ ├── Status404.php │ ├── Status405.php │ ├── Status406.php │ ├── Status407.php │ ├── Status408.php │ ├── Status409.php │ ├── Status410.php │ ├── Status411.php │ ├── Status412.php │ ├── Status413.php │ ├── Status414.php │ ├── Status415.php │ ├── Status416.php │ ├── Status417.php │ ├── Status418.php │ ├── Status421.php │ ├── Status422.php │ ├── Status423.php │ ├── Status424.php │ ├── Status425.php │ ├── Status426.php │ ├── Status428.php │ ├── Status429.php │ ├── Status431.php │ ├── Status451.php │ ├── Status500.php │ ├── Status501.php │ ├── Status502.php │ ├── Status503.php │ ├── Status504.php │ ├── Status505.php │ ├── Status506.php │ ├── Status507.php │ ├── Status508.php │ ├── Status510.php │ ├── Status511.php │ └── StatusUnknown.php ├── InvalidArgument.php ├── Transport.php └── Transport │ └── Curl.php ├── HookManager.php ├── Hooks.php ├── IdnaEncoder.php ├── Ipv6.php ├── Iri.php ├── Port.php ├── Proxy.php ├── Proxy └── Http.php ├── Requests.php ├── Response.php ├── Response └── Headers.php ├── Session.php ├── Ssl.php ├── Transport.php ├── Transport ├── Curl.php └── Fsockopen.php └── Utility ├── CaseInsensitiveDictionary.php ├── FilteredIterator.php ├── HttpStatus.php └── InputValidator.php /LICENSE: -------------------------------------------------------------------------------- 1 | Requests 2 | ======== 3 | 4 | Copyright (c) 2010-2012 Ryan McCue and contributors 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any 7 | purpose with or without fee is hereby granted, provided that the above 8 | copyright notice and this permission notice appear in all copies. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | 18 | 19 | ComplexPie IRI Parser 20 | ===================== 21 | 22 | Copyright (c) 2007-2010, Geoffrey Sneddon and Steve Minutillo. 23 | All rights reserved. 24 | 25 | Redistribution and use in source and binary forms, with or without 26 | modification, are permitted provided that the following conditions are met: 27 | 28 | * Redistributions of source code must retain the above copyright notice, 29 | this list of conditions and the following disclaimer. 30 | 31 | * Redistributions in binary form must reproduce the above copyright notice, 32 | this list of conditions and the following disclaimer in the documentation 33 | and/or other materials provided with the distribution. 34 | 35 | * Neither the name of the SimplePie Team nor the names of its contributors 36 | may be used to endorse or promote products derived from this software 37 | without specific prior written permission. 38 | 39 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 40 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 41 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 42 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE 43 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 44 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 45 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 46 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 47 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 48 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 49 | POSSIBILITY OF SUCH DAMAGE. 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Requests for PHP 2 | ================ 3 | 4 | [![CS](https://github.com/WordPress/Requests/actions/workflows/cs.yml/badge.svg)](https://github.com/WordPress/Requests/actions/workflows/cs.yml) 5 | [![Lint](https://github.com/WordPress/Requests/actions/workflows/lint.yml/badge.svg)](https://github.com/WordPress/Requests/actions/workflows/lint.yml) 6 | [![Test](https://github.com/WordPress/Requests/actions/workflows/test.yml/badge.svg)](https://github.com/WordPress/Requests/actions/workflows/test.yml) 7 | [![codecov.io](https://codecov.io/gh/WordPress/Requests/branch/stable/graph/badge.svg?token=AfpxK7WMxj&branch=stable)](https://codecov.io/gh/WordPress/Requests?branch=stable) 8 | 9 | Requests is a HTTP library written in PHP, for human beings. It is roughly 10 | based on the API from the excellent [Requests Python 11 | library](http://python-requests.org/). Requests is [ISC 12 | Licensed](https://github.com/WordPress/Requests/blob/stable/LICENSE) (similar to 13 | the new BSD license) and has no dependencies, except for PHP 5.6.20+. 14 | 15 | Despite PHP's use as a language for the web, its tools for sending HTTP requests 16 | are severely lacking. cURL has an 17 | [interesting API](https://www.php.net/curl-setopt), to say the 18 | least, and you can't always rely on it being available. Sockets provide only low 19 | level access, and require you to build most of the HTTP response parsing 20 | yourself. 21 | 22 | We all have better things to do. That's why Requests was born. 23 | 24 | ```php 25 | $headers = array('Accept' => 'application/json'); 26 | $options = array('auth' => array('user', 'pass')); 27 | $request = WpOrg\Requests\Requests::get('https://api.github.com/gists', $headers, $options); 28 | 29 | var_dump($request->status_code); 30 | // int(200) 31 | 32 | var_dump($request->headers['content-type']); 33 | // string(31) "application/json; charset=utf-8" 34 | 35 | var_dump($request->body); 36 | // string(26891) "[...]" 37 | ``` 38 | 39 | Requests allows you to send **HEAD**, **GET**, **POST**, **PUT**, **DELETE**, 40 | and **PATCH** HTTP requests. You can add headers, form data, multipart files, 41 | and parameters with basic arrays, and access the response data in the same way. 42 | Requests uses cURL and fsockopen, depending on what your system has available, 43 | but abstracts all the nasty stuff out of your way, providing a consistent API. 44 | 45 | 46 | Features 47 | -------- 48 | 49 | - International Domains and URLs 50 | - Browser-style SSL Verification 51 | - Basic/Digest Authentication 52 | - Automatic Decompression 53 | - Connection Timeouts 54 | 55 | 56 | Installation 57 | ------------ 58 | 59 | ### Install with Composer 60 | If you're using [Composer](https://getcomposer.org/) to manage 61 | dependencies, you can add Requests with it. 62 | 63 | ```sh 64 | composer require rmccue/requests 65 | ``` 66 | 67 | or 68 | ```json 69 | { 70 | "require": { 71 | "rmccue/requests": "^2.0" 72 | } 73 | } 74 | ``` 75 | 76 | ### Install source from GitHub 77 | To install the source code: 78 | ```bash 79 | $ git clone git://github.com/WordPress/Requests.git 80 | ``` 81 | 82 | Next, include the autoloader in your scripts: 83 | ```php 84 | require_once '/path/to/Requests/src/Autoload.php'; 85 | ``` 86 | 87 | You'll probably also want to register the autoloader: 88 | ```php 89 | WpOrg\Requests\Autoload::register(); 90 | ``` 91 | 92 | ### Install source from zip/tarball 93 | Alternatively, you can fetch a [tarball][] or [zipball][]: 94 | 95 | ```bash 96 | $ curl -L https://github.com/WordPress/Requests/tarball/stable | tar xzv 97 | (or) 98 | $ wget https://github.com/WordPress/Requests/tarball/stable -O - | tar xzv 99 | ``` 100 | 101 | [tarball]: https://github.com/WordPress/Requests/tarball/stable 102 | [zipball]: https://github.com/WordPress/Requests/zipball/stable 103 | 104 | 105 | ### Using a Class Loader 106 | If you're using a class loader (e.g., [Symfony Class Loader][]) for 107 | [PSR-4][]-style class loading: 108 | ```php 109 | $loader = new Psr4ClassLoader(); 110 | $loader->addPrefix('WpOrg\\Requests\\', 'path/to/vendor/Requests/src'); 111 | $loader->register(); 112 | ``` 113 | 114 | [Symfony Class Loader]: https://github.com/symfony/ClassLoader 115 | [PSR-4]: https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-4.md 116 | 117 | 118 | Documentation 119 | ------------- 120 | The best place to start is our [prose-based documentation][], which will guide 121 | you through using Requests. 122 | 123 | After that, take a look at [the documentation for 124 | `\WpOrg\Requests\Requests::request()`][request_method], where all the parameters are fully 125 | documented. 126 | 127 | Requests is [100% documented with PHPDoc](https://requests.ryanmccue.info/api-2.x/). 128 | If you find any problems with it, [create a new 129 | issue](https://github.com/WordPress/Requests/issues/new)! 130 | 131 | [prose-based documentation]: https://github.com/WordPress/Requests/blob/stable/docs/README.md 132 | [request_method]: https://requests.ryanmccue.info/api-2.x/classes/WpOrg-Requests-Requests.html#method_request 133 | 134 | 135 | Test Coverage 136 | ------------- 137 | 138 | Requests strives to have 100% code-coverage of the library with an extensive 139 | set of tests. We're not quite there yet, but [we're getting close][codecov]. 140 | 141 | [codecov]: https://codecov.io/github/WordPress/Requests/ 142 | 143 | 144 | Requests and PSR-7/PSR-18 145 | ------------------------- 146 | 147 | [PSR-7][psr-7] describes common interfaces for representing HTTP messages. 148 | [PSR-18][psr-18] describes a common interface for sending HTTP requests and receiving HTTP responses. 149 | 150 | Both PSR-7 as well as PSR-18 were created after Requests' conception. 151 | At this time, there is no intention to add a native PSR-7/PSR-18 implementation to the Requests library. 152 | 153 | However, the amazing [Artur Weigandt][art4] has created a [package][requests-psr-18], which allows you to use Requests as a PSR-7 compatible PSR-18 HTTP Client. 154 | If you are interested in a PSR-7/PSR-18 compatible version of Requests, we highly recommend you check out [this package][requests-psr-18]. 155 | 156 | [psr-7]: https://www.php-fig.org/psr/psr-7/ 157 | [psr-18]: https://www.php-fig.org/psr/psr-18/ 158 | [art4]: https://github.com/Art4 159 | [requests-psr-18]: https://packagist.org/packages/art4/requests-psr18-adapter 160 | 161 | 162 | Contribute 163 | ---------- 164 | 165 | Contributions to this library are very welcome. Please read the [Contributing guidelines][] to get started. 166 | 167 | [Contributing guidelines]: https://github.com/WordPress/Requests/blob/develop/.github/CONTRIBUTING.md 168 | -------------------------------------------------------------------------------- /certificates/cacert.pem.sha256: -------------------------------------------------------------------------------- 1 | a3f328c21e39ddd1f2be1cea43ac0dec819eaa20a90425d7da901a11531b3aa5 cacert.pem 2 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rmccue/requests", 3 | "description": "A HTTP library written in PHP, for human beings.", 4 | "license": "ISC", 5 | "type": "library", 6 | "keywords": [ 7 | "http", 8 | "idna", 9 | "iri", 10 | "ipv6", 11 | "curl", 12 | "sockets", 13 | "fsockopen" 14 | ], 15 | "authors": [ 16 | { 17 | "name": "Ryan McCue", 18 | "homepage": "https://rmccue.io/" 19 | }, 20 | { 21 | "name": "Alain Schlesser", 22 | "homepage": "https://github.com/schlessera" 23 | }, 24 | { 25 | "name": "Juliette Reinders Folmer", 26 | "homepage": "https://github.com/jrfnl" 27 | }, 28 | { 29 | "name": "Contributors", 30 | "homepage": "https://github.com/WordPress/Requests/graphs/contributors" 31 | } 32 | ], 33 | "homepage": "https://requests.ryanmccue.info/", 34 | "support": { 35 | "issues": "https://github.com/WordPress/Requests/issues", 36 | "source": "https://github.com/WordPress/Requests", 37 | "docs": "https://requests.ryanmccue.info/" 38 | }, 39 | "require": { 40 | "php": ">=5.6.20", 41 | "ext-json": "*" 42 | }, 43 | "require-dev": { 44 | "php-parallel-lint/php-console-highlighter": "^1.0.0", 45 | "php-parallel-lint/php-parallel-lint": "^1.4.0", 46 | "phpcompatibility/php-compatibility": "^9.3.5", 47 | "requests/test-server": "dev-main", 48 | "roave/security-advisories": "dev-latest", 49 | "wp-coding-standards/wpcs": "^3.1", 50 | "yoast/phpunit-polyfills": "^2.0.1" 51 | }, 52 | "suggest": { 53 | "ext-curl": "For improved performance", 54 | "ext-openssl": "For secure transport support", 55 | "ext-zlib": "For improved performance when decompressing encoded streams", 56 | "art4/requests-psr18-adapter": "For using Requests as a PSR-18 HTTP Client" 57 | }, 58 | "autoload": { 59 | "psr-4": { 60 | "WpOrg\\Requests\\": "src/" 61 | }, 62 | "classmap": [ 63 | "library/Requests.php" 64 | ], 65 | "files": [ 66 | "library/Deprecated.php" 67 | ] 68 | }, 69 | "autoload-dev": { 70 | "psr-4": { 71 | "WpOrg\\Requests\\Tests\\": "tests/" 72 | } 73 | }, 74 | "config": { 75 | "allow-plugins": { 76 | "dealerdirect/phpcodesniffer-composer-installer": true 77 | } 78 | }, 79 | "scripts": { 80 | "lint": [ 81 | "@php ./vendor/php-parallel-lint/php-parallel-lint/parallel-lint . --show-deprecated -e php --exclude vendor --exclude .git" 82 | ], 83 | "checkcs": [ 84 | "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs" 85 | ], 86 | "fixcs": [ 87 | "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf" 88 | ], 89 | "test": [ 90 | "@php ./vendor/phpunit/phpunit/phpunit --no-coverage" 91 | ], 92 | "test10": [ 93 | "@php ./vendor/phpunit/phpunit/phpunit -c phpunit10.xml.dist --no-coverage" 94 | ], 95 | "coverage": [ 96 | "@php ./vendor/phpunit/phpunit/phpunit" 97 | ], 98 | "coverage10": [ 99 | "@php ./vendor/phpunit/phpunit/phpunit -c phpunit10.xml.dist" 100 | ] 101 | }, 102 | "scripts-descriptions": { 103 | "lint": "Lint PHP files to find parse errors.", 104 | "checkcs": "Check the entire codebase for code-style issues.", 105 | "fixcs": "Fix all auto-fixable code-style issues in the entire codebase.", 106 | "test": "Run the unit tests on PHPUnit 5.x - 9.x without code coverage.", 107 | "test10": "Run the unit tests on PHPUnit 10.x without code coverage.", 108 | "coverage": "Run the unit tests on PHPUnit 5.x - 9.x with code coverage.", 109 | "coverage10": "Run the unit tests on PHPUnit 10.x with code coverage." 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /library/Deprecated.php: -------------------------------------------------------------------------------- 1 | user, $this->pass) = $args; 58 | return; 59 | } 60 | 61 | if ($args !== null) { 62 | throw InvalidArgument::create(1, '$args', 'array|null', gettype($args)); 63 | } 64 | } 65 | 66 | /** 67 | * Register the necessary callbacks 68 | * 69 | * @see \WpOrg\Requests\Auth\Basic::curl_before_send() 70 | * @see \WpOrg\Requests\Auth\Basic::fsockopen_header() 71 | * @param \WpOrg\Requests\Hooks $hooks Hook system 72 | */ 73 | public function register(Hooks $hooks) { 74 | $hooks->register('curl.before_send', [$this, 'curl_before_send']); 75 | $hooks->register('fsockopen.after_headers', [$this, 'fsockopen_header']); 76 | } 77 | 78 | /** 79 | * Set cURL parameters before the data is sent. 80 | * 81 | * @param resource|\CurlHandle $handle The cURL handle. 82 | */ 83 | public function curl_before_send(&$handle) { 84 | curl_setopt($handle, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); 85 | curl_setopt($handle, CURLOPT_USERPWD, $this->getAuthString()); 86 | } 87 | 88 | /** 89 | * Add extra headers to the request before sending. 90 | * 91 | * @param string $out HTTP header string. 92 | */ 93 | public function fsockopen_header(&$out) { 94 | $out .= sprintf("Authorization: Basic %s\r\n", base64_encode($this->getAuthString())); 95 | } 96 | 97 | /** 98 | * Get the authentication string (user:pass) 99 | * 100 | * @return string 101 | */ 102 | public function getAuthString() { 103 | return $this->user . ':' . $this->pass; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/Autoload.php: -------------------------------------------------------------------------------- 1 | '\WpOrg\Requests\Auth', 45 | 'requests_hooker' => '\WpOrg\Requests\HookManager', 46 | 'requests_proxy' => '\WpOrg\Requests\Proxy', 47 | 'requests_transport' => '\WpOrg\Requests\Transport', 48 | 49 | // Classes. 50 | 'requests_cookie' => '\WpOrg\Requests\Cookie', 51 | 'requests_exception' => '\WpOrg\Requests\Exception', 52 | 'requests_hooks' => '\WpOrg\Requests\Hooks', 53 | 'requests_idnaencoder' => '\WpOrg\Requests\IdnaEncoder', 54 | 'requests_ipv6' => '\WpOrg\Requests\Ipv6', 55 | 'requests_iri' => '\WpOrg\Requests\Iri', 56 | 'requests_response' => '\WpOrg\Requests\Response', 57 | 'requests_session' => '\WpOrg\Requests\Session', 58 | 'requests_ssl' => '\WpOrg\Requests\Ssl', 59 | 'requests_auth_basic' => '\WpOrg\Requests\Auth\Basic', 60 | 'requests_cookie_jar' => '\WpOrg\Requests\Cookie\Jar', 61 | 'requests_proxy_http' => '\WpOrg\Requests\Proxy\Http', 62 | 'requests_response_headers' => '\WpOrg\Requests\Response\Headers', 63 | 'requests_transport_curl' => '\WpOrg\Requests\Transport\Curl', 64 | 'requests_transport_fsockopen' => '\WpOrg\Requests\Transport\Fsockopen', 65 | 'requests_utility_caseinsensitivedictionary' => '\WpOrg\Requests\Utility\CaseInsensitiveDictionary', 66 | 'requests_utility_filterediterator' => '\WpOrg\Requests\Utility\FilteredIterator', 67 | 'requests_exception_http' => '\WpOrg\Requests\Exception\Http', 68 | 'requests_exception_transport' => '\WpOrg\Requests\Exception\Transport', 69 | 'requests_exception_transport_curl' => '\WpOrg\Requests\Exception\Transport\Curl', 70 | 'requests_exception_http_304' => '\WpOrg\Requests\Exception\Http\Status304', 71 | 'requests_exception_http_305' => '\WpOrg\Requests\Exception\Http\Status305', 72 | 'requests_exception_http_306' => '\WpOrg\Requests\Exception\Http\Status306', 73 | 'requests_exception_http_400' => '\WpOrg\Requests\Exception\Http\Status400', 74 | 'requests_exception_http_401' => '\WpOrg\Requests\Exception\Http\Status401', 75 | 'requests_exception_http_402' => '\WpOrg\Requests\Exception\Http\Status402', 76 | 'requests_exception_http_403' => '\WpOrg\Requests\Exception\Http\Status403', 77 | 'requests_exception_http_404' => '\WpOrg\Requests\Exception\Http\Status404', 78 | 'requests_exception_http_405' => '\WpOrg\Requests\Exception\Http\Status405', 79 | 'requests_exception_http_406' => '\WpOrg\Requests\Exception\Http\Status406', 80 | 'requests_exception_http_407' => '\WpOrg\Requests\Exception\Http\Status407', 81 | 'requests_exception_http_408' => '\WpOrg\Requests\Exception\Http\Status408', 82 | 'requests_exception_http_409' => '\WpOrg\Requests\Exception\Http\Status409', 83 | 'requests_exception_http_410' => '\WpOrg\Requests\Exception\Http\Status410', 84 | 'requests_exception_http_411' => '\WpOrg\Requests\Exception\Http\Status411', 85 | 'requests_exception_http_412' => '\WpOrg\Requests\Exception\Http\Status412', 86 | 'requests_exception_http_413' => '\WpOrg\Requests\Exception\Http\Status413', 87 | 'requests_exception_http_414' => '\WpOrg\Requests\Exception\Http\Status414', 88 | 'requests_exception_http_415' => '\WpOrg\Requests\Exception\Http\Status415', 89 | 'requests_exception_http_416' => '\WpOrg\Requests\Exception\Http\Status416', 90 | 'requests_exception_http_417' => '\WpOrg\Requests\Exception\Http\Status417', 91 | 'requests_exception_http_418' => '\WpOrg\Requests\Exception\Http\Status418', 92 | 'requests_exception_http_428' => '\WpOrg\Requests\Exception\Http\Status428', 93 | 'requests_exception_http_429' => '\WpOrg\Requests\Exception\Http\Status429', 94 | 'requests_exception_http_431' => '\WpOrg\Requests\Exception\Http\Status431', 95 | 'requests_exception_http_500' => '\WpOrg\Requests\Exception\Http\Status500', 96 | 'requests_exception_http_501' => '\WpOrg\Requests\Exception\Http\Status501', 97 | 'requests_exception_http_502' => '\WpOrg\Requests\Exception\Http\Status502', 98 | 'requests_exception_http_503' => '\WpOrg\Requests\Exception\Http\Status503', 99 | 'requests_exception_http_504' => '\WpOrg\Requests\Exception\Http\Status504', 100 | 'requests_exception_http_505' => '\WpOrg\Requests\Exception\Http\Status505', 101 | 'requests_exception_http_511' => '\WpOrg\Requests\Exception\Http\Status511', 102 | 'requests_exception_http_unknown' => '\WpOrg\Requests\Exception\Http\StatusUnknown', 103 | ]; 104 | 105 | /** 106 | * Register the autoloader. 107 | * 108 | * Note: the autoloader is *prepended* in the autoload queue. 109 | * This is done to ensure that the Requests 2.0 autoloader takes precedence 110 | * over a potentially (dependency-registered) Requests 1.x autoloader. 111 | * 112 | * @internal This method contains a safeguard against the autoloader being 113 | * registered multiple times. This safeguard uses a global constant to 114 | * (hopefully/in most cases) still function correctly, even if the 115 | * class would be renamed. 116 | * 117 | * @return void 118 | */ 119 | public static function register() { 120 | if (defined('REQUESTS_AUTOLOAD_REGISTERED') === false) { 121 | spl_autoload_register([self::class, 'load'], true); 122 | define('REQUESTS_AUTOLOAD_REGISTERED', true); 123 | } 124 | } 125 | 126 | /** 127 | * Autoloader. 128 | * 129 | * @param string $class_name Name of the class name to load. 130 | * 131 | * @return bool Whether a class was loaded or not. 132 | */ 133 | public static function load($class_name) { 134 | // Check that the class starts with "Requests" (PSR-0) or "WpOrg\Requests" (PSR-4). 135 | $psr_4_prefix_pos = strpos($class_name, 'WpOrg\\Requests\\'); 136 | 137 | if (stripos($class_name, 'Requests') !== 0 && $psr_4_prefix_pos !== 0) { 138 | return false; 139 | } 140 | 141 | $class_lower = strtolower($class_name); 142 | 143 | if ($class_lower === 'requests') { 144 | // Reference to the original PSR-0 Requests class. 145 | $file = dirname(__DIR__) . '/library/Requests.php'; 146 | } elseif ($psr_4_prefix_pos === 0) { 147 | // PSR-4 classname. 148 | $file = __DIR__ . '/' . strtr(substr($class_name, 15), '\\', '/') . '.php'; 149 | } 150 | 151 | if (isset($file) && file_exists($file)) { 152 | include $file; 153 | return true; 154 | } 155 | 156 | /* 157 | * Okay, so the class starts with "Requests", but we couldn't find the file. 158 | * If this is one of the deprecated/renamed PSR-0 classes being requested, 159 | * let's alias it to the new name and throw a deprecation notice. 160 | */ 161 | if (isset(self::$deprecated_classes[$class_lower])) { 162 | /* 163 | * Integrators who cannot yet upgrade to the PSR-4 class names can silence deprecations 164 | * by defining a `REQUESTS_SILENCE_PSR0_DEPRECATIONS` constant and setting it to `true`. 165 | * The constant needs to be defined before the first deprecated class is requested 166 | * via this autoloader. 167 | */ 168 | if (!defined('REQUESTS_SILENCE_PSR0_DEPRECATIONS') || REQUESTS_SILENCE_PSR0_DEPRECATIONS !== true) { 169 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error 170 | trigger_error( 171 | 'The PSR-0 `Requests_...` class names in the Requests library are deprecated.' 172 | . ' Switch to the PSR-4 `WpOrg\Requests\...` class names at your earliest convenience.', 173 | E_USER_DEPRECATED 174 | ); 175 | 176 | // Prevent the deprecation notice from being thrown twice. 177 | if (!defined('REQUESTS_SILENCE_PSR0_DEPRECATIONS')) { 178 | define('REQUESTS_SILENCE_PSR0_DEPRECATIONS', true); 179 | } 180 | } 181 | 182 | // Create an alias and let the autoloader recursively kick in to load the PSR-4 class. 183 | return class_alias(self::$deprecated_classes[$class_lower], $class_name, true); 184 | } 185 | 186 | return false; 187 | } 188 | 189 | /** 190 | * Get the array of deprecated Requests 1.x classes mapped to their equivalent Requests 2.x implementation. 191 | * 192 | * @return array 193 | */ 194 | public static function get_deprecated_classes() { 195 | return self::$deprecated_classes; 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/Capability.php: -------------------------------------------------------------------------------- 1 | 35 | */ 36 | const ALL = [ 37 | self::SSL, 38 | ]; 39 | } 40 | -------------------------------------------------------------------------------- /src/Cookie.php: -------------------------------------------------------------------------------- 1 | name = (string) $name; 106 | $this->value = $value; 107 | $this->attributes = $attributes; 108 | $default_flags = [ 109 | 'creation' => time(), 110 | 'last-access' => time(), 111 | 'persistent' => false, 112 | 'host-only' => true, 113 | ]; 114 | $this->flags = array_merge($default_flags, $flags); 115 | 116 | $this->reference_time = time(); 117 | if ($reference_time !== null) { 118 | $this->reference_time = $reference_time; 119 | } 120 | 121 | $this->normalize(); 122 | } 123 | 124 | /** 125 | * Get the cookie value 126 | * 127 | * Attributes and other data can be accessed via methods. 128 | */ 129 | public function __toString() { 130 | return $this->value; 131 | } 132 | 133 | /** 134 | * Check if a cookie is expired. 135 | * 136 | * Checks the age against $this->reference_time to determine if the cookie 137 | * is expired. 138 | * 139 | * @return bool True if expired, false if time is valid. 140 | */ 141 | public function is_expired() { 142 | // RFC6265, s. 4.1.2.2: 143 | // If a cookie has both the Max-Age and the Expires attribute, the Max- 144 | // Age attribute has precedence and controls the expiration date of the 145 | // cookie. 146 | if (isset($this->attributes['max-age'])) { 147 | $max_age = $this->attributes['max-age']; 148 | return $max_age < $this->reference_time; 149 | } 150 | 151 | if (isset($this->attributes['expires'])) { 152 | $expires = $this->attributes['expires']; 153 | return $expires < $this->reference_time; 154 | } 155 | 156 | return false; 157 | } 158 | 159 | /** 160 | * Check if a cookie is valid for a given URI 161 | * 162 | * @param \WpOrg\Requests\Iri $uri URI to check 163 | * @return bool Whether the cookie is valid for the given URI 164 | */ 165 | public function uri_matches(Iri $uri) { 166 | if (!$this->domain_matches($uri->host)) { 167 | return false; 168 | } 169 | 170 | if (!$this->path_matches($uri->path)) { 171 | return false; 172 | } 173 | 174 | return empty($this->attributes['secure']) || $uri->scheme === 'https'; 175 | } 176 | 177 | /** 178 | * Check if a cookie is valid for a given domain 179 | * 180 | * @param string $domain Domain to check 181 | * @return bool Whether the cookie is valid for the given domain 182 | */ 183 | public function domain_matches($domain) { 184 | if (is_string($domain) === false) { 185 | return false; 186 | } 187 | 188 | if (!isset($this->attributes['domain'])) { 189 | // Cookies created manually; cookies created by Requests will set 190 | // the domain to the requested domain 191 | return true; 192 | } 193 | 194 | $cookie_domain = $this->attributes['domain']; 195 | if ($cookie_domain === $domain) { 196 | // The cookie domain and the passed domain are identical. 197 | return true; 198 | } 199 | 200 | // If the cookie is marked as host-only and we don't have an exact 201 | // match, reject the cookie 202 | if ($this->flags['host-only'] === true) { 203 | return false; 204 | } 205 | 206 | $cookie_domain_length = strlen($cookie_domain); 207 | if (strlen($domain) <= $cookie_domain_length) { 208 | // For obvious reasons, the cookie domain cannot be a suffix if the passed domain 209 | // is shorter than the cookie domain 210 | return false; 211 | } 212 | 213 | if (substr($domain, -$cookie_domain_length) !== $cookie_domain) { 214 | // The cookie domain should be a suffix of the passed domain. 215 | return false; 216 | } 217 | 218 | $prefix = substr($domain, 0, -$cookie_domain_length); 219 | if (substr($prefix, -1) !== '.') { 220 | // The last character of the passed domain that is not included in the 221 | // domain string should be a %x2E (".") character. 222 | return false; 223 | } 224 | 225 | // The passed domain should be a host name (i.e., not an IP address). 226 | return !preg_match('#^(.+\.)\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$#', $domain); 227 | } 228 | 229 | /** 230 | * Check if a cookie is valid for a given path 231 | * 232 | * From the path-match check in RFC 6265 section 5.1.4 233 | * 234 | * @param string $request_path Path to check 235 | * @return bool Whether the cookie is valid for the given path 236 | */ 237 | public function path_matches($request_path) { 238 | if (empty($request_path)) { 239 | // Normalize empty path to root 240 | $request_path = '/'; 241 | } 242 | 243 | if (!isset($this->attributes['path'])) { 244 | // Cookies created manually; cookies created by Requests will set 245 | // the path to the requested path 246 | return true; 247 | } 248 | 249 | if (is_scalar($request_path) === false) { 250 | return false; 251 | } 252 | 253 | $cookie_path = $this->attributes['path']; 254 | 255 | if ($cookie_path === $request_path) { 256 | // The cookie-path and the request-path are identical. 257 | return true; 258 | } 259 | 260 | $request_path = (string) $request_path; 261 | $cookie_path_length = strlen($cookie_path); 262 | if (strlen($request_path) <= $cookie_path_length) { 263 | return false; 264 | } 265 | 266 | if (substr($request_path, 0, $cookie_path_length) === $cookie_path) { 267 | if (substr($cookie_path, -1) === '/') { 268 | // The cookie-path is a prefix of the request-path, and the last 269 | // character of the cookie-path is %x2F ("/"). 270 | return true; 271 | } 272 | 273 | if (substr($request_path, $cookie_path_length, 1) === '/') { 274 | // The cookie-path is a prefix of the request-path, and the 275 | // first character of the request-path that is not included in 276 | // the cookie-path is a %x2F ("/") character. 277 | return true; 278 | } 279 | } 280 | 281 | return false; 282 | } 283 | 284 | /** 285 | * Normalize cookie and attributes 286 | * 287 | * @return bool Whether the cookie was successfully normalized 288 | */ 289 | public function normalize() { 290 | foreach ($this->attributes as $key => $value) { 291 | $orig_value = $value; 292 | 293 | if (is_string($key)) { 294 | $value = $this->normalize_attribute($key, $value); 295 | } 296 | 297 | if ($value === null) { 298 | unset($this->attributes[$key]); 299 | continue; 300 | } 301 | 302 | if ($value !== $orig_value) { 303 | $this->attributes[$key] = $value; 304 | } 305 | } 306 | 307 | return true; 308 | } 309 | 310 | /** 311 | * Parse an individual cookie attribute 312 | * 313 | * Handles parsing individual attributes from the cookie values. 314 | * 315 | * @param string $name Attribute name 316 | * @param string|int|bool $value Attribute value (string/integer value, or true if empty/flag) 317 | * @return mixed Value if available, or null if the attribute value is invalid (and should be skipped) 318 | */ 319 | protected function normalize_attribute($name, $value) { 320 | switch (strtolower($name)) { 321 | case 'expires': 322 | // Expiration parsing, as per RFC 6265 section 5.2.1 323 | if (is_int($value)) { 324 | return $value; 325 | } 326 | 327 | if (!is_string($value)) { 328 | return null; 329 | } 330 | 331 | $expiry_time = strtotime($value); 332 | if ($expiry_time === false) { 333 | return null; 334 | } 335 | 336 | return $expiry_time; 337 | 338 | case 'max-age': 339 | // Expiration parsing, as per RFC 6265 section 5.2.2 340 | if (is_int($value)) { 341 | return $value; 342 | } 343 | 344 | if (!is_string($value)) { 345 | return null; 346 | } 347 | 348 | // Check that we have a valid age 349 | if (!preg_match('/^-?\d+$/', $value)) { 350 | return null; 351 | } 352 | 353 | $delta_seconds = (int) $value; 354 | if ($delta_seconds <= 0) { 355 | $expiry_time = 0; 356 | } else { 357 | $expiry_time = $this->reference_time + $delta_seconds; 358 | } 359 | 360 | return $expiry_time; 361 | 362 | case 'domain': 363 | // Domains are not required as per RFC 6265 section 5.2.3 364 | if (!is_string($value)) { 365 | return null; 366 | } 367 | 368 | if ($value === '') { 369 | return null; 370 | } 371 | 372 | // Domain normalization, as per RFC 6265 section 5.2.3 373 | if ($value[0] === '.') { 374 | $value = substr($value, 1); 375 | } 376 | 377 | return strtolower(IdnaEncoder::encode($value)); 378 | 379 | default: 380 | return $value; 381 | } 382 | } 383 | 384 | /** 385 | * Format a cookie for a Cookie header 386 | * 387 | * This is used when sending cookies to a server. 388 | * 389 | * @return string Cookie formatted for Cookie header 390 | */ 391 | public function format_for_header() { 392 | return sprintf('%s=%s', $this->name, $this->value); 393 | } 394 | 395 | /** 396 | * Format a cookie for a Set-Cookie header 397 | * 398 | * This is used when sending cookies to clients. This isn't really 399 | * applicable to client-side usage, but might be handy for debugging. 400 | * 401 | * @return string Cookie formatted for Set-Cookie header 402 | */ 403 | public function format_for_set_cookie() { 404 | $header_value = $this->format_for_header(); 405 | if (!empty($this->attributes)) { 406 | $parts = []; 407 | foreach ($this->attributes as $key => $value) { 408 | // Ignore non-associative attributes 409 | if (is_numeric($key)) { 410 | $parts[] = $value; 411 | } else { 412 | $parts[] = sprintf('%s=%s', $key, $value); 413 | } 414 | } 415 | 416 | $header_value .= '; ' . implode('; ', $parts); 417 | } 418 | 419 | return $header_value; 420 | } 421 | 422 | /** 423 | * Parse a cookie string into a cookie object 424 | * 425 | * Based on Mozilla's parsing code in Firefox and related projects, which 426 | * is an intentional deviation from RFC 2109 and RFC 2616. RFC 6265 427 | * specifies some of this handling, but not in a thorough manner. 428 | * 429 | * @param int|string $cookie_header Cookie header value (from a Set-Cookie header) 430 | * @param string $name 431 | * @param int|null $reference_time 432 | * @return \WpOrg\Requests\Cookie Parsed cookie object 433 | * 434 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $cookie_header argument is not a string. 435 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $name argument is not a string. 436 | */ 437 | public static function parse($cookie_header, $name = '', $reference_time = null) { 438 | if (is_string($cookie_header) === false) { 439 | throw InvalidArgument::create(1, '$cookie_header', 'string', gettype($cookie_header)); 440 | } 441 | 442 | if (is_string($name)) { 443 | $name = trim($name); 444 | } 445 | 446 | if ($name !== '' && InputValidator::is_valid_rfc2616_token($name) === false) { 447 | throw InvalidArgument::create(2, '$name', 'integer|string and conform to RFC 2616', gettype($name)); 448 | } 449 | 450 | $parts = explode(';', $cookie_header); 451 | $kvparts = array_shift($parts); 452 | 453 | if (!empty($name)) { 454 | $value = $cookie_header; 455 | } elseif (strpos($kvparts, '=') === false) { 456 | // Some sites might only have a value without the equals separator. 457 | // Deviate from RFC 6265 and pretend it was actually a blank name 458 | // (`=foo`) 459 | // 460 | // https://bugzilla.mozilla.org/show_bug.cgi?id=169091 461 | $name = ''; 462 | $value = $kvparts; 463 | } else { 464 | list($name, $value) = explode('=', $kvparts, 2); 465 | } 466 | 467 | $name = trim($name); 468 | $value = trim($value); 469 | 470 | if ($name !== '' && InputValidator::is_valid_rfc2616_token($name) === false) { 471 | throw InvalidArgument::create(2, '$name', 'integer|string and conform to RFC 2616', gettype($name)); 472 | } 473 | 474 | // Attribute keys are handled case-insensitively 475 | $attributes = new CaseInsensitiveDictionary(); 476 | 477 | if (!empty($parts)) { 478 | foreach ($parts as $part) { 479 | if (strpos($part, '=') === false) { 480 | $part_key = $part; 481 | $part_value = true; 482 | } else { 483 | list($part_key, $part_value) = explode('=', $part, 2); 484 | $part_value = trim($part_value); 485 | } 486 | 487 | $part_key = trim($part_key); 488 | $attributes[$part_key] = $part_value; 489 | } 490 | } 491 | 492 | return new static($name, $value, $attributes, [], $reference_time); 493 | } 494 | 495 | /** 496 | * Parse all Set-Cookie headers from request headers 497 | * 498 | * @param \WpOrg\Requests\Response\Headers $headers Headers to parse from 499 | * @param \WpOrg\Requests\Iri|null $origin URI for comparing cookie origins 500 | * @param int|null $time Reference time for expiration calculation 501 | * @return array 502 | * 503 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $origin argument is not null or an instance of the Iri class. 504 | */ 505 | public static function parse_from_headers(Headers $headers, $origin = null, $time = null) { 506 | $cookie_headers = $headers->getValues('Set-Cookie'); 507 | if (empty($cookie_headers)) { 508 | return []; 509 | } 510 | 511 | if ($origin !== null && !($origin instanceof Iri)) { 512 | throw InvalidArgument::create(2, '$origin', Iri::class . ' or null', gettype($origin)); 513 | } 514 | 515 | $cookies = []; 516 | foreach ($cookie_headers as $header) { 517 | $parsed = self::parse($header, '', $time); 518 | 519 | // Default domain/path attributes 520 | if (empty($parsed->attributes['domain']) && !empty($origin)) { 521 | $parsed->attributes['domain'] = $origin->host; 522 | $parsed->flags['host-only'] = true; 523 | } else { 524 | $parsed->flags['host-only'] = false; 525 | } 526 | 527 | $path_is_valid = (!empty($parsed->attributes['path']) && $parsed->attributes['path'][0] === '/'); 528 | if (!$path_is_valid && !empty($origin)) { 529 | $path = $origin->path; 530 | 531 | // Default path normalization as per RFC 6265 section 5.1.4 532 | if (substr($path, 0, 1) !== '/') { 533 | // If the uri-path is empty or if the first character of 534 | // the uri-path is not a %x2F ("/") character, output 535 | // %x2F ("/") and skip the remaining steps. 536 | $path = '/'; 537 | } elseif (substr_count($path, '/') === 1) { 538 | // If the uri-path contains no more than one %x2F ("/") 539 | // character, output %x2F ("/") and skip the remaining 540 | // step. 541 | $path = '/'; 542 | } else { 543 | // Output the characters of the uri-path from the first 544 | // character up to, but not including, the right-most 545 | // %x2F ("/"). 546 | $path = substr($path, 0, strrpos($path, '/')); 547 | } 548 | 549 | $parsed->attributes['path'] = $path; 550 | } 551 | 552 | // Reject invalid cookie domains 553 | if (!empty($origin) && !$parsed->domain_matches($origin->host)) { 554 | continue; 555 | } 556 | 557 | $cookies[$parsed->name] = $parsed; 558 | } 559 | 560 | return $cookies; 561 | } 562 | } 563 | -------------------------------------------------------------------------------- /src/Cookie/Jar.php: -------------------------------------------------------------------------------- 1 | cookies = $cookies; 49 | } 50 | 51 | /** 52 | * Normalise cookie data into a \WpOrg\Requests\Cookie 53 | * 54 | * @param string|\WpOrg\Requests\Cookie $cookie Cookie header value, possibly pre-parsed (object). 55 | * @param string $key Optional. The name for this cookie. 56 | * @return \WpOrg\Requests\Cookie 57 | */ 58 | public function normalize_cookie($cookie, $key = '') { 59 | if ($cookie instanceof Cookie) { 60 | return $cookie; 61 | } 62 | 63 | return Cookie::parse($cookie, $key); 64 | } 65 | 66 | /** 67 | * Check if the given item exists 68 | * 69 | * @param string $offset Item key 70 | * @return bool Does the item exist? 71 | */ 72 | #[ReturnTypeWillChange] 73 | public function offsetExists($offset) { 74 | return isset($this->cookies[$offset]); 75 | } 76 | 77 | /** 78 | * Get the value for the item 79 | * 80 | * @param string $offset Item key 81 | * @return string|null Item value (null if offsetExists is false) 82 | */ 83 | #[ReturnTypeWillChange] 84 | public function offsetGet($offset) { 85 | if (!isset($this->cookies[$offset])) { 86 | return null; 87 | } 88 | 89 | return $this->cookies[$offset]; 90 | } 91 | 92 | /** 93 | * Set the given item 94 | * 95 | * @param string $offset Item name 96 | * @param string $value Item value 97 | * 98 | * @throws \WpOrg\Requests\Exception On attempting to use dictionary as list (`invalidset`). 99 | */ 100 | #[ReturnTypeWillChange] 101 | public function offsetSet($offset, $value) { 102 | if ($offset === null) { 103 | throw new Exception('Object is a dictionary, not a list', 'invalidset'); 104 | } 105 | 106 | $this->cookies[$offset] = $value; 107 | } 108 | 109 | /** 110 | * Unset the given header 111 | * 112 | * @param string $offset The key for the item to unset. 113 | */ 114 | #[ReturnTypeWillChange] 115 | public function offsetUnset($offset) { 116 | unset($this->cookies[$offset]); 117 | } 118 | 119 | /** 120 | * Get an iterator for the data 121 | * 122 | * @return \ArrayIterator 123 | */ 124 | #[ReturnTypeWillChange] 125 | public function getIterator() { 126 | return new ArrayIterator($this->cookies); 127 | } 128 | 129 | /** 130 | * Register the cookie handler with the request's hooking system 131 | * 132 | * @param \WpOrg\Requests\HookManager $hooks Hooking system 133 | */ 134 | public function register(HookManager $hooks) { 135 | $hooks->register('requests.before_request', [$this, 'before_request']); 136 | $hooks->register('requests.before_redirect_check', [$this, 'before_redirect_check']); 137 | } 138 | 139 | /** 140 | * Add Cookie header to a request if we have any 141 | * 142 | * As per RFC 6265, cookies are separated by '; ' 143 | * 144 | * @param string $url 145 | * @param array $headers 146 | * @param array $data 147 | * @param string $type 148 | * @param array $options 149 | */ 150 | public function before_request($url, &$headers, &$data, &$type, &$options) { 151 | if (!$url instanceof Iri) { 152 | $url = new Iri($url); 153 | } 154 | 155 | if (!empty($this->cookies)) { 156 | $cookies = []; 157 | foreach ($this->cookies as $key => $cookie) { 158 | $cookie = $this->normalize_cookie($cookie, $key); 159 | 160 | // Skip expired cookies 161 | if ($cookie->is_expired()) { 162 | continue; 163 | } 164 | 165 | if ($cookie->domain_matches($url->host)) { 166 | $cookies[] = $cookie->format_for_header(); 167 | } 168 | } 169 | 170 | $headers['Cookie'] = implode('; ', $cookies); 171 | } 172 | } 173 | 174 | /** 175 | * Parse all cookies from a response and attach them to the response 176 | * 177 | * @param \WpOrg\Requests\Response $response Response as received. 178 | */ 179 | public function before_redirect_check(Response $response) { 180 | $url = $response->url; 181 | if (!$url instanceof Iri) { 182 | $url = new Iri($url); 183 | } 184 | 185 | $cookies = Cookie::parse_from_headers($response->headers, $url); 186 | $this->cookies = array_merge($this->cookies, $cookies); 187 | $response->cookies = $this; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/Exception.php: -------------------------------------------------------------------------------- 1 | type = $type; 46 | $this->data = $data; 47 | } 48 | 49 | /** 50 | * Like {@see \Exception::getCode()}, but a string code. 51 | * 52 | * @return string 53 | */ 54 | public function getType() { 55 | return $this->type; 56 | } 57 | 58 | /** 59 | * Gives any relevant data 60 | * 61 | * @return mixed 62 | */ 63 | public function getData() { 64 | return $this->data; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Exception/ArgumentCount.php: -------------------------------------------------------------------------------- 1 | reason = $reason; 47 | } 48 | 49 | $message = sprintf('%d %s', $this->code, $this->reason); 50 | parent::__construct($message, 'httpresponse', $data, $this->code); 51 | } 52 | 53 | /** 54 | * Get the status message. 55 | * 56 | * @return string 57 | */ 58 | public function getReason() { 59 | return $this->reason; 60 | } 61 | 62 | /** 63 | * Get the correct exception class for a given error code 64 | * 65 | * @param int|bool $code HTTP status code, or false if unavailable 66 | * @return string Exception class name to use 67 | */ 68 | public static function get_class($code) { 69 | if (!$code) { 70 | return StatusUnknown::class; 71 | } 72 | 73 | $class = sprintf('\WpOrg\Requests\Exception\Http\Status%d', $code); 74 | if (class_exists($class)) { 75 | return $class; 76 | } 77 | 78 | return StatusUnknown::class; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Exception/Http/Status304.php: -------------------------------------------------------------------------------- 1 | code = (int) $data->status_code; 47 | } 48 | 49 | parent::__construct($reason, $data); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Exception/InvalidArgument.php: -------------------------------------------------------------------------------- 1 | type = $type; 59 | } 60 | 61 | if ($code !== null) { 62 | $this->code = (int) $code; 63 | } 64 | 65 | if ($message !== null) { 66 | $this->reason = $message; 67 | } 68 | 69 | $message = sprintf('%d %s', $this->code, $this->reason); 70 | parent::__construct($message, $this->type, $data, $this->code); 71 | } 72 | 73 | /** 74 | * Get the error message. 75 | * 76 | * @return string 77 | */ 78 | public function getReason() { 79 | return $this->reason; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/HookManager.php: -------------------------------------------------------------------------------- 1 | 0 is executed later 24 | */ 25 | public function register($hook, $callback, $priority = 0); 26 | 27 | /** 28 | * Dispatch a message 29 | * 30 | * @param string $hook Hook name 31 | * @param array $parameters Parameters to pass to callbacks 32 | * @return bool Successfulness 33 | */ 34 | public function dispatch($hook, $parameters = []); 35 | } 36 | -------------------------------------------------------------------------------- /src/Hooks.php: -------------------------------------------------------------------------------- 1 | 0 is executed later 35 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $hook argument is not a string. 36 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $callback argument is not callable. 37 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $priority argument is not an integer. 38 | */ 39 | public function register($hook, $callback, $priority = 0) { 40 | if (is_string($hook) === false) { 41 | throw InvalidArgument::create(1, '$hook', 'string', gettype($hook)); 42 | } 43 | 44 | if (is_callable($callback) === false) { 45 | throw InvalidArgument::create(2, '$callback', 'callable', gettype($callback)); 46 | } 47 | 48 | if (InputValidator::is_numeric_array_key($priority) === false) { 49 | throw InvalidArgument::create(3, '$priority', 'integer', gettype($priority)); 50 | } 51 | 52 | if (!isset($this->hooks[$hook])) { 53 | $this->hooks[$hook] = [ 54 | $priority => [], 55 | ]; 56 | } elseif (!isset($this->hooks[$hook][$priority])) { 57 | $this->hooks[$hook][$priority] = []; 58 | } 59 | 60 | $this->hooks[$hook][$priority][] = $callback; 61 | } 62 | 63 | /** 64 | * Dispatch a message 65 | * 66 | * @param string $hook Hook name 67 | * @param array $parameters Parameters to pass to callbacks 68 | * @return bool Successfulness 69 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $hook argument is not a string. 70 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $parameters argument is not an array. 71 | */ 72 | public function dispatch($hook, $parameters = []) { 73 | if (is_string($hook) === false) { 74 | throw InvalidArgument::create(1, '$hook', 'string', gettype($hook)); 75 | } 76 | 77 | // Check strictly against array, as Array* objects don't work in combination with `call_user_func_array()`. 78 | if (is_array($parameters) === false) { 79 | throw InvalidArgument::create(2, '$parameters', 'array', gettype($parameters)); 80 | } 81 | 82 | if (empty($this->hooks[$hook])) { 83 | return false; 84 | } 85 | 86 | if (!empty($parameters)) { 87 | // Strip potential keys from the array to prevent them being interpreted as parameter names in PHP 8.0. 88 | $parameters = array_values($parameters); 89 | } 90 | 91 | ksort($this->hooks[$hook]); 92 | 93 | foreach ($this->hooks[$hook] as $priority => $hooked) { 94 | foreach ($hooked as $callback) { 95 | $callback(...$parameters); 96 | } 97 | } 98 | 99 | return true; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/IdnaEncoder.php: -------------------------------------------------------------------------------- 1 | 0) { 206 | if ($position + $length > $strlen) { 207 | throw new Exception('Invalid Unicode codepoint', 'idna.invalidcodepoint', $character); 208 | } 209 | 210 | for ($position++; $remaining > 0; $position++) { 211 | $value = ord($input[$position]); 212 | 213 | // If it is invalid, count the sequence as invalid and reprocess the current byte: 214 | if (($value & 0xC0) !== 0x80) { 215 | throw new Exception('Invalid Unicode codepoint', 'idna.invalidcodepoint', $character); 216 | } 217 | 218 | --$remaining; 219 | $character |= ($value & 0x3F) << ($remaining * 6); 220 | } 221 | 222 | --$position; 223 | } 224 | 225 | if (// Non-shortest form sequences are invalid 226 | ($length > 1 && $character <= 0x7F) 227 | || ($length > 2 && $character <= 0x7FF) 228 | || ($length > 3 && $character <= 0xFFFF) 229 | // Outside of range of ucschar codepoints 230 | // Noncharacters 231 | || ($character & 0xFFFE) === 0xFFFE 232 | || ($character >= 0xFDD0 && $character <= 0xFDEF) 233 | || ( 234 | // Everything else not in ucschar 235 | ($character > 0xD7FF && $character < 0xF900) 236 | || $character < 0x20 237 | || ($character > 0x7E && $character < 0xA0) 238 | || $character > 0xEFFFD 239 | ) 240 | ) { 241 | throw new Exception('Invalid Unicode codepoint', 'idna.invalidcodepoint', $character); 242 | } 243 | 244 | $codepoints[] = $character; 245 | } 246 | 247 | return $codepoints; 248 | } 249 | 250 | /** 251 | * RFC3492-compliant encoder 252 | * 253 | * @internal Pseudo-code from Section 6.3 is commented with "#" next to relevant code 254 | * 255 | * @param string $input UTF-8 encoded string to encode 256 | * @return string Punycode-encoded string 257 | * 258 | * @throws \WpOrg\Requests\Exception On character outside of the domain (never happens with Punycode) (`idna.character_outside_domain`) 259 | */ 260 | public static function punycode_encode($input) { 261 | $output = ''; 262 | // let n = initial_n 263 | $n = self::BOOTSTRAP_INITIAL_N; 264 | // let delta = 0 265 | $delta = 0; 266 | // let bias = initial_bias 267 | $bias = self::BOOTSTRAP_INITIAL_BIAS; 268 | // let h = b = the number of basic code points in the input 269 | $h = 0; 270 | $b = 0; // see loop 271 | // copy them to the output in order 272 | $codepoints = self::utf8_to_codepoints($input); 273 | $extended = []; 274 | 275 | foreach ($codepoints as $char) { 276 | if ($char < 128) { 277 | // Character is valid ASCII 278 | // TODO: this should also check if it's valid for a URL 279 | $output .= chr($char); 280 | ++$h; 281 | 282 | // Check if the character is non-ASCII, but below initial n 283 | // This never occurs for Punycode, so ignore in coverage 284 | // @codeCoverageIgnoreStart 285 | } elseif ($char < $n) { 286 | throw new Exception('Invalid character', 'idna.character_outside_domain', $char); 287 | // @codeCoverageIgnoreEnd 288 | } else { 289 | $extended[$char] = true; 290 | } 291 | } 292 | 293 | $extended = array_keys($extended); 294 | sort($extended); 295 | $b = $h; 296 | // [copy them] followed by a delimiter if b > 0 297 | if (strlen($output) > 0) { 298 | $output .= '-'; 299 | } 300 | 301 | // {if the input contains a non-basic code point < n then fail} 302 | // while h < length(input) do begin 303 | $codepointcount = count($codepoints); 304 | while ($h < $codepointcount) { 305 | // let m = the minimum code point >= n in the input 306 | $m = array_shift($extended); 307 | //printf('next code point to insert is %s' . PHP_EOL, dechex($m)); 308 | // let delta = delta + (m - n) * (h + 1), fail on overflow 309 | $delta += ($m - $n) * ($h + 1); 310 | // let n = m 311 | $n = $m; 312 | // for each code point c in the input (in order) do begin 313 | for ($num = 0; $num < $codepointcount; $num++) { 314 | $c = $codepoints[$num]; 315 | // if c < n then increment delta, fail on overflow 316 | if ($c < $n) { 317 | ++$delta; 318 | } elseif ($c === $n) { // if c == n then begin 319 | // let q = delta 320 | $q = $delta; 321 | // for k = base to infinity in steps of base do begin 322 | for ($k = self::BOOTSTRAP_BASE; ; $k += self::BOOTSTRAP_BASE) { 323 | // let t = tmin if k <= bias {+ tmin}, or 324 | // tmax if k >= bias + tmax, or k - bias otherwise 325 | if ($k <= ($bias + self::BOOTSTRAP_TMIN)) { 326 | $t = self::BOOTSTRAP_TMIN; 327 | } elseif ($k >= ($bias + self::BOOTSTRAP_TMAX)) { 328 | $t = self::BOOTSTRAP_TMAX; 329 | } else { 330 | $t = $k - $bias; 331 | } 332 | 333 | // if q < t then break 334 | if ($q < $t) { 335 | break; 336 | } 337 | 338 | // output the code point for digit t + ((q - t) mod (base - t)) 339 | $digit = (int) ($t + (($q - $t) % (self::BOOTSTRAP_BASE - $t))); 340 | $output .= self::digit_to_char($digit); 341 | // let q = (q - t) div (base - t) 342 | $q = (int) floor(($q - $t) / (self::BOOTSTRAP_BASE - $t)); 343 | } 344 | 345 | // output the code point for digit q 346 | $output .= self::digit_to_char($q); 347 | // let bias = adapt(delta, h + 1, test h equals b?) 348 | $bias = self::adapt($delta, $h + 1, $h === $b); 349 | // let delta = 0 350 | $delta = 0; 351 | // increment h 352 | ++$h; 353 | } 354 | } 355 | 356 | // increment delta and n 357 | ++$delta; 358 | ++$n; 359 | } 360 | 361 | return $output; 362 | } 363 | 364 | /** 365 | * Convert a digit to its respective character 366 | * 367 | * @link https://tools.ietf.org/html/rfc3492#section-5 368 | * 369 | * @param int $digit Digit in the range 0-35 370 | * @return string Single character corresponding to digit 371 | * 372 | * @throws \WpOrg\Requests\Exception On invalid digit (`idna.invalid_digit`) 373 | */ 374 | protected static function digit_to_char($digit) { 375 | // @codeCoverageIgnoreStart 376 | // As far as I know, this never happens, but still good to be sure. 377 | if ($digit < 0 || $digit > 35) { 378 | throw new Exception(sprintf('Invalid digit %d', $digit), 'idna.invalid_digit', $digit); 379 | } 380 | 381 | // @codeCoverageIgnoreEnd 382 | $digits = 'abcdefghijklmnopqrstuvwxyz0123456789'; 383 | return substr($digits, $digit, 1); 384 | } 385 | 386 | /** 387 | * Adapt the bias 388 | * 389 | * @link https://tools.ietf.org/html/rfc3492#section-6.1 390 | * @param int $delta 391 | * @param int $numpoints 392 | * @param bool $firsttime 393 | * @return int|float New bias 394 | */ 395 | protected static function adapt($delta, $numpoints, $firsttime) { 396 | // if firsttime then let delta = delta div damp 397 | if ($firsttime) { 398 | $delta = floor($delta / self::BOOTSTRAP_DAMP); 399 | } else { 400 | // else let delta = delta div 2 401 | $delta = floor($delta / 2); 402 | } 403 | 404 | // let delta = delta + (delta div numpoints) 405 | $delta += floor($delta / $numpoints); 406 | // let k = 0 407 | $k = 0; 408 | // while delta > ((base - tmin) * tmax) div 2 do begin 409 | $max = floor(((self::BOOTSTRAP_BASE - self::BOOTSTRAP_TMIN) * self::BOOTSTRAP_TMAX) / 2); 410 | while ($delta > $max) { 411 | // let delta = delta div (base - tmin) 412 | $delta = floor($delta / (self::BOOTSTRAP_BASE - self::BOOTSTRAP_TMIN)); 413 | // let k = k + base 414 | $k += self::BOOTSTRAP_BASE; 415 | } 416 | 417 | // return k + (((base - tmin + 1) * delta) div (delta + skew)) 418 | return $k + floor(((self::BOOTSTRAP_BASE - self::BOOTSTRAP_TMIN + 1) * $delta) / ($delta + self::BOOTSTRAP_SKEW)); 419 | } 420 | } 421 | -------------------------------------------------------------------------------- /src/Ipv6.php: -------------------------------------------------------------------------------- 1 | FF01:0:0:0:0:0:0:101 32 | * ::1 -> 0:0:0:0:0:0:0:1 33 | * 34 | * @author Alexander Merz 35 | * @author elfrink at introweb dot nl 36 | * @author Josh Peck 37 | * @copyright 2003-2005 The PHP Group 38 | * @license https://opensource.org/licenses/bsd-license.php 39 | * 40 | * @param string|\Stringable $ip An IPv6 address 41 | * @return string The uncompressed IPv6 address 42 | * 43 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string or a stringable object. 44 | */ 45 | public static function uncompress($ip) { 46 | if (InputValidator::is_string_or_stringable($ip) === false) { 47 | throw InvalidArgument::create(1, '$ip', 'string|Stringable', gettype($ip)); 48 | } 49 | 50 | $ip = (string) $ip; 51 | 52 | if (substr_count($ip, '::') !== 1) { 53 | return $ip; 54 | } 55 | 56 | list($ip1, $ip2) = explode('::', $ip); 57 | $c1 = ($ip1 === '') ? -1 : substr_count($ip1, ':'); 58 | $c2 = ($ip2 === '') ? -1 : substr_count($ip2, ':'); 59 | 60 | if (strpos($ip2, '.') !== false) { 61 | ++$c2; 62 | } 63 | 64 | if ($c1 === -1 && $c2 === -1) { 65 | // :: 66 | $ip = '0:0:0:0:0:0:0:0'; 67 | } elseif ($c1 === -1) { 68 | // ::xxx 69 | $fill = str_repeat('0:', 7 - $c2); 70 | $ip = str_replace('::', $fill, $ip); 71 | } elseif ($c2 === -1) { 72 | // xxx:: 73 | $fill = str_repeat(':0', 7 - $c1); 74 | $ip = str_replace('::', $fill, $ip); 75 | } else { 76 | // xxx::xxx 77 | $fill = ':' . str_repeat('0:', 6 - $c2 - $c1); 78 | $ip = str_replace('::', $fill, $ip); 79 | } 80 | 81 | return $ip; 82 | } 83 | 84 | /** 85 | * Compresses an IPv6 address 86 | * 87 | * RFC 4291 allows you to compress consecutive zero pieces in an address to 88 | * '::'. This method expects a valid IPv6 address and compresses consecutive 89 | * zero pieces to '::'. 90 | * 91 | * Example: FF01:0:0:0:0:0:0:101 -> FF01::101 92 | * 0:0:0:0:0:0:0:1 -> ::1 93 | * 94 | * @see \WpOrg\Requests\Ipv6::uncompress() 95 | * 96 | * @param string $ip An IPv6 address 97 | * @return string The compressed IPv6 address 98 | */ 99 | public static function compress($ip) { 100 | // Prepare the IP to be compressed. 101 | // Note: Input validation is handled in the `uncompress()` method, which is the first call made in this method. 102 | $ip = self::uncompress($ip); 103 | $ip_parts = self::split_v6_v4($ip); 104 | 105 | // Replace all leading zeros 106 | $ip_parts[0] = preg_replace('/(^|:)0+([0-9])/', '\1\2', $ip_parts[0]); 107 | 108 | // Find bunches of zeros 109 | if (preg_match_all('/(?:^|:)(?:0(?::|$))+/', $ip_parts[0], $matches, PREG_OFFSET_CAPTURE)) { 110 | $max = 0; 111 | $pos = null; 112 | foreach ($matches[0] as $match) { 113 | if (strlen($match[0]) > $max) { 114 | $max = strlen($match[0]); 115 | $pos = $match[1]; 116 | } 117 | } 118 | 119 | $ip_parts[0] = substr_replace($ip_parts[0], '::', $pos, $max); 120 | } 121 | 122 | if ($ip_parts[1] !== '') { 123 | return implode(':', $ip_parts); 124 | } else { 125 | return $ip_parts[0]; 126 | } 127 | } 128 | 129 | /** 130 | * Splits an IPv6 address into the IPv6 and IPv4 representation parts 131 | * 132 | * RFC 4291 allows you to represent the last two parts of an IPv6 address 133 | * using the standard IPv4 representation 134 | * 135 | * Example: 0:0:0:0:0:0:13.1.68.3 136 | * 0:0:0:0:0:FFFF:129.144.52.38 137 | * 138 | * @param string $ip An IPv6 address 139 | * @return array [0] contains the IPv6 represented part, and [1] the IPv4 represented part 140 | */ 141 | private static function split_v6_v4($ip) { 142 | if (strpos($ip, '.') !== false) { 143 | $pos = strrpos($ip, ':'); 144 | $ipv6_part = substr($ip, 0, $pos); 145 | $ipv4_part = substr($ip, $pos + 1); 146 | return [$ipv6_part, $ipv4_part]; 147 | } else { 148 | return [$ip, '']; 149 | } 150 | } 151 | 152 | /** 153 | * Checks an IPv6 address 154 | * 155 | * Checks if the given IP is a valid IPv6 address 156 | * 157 | * @param string $ip An IPv6 address 158 | * @return bool true if $ip is a valid IPv6 address 159 | */ 160 | public static function check_ipv6($ip) { 161 | // Note: Input validation is handled in the `uncompress()` method, which is the first call made in this method. 162 | $ip = self::uncompress($ip); 163 | list($ipv6, $ipv4) = self::split_v6_v4($ip); 164 | $ipv6 = explode(':', $ipv6); 165 | $ipv4 = explode('.', $ipv4); 166 | if ((count($ipv6) === 8 && count($ipv4) === 1) || (count($ipv6) === 6 && count($ipv4) === 4)) { 167 | foreach ($ipv6 as $ipv6_part) { 168 | // The section can't be empty 169 | if ($ipv6_part === '') { 170 | return false; 171 | } 172 | 173 | // Nor can it be over four characters 174 | if (strlen($ipv6_part) > 4) { 175 | return false; 176 | } 177 | 178 | // Remove leading zeros (this is safe because of the above) 179 | $ipv6_part = ltrim($ipv6_part, '0'); 180 | if ($ipv6_part === '') { 181 | $ipv6_part = '0'; 182 | } 183 | 184 | // Check the value is valid 185 | $value = hexdec($ipv6_part); 186 | if (dechex($value) !== strtolower($ipv6_part) || $value < 0 || $value > 0xFFFF) { 187 | return false; 188 | } 189 | } 190 | 191 | if (count($ipv4) === 4) { 192 | foreach ($ipv4 as $ipv4_part) { 193 | $value = (int) $ipv4_part; 194 | if ((string) $value !== $ipv4_part || $value < 0 || $value > 0xFF) { 195 | return false; 196 | } 197 | } 198 | } 199 | 200 | return true; 201 | } else { 202 | return false; 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/Port.php: -------------------------------------------------------------------------------- 1 | proxy = $args; 71 | } elseif (is_array($args)) { 72 | if (count($args) === 1) { 73 | list($this->proxy) = $args; 74 | } elseif (count($args) === 3) { 75 | list($this->proxy, $this->user, $this->pass) = $args; 76 | $this->use_authentication = true; 77 | } else { 78 | throw ArgumentCount::create( 79 | 'an array with exactly one element or exactly three elements', 80 | count($args), 81 | 'proxyhttpbadargs' 82 | ); 83 | } 84 | } elseif ($args !== null) { 85 | throw InvalidArgument::create(1, '$args', 'array|string|null', gettype($args)); 86 | } 87 | } 88 | 89 | /** 90 | * Register the necessary callbacks 91 | * 92 | * @since 1.6 93 | * @see \WpOrg\Requests\Proxy\Http::curl_before_send() 94 | * @see \WpOrg\Requests\Proxy\Http::fsockopen_remote_socket() 95 | * @see \WpOrg\Requests\Proxy\Http::fsockopen_remote_host_path() 96 | * @see \WpOrg\Requests\Proxy\Http::fsockopen_header() 97 | * @param \WpOrg\Requests\Hooks $hooks Hook system 98 | */ 99 | public function register(Hooks $hooks) { 100 | $hooks->register('curl.before_send', [$this, 'curl_before_send']); 101 | 102 | $hooks->register('fsockopen.remote_socket', [$this, 'fsockopen_remote_socket']); 103 | $hooks->register('fsockopen.remote_host_path', [$this, 'fsockopen_remote_host_path']); 104 | if ($this->use_authentication) { 105 | $hooks->register('fsockopen.after_headers', [$this, 'fsockopen_header']); 106 | } 107 | } 108 | 109 | /** 110 | * Set cURL parameters before the data is sent 111 | * 112 | * @since 1.6 113 | * @param resource|\CurlHandle $handle cURL handle 114 | */ 115 | public function curl_before_send(&$handle) { 116 | curl_setopt($handle, CURLOPT_PROXYTYPE, CURLPROXY_HTTP); 117 | curl_setopt($handle, CURLOPT_PROXY, $this->proxy); 118 | 119 | if ($this->use_authentication) { 120 | curl_setopt($handle, CURLOPT_PROXYAUTH, CURLAUTH_ANY); 121 | curl_setopt($handle, CURLOPT_PROXYUSERPWD, $this->get_auth_string()); 122 | } 123 | } 124 | 125 | /** 126 | * Alter remote socket information before opening socket connection 127 | * 128 | * @since 1.6 129 | * @param string $remote_socket Socket connection string 130 | */ 131 | public function fsockopen_remote_socket(&$remote_socket) { 132 | $remote_socket = $this->proxy; 133 | } 134 | 135 | /** 136 | * Alter remote path before getting stream data 137 | * 138 | * @since 1.6 139 | * @param string $path Path to send in HTTP request string ("GET ...") 140 | * @param string $url Full URL we're requesting 141 | */ 142 | public function fsockopen_remote_host_path(&$path, $url) { 143 | $path = $url; 144 | } 145 | 146 | /** 147 | * Add extra headers to the request before sending 148 | * 149 | * @since 1.6 150 | * @param string $out HTTP header string 151 | */ 152 | public function fsockopen_header(&$out) { 153 | $out .= sprintf("Proxy-Authorization: Basic %s\r\n", base64_encode($this->get_auth_string())); 154 | } 155 | 156 | /** 157 | * Get the authentication string (user:pass) 158 | * 159 | * @since 1.6 160 | * @return string 161 | */ 162 | public function get_auth_string() { 163 | return $this->user . ':' . $this->pass; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | headers = new Headers(); 101 | $this->cookies = new Jar(); 102 | } 103 | 104 | /** 105 | * Is the response a redirect? 106 | * 107 | * @return bool True if redirect (3xx status), false if not. 108 | */ 109 | public function is_redirect() { 110 | $code = $this->status_code; 111 | return is_int($code) 112 | && (in_array($code, [300, 301, 302, 303, 307], true) 113 | || ($code > 307 && $code < 400)); 114 | } 115 | 116 | /** 117 | * Throws an exception if the request was not successful 118 | * 119 | * @param bool $allow_redirects Set to false to throw on a 3xx as well 120 | * 121 | * @throws \WpOrg\Requests\Exception If `$allow_redirects` is false, and code is 3xx (`response.no_redirects`) 122 | * @throws \WpOrg\Requests\Exception\Http On non-successful status code. Exception class corresponds to "Status" + code (e.g. {@see \WpOrg\Requests\Exception\Http\Status404}) 123 | */ 124 | public function throw_for_status($allow_redirects = true) { 125 | if ($this->is_redirect()) { 126 | if ($allow_redirects !== true) { 127 | throw new Exception('Redirection not allowed', 'response.no_redirects', $this); 128 | } 129 | } elseif (!$this->success) { 130 | $exception = Http::get_class($this->status_code); 131 | throw new $exception(null, $this); 132 | } 133 | } 134 | 135 | /** 136 | * JSON decode the response body. 137 | * 138 | * The method parameters are the same as those for the PHP native `json_decode()` function. 139 | * 140 | * @link https://php.net/json-decode 141 | * 142 | * @param bool|null $associative Optional. When `true`, JSON objects will be returned as associative arrays; 143 | * When `false`, JSON objects will be returned as objects. 144 | * When `null`, JSON objects will be returned as associative arrays 145 | * or objects depending on whether `JSON_OBJECT_AS_ARRAY` is set in the flags. 146 | * Defaults to `true` (in contrast to the PHP native default of `null`). 147 | * @param int $depth Optional. Maximum nesting depth of the structure being decoded. 148 | * Defaults to `512`. 149 | * @param int $options Optional. Bitmask of JSON_BIGINT_AS_STRING, JSON_INVALID_UTF8_IGNORE, 150 | * JSON_INVALID_UTF8_SUBSTITUTE, JSON_OBJECT_AS_ARRAY, JSON_THROW_ON_ERROR. 151 | * Defaults to `0` (no options set). 152 | * 153 | * @return array 154 | * 155 | * @throws \WpOrg\Requests\Exception If `$this->body` is not valid json. 156 | */ 157 | public function decode_body($associative = true, $depth = 512, $options = 0) { 158 | $data = json_decode($this->body, $associative, $depth, $options); 159 | 160 | if (json_last_error() !== JSON_ERROR_NONE) { 161 | $last_error = json_last_error_msg(); 162 | throw new Exception('Unable to parse JSON data: ' . $last_error, 'response.invalid', $this); 163 | } 164 | 165 | return $data; 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Response/Headers.php: -------------------------------------------------------------------------------- 1 | data[$offset])) { 41 | return null; 42 | } 43 | 44 | return $this->flatten($this->data[$offset]); 45 | } 46 | 47 | /** 48 | * Set the given item 49 | * 50 | * @param string $offset Item name 51 | * @param string $value Item value 52 | * 53 | * @throws \WpOrg\Requests\Exception On attempting to use dictionary as list (`invalidset`) 54 | */ 55 | public function offsetSet($offset, $value) { 56 | if ($offset === null) { 57 | throw new Exception('Object is a dictionary, not a list', 'invalidset'); 58 | } 59 | 60 | if (is_string($offset)) { 61 | $offset = strtolower($offset); 62 | } 63 | 64 | if (!isset($this->data[$offset])) { 65 | $this->data[$offset] = []; 66 | } 67 | 68 | $this->data[$offset][] = $value; 69 | } 70 | 71 | /** 72 | * Get all values for a given header 73 | * 74 | * @param string $offset Name of the header to retrieve. 75 | * @return array|null Header values 76 | * 77 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not valid as an array key. 78 | */ 79 | public function getValues($offset) { 80 | if (!is_string($offset) && !is_int($offset)) { 81 | throw InvalidArgument::create(1, '$offset', 'string|int', gettype($offset)); 82 | } 83 | 84 | if (is_string($offset)) { 85 | $offset = strtolower($offset); 86 | } 87 | 88 | if (!isset($this->data[$offset])) { 89 | return null; 90 | } 91 | 92 | return $this->data[$offset]; 93 | } 94 | 95 | /** 96 | * Flattens a value into a string 97 | * 98 | * Converts an array into a string by imploding values with a comma, as per 99 | * RFC2616's rules for folding headers. 100 | * 101 | * @param string|array $value Value to flatten 102 | * @return string Flattened value 103 | * 104 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed argument is not a string or an array. 105 | */ 106 | public function flatten($value) { 107 | if (is_string($value)) { 108 | return $value; 109 | } 110 | 111 | if (is_array($value)) { 112 | return implode(',', $value); 113 | } 114 | 115 | throw InvalidArgument::create(1, '$value', 'string|array', gettype($value)); 116 | } 117 | 118 | /** 119 | * Get an iterator for the data 120 | * 121 | * Converts the internally stored values to a comma-separated string if there is more 122 | * than one value for a key. 123 | * 124 | * @return \ArrayIterator 125 | */ 126 | public function getIterator() { 127 | return new FilteredIterator($this->data, [$this, 'flatten']); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Session.php: -------------------------------------------------------------------------------- 1 | useragent = 'X';` 63 | * 64 | * @var array 65 | */ 66 | public $options = []; 67 | 68 | /** 69 | * Create a new session 70 | * 71 | * @param string|\Stringable|null $url Base URL for requests 72 | * @param array $headers Default headers for requests 73 | * @param array $data Default data for requests 74 | * @param array $options Default options for requests 75 | * 76 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string, Stringable or null. 77 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $headers argument is not an array. 78 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $data argument is not an array. 79 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. 80 | */ 81 | public function __construct($url = null, $headers = [], $data = [], $options = []) { 82 | if ($url !== null && InputValidator::is_string_or_stringable($url) === false) { 83 | throw InvalidArgument::create(1, '$url', 'string|Stringable|null', gettype($url)); 84 | } 85 | 86 | if (is_array($headers) === false) { 87 | throw InvalidArgument::create(2, '$headers', 'array', gettype($headers)); 88 | } 89 | 90 | if (is_array($data) === false) { 91 | throw InvalidArgument::create(3, '$data', 'array', gettype($data)); 92 | } 93 | 94 | if (is_array($options) === false) { 95 | throw InvalidArgument::create(4, '$options', 'array', gettype($options)); 96 | } 97 | 98 | $this->url = $url; 99 | $this->headers = $headers; 100 | $this->data = $data; 101 | $this->options = $options; 102 | 103 | if (empty($this->options['cookies'])) { 104 | $this->options['cookies'] = new Jar(); 105 | } 106 | } 107 | 108 | /** 109 | * Get a property's value 110 | * 111 | * @param string $name Property name. 112 | * @return mixed|null Property value, null if none found 113 | */ 114 | public function __get($name) { 115 | if (isset($this->options[$name])) { 116 | return $this->options[$name]; 117 | } 118 | 119 | return null; 120 | } 121 | 122 | /** 123 | * Set a property's value 124 | * 125 | * @param string $name Property name. 126 | * @param mixed $value Property value 127 | */ 128 | public function __set($name, $value) { 129 | $this->options[$name] = $value; 130 | } 131 | 132 | /** 133 | * Remove a property's value 134 | * 135 | * @param string $name Property name. 136 | */ 137 | public function __isset($name) { 138 | return isset($this->options[$name]); 139 | } 140 | 141 | /** 142 | * Remove a property's value 143 | * 144 | * @param string $name Property name. 145 | */ 146 | public function __unset($name) { 147 | unset($this->options[$name]); 148 | } 149 | 150 | /**#@+ 151 | * @see \WpOrg\Requests\Session::request() 152 | * @param string $url 153 | * @param array $headers 154 | * @param array $options 155 | * @return \WpOrg\Requests\Response 156 | */ 157 | /** 158 | * Send a GET request 159 | */ 160 | public function get($url, $headers = [], $options = []) { 161 | return $this->request($url, $headers, null, Requests::GET, $options); 162 | } 163 | 164 | /** 165 | * Send a HEAD request 166 | */ 167 | public function head($url, $headers = [], $options = []) { 168 | return $this->request($url, $headers, null, Requests::HEAD, $options); 169 | } 170 | 171 | /** 172 | * Send a DELETE request 173 | */ 174 | public function delete($url, $headers = [], $options = []) { 175 | return $this->request($url, $headers, null, Requests::DELETE, $options); 176 | } 177 | /**#@-*/ 178 | 179 | /**#@+ 180 | * @see \WpOrg\Requests\Session::request() 181 | * @param string $url 182 | * @param array $headers 183 | * @param array $data 184 | * @param array $options 185 | * @return \WpOrg\Requests\Response 186 | */ 187 | /** 188 | * Send a POST request 189 | */ 190 | public function post($url, $headers = [], $data = [], $options = []) { 191 | return $this->request($url, $headers, $data, Requests::POST, $options); 192 | } 193 | 194 | /** 195 | * Send a PUT request 196 | */ 197 | public function put($url, $headers = [], $data = [], $options = []) { 198 | return $this->request($url, $headers, $data, Requests::PUT, $options); 199 | } 200 | 201 | /** 202 | * Send a PATCH request 203 | * 204 | * Note: Unlike {@see \WpOrg\Requests\Session::post()} and {@see \WpOrg\Requests\Session::put()}, 205 | * `$headers` is required, as the specification recommends that should send an ETag 206 | * 207 | * @link https://tools.ietf.org/html/rfc5789 208 | */ 209 | public function patch($url, $headers, $data = [], $options = []) { 210 | return $this->request($url, $headers, $data, Requests::PATCH, $options); 211 | } 212 | /**#@-*/ 213 | 214 | /** 215 | * Main interface for HTTP requests 216 | * 217 | * This method initiates a request and sends it via a transport before 218 | * parsing. 219 | * 220 | * @see \WpOrg\Requests\Requests::request() 221 | * 222 | * @param string $url URL to request 223 | * @param array $headers Extra headers to send with the request 224 | * @param array|null $data Data to send either as a query string for GET/HEAD requests, or in the body for POST requests 225 | * @param string $type HTTP request type (use \WpOrg\Requests\Requests constants) 226 | * @param array $options Options for the request (see {@see \WpOrg\Requests\Requests::request()}) 227 | * @return \WpOrg\Requests\Response 228 | * 229 | * @throws \WpOrg\Requests\Exception On invalid URLs (`nonhttp`) 230 | */ 231 | public function request($url, $headers = [], $data = [], $type = Requests::GET, $options = []) { 232 | $request = [ 233 | 'url' => $url, 234 | 'headers' => $headers, 235 | 'data' => $data, 236 | 'options' => $options, 237 | ]; 238 | $request = $this->merge_request($request); 239 | 240 | return Requests::request($request['url'], $request['headers'], $request['data'], $type, $request['options']); 241 | } 242 | 243 | /** 244 | * Send multiple HTTP requests simultaneously 245 | * 246 | * @see \WpOrg\Requests\Requests::request_multiple() 247 | * 248 | * @param array $requests Requests data (see {@see \WpOrg\Requests\Requests::request_multiple()}) 249 | * @param array $options Global and default options (see {@see \WpOrg\Requests\Requests::request()}) 250 | * @return array Responses (either \WpOrg\Requests\Response or a \WpOrg\Requests\Exception object) 251 | * 252 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access. 253 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. 254 | */ 255 | public function request_multiple($requests, $options = []) { 256 | if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) { 257 | throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests)); 258 | } 259 | 260 | if (is_array($options) === false) { 261 | throw InvalidArgument::create(2, '$options', 'array', gettype($options)); 262 | } 263 | 264 | foreach ($requests as $key => $request) { 265 | $requests[$key] = $this->merge_request($request, false); 266 | } 267 | 268 | $options = array_merge($this->options, $options); 269 | 270 | // Disallow forcing the type, as that's a per request setting 271 | unset($options['type']); 272 | 273 | return Requests::request_multiple($requests, $options); 274 | } 275 | 276 | /** 277 | * Merge a request's data with the default data 278 | * 279 | * @param array $request Request data (same form as {@see \WpOrg\Requests\Session::request_multiple()}) 280 | * @param bool $merge_options Should we merge options as well? 281 | * @return array Request data 282 | */ 283 | protected function merge_request($request, $merge_options = true) { 284 | if ($this->url !== null) { 285 | $request['url'] = Iri::absolutize($this->url, $request['url']); 286 | $request['url'] = $request['url']->uri; 287 | } 288 | 289 | if (empty($request['headers'])) { 290 | $request['headers'] = []; 291 | } 292 | 293 | $request['headers'] = array_merge($this->headers, $request['headers']); 294 | 295 | if (empty($request['data'])) { 296 | if (is_array($this->data)) { 297 | $request['data'] = $this->data; 298 | } 299 | } elseif (is_array($request['data']) && is_array($this->data)) { 300 | $request['data'] = array_merge($this->data, $request['data']); 301 | } 302 | 303 | if ($merge_options === true) { 304 | $request['options'] = array_merge($this->options, $request['options']); 305 | 306 | // Disallow forcing the type, as that's a per request setting 307 | unset($request['options']['type']); 308 | } 309 | 310 | return $request; 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /src/Ssl.php: -------------------------------------------------------------------------------- 1 | 0) { 110 | // Whitespace detected. This can never be a dNSName. 111 | return false; 112 | } 113 | 114 | $parts = explode('.', $reference); 115 | if ($parts !== array_filter($parts)) { 116 | // DNSName cannot contain two dots next to each other. 117 | return false; 118 | } 119 | 120 | // Check the first part of the name 121 | $first = array_shift($parts); 122 | 123 | if (strpos($first, '*') !== false) { 124 | // Check that the wildcard is the full part 125 | if ($first !== '*') { 126 | return false; 127 | } 128 | 129 | // Check that we have at least 3 components (including first) 130 | if (count($parts) < 2) { 131 | return false; 132 | } 133 | } 134 | 135 | // Check the remaining parts 136 | foreach ($parts as $part) { 137 | if (strpos($part, '*') !== false) { 138 | return false; 139 | } 140 | } 141 | 142 | // Nothing found, verified! 143 | return true; 144 | } 145 | 146 | /** 147 | * Match a hostname against a dNSName reference 148 | * 149 | * @param string|\Stringable $host Requested host 150 | * @param string|\Stringable $reference dNSName to match against 151 | * @return bool Does the domain match? 152 | * @throws \WpOrg\Requests\Exception\InvalidArgument When either of the passed arguments is not a string or a stringable object. 153 | */ 154 | public static function match_domain($host, $reference) { 155 | if (InputValidator::is_string_or_stringable($host) === false) { 156 | throw InvalidArgument::create(1, '$host', 'string|Stringable', gettype($host)); 157 | } 158 | 159 | // Check if the reference is blocklisted first 160 | if (self::verify_reference_name($reference) !== true) { 161 | return false; 162 | } 163 | 164 | // Check for a direct match 165 | if ((string) $host === (string) $reference) { 166 | return true; 167 | } 168 | 169 | // Calculate the valid wildcard match if the host is not an IP address 170 | // Also validates that the host has 3 parts or more, as per Firefox's ruleset, 171 | // as a wildcard reference is only allowed with 3 parts or more, so the 172 | // comparison will never match if host doesn't contain 3 parts or more as well. 173 | if (ip2long($host) === false) { 174 | $parts = explode('.', $host); 175 | $parts[0] = '*'; 176 | $wildcard = implode('.', $parts); 177 | if ($wildcard === (string) $reference) { 178 | return true; 179 | } 180 | } 181 | 182 | return false; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Transport.php: -------------------------------------------------------------------------------- 1 | $capabilities Optional. Associative array of capabilities to test against, i.e. `['' => true]`. 44 | * @return bool Whether the transport can be used. 45 | */ 46 | public static function test($capabilities = []); 47 | } 48 | -------------------------------------------------------------------------------- /src/Transport/Curl.php: -------------------------------------------------------------------------------- 1 | = 8.0. 63 | */ 64 | private $handle; 65 | 66 | /** 67 | * Hook dispatcher instance 68 | * 69 | * @var \WpOrg\Requests\Hooks 70 | */ 71 | private $hooks; 72 | 73 | /** 74 | * Have we finished the headers yet? 75 | * 76 | * @var bool 77 | */ 78 | private $done_headers = false; 79 | 80 | /** 81 | * If streaming to a file, keep the file pointer 82 | * 83 | * @var resource 84 | */ 85 | private $stream_handle; 86 | 87 | /** 88 | * How many bytes are in the response body? 89 | * 90 | * @var int 91 | */ 92 | private $response_bytes; 93 | 94 | /** 95 | * What's the maximum number of bytes we should keep? 96 | * 97 | * @var int|bool Byte count, or false if no limit. 98 | */ 99 | private $response_byte_limit; 100 | 101 | /** 102 | * Constructor 103 | */ 104 | public function __construct() { 105 | $curl = curl_version(); 106 | $this->version = $curl['version_number']; 107 | $this->handle = curl_init(); 108 | 109 | curl_setopt($this->handle, CURLOPT_HEADER, false); 110 | curl_setopt($this->handle, CURLOPT_RETURNTRANSFER, 1); 111 | if ($this->version >= self::CURL_7_10_5) { 112 | curl_setopt($this->handle, CURLOPT_ENCODING, ''); 113 | } 114 | 115 | if (defined('CURLOPT_PROTOCOLS')) { 116 | // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_protocolsFound 117 | curl_setopt($this->handle, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); 118 | } 119 | 120 | if (defined('CURLOPT_REDIR_PROTOCOLS')) { 121 | // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_redir_protocolsFound 122 | curl_setopt($this->handle, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); 123 | } 124 | } 125 | 126 | /** 127 | * Destructor 128 | */ 129 | public function __destruct() { 130 | if (is_resource($this->handle)) { 131 | curl_close($this->handle); 132 | } 133 | } 134 | 135 | /** 136 | * Perform a request 137 | * 138 | * @param string|\Stringable $url URL to request 139 | * @param array $headers Associative array of request headers 140 | * @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD 141 | * @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation 142 | * @return string Raw HTTP result 143 | * 144 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $url argument is not a string or Stringable. 145 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $headers argument is not an array. 146 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $data parameter is not an array or string. 147 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. 148 | * @throws \WpOrg\Requests\Exception On a cURL error (`curlerror`) 149 | */ 150 | public function request($url, $headers = [], $data = [], $options = []) { 151 | if (InputValidator::is_string_or_stringable($url) === false) { 152 | throw InvalidArgument::create(1, '$url', 'string|Stringable', gettype($url)); 153 | } 154 | 155 | if (is_array($headers) === false) { 156 | throw InvalidArgument::create(2, '$headers', 'array', gettype($headers)); 157 | } 158 | 159 | if (!is_array($data) && !is_string($data)) { 160 | if ($data === null) { 161 | $data = ''; 162 | } else { 163 | throw InvalidArgument::create(3, '$data', 'array|string', gettype($data)); 164 | } 165 | } 166 | 167 | if (is_array($options) === false) { 168 | throw InvalidArgument::create(4, '$options', 'array', gettype($options)); 169 | } 170 | 171 | $this->hooks = $options['hooks']; 172 | 173 | $this->setup_handle($url, $headers, $data, $options); 174 | 175 | $options['hooks']->dispatch('curl.before_send', [&$this->handle]); 176 | 177 | if ($options['filename'] !== false) { 178 | // phpcs:ignore WordPress.PHP.NoSilencedErrors -- Silenced the PHP native warning in favour of throwing an exception. 179 | $this->stream_handle = @fopen($options['filename'], 'wb'); 180 | if ($this->stream_handle === false) { 181 | $error = error_get_last(); 182 | if (!is_array($error)) { 183 | // Shouldn't be possible, but can happen in test situations. 184 | $error = ['message' => 'Failed to open stream']; 185 | } 186 | 187 | throw new Exception($error['message'], 'fopen'); 188 | } 189 | } 190 | 191 | $this->response_data = ''; 192 | $this->response_bytes = 0; 193 | $this->response_byte_limit = false; 194 | if ($options['max_bytes'] !== false) { 195 | $this->response_byte_limit = $options['max_bytes']; 196 | } 197 | 198 | if (isset($options['verify'])) { 199 | if ($options['verify'] === false) { 200 | curl_setopt($this->handle, CURLOPT_SSL_VERIFYHOST, 0); 201 | curl_setopt($this->handle, CURLOPT_SSL_VERIFYPEER, 0); 202 | } elseif (is_string($options['verify'])) { 203 | curl_setopt($this->handle, CURLOPT_CAINFO, $options['verify']); 204 | } 205 | } 206 | 207 | if (isset($options['verifyname']) && $options['verifyname'] === false) { 208 | curl_setopt($this->handle, CURLOPT_SSL_VERIFYHOST, 0); 209 | } 210 | 211 | curl_exec($this->handle); 212 | $response = $this->response_data; 213 | 214 | $options['hooks']->dispatch('curl.after_send', []); 215 | 216 | $curl_errno = curl_errno($this->handle); 217 | 218 | if ($curl_errno === CURLE_WRITE_ERROR 219 | && $this->response_byte_limit 220 | && $this->response_bytes >= $this->response_byte_limit 221 | ) { 222 | // Not actually an error in this case. We've drained all the data from the request that we want. 223 | $curl_errno = false; 224 | } 225 | 226 | if ($curl_errno === CURLE_WRITE_ERROR || $curl_errno === CURLE_BAD_CONTENT_ENCODING) { 227 | // Reset encoding and try again 228 | curl_setopt($this->handle, CURLOPT_ENCODING, 'none'); 229 | 230 | $this->response_data = ''; 231 | $this->response_bytes = 0; 232 | curl_exec($this->handle); 233 | $response = $this->response_data; 234 | } 235 | 236 | $this->process_response($response, $options); 237 | 238 | // Need to remove the $this reference from the curl handle. 239 | // Otherwise \WpOrg\Requests\Transport\Curl won't be garbage collected and the curl_close() will never be called. 240 | curl_setopt($this->handle, CURLOPT_HEADERFUNCTION, null); 241 | curl_setopt($this->handle, CURLOPT_WRITEFUNCTION, null); 242 | 243 | return $this->headers; 244 | } 245 | 246 | /** 247 | * Send multiple requests simultaneously 248 | * 249 | * @param array $requests Request data 250 | * @param array $options Global options 251 | * @return array Array of \WpOrg\Requests\Response objects (may contain \WpOrg\Requests\Exception or string responses as well) 252 | * 253 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access. 254 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. 255 | */ 256 | public function request_multiple($requests, $options) { 257 | // If you're not requesting, we can't get any responses ¯\_(ツ)_/¯ 258 | if (empty($requests)) { 259 | return []; 260 | } 261 | 262 | if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) { 263 | throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests)); 264 | } 265 | 266 | if (is_array($options) === false) { 267 | throw InvalidArgument::create(2, '$options', 'array', gettype($options)); 268 | } 269 | 270 | $multihandle = curl_multi_init(); 271 | $subrequests = []; 272 | $subhandles = []; 273 | 274 | $class = get_class($this); 275 | foreach ($requests as $id => $request) { 276 | $subrequests[$id] = new $class(); 277 | $subhandles[$id] = $subrequests[$id]->get_subrequest_handle($request['url'], $request['headers'], $request['data'], $request['options']); 278 | $request['options']['hooks']->dispatch('curl.before_multi_add', [&$subhandles[$id]]); 279 | curl_multi_add_handle($multihandle, $subhandles[$id]); 280 | } 281 | 282 | $completed = 0; 283 | $responses = []; 284 | $subrequestcount = count($subrequests); 285 | 286 | $request['options']['hooks']->dispatch('curl.before_multi_exec', [&$multihandle]); 287 | 288 | do { 289 | $active = 0; 290 | 291 | do { 292 | $status = curl_multi_exec($multihandle, $active); 293 | } while ($status === CURLM_CALL_MULTI_PERFORM); 294 | 295 | $to_process = []; 296 | 297 | // Read the information as needed 298 | while ($done = curl_multi_info_read($multihandle)) { 299 | $key = array_search($done['handle'], $subhandles, true); 300 | if (!isset($to_process[$key])) { 301 | $to_process[$key] = $done; 302 | } 303 | } 304 | 305 | // Parse the finished requests before we start getting the new ones 306 | foreach ($to_process as $key => $done) { 307 | $options = $requests[$key]['options']; 308 | if ($done['result'] !== CURLE_OK) { 309 | //get error string for handle. 310 | $reason = curl_error($done['handle']); 311 | $exception = new CurlException( 312 | $reason, 313 | CurlException::EASY, 314 | $done['handle'], 315 | $done['result'] 316 | ); 317 | $responses[$key] = $exception; 318 | $options['hooks']->dispatch('transport.internal.parse_error', [&$responses[$key], $requests[$key]]); 319 | } else { 320 | $responses[$key] = $subrequests[$key]->process_response($subrequests[$key]->response_data, $options); 321 | 322 | $options['hooks']->dispatch('transport.internal.parse_response', [&$responses[$key], $requests[$key]]); 323 | } 324 | 325 | curl_multi_remove_handle($multihandle, $done['handle']); 326 | curl_close($done['handle']); 327 | 328 | if (!is_string($responses[$key])) { 329 | $options['hooks']->dispatch('multiple.request.complete', [&$responses[$key], $key]); 330 | } 331 | 332 | ++$completed; 333 | } 334 | } while ($active || $completed < $subrequestcount); 335 | 336 | $request['options']['hooks']->dispatch('curl.after_multi_exec', [&$multihandle]); 337 | 338 | curl_multi_close($multihandle); 339 | 340 | return $responses; 341 | } 342 | 343 | /** 344 | * Get the cURL handle for use in a multi-request 345 | * 346 | * @param string $url URL to request 347 | * @param array $headers Associative array of request headers 348 | * @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD 349 | * @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation 350 | * @return resource|\CurlHandle Subrequest's cURL handle 351 | */ 352 | public function &get_subrequest_handle($url, $headers, $data, $options) { 353 | $this->setup_handle($url, $headers, $data, $options); 354 | 355 | $options['hooks']->dispatch('curl.before_send', [&$this->handle]); 356 | 357 | if ($options['filename'] !== false) { 358 | $this->stream_handle = fopen($options['filename'], 'wb'); 359 | } 360 | 361 | $this->response_data = ''; 362 | $this->response_bytes = 0; 363 | $this->response_byte_limit = false; 364 | if ($options['max_bytes'] !== false) { 365 | $this->response_byte_limit = $options['max_bytes']; 366 | } 367 | 368 | $this->hooks = $options['hooks']; 369 | 370 | return $this->handle; 371 | } 372 | 373 | /** 374 | * Setup the cURL handle for the given data 375 | * 376 | * @param string $url URL to request 377 | * @param array $headers Associative array of request headers 378 | * @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD 379 | * @param array $options Request options, see {@see \WpOrg\Requests\Requests::response()} for documentation 380 | */ 381 | private function setup_handle($url, $headers, $data, $options) { 382 | $options['hooks']->dispatch('curl.before_request', [&$this->handle]); 383 | 384 | // Force closing the connection for old versions of cURL (<7.22). 385 | if (!isset($headers['Connection'])) { 386 | $headers['Connection'] = 'close'; 387 | } 388 | 389 | /** 390 | * Add "Expect" header. 391 | * 392 | * By default, cURL adds a "Expect: 100-Continue" to most requests. This header can 393 | * add as much as a second to the time it takes for cURL to perform a request. To 394 | * prevent this, we need to set an empty "Expect" header. To match the behaviour of 395 | * Guzzle, we'll add the empty header to requests that are smaller than 1 MB and use 396 | * HTTP/1.1. 397 | * 398 | * https://curl.se/mail/lib-2017-07/0013.html 399 | */ 400 | if (!isset($headers['Expect']) && $options['protocol_version'] === 1.1) { 401 | $headers['Expect'] = $this->get_expect_header($data); 402 | } 403 | 404 | $headers = Requests::flatten($headers); 405 | 406 | if (!empty($data)) { 407 | $data_format = $options['data_format']; 408 | 409 | if ($data_format === 'query') { 410 | $url = self::format_get($url, $data); 411 | $data = ''; 412 | } elseif (!is_string($data)) { 413 | $data = http_build_query($data, '', '&'); 414 | } 415 | } 416 | 417 | switch ($options['type']) { 418 | case Requests::POST: 419 | curl_setopt($this->handle, CURLOPT_POST, true); 420 | curl_setopt($this->handle, CURLOPT_POSTFIELDS, $data); 421 | break; 422 | case Requests::HEAD: 423 | curl_setopt($this->handle, CURLOPT_CUSTOMREQUEST, $options['type']); 424 | curl_setopt($this->handle, CURLOPT_NOBODY, true); 425 | break; 426 | case Requests::TRACE: 427 | curl_setopt($this->handle, CURLOPT_CUSTOMREQUEST, $options['type']); 428 | break; 429 | case Requests::PATCH: 430 | case Requests::PUT: 431 | case Requests::DELETE: 432 | case Requests::OPTIONS: 433 | default: 434 | curl_setopt($this->handle, CURLOPT_CUSTOMREQUEST, $options['type']); 435 | if (!empty($data)) { 436 | curl_setopt($this->handle, CURLOPT_POSTFIELDS, $data); 437 | } 438 | } 439 | 440 | // cURL requires a minimum timeout of 1 second when using the system 441 | // DNS resolver, as it uses `alarm()`, which is second resolution only. 442 | // There's no way to detect which DNS resolver is being used from our 443 | // end, so we need to round up regardless of the supplied timeout. 444 | // 445 | // https://github.com/curl/curl/blob/4f45240bc84a9aa648c8f7243be7b79e9f9323a5/lib/hostip.c#L606-L609 446 | $timeout = max($options['timeout'], 1); 447 | 448 | if (is_int($timeout) || $this->version < self::CURL_7_16_2) { 449 | curl_setopt($this->handle, CURLOPT_TIMEOUT, ceil($timeout)); 450 | } else { 451 | // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_timeout_msFound 452 | curl_setopt($this->handle, CURLOPT_TIMEOUT_MS, round($timeout * 1000)); 453 | } 454 | 455 | if (is_int($options['connect_timeout']) || $this->version < self::CURL_7_16_2) { 456 | curl_setopt($this->handle, CURLOPT_CONNECTTIMEOUT, ceil($options['connect_timeout'])); 457 | } else { 458 | // phpcs:ignore PHPCompatibility.Constants.NewConstants.curlopt_connecttimeout_msFound 459 | curl_setopt($this->handle, CURLOPT_CONNECTTIMEOUT_MS, round($options['connect_timeout'] * 1000)); 460 | } 461 | 462 | curl_setopt($this->handle, CURLOPT_URL, $url); 463 | curl_setopt($this->handle, CURLOPT_USERAGENT, $options['useragent']); 464 | if (!empty($headers)) { 465 | curl_setopt($this->handle, CURLOPT_HTTPHEADER, $headers); 466 | } 467 | 468 | if ($options['protocol_version'] === 1.1) { 469 | curl_setopt($this->handle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); 470 | } else { 471 | curl_setopt($this->handle, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0); 472 | } 473 | 474 | if ($options['blocking'] === true) { 475 | curl_setopt($this->handle, CURLOPT_HEADERFUNCTION, [$this, 'stream_headers']); 476 | curl_setopt($this->handle, CURLOPT_WRITEFUNCTION, [$this, 'stream_body']); 477 | curl_setopt($this->handle, CURLOPT_BUFFERSIZE, Requests::BUFFER_SIZE); 478 | } 479 | } 480 | 481 | /** 482 | * Process a response 483 | * 484 | * @param string $response Response data from the body 485 | * @param array $options Request options 486 | * @return string|false HTTP response data including headers. False if non-blocking. 487 | * @throws \WpOrg\Requests\Exception If the request resulted in a cURL error. 488 | */ 489 | public function process_response($response, $options) { 490 | if ($options['blocking'] === false) { 491 | $fake_headers = ''; 492 | $fake_info = []; 493 | $options['hooks']->dispatch('curl.after_request', [&$fake_headers, &$fake_info]); 494 | return false; 495 | } 496 | 497 | if ($options['filename'] !== false && $this->stream_handle) { 498 | fclose($this->stream_handle); 499 | $this->headers = trim($this->headers); 500 | } else { 501 | $this->headers .= $response; 502 | } 503 | 504 | $curl_errno = curl_errno($this->handle); 505 | if ($curl_errno === CURLE_WRITE_ERROR 506 | && $this->response_byte_limit 507 | && $this->response_bytes >= $this->response_byte_limit 508 | ) { 509 | // Not actually an error in this case. We've drained all the data from the request that we want. 510 | $curl_errno = false; 511 | } 512 | 513 | if ($curl_errno) { 514 | $error = sprintf( 515 | 'cURL error %s: %s', 516 | curl_errno($this->handle), 517 | curl_error($this->handle) 518 | ); 519 | throw new Exception($error, 'curlerror', $this->handle); 520 | } 521 | 522 | $this->info = curl_getinfo($this->handle); 523 | 524 | $options['hooks']->dispatch('curl.after_request', [&$this->headers, &$this->info]); 525 | return $this->headers; 526 | } 527 | 528 | /** 529 | * Collect the headers as they are received 530 | * 531 | * @param resource|\CurlHandle $handle cURL handle 532 | * @param string $headers Header string 533 | * @return int Length of provided header 534 | */ 535 | public function stream_headers($handle, $headers) { 536 | // Why do we do this? cURL will send both the final response and any 537 | // interim responses, such as a 100 Continue. We don't need that. 538 | // (We may want to keep this somewhere just in case) 539 | if ($this->done_headers) { 540 | $this->headers = ''; 541 | $this->done_headers = false; 542 | } 543 | 544 | $this->headers .= $headers; 545 | 546 | if ($headers === "\r\n") { 547 | $this->done_headers = true; 548 | } 549 | 550 | return strlen($headers); 551 | } 552 | 553 | /** 554 | * Collect data as it's received 555 | * 556 | * @since 1.6.1 557 | * 558 | * @param resource|\CurlHandle $handle cURL handle 559 | * @param string $data Body data 560 | * @return int Length of provided data 561 | */ 562 | public function stream_body($handle, $data) { 563 | $this->hooks->dispatch('request.progress', [$data, $this->response_bytes, $this->response_byte_limit]); 564 | $data_length = strlen($data); 565 | 566 | // Are we limiting the response size? 567 | if ($this->response_byte_limit) { 568 | if (($this->response_bytes + $data_length) > $this->response_byte_limit) { 569 | // Limit the length 570 | $data_length = ($this->response_byte_limit - $this->response_bytes); 571 | $data = substr($data, 0, $data_length); 572 | } 573 | } 574 | 575 | if ($this->stream_handle) { 576 | if ($data !== '') { 577 | fwrite($this->stream_handle, $data); 578 | } 579 | } else { 580 | $this->response_data .= $data; 581 | } 582 | 583 | $this->response_bytes += $data_length; 584 | return $data_length; 585 | } 586 | 587 | /** 588 | * Format a URL given GET data 589 | * 590 | * @param string $url Original URL. 591 | * @param array|object $data Data to build query using, see {@link https://www.php.net/http_build_query} 592 | * @return string URL with data 593 | */ 594 | private static function format_get($url, $data) { 595 | if (!empty($data)) { 596 | $query = ''; 597 | $url_parts = parse_url($url); 598 | if (empty($url_parts['query'])) { 599 | $url_parts['query'] = ''; 600 | } else { 601 | $query = $url_parts['query']; 602 | } 603 | 604 | $query .= '&' . http_build_query($data, '', '&'); 605 | $query = trim($query, '&'); 606 | 607 | if (empty($url_parts['query'])) { 608 | $url .= '?' . $query; 609 | } else { 610 | $url = str_replace($url_parts['query'], $query, $url); 611 | } 612 | } 613 | 614 | return $url; 615 | } 616 | 617 | /** 618 | * Self-test whether the transport can be used. 619 | * 620 | * The available capabilities to test for can be found in {@see \WpOrg\Requests\Capability}. 621 | * 622 | * @codeCoverageIgnore 623 | * @param array $capabilities Optional. Associative array of capabilities to test against, i.e. `['' => true]`. 624 | * @return bool Whether the transport can be used. 625 | */ 626 | public static function test($capabilities = []) { 627 | if (!function_exists('curl_init') || !function_exists('curl_exec')) { 628 | return false; 629 | } 630 | 631 | // If needed, check that our installed curl version supports SSL 632 | if (isset($capabilities[Capability::SSL]) && $capabilities[Capability::SSL]) { 633 | $curl_version = curl_version(); 634 | if (!(CURL_VERSION_SSL & $curl_version['features'])) { 635 | return false; 636 | } 637 | } 638 | 639 | return true; 640 | } 641 | 642 | /** 643 | * Get the correct "Expect" header for the given request data. 644 | * 645 | * @param string|array $data Data to send either as the POST body, or as parameters in the URL for a GET/HEAD. 646 | * @return string The "Expect" header. 647 | */ 648 | private function get_expect_header($data) { 649 | if (!is_array($data)) { 650 | return strlen((string) $data) >= 1048576 ? '100-Continue' : ''; 651 | } 652 | 653 | $bytesize = 0; 654 | $iterator = new RecursiveIteratorIterator(new RecursiveArrayIterator($data)); 655 | 656 | foreach ($iterator as $datum) { 657 | $bytesize += strlen((string) $datum); 658 | 659 | if ($bytesize >= 1048576) { 660 | return '100-Continue'; 661 | } 662 | } 663 | 664 | return ''; 665 | } 666 | } 667 | -------------------------------------------------------------------------------- /src/Transport/Fsockopen.php: -------------------------------------------------------------------------------- 1 | dispatch('fsockopen.before_request'); 101 | 102 | $url_parts = parse_url($url); 103 | if (empty($url_parts)) { 104 | throw new Exception('Invalid URL.', 'invalidurl', $url); 105 | } 106 | 107 | $host = $url_parts['host']; 108 | $context = stream_context_create(); 109 | $verifyname = false; 110 | $case_insensitive_headers = new CaseInsensitiveDictionary($headers); 111 | 112 | // HTTPS support 113 | if (isset($url_parts['scheme']) && strtolower($url_parts['scheme']) === 'https') { 114 | $remote_socket = 'ssl://' . $host; 115 | if (!isset($url_parts['port'])) { 116 | $url_parts['port'] = Port::HTTPS; 117 | } 118 | 119 | $context_options = [ 120 | 'verify_peer' => true, 121 | 'capture_peer_cert' => true, 122 | ]; 123 | $verifyname = true; 124 | 125 | // SNI, if enabled (OpenSSL >=0.9.8j) 126 | // phpcs:ignore PHPCompatibility.Constants.NewConstants.openssl_tlsext_server_nameFound 127 | if (defined('OPENSSL_TLSEXT_SERVER_NAME') && OPENSSL_TLSEXT_SERVER_NAME) { 128 | $context_options['SNI_enabled'] = true; 129 | } 130 | 131 | if (isset($options['verify'])) { 132 | if ($options['verify'] === false) { 133 | $context_options['verify_peer'] = false; 134 | $context_options['verify_peer_name'] = false; 135 | $verifyname = false; 136 | } elseif (is_string($options['verify'])) { 137 | $context_options['cafile'] = $options['verify']; 138 | } 139 | } 140 | 141 | if (isset($options['verifyname']) && $options['verifyname'] === false) { 142 | $context_options['verify_peer_name'] = false; 143 | $verifyname = false; 144 | } 145 | 146 | // Handle the PHP 8.4 deprecation (PHP 9.0 removal) of the function signature we use for stream_context_set_option(). 147 | // Ref: https://wiki.php.net/rfc/deprecate_functions_with_overloaded_signatures#stream_context_set_option 148 | if (function_exists('stream_context_set_options')) { 149 | // PHP 8.3+. 150 | stream_context_set_options($context, ['ssl' => $context_options]); 151 | } else { 152 | // PHP < 8.3. 153 | stream_context_set_option($context, ['ssl' => $context_options]); 154 | } 155 | } else { 156 | $remote_socket = 'tcp://' . $host; 157 | } 158 | 159 | $this->max_bytes = $options['max_bytes']; 160 | 161 | if (!isset($url_parts['port'])) { 162 | $url_parts['port'] = Port::HTTP; 163 | } 164 | 165 | $remote_socket .= ':' . $url_parts['port']; 166 | 167 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler 168 | set_error_handler([$this, 'connect_error_handler'], E_WARNING | E_NOTICE); 169 | 170 | $options['hooks']->dispatch('fsockopen.remote_socket', [&$remote_socket]); 171 | 172 | $socket = stream_socket_client($remote_socket, $errno, $errstr, ceil($options['connect_timeout']), STREAM_CLIENT_CONNECT, $context); 173 | 174 | restore_error_handler(); 175 | 176 | if ($verifyname && !$this->verify_certificate_from_context($host, $context)) { 177 | throw new Exception('SSL certificate did not match the requested domain name', 'ssl.no_match'); 178 | } 179 | 180 | if (!$socket) { 181 | if ($errno === 0) { 182 | // Connection issue 183 | throw new Exception(rtrim($this->connect_error), 'fsockopen.connect_error'); 184 | } 185 | 186 | throw new Exception($errstr, 'fsockopenerror', null, $errno); 187 | } 188 | 189 | $data_format = $options['data_format']; 190 | 191 | if ($data_format === 'query') { 192 | $path = self::format_get($url_parts, $data); 193 | $data = ''; 194 | } else { 195 | $path = self::format_get($url_parts, []); 196 | } 197 | 198 | $options['hooks']->dispatch('fsockopen.remote_host_path', [&$path, $url]); 199 | 200 | $request_body = ''; 201 | $out = sprintf("%s %s HTTP/%.1F\r\n", $options['type'], $path, $options['protocol_version']); 202 | 203 | if ($options['type'] !== Requests::TRACE) { 204 | if (is_array($data)) { 205 | $request_body = http_build_query($data, '', '&'); 206 | } else { 207 | $request_body = $data; 208 | } 209 | 210 | // Always include Content-length on POST requests to prevent 211 | // 411 errors from some servers when the body is empty. 212 | if (!empty($data) || $options['type'] === Requests::POST) { 213 | if (!isset($case_insensitive_headers['Content-Length'])) { 214 | $headers['Content-Length'] = strlen($request_body); 215 | } 216 | 217 | if (!isset($case_insensitive_headers['Content-Type'])) { 218 | $headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; 219 | } 220 | } 221 | } 222 | 223 | if (!isset($case_insensitive_headers['Host'])) { 224 | $out .= sprintf('Host: %s', $url_parts['host']); 225 | $scheme_lower = strtolower($url_parts['scheme']); 226 | 227 | if (($scheme_lower === 'http' && $url_parts['port'] !== Port::HTTP) || ($scheme_lower === 'https' && $url_parts['port'] !== Port::HTTPS)) { 228 | $out .= ':' . $url_parts['port']; 229 | } 230 | 231 | $out .= "\r\n"; 232 | } 233 | 234 | if (!isset($case_insensitive_headers['User-Agent'])) { 235 | $out .= sprintf("User-Agent: %s\r\n", $options['useragent']); 236 | } 237 | 238 | $accept_encoding = $this->accept_encoding(); 239 | if (!isset($case_insensitive_headers['Accept-Encoding']) && !empty($accept_encoding)) { 240 | $out .= sprintf("Accept-Encoding: %s\r\n", $accept_encoding); 241 | } 242 | 243 | $headers = Requests::flatten($headers); 244 | 245 | if (!empty($headers)) { 246 | $out .= implode("\r\n", $headers) . "\r\n"; 247 | } 248 | 249 | $options['hooks']->dispatch('fsockopen.after_headers', [&$out]); 250 | 251 | if (substr($out, -2) !== "\r\n") { 252 | $out .= "\r\n"; 253 | } 254 | 255 | if (!isset($case_insensitive_headers['Connection'])) { 256 | $out .= "Connection: Close\r\n"; 257 | } 258 | 259 | $out .= "\r\n" . $request_body; 260 | 261 | $options['hooks']->dispatch('fsockopen.before_send', [&$out]); 262 | 263 | fwrite($socket, $out); 264 | $options['hooks']->dispatch('fsockopen.after_send', [$out]); 265 | 266 | if (!$options['blocking']) { 267 | fclose($socket); 268 | $fake_headers = ''; 269 | $fake_info = []; 270 | $options['hooks']->dispatch('fsockopen.after_request', [&$fake_headers, &$fake_info]); 271 | return ''; 272 | } 273 | 274 | $timeout_sec = (int) floor($options['timeout']); 275 | if ($timeout_sec === $options['timeout']) { 276 | $timeout_msec = 0; 277 | } else { 278 | $timeout_msec = self::SECOND_IN_MICROSECONDS * $options['timeout'] % self::SECOND_IN_MICROSECONDS; 279 | } 280 | 281 | stream_set_timeout($socket, $timeout_sec, $timeout_msec); 282 | 283 | $response = ''; 284 | $body = ''; 285 | $headers = ''; 286 | $this->info = stream_get_meta_data($socket); 287 | $size = 0; 288 | $doingbody = false; 289 | $download = false; 290 | if ($options['filename']) { 291 | // phpcs:ignore WordPress.PHP.NoSilencedErrors -- Silenced the PHP native warning in favour of throwing an exception. 292 | $download = @fopen($options['filename'], 'wb'); 293 | if ($download === false) { 294 | $error = error_get_last(); 295 | if (!is_array($error)) { 296 | // Shouldn't be possible, but can happen in test situations. 297 | $error = ['message' => 'Failed to open stream']; 298 | } 299 | 300 | throw new Exception($error['message'], 'fopen'); 301 | } 302 | } 303 | 304 | while (!feof($socket)) { 305 | $this->info = stream_get_meta_data($socket); 306 | if ($this->info['timed_out']) { 307 | throw new Exception('fsocket timed out', 'timeout'); 308 | } 309 | 310 | $block = fread($socket, Requests::BUFFER_SIZE); 311 | if (!$doingbody) { 312 | $response .= $block; 313 | if (strpos($response, "\r\n\r\n")) { 314 | list($headers, $block) = explode("\r\n\r\n", $response, 2); 315 | $doingbody = true; 316 | } 317 | } 318 | 319 | // Are we in body mode now? 320 | if ($doingbody) { 321 | $options['hooks']->dispatch('request.progress', [$block, $size, $this->max_bytes]); 322 | $data_length = strlen($block); 323 | 324 | if ($this->max_bytes) { 325 | if (($size + $data_length) > $this->max_bytes) { 326 | // Limit the length 327 | $data_length = ($this->max_bytes - $size); 328 | $block = substr($block, 0, $data_length); 329 | } 330 | } 331 | 332 | $size += $data_length; 333 | if ($download) { 334 | fwrite($download, $block); 335 | } else { 336 | $body .= $block; 337 | } 338 | 339 | // Have we hit a limit? 340 | if ($this->max_bytes && $size >= $this->max_bytes) { 341 | break; 342 | } 343 | } 344 | } 345 | 346 | $this->headers = $headers; 347 | 348 | if ($download) { 349 | fclose($download); 350 | } else { 351 | $this->headers .= "\r\n\r\n" . $body; 352 | } 353 | 354 | fclose($socket); 355 | 356 | $options['hooks']->dispatch('fsockopen.after_request', [&$this->headers, &$this->info]); 357 | return $this->headers; 358 | } 359 | 360 | /** 361 | * Send multiple requests simultaneously 362 | * 363 | * @param array $requests Request data (array of 'url', 'headers', 'data', 'options') as per {@see \WpOrg\Requests\Transport::request()} 364 | * @param array $options Global options, see {@see \WpOrg\Requests\Requests::response()} for documentation 365 | * @return array Array of \WpOrg\Requests\Response objects (may contain \WpOrg\Requests\Exception or string responses as well) 366 | * 367 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $requests argument is not an array or iterable object with array access. 368 | * @throws \WpOrg\Requests\Exception\InvalidArgument When the passed $options argument is not an array. 369 | */ 370 | public function request_multiple($requests, $options) { 371 | // If you're not requesting, we can't get any responses ¯\_(ツ)_/¯ 372 | if (empty($requests)) { 373 | return []; 374 | } 375 | 376 | if (InputValidator::has_array_access($requests) === false || InputValidator::is_iterable($requests) === false) { 377 | throw InvalidArgument::create(1, '$requests', 'array|ArrayAccess&Traversable', gettype($requests)); 378 | } 379 | 380 | if (is_array($options) === false) { 381 | throw InvalidArgument::create(2, '$options', 'array', gettype($options)); 382 | } 383 | 384 | $responses = []; 385 | $class = get_class($this); 386 | foreach ($requests as $id => $request) { 387 | try { 388 | $handler = new $class(); 389 | $responses[$id] = $handler->request($request['url'], $request['headers'], $request['data'], $request['options']); 390 | 391 | $request['options']['hooks']->dispatch('transport.internal.parse_response', [&$responses[$id], $request]); 392 | } catch (Exception $e) { 393 | $responses[$id] = $e; 394 | } 395 | 396 | if (!is_string($responses[$id])) { 397 | $request['options']['hooks']->dispatch('multiple.request.complete', [&$responses[$id], $id]); 398 | } 399 | } 400 | 401 | return $responses; 402 | } 403 | 404 | /** 405 | * Retrieve the encodings we can accept 406 | * 407 | * @return string Accept-Encoding header value 408 | */ 409 | private static function accept_encoding() { 410 | $type = []; 411 | if (function_exists('gzinflate')) { 412 | $type[] = 'deflate;q=1.0'; 413 | } 414 | 415 | if (function_exists('gzuncompress')) { 416 | $type[] = 'compress;q=0.5'; 417 | } 418 | 419 | $type[] = 'gzip;q=0.5'; 420 | 421 | return implode(', ', $type); 422 | } 423 | 424 | /** 425 | * Format a URL given GET data 426 | * 427 | * @param array $url_parts Array of URL parts as received from {@link https://www.php.net/parse_url} 428 | * @param array|object $data Data to build query using, see {@link https://www.php.net/http_build_query} 429 | * @return string URL with data 430 | */ 431 | private static function format_get($url_parts, $data) { 432 | if (!empty($data)) { 433 | if (empty($url_parts['query'])) { 434 | $url_parts['query'] = ''; 435 | } 436 | 437 | $url_parts['query'] .= '&' . http_build_query($data, '', '&'); 438 | $url_parts['query'] = trim($url_parts['query'], '&'); 439 | } 440 | 441 | if (isset($url_parts['path'])) { 442 | if (isset($url_parts['query'])) { 443 | $get = $url_parts['path'] . '?' . $url_parts['query']; 444 | } else { 445 | $get = $url_parts['path']; 446 | } 447 | } else { 448 | $get = '/'; 449 | } 450 | 451 | return $get; 452 | } 453 | 454 | /** 455 | * Error handler for stream_socket_client() 456 | * 457 | * @param int $errno Error number (e.g. E_WARNING) 458 | * @param string $errstr Error message 459 | */ 460 | public function connect_error_handler($errno, $errstr) { 461 | // Double-check we can handle it 462 | if (($errno & E_WARNING) === 0 && ($errno & E_NOTICE) === 0) { 463 | // Return false to indicate the default error handler should engage 464 | return false; 465 | } 466 | 467 | $this->connect_error .= $errstr . "\n"; 468 | return true; 469 | } 470 | 471 | /** 472 | * Verify the certificate against common name and subject alternative names 473 | * 474 | * Unfortunately, PHP doesn't check the certificate against the alternative 475 | * names, leading things like 'https://www.github.com/' to be invalid. 476 | * Instead 477 | * 478 | * @link https://tools.ietf.org/html/rfc2818#section-3.1 RFC2818, Section 3.1 479 | * 480 | * @param string $host Host name to verify against 481 | * @param resource $context Stream context 482 | * @return bool 483 | * 484 | * @throws \WpOrg\Requests\Exception On failure to connect via TLS (`fsockopen.ssl.connect_error`) 485 | * @throws \WpOrg\Requests\Exception On not obtaining a match for the host (`fsockopen.ssl.no_match`) 486 | */ 487 | public function verify_certificate_from_context($host, $context) { 488 | $meta = stream_context_get_options($context); 489 | 490 | // If we don't have SSL options, then we couldn't make the connection at 491 | // all 492 | if (empty($meta) || empty($meta['ssl']) || empty($meta['ssl']['peer_certificate'])) { 493 | throw new Exception(rtrim($this->connect_error), 'ssl.connect_error'); 494 | } 495 | 496 | $cert = openssl_x509_parse($meta['ssl']['peer_certificate']); 497 | 498 | return Ssl::verify_certificate($host, $cert); 499 | } 500 | 501 | /** 502 | * Self-test whether the transport can be used. 503 | * 504 | * The available capabilities to test for can be found in {@see \WpOrg\Requests\Capability}. 505 | * 506 | * @codeCoverageIgnore 507 | * @param array $capabilities Optional. Associative array of capabilities to test against, i.e. `['' => true]`. 508 | * @return bool Whether the transport can be used. 509 | */ 510 | public static function test($capabilities = []) { 511 | if (!function_exists('fsockopen')) { 512 | return false; 513 | } 514 | 515 | // If needed, check that streams support SSL 516 | if (isset($capabilities[Capability::SSL]) && $capabilities[Capability::SSL]) { 517 | if (!extension_loaded('openssl') || !function_exists('openssl_x509_parse')) { 518 | return false; 519 | } 520 | } 521 | 522 | return true; 523 | } 524 | } 525 | -------------------------------------------------------------------------------- /src/Utility/CaseInsensitiveDictionary.php: -------------------------------------------------------------------------------- 1 | $value) { 38 | $this->offsetSet($offset, $value); 39 | } 40 | } 41 | 42 | /** 43 | * Check if the given item exists 44 | * 45 | * @param string $offset Item key 46 | * @return bool Does the item exist? 47 | */ 48 | #[ReturnTypeWillChange] 49 | public function offsetExists($offset) { 50 | if (is_string($offset)) { 51 | $offset = strtolower($offset); 52 | } 53 | 54 | return isset($this->data[$offset]); 55 | } 56 | 57 | /** 58 | * Get the value for the item 59 | * 60 | * @param string $offset Item key 61 | * @return string|null Item value (null if the item key doesn't exist) 62 | */ 63 | #[ReturnTypeWillChange] 64 | public function offsetGet($offset) { 65 | if (is_string($offset)) { 66 | $offset = strtolower($offset); 67 | } 68 | 69 | if (!isset($this->data[$offset])) { 70 | return null; 71 | } 72 | 73 | return $this->data[$offset]; 74 | } 75 | 76 | /** 77 | * Set the given item 78 | * 79 | * @param string $offset Item name 80 | * @param string $value Item value 81 | * 82 | * @throws \WpOrg\Requests\Exception On attempting to use dictionary as list (`invalidset`) 83 | */ 84 | #[ReturnTypeWillChange] 85 | public function offsetSet($offset, $value) { 86 | if ($offset === null) { 87 | throw new Exception('Object is a dictionary, not a list', 'invalidset'); 88 | } 89 | 90 | if (is_string($offset)) { 91 | $offset = strtolower($offset); 92 | } 93 | 94 | $this->data[$offset] = $value; 95 | } 96 | 97 | /** 98 | * Unset the given header 99 | * 100 | * @param string $offset The key for the item to unset. 101 | */ 102 | #[ReturnTypeWillChange] 103 | public function offsetUnset($offset) { 104 | if (is_string($offset)) { 105 | $offset = strtolower($offset); 106 | } 107 | 108 | unset($this->data[$offset]); 109 | } 110 | 111 | /** 112 | * Get an iterator for the data 113 | * 114 | * @return \ArrayIterator 115 | */ 116 | #[ReturnTypeWillChange] 117 | public function getIterator() { 118 | return new ArrayIterator($this->data); 119 | } 120 | 121 | /** 122 | * Get the headers as an array 123 | * 124 | * @return array Header data 125 | */ 126 | public function getAll() { 127 | return $this->data; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/Utility/FilteredIterator.php: -------------------------------------------------------------------------------- 1 | callback = $callback; 47 | } 48 | } 49 | 50 | /** 51 | * Prevent unserialization of the object for security reasons. 52 | * 53 | * This method is used on PHP 7.4+. 54 | * 55 | * @phpcs:disable PHPCompatibility.FunctionNameRestrictions.NewMagicMethods.__unserializeFound 56 | * 57 | * @param array $data Restored array of data originally serialized. 58 | * 59 | * @return void 60 | */ 61 | #[ReturnTypeWillChange] 62 | public function __unserialize($data) {} 63 | // phpcs:enable 64 | 65 | /** 66 | * Prevent creating a PHP value from a stored representation of the object for security reasons. 67 | * 68 | * This method is used on PHP < 7.4. 69 | * 70 | * @param string $data The serialized string. 71 | * 72 | * @return void 73 | */ 74 | #[ReturnTypeWillChange] 75 | public function unserialize($data) {} 76 | 77 | /** 78 | * Get the current item's value after filtering 79 | * 80 | * @return string 81 | */ 82 | #[ReturnTypeWillChange] 83 | public function current() { 84 | $value = parent::current(); 85 | 86 | if (is_callable($this->callback)) { 87 | $value = call_user_func($this->callback, $value); 88 | } 89 | 90 | return $value; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Utility/InputValidator.php: -------------------------------------------------------------------------------- 1 |