├── src ├── Debug │ ├── HTTPCollection.php │ ├── icons │ │ └── http.svg │ └── HTTPCollector.php ├── RequestInterface.php ├── ResponseInterface.php ├── MessageInterface.php ├── Protocol.php ├── Method.php ├── RequestHeader.php ├── ResponseHeader.php ├── AntiCSRF.php ├── Cookie.php ├── HeaderTrait.php ├── ResponseDownload.php ├── URL.php ├── Message.php ├── UserAgent.php ├── CSP.php └── Response.php ├── README.md ├── LICENSE ├── composer.json └── .phpstorm.meta.php /src/Debug/HTTPCollection.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP\Debug; 11 | 12 | use Framework\Debug\Collection; 13 | 14 | /** 15 | * Class HTTPCollection. 16 | * 17 | * @package http 18 | */ 19 | class HTTPCollection extends Collection 20 | { 21 | protected string $iconPath = __DIR__ . '/icons/http.svg'; 22 | } 23 | -------------------------------------------------------------------------------- /src/Debug/icons/http.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/RequestInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | /** 13 | * Interface RequestInterface. 14 | * 15 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages#http_requests 16 | * 17 | * @package http 18 | */ 19 | interface RequestInterface extends MessageInterface 20 | { 21 | public function getMethod() : string; 22 | 23 | public function isMethod(string $method) : bool; 24 | 25 | public function getUrl() : URL; 26 | } 27 | -------------------------------------------------------------------------------- /src/ResponseInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | /** 13 | * Interface ResponseInterface. 14 | * 15 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages#http_responses 16 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status 17 | * @see https://datatracker.ietf.org/doc/html/rfc7231#section-6 18 | * 19 | * @package http 20 | */ 21 | interface ResponseInterface extends MessageInterface 22 | { 23 | public function getStatusCode() : int; 24 | 25 | public function isStatusCode(int $code) : bool; 26 | 27 | public function getStatusReason() : string; 28 | 29 | public function getStatus() : string; 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Aplus Framework HTTP Library 2 | 3 | # Aplus Framework HTTP Library 4 | 5 | - [Home](https://aplus-framework.com/packages/http) 6 | - [User Guide](https://docs.aplus-framework.com/guides/libraries/http/index.html) 7 | - [API Documentation](https://docs.aplus-framework.com/packages/http.html) 8 | 9 | [![tests](https://github.com/aplus-framework/http/actions/workflows/tests.yml/badge.svg)](https://github.com/aplus-framework/http/actions/workflows/tests.yml) 10 | [![coverage](https://coveralls.io/repos/github/aplus-framework/http/badge.svg?branch=master)](https://coveralls.io/github/aplus-framework/http?branch=master) 11 | [![packagist](https://img.shields.io/packagist/v/aplus/http)](https://packagist.org/packages/aplus/http) 12 | [![open-source](https://img.shields.io/badge/open--source-sponsor-magenta)](https://aplus-framework.com/sponsor) 13 | -------------------------------------------------------------------------------- /src/MessageInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | use Stringable; 13 | 14 | /** 15 | * Interface MessageInterface. 16 | * 17 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP 18 | * 19 | * @package http 20 | */ 21 | interface MessageInterface extends Stringable 22 | { 23 | public function getProtocol() : string; 24 | 25 | public function getStartLine() : string; 26 | 27 | public function getHeader(string $name) : ?string; 28 | 29 | public function hasHeader(string $name, ?string $value = null) : bool; 30 | 31 | /** 32 | * @return array 33 | */ 34 | public function getHeaders() : array; 35 | 36 | public function getBody() : string; 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Natan Felles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Protocol.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | use InvalidArgumentException; 13 | 14 | /** 15 | * Class Protocol. 16 | * 17 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Overview 18 | * 19 | * @package http 20 | */ 21 | class Protocol 22 | { 23 | /** 24 | * @see https://en.wikipedia.org/wiki/HTTP/1.0 25 | */ 26 | public const string HTTP_1_0 = 'HTTP/1.0'; 27 | /** 28 | * @see https://en.wikipedia.org/wiki/HTTP/1.1 29 | */ 30 | public const string HTTP_1_1 = 'HTTP/1.1'; 31 | /** 32 | * @see https://en.wikipedia.org/wiki/HTTP/2.0 33 | */ 34 | public const string HTTP_2_0 = 'HTTP/2.0'; 35 | /** 36 | * @see https://en.wikipedia.org/wiki/HTTP/2 37 | */ 38 | public const string HTTP_2 = 'HTTP/2'; 39 | /** 40 | * @see https://en.wikipedia.org/wiki/HTTP/3 41 | */ 42 | public const string HTTP_3 = 'HTTP/3'; 43 | /** 44 | * @var array 45 | */ 46 | protected static array $protocols = [ 47 | 'HTTP/1.0', 48 | 'HTTP/1.1', 49 | 'HTTP/2.0', 50 | 'HTTP/2', 51 | 'HTTP/3', 52 | ]; 53 | 54 | /** 55 | * @param string $protocol 56 | * 57 | * @throws InvalidArgumentException for invalid protocol 58 | * 59 | * @return string 60 | */ 61 | public static function validate(string $protocol) : string 62 | { 63 | $valid = \strtoupper($protocol); 64 | if (\in_array($valid, static::$protocols, true)) { 65 | return $valid; 66 | } 67 | throw new InvalidArgumentException('Invalid protocol: ' . $protocol); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aplus/http", 3 | "description": "Aplus Framework HTTP Library", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "http", 8 | "anti-csrf", 9 | "csrf", 10 | "message", 11 | "request", 12 | "response", 13 | "url", 14 | "cookie", 15 | "user-agent", 16 | "download", 17 | "rest" 18 | ], 19 | "authors": [ 20 | { 21 | "name": "Natan Felles", 22 | "email": "natanfelles@gmail.com", 23 | "homepage": "https://natanfelles.github.io" 24 | } 25 | ], 26 | "homepage": "https://aplus-framework.com/packages/http", 27 | "support": { 28 | "email": "support@aplus-framework.com", 29 | "issues": "https://github.com/aplus-framework/http/issues", 30 | "forum": "https://aplus-framework.com/forum", 31 | "source": "https://github.com/aplus-framework/http", 32 | "docs": "https://docs.aplus-framework.com/guides/libraries/http/" 33 | }, 34 | "funding": [ 35 | { 36 | "type": "Aplus Sponsor", 37 | "url": "https://aplus-framework.com/sponsor" 38 | } 39 | ], 40 | "require": { 41 | "php": ">=8.3", 42 | "ext-fileinfo": "*", 43 | "ext-json": "*", 44 | "aplus/debug": "^4.3", 45 | "aplus/helpers": "^4.0" 46 | }, 47 | "require-dev": { 48 | "ext-xdebug": "*", 49 | "aplus/coding-standard": "^2.8", 50 | "ergebnis/composer-normalize": "^2.25", 51 | "jetbrains/phpstorm-attributes": "^1.0", 52 | "phpmd/phpmd": "^2.13", 53 | "phpstan/phpstan": "^1.9", 54 | "phpunit/phpunit": "^10.5" 55 | }, 56 | "minimum-stability": "dev", 57 | "prefer-stable": true, 58 | "autoload": { 59 | "psr-4": { 60 | "Framework\\HTTP\\": "src/" 61 | } 62 | }, 63 | "autoload-dev": { 64 | "psr-4": { 65 | "Tests\\HTTP\\": "tests/" 66 | } 67 | }, 68 | "config": { 69 | "allow-plugins": { 70 | "ergebnis/composer-normalize": true 71 | }, 72 | "optimize-autoloader": true, 73 | "preferred-install": "dist", 74 | "sort-packages": true 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Method.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | use InvalidArgumentException; 13 | 14 | /** 15 | * Class Method. 16 | * 17 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods 18 | * 19 | * @package http 20 | */ 21 | class Method 22 | { 23 | /** 24 | * The HTTP CONNECT method starts two-way communications with the requested 25 | * resource. It can be used to open a tunnel. 26 | * 27 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/CONNECT 28 | */ 29 | public const string CONNECT = 'CONNECT'; 30 | /** 31 | * The HTTP DELETE request method deletes the specified resource. 32 | * 33 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE 34 | */ 35 | public const string DELETE = 'DELETE'; 36 | /** 37 | * The HTTP GET method requests a representation of the specified resource. 38 | * Requests using GET should only be used to request data (they shouldn't 39 | * include data). 40 | * 41 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET 42 | */ 43 | public const string GET = 'GET'; 44 | /** 45 | * The HTTP HEAD method requests the headers that would be returned if the 46 | * HEAD request's URL was instead requested with the HTTP GET method. 47 | * 48 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/HEAD 49 | */ 50 | public const string HEAD = 'HEAD'; 51 | /** 52 | * The HTTP OPTIONS method requests permitted communication options for a 53 | * given URL or server. A client can specify a URL with this method, or an 54 | * asterisk (*) to refer to the entire server. 55 | * 56 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/OPTIONS 57 | */ 58 | public const string OPTIONS = 'OPTIONS'; 59 | /** 60 | * The HTTP PATCH request method applies partial modifications to a resource. 61 | * 62 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH 63 | */ 64 | public const string PATCH = 'PATCH'; 65 | /** 66 | * The HTTP POST method sends data to the server. The type of the body of 67 | * the request is indicated by the Content-Type header. 68 | * 69 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST 70 | * @see Header::CONTENT_TYPE 71 | */ 72 | public const string POST = 'POST'; 73 | /** 74 | * The HTTP PUT request method creates a new resource or replaces a 75 | * representation of the target resource with the request payload. 76 | * 77 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT 78 | */ 79 | public const string PUT = 'PUT'; 80 | /** 81 | * The HTTP TRACE method performs a message loop-back test along the path to 82 | * the target resource, providing a useful debugging mechanism. 83 | * 84 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/TRACE 85 | */ 86 | public const string TRACE = 'TRACE'; 87 | /** 88 | * @var array 89 | */ 90 | protected static array $methods = [ 91 | 'CONNECT', 92 | 'DELETE', 93 | 'GET', 94 | 'HEAD', 95 | 'OPTIONS', 96 | 'PATCH', 97 | 'POST', 98 | 'PUT', 99 | 'TRACE', 100 | ]; 101 | 102 | /** 103 | * @param string $method 104 | * 105 | * @throws InvalidArgumentException for invalid method 106 | * 107 | * @return string 108 | */ 109 | public static function validate(string $method) : string 110 | { 111 | $valid = \strtoupper($method); 112 | if (\in_array($valid, static::$methods, true)) { 113 | return $valid; 114 | } 115 | throw new InvalidArgumentException('Invalid request method: ' . $method); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/RequestHeader.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | /** 13 | * Class RequestHeader. 14 | * 15 | * @see https://developer.mozilla.org/en-US/docs/Glossary/Request_header 16 | * 17 | * @package http 18 | */ 19 | class RequestHeader 20 | { 21 | use HeaderTrait; 22 | /** 23 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept 24 | */ 25 | public const string ACCEPT = 'Accept'; 26 | /** 27 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Charset 28 | */ 29 | public const string ACCEPT_CHARSET = 'Accept-Charset'; 30 | /** 31 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding 32 | */ 33 | public const string ACCEPT_ENCODING = 'Accept-Encoding'; 34 | /** 35 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language 36 | */ 37 | public const string ACCEPT_LANGUAGE = 'Accept-Language'; 38 | /** 39 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers 40 | */ 41 | public const string ACCESS_CONTROL_REQUEST_HEADERS = 'Access-Control-Request-Headers'; 42 | /** 43 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method 44 | */ 45 | public const string ACCESS_CONTROL_REQUEST_METHOD = 'Access-Control-Request-Method'; 46 | /** 47 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization 48 | */ 49 | public const string AUTHORIZATION = 'Authorization'; 50 | /** 51 | * @see https://datatracker.ietf.org/doc/html/rfc8586 52 | */ 53 | public const string CDN_LOOP = 'CDN-Loop'; 54 | /** 55 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie 56 | */ 57 | public const string COOKIE = 'Cookie'; 58 | /** 59 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/DNT 60 | */ 61 | public const string DNT = 'DNT'; 62 | /** 63 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expect 64 | */ 65 | public const string EXPECT = 'Expect'; 66 | /** 67 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded 68 | */ 69 | public const string FORWARDED = 'Forwarded'; 70 | /** 71 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/From 72 | */ 73 | public const string FROM = 'From'; 74 | /** 75 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host 76 | */ 77 | public const string HOST = 'Host'; 78 | /** 79 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match 80 | */ 81 | public const string IF_MATCH = 'If-Match'; 82 | /** 83 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since 84 | */ 85 | public const string IF_MODIFIED_SINCE = 'If-Modified-Since'; 86 | /** 87 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match 88 | */ 89 | public const string IF_NONE_MATCH = 'If-None-Match'; 90 | /** 91 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range 92 | */ 93 | public const string IF_RANGE = 'If-Range'; 94 | /** 95 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Unmodified-Since 96 | */ 97 | public const string IF_UNMODIFIED_SINCE = 'If-Unmodified-Since'; 98 | /** 99 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin 100 | */ 101 | public const string ORIGIN = 'Origin'; 102 | /** 103 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Priority 104 | */ 105 | public const string PRIORITY = 'Priority'; 106 | /** 107 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Proxy-Authorization 108 | */ 109 | public const string PROXY_AUTHORIZATION = 'Proxy-Authorization'; 110 | /** 111 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range 112 | */ 113 | public const string RANGE = 'Range'; 114 | /** 115 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer 116 | */ 117 | public const string REFERER = 'Referer'; 118 | /** 119 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Dest 120 | */ 121 | public const string SEC_FETCH_DEST = 'Sec-Fetch-Dest'; 122 | /** 123 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode 124 | */ 125 | public const string SEC_FETCH_MODE = 'Sec-Fetch-Mode'; 126 | /** 127 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site 128 | */ 129 | public const string SEC_FETCH_SITE = 'Sec-Fetch-Site'; 130 | /** 131 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-User 132 | */ 133 | public const string SEC_FETCH_USER = 'Sec-Fetch-User'; 134 | /** 135 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/TE 136 | */ 137 | public const string TE = 'TE'; 138 | /** 139 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Upgrade-Insecure-Requests 140 | */ 141 | public const string UPGRADE_INSECURE_REQUESTS = 'Upgrade-Insecure-Requests'; 142 | /** 143 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent 144 | */ 145 | public const string USER_AGENT = 'User-Agent'; 146 | /** 147 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For 148 | */ 149 | public const string X_FORWARDED_FOR = 'X-Forwarded-For'; 150 | /** 151 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host 152 | */ 153 | public const string X_FORWARDED_HOST = 'X-Forwarded-Host'; 154 | /** 155 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto 156 | */ 157 | public const string X_FORWARDED_PROTO = 'X-Forwarded-Proto'; 158 | /** 159 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Real-IP 160 | */ 161 | public const string X_REAL_IP = 'X-Real-IP'; 162 | /** 163 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Requested-With 164 | */ 165 | public const string X_REQUESTED_WITH = 'X-Requested-With'; 166 | 167 | /** 168 | * @param array $input 169 | * 170 | * @return array 171 | */ 172 | public static function parseInput(array $input) : array 173 | { 174 | $headers = []; 175 | foreach ($input as $name => $value) { 176 | if (\str_starts_with($name, 'HTTP_')) { 177 | $name = \strtr(\substr($name, 5), ['_' => '-']); 178 | $name = static::getName($name); 179 | $headers[$name] = (string) $value; 180 | } 181 | } 182 | return $headers; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/ResponseHeader.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | /** 13 | * Class ResponseHeader. 14 | * 15 | * @see https://developer.mozilla.org/en-US/docs/Glossary/Response_header 16 | * 17 | * @package http 18 | */ 19 | class ResponseHeader 20 | { 21 | use HeaderTrait; 22 | 23 | /** 24 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges 25 | */ 26 | public const string ACCEPT_RANGES = 'Accept-Ranges'; 27 | /** 28 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials 29 | */ 30 | public const string ACCESS_CONTROL_ALLOW_CREDENTIALS = 'Access-Control-Allow-Credentials'; 31 | /** 32 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers 33 | */ 34 | public const string ACCESS_CONTROL_ALLOW_HEADERS = 'Access-Control-Allow-Headers'; 35 | /** 36 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Methods 37 | */ 38 | public const string ACCESS_CONTROL_ALLOW_METHODS = 'Access-Control-Allow-Methods'; 39 | /** 40 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin 41 | */ 42 | public const string ACCESS_CONTROL_ALLOW_ORIGIN = 'Access-Control-Allow-Origin'; 43 | /** 44 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers 45 | */ 46 | public const string ACCESS_CONTROL_EXPOSE_HEADERS = 'Access-Control-Expose-Headers'; 47 | /** 48 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age 49 | */ 50 | public const string ACCESS_CONTROL_MAX_AGE = 'Access-Control-Max-Age'; 51 | /** 52 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Age 53 | */ 54 | public const string AGE = 'Age'; 55 | /** 56 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Allow 57 | */ 58 | public const string ALLOW = 'Allow'; 59 | /** 60 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Clear-Site-Data 61 | */ 62 | public const string CLEAR_SITE_DATA = 'Clear-Site-Data'; 63 | /** 64 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 65 | */ 66 | public const string CONTENT_SECURITY_POLICY = 'Content-Security-Policy'; 67 | /** 68 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 69 | */ 70 | public const string CONTENT_SECURITY_POLICY_REPORT_ONLY = 'Content-Security-Policy-Report-Only'; 71 | /** 72 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag 73 | */ 74 | public const string ETAG = 'ETag'; 75 | /** 76 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expect-CT 77 | */ 78 | public const string EXPECT_CT = 'Expect-CT'; 79 | /** 80 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires 81 | */ 82 | public const string EXPIRES = 'Expires'; 83 | /** 84 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Feature-Policy 85 | */ 86 | public const string FEATURE_POLICY = 'Feature-Policy'; 87 | /** 88 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified 89 | */ 90 | public const string LAST_MODIFIED = 'Last-Modified'; 91 | /** 92 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location 93 | */ 94 | public const string LOCATION = 'Location'; 95 | /** 96 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Proxy-Authenticate 97 | */ 98 | public const string PROXY_AUTHENTICATE = 'Proxy-Authenticate'; 99 | /** 100 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Public-Key-Pins 101 | */ 102 | public const string PUBLIC_KEY_PINS = 'Public-Key-Pins'; 103 | /** 104 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Public-Key-Pins-Report-Only 105 | */ 106 | public const string PUBLIC_KEY_PINS_REPORT_ONLY = 'Public-Key-Pins-Report-Only'; 107 | /** 108 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy 109 | */ 110 | public const string REFERRER_POLICY = 'Referrer-Policy'; 111 | /** 112 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After 113 | */ 114 | public const string RETRY_AFTER = 'Retry-After'; 115 | /** 116 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Server 117 | */ 118 | public const string SERVER = 'Server'; 119 | /** 120 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie 121 | */ 122 | public const string SET_COOKIE = 'Set-Cookie'; 123 | /** 124 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/SourceMap 125 | */ 126 | public const string SOURCEMAP = 'SourceMap'; 127 | /** 128 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security 129 | */ 130 | public const string STRICT_TRANSPORT_SECURITY = 'Strict-Transport-Security'; 131 | /** 132 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Timing-Allow-Origin 133 | */ 134 | public const string TIMING_ALLOW_ORIGIN = 'Timing-Allow-Origin'; 135 | /** 136 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Tk 137 | */ 138 | public const string TK = 'Tk'; 139 | /** 140 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary 141 | */ 142 | public const string VARY = 'Vary'; 143 | /** 144 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/WWW-Authenticate 145 | */ 146 | public const string WWW_AUTHENTICATE = 'WWW-Authenticate'; 147 | /** 148 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options 149 | */ 150 | public const string X_CONTENT_TYPE_OPTIONS = 'X-Content-Type-Options'; 151 | /** 152 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-DNS-Prefetch-Control 153 | */ 154 | public const string X_DNS_PREFETCH_CONTROL = 'X-DNS-Prefetch-Control'; 155 | /** 156 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options 157 | */ 158 | public const string X_FRAME_OPTIONS = 'X-Frame-Options'; 159 | /** 160 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Robots-Tag 161 | * @see https://developers.google.com/search/docs/crawling-indexing/robots-meta-tag 162 | */ 163 | public const string X_ROBOTS_TAG = 'X-Robots-Tag'; 164 | /** 165 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection 166 | */ 167 | public const string X_XSS_PROTECTION = 'X-XSS-Protection'; 168 | // ------------------------------------------------------------------------- 169 | // Custom 170 | // ------------------------------------------------------------------------- 171 | /** 172 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Powered-By 173 | */ 174 | public const string X_POWERED_BY = 'X-Powered-By'; 175 | } 176 | -------------------------------------------------------------------------------- /src/AntiCSRF.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | use InvalidArgumentException; 13 | use JetBrains\PhpStorm\Pure; 14 | use LogicException; 15 | 16 | /** 17 | * Class AntiCSRF. 18 | * 19 | * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern 20 | * @see https://stackoverflow.com/q/6287903/6027968 21 | * @see https://portswigger.net/web-security/csrf 22 | * @see https://www.netsparker.com/blog/web-security/protecting-website-using-anti-csrf-token/ 23 | * 24 | * @package http 25 | */ 26 | class AntiCSRF 27 | { 28 | protected string $tokenName = 'csrf_token'; 29 | protected Request $request; 30 | protected bool $verified = false; 31 | protected bool $enabled = true; 32 | protected int $tokenBytesLength = 8; 33 | protected string $generateTokenFunction = 'base64_encode'; 34 | /** 35 | * @var array 36 | */ 37 | protected array $generateTokenFunctions = [ 38 | 'base64_encode', 39 | 'bin2hex', 40 | 'md5', 41 | ]; 42 | 43 | /** 44 | * AntiCSRF constructor. 45 | * 46 | * @param Request $request 47 | * @param int|null $tokenBytesLength 48 | * @param string|null $generateTokenFunction 49 | */ 50 | public function __construct( 51 | Request $request, 52 | ?int $tokenBytesLength = null, 53 | ?string $generateTokenFunction = null, 54 | ) { 55 | if (\session_status() !== \PHP_SESSION_ACTIVE) { 56 | throw new LogicException('Session must be active to use AntiCSRF class'); 57 | } 58 | $this->request = $request; 59 | if ($tokenBytesLength !== null) { 60 | $this->setTokenBytesLength($tokenBytesLength); 61 | } 62 | if ($generateTokenFunction !== null) { 63 | $this->setGenerateTokenFunction($generateTokenFunction); 64 | } 65 | if ($this->getToken() === null) { 66 | $this->setToken(); 67 | } 68 | } 69 | 70 | public function setTokenBytesLength(int $length) : static 71 | { 72 | if ($length < 3) { 73 | throw new InvalidArgumentException( 74 | 'AntiCSRF token bytes length must be greater than 2, ' . $length . ' given' 75 | ); 76 | } 77 | $this->tokenBytesLength = $length; 78 | return $this; 79 | } 80 | 81 | #[Pure] 82 | public function getTokenBytesLength() : int 83 | { 84 | return $this->tokenBytesLength; 85 | } 86 | 87 | /** 88 | * Gets the anti-csrf token name. 89 | * 90 | * @return string 91 | */ 92 | #[Pure] 93 | public function getTokenName() : string 94 | { 95 | return $this->tokenName; 96 | } 97 | 98 | /** 99 | * Sets the anti-csrf token name. 100 | * 101 | * @param string $tokenName 102 | * 103 | * @return static 104 | */ 105 | public function setTokenName(string $tokenName) : static 106 | { 107 | $this->tokenName = \htmlspecialchars($tokenName, \ENT_QUOTES | \ENT_HTML5); 108 | return $this; 109 | } 110 | 111 | /** 112 | * Gets the anti-csrf token from the session. 113 | * 114 | * @return string|null 115 | */ 116 | #[Pure] 117 | public function getToken() : ?string 118 | { 119 | return $_SESSION['$']['csrf_token'] ?? null; 120 | } 121 | 122 | /** 123 | * Sets the anti-csrf token into the session. 124 | * 125 | * @param string|null $token A custom anti-csrf token or null to generate one 126 | * 127 | * @return static 128 | */ 129 | public function setToken(?string $token = null) : static 130 | { 131 | $_SESSION['$']['csrf_token'] = $token ?? $this->generateToken(); 132 | return $this; 133 | } 134 | 135 | public function setGenerateTokenFunction(string $function) : static 136 | { 137 | if (!\in_array($function, $this->generateTokenFunctions, true)) { 138 | throw new InvalidArgumentException( 139 | 'Invalid generate token function name: ' . $function 140 | ); 141 | } 142 | $this->generateTokenFunction = $function; 143 | return $this; 144 | } 145 | 146 | #[Pure] 147 | public function getGenerateTokenFunction() : string 148 | { 149 | return $this->generateTokenFunction; 150 | } 151 | 152 | public function generateToken() : string 153 | { 154 | $bytes = \random_bytes($this->getTokenBytesLength()); // @phpstan-ignore-line 155 | return $this->getGenerateTokenFunction()($bytes); // @phpstan-ignore-line 156 | } 157 | 158 | /** 159 | * Gets the user token from the request input form. 160 | * 161 | * @return string|null 162 | */ 163 | public function getUserToken() : ?string 164 | { 165 | $token = $this->request->getParsedBody($this->getTokenName()); 166 | return \is_string($token) ? $token : null; 167 | } 168 | 169 | /** 170 | * Verifies the request input token, if the verification is enabled. 171 | * The verification always succeed on HTTP GET, HEAD and OPTIONS methods. 172 | * If verification is successful with other HTTP methods, a new token is 173 | * generated. 174 | * 175 | * @return bool 176 | */ 177 | public function verify() : bool 178 | { 179 | if ($this->isEnabled() === false) { 180 | return true; 181 | } 182 | if ($this->isSafeMethod()) { 183 | return true; 184 | } 185 | if ($this->getUserToken() === null) { 186 | return false; 187 | } 188 | if (!$this->validate($this->getUserToken())) { 189 | return false; 190 | } 191 | if (!$this->isVerified()) { 192 | $this->setToken(); 193 | $this->setVerified(); 194 | } 195 | return true; 196 | } 197 | 198 | /** 199 | * Safe HTTP Request methods are: GET, HEAD and OPTIONS. 200 | * 201 | * @return bool 202 | */ 203 | #[Pure] 204 | public function isSafeMethod() : bool 205 | { 206 | return \in_array($this->request->getMethod(), [ 207 | Method::GET, 208 | Method::HEAD, 209 | Method::OPTIONS, 210 | ], true); 211 | } 212 | 213 | /** 214 | * Validates if a user token is equals the session token. 215 | * 216 | * This method can be used to validate tokens not received through forms. 217 | * For example: Through a request header, JSON, etc. 218 | * 219 | * @param string $userToken 220 | * 221 | * @return bool 222 | */ 223 | public function validate(string $userToken) : bool 224 | { 225 | return \hash_equals($_SESSION['$']['csrf_token'], $userToken); 226 | } 227 | 228 | #[Pure] 229 | protected function isVerified() : bool 230 | { 231 | return $this->verified; 232 | } 233 | 234 | /** 235 | * @param bool $status 236 | * 237 | * @return static 238 | */ 239 | protected function setVerified(bool $status = true) : static 240 | { 241 | $this->verified = $status; 242 | return $this; 243 | } 244 | 245 | /** 246 | * Gets the HTML form hidden input if the verification is enabled. 247 | * 248 | * @return string 249 | */ 250 | #[Pure] 251 | public function input() : string 252 | { 253 | if ($this->isEnabled() === false) { 254 | return ''; 255 | } 256 | return ''; 259 | } 260 | 261 | /** 262 | * Tells if the verification is enabled. 263 | * 264 | * @see AntiCSRF::verify() 265 | * 266 | * @return bool 267 | */ 268 | #[Pure] 269 | public function isEnabled() : bool 270 | { 271 | return $this->enabled; 272 | } 273 | 274 | /** 275 | * Enables the Anti CSRF verification. 276 | * 277 | * @see AntiCSRF::verify() 278 | * 279 | * @return static 280 | */ 281 | public function enable() : static 282 | { 283 | $this->enabled = true; 284 | return $this; 285 | } 286 | 287 | /** 288 | * Disables the Anti CSRF verification. 289 | * 290 | * @see AntiCSRF::verify() 291 | * 292 | * @return static 293 | */ 294 | public function disable() : static 295 | { 296 | $this->enabled = false; 297 | return $this; 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/Cookie.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | use DateTime; 13 | use DateTimeInterface; 14 | use DateTimeZone; 15 | use Exception; 16 | use InvalidArgumentException; 17 | use JetBrains\PhpStorm\Pure; 18 | 19 | /** 20 | * Class Cookie. 21 | * 22 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie 23 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies 24 | * @see https://datatracker.ietf.org/doc/html/rfc6265 25 | * @see https://www.php.net/manual/en/function.setcookie.php 26 | * 27 | * @package http 28 | */ 29 | class Cookie implements \Stringable 30 | { 31 | protected ?string $domain = null; 32 | protected ?DateTime $expires = null; 33 | protected bool $httpOnly = false; 34 | protected string $name; 35 | protected ?string $path = null; 36 | protected ?string $sameSite = null; 37 | protected bool $secure = false; 38 | protected string $value; 39 | 40 | /** 41 | * Cookie constructor. 42 | * 43 | * @param string $name 44 | * @param string $value 45 | */ 46 | public function __construct(string $name, string $value) 47 | { 48 | $this->setName($name); 49 | $this->setValue($value); 50 | } 51 | 52 | public function __toString() : string 53 | { 54 | return $this->toString(); 55 | } 56 | 57 | /** 58 | * @since 5.3 59 | * 60 | * @return string 61 | */ 62 | public function toString() : string 63 | { 64 | $string = $this->getName() . '=' . $this->getValue(); 65 | $part = $this->getExpires(); 66 | if ($part !== null) { 67 | $string .= '; expires=' . $this->expires->format(DateTimeInterface::RFC7231); 68 | $string .= '; Max-Age=' . $this->expires->diff(new DateTime('-1 second'))->s; 69 | } 70 | $part = $this->getPath(); 71 | if ($part !== null) { 72 | $string .= '; path=' . $part; 73 | } 74 | $part = $this->getDomain(); 75 | if ($part !== null) { 76 | $string .= '; domain=' . $part; 77 | } 78 | $part = $this->isSecure(); 79 | if ($part) { 80 | $string .= '; secure'; 81 | } 82 | $part = $this->isHttpOnly(); 83 | if ($part) { 84 | $string .= '; HttpOnly'; 85 | } 86 | $part = $this->getSameSite(); 87 | if ($part !== null) { 88 | $string .= '; SameSite=' . $part; 89 | } 90 | return $string; 91 | } 92 | 93 | /** 94 | * @return string|null 95 | */ 96 | #[Pure] 97 | public function getDomain() : ?string 98 | { 99 | return $this->domain; 100 | } 101 | 102 | /** 103 | * @param string|null $domain 104 | * 105 | * @return static 106 | */ 107 | public function setDomain(?string $domain) : static 108 | { 109 | $this->domain = $domain; 110 | return $this; 111 | } 112 | 113 | /** 114 | * @return DateTime|null 115 | */ 116 | #[Pure] 117 | public function getExpires() : ?DateTime 118 | { 119 | return $this->expires; 120 | } 121 | 122 | /** 123 | * @param DateTime|int|string|null $expires 124 | * 125 | * @throws Exception if can not create from format 126 | * 127 | * @return static 128 | */ 129 | public function setExpires(DateTime | int | string | null $expires) : static 130 | { 131 | if ($expires instanceof DateTime) { 132 | $expires = clone $expires; 133 | $expires->setTimezone(new DateTimeZone('UTC')); 134 | } elseif (\is_numeric($expires)) { 135 | $expires = DateTime::createFromFormat('U', (string) $expires, new DateTimeZone('UTC')); 136 | } elseif ($expires !== null) { 137 | $expires = new DateTime($expires, new DateTimeZone('UTC')); 138 | } 139 | $this->expires = $expires; // @phpstan-ignore-line 140 | return $this; 141 | } 142 | 143 | /** 144 | * @return bool 145 | */ 146 | public function isExpired() : bool 147 | { 148 | return $this->getExpires() && \time() > $this->getExpires()->getTimestamp(); 149 | } 150 | 151 | /** 152 | * @return string 153 | */ 154 | #[Pure] 155 | public function getName() : string 156 | { 157 | return $this->name; 158 | } 159 | 160 | /** 161 | * @param string $name 162 | * 163 | * @return static 164 | */ 165 | protected function setName(string $name) : static 166 | { 167 | $this->name = $name; 168 | return $this; 169 | } 170 | 171 | /** 172 | * @return string|null 173 | */ 174 | #[Pure] 175 | public function getPath() : ?string 176 | { 177 | return $this->path; 178 | } 179 | 180 | /** 181 | * @param string|null $path 182 | * 183 | * @return static 184 | */ 185 | public function setPath(?string $path) : static 186 | { 187 | $this->path = $path; 188 | return $this; 189 | } 190 | 191 | /** 192 | * @return string|null 193 | */ 194 | #[Pure] 195 | public function getSameSite() : ?string 196 | { 197 | return $this->sameSite; 198 | } 199 | 200 | /** 201 | * @param string|null $sameSite Strict, Lax, Unset or None 202 | * 203 | * @throws InvalidArgumentException for invalid $sameSite value 204 | * 205 | * @return static 206 | */ 207 | public function setSameSite(?string $sameSite) : static 208 | { 209 | if ($sameSite !== null) { 210 | $sameSite = \ucfirst(\strtolower($sameSite)); 211 | if (!\in_array($sameSite, ['Strict', 'Lax', 'Unset', 'None'])) { 212 | throw new InvalidArgumentException('SameSite must be Strict, Lax, Unset or None'); 213 | } 214 | } 215 | $this->sameSite = $sameSite; 216 | return $this; 217 | } 218 | 219 | /** 220 | * @return string 221 | */ 222 | #[Pure] 223 | public function getValue() : string 224 | { 225 | return $this->value; 226 | } 227 | 228 | /** 229 | * @param string $value 230 | * 231 | * @return static 232 | */ 233 | public function setValue(string $value) : static 234 | { 235 | $this->value = $value; 236 | return $this; 237 | } 238 | 239 | /** 240 | * @param bool $httpOnly 241 | * 242 | * @return static 243 | */ 244 | public function setHttpOnly(bool $httpOnly = true) : static 245 | { 246 | $this->httpOnly = $httpOnly; 247 | return $this; 248 | } 249 | 250 | /** 251 | * @return bool 252 | */ 253 | #[Pure] 254 | public function isHttpOnly() : bool 255 | { 256 | return $this->httpOnly; 257 | } 258 | 259 | /** 260 | * @param bool $secure 261 | * 262 | * @return static 263 | */ 264 | public function setSecure(bool $secure = true) : static 265 | { 266 | $this->secure = $secure; 267 | return $this; 268 | } 269 | 270 | /** 271 | * @return bool 272 | */ 273 | #[Pure] 274 | public function isSecure() : bool 275 | { 276 | return $this->secure; 277 | } 278 | 279 | /** 280 | * @return bool 281 | */ 282 | public function send() : bool 283 | { 284 | $options = []; 285 | $value = $this->getExpires(); 286 | if ($value) { 287 | $options['expires'] = $value->getTimestamp(); 288 | } 289 | $value = $this->getPath(); 290 | if ($value !== null) { 291 | $options['path'] = $value; 292 | } 293 | $value = $this->getDomain(); 294 | if ($value !== null) { 295 | $options['domain'] = $value; 296 | } 297 | $options['secure'] = $this->isSecure(); 298 | $options['httponly'] = $this->isHttpOnly(); 299 | $value = $this->getSameSite(); 300 | if ($value !== null) { 301 | $options['samesite'] = $value; 302 | } 303 | // @phpstan-ignore-next-line 304 | return \setcookie($this->getName(), $this->getValue(), $options); 305 | } 306 | 307 | /** 308 | * Parses a Set-Cookie Header line and creates a new Cookie object. 309 | * 310 | * @param string $line 311 | * 312 | * @throws Exception if setExpires fail 313 | * 314 | * @return Cookie|null 315 | */ 316 | public static function parse(string $line) : ?Cookie 317 | { 318 | $parts = \array_map('\trim', \explode(';', $line, 20)); 319 | $cookie = null; 320 | foreach ($parts as $key => $part) { 321 | [$arg, $val] = static::makeArgumentValue($part); 322 | if ($key === 0) { 323 | if (isset($arg, $val)) { 324 | $cookie = new Cookie($arg, $val); 325 | continue; 326 | } 327 | break; 328 | } 329 | if ($arg === null) { 330 | continue; 331 | } 332 | switch (\strtolower($arg)) { 333 | case 'expires': 334 | $cookie->setExpires($val); 335 | break; 336 | case 'domain': 337 | $cookie->setDomain($val); 338 | break; 339 | case 'path': 340 | $cookie->setPath($val); 341 | break; 342 | case 'httponly': 343 | $cookie->setHttpOnly(); 344 | break; 345 | case 'secure': 346 | $cookie->setSecure(); 347 | break; 348 | case 'samesite': 349 | $cookie->setSameSite($val); 350 | break; 351 | } 352 | } 353 | return $cookie; 354 | } 355 | 356 | /** 357 | * Create Cookie objects from a Cookie Header line. 358 | * 359 | * @param string $line 360 | * 361 | * @return array 362 | */ 363 | public static function create(string $line) : array 364 | { 365 | $items = \array_map('\trim', \explode(';', $line, 3000)); 366 | $cookies = []; 367 | foreach ($items as $item) { 368 | [$name, $value] = static::makeArgumentValue($item); 369 | if (isset($name, $value)) { 370 | $cookies[$name] = new Cookie($name, $value); 371 | } 372 | } 373 | return $cookies; 374 | } 375 | 376 | /** 377 | * @param string $part 378 | * 379 | * @return array 380 | */ 381 | protected static function makeArgumentValue(string $part) : array 382 | { 383 | $part = \array_pad(\explode('=', $part, 2), 2, null); 384 | if ($part[0] !== null) { 385 | $part[0] = static::trimmedOrNull($part[0]); 386 | } 387 | if ($part[1] !== null) { 388 | $part[1] = static::trimmedOrNull($part[1]); 389 | } 390 | return $part; 391 | } 392 | 393 | protected static function trimmedOrNull(string $value) : ?string 394 | { 395 | $value = \trim($value); 396 | if ($value === '') { 397 | $value = null; 398 | } 399 | return $value; 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /src/HeaderTrait.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | /** 13 | * Trait HeaderTrait. 14 | * 15 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers 16 | * 17 | * @package http 18 | */ 19 | trait HeaderTrait 20 | { 21 | // ------------------------------------------------------------------------- 22 | // General headers (Request and Response) 23 | // ------------------------------------------------------------------------- 24 | /** 25 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control 26 | */ 27 | public const string CACHE_CONTROL = 'Cache-Control'; 28 | /** 29 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection 30 | */ 31 | public const string CONNECTION = 'Connection'; 32 | /** 33 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition 34 | */ 35 | public const string CONTENT_DISPOSITION = 'Content-Disposition'; 36 | /** 37 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Date 38 | */ 39 | public const string DATE = 'Date'; 40 | /** 41 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Keep-Alive 42 | */ 43 | public const string KEEP_ALIVE = 'Keep-Alive'; 44 | /** 45 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Pragma 46 | */ 47 | public const string PRAGMA = 'Pragma'; 48 | /** 49 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Via 50 | */ 51 | public const string VIA = 'Via'; 52 | /** 53 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Warning 54 | */ 55 | public const string WARNING = 'Warning'; 56 | // ------------------------------------------------------------------------- 57 | // Representation headers (Request and Response) 58 | // ------------------------------------------------------------------------- 59 | /** 60 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding 61 | */ 62 | public const string CONTENT_ENCODING = 'Content-Encoding'; 63 | /** 64 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Language 65 | */ 66 | public const string CONTENT_LANGUAGE = 'Content-Language'; 67 | /** 68 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Location 69 | */ 70 | public const string CONTENT_LOCATION = 'Content-Location'; 71 | /** 72 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type 73 | */ 74 | public const string CONTENT_TYPE = 'Content-Type'; 75 | // ------------------------------------------------------------------------- 76 | // Payload headers (Request and Response) 77 | // ------------------------------------------------------------------------- 78 | /** 79 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length 80 | */ 81 | public const string CONTENT_LENGTH = 'Content-Length'; 82 | /** 83 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range 84 | */ 85 | public const string CONTENT_RANGE = 'Content-Range'; 86 | /** 87 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link 88 | */ 89 | public const string LINK = 'Link'; 90 | /** 91 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Trailer 92 | */ 93 | public const string TRAILER = 'Trailer'; 94 | /** 95 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Transfer-Encoding 96 | */ 97 | public const string TRANSFER_ENCODING = 'Transfer-Encoding'; 98 | /** 99 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Upgrade 100 | */ 101 | public const string UPGRADE = 'Upgrade'; 102 | // ------------------------------------------------------------------------- 103 | // Custom 104 | // ------------------------------------------------------------------------- 105 | /** 106 | * @see https://riptutorial.com/http-headers/topic/10581/x-request-id 107 | */ 108 | public const string X_REQUEST_ID = 'X-Request-ID'; 109 | /** 110 | * Header names. 111 | * 112 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers 113 | * 114 | * @var array 115 | */ 116 | protected static array $headers = [ 117 | // --------------------------------------------------------------------- 118 | // General headers (Request and Response) 119 | // --------------------------------------------------------------------- 120 | 'cache-control' => 'Cache-Control', 121 | 'connection' => 'Connection', 122 | 'content-disposition' => 'Content-Disposition', 123 | 'date' => 'Date', 124 | 'keep-alive' => 'Keep-Alive', 125 | 'link' => 'Link', 126 | 'pragma' => 'Pragma', 127 | 'via' => 'Via', 128 | 'warning' => 'Warning', 129 | // --------------------------------------------------------------------- 130 | // Representation headers (Request and Response) 131 | // --------------------------------------------------------------------- 132 | 'content-encoding' => 'Content-Encoding', 133 | 'content-language' => 'Content-Language', 134 | 'content-location' => 'Content-Location', 135 | 'content-type' => 'Content-Type', 136 | // --------------------------------------------------------------------- 137 | // Payload headers (Request and Response) 138 | // --------------------------------------------------------------------- 139 | 'content-length' => 'Content-Length', 140 | 'content-range' => 'Content-Range', 141 | 'trailer' => 'Trailer', 142 | 'transfer-encoding' => 'Transfer-Encoding', 143 | // --------------------------------------------------------------------- 144 | // Request headers 145 | // --------------------------------------------------------------------- 146 | 'accept' => 'Accept', 147 | 'accept-charset' => 'Accept-Charset', 148 | 'accept-encoding' => 'Accept-Encoding', 149 | 'accept-language' => 'Accept-Language', 150 | 'access-control-request-headers' => 'Access-Control-Request-Headers', 151 | 'access-control-request-method' => 'Access-Control-Request-Method', 152 | 'authorization' => 'Authorization', 153 | 'cdn-loop' => 'CDN-Loop', 154 | 'cookie' => 'Cookie', 155 | 'dnt' => 'DNT', 156 | 'expect' => 'Expect', 157 | 'forwarded' => 'Forwarded', 158 | 'from' => 'From', 159 | 'host' => 'Host', 160 | 'if-match' => 'If-Match', 161 | 'if-modified-since' => 'If-Modified-Since', 162 | 'if-none-match' => 'If-None-Match', 163 | 'if-range' => 'If-Range', 164 | 'if-unmodified-since' => 'If-Unmodified-Since', 165 | 'origin' => 'Origin', 166 | 'priority' => 'Priority', 167 | 'proxy-authorization' => 'Proxy-Authorization', 168 | 'range' => 'Range', 169 | 'referer' => 'Referer', 170 | 'sec-fetch-dest' => 'Sec-Fetch-Dest', 171 | 'sec-fetch-mode' => 'Sec-Fetch-Mode', 172 | 'sec-fetch-site' => 'Sec-Fetch-Site', 173 | 'sec-fetch-user' => 'Sec-Fetch-User', 174 | 'te' => 'TE', 175 | 'upgrade-insecure-requests' => 'Upgrade-Insecure-Requests', 176 | 'user-agent' => 'User-Agent', 177 | 'x-forwarded-for' => 'X-Forwarded-For', 178 | 'x-forwarded-host' => 'X-Forwarded-Host', 179 | 'x-forwarded-proto' => 'X-Forwarded-Proto', 180 | 'x-real-ip' => 'X-Real-IP', 181 | 'x-requested-with' => 'X-Requested-With', 182 | // --------------------------------------------------------------------- 183 | // Response headers 184 | // --------------------------------------------------------------------- 185 | 'accept-ranges' => 'Accept-Ranges', 186 | 'access-control-allow-credentials' => 'Access-Control-Allow-Credentials', 187 | 'access-control-allow-headers' => 'Access-Control-Allow-Headers', 188 | 'access-control-allow-methods' => 'Access-Control-Allow-Methods', 189 | 'access-control-allow-origin' => 'Access-Control-Allow-Origin', 190 | 'access-control-expose-headers' => 'Access-Control-Expose-Headers', 191 | 'access-control-max-age' => 'Access-Control-Max-Age', 192 | 'age' => 'Age', 193 | 'allow' => 'Allow', 194 | 'clear-site-data' => 'Clear-Site-Data', 195 | 'content-security-policy' => 'Content-Security-Policy', 196 | 'content-security-policy-report-only' => 'Content-Security-Policy-Report-Only', 197 | 'etag' => 'ETag', 198 | 'expect-ct' => 'Expect-CT', 199 | 'expires' => 'Expires', 200 | 'feature-policy' => 'Feature-Policy', 201 | 'last-modified' => 'Last-Modified', 202 | 'location' => 'Location', 203 | 'proxy-authenticate' => 'Proxy-Authenticate', 204 | 'public-key-pins' => 'Public-Key-Pins', 205 | 'public-key-pins-report-only' => 'Public-Key-Pins-Report-Only', 206 | 'referrer-policy' => 'Referrer-Policy', 207 | 'retry-after' => 'Retry-After', 208 | 'server' => 'Server', 209 | 'set-cookie' => 'Set-Cookie', 210 | 'sourcemap' => 'SourceMap', 211 | 'strict-transport-security' => 'Strict-Transport-Security', 212 | 'timing-allow-origin' => 'Timing-Allow-Origin', 213 | 'tk' => 'Tk', 214 | 'vary' => 'Vary', 215 | 'www-authenticate' => 'WWW-Authenticate', 216 | 'x-content-type-options' => 'X-Content-Type-Options', 217 | 'x-dns-prefetch-control' => 'X-DNS-Prefetch-Control', 218 | 'x-frame-options' => 'X-Frame-Options', 219 | 'x-robots-tag' => 'X-Robots-Tag', 220 | 'x-xss-protection' => 'X-XSS-Protection', 221 | // --------------------------------------------------------------------- 222 | // Custom (Response) 223 | // --------------------------------------------------------------------- 224 | 'x-request-id' => 'X-Request-ID', 225 | 'x-powered-by' => 'X-Powered-By', 226 | // --------------------------------------------------------------------- 227 | // WebSocket 228 | // --------------------------------------------------------------------- 229 | 'sec-websocket-extensions' => 'Sec-WebSocket-Extensions', 230 | 'sec-websocket-key' => 'Sec-WebSocket-Key', 231 | 'sec-websocket-protocol' => 'Sec-WebSocket-Protocol', 232 | 'sec-websocket-version' => 'Sec-WebSocket-Version', 233 | ]; 234 | 235 | public static function getName(string $name) : string 236 | { 237 | return static::$headers[\strtolower($name)] ?? $name; 238 | } 239 | 240 | public static function setName(string $name) : void 241 | { 242 | static::$headers[\strtolower($name)] = $name; 243 | } 244 | 245 | /** 246 | * @return array 247 | */ 248 | public static function getMultilines() : array 249 | { 250 | return [ 251 | 'cdn-loop', 252 | 'date', 253 | 'expires', 254 | 'if-modified-since', 255 | 'if-range', 256 | 'if-unmodified-since', 257 | 'last-modified', 258 | 'proxy-authenticate', 259 | 'retry-after', 260 | 'set-cookie', 261 | 'x-robots-tag', 262 | 'www-authenticate', 263 | ]; 264 | } 265 | 266 | public static function isMultiline(string $name) : bool 267 | { 268 | return \in_array(\strtolower($name), static::getMultilines(), true); 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/ResponseDownload.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | use InvalidArgumentException; 13 | use JetBrains\PhpStorm\Pure; 14 | use RuntimeException; 15 | 16 | /** 17 | * Trait ResponseDownload. 18 | * 19 | * @see https://datatracker.ietf.org/doc/html/rfc7233 20 | * 21 | * @property Request $request 22 | * 23 | * @package http 24 | */ 25 | trait ResponseDownload 26 | { 27 | private string $filepath; 28 | private int $filesize; 29 | private bool $acceptRanges = true; 30 | /** 31 | * @var array>|false 32 | */ 33 | private array | false $byteRanges = []; 34 | private string $sendType = 'normal'; 35 | private string $boundary; 36 | /** 37 | * @var resource 38 | */ 39 | private $handle; 40 | private int $delay = 0; 41 | private int $readLength = 1024; 42 | 43 | /** 44 | * Sets a file to download/stream. 45 | * 46 | * @param string $filepath 47 | * @param bool $inline Set Content-Disposition header as "inline". Browsers 48 | * load the file in the window. Set true to allow video or audio streams 49 | * @param bool $acceptRanges Set Accept-Ranges header to "bytes". Allow 50 | * partial downloads, media players to move the time position forward and 51 | * back and download managers to continue/download multi-parts 52 | * @param int $delay Delay between flushs in microseconds 53 | * @param int $readLength Bytes read by flush 54 | * @param string|null $filename A custom filename 55 | * 56 | * @throws InvalidArgumentException If invalid file path 57 | * @throws RuntimeException If can not get the file size or modification time 58 | * 59 | * @return static 60 | */ 61 | public function setDownload( 62 | string $filepath, 63 | bool $inline = false, 64 | bool $acceptRanges = true, 65 | int $delay = 0, 66 | int $readLength = 1024, 67 | ?string $filename = null 68 | ) : static { 69 | $realpath = \realpath($filepath); 70 | if ($realpath === false || !\is_file($realpath)) { 71 | throw new InvalidArgumentException('Invalid file path: ' . $filepath); 72 | } 73 | $this->filepath = $realpath; 74 | $this->delay = $delay; 75 | $this->readLength = $readLength; 76 | $filesize = @\filesize($this->filepath); 77 | if ($filesize === false) { 78 | throw new RuntimeException( 79 | "Could not get the file size of '{$this->filepath}'" 80 | ); 81 | } 82 | $this->filesize = $filesize; 83 | $filemtime = \filemtime($this->filepath); 84 | if ($filemtime === false) { 85 | throw new RuntimeException( 86 | "Could not get the file modification time of '{$this->filepath}'" 87 | ); 88 | } 89 | $this->setHeader(ResponseHeader::LAST_MODIFIED, \gmdate(\DATE_RFC7231, $filemtime)); 90 | $filename ??= \basename($filepath); 91 | $filename = \htmlspecialchars($filename, \ENT_QUOTES | \ENT_HTML5); 92 | $filename = \strtr($filename, ['/' => '_', '\\' => '_']); 93 | $this->setHeader( 94 | ResponseHeader::CONTENT_DISPOSITION, 95 | $inline ? 'inline' : \sprintf('attachment; filename="%s"', $filename) 96 | ); 97 | $this->setAcceptRanges($acceptRanges); 98 | if ($acceptRanges) { 99 | $rangeLine = $this->request->getHeader(RequestHeader::RANGE); 100 | if ($rangeLine) { 101 | $this->prepareRange($rangeLine); 102 | return $this; 103 | } 104 | } 105 | $this->setHeader(ResponseHeader::CONTENT_LENGTH, (string) $this->filesize); 106 | $this->setHeader( 107 | ResponseHeader::CONTENT_TYPE, 108 | \mime_content_type($this->filepath) ?: 'application/octet-stream' 109 | ); 110 | $this->sendType = 'normal'; 111 | return $this; 112 | } 113 | 114 | private function prepareRange(string $rangeLine) : void 115 | { 116 | $this->byteRanges = $this->parseByteRange($rangeLine); 117 | if ($this->byteRanges === false) { 118 | // https://datatracker.ietf.org/doc/html/rfc7233#section-4.2 119 | $this->setStatus(Status::RANGE_NOT_SATISFIABLE); 120 | $this->setHeader(ResponseHeader::CONTENT_RANGE, '*/' . $this->filesize); 121 | return; 122 | } 123 | $this->setStatus(Status::PARTIAL_CONTENT); 124 | if (\count($this->byteRanges) === 1) { 125 | $this->setSinglePart(...$this->byteRanges[0]); 126 | return; 127 | } 128 | $this->setMultiPart(...$this->byteRanges); 129 | } 130 | 131 | private function setAcceptRanges(bool $acceptRanges) : void 132 | { 133 | $this->acceptRanges = $acceptRanges; 134 | $this->setHeader( 135 | ResponseHeader::ACCEPT_RANGES, 136 | $acceptRanges ? 'bytes' : 'none' 137 | ); 138 | } 139 | 140 | /** 141 | * Parse the HTTP Range Header line. 142 | * 143 | * Returns arrays of two indexes, representing first-byte-pos and last-byte-pos. 144 | * If return false, the Byte Ranges are invalid, so the Response must return 145 | * a 416 (Range Not Satisfiable) status. 146 | * 147 | * @see https://datatracker.ietf.org/doc/html/rfc7233#section-2.1 148 | * @see https://datatracker.ietf.org/doc/html/rfc7233#section-4.4 149 | * 150 | * @param string $line 151 | * 152 | * @return array>|false 153 | * 154 | * @phpstan-ignore-next-line 155 | */ 156 | #[Pure] 157 | private function parseByteRange(string $line) : array | false 158 | { 159 | if (!\str_starts_with($line, 'bytes=')) { 160 | return false; 161 | } 162 | $line = \substr($line, 6); 163 | $ranges = \explode(',', $line, 100); 164 | foreach ($ranges as &$range) { 165 | $range = \array_pad(\explode('-', $range, 2), 2, null); 166 | if ($range[0] === null || $range[1] === null) { 167 | return false; 168 | } 169 | if ($range[0] === '') { 170 | $range[1] = $this->validBytePos($range[1]); 171 | if ($range[1] === false) { 172 | return false; 173 | } 174 | $range[0] = $this->filesize - $range[1]; 175 | $range[1] = $this->filesize - 1; 176 | continue; 177 | } 178 | $range[0] = $this->validBytePos($range[0]); 179 | if ($range[0] === false) { 180 | return false; 181 | } 182 | if ($range[1] === '') { 183 | $range[1] = $this->filesize - 1; 184 | continue; 185 | } 186 | $range[1] = $this->validBytePos($range[1]); 187 | if ($range[1] === false) { 188 | return false; 189 | } 190 | } 191 | // @phpstan-ignore-next-line 192 | return $ranges; 193 | } 194 | 195 | /** 196 | * @param string $pos 197 | * 198 | * @return false|int 199 | */ 200 | #[Pure] 201 | private function validBytePos(string $pos) : false | int 202 | { 203 | if (!\is_numeric($pos) || $pos < \PHP_INT_MIN || $pos > \PHP_INT_MAX) { 204 | return false; 205 | } 206 | if ($pos < 0 || $pos >= $this->filesize) { 207 | return false; 208 | } 209 | return (int) $pos; 210 | } 211 | 212 | private function setSinglePart(int $firstByte, int $lastByte) : void 213 | { 214 | $this->sendType = 'single'; 215 | $this->setHeader( 216 | ResponseHeader::CONTENT_LENGTH, 217 | (string) ($lastByte - $firstByte + 1) 218 | ); 219 | $this->setHeader( 220 | ResponseHeader::CONTENT_TYPE, 221 | \mime_content_type($this->filepath) ?: 'application/octet-stream' 222 | ); 223 | $this->setHeader( 224 | ResponseHeader::CONTENT_RANGE, 225 | \sprintf('bytes %d-%d/%d', $firstByte, $lastByte, $this->filesize) 226 | ); 227 | } 228 | 229 | private function sendSinglePart() : void 230 | { 231 | // @phpstan-ignore-next-line 232 | $this->readBuffer($this->byteRanges[0][0], $this->byteRanges[0][1]); 233 | //$this->readFile(); 234 | } 235 | 236 | /** 237 | * @param array ...$byteRanges 238 | */ 239 | private function setMultiPart(array ...$byteRanges) : void 240 | { 241 | $this->sendType = 'multi'; 242 | $this->boundary = \md5($this->filepath); 243 | $length = 0; 244 | $topLength = \strlen($this->getMultiPartTopLine()); 245 | foreach ($byteRanges as $range) { 246 | $length += $topLength; 247 | $length += \strlen($this->getContentRangeLine($range[0], $range[1])); 248 | $length += $range[1] - $range[0] + 1; 249 | } 250 | $length += \strlen($this->getBoundaryLine()); 251 | $this->setHeader(ResponseHeader::CONTENT_LENGTH, (string) $length); 252 | $this->setHeader( 253 | ResponseHeader::CONTENT_TYPE, 254 | "multipart/x-byteranges; boundary={$this->boundary}" 255 | ); 256 | } 257 | 258 | private function sendMultiPart() : void 259 | { 260 | $topLine = $this->getMultiPartTopLine(); 261 | foreach ((array) $this->byteRanges as $range) { 262 | echo $topLine; 263 | echo $this->getContentRangeLine($range[0], $range[1]); // @phpstan-ignore-line 264 | $this->readBuffer($range[0], $range[1]); // @phpstan-ignore-line 265 | } 266 | echo $this->getBoundaryLine(); 267 | if ($this->inToString) { 268 | $this->appendBody(''); 269 | } 270 | } 271 | 272 | #[Pure] 273 | private function getBoundaryLine() : string 274 | { 275 | return "\r\n--{$this->boundary}--\r\n"; 276 | } 277 | 278 | #[Pure] 279 | private function getMultiPartTopLine() : string 280 | { 281 | return $this->getBoundaryLine() 282 | . "Content-Type: application/octet-stream\r\n"; 283 | } 284 | 285 | #[Pure] 286 | private function getContentRangeLine(int $fistByte, int $lastByte) : string 287 | { 288 | return \sprintf( 289 | "Content-Range: bytes %d-%d/%d\r\n\r\n", 290 | $fistByte, 291 | $lastByte, 292 | $this->filesize 293 | ); 294 | } 295 | 296 | private function readBuffer(int $firstByte, int $lastByte) : void 297 | { 298 | \fseek($this->handle, $firstByte); 299 | $bytesLeft = $lastByte - $firstByte + 1; 300 | while ($bytesLeft > 0 && !\feof($this->handle)) { 301 | $bytesRead = $bytesLeft > $this->readLength ? $this->readLength : $bytesLeft; 302 | $bytesLeft -= $bytesRead; 303 | $this->flush($bytesRead); 304 | if (\connection_status() !== \CONNECTION_NORMAL) { 305 | break; 306 | } 307 | } 308 | } 309 | 310 | private function flush(int $length) : void 311 | { 312 | echo \fread($this->handle, $length); // @phpstan-ignore-line 313 | if ($this->inToString) { 314 | $this->appendBody(''); 315 | return; 316 | } 317 | \ob_flush(); 318 | \flush(); 319 | if ($this->delay) { 320 | \usleep($this->delay); 321 | } 322 | } 323 | 324 | private function readFile() : void 325 | { 326 | while (!\feof($this->handle)) { 327 | $this->flush($this->readLength); 328 | if (\connection_status() !== \CONNECTION_NORMAL) { 329 | break; 330 | } 331 | } 332 | } 333 | 334 | /** 335 | * Tell if Response has a downloadable file. 336 | * 337 | * @return bool 338 | */ 339 | #[Pure] 340 | public function hasDownload() : bool 341 | { 342 | return isset($this->filepath); 343 | } 344 | 345 | protected function sendDownload() : void 346 | { 347 | $handle = \fopen($this->filepath, 'rb'); 348 | if ($handle === false) { 349 | throw new RuntimeException( 350 | "Could not open a resource for file '{$this->filepath}'" 351 | ); 352 | } 353 | $this->handle = $handle; 354 | switch ($this->sendType) { 355 | case 'multi': 356 | $this->sendMultiPart(); 357 | break; 358 | case 'single': 359 | $this->sendSinglePart(); 360 | break; 361 | default: 362 | $this->readFile(); 363 | } 364 | \fclose($this->handle); 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /src/URL.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | use InvalidArgumentException; 13 | use JetBrains\PhpStorm\ArrayShape; 14 | use JetBrains\PhpStorm\Pure; 15 | use RuntimeException; 16 | 17 | /** 18 | * Class URL. 19 | * 20 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Identifying_resources_on_the_Web#urls 21 | * @see https://developer.mozilla.org/en-US/docs/Web/API/URL 22 | * @see https://datatracker.ietf.org/doc/html/rfc3986#section-3 23 | * 24 | * @package http 25 | */ 26 | class URL implements \JsonSerializable, \Stringable 27 | { 28 | /** 29 | * The #fragment (id). 30 | */ 31 | protected ?string $fragment = null; 32 | protected ?string $hostname = null; 33 | protected ?string $pass = null; 34 | /** 35 | * The /paths/of/url. 36 | * 37 | * @var array 38 | */ 39 | protected array $pathSegments = []; 40 | protected ?int $port = null; 41 | /** 42 | * The ?queries. 43 | * 44 | * @var array 45 | */ 46 | protected array $queryData = []; 47 | protected ?string $scheme = null; 48 | protected ?string $user = null; 49 | 50 | /** 51 | * URL constructor. 52 | * 53 | * @param string $url An absolute URL 54 | */ 55 | public function __construct(string $url) 56 | { 57 | $this->setUrl($url); 58 | } 59 | 60 | /** 61 | * @return string 62 | */ 63 | public function __toString() : string 64 | { 65 | return $this->toString(); 66 | } 67 | 68 | /** 69 | * @param string $query 70 | * @param int|string|null $value 71 | * 72 | * @return static 73 | */ 74 | public function addQuery(string $query, int | string | null $value = null) : static 75 | { 76 | $this->queryData[$query] = $value; 77 | return $this; 78 | } 79 | 80 | /** 81 | * @param array $queries 82 | * 83 | * @return static 84 | */ 85 | public function addQueries(array $queries) : static 86 | { 87 | foreach ($queries as $name => $value) { 88 | $this->addQuery($name, $value); 89 | } 90 | return $this; 91 | } 92 | 93 | /** 94 | * @param array $allowed 95 | * 96 | * @return array 97 | */ 98 | #[Pure] 99 | protected function filterQuery(array $allowed) : array 100 | { 101 | return $this->queryData ? 102 | \array_intersect_key($this->queryData, \array_flip($allowed)) 103 | : []; 104 | } 105 | 106 | #[Pure] 107 | public function getBaseUrl(string $path = '/') : string 108 | { 109 | if ($path && $path !== '/') { 110 | $path = '/' . \ltrim($path, '/'); 111 | } 112 | return $this->getOrigin() . $path; 113 | } 114 | 115 | /** 116 | * @return string|null 117 | */ 118 | public function getFragment() : ?string 119 | { 120 | return $this->fragment; 121 | } 122 | 123 | /** 124 | * @return string|null 125 | */ 126 | #[Pure] 127 | public function getHost() : ?string 128 | { 129 | return $this->hostname === null ? null : $this->hostname . $this->getPortPart(); 130 | } 131 | 132 | #[Pure] 133 | public function getHostname() : ?string 134 | { 135 | return $this->hostname; 136 | } 137 | 138 | #[Pure] 139 | public function getOrigin() : string 140 | { 141 | return $this->getScheme() . '://' . $this->getHost(); 142 | } 143 | 144 | /** 145 | * @return array 146 | */ 147 | #[ArrayShape([ 148 | 'scheme' => 'string', 149 | 'user' => 'null|string', 150 | 'pass' => 'null|string', 151 | 'hostname' => 'string', 152 | 'port' => 'int|null', 153 | 'path' => 'string[]', 154 | 'query' => 'mixed[]', 155 | 'fragment' => 'null|string', 156 | ])] 157 | #[Pure] 158 | public function getParsedUrl() : array 159 | { 160 | return [ 161 | 'scheme' => $this->getScheme(), 162 | 'user' => $this->getUser(), 163 | 'pass' => $this->getPass(), 164 | 'hostname' => $this->getHostname(), 165 | 'port' => $this->getPort(), 166 | 'path' => $this->getPathSegments(), 167 | 'query' => $this->getQueryData(), 168 | 'fragment' => $this->getFragment(), 169 | ]; 170 | } 171 | 172 | /** 173 | * @return string|null 174 | */ 175 | #[Pure] 176 | public function getPass() : ?string 177 | { 178 | return $this->pass; 179 | } 180 | 181 | #[Pure] 182 | public function getPath() : string 183 | { 184 | return '/' . \implode('/', $this->pathSegments); 185 | } 186 | 187 | /** 188 | * @return array 189 | */ 190 | #[Pure] 191 | public function getPathSegments() : array 192 | { 193 | return $this->pathSegments; 194 | } 195 | 196 | #[Pure] 197 | public function getPathSegment(int $index) : ?string 198 | { 199 | return $this->pathSegments[$index] ?? null; 200 | } 201 | 202 | /** 203 | * @return int|null 204 | */ 205 | #[Pure] 206 | public function getPort() : ?int 207 | { 208 | return $this->port; 209 | } 210 | 211 | #[Pure] 212 | protected function getPortPart() : string 213 | { 214 | $part = $this->getPort(); 215 | if (!\in_array($part, [ 216 | null, 217 | 80, 218 | 443, 219 | ], true)) { 220 | return ':' . $part; 221 | } 222 | return ''; 223 | } 224 | 225 | /** 226 | * Get the "Query" part of the URL. 227 | * 228 | * @param array $allowedKeys Allowed query keys 229 | * 230 | * @return string|null 231 | */ 232 | #[Pure] 233 | public function getQuery(array $allowedKeys = []) : ?string 234 | { 235 | $query = $this->getQueryData($allowedKeys); 236 | return $query ? \http_build_query($query) : null; 237 | } 238 | 239 | /** 240 | * @param array $allowedKeys 241 | * 242 | * @return array 243 | */ 244 | #[Pure] 245 | public function getQueryData(array $allowedKeys = []) : array 246 | { 247 | return $allowedKeys ? $this->filterQuery($allowedKeys) : $this->queryData; 248 | } 249 | 250 | /** 251 | * @return string|null 252 | */ 253 | #[Pure] 254 | public function getScheme() : ?string 255 | { 256 | return $this->scheme; 257 | } 258 | 259 | /** 260 | * @since 5.3 261 | * 262 | * @return string 263 | */ 264 | public function toString() : string 265 | { 266 | return $this->getUrl(); 267 | } 268 | 269 | /** 270 | * @since 6.1 271 | * 272 | * @return string 273 | */ 274 | public function getUrl() : string 275 | { 276 | $url = $this->getScheme() . '://'; 277 | $part = $this->getUser(); 278 | if ($part !== null) { 279 | $url .= $part; 280 | $part = $this->getPass(); 281 | if ($part !== null) { 282 | $url .= ':' . $part; 283 | } 284 | $url .= '@'; 285 | } 286 | $url .= $this->getHost(); 287 | $url .= $this->getRelative(); 288 | return $url; 289 | } 290 | 291 | /** 292 | * Get the relative URL. 293 | * 294 | * @since 6.1 295 | * 296 | * @return string 297 | */ 298 | public function getRelative() : string 299 | { 300 | $relative = $this->getPath(); 301 | $part = $this->getQuery(); 302 | if ($part !== null) { 303 | $relative .= '?' . $part; 304 | } 305 | $part = $this->getFragment(); 306 | if ($part !== null) { 307 | $relative .= '#' . $part; 308 | } 309 | return $relative; 310 | } 311 | 312 | /** 313 | * @return string|null 314 | */ 315 | #[Pure] 316 | public function getUser() : ?string 317 | { 318 | return $this->user; 319 | } 320 | 321 | /** 322 | * @param string $key 323 | * 324 | * @return static 325 | */ 326 | public function removeQueryData(string $key) : static 327 | { 328 | unset($this->queryData[$key]); 329 | return $this; 330 | } 331 | 332 | /** 333 | * @param string $fragment 334 | * 335 | * @return static 336 | */ 337 | public function setFragment(string $fragment) : static 338 | { 339 | $this->fragment = \ltrim($fragment, '#'); 340 | return $this; 341 | } 342 | 343 | /** 344 | * @param string $hostname 345 | * 346 | * @throws InvalidArgumentException for invalid URL Hostname 347 | * 348 | * @return static 349 | */ 350 | public function setHostname(string $hostname) : static 351 | { 352 | $filtered = \filter_var($hostname, \FILTER_VALIDATE_DOMAIN, \FILTER_FLAG_HOSTNAME); 353 | if (!$filtered) { 354 | throw new InvalidArgumentException("Invalid URL Hostname: {$hostname}"); 355 | } 356 | $this->hostname = $filtered; 357 | return $this; 358 | } 359 | 360 | /** 361 | * @param string $pass 362 | * 363 | * @return static 364 | */ 365 | public function setPass(string $pass) : static 366 | { 367 | $this->pass = $pass; 368 | return $this; 369 | } 370 | 371 | /** 372 | * @param string $segments 373 | * 374 | * @return static 375 | */ 376 | public function setPath(string $segments) : static 377 | { 378 | return $this->setPathSegments(\explode('/', \ltrim($segments, '/'))); 379 | } 380 | 381 | /** 382 | * @param array $segments 383 | * 384 | * @return static 385 | */ 386 | public function setPathSegments(array $segments) : static 387 | { 388 | $this->pathSegments = $segments; 389 | return $this; 390 | } 391 | 392 | /** 393 | * @param int $port 394 | * 395 | * @throws InvalidArgumentException for invalid URL Port 396 | * 397 | * @return static 398 | */ 399 | public function setPort(int $port) : static 400 | { 401 | if ($port < 1 || $port > 65535) { 402 | throw new InvalidArgumentException("Invalid URL Port: {$port}"); 403 | } 404 | $this->port = $port; 405 | return $this; 406 | } 407 | 408 | /** 409 | * @param string $data 410 | * @param array $only 411 | * 412 | * @return static 413 | */ 414 | public function setQuery(string $data, array $only = []) : static 415 | { 416 | \parse_str(\ltrim($data, '?'), $data); 417 | return $this->setQueryData($data, $only); 418 | } 419 | 420 | /** 421 | * @param array $data 422 | * @param array $only 423 | * 424 | * @return static 425 | */ 426 | public function setQueryData(array $data, array $only = []) : static 427 | { 428 | if ($only) { 429 | $data = \array_intersect_key($data, \array_flip($only)); 430 | } 431 | $this->queryData = $data; 432 | return $this; 433 | } 434 | 435 | /** 436 | * @param string $scheme 437 | * 438 | * @return static 439 | */ 440 | public function setScheme(string $scheme) : static 441 | { 442 | $this->scheme = $scheme; 443 | return $this; 444 | } 445 | 446 | /** 447 | * @param string $url 448 | * 449 | * @throws InvalidArgumentException for invalid URL 450 | * 451 | * @return static 452 | */ 453 | protected function setUrl(string $url) : static 454 | { 455 | $filteredUrl = \filter_var($url, \FILTER_VALIDATE_URL); 456 | if (!$filteredUrl) { 457 | throw new InvalidArgumentException("Invalid URL: {$url}"); 458 | } 459 | $url = \parse_url($filteredUrl); 460 | if ($url === false) { 461 | throw new RuntimeException("URL could not be parsed: {$filteredUrl}"); 462 | } 463 | $this->setScheme($url['scheme']); // @phpstan-ignore-line 464 | if (isset($url['user'])) { 465 | $this->setUser($url['user']); 466 | } 467 | if (isset($url['pass'])) { 468 | $this->setPass($url['pass']); 469 | } 470 | $this->setHostname($url['host']); // @phpstan-ignore-line 471 | if (isset($url['port'])) { 472 | $this->setPort($url['port']); 473 | } 474 | if (isset($url['path'])) { 475 | $this->setPath($url['path']); 476 | } 477 | if (isset($url['query'])) { 478 | $this->setQuery($url['query']); 479 | } 480 | if (isset($url['fragment'])) { 481 | $this->setFragment($url['fragment']); 482 | } 483 | return $this; 484 | } 485 | 486 | /** 487 | * @param string $user 488 | * 489 | * @return static 490 | */ 491 | public function setUser(string $user) : static 492 | { 493 | $this->user = $user; 494 | return $this; 495 | } 496 | 497 | #[Pure] 498 | public function jsonSerialize() : string 499 | { 500 | return $this->toString(); 501 | } 502 | } 503 | -------------------------------------------------------------------------------- /src/Debug/HTTPCollector.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP\Debug; 11 | 12 | use Framework\Debug\Collector; 13 | use Framework\Debug\Debugger; 14 | use Framework\Helpers\ArraySimple; 15 | use Framework\HTTP\Request; 16 | use Framework\HTTP\Response; 17 | 18 | /** 19 | * Class HTTPCollector. 20 | * 21 | * @package http 22 | */ 23 | class HTTPCollector extends Collector 24 | { 25 | protected Request $request; 26 | protected Response $response; 27 | 28 | public function setRequest(Request $request) : static 29 | { 30 | $this->request = $request; 31 | return $this; 32 | } 33 | 34 | public function setResponse(Response $response, bool $replaceRequest = true) : static 35 | { 36 | $this->response = $response; 37 | if ($replaceRequest) { 38 | $this->setRequest($response->getRequest()); 39 | } 40 | return $this; 41 | } 42 | 43 | public function getActivities() : array 44 | { 45 | $activities = []; 46 | foreach ($this->getData() as $data) { 47 | if (isset($data['message'], $data['type']) && 48 | $data['message'] === 'response' && 49 | $data['type'] === 'send' 50 | ) { 51 | $activities[] = [ 52 | 'collector' => $this->getName(), 53 | 'class' => static::class, 54 | 'description' => 'Send response', 55 | 'start' => $data['start'], 56 | 'end' => $data['end'], 57 | ]; 58 | } 59 | } 60 | return $activities; 61 | } 62 | 63 | public function getContents() : string 64 | { 65 | \ob_start(); ?> 66 |

Request

67 | renderRequest() ?> 68 |

Response

69 | renderResponse() ?> 70 | request)) { 77 | return '

A Request instance has not been set on this collector.

'; 78 | } 79 | \ob_start(); ?> 80 |

IP: request->getIp() ?>

81 |

Is Secure: request->isSecure() ? 'Yes' : 'No' ?>

82 |

Protocol: request->getProtocol() ?>

83 |

Method: request->getMethod() ?>

84 |

URL: request->getUrl() ?>

85 |

Server: request->getServer('SERVER_SOFTWARE') ?>

86 |

Hostname:

87 | renderRequestUserAgent() ?> 88 | renderHeadersTable($this->request->getHeaderLines()); 90 | echo $this->renderRequestBody(); 91 | echo $this->renderRequestForm(); 92 | echo $this->renderRequestFiles(); 93 | return \ob_get_clean(); // @phpstan-ignore-line 94 | } 95 | 96 | protected function renderRequestUserAgent() : string 97 | { 98 | $userAgent = $this->request->getUserAgent(); 99 | if ($userAgent === null) { 100 | return ''; 101 | } 102 | \ob_start(); ?> 103 |

User-Agent

104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 121 | 122 | 123 | 124 | 125 |
TypeNameVersionPlatformIs Mobile
getType()) ?>getName()) ?>isBrowser() 119 | ? \htmlentities((string) $userAgent->getBrowserVersion()) 120 | : '' ?>getPlatform()) ?>isMobile() ? 'Yes' : 'No' ?>
126 | request->hasFiles() 133 | ? \http_build_query($this->request->getPost()) 134 | : $this->request->getBody(); 135 | if ($body === '') { 136 | return ''; 137 | } 138 | \ob_start(); ?> 139 |

Body Contents

140 |
143 | request->isPost() && !$this->request->isFormUrlEncoded()) { 150 | return ''; 151 | } 152 | \ob_start(); ?> 153 |

Form

154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | request->getParsedBody()) as $field => $value): ?> 163 | 164 | 165 | 168 | 169 | 170 | 171 |
FieldValue
166 |
167 |
172 | request->hasFiles()) { 179 | return ''; 180 | } 181 | \ob_start(); ?> 182 |

Uploaded Files

183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | request->getFiles()) as $field => $file): ?> 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 |
FieldNameFull PathTypeClient TypeExtensionSizeDestinationError
getName()) ?>getFullPath()) ?>getType()) ?>getClientType()) ?>getExtension()) ?>getSize()) ?>getDestination() ?>getError() ?>getErrorMessage()) ?>
214 | response)) { 221 | return '

A Response instance has not been set on this collector.

'; 222 | } 223 | \ob_start(); ?> 224 |

Protocol: response->getProtocol()) ?>

225 |

Status: response->getStatus()) ?>

226 |

Sent: response->isSent() ? 'Yes' : 'No' ?>

227 | response->isSent()): 229 | $info = []; 230 | foreach ($this->getData() as $data) { 231 | if ( 232 | isset($data['message'], $data['type']) 233 | && $data['message'] === 'response' 234 | && $data['type'] === 'send' 235 | ) { 236 | $info = $data; 237 | break; 238 | } 239 | } ?> 240 |

241 | Time Sending: ms 242 |

243 | renderHeadersTable($this->response->getHeaderLines()); 246 | if ($this->response->isReplacingHeaders()) { 247 | echo '

* Note that the Response is replacing headers.

'; 248 | } 249 | echo '

* Note that some headers can be set outside the Response'; 250 | echo ' class, for example by the session or the server.'; 251 | echo ' So they don\'t appear here.

'; 252 | echo $this->renderResponseCookies(); 253 | echo $this->renderResponseBody(); 254 | return \ob_get_clean(); // @phpstan-ignore-line 255 | } 256 | 257 | protected function renderResponseCookies() : string 258 | { 259 | if (!$this->response->getCookies()) { 260 | return ''; 261 | } 262 | \ob_start(); ?> 263 |

Cookies

264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | response->getCookies() as $cookie): ?> 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 |
NameValueExpiresPathDomainIs SecureIs HTTP OnlySameSite
getName()) ?>getValue()) ?>getExpires()?->format('D, d-M-Y H:i:s \G\M\T') ?>getPath()) ?>getDomain()) ?>isSecure() ? 'Yes' : 'No' ?>isHttpOnly() ? 'Yes' : 'No' ?>getSameSite()) ?>
292 | 299 |

Body Contents

300 | response->isSent()) { 302 | echo '

Response has not been sent.

'; 303 | return \ob_get_clean(); // @phpstan-ignore-line 304 | } 305 | if ($this->response->hasDownload()) { 306 | echo '

Body has downloadable content.

'; 307 | return \ob_get_clean(); // @phpstan-ignore-line 308 | } 309 | $body = $this->response->getBody(); 310 | if ($body === '') { 311 | echo '

Body is empty.

'; 312 | return \ob_get_clean(); // @phpstan-ignore-line 313 | } ?> 314 |
317 | $headerLines 323 | * 324 | * @return string 325 | */ 326 | protected function renderHeadersTable(array $headerLines) : string 327 | { 328 | \ob_start(); ?> 329 |

Headers

330 | No headers.

'; 333 | return \ob_get_clean(); // @phpstan-ignore-line 334 | } ?> 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 345 | 346 | 347 | 348 | 349 | 350 | 351 |
NameValue
352 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | use BadMethodCallException; 13 | use InvalidArgumentException; 14 | use JetBrains\PhpStorm\Pure; 15 | 16 | /** 17 | * Class Message. 18 | * 19 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages 20 | * @see https://datatracker.ietf.org/doc/html/rfc7231 21 | * 22 | * @package http 23 | */ 24 | abstract class Message implements MessageInterface 25 | { 26 | /** 27 | * HTTP Message Protocol. 28 | */ 29 | protected string $protocol = Protocol::HTTP_1_1; 30 | /** 31 | * HTTP Request URL. 32 | */ 33 | protected URL $url; 34 | /** 35 | * HTTP Request Method. 36 | */ 37 | protected string $method; 38 | /** 39 | * HTTP Response Status Code. 40 | */ 41 | protected int $statusCode; 42 | /** 43 | * HTTP Message Body. 44 | */ 45 | protected string $body; 46 | /** 47 | * HTTP Message Cookies. 48 | * 49 | * @var array 50 | */ 51 | protected array $cookies = []; 52 | /** 53 | * HTTP Message Headers. 54 | * 55 | * @var array 56 | */ 57 | protected array $headers = []; 58 | protected int $jsonFlags = \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE; 59 | 60 | public function __toString() : string 61 | { 62 | $eol = "\r\n"; 63 | $message = $this->getStartLine() . $eol; 64 | foreach ($this->getHeaderLines() as $headerLine) { 65 | $message .= $headerLine . $eol; 66 | } 67 | $message .= $eol; 68 | $message .= $this->getBody(); 69 | return $message; 70 | } 71 | 72 | /** 73 | * Get the Message Start-Line. 74 | * 75 | * @throws BadMethodCallException if $this is not an instance of 76 | * RequestInterface or ResponseInterface 77 | * 78 | * @return string 79 | */ 80 | public function getStartLine() : string 81 | { 82 | if ($this instanceof RequestInterface) { 83 | $query = $this->getUrl()->getQuery(); 84 | $query = ($query !== null && $query !== '') ? '?' . $query : ''; 85 | return $this->getMethod() 86 | . ' ' . $this->getUrl()->getPath() . $query 87 | . ' ' . $this->getProtocol(); 88 | } 89 | if ($this instanceof ResponseInterface) { 90 | return $this->getProtocol() 91 | . ' ' . $this->getStatus(); 92 | } 93 | throw new BadMethodCallException( 94 | static::class . ' is not an instance of ' . RequestInterface::class 95 | . ' or ' . ResponseInterface::class 96 | ); 97 | } 98 | 99 | #[Pure] 100 | public function hasHeader(string $name, ?string $value = null) : bool 101 | { 102 | return $value === null 103 | ? $this->getHeader($name) !== null 104 | : $this->getHeader($name) === $value; 105 | } 106 | 107 | #[Pure] 108 | public function getHeader(string $name) : ?string 109 | { 110 | return $this->headers[\strtolower($name)] ?? null; 111 | } 112 | 113 | /** 114 | * @return array 115 | */ 116 | #[Pure] 117 | public function getHeaders() : array 118 | { 119 | return $this->headers; 120 | } 121 | 122 | #[Pure] 123 | public function getHeaderLine(string $name) : ?string 124 | { 125 | $value = $this->getHeader($name); 126 | if ($value === null) { 127 | return null; 128 | } 129 | $name = ResponseHeader::getName($name); 130 | return $name . ': ' . $value; 131 | } 132 | 133 | /** 134 | * @return array 135 | */ 136 | #[Pure] 137 | public function getHeaderLines() : array 138 | { 139 | $lines = []; 140 | foreach ($this->getHeaders() as $name => $value) { 141 | $name = ResponseHeader::getName($name); 142 | if (\str_contains($value, "\n")) { 143 | foreach (\explode("\n", $value) as $val) { 144 | $lines[] = $name . ': ' . $val; 145 | } 146 | continue; 147 | } 148 | $lines[] = $name . ': ' . $value; 149 | } 150 | return $lines; 151 | } 152 | 153 | /** 154 | * Set a Message header. 155 | * 156 | * @param string $name 157 | * @param string $value 158 | * 159 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers 160 | * 161 | * @return static 162 | */ 163 | protected function setHeader(string $name, string $value) : static 164 | { 165 | $this->headers[\strtolower($name)] = $value; 166 | return $this; 167 | } 168 | 169 | /** 170 | * Set a list of headers. 171 | * 172 | * @param array $headers 173 | * 174 | * @return static 175 | */ 176 | protected function setHeaders(array $headers) : static 177 | { 178 | foreach ($headers as $name => $value) { 179 | $this->setHeader((string) $name, (string) $value); 180 | } 181 | return $this; 182 | } 183 | 184 | /** 185 | * Append a Message header. 186 | * 187 | * Used to set repeated header field names. 188 | * 189 | * @param string $name 190 | * @param string $value 191 | * 192 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers 193 | * 194 | * @return static 195 | */ 196 | protected function appendHeader(string $name, string $value) : static 197 | { 198 | $current = $this->getHeader($name); 199 | if ($current !== null) { 200 | $separator = $this->getHeaderValueSeparator($name); 201 | $value = $current . $separator . $value; 202 | } 203 | $this->setHeader($name, $value); 204 | return $this; 205 | } 206 | 207 | /** 208 | * @param string $headerName 209 | * 210 | * @see https://stackoverflow.com/a/38406581/6027968 211 | * 212 | * @return string 213 | */ 214 | private function getHeaderValueSeparator(string $headerName) : string 215 | { 216 | if (ResponseHeader::isMultiline($headerName)) { 217 | return "\n"; 218 | } 219 | return ', '; 220 | } 221 | 222 | /** 223 | * Remove a header by name. 224 | * 225 | * @param string $name 226 | * 227 | * @return static 228 | */ 229 | protected function removeHeader(string $name) : static 230 | { 231 | unset($this->headers[\strtolower($name)]); 232 | return $this; 233 | } 234 | 235 | /** 236 | * Remove all headers. 237 | * 238 | * @return static 239 | */ 240 | protected function removeHeaders() : static 241 | { 242 | $this->headers = []; 243 | return $this; 244 | } 245 | 246 | /** 247 | * Remove headers by names. 248 | * 249 | * @param array $names 250 | * 251 | * @return static 252 | */ 253 | protected function removeHeadersByNames(array $names) : static 254 | { 255 | foreach ($names as $name) { 256 | $this->removeHeader($name); 257 | } 258 | return $this; 259 | } 260 | 261 | /** 262 | * Say if the Message has a Cookie. 263 | * 264 | * @param string $name Cookie name 265 | * 266 | * @return bool 267 | */ 268 | #[Pure] 269 | public function hasCookie(string $name) : bool 270 | { 271 | return (bool) $this->getCookie($name); 272 | } 273 | 274 | /** 275 | * Get a Cookie by name. 276 | * 277 | * @param string $name 278 | * 279 | * @return Cookie|null 280 | */ 281 | #[Pure] 282 | public function getCookie(string $name) : ?Cookie 283 | { 284 | return $this->cookies[$name] ?? null; 285 | } 286 | 287 | /** 288 | * Get all Cookies. 289 | * 290 | * @return array 291 | */ 292 | #[Pure] 293 | public function getCookies() : array 294 | { 295 | return $this->cookies; 296 | } 297 | 298 | /** 299 | * Set a new Cookie. 300 | * 301 | * @param Cookie $cookie 302 | * 303 | * @return static 304 | */ 305 | protected function setCookie(Cookie $cookie) : static 306 | { 307 | $this->cookies[$cookie->getName()] = $cookie; 308 | return $this; 309 | } 310 | 311 | /** 312 | * Set a list of Cookies. 313 | * 314 | * @param array $cookies 315 | * 316 | * @return static 317 | */ 318 | protected function setCookies(array $cookies) : static 319 | { 320 | foreach ($cookies as $cookie) { 321 | $this->setCookie($cookie); 322 | } 323 | return $this; 324 | } 325 | 326 | /** 327 | * Remove a Cookie by name. 328 | * 329 | * @param string $name 330 | * 331 | * @return static 332 | */ 333 | protected function removeCookie(string $name) : static 334 | { 335 | unset($this->cookies[$name]); 336 | return $this; 337 | } 338 | 339 | /** 340 | * Remove many Cookies by names. 341 | * 342 | * @param array $names 343 | * 344 | * @return static 345 | */ 346 | protected function removeCookies(array $names) : static 347 | { 348 | foreach ($names as $name) { 349 | $this->removeCookie($name); 350 | } 351 | return $this; 352 | } 353 | 354 | /** 355 | * Get the Message body. 356 | * 357 | * @return string 358 | */ 359 | #[Pure] 360 | public function getBody() : string 361 | { 362 | return $this->body ?? ''; 363 | } 364 | 365 | /** 366 | * Set the Message body. 367 | * 368 | * @param string $body 369 | * 370 | * @return static 371 | */ 372 | protected function setBody(string $body) : static 373 | { 374 | $this->body = $body; 375 | return $this; 376 | } 377 | 378 | /** 379 | * Get the HTTP protocol. 380 | * 381 | * @return string 382 | */ 383 | #[Pure] 384 | public function getProtocol() : string 385 | { 386 | return $this->protocol; 387 | } 388 | 389 | /** 390 | * Set the HTTP protocol. 391 | * 392 | * @param string $protocol HTTP/1.1, HTTP/2, etc 393 | * 394 | * @return static 395 | */ 396 | protected function setProtocol(string $protocol) : static 397 | { 398 | $this->protocol = Protocol::validate($protocol); 399 | return $this; 400 | } 401 | 402 | /** 403 | * Gets the HTTP Request Method. 404 | * 405 | * @return string $method One of: CONNECT, DELETE, GET, HEAD, OPTIONS, 406 | * PATCH, POST, PUT, or TRACE 407 | */ 408 | #[Pure] 409 | protected function getMethod() : string 410 | { 411 | return $this->method; 412 | } 413 | 414 | /** 415 | * @param string $method 416 | * 417 | * @throws InvalidArgumentException for invalid method 418 | * 419 | * @return bool 420 | */ 421 | protected function isMethod(string $method) : bool 422 | { 423 | return $this->getMethod() === Method::validate($method); 424 | } 425 | 426 | /** 427 | * Set the request method. 428 | * 429 | * @param string $method One of: CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, 430 | * POST, PUT, or TRACE 431 | * 432 | * @throws InvalidArgumentException for invalid method 433 | * 434 | * @return static 435 | */ 436 | protected function setMethod(string $method) : static 437 | { 438 | $this->method = Method::validate($method); 439 | return $this; 440 | } 441 | 442 | protected function setStatusCode(int $code) : static 443 | { 444 | $this->statusCode = Status::validate($code); 445 | return $this; 446 | } 447 | 448 | /** 449 | * Get the status code. 450 | * 451 | * @return int 452 | */ 453 | #[Pure] 454 | protected function getStatusCode() : int 455 | { 456 | return $this->statusCode; 457 | } 458 | 459 | protected function isStatusCode(int $code) : bool 460 | { 461 | return $this->getStatusCode() === Status::validate($code); 462 | } 463 | 464 | /** 465 | * Gets the requested URL. 466 | * 467 | * @return URL 468 | */ 469 | #[Pure] 470 | protected function getUrl() : URL 471 | { 472 | return $this->url; 473 | } 474 | 475 | /** 476 | * Set the Message URL. 477 | * 478 | * @param URL|string $url 479 | * 480 | * @return static 481 | */ 482 | protected function setUrl(URL | string $url) : static 483 | { 484 | if (!$url instanceof URL) { 485 | $url = new URL($url); 486 | } 487 | $this->url = $url; 488 | return $this; 489 | } 490 | 491 | #[Pure] 492 | protected function parseContentType() : ?string 493 | { 494 | $contentType = $this->getHeader('Content-Type'); 495 | if ($contentType === null) { 496 | return null; 497 | } 498 | $contentType = \explode(';', $contentType, 2)[0]; 499 | return \trim($contentType); 500 | } 501 | 502 | /** 503 | * Set JSON flags. 504 | * 505 | * @since 5.6 506 | * 507 | * @param int $flags 508 | * 509 | * @return static 510 | */ 511 | public function setJsonFlags(int $flags) : static 512 | { 513 | $this->jsonFlags = $flags; 514 | return $this; 515 | } 516 | 517 | /** 518 | * Get JSON flags. 519 | * 520 | * @since 5.6 521 | * 522 | * @return int 523 | */ 524 | public function getJsonFlags() : int 525 | { 526 | return $this->jsonFlags; 527 | } 528 | 529 | /** 530 | * @see https://developer.mozilla.org/en-US/docs/Glossary/Quality_values 531 | * @see https://stackoverflow.com/a/33748742/6027968 532 | * 533 | * @param string|null $string 534 | * 535 | * @return array 536 | */ 537 | public static function parseQualityValues(?string $string) : array 538 | { 539 | if (empty($string)) { 540 | return []; 541 | } 542 | $quality = \array_reduce( 543 | \explode(',', $string, 20), 544 | static function ($qualifier, $part) { 545 | [$value, $priority] = \array_merge(\explode(';q=', $part), [1]); 546 | $qualifier[\trim((string) $value)] = (float) $priority; 547 | return $qualifier; 548 | }, 549 | [] 550 | ); 551 | \arsort($quality); 552 | return $quality; 553 | } 554 | } 555 | -------------------------------------------------------------------------------- /src/UserAgent.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | use JetBrains\PhpStorm\Pure; 13 | 14 | /** 15 | * Class UserAgent. 16 | * 17 | * @package http 18 | */ 19 | class UserAgent implements \JsonSerializable, \Stringable 20 | { 21 | protected ?string $agent = null; 22 | protected ?string $browser = null; 23 | protected ?string $browserVersion = null; 24 | protected ?string $mobile = null; 25 | protected ?string $platform = null; 26 | protected ?string $robot = null; 27 | protected bool $isBrowser = false; 28 | protected bool $isMobile = false; 29 | protected bool $isRobot = false; 30 | /** 31 | * @var array> 32 | */ 33 | protected static array $config = [ 34 | 'platforms' => [ 35 | 'windows nt 10.0' => 'Windows 10', 36 | 'windows nt 6.3' => 'Windows 8.1', 37 | 'windows nt 6.2' => 'Windows 8', 38 | 'windows nt 6.1' => 'Windows 7', 39 | 'windows nt 6.0' => 'Windows Vista', 40 | 'windows nt 5.2' => 'Windows 2003', 41 | 'windows nt 5.1' => 'Windows XP', 42 | 'windows nt 5.0' => 'Windows 2000', 43 | 'windows nt 4.0' => 'Windows NT 4.0', 44 | 'winnt4.0' => 'Windows NT 4.0', 45 | 'winnt 4.0' => 'Windows NT', 46 | 'winnt' => 'Windows NT', 47 | 'windows 98' => 'Windows 98', 48 | 'win98' => 'Windows 98', 49 | 'windows 95' => 'Windows 95', 50 | 'win95' => 'Windows 95', 51 | 'windows phone' => 'Windows Phone', 52 | 'windows' => 'Unknown Windows OS', 53 | 'android' => 'Android', 54 | 'blackberry' => 'BlackBerry', 55 | 'iphone' => 'iOS', 56 | 'ipad' => 'iOS', 57 | 'ipod' => 'iOS', 58 | 'os x' => 'Mac OS X', 59 | 'ppc mac' => 'Power PC Mac', 60 | 'freebsd' => 'FreeBSD', 61 | 'ppc' => 'Macintosh', 62 | 'ubuntu' => 'Ubuntu', 63 | 'debian' => 'Debian', 64 | 'fedora' => 'Fedora', 65 | 'linux' => 'Linux', 66 | 'sunos' => 'Sun Solaris', 67 | 'beos' => 'BeOS', 68 | 'apachebench' => 'ApacheBench', 69 | 'aix' => 'AIX', 70 | 'irix' => 'Irix', 71 | 'osf' => 'DEC OSF', 72 | 'hp-ux' => 'HP-UX', 73 | 'netbsd' => 'NetBSD', 74 | 'bsdi' => 'BSDi', 75 | 'openbsd' => 'OpenBSD', 76 | 'gnu' => 'GNU/Linux', 77 | 'unix' => 'Unknown Unix OS', 78 | 'symbian' => 'Symbian OS', 79 | ], 80 | // The order of this array should NOT be changed. Many browsers return 81 | // multiple browser types so we want to identify the sub-type first. 82 | 'browsers' => [ 83 | 'curl' => 'Curl', 84 | 'PostmanRuntime' => 'Postman', 85 | 'OPR' => 'Opera', 86 | 'Flock' => 'Flock', 87 | 'Edge' => 'Spartan', 88 | 'Edg' => 'Edge', 89 | 'EdgA' => 'Edge', 90 | 'Chrome' => 'Chrome', 91 | // Opera 10+ always reports Opera/9.80 and appends 92 | // Version/ to the user agent string 93 | 'Opera.*?Version' => 'Opera', 94 | 'Opera' => 'Opera', 95 | 'MSIE' => 'Internet Explorer', 96 | 'Internet Explorer' => 'Internet Explorer', 97 | 'Trident.* rv' => 'Internet Explorer', 98 | 'Shiira' => 'Shiira', 99 | 'Firefox' => 'Firefox', 100 | 'Chimera' => 'Chimera', 101 | 'Phoenix' => 'Phoenix', 102 | 'Firebird' => 'Firebird', 103 | 'Camino' => 'Camino', 104 | 'Netscape' => 'Netscape', 105 | 'OmniWeb' => 'OmniWeb', 106 | 'Safari' => 'Safari', 107 | 'Mozilla' => 'Mozilla', 108 | 'Konqueror' => 'Konqueror', 109 | 'icab' => 'iCab', 110 | 'Lynx' => 'Lynx', 111 | 'Links' => 'Links', 112 | 'hotjava' => 'HotJava', 113 | 'amaya' => 'Amaya', 114 | 'IBrowse' => 'IBrowse', 115 | 'Maxthon' => 'Maxthon', 116 | 'Ubuntu' => 'Ubuntu Web Browser', 117 | 'Vivaldi' => 'Vivaldi', 118 | ], 119 | 'mobiles' => [ 120 | 'mobileexplorer' => 'Mobile Explorer', 121 | 'palmsource' => 'Palm', 122 | 'palmscape' => 'Palmscape', 123 | // Phones and Manufacturers 124 | 'motorola' => 'Motorola', 125 | 'nokia' => 'Nokia', 126 | 'palm' => 'Palm', 127 | 'iphone' => 'Apple iPhone', 128 | 'ipad' => 'iPad', 129 | 'ipod' => 'Apple iPod Touch', 130 | 'sony' => 'Sony Ericsson', 131 | 'ericsson' => 'Sony Ericsson', 132 | 'blackberry' => 'BlackBerry', 133 | 'cocoon' => 'O2 Cocoon', 134 | 'blazer' => 'Treo', 135 | 'lg' => 'LG', 136 | 'amoi' => 'Amoi', 137 | 'xda' => 'XDA', 138 | 'mda' => 'MDA', 139 | 'vario' => 'Vario', 140 | 'htc' => 'HTC', 141 | 'samsung' => 'Samsung', 142 | 'sharp' => 'Sharp', 143 | 'sie-' => 'Siemens', 144 | 'alcatel' => 'Alcatel', 145 | 'benq' => 'BenQ', 146 | 'ipaq' => 'HP iPaq', 147 | 'mot-' => 'Motorola', 148 | 'playstation portable' => 'PlayStation Portable', 149 | 'playstation 3' => 'PlayStation 3', 150 | 'playstation vita' => 'PlayStation Vita', 151 | 'hiptop' => 'Danger Hiptop', 152 | 'nec-' => 'NEC', 153 | 'panasonic' => 'Panasonic', 154 | 'philips' => 'Philips', 155 | 'sagem' => 'Sagem', 156 | 'sanyo' => 'Sanyo', 157 | 'spv' => 'SPV', 158 | 'zte' => 'ZTE', 159 | 'sendo' => 'Sendo', 160 | 'nintendo dsi' => 'Nintendo DSi', 161 | 'nintendo ds' => 'Nintendo DS', 162 | 'nintendo 3ds' => 'Nintendo 3DS', 163 | 'wii' => 'Nintendo Wii', 164 | 'open web' => 'Open Web', 165 | 'openweb' => 'OpenWeb', 166 | // Operating Systems 167 | 'android' => 'Android', 168 | 'symbian' => 'Symbian', 169 | 'SymbianOS' => 'SymbianOS', 170 | 'elaine' => 'Palm', 171 | 'series60' => 'Symbian S60', 172 | 'windows ce' => 'Windows CE', 173 | // Browsers 174 | 'obigo' => 'Obigo', 175 | 'netfront' => 'Netfront Browser', 176 | 'openwave' => 'Openwave Browser', 177 | 'mobilexplorer' => 'Mobile Explorer', 178 | 'operamini' => 'Opera Mini', 179 | 'opera mini' => 'Opera Mini', 180 | 'opera mobi' => 'Opera Mobile', 181 | 'fennec' => 'Firefox Mobile', 182 | // Other 183 | 'digital paths' => 'Digital Paths', 184 | 'avantgo' => 'AvantGo', 185 | 'xiino' => 'Xiino', 186 | 'novarra' => 'Novarra Transcoder', 187 | 'vodafone' => 'Vodafone', 188 | 'docomo' => 'NTT DoCoMo', 189 | 'o2' => 'O2', 190 | // Fallback 191 | 'mobile' => 'Generic Mobile', 192 | 'wireless' => 'Generic Mobile', 193 | 'j2me' => 'Generic Mobile', 194 | 'midp' => 'Generic Mobile', 195 | 'cldc' => 'Generic Mobile', 196 | 'up.link' => 'Generic Mobile', 197 | 'up.browser' => 'Generic Mobile', 198 | 'smartphone' => 'Generic Mobile', 199 | 'cellphone' => 'Generic Mobile', 200 | ], 201 | 'robots' => [ 202 | 'googlebot' => 'Googlebot', 203 | 'msnbot' => 'MSNBot', 204 | 'baiduspider' => 'Baiduspider', 205 | 'bingbot' => 'Bing', 206 | 'slurp' => 'Inktomi Slurp', 207 | 'yahoo' => 'Yahoo', 208 | 'ask jeeves' => 'Ask Jeeves', 209 | 'fastcrawler' => 'FastCrawler', 210 | 'infoseek' => 'InfoSeek Robot 1.0', 211 | 'lycos' => 'Lycos', 212 | 'yandex' => 'YandexBot', 213 | 'mediapartners-google' => 'MediaPartners Google', 214 | 'CRAZYWEBCRAWLER' => 'Crazy Webcrawler', 215 | 'adsbot-google' => 'AdsBot Google', 216 | 'feedfetcher-google' => 'Feedfetcher Google', 217 | 'curious george' => 'Curious George', 218 | 'ia_archiver' => 'Alexa Crawler', 219 | 'MJ12bot' => 'Majestic-12', 220 | 'Uptimebot' => 'Uptimebot', 221 | ], 222 | ]; 223 | 224 | /** 225 | * UserAgent constructor. 226 | * 227 | * @param string $userAgent User-Agent string 228 | */ 229 | public function __construct(string $userAgent) 230 | { 231 | $this->parse($userAgent); 232 | } 233 | 234 | #[Pure] 235 | public function __toString() : string 236 | { 237 | return $this->toString(); 238 | } 239 | 240 | /** 241 | * @param string $string 242 | * 243 | * @return static 244 | */ 245 | protected function parse(string $string) : static 246 | { 247 | $this->isBrowser = false; 248 | $this->isRobot = false; 249 | $this->isMobile = false; 250 | $this->browser = null; 251 | $this->browserVersion = null; 252 | $this->mobile = null; 253 | $this->robot = null; 254 | $this->agent = $string; 255 | $this->compileData(); 256 | return $this; 257 | } 258 | 259 | protected function compileData() : void 260 | { 261 | $this->setPlatform(); 262 | foreach (['setRobot', 'setBrowser', 'setMobile'] as $function) { 263 | if ($this->{$function}()) { 264 | break; 265 | } 266 | } 267 | } 268 | 269 | protected function setPlatform() : bool 270 | { 271 | foreach (static::$config['platforms'] as $key => $val) { 272 | if (\preg_match('#' . \preg_quote($key, '#') . '#i', $this->agent)) { 273 | $this->platform = $val; 274 | return true; 275 | } 276 | } 277 | return false; 278 | } 279 | 280 | protected function setBrowser() : bool 281 | { 282 | foreach (static::$config['browsers'] as $key => $val) { 283 | if (\preg_match( 284 | '#' . \preg_quote($key, '#') . '.*?([0-9\.]+)#i', 285 | $this->agent, 286 | $match 287 | )) { 288 | $this->isBrowser = true; 289 | $this->browserVersion = $match[1]; 290 | $this->browser = $val; 291 | $this->setMobile(); 292 | return true; 293 | } 294 | } 295 | return false; 296 | } 297 | 298 | protected function setMobile() : bool 299 | { 300 | foreach (static::$config['mobiles'] as $key => $val) { 301 | if (\stripos($this->agent, $key) !== false) { 302 | $this->isMobile = true; 303 | $this->mobile = $val; 304 | return true; 305 | } 306 | } 307 | return false; 308 | } 309 | 310 | protected function setRobot() : bool 311 | { 312 | foreach (static::$config['robots'] as $key => $val) { 313 | if (\preg_match('#' . \preg_quote($key, '#') . '#i', $this->agent)) { 314 | $this->isRobot = true; 315 | $this->robot = $val; 316 | $this->setMobile(); 317 | return true; 318 | } 319 | } 320 | return false; 321 | } 322 | 323 | /** 324 | * Get the User-Agent as string. 325 | * 326 | * @since 5.3 327 | * 328 | * @return string 329 | */ 330 | #[Pure] 331 | public function toString() : string 332 | { 333 | return $this->agent; 334 | } 335 | 336 | /** 337 | * Gets the Browser name. 338 | * 339 | * @return string|null 340 | */ 341 | #[Pure] 342 | public function getBrowser() : ?string 343 | { 344 | return $this->browser; 345 | } 346 | 347 | /** 348 | * Gets the Browser Version. 349 | * 350 | * @return string|null 351 | */ 352 | #[Pure] 353 | public function getBrowserVersion() : ?string 354 | { 355 | return $this->browserVersion; 356 | } 357 | 358 | /** 359 | * Gets the Mobile device. 360 | * 361 | * @return string|null 362 | */ 363 | #[Pure] 364 | public function getMobile() : ?string 365 | { 366 | return $this->mobile; 367 | } 368 | 369 | /** 370 | * Gets the OS Platform. 371 | * 372 | * @return string|null 373 | */ 374 | #[Pure] 375 | public function getPlatform() : ?string 376 | { 377 | return $this->platform; 378 | } 379 | 380 | /** 381 | * Gets the Robot name. 382 | * 383 | * @return string|null 384 | */ 385 | #[Pure] 386 | public function getRobot() : ?string 387 | { 388 | return $this->robot; 389 | } 390 | 391 | /** 392 | * Is Browser. 393 | * 394 | * @param string|null $key 395 | * 396 | * @return bool 397 | */ 398 | #[Pure] 399 | public function isBrowser(?string $key = null) : bool 400 | { 401 | if ($key === null || $this->isBrowser === false) { 402 | return $this->isBrowser; 403 | } 404 | $config = static::$config['browsers'] ?? []; 405 | return isset($config[$key]) 406 | && $this->browser === $config[$key]; 407 | } 408 | 409 | /** 410 | * Is Mobile. 411 | * 412 | * @param string|null $key 413 | * 414 | * @return bool 415 | */ 416 | #[Pure] 417 | public function isMobile(?string $key = null) : bool 418 | { 419 | if ($key === null || $this->isMobile === false) { 420 | return $this->isMobile; 421 | } 422 | $config = static::$config['mobiles'] ?? []; 423 | return isset($config[$key]) 424 | && $this->mobile === $config[$key]; 425 | } 426 | 427 | /** 428 | * Is Robot. 429 | * 430 | * @param string|null $key 431 | * 432 | * @return bool 433 | */ 434 | #[Pure] 435 | public function isRobot(?string $key = null) : bool 436 | { 437 | if ($key === null || $this->isRobot === false) { 438 | return $this->isRobot; 439 | } 440 | $config = static::$config['robots'] ?? []; 441 | return isset($config[$key]) 442 | && $this->robot === $config[$key]; 443 | } 444 | 445 | #[Pure] 446 | public function getType() : string 447 | { 448 | if ($this->isBrowser()) { 449 | return 'Browser'; 450 | } 451 | if ($this->isRobot()) { 452 | return 'Robot'; 453 | } 454 | return 'Unknown'; 455 | } 456 | 457 | #[Pure] 458 | public function getName() : string 459 | { 460 | if ($this->isBrowser()) { 461 | return $this->getBrowser(); 462 | } 463 | if ($this->isRobot()) { 464 | return $this->getRobot(); 465 | } 466 | return 'Unknown'; 467 | } 468 | 469 | #[Pure] 470 | public function jsonSerialize() : string 471 | { 472 | return $this->toString(); 473 | } 474 | } 475 | -------------------------------------------------------------------------------- /src/CSP.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\HTTP; 11 | 12 | use InvalidArgumentException; 13 | use LogicException; 14 | 15 | /** 16 | * Class CSP. 17 | * 18 | * @see https://content-security-policy.com/ 19 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP 20 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 21 | * 22 | * @package http 23 | */ 24 | class CSP implements \Stringable 25 | { 26 | /** 27 | * Restricts the URLs which can be used in a document's `` element. 28 | * 29 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/base-uri 30 | */ 31 | public const string baseUri = 'base-uri'; 32 | /** 33 | * Defines the valid sources for web workers and nested browsing contexts 34 | * loaded using elements such as `` and `