diff --git a/framework/CHANGELOG.md b/framework/CHANGELOG.md index 9833be22ac4..0b47c48c78c 100644 --- a/framework/CHANGELOG.md +++ b/framework/CHANGELOG.md @@ -15,6 +15,7 @@ Yii Framework 2 Change Log - Enh #19884: Added support Enums in Query Builder (sk1t0n) - Bug #19908: Fix associative array cell content rendering in Table widget (rhertogh) - Bug #19906: Fixed multiline strings in the `\yii\console\widgets\Table` widget (rhertogh) +- Enh #19920: Broadened the accepted type of `Cookie::$expire` from `int` to `int|string|\DateTimeInterface|null` (rhertogh) 2.0.48.1 May 24, 2023 diff --git a/framework/web/Cookie.php b/framework/web/Cookie.php index ac9a7233710..d249afa6cbd 100644 --- a/framework/web/Cookie.php +++ b/framework/web/Cookie.php @@ -57,8 +57,8 @@ class Cookie extends \yii\base\BaseObject */ public $domain = ''; /** - * @var int the timestamp at which the cookie expires. This is the server timestamp. - * Defaults to 0, meaning "until the browser is closed". + * @var int|string|\DateTimeInterface|null the timestamp or date at which the cookie expires. This is the server timestamp. + * Defaults to 0, meaning "until the browser is closed" (the same applies to `null`). */ public $expire = 0; /** diff --git a/framework/web/CookieCollection.php b/framework/web/CookieCollection.php index 5c56e46f708..597fc2f5fd7 100644 --- a/framework/web/CookieCollection.php +++ b/framework/web/CookieCollection.php @@ -51,7 +51,7 @@ public function __construct($cookies = [], $config = []) * Returns an iterator for traversing the cookies in the collection. * This method is required by the SPL interface [[\IteratorAggregate]]. * It will be implicitly called when you use `foreach` to traverse the collection. - * @return ArrayIterator an iterator for traversing the cookies in the collection. + * @return ArrayIterator an iterator for traversing the cookies in the collection. */ #[\ReturnTypeWillChange] public function getIterator() @@ -113,7 +113,18 @@ public function getValue($name, $defaultValue = null) public function has($name) { return isset($this->_cookies[$name]) && $this->_cookies[$name]->value !== '' - && ($this->_cookies[$name]->expire === null || $this->_cookies[$name]->expire === 0 || $this->_cookies[$name]->expire >= time()); + && ($this->_cookies[$name]->expire === null + || $this->_cookies[$name]->expire === 0 + || ( + (is_string($this->_cookies[$name]->expire) && strtotime($this->_cookies[$name]->expire) >= time()) + || ( + interface_exists('\\DateTimeInterface') + && $this->_cookies[$name]->expire instanceof \DateTimeInterface + && $this->_cookies[$name]->expire->getTimestamp() >= time() + ) + || $this->_cookies[$name]->expire >= time() + ) + ); } /** @@ -174,7 +185,7 @@ public function removeAll() /** * Returns the collection as a PHP array. - * @return array the array representation of the collection. + * @return Cookie[] the array representation of the collection. * The array keys are cookie names, and the array values are the corresponding cookie objects. */ public function toArray() diff --git a/framework/web/Response.php b/framework/web/Response.php index 57349b599ef..1724438803e 100644 --- a/framework/web/Response.php +++ b/framework/web/Response.php @@ -401,12 +401,21 @@ protected function sendCookies() } foreach ($this->getCookies() as $cookie) { $value = $cookie->value; - if ($cookie->expire != 1 && isset($validationKey)) { + $expire = $cookie->expire; + if (is_string($expire)) { + $expire = strtotime($expire); + } elseif (interface_exists('\\DateTimeInterface') && $expire instanceof \DateTimeInterface) { + $expire = $expire->getTimestamp(); + } + if ($expire === null || $expire === false) { + $expire = 0; + } + if ($expire != 1 && isset($validationKey)) { $value = Yii::$app->getSecurity()->hashData(serialize([$cookie->name, $value]), $validationKey); } if (PHP_VERSION_ID >= 70300) { setcookie($cookie->name, $value, [ - 'expires' => $cookie->expire, + 'expires' => $expire, 'path' => $cookie->path, 'domain' => $cookie->domain, 'secure' => $cookie->secure, @@ -420,7 +429,7 @@ protected function sendCookies() if (!is_null($cookie->sameSite)) { $cookiePath .= '; samesite=' . $cookie->sameSite; } - setcookie($cookie->name, $value, $cookie->expire, $cookiePath, $cookie->domain, $cookie->secure, $cookie->httpOnly); + setcookie($cookie->name, $value, $expire, $cookiePath, $cookie->domain, $cookie->secure, $cookie->httpOnly); } } } diff --git a/tests/framework/web/ResponseTest.php b/tests/framework/web/ResponseTest.php index 07e55b7dfa3..2c49964fbcd 100644 --- a/tests/framework/web/ResponseTest.php +++ b/tests/framework/web/ResponseTest.php @@ -380,21 +380,116 @@ public function testSendFileWithInvalidCharactersInFileName() ); } - public function testSameSiteCookie() + /** + * @dataProvider cookiesTestProvider + */ + public function testCookies($cookieConfig, $expected) { $response = new Response(); - $response->cookies->add(new Cookie([ - 'name' => 'test', - 'value' => 'testValue', - 'sameSite' => Cookie::SAME_SITE_STRICT, - ])); + $response->cookies->add(new Cookie(array_merge( + [ + 'name' => 'test', + 'value' => 'testValue', + ], + $cookieConfig + ))); ob_start(); $response->send(); $content = ob_get_clean(); - // Only way to test is that it doesn't create any errors - $this->assertEquals('', $content); + $cookies = $this->parseHeaderCookies(); + if ($cookies === false) { + // Unable to resolve cookies, only way to test is that it doesn't create any errors + $this->assertEquals('', $content); + } else { + $testCookie = $cookies['test']; + $actual = array_intersect_key($testCookie, $expected); + ksort($actual); + ksort($expected); + $this->assertEquals($expected, $actual); + } + } + + public function cookiesTestProvider() + { + $expireInt = time() + 3600; + $expireString = date('D, d-M-Y H:i:s', $expireInt) . ' GMT'; + + $testCases = [ + 'same-site' => [ + ['sameSite' => Cookie::SAME_SITE_STRICT], + ['samesite' => Cookie::SAME_SITE_STRICT], + ], + 'expire-as-int' => [ + ['expire' => $expireInt], + ['expires' => $expireString], + ], + 'expire-as-string' => [ + ['expire' => $expireString], + ['expires' => $expireString], + ], + ]; + + if (version_compare(PHP_VERSION, '5.5.0', '>=')) { + $testCases = array_merge($testCases, [ + 'expire-as-date_time' => [ + ['expire' => new \DateTime('@' . $expireInt)], + ['expires' => $expireString], + ], + 'expire-as-date_time_immutable' => [ + ['expire' => new \DateTimeImmutable('@' . $expireInt)], + ['expires' => $expireString], + ], + ]); + } + + return $testCases; + } + + /** + * Tries to parse cookies set in the response headers. + * When running PHP on the CLI headers are not available (the `headers_list()` function always returns an + * empty array). If possible use xDebug: http://xdebug.org/docs/all_functions#xdebug_get_headers + * @param $name + * @return array|false + */ + protected function parseHeaderCookies() { + + if (!function_exists('xdebug_get_headers')) { + return false; + } + + $cookies = []; + foreach(xdebug_get_headers() as $header) { + if (strpos($header, 'Set-Cookie: ') !== 0) { + continue; + } + + $name = null; + $params = []; + $pairs = explode(';', substr($header, 12)); + foreach ($pairs as $index => $pair) { + $pair = trim($pair); + if (strpos($pair, '=') === false) { + $params[strtolower($pair)] = true; + } else { + list($paramName, $paramValue) = explode('=', $pair, 2); + if ($index === 0) { + $name = $paramName; + $params['value'] = urldecode($paramValue); + } else { + $params[strtolower($paramName)] = urldecode($paramValue); + } + } + } + if ($name === null) { + throw new \Exception('Could not determine cookie name for header "' . $header . '".'); + } + $cookies[$name] = $params; + } + + return $cookies; } /**