From a9e9c4218eeb59054373b99ab75eb4647da91321 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 17 Oct 2019 19:47:42 +0200 Subject: [PATCH 01/12] opened 4.0-dev --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } } } From ed8f1388a1809c8a3dfd8ee8b2d6f59c3630344f Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 17 Oct 2019 19:57:20 +0200 Subject: [PATCH 02/12] removed deprecated stuff --- src/Http/IResponse.php | 6 ------ src/Http/Request.php | 10 ---------- src/Http/Response.php | 3 --- src/Http/Session.php | 7 ------- src/Http/Url.php | 3 --- 5 files changed, 29 deletions(-) diff --git a/src/Http/IResponse.php b/src/Http/IResponse.php index 37804a3e..e8b273cf 100644 --- a/src/Http/IResponse.php +++ b/src/Http/IResponse.php @@ -16,12 +16,6 @@ */ interface IResponse { - /** @deprecated */ - public const PERMANENT = 2116333333; - - /** @deprecated */ - public const BROWSER = 0; - /** HTTP 1.1 response code */ public const S100_CONTINUE = 100, 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..6bec7344 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -187,9 +187,6 @@ public function isSent(): 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 .= ':'; $len = strlen($header); foreach (headers_list() as $item) { diff --git a/src/Http/Session.php b/src/Http/Session.php index 333dfeb9..858fc446 100644 --- a/src/Http/Session.php +++ b/src/Http/Session.php @@ -460,13 +460,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 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; } From 76043dac4a0a7c2eab1b16f2203d83dcf51140cb Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 17 Oct 2019 20:30:56 +0200 Subject: [PATCH 03/12] Response::getHeaders() returns array [name => [headers]] instead of [name => header] (BC break!) tests: added test for Response::getHeaders() --- src/Http/IResponse.php | 1 + src/Http/Response.php | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Http/IResponse.php b/src/Http/IResponse.php index e8b273cf..2ebf245a 100644 --- a/src/Http/IResponse.php +++ b/src/Http/IResponse.php @@ -193,6 +193,7 @@ function getHeader(string $header): ?string; /** * Returns a associative array of headers to sent. + * @return string[][] */ function getHeaders(): array; diff --git a/src/Http/Response.php b/src/Http/Response.php index 6bec7344..1f5ab3fd 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -200,13 +200,14 @@ public function getHeader(string $header): ?string /** * Returns a associative array of headers to sent. + * @return string[][] */ public function getHeaders(): array { $headers = []; foreach (headers_list() as $header) { - $a = strpos($header, ':'); - $headers[substr($header, 0, $a)] = (string) substr($header, $a + 2); + $pair = explode(': ', $header); + $headers[$pair[0]][] = $pair[1]; } return $headers; } From 59d95e48dac604ec3e4b5557ccbae52f2fc29b17 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 31 Oct 2019 16:11:26 +0100 Subject: [PATCH 04/12] Response divided into storage which doesn't emit data to the browser and ResponseEmitter (BC break!) --- src/Http/IResponse.php | 5 - src/Http/Response.php | 222 ++++++++---------- src/Http/ResponseEmitter.php | 93 ++++++++ src/Http/ResponseFactory.php | 42 ++++ src/Http/Session.php | 21 +- tests/Http.DI/HttpExtension.csp.phpt | 21 +- .../Http.DI/HttpExtension.defaultHeaders.phpt | 15 +- .../Http.DI/HttpExtension.featurePolicy.phpt | 18 +- tests/Http.DI/HttpExtension.headers.phpt | 27 +-- .../HttpExtension.sameSiteProtection.phpt | 14 +- tests/Http/Response.headers.phpt | 4 - tests/Http/Response.redirect.phpt | 17 +- tests/Http/Response.setCookie.phpt | 25 +- ....error.phpt => ResponseEmitter.error.phpt} | 28 +-- tests/Http/ResponseEmitter.phpt | 55 +++++ tests/Http/ResponseFactory.fromGlobals.phpt | 34 +++ tests/Http/Session.id.phpt | 5 +- tests/Http/Session.regenerateId().phpt | 5 +- tests/Http/Session.sameSite.phpt | 12 +- 19 files changed, 403 insertions(+), 260 deletions(-) create mode 100644 src/Http/ResponseEmitter.php create mode 100644 src/Http/ResponseFactory.php rename tests/Http/{Response.error.phpt => ResponseEmitter.error.phpt} (54%) create mode 100644 tests/Http/ResponseEmitter.phpt create mode 100644 tests/Http/ResponseFactory.fromGlobals.phpt diff --git a/src/Http/IResponse.php b/src/Http/IResponse.php index 2ebf245a..61329dab 100644 --- a/src/Http/IResponse.php +++ b/src/Http/IResponse.php @@ -181,11 +181,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. */ diff --git a/src/Http/Response.php b/src/Http/Response.php index 1f5ab3fd..844ab857 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 "

Redirect

\n\n

Please click here to continue.

"; + $this->setBody("

Redirect

\n\n

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,115 +212,63 @@ 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 - { - $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. - * @return string[][] - */ - public function getHeaders(): array - { - $headers = []; - foreach (headers_list() as $header) { - $pair = explode(': ', $header); - $headers[$pair[0]][] = $pair[1]; - } - 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); - } - - - private function checkHeaders(): void - { - 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.'); - } + return $this->body; } } 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..ca4ccc1c --- /dev/null +++ b/src/Http/ResponseFactory.php @@ -0,0 +1,42 @@ +setCode($code); + } + $protocol = $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1'; + $response->setProtocolVersion(explode('/', $protocol)[1]); + $this->parseHeaders($response, headers_list()); + return $response; + } + + + 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 858fc446..aba196e6 100644 --- a/src/Http/Session.php +++ b/src/Http/Session.php @@ -104,6 +104,8 @@ public function start(): void throw $e; } + $this->sendCookie(); + $this->initialize(); } @@ -183,10 +185,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 +213,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()); } @@ -491,11 +492,21 @@ 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 ); } } 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/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("

Redirect

\n\n

Please click here to continue.

", ob_get_clean()); +Assert::same("

Redirect

\n\n

Please click here to continue.

", $response->getBody()); -if (PHP_SAPI !== 'cli') { - Assert::contains('Location: http://nette.org/&', headers_list()); -} +Assert::same(['Location' => ['http://nette.org/&']], $response->getHeaders()); -ob_start(); $response->redirect(' javascript:alert(1)'); -Assert::same('', ob_get_clean()); +Assert::same('', $response->getBody()); -if (PHP_SAPI !== 'cli') { - Assert::contains('Location: javascript:alert(1)', headers_list()); -} - - -$response->setCode(200); +Assert::same(['Location' => ['javascript:alert(1)']], $response->getHeaders()); diff --git a/tests/Http/Response.setCookie.phpt b/tests/Http/Response.setCookie.phpt index 30791cb9..d6c3ae31 100644 --- a/tests/Http/Response.setCookie.phpt +++ b/tests/Http/Response.setCookie.phpt @@ -12,36 +12,23 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -if (PHP_SAPI === 'cli') { - Tester\Environment::skip('Cookies are not available in CLI'); -} -$old = headers_list(); $response = new Http\Response; - $response->setCookie('test', 'value', 0); -$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:'])); Assert::same([ - 'Set-Cookie: test=value; path=/; HttpOnly', -], $headers); + 'Set-Cookie' => ['test=value; path=/; HttpOnly'], +], $response->getHeaders()); $response->setCookie('test', 'newvalue', 0); -$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:'])); Assert::same([ - 'Set-Cookie: test=value; path=/; HttpOnly', - 'Set-Cookie: test=newvalue; path=/; HttpOnly', -], $headers); + 'Set-Cookie' => ['test=value; path=/; HttpOnly', 'test=newvalue; path=/; HttpOnly'], +], $response->getHeaders()); $response->setCookie('test', 'newvalue', 0, null, null, null, null, 'Lax'); -$headers = array_values(array_diff(headers_list(), $old, ['Set-Cookie:'])); Assert::same([ - 'Set-Cookie: test=value; path=/; HttpOnly', - 'Set-Cookie: test=newvalue; path=/; HttpOnly', - PHP_VERSION_ID >= 70300 - ? 'Set-Cookie: test=newvalue; path=/; HttpOnly; SameSite=Lax' - : 'Set-Cookie: test=newvalue; path=/; SameSite=Lax; HttpOnly', -], $headers); + 'Set-Cookie' => ['test=value; path=/; HttpOnly', 'test=newvalue; path=/; HttpOnly', 'test=newvalue; path=/; HttpOnly; SameSite=Lax'], +], $response->getHeaders()); diff --git a/tests/Http/Response.error.phpt b/tests/Http/ResponseEmitter.error.phpt similarity index 54% rename from tests/Http/Response.error.phpt rename to tests/Http/ResponseEmitter.error.phpt index c9c904e4..f793dbbd 100644 --- a/tests/Http/Response.error.phpt +++ b/tests/Http/ResponseEmitter.error.phpt @@ -1,7 +1,7 @@ setHeader('A', 'b'); // no output +$emitter = new Http\ResponseEmitter; ob_start(); echo ' '; -$response->setHeader('A', 'b'); // full buffer +$emitter->sendHeaders($response); // full buffer ob_end_clean(); if (PHP_SAPI === 'cli') { - Assert::noError(function () use ($response) { + Assert::noError(function () use ($emitter, $response) { ob_start(null, 4096); echo ' '; - $response->setHeader('A', 'b'); + $emitter->sendHeaders($response); }); - Assert::error(function () use ($response) { + Assert::error(function () use ($emitter, $response) { ob_flush(); - $response->setHeader('A', 'b'); + $emitter->sendHeaders($response); }, E_WARNING, 'Cannot modify header information - headers already sent by (output started at ' . __FILE__ . ':' . (__LINE__ - 2) . ')'); } else { - Assert::error(function () use ($response) { + Assert::error(function () use ($emitter, $response) { ob_start(null, 4096); echo ' '; - $response->setHeader('A', 'b'); + $emitter->sendHeaders($response); }, E_USER_NOTICE, 'Possible problem: you are sending a HTTP header while already having some data in output buffer%a%'); - Assert::noError(function () use ($response) { - $response->warnOnBuffer = false; - $response->setHeader('A', 'b'); + Assert::noError(function () use ($emitter, $response) { + $emitter->warnOnBuffer = false; + $emitter->sendHeaders($response); }); - Assert::exception(function () use ($response) { + Assert::exception(function () use ($emitter, $response) { ob_flush(); - $response->setHeader('A', 'b'); + $emitter->sendHeaders($response); }, Nette\InvalidStateException::class, 'Cannot send header after HTTP headers have been sent (output started at ' . __FILE__ . ':' . (__LINE__ - 2) . ').'); } diff --git a/tests/Http/ResponseEmitter.phpt b/tests/Http/ResponseEmitter.phpt new file mode 100644 index 00000000..502b6717 --- /dev/null +++ b/tests/Http/ResponseEmitter.phpt @@ -0,0 +1,55 @@ +setCode(123, 'my reason'); + $response->setHeader('A', 'b'); + $response->addHeader('A', 'c'); + $response->setBody('hello'); + + $emitter = new Http\ResponseEmitter; + + ob_start(); + $emitter->send($response); + Assert::same('hello', ob_get_clean()); + + + if (PHP_SAPI !== 'cli') { + Assert::same(['A: b', 'A: c'], headers_list()); + } +}); + + +test(function () { + $response = new Http\Response; + $response->setCode(123, 'my reason'); + $response->setHeader('B', 'b'); + $response->setBody(function () { + echo 'nette'; + }); + + $emitter = new Http\ResponseEmitter; + + ob_start(); + $emitter->send($response); + Assert::same('nette', ob_get_clean()); + + + if (PHP_SAPI !== 'cli') { + Assert::same(['B: b'], headers_list()); + } +}); diff --git a/tests/Http/ResponseFactory.fromGlobals.phpt b/tests/Http/ResponseFactory.fromGlobals.phpt new file mode 100644 index 00000000..a14e4515 --- /dev/null +++ b/tests/Http/ResponseFactory.fromGlobals.phpt @@ -0,0 +1,34 @@ +fromGlobals(); + +Assert::same(123, $response->getCode()); +Assert::same('3.0', $response->getProtocolVersion()); + +if (PHP_SAPI === 'cli') { + Assert::same([], $response->getHeaders()); +} else { + Assert::same(['b'], $response->getHeaders()['X-A']); +} + +Assert::same('', $response->getBody()); + +http_response_code(200); diff --git a/tests/Http/Session.id.phpt b/tests/Http/Session.id.phpt index df8fe1ea..b82823d1 100644 --- a/tests/Http/Session.id.phpt +++ b/tests/Http/Session.id.phpt @@ -20,7 +20,8 @@ $_COOKIE[$sessionName] = $leet = md5('1337'); $cookies = [$sessionName => $sessionId = md5('1')]; file_put_contents(getTempDir() . '/sess_' . $sessionId, sprintf('__NF|a:2:{s:4:"Time";i:%s;s:4:"DATA";a:1:{s:4:"temp";a:1:{s:5:"value";s:3:"yes";}}}', time() - 1000)); -$session = new Session(new Http\Request(new Http\UrlScript('http://nette.org'), [], [], $cookies), new Http\Response); +$response = new Http\Response; +$session = new Session(new Http\Request(new Http\UrlScript('http://nette.org'), [], [], $cookies), $response); $session->start(); Assert::same($sessionId, $session->getId()); @@ -32,3 +33,5 @@ $session->close(); // session was not regenerated Assert::true(file_exists(getTempDir() . '/sess_' . $sessionId)); Assert::count(1, glob(getTempDir() . '/sess_*')); + +Assert::same(['PHPSESSID=' . $sessionId . '; path=/; HttpOnly'], $response->getHeaders()['Set-Cookie']); diff --git a/tests/Http/Session.regenerateId().phpt b/tests/Http/Session.regenerateId().phpt index 3e3cc9c8..56636bd0 100644 --- a/tests/Http/Session.regenerateId().phpt +++ b/tests/Http/Session.regenerateId().phpt @@ -13,7 +13,8 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -$session = new Session(new Nette\Http\Request(new Nette\Http\UrlScript), new Nette\Http\Response); +$response = new Nette\Http\Response; +$session = new Session(new Nette\Http\Request(new Nette\Http\UrlScript), $response); $path = rtrim(ini_get('session.save_path'), '/\\') . '/sess_'; @@ -30,3 +31,5 @@ Assert::true(is_file($path . $newId)); $ref = 20; Assert::same(20, $_SESSION['var']); + +Assert::same(['PHPSESSID=' . $newId . '; path=/; HttpOnly'], $response->getHeaders()['Set-Cookie']); diff --git a/tests/Http/Session.sameSite.phpt b/tests/Http/Session.sameSite.phpt index 4314d8ad..a240126a 100644 --- a/tests/Http/Session.sameSite.phpt +++ b/tests/Http/Session.sameSite.phpt @@ -7,10 +7,6 @@ use Tester\Assert; require __DIR__ . '/../bootstrap.php'; -if (PHP_SAPI === 'cli') { - Tester\Environment::skip('Cookies are not available in CLI'); -} - $factory = new Nette\Http\RequestFactory; $response = new Nette\Http\Response; @@ -22,9 +18,7 @@ $session->setOptions([ $session->start(); -Assert::contains( - PHP_VERSION_ID >= 70300 - ? 'Set-Cookie: PHPSESSID=' . $session->getId() . '; path=/; HttpOnly; SameSite=Lax' - : 'Set-Cookie: PHPSESSID=' . $session->getId() . '; path=/; SameSite=Lax; HttpOnly', - headers_list() +Assert::same( + ['PHPSESSID=' . $session->getId() . '; path=/; HttpOnly; SameSite=Lax'], + $response->getHeaders()['Set-Cookie'] ); From 7cb577b9cf9949b302a346e269b50f6df2bde50d Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 31 Oct 2019 16:16:49 +0100 Subject: [PATCH 05/12] IResponse: added missing methods (BC break) --- src/Http/IResponse.php | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/Http/IResponse.php b/src/Http/IResponse.php index 61329dab..aaa1fadb 100644 --- a/src/Http/IResponse.php +++ b/src/Http/IResponse.php @@ -12,7 +12,6 @@ /** * HTTP response interface. - * @method self deleteHeader(string $name) */ interface IResponse { @@ -141,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 @@ -152,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 @@ -164,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 @@ -197,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(); } From 1c2d709c1b42b85714c09a3345311dc3717e74d0 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 17 Oct 2019 22:27:09 +0200 Subject: [PATCH 06/12] Response: added toString() --- src/Http/Response.php | 26 ++++++++++++++++++++++++++ tests/Http/Response.toString.phpt | 22 ++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 tests/Http/Response.toString.phpt diff --git a/src/Http/Response.php b/src/Http/Response.php index 844ab857..934b75de 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -271,4 +271,30 @@ public function getBody() { return $this->body; } + + + public function __toString(): string + { + $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/tests/Http/Response.toString.phpt b/tests/Http/Response.toString.phpt new file mode 100644 index 00000000..85bc7d91 --- /dev/null +++ b/tests/Http/Response.toString.phpt @@ -0,0 +1,22 @@ +setProtocolVersion('3.0'); +$response->setCode(123, 'My Reason'); +$response->setHeader('X-A', 'a'); +$response->addHeader('X-A', 'b'); +$response->setBody('Hello'); + +Assert::same( + "HTTP/3.0 123 My Reason\r\nX-A: a\r\nX-A: b\r\n\r\nHello", + (string) $response +); From 37ca6dd6e1af71dea840cf168a20b2915d765590 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 17 Oct 2019 22:27:42 +0200 Subject: [PATCH 07/12] ResponseFactory: added fromString() & fromUrl() --- src/Http/ResponseFactory.php | 39 ++++++++++++++++++++++ tests/Http/ResponseFactory.fromString.phpt | 21 ++++++++++++ tests/Http/ResponseFactory.fromUrl.phpt | 19 +++++++++++ tests/php-win.ini | 1 + 4 files changed, 80 insertions(+) create mode 100644 tests/Http/ResponseFactory.fromString.phpt create mode 100644 tests/Http/ResponseFactory.fromUrl.phpt diff --git a/src/Http/ResponseFactory.php b/src/Http/ResponseFactory.php index ca4ccc1c..d89af7dd 100644 --- a/src/Http/ResponseFactory.php +++ b/src/Http/ResponseFactory.php @@ -32,6 +32,45 @@ public function fromGlobals(): 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) { diff --git a/tests/Http/ResponseFactory.fromString.phpt b/tests/Http/ResponseFactory.fromString.phpt new file mode 100644 index 00000000..1f2bdffc --- /dev/null +++ b/tests/Http/ResponseFactory.fromString.phpt @@ -0,0 +1,21 @@ +fromString("HTTP/3.0 123 My Reason\r\nX-A: a\r\nX-A: b\r\n\r\nHello"); + +Assert::same(123, $response->getCode()); +Assert::same('3.0', $response->getProtocolVersion()); +Assert::same('My Reason', $response->getReasonPhrase()); + +Assert::same(['X-A' => ['a', 'b']], $response->getHeaders()); + +Assert::same('Hello', $response->getBody()); diff --git a/tests/Http/ResponseFactory.fromUrl.phpt b/tests/Http/ResponseFactory.fromUrl.phpt new file mode 100644 index 00000000..098a220b --- /dev/null +++ b/tests/Http/ResponseFactory.fromUrl.phpt @@ -0,0 +1,19 @@ +fromUrl('https://nette.org'); + +Assert::same(200, $response->getCode()); +Assert::same('1.1', $response->getProtocolVersion()); +Assert::same('OK', $response->getReasonPhrase()); +Assert::same(['SAMEORIGIN'], $response->getHeaders()['X-Frame-Options']); +Assert::contains('Nette Foundation', $response->getBody()); diff --git a/tests/php-win.ini b/tests/php-win.ini index 4b044eda..62b186d7 100644 --- a/tests/php-win.ini +++ b/tests/php-win.ini @@ -1,6 +1,7 @@ [PHP] extension_dir = "./ext" extension=php_fileinfo.dll +extension=php_openssl.dll [Zend] ;zend_extension="./ext/php_xdebug-2.0.5-5.3-vc6.dll" From 0cd565b37c90de0979f8e6f92a6e2f2d2359c6fb Mon Sep 17 00:00:00 2001 From: David Grudl Date: Thu, 17 Oct 2019 20:04:45 +0200 Subject: [PATCH 08/12] Session: emulates session.cache_limiter & session.cache_expire --- src/Http/Session.php | 30 ++++++++++ tests/Http/Session.cache.phpt | 106 ++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 tests/Http/Session.cache.phpt diff --git a/src/Http/Session.php b/src/Http/Session.php index aba196e6..d8ac4481 100644 --- a/src/Http/Session.php +++ b/src/Http/Session.php @@ -105,6 +105,7 @@ public function start(): void } $this->sendCookie(); + $this->sendCachingCookie(); $this->initialize(); } @@ -509,4 +510,33 @@ private function sendCookie(): void $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/tests/Http/Session.cache.phpt b/tests/Http/Session.cache.phpt new file mode 100644 index 00000000..f14d0d61 --- /dev/null +++ b/tests/Http/Session.cache.phpt @@ -0,0 +1,106 @@ +fromGlobals(), $response); + + $session->setOptions([ + 'cache_limiter' => 'public', + 'cache_expire' => '180', + ]); + $session->start(); + + Assert::same( + [ + 'Set-Cookie' => ['PHPSESSID=' . $session->getId() . '; path=/; HttpOnly'], + 'Expires' => [Nette\Http\Helpers::formatDate(time() + 180 * 60)], + 'Cache-Control' => ['public, max-age=10800'], + 'Last-Modified' => [Nette\Http\Helpers::formatDate(getlastmod())], + ], + $response->getHeaders() + ); + + $session->close(); +}); + + +test(function () { + $factory = new Nette\Http\RequestFactory; + $response = new Nette\Http\Response; + $session = new Nette\Http\Session($factory->fromGlobals(), $response); + + $session->setOptions([ + 'cache_limiter' => 'private_no_expire', + 'cache_expire' => '180', + ]); + $session->start(); + + Assert::same( + [ + 'Set-Cookie' => ['PHPSESSID=' . $session->getId() . '; path=/; HttpOnly'], + 'Cache-Control' => ['private, max-age=10800'], + 'Last-Modified' => [Nette\Http\Helpers::formatDate(getlastmod())], + ], + $response->getHeaders() + ); + + $session->close(); +}); + + +test(function () { + $factory = new Nette\Http\RequestFactory; + $response = new Nette\Http\Response; + $session = new Nette\Http\Session($factory->fromGlobals(), $response); + + $session->setOptions([ + 'cache_limiter' => 'private', + 'cache_expire' => '180', + ]); + $session->start(); + + Assert::same( + [ + 'Set-Cookie' => ['PHPSESSID=' . $session->getId() . '; path=/; HttpOnly'], + 'Expires' => ['Mon, 23 Jan 1978 10:00:00 GMT'], + 'Cache-Control' => ['private, max-age=10800'], + 'Last-Modified' => [Nette\Http\Helpers::formatDate(getlastmod())], + ], + $response->getHeaders() + ); + + $session->close(); +}); + + +test(function () { + $factory = new Nette\Http\RequestFactory; + $response = new Nette\Http\Response; + $session = new Nette\Http\Session($factory->fromGlobals(), $response); + + $session->setOptions([ + 'cache_limiter' => 'nocache', + ]); + $session->start(); + + Assert::same( + [ + 'Set-Cookie' => ['PHPSESSID=' . $session->getId() . '; path=/; HttpOnly'], + 'Expires' => ['Mon, 23 Jan 1978 10:00:00 GMT'], + 'Cache-Control' => ['no-store, no-cache, must-revalidate'], + 'Pragma' => ['no-cache'], + ], + $response->getHeaders() + ); + + $session->close(); +}); From 695afba28c05e6c7a9d0e639de4ccea4d8dc1ba8 Mon Sep 17 00:00:00 2001 From: David Grudl Date: Fri, 27 Mar 2020 00:17:11 +0100 Subject: [PATCH 09/12] Session: internal callback changed to private --- src/Http/Session.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Http/Session.php b/src/Http/Session.php index d8ac4481..9aa710b8 100644 --- a/src/Http/Session.php +++ b/src/Http/Session.php @@ -148,7 +148,7 @@ private function initialize(): void } } - register_shutdown_function([$this, 'clean']); + register_shutdown_function(\Closure::fromCallable([$this, 'clean'])); } @@ -298,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; From e944078567a765203936ac6e64f2486f4389618d Mon Sep 17 00:00:00 2001 From: David Grudl Date: Mon, 18 Nov 2019 21:29:37 +0100 Subject: [PATCH 10/12] HttpExtension: cookieSecure is by default 'auto' (BC break) --- src/Bridges/HttpDI/HttpExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ]); } From c9fc147bbfca090d088062aa9b843b0d3b593857 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bar=C3=A1=C5=A1ek?= Date: Thu, 14 May 2020 12:22:36 +0200 Subject: [PATCH 11/12] FileUpload::getSanitizedName: Remove redundant minus before dot. (#179) --- src/Http/FileUpload.php | 2 +- tests/Http/FileUpload.basic.phpt | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) 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/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' => '', From 8e5fe1b9d0f39c49316584656d403a78b965fee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bar=C3=A1=C5=A1ek?= Date: Fri, 5 Jun 2020 17:20:43 +0200 Subject: [PATCH 12/12] Url: Add new method getDomainWithoutWww(). --- src/Http/UrlImmutable.php | 6 ++++++ 1 file changed, 6 insertions(+) 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) {