diff --git a/composer.json b/composer.json index 92d487fa..2a3e77f1 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ }, "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-master": "4.0-dev" } } } diff --git a/src/Bridges/HttpDI/HttpExtension.php b/src/Bridges/HttpDI/HttpExtension.php index 91d82375..bdeff31b 100644 --- a/src/Bridges/HttpDI/HttpExtension.php +++ b/src/Bridges/HttpDI/HttpExtension.php @@ -40,7 +40,7 @@ public function getConfigSchema(): Nette\Schema\Schema 'csp' => Expect::arrayOf('array|scalar|null'), // Content-Security-Policy 'cspReportOnly' => Expect::arrayOf('array|scalar|null'), // Content-Security-Policy-Report-Only 'featurePolicy' => Expect::arrayOf('array|scalar|null'), // Feature-Policy - 'cookieSecure' => Expect::anyOf(null, true, false, 'auto'), // true|false|auto Whether the cookie is available only through HTTPS + 'cookieSecure' => Expect::anyOf(null, true, false, 'auto')->default('auto'), // true|false|auto Whether the cookie is available only through HTTPS ]); } diff --git a/src/Http/FileUpload.php b/src/Http/FileUpload.php index 23b29b87..b560399a 100644 --- a/src/Http/FileUpload.php +++ b/src/Http/FileUpload.php @@ -75,7 +75,7 @@ public function getName(): string */ public function getSanitizedName(): string { - return trim(Nette\Utils\Strings::webalize($this->name, '.', false), '.-'); + return trim(str_replace('-.', '.', Nette\Utils\Strings::webalize($this->name, '.', false)), '.-'); } diff --git a/src/Http/IResponse.php b/src/Http/IResponse.php index 37804a3e..aaa1fadb 100644 --- a/src/Http/IResponse.php +++ b/src/Http/IResponse.php @@ -12,16 +12,9 @@ /** * HTTP response interface. - * @method self deleteHeader(string $name) */ interface IResponse { - /** @deprecated */ - public const PERMANENT = 2116333333; - - /** @deprecated */ - public const BROWSER = 0; - /** HTTP 1.1 response code */ public const S100_CONTINUE = 100, @@ -147,6 +140,17 @@ interface IResponse 511 => 'Network Authentication Required', ]; + /** + * Sets HTTP protocol version. + * @return static + */ + function setProtocolVersion(string $version); + + /** + * Returns HTTP protocol version. + */ + function getProtocolVersion(): string; + /** * Sets HTTP response code. * @return static @@ -158,6 +162,11 @@ function setCode(int $code, string $reason = null); */ function getCode(): int; + /** + * Returns HTTP reason phrase. + */ + function getReasonPhrase(): string; + /** * Sends a HTTP header and replaces a previous one. * @return static @@ -170,6 +179,11 @@ function setHeader(string $name, string $value); */ function addHeader(string $name, string $value); + /** + * @return static + */ + function deleteHeader(string $name); + /** * Sends a Content-type HTTP header. * @return static @@ -187,11 +201,6 @@ function redirect(string $url, int $code = self::S302_FOUND): void; */ function setExpiration(?string $expire); - /** - * Checks if headers have been sent. - */ - function isSent(): bool; - /** * Returns value of an HTTP header. */ @@ -199,6 +208,7 @@ function getHeader(string $header): ?string; /** * Returns a associative array of headers to sent. + * @return string[][] */ function getHeaders(): array; @@ -207,10 +217,21 @@ function getHeaders(): array; * @param string|int|\DateTimeInterface $expire time, value 0 means "until the browser is closed" * @return static */ - function setCookie(string $name, string $value, $expire, string $path = null, string $domain = null, bool $secure = null, bool $httpOnly = null); + function setCookie(string $name, string $value, $expire, string $path = null, string $domain = null, bool $secure = null, bool $httpOnly = null, string $sameSite = null); /** * Deletes a cookie. */ function deleteCookie(string $name, string $path = null, string $domain = null, bool $secure = null); + + /** + * @param string|\Closure $body + * @return static + */ + function setBody($body); + + /** + * @return string|\Closure + */ + function getBody(); } diff --git a/src/Http/Request.php b/src/Http/Request.php index ef67077a..abb7ad22 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -106,8 +106,6 @@ public function getQuery(string $key = null) { if (func_num_args() === 0) { return $this->url->getQueryParameters(); - } elseif (func_num_args() > 1) { - trigger_error(__METHOD__ . '() parameter $default is deprecated, use operator ??', E_USER_DEPRECATED); } return $this->url->getQueryParameter($key); } @@ -122,8 +120,6 @@ public function getPost(string $key = null) { if (func_num_args() === 0) { return $this->post; - } elseif (func_num_args() > 1) { - trigger_error(__METHOD__ . '() parameter $default is deprecated, use operator ??', E_USER_DEPRECATED); } return $this->post[$key] ?? null; } @@ -154,9 +150,6 @@ public function getFiles(): array */ public function getCookie(string $key) { - if (func_num_args() > 1) { - trigger_error(__METHOD__ . '() parameter $default is deprecated, use operator ??', E_USER_DEPRECATED); - } return $this->cookies[$key] ?? null; } @@ -197,9 +190,6 @@ public function isMethod(string $method): bool */ public function getHeader(string $header): ?string { - if (func_num_args() > 1) { - trigger_error(__METHOD__ . '() parameter $default is deprecated, use operator ??', E_USER_DEPRECATED); - } $header = strtolower($header); return $this->headers[$header] ?? null; } diff --git a/src/Http/Response.php b/src/Http/Response.php index 572421de..934b75de 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -34,21 +34,39 @@ final class Response implements IResponse /** @var bool Whether the cookie is hidden from client-side */ public $cookieHttpOnly = true; - /** @var bool Whether warn on possible problem with data in output buffer */ - public $warnOnBuffer = true; - - /** @var bool Send invisible garbage for IE 6? */ - private static $fixIE = true; - /** @var int HTTP response code */ private $code = self::S200_OK; + /** @var string */ + private $reason = self::REASON_PHRASES[self::S200_OK]; + + /** @var string */ + private $version = '1.1'; + + /** @var array of [name, values] */ + private $headers = []; + + /** @var string|\Closure */ + private $body = ''; - public function __construct() + + /** + * Sets HTTP protocol version. + * @return static + */ + public function setProtocolVersion(string $version) { - if (is_int($code = http_response_code())) { - $this->code = $code; - } + $this->version = $version; + return $this; + } + + + /** + * Returns HTTP protocol version. + */ + public function getProtocolVersion(): string + { + return $this->version; } @@ -56,18 +74,14 @@ public function __construct() * Sets HTTP response code. * @return static * @throws Nette\InvalidArgumentException if code is invalid - * @throws Nette\InvalidStateException if HTTP headers have been sent */ public function setCode(int $code, string $reason = null) { if ($code < 100 || $code > 599) { throw new Nette\InvalidArgumentException("Bad HTTP response '$code'."); } - self::checkHeaders(); $this->code = $code; - $protocol = $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1'; - $reason = $reason ?? self::REASON_PHRASES[$code] ?? 'Unknown status'; - header("$protocol $code $reason"); + $this->reason = $reason ?? self::REASON_PHRASES[$code] ?? 'Unknown status'; return $this; } @@ -81,20 +95,24 @@ public function getCode(): int } + /** + * Returns HTTP reason phrase. + */ + public function getReasonPhrase(): string + { + return $this->reason; + } + + /** * Sends a HTTP header and replaces a previous one. * @return static - * @throws Nette\InvalidStateException if HTTP headers have been sent */ public function setHeader(string $name, ?string $value) { - self::checkHeaders(); - if ($value === null) { - header_remove($name); - } elseif (strcasecmp($name, 'Content-Length') === 0 && ini_get('zlib.output_compression')) { - // ignore, PHP bug #44164 - } else { - header($name . ': ' . $value, true, $this->code); + unset($this->headers[strtolower($name)]); + if ($value !== null) { // supports null for back compatibility + $this->addHeader($name, $value); } return $this; } @@ -103,32 +121,52 @@ public function setHeader(string $name, ?string $value) /** * Adds HTTP header. * @return static - * @throws Nette\InvalidStateException if HTTP headers have been sent */ public function addHeader(string $name, string $value) { - self::checkHeaders(); - header($name . ': ' . $value, false, $this->code); + $lname = strtolower($name); + $this->headers[$lname][0] = $name; + $this->headers[$lname][1][] = trim(preg_replace('#[^\x20-\x7E\x80-\xFE]#', '', $value)); return $this; } /** * @return static - * @throws Nette\InvalidStateException if HTTP headers have been sent */ public function deleteHeader(string $name) { - self::checkHeaders(); - header_remove($name); + unset($this->headers[strtolower($name)]); return $this; } + /** + * Returns value of an HTTP header. + */ + public function getHeader(string $name): ?string + { + return $this->headers[strtolower($name)][1][0] ?? null; + } + + + /** + * Returns a associative array of headers to sent. + * @return string[][] + */ + public function getHeaders(): array + { + $res = []; + foreach ($this->headers as $info) { + $res[$info[0]] = $info[1]; + } + return $res; + } + + /** * Sends a Content-type HTTP header. * @return static - * @throws Nette\InvalidStateException if HTTP headers have been sent */ public function setContentType(string $type, string $charset = null) { @@ -139,7 +177,6 @@ public function setContentType(string $type, string $charset = null) /** * Redirects to a new URL. Note: call exit() after it. - * @throws Nette\InvalidStateException if HTTP headers have been sent */ public function redirect(string $url, int $code = self::S302_FOUND): void { @@ -147,7 +184,9 @@ public function redirect(string $url, int $code = self::S302_FOUND): void $this->setHeader('Location', $url); if (preg_match('#^https?:|^\s*+[a-z0-9+.-]*+[^:]#i', $url)) { $escapedUrl = htmlspecialchars($url, ENT_IGNORE | ENT_QUOTES, 'UTF-8'); - echo "
Please click here to continue.
"; + $this->setBody("Please click here to continue.
"); + } else { + $this->setBody(''); } } @@ -155,7 +194,6 @@ public function redirect(string $url, int $code = self::S302_FOUND): void /** * Sets the time (like '20 minutes') before a page cached on a browser expires, null means "must-revalidate". * @return static - * @throws Nette\InvalidStateException if HTTP headers have been sent */ public function setExpiration(?string $time) { @@ -174,117 +212,89 @@ public function setExpiration(?string $time) /** - * Checks if headers have been sent. + * Sends a cookie. + * @param string|int|\DateTimeInterface $time expiration time, value 0 means "until the browser is closed" + * @return static */ - public function isSent(): bool + public function setCookie(string $name, string $value, $expire, string $path = null, string $domain = null, bool $secure = null, bool $httpOnly = null, string $sameSite = null) { - return headers_sent(); - } - + $path = $path === null ? $this->cookiePath : $path; + $domain = $domain === null ? $this->cookieDomain : $domain; + $secure = $secure === null ? $this->cookieSecure : $secure; + $httpOnly = $httpOnly === null ? $this->cookieHttpOnly : $httpOnly; - /** - * Returns value of an HTTP header. - */ - public function getHeader(string $header): ?string - { - if (func_num_args() > 1) { - trigger_error(__METHOD__ . '() parameter $default is deprecated, use operator ??', E_USER_DEPRECATED); - } - $header .= ':'; - $len = strlen($header); - foreach (headers_list() as $item) { - if (strncasecmp($item, $header, $len) === 0) { - return ltrim(substr($item, $len)); - } + if (strpbrk($name . $path . $domain . $sameSite, "=,; \t\r\n\013\014") !== false) { + throw new Nette\InvalidArgumentException('Cookie cannot contain any of the following \'=,; \t\r\n\013\014\''); } - return null; - } + $value = $name . '=' . rawurlencode($value) + . ($expire ? '; expires=' . Helpers::formatDate($expire) : '') + . ($expire ? '; Max-Age=' . (DateTime::from($expire)->format('U') - time()) : '') + . ($domain ? '; domain=' . $domain : '') + . ($path ? '; path=' . $path : '') + . ($secure ? '; secure' : '') + . ($httpOnly ? '; HttpOnly' : '') + . ($sameSite ? '; SameSite=' . $sameSite : ''); - /** - * Returns a associative array of headers to sent. - */ - public function getHeaders(): array - { - $headers = []; - foreach (headers_list() as $header) { - $a = strpos($header, ':'); - $headers[substr($header, 0, $a)] = (string) substr($header, $a + 2); - } - return $headers; + $this->addHeader('Set-Cookie', $value); + return $this; } - public function __destruct() + /** + * Deletes a cookie. + */ + public function deleteCookie(string $name, string $path = null, string $domain = null, bool $secure = null): void { - if ( - self::$fixIE - && strpos($_SERVER['HTTP_USER_AGENT'] ?? '', 'MSIE ') !== false - && in_array($this->code, [400, 403, 404, 405, 406, 408, 409, 410, 500, 501, 505], true) - && preg_match('#^text/html(?:;|$)#', (string) $this->getHeader('Content-Type')) - ) { - echo Nette\Utils\Random::generate(2000, " \t\r\n"); // sends invisible garbage for IE - self::$fixIE = false; - } + $this->setCookie($name, '', 0, $path, $domain, $secure); } /** - * Sends a cookie. - * @param string|int|\DateTimeInterface $time expiration time, value 0 means "until the browser is closed" + * @param string|\Closure $body * @return static - * @throws Nette\InvalidStateException if HTTP headers have been sent */ - public function setCookie(string $name, string $value, $time, string $path = null, string $domain = null, bool $secure = null, bool $httpOnly = null, string $sameSite = null) + public function setBody($body) { - self::checkHeaders(); - $options = [ - 'expires' => $time ? (int) DateTime::from($time)->format('U') : 0, - 'path' => $path === null ? $this->cookiePath : $path, - 'domain' => $domain === null ? $this->cookieDomain : $domain, - 'secure' => $secure === null ? $this->cookieSecure : $secure, - 'httponly' => $httpOnly === null ? $this->cookieHttpOnly : $httpOnly, - 'samesite' => $sameSite, - ]; - if (PHP_VERSION_ID >= 70300) { - setcookie($name, $value, $options); - } else { - setcookie( - $name, - $value, - $options['expires'], - $options['path'] . ($sameSite ? "; SameSite=$sameSite" : ''), - $options['domain'], - $options['secure'], - $options['httponly'] - ); + if (!is_string($body) && !$body instanceof \Closure) { + throw new Nette\InvalidArgumentException('Body must be string or Closure.'); } + $this->body = $body; return $this; } /** - * Deletes a cookie. - * @throws Nette\InvalidStateException if HTTP headers have been sent + * @return string|\Closure */ - public function deleteCookie(string $name, string $path = null, string $domain = null, bool $secure = null): void + public function getBody() { - $this->setCookie($name, '', 0, $path, $domain, $secure); + return $this->body; } - private function checkHeaders(): void + public function __toString(): string { - if (PHP_SAPI === 'cli') { - } elseif (headers_sent($file, $line)) { - throw new Nette\InvalidStateException('Cannot send header after HTTP headers have been sent' . ($file ? " (output started at $file:$line)." : '.')); - - } elseif ( - $this->warnOnBuffer && - ob_get_length() && - !array_filter(ob_get_status(true), function (array $i): bool { return !$i['chunk_size']; }) - ) { - trigger_error('Possible problem: you are sending a HTTP header while already having some data in output buffer. Try Tracy\OutputDebugger or start session earlier.'); + $res = "HTTP/$this->version $this->code $this->reason\r\n"; + foreach ($this->headers as $name => $info) { + foreach ($info[1] as $value) { + $res .= $info[0] . ': ' . $value . "\r\n"; + } } + + if (is_string($this->body)) { + $res .= "\r\n" . $this->body; + } else { + ob_start(function () {}); + try { + ($this->body)(); + $res .= "\r\n" . ob_get_clean(); + } catch (\Throwable $e) { + ob_end_clean(); + throw $e; + } + } + + return $res; } } diff --git a/src/Http/ResponseEmitter.php b/src/Http/ResponseEmitter.php new file mode 100644 index 00000000..bc27bc5b --- /dev/null +++ b/src/Http/ResponseEmitter.php @@ -0,0 +1,93 @@ +sendHeaders($response); + $this->sendBody($response); + } + + + public function sendHeaders(IResponse $response): void + { + $this->checkHeaders(); + + header('HTTP/' . $response->getProtocolVersion() . ' ' . $response->getCode() . ' ' . ($response->getReasonPhrase() ?: 'Unknown status')); + + foreach (headers_list() as $header) { + header_remove(explode(':', $header)[0]); + } + + foreach ($response->getHeaders() as $name => $values) { + if (strcasecmp($name, 'Content-Length') === 0 && ini_get('zlib.output_compression')) { + continue; // ignore, PHP bug #44164 + } + foreach ($values as $value) { + header($name . ': ' . $value, false); + } + } + } + + + public function sendBody(IResponse $response): void + { + $body = $response->getBody(); + if (is_string($body)) { + echo $body; + } else { + flush(); + $body(); + } + + if ( + $this->fixIE + && strpos($_SERVER['HTTP_USER_AGENT'] ?? '', 'MSIE ') !== false + && in_array($response->getCode(), [400, 403, 404, 405, 406, 408, 409, 410, 500, 501, 505], true) + && preg_match('#^text/html(?:;|$)#', (string) $response->getHeader('Content-Type')) + ) { + echo Nette\Utils\Random::generate(2000, " \t\r\n"); // sends invisible garbage for IE + } + } + + + private function checkHeaders(): void + { + if (PHP_SAPI === 'cli') { + // ok + } elseif (headers_sent($file, $line)) { + throw new Nette\InvalidStateException('Cannot send header after HTTP headers have been sent' . ($file ? " (output started at $file:$line)." : '.')); + + } elseif ( + $this->warnOnBuffer && + ob_get_length() && + !array_filter(ob_get_status(true), function (array $i): bool { return !$i['chunk_size']; }) + ) { + trigger_error('Possible problem: you are sending a HTTP header while already having some data in output buffer. Try Tracy\OutputDebugger or start session earlier.'); + } + } +} diff --git a/src/Http/ResponseFactory.php b/src/Http/ResponseFactory.php new file mode 100644 index 00000000..d89af7dd --- /dev/null +++ b/src/Http/ResponseFactory.php @@ -0,0 +1,81 @@ +setCode($code); + } + $protocol = $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1'; + $response->setProtocolVersion(explode('/', $protocol)[1]); + $this->parseHeaders($response, headers_list()); + return $response; + } + + + public function fromString(string $message): Response + { + $response = new Response; + $parts = explode("\r\n\r\n", $message, 2); + $headers = explode("\r\n", $parts[0]); + $this->parseStatus($response, array_shift($headers)); + $this->parseHeaders($response, $headers); + $response->setBody($parts[1] ?? ''); + return $response; + } + + + public function fromUrl(string $url): Response + { + $response = new Response; + $response->setBody(file_get_contents($url)); + $headers = []; + foreach ($http_response_header as $header) { + if (substr($header, 0, 5) === 'HTTP/') { + $headers = []; + } + $headers[] = $header; + } + $this->parseStatus($response, array_shift($headers)); + $this->parseHeaders($response, $headers); + return $response; + } + + + private function parseStatus(Response $response, string $status): void + { + if (!preg_match('#^HTTP/([\d.]+) (\d+) (.+)$#', $status, $m)) { + throw new Nette\InvalidArgumentException("Invalid status line '$status'."); + } + $response->setProtocolVersion($m[1]); + $response->setCode((int) $m[2], $m[3]); + } + + + private function parseHeaders(Response $response, array $headers): void + { + foreach ($headers as $header) { + $parts = explode(': ', $header, 2); + $response->addHeader($parts[0], $parts[1]); + } + } +} diff --git a/src/Http/Session.php b/src/Http/Session.php index 333dfeb9..9aa710b8 100644 --- a/src/Http/Session.php +++ b/src/Http/Session.php @@ -104,6 +104,9 @@ public function start(): void throw $e; } + $this->sendCookie(); + $this->sendCachingCookie(); + $this->initialize(); } @@ -145,7 +148,7 @@ private function initialize(): void } } - register_shutdown_function([$this, 'clean']); + register_shutdown_function(\Closure::fromCallable([$this, 'clean'])); } @@ -183,10 +186,8 @@ public function destroy(): void session_destroy(); $_SESSION = null; $this->started = false; - if (!$this->response->isSent()) { - $params = session_get_cookie_params(); - $this->response->deleteCookie(session_name(), $params['path'], $params['domain'], $params['secure']); - } + $params = session_get_cookie_params(); + $this->response->deleteCookie(session_name(), $params['path'], $params['domain'], $params['secure']); } @@ -213,6 +214,7 @@ public function regenerateId(): void throw new Nette\InvalidStateException('Cannot regenerate session ID after HTTP headers have been sent' . ($file ? " (output started at $file:$line)." : '.')); } session_regenerate_id(true); + $this->sendCookie(); } else { session_id(session_create_id()); } @@ -296,9 +298,8 @@ public function getIterator(): \Iterator /** * Cleans and minimizes meta structures. This method is called automatically on shutdown, do not call it directly. - * @internal */ - public function clean(): void + private function clean(): void { if (!session_status() === PHP_SESSION_ACTIVE || empty($_SESSION)) { return; @@ -460,13 +461,6 @@ public function setCookieParameters(string $path, string $domain = null, bool $s } - /** @deprecated */ - public function getCookieParameters(): array - { - return session_get_cookie_params(); - } - - /** * Sets path of the directory used to save session data. * @return static @@ -498,11 +492,50 @@ public function setHandler(\SessionHandlerInterface $handler) */ private function sendCookie(): void { + // remove old cookie + $cookies = $this->response->getHeaders()['Set-Cookie'] ?? []; + $this->response->deleteHeader('Set-Cookie'); + foreach ($cookies as $value) { + if (!Nette\Utils\Strings::startsWith($value, session_name() . '=')) { + $this->response->addHeader('Set-Cookie', $value); + } + } + $cookie = session_get_cookie_params(); + $tmp = explode('; SameSite=', $cookie['path']); // PHP < 7.3 workaround $this->response->setCookie( session_name(), session_id(), $cookie['lifetime'] ? $cookie['lifetime'] + time() : 0, - $cookie['path'], $cookie['domain'], $cookie['secure'], $cookie['httponly'], $cookie['samesite'] ?? null + $tmp[0], $cookie['domain'], $cookie['secure'], $cookie['httponly'], $cookie['samesite'] ?? $tmp[1] ?? null ); } + + + /** + * Sends the cache control HTTP headers. + */ + private function sendCachingCookie(): void + { + $expire = 60 * ini_get('session.cache_expire'); + switch (ini_get('session.cache_limiter')) { + case 'public': + $this->response->setHeader('Expires', Helpers::formatDate(time() + $expire)); + $this->response->setHeader('Cache-Control', "public, max-age=$expire"); + $this->response->setHeader('Last-Modified', Helpers::formatDate(getlastmod())); + return; + case 'private_no_expire': + $this->response->setHeader('Cache-Control', "private, max-age=$expire"); + $this->response->setHeader('Last-Modified', Helpers::formatDate(getlastmod())); + return; + case 'private': + $this->response->setHeader('Expires', 'Mon, 23 Jan 1978 10:00:00 GMT'); + $this->response->setHeader('Cache-Control', "private, max-age=$expire"); + $this->response->setHeader('Last-Modified', Helpers::formatDate(getlastmod())); + return; + case 'nocache': + $this->response->setHeader('Expires', 'Mon, 23 Jan 1978 10:00:00 GMT'); + $this->response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); // For HTTP/1.1 conforming clients + $this->response->setHeader('Pragma', 'no-cache'); // For HTTP/1.0 conforming clients + } + } } diff --git a/src/Http/Url.php b/src/Http/Url.php index 55517884..c110e0a3 100644 --- a/src/Http/Url.php +++ b/src/Http/Url.php @@ -245,9 +245,6 @@ public function getQueryParameters(): array /** @return mixed */ public function getQueryParameter(string $name) { - if (func_num_args() > 1) { - trigger_error(__METHOD__ . '() parameter $default is deprecated, use operator ??', E_USER_DEPRECATED); - } return $this->query[$name] ?? null; } diff --git a/src/Http/UrlImmutable.php b/src/Http/UrlImmutable.php index 2661d0cf..ce954405 100644 --- a/src/Http/UrlImmutable.php +++ b/src/Http/UrlImmutable.php @@ -169,6 +169,12 @@ public function getDomain(int $level = 2): string } + public function getDomainWithoutWww(int $level = 2): string + { + return (string) preg_replace('/^www\./', '', $this->getDomain($level)); + } + + /** @return static */ public function withPort(int $port) { diff --git a/tests/Http.DI/HttpExtension.csp.phpt b/tests/Http.DI/HttpExtension.csp.phpt index 00f2665d..09182a32 100644 --- a/tests/Http.DI/HttpExtension.csp.phpt +++ b/tests/Http.DI/HttpExtension.csp.phpt @@ -13,10 +13,6 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -if (PHP_SAPI === 'cli') { - Tester\Environment::skip('Headers are not testable in CLI mode'); -} - $compiler = new DI\Compiler; $compiler->addExtension('http', new HttpExtension); @@ -48,17 +44,8 @@ eval($compiler->addConfig($config)->compile()); $container = new Container; $container->initialize(); -$headers = headers_list(); - -preg_match('#nonce-([\w+/]+=*)#', implode($headers), $nonce); -Assert::contains("Content-Security-Policy: default-src 'self' https://example.com; upgrade-insecure-requests; script-src 'nonce-$nonce[1]'; style-src 'self' https://example.com http:; require-sri-for style; sandbox allow-forms; plugin-types application/x-java-applet;", $headers); -Assert::contains("Content-Security-Policy-Report-Only: default-src 'nonce-$nonce[1]'; report-uri https://example.com/report; upgrade-insecure-requests;", $headers); - - -echo ' '; @ob_flush(); flush(); - -Assert::true(headers_sent()); +$headers = $container->getByType(Nette\Http\Response::class)->getHeaders(); -Assert::exception(function () use ($container) { - $container->initialize(); -}, Nette\InvalidStateException::class, 'Cannot send header after %a%'); +preg_match('#nonce-([\w+/]+=*)#', implode($headers['Content-Security-Policy']), $nonce); +Assert::same(["default-src 'self' https://example.com; upgrade-insecure-requests; script-src 'nonce-$nonce[1]'; style-src 'self' https://example.com http:; require-sri-for style; sandbox allow-forms; plugin-types application/x-java-applet;"], $headers['Content-Security-Policy']); +Assert::same(["default-src 'nonce-$nonce[1]'; report-uri https://example.com/report; upgrade-insecure-requests;"], $headers['Content-Security-Policy-Report-Only']); diff --git a/tests/Http.DI/HttpExtension.defaultHeaders.phpt b/tests/Http.DI/HttpExtension.defaultHeaders.phpt index 3e70b35d..b7d37770 100644 --- a/tests/Http.DI/HttpExtension.defaultHeaders.phpt +++ b/tests/Http.DI/HttpExtension.defaultHeaders.phpt @@ -13,10 +13,6 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -if (PHP_SAPI === 'cli') { - Tester\Environment::skip('Headers are not testable in CLI mode'); -} - $compiler = new DI\Compiler; $compiler->addExtension('http', new HttpExtension); @@ -25,7 +21,10 @@ eval($compiler->compile()); $container = new Container; $container->initialize(); -$headers = headers_list(); -Assert::contains('X-Frame-Options: SAMEORIGIN', $headers); -Assert::contains('Content-Type: text/html; charset=utf-8', $headers); -Assert::contains('X-Powered-By: Nette Framework 3', $headers); +$headers = $container->getByType(Nette\Http\Response::class)->getHeaders(); +Assert::same([ + 'X-Powered-By' => ['Nette Framework 3'], + 'Content-Type' => ['text/html; charset=utf-8'], + 'X-Frame-Options' => ['SAMEORIGIN'], + 'Set-Cookie' => ['nette-samesite=1; path=/; HttpOnly; SameSite=Strict'], +], $headers); diff --git a/tests/Http.DI/HttpExtension.featurePolicy.phpt b/tests/Http.DI/HttpExtension.featurePolicy.phpt index 9a792053..81556ef9 100644 --- a/tests/Http.DI/HttpExtension.featurePolicy.phpt +++ b/tests/Http.DI/HttpExtension.featurePolicy.phpt @@ -13,10 +13,6 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -if (PHP_SAPI === 'cli') { - Tester\Environment::skip('Headers are not testable in CLI mode'); -} - $compiler = new DI\Compiler; $compiler->addExtension('http', new HttpExtension); @@ -37,16 +33,6 @@ eval($compiler->addConfig($config)->compile()); $container = new Container; $container->initialize(); -$headers = headers_list(); -var_dump($headers); - -Assert::contains("Feature-Policy: unsized-media 'none'; geolocation 'self' https://example.com; camera *;", $headers); - - -echo ' '; @ob_flush(); flush(); - -Assert::true(headers_sent()); +$headers = $container->getByType(Nette\Http\Response::class)->getHeaders(); -Assert::exception(function () use ($container) { - $container->initialize(); -}, Nette\InvalidStateException::class, 'Cannot send header after %a%'); +Assert::same(["unsized-media 'none'; geolocation 'self' https://example.com; camera *;"], $headers['Feature-Policy']); diff --git a/tests/Http.DI/HttpExtension.headers.phpt b/tests/Http.DI/HttpExtension.headers.phpt index cac2a1ce..2eb5c8a2 100644 --- a/tests/Http.DI/HttpExtension.headers.phpt +++ b/tests/Http.DI/HttpExtension.headers.phpt @@ -13,10 +13,6 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -if (PHP_SAPI === 'cli') { - Tester\Environment::skip('Headers are not testable in CLI mode'); -} - $compiler = new DI\Compiler; $compiler->addExtension('http', new HttpExtension); @@ -35,19 +31,10 @@ eval($compiler->addConfig($config)->compile()); $container = new Container; $container->initialize(); -$headers = headers_list(); -Assert::contains('X-Frame-Options: SAMEORIGIN', $headers); -Assert::contains('Content-Type: text/html; charset=utf-8', $headers); -Assert::contains('X-Powered-By: Nette Framework 3', $headers); -Assert::contains('A: b', $headers); -Assert::contains('D: 0', $headers); -Assert::notContains('C:', $headers); - - -echo ' '; @ob_flush(); flush(); - -Assert::true(headers_sent()); - -Assert::exception(function () use ($container) { - $container->initialize(); -}, Nette\InvalidStateException::class, 'Cannot send header after %a%'); +$headers = $container->getByType(Nette\Http\Response::class)->getHeaders(); +Assert::same(['SAMEORIGIN'], $headers['X-Frame-Options']); +Assert::same(['text/html; charset=utf-8'], $headers['Content-Type']); +Assert::same(['Nette Framework 3'], $headers['X-Powered-By']); +Assert::same(['b'], $headers['A']); +Assert::same(['0'], $headers['D']); +Assert::false(isset($headers['C'])); diff --git a/tests/Http.DI/HttpExtension.sameSiteProtection.phpt b/tests/Http.DI/HttpExtension.sameSiteProtection.phpt index a9d3dbb3..2390dfde 100644 --- a/tests/Http.DI/HttpExtension.sameSiteProtection.phpt +++ b/tests/Http.DI/HttpExtension.sameSiteProtection.phpt @@ -9,10 +9,6 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -if (PHP_SAPI === 'cli') { - Tester\Environment::skip('Headers are not testable in CLI mode'); -} - $compiler = new DI\Compiler; $compiler->addExtension('http', new HttpExtension); @@ -23,10 +19,8 @@ eval($compiler->compile()); $container = new Container; $container->initialize(); -$headers = headers_list(); -Assert::contains( - PHP_VERSION_ID >= 70300 - ? 'Set-Cookie: nette-samesite=1; path=/; HttpOnly; SameSite=Strict' - : 'Set-Cookie: nette-samesite=1; path=/; SameSite=Strict; HttpOnly', - $headers +$headers = $container->getByType(Nette\Http\Response::class)->getHeaders(); +Assert::same( + ['nette-samesite=1; path=/; HttpOnly; SameSite=Strict'], + $headers['Set-Cookie'] ); diff --git a/tests/Http/FileUpload.basic.phpt b/tests/Http/FileUpload.basic.phpt index 616bbbd8..2fdd1e0d 100644 --- a/tests/Http/FileUpload.basic.phpt +++ b/tests/Http/FileUpload.basic.phpt @@ -51,6 +51,19 @@ test(function () { }); +test(function () { + $upload = new FileUpload([ + 'name' => 'logo 2020+.pdf', + 'type' => 'text/plain', + 'tmp_name' => __DIR__ . '/files/logo.png', + 'error' => 0, + 'size' => 209, + ]); + + Assert::same('logo-2020.pdf', $upload->getSanitizedName()); +}); + + test(function () { $upload = new FileUpload([ 'name' => '', diff --git a/tests/Http/Response.headers.phpt b/tests/Http/Response.headers.phpt index b1db597b..d70000cc 100644 --- a/tests/Http/Response.headers.phpt +++ b/tests/Http/Response.headers.phpt @@ -12,10 +12,6 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -if (PHP_SAPI === 'cli') { - Tester\Environment::skip('Headers are not available in CLI'); -} - $response = new Http\Response; diff --git a/tests/Http/Response.redirect.phpt b/tests/Http/Response.redirect.phpt index cd3540e0..c8b6a7fc 100644 --- a/tests/Http/Response.redirect.phpt +++ b/tests/Http/Response.redirect.phpt @@ -15,22 +15,13 @@ require __DIR__ . '/../bootstrap.php'; $response = new Http\Response; -ob_start(); $response->redirect('http://nette.org/&'); -Assert::same("Please click here to continue.
", ob_get_clean()); +Assert::same("