From 7314456cb0c58affea8214d05b2753b7ed0d7290 Mon Sep 17 00:00:00 2001 From: Roland Franssen Date: Sat, 19 Nov 2016 14:10:17 +0000 Subject: [PATCH] [HttpFoundation] Create cookie from string + synchronize response cookies --- .../Component/HttpFoundation/Cookie.php | 53 ++++++++++++- .../Component/HttpFoundation/HeaderBag.php | 19 ++--- .../Component/HttpFoundation/Response.php | 2 +- .../HttpFoundation/ResponseHeaderBag.php | 75 ++++++++++++++----- .../HttpFoundation/Tests/CookieTest.php | 9 +++ .../Tests/ResponseHeaderBagTest.php | 53 ++++++++++--- src/Symfony/Component/HttpKernel/Client.php | 11 +-- .../Component/HttpKernel/Tests/ClientTest.php | 4 +- 8 files changed, 173 insertions(+), 53 deletions(-) diff --git a/src/Symfony/Component/HttpFoundation/Cookie.php b/src/Symfony/Component/HttpFoundation/Cookie.php index 4119a46fe7e88..30d1e00ab257e 100644 --- a/src/Symfony/Component/HttpFoundation/Cookie.php +++ b/src/Symfony/Component/HttpFoundation/Cookie.php @@ -31,14 +31,63 @@ class Cookie const SAMESITE_LAX = 'lax'; const SAMESITE_STRICT = 'strict'; + /** + * Creates cookie from raw header string. + * + * @param string $cookie + * @param bool $decode + * + * @return static + */ + public static function fromString($cookie, $decode = false) + { + $data = array( + 'expires' => 0, + 'path' => '/', + 'domain' => null, + 'secure' => false, + 'httponly' => true, + 'raw' => !$decode, + 'samesite' => null, + ); + foreach (explode(';', $cookie) as $part) { + if (false === strpos($part, '=')) { + $key = trim($part); + $value = true; + } else { + list($key, $value) = explode('=', trim($part), 2); + $key = trim($key); + $value = trim($value); + } + if (!isset($data['name'])) { + $data['name'] = $decode ? urldecode($key) : $key; + $data['value'] = true === $value ? null : ($decode ? urldecode($value) : $value); + continue; + } + switch ($key = strtolower($key)) { + case 'name': + case 'value': + break; + case 'max-age': + $data['expires'] = time() + (int) $value; + break; + default: + $data[$key] = $value; + break; + } + } + + return new static($data['name'], $data['value'], $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite']); + } + /** * Constructor. * * @param string $name The name of the cookie - * @param string $value The value of the cookie + * @param string|null $value The value of the cookie * @param int|string|\DateTimeInterface $expire The time the cookie expires * @param string $path The path on the server in which the cookie will be available on - * @param string $domain The domain that the cookie is available to + * @param string|null $domain The domain that the cookie is available to * @param bool $secure Whether the cookie should only be transmitted over a secure HTTPS connection from the client * @param bool $httpOnly Whether the cookie will be made accessible only through the HTTP protocol * @param bool $raw Whether the cookie value should be sent with no url encoding diff --git a/src/Symfony/Component/HttpFoundation/HeaderBag.php b/src/Symfony/Component/HttpFoundation/HeaderBag.php index 29bac5e513414..3cc9e70243bca 100644 --- a/src/Symfony/Component/HttpFoundation/HeaderBag.php +++ b/src/Symfony/Component/HttpFoundation/HeaderBag.php @@ -40,14 +40,14 @@ public function __construct(array $headers = array()) */ public function __toString() { - if (!$this->headers) { + if (!$headers = $this->all()) { return ''; } - $max = max(array_map('strlen', array_keys($this->headers))) + 1; + ksort($headers); + $max = max(array_map('strlen', array_keys($headers))) + 1; $content = ''; - ksort($this->headers); - foreach ($this->headers as $name => $values) { + foreach ($headers as $name => $values) { $name = implode('-', array_map('ucfirst', explode('-', $name))); foreach ($values as $value) { $content .= sprintf("%-{$max}s %s\r\n", $name.':', $value); @@ -74,7 +74,7 @@ public function all() */ public function keys() { - return array_keys($this->headers); + return array_keys($this->all()); } /** @@ -112,8 +112,9 @@ public function add(array $headers) public function get($key, $default = null, $first = true) { $key = str_replace('_', '-', strtolower($key)); + $headers = $this->all(); - if (!array_key_exists($key, $this->headers)) { + if (!array_key_exists($key, $headers)) { if (null === $default) { return $first ? null : array(); } @@ -122,10 +123,10 @@ public function get($key, $default = null, $first = true) } if ($first) { - return count($this->headers[$key]) ? $this->headers[$key][0] : $default; + return count($headers[$key]) ? $headers[$key][0] : $default; } - return $this->headers[$key]; + return $headers[$key]; } /** @@ -161,7 +162,7 @@ public function set($key, $values, $replace = true) */ public function has($key) { - return array_key_exists(str_replace('_', '-', strtolower($key)), $this->headers); + return array_key_exists(str_replace('_', '-', strtolower($key)), $this->all()); } /** diff --git a/src/Symfony/Component/HttpFoundation/Response.php b/src/Symfony/Component/HttpFoundation/Response.php index 73f64f039ff3b..9391aeb73ee4c 100644 --- a/src/Symfony/Component/HttpFoundation/Response.php +++ b/src/Symfony/Component/HttpFoundation/Response.php @@ -375,7 +375,7 @@ public function sendHeaders() } // headers - foreach ($this->headers->allPreserveCase() as $name => $values) { + foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) { foreach ($values as $value) { header($name.': '.$value, false, $this->statusCode); } diff --git a/src/Symfony/Component/HttpFoundation/ResponseHeaderBag.php b/src/Symfony/Component/HttpFoundation/ResponseHeaderBag.php index 2b630d8a72276..df2931be05a2e 100644 --- a/src/Symfony/Component/HttpFoundation/ResponseHeaderBag.php +++ b/src/Symfony/Component/HttpFoundation/ResponseHeaderBag.php @@ -54,28 +54,28 @@ public function __construct(array $headers = array()) } /** - * {@inheritdoc} + * Returns the headers, with original capitalizations. + * + * @return array An array of headers */ - public function __toString() + public function allPreserveCase() { - $cookies = ''; - foreach ($this->getCookies() as $cookie) { - $cookies .= 'Set-Cookie: '.$cookie."\r\n"; + $headers = array(); + foreach ($this->all() as $name => $value) { + $headers[isset($this->headerNames[$name]) ? $this->headerNames[$name] : $name] = $value; } - ksort($this->headerNames); - - return parent::__toString().$cookies; + return $headers; } - /** - * Returns the headers, with original capitalizations. - * - * @return array An array of headers - */ - public function allPreserveCase() + public function allPreserveCaseWithoutCookies() { - return array_combine($this->headerNames, $this->headers); + $headers = $this->allPreserveCase(); + if (isset($this->headerNames['set-cookie'])) { + unset($headers[$this->headerNames['set-cookie']]); + } + + return $headers; } /** @@ -95,13 +95,39 @@ public function replace(array $headers = array()) /** * {@inheritdoc} */ - public function set($key, $values, $replace = true) + public function all() { - parent::set($key, $values, $replace); + $headers = parent::all(); + foreach ($this->getCookies() as $cookie) { + $headers['set-cookie'][] = (string) $cookie; + } + return $headers; + } + + /** + * {@inheritdoc} + */ + public function set($key, $values, $replace = true) + { $uniqueKey = str_replace('_', '-', strtolower($key)); + + if ('set-cookie' === $uniqueKey) { + if ($replace) { + $this->cookies = array(); + } + foreach ((array) $values as $cookie) { + $this->setCookie(Cookie::fromString($cookie)); + } + $this->headerNames[$uniqueKey] = $key; + + return; + } + $this->headerNames[$uniqueKey] = $key; + parent::set($key, $values, $replace); + // ensure the cache-control header has sensible defaults if (in_array($uniqueKey, array('cache-control', 'etag', 'last-modified', 'expires'))) { $computed = $this->computeCacheControlValue(); @@ -116,11 +142,17 @@ public function set($key, $values, $replace = true) */ public function remove($key) { - parent::remove($key); - $uniqueKey = str_replace('_', '-', strtolower($key)); unset($this->headerNames[$uniqueKey]); + if ('set-cookie' === $uniqueKey) { + $this->cookies = array(); + + return; + } + + parent::remove($key); + if ('cache-control' === $uniqueKey) { $this->computedCacheControl = array(); } @@ -150,6 +182,7 @@ public function getCacheControlDirective($key) public function setCookie(Cookie $cookie) { $this->cookies[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie; + $this->headerNames['set-cookie'] = 'Set-Cookie'; } /** @@ -174,6 +207,10 @@ public function removeCookie($name, $path = '/', $domain = null) unset($this->cookies[$domain]); } } + + if (empty($this->cookies)) { + unset($this->headerNames['set-cookie']); + } } /** diff --git a/src/Symfony/Component/HttpFoundation/Tests/CookieTest.php b/src/Symfony/Component/HttpFoundation/Tests/CookieTest.php index 0bda11cdeaf87..f7dd3f37dfa89 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/CookieTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/CookieTest.php @@ -178,4 +178,13 @@ public function testGetMaxAge() $cookie = new Cookie('foo', 'bar', $expire = time() - 100); $this->assertEquals($expire - time(), $cookie->getMaxAge()); } + + public function testFromString() + { + $cookie = Cookie::fromString('foo=bar; expires=Fri, 20-May-2011 15:25:52 GMT; path=/; domain=.myfoodomain.com; secure; httponly'); + $this->assertEquals(new Cookie('foo', 'bar', strtotime('Fri, 20-May-2011 15:25:52 GMT'), '/', '.myfoodomain.com', true, true, true), $cookie); + + $cookie = Cookie::fromString('foo=bar', true); + $this->assertEquals(new Cookie('foo', 'bar'), $cookie); + } } diff --git a/src/Symfony/Component/HttpFoundation/Tests/ResponseHeaderBagTest.php b/src/Symfony/Component/HttpFoundation/Tests/ResponseHeaderBagTest.php index c1f07a21f22ae..1b6b0d8ed6986 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/ResponseHeaderBagTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/ResponseHeaderBagTest.php @@ -124,11 +124,11 @@ public function testToStringIncludesCookieHeaders() $bag = new ResponseHeaderBag(array()); $bag->setCookie(new Cookie('foo', 'bar')); - $this->assertContains('Set-Cookie: foo=bar; path=/; httponly', explode("\r\n", $bag->__toString())); + $this->assertSetCookieHeader('foo=bar; path=/; httponly', $bag); $bag->clearCookie('foo'); - $this->assertRegExp('#^Set-Cookie: foo=deleted; expires='.gmdate('D, d-M-Y H:i:s T', time() - 31536001).'; max-age=-31536001; path=/; httponly#m', $bag->__toString()); + $this->assertSetCookieHeader('foo=deleted; expires='.gmdate('D, d-M-Y H:i:s T', time() - 31536001).'; max-age=-31536001; path=/; httponly', $bag); } public function testClearCookieSecureNotHttpOnly() @@ -137,7 +137,7 @@ public function testClearCookieSecureNotHttpOnly() $bag->clearCookie('foo', '/', null, true, false); - $this->assertRegExp('#^Set-Cookie: foo=deleted; expires='.gmdate('D, d-M-Y H:i:s T', time() - 31536001).'; max-age=-31536001; path=/; secure#m', $bag->__toString()); + $this->assertSetCookieHeader('foo=deleted; expires='.gmdate('D, d-M-Y H:i:s T', time() - 31536001).'; max-age=-31536001; path=/; secure', $bag); } public function testReplace() @@ -172,14 +172,21 @@ public function testCookiesWithSameNames() $bag->setCookie(new Cookie('foo', 'bar')); $this->assertCount(4, $bag->getCookies()); - - $headers = explode("\r\n", $bag->__toString()); - $this->assertContains('Set-Cookie: foo=bar; path=/path/foo; domain=foo.bar; httponly', $headers); - $this->assertContains('Set-Cookie: foo=bar; path=/path/foo; domain=foo.bar; httponly', $headers); - $this->assertContains('Set-Cookie: foo=bar; path=/path/bar; domain=bar.foo; httponly', $headers); - $this->assertContains('Set-Cookie: foo=bar; path=/; httponly', $headers); + $this->assertEquals('foo=bar; path=/path/foo; domain=foo.bar; httponly', $bag->get('set-cookie')); + $this->assertEquals(array( + 'foo=bar; path=/path/foo; domain=foo.bar; httponly', + 'foo=bar; path=/path/bar; domain=foo.bar; httponly', + 'foo=bar; path=/path/bar; domain=bar.foo; httponly', + 'foo=bar; path=/; httponly', + ), $bag->get('set-cookie', null, false)); + + $this->assertSetCookieHeader('foo=bar; path=/path/foo; domain=foo.bar; httponly', $bag); + $this->assertSetCookieHeader('foo=bar; path=/path/bar; domain=foo.bar; httponly', $bag); + $this->assertSetCookieHeader('foo=bar; path=/path/bar; domain=bar.foo; httponly', $bag); + $this->assertSetCookieHeader('foo=bar; path=/; httponly', $bag); $cookies = $bag->getCookies(ResponseHeaderBag::COOKIES_ARRAY); + $this->assertTrue(isset($cookies['foo.bar']['/path/foo']['foo'])); $this->assertTrue(isset($cookies['foo.bar']['/path/bar']['foo'])); $this->assertTrue(isset($cookies['bar.foo']['/path/bar']['foo'])); @@ -189,18 +196,23 @@ public function testCookiesWithSameNames() public function testRemoveCookie() { $bag = new ResponseHeaderBag(); + $this->assertFalse($bag->has('set-cookie')); + $bag->setCookie(new Cookie('foo', 'bar', 0, '/path/foo', 'foo.bar')); $bag->setCookie(new Cookie('bar', 'foo', 0, '/path/bar', 'foo.bar')); + $this->assertTrue($bag->has('set-cookie')); $cookies = $bag->getCookies(ResponseHeaderBag::COOKIES_ARRAY); $this->assertTrue(isset($cookies['foo.bar']['/path/foo'])); $bag->removeCookie('foo', '/path/foo', 'foo.bar'); + $this->assertTrue($bag->has('set-cookie')); $cookies = $bag->getCookies(ResponseHeaderBag::COOKIES_ARRAY); $this->assertFalse(isset($cookies['foo.bar']['/path/foo'])); $bag->removeCookie('bar', '/path/bar', 'foo.bar'); + $this->assertFalse($bag->has('set-cookie')); $cookies = $bag->getCookies(ResponseHeaderBag::COOKIES_ARRAY); $this->assertFalse(isset($cookies['foo.bar'])); @@ -224,6 +236,22 @@ public function testRemoveCookieWithNullRemove() $this->assertFalse(isset($cookies['']['/']['bar'])); } + public function testSetCookieHeader() + { + $bag = new ResponseHeaderBag(); + $bag->set('set-cookie', 'foo=bar'); + $this->assertEquals(array(new Cookie('foo', 'bar', 0, '/', null, false, true, true)), $bag->getCookies()); + + $bag->set('set-cookie', 'foo2=bar2', false); + $this->assertEquals(array( + new Cookie('foo', 'bar', 0, '/', null, false, true, true), + new Cookie('foo2', 'bar2', 0, '/', null, false, true, true), + ), $bag->getCookies()); + + $bag->remove('set-cookie'); + $this->assertEquals(array(), $bag->getCookies()); + } + /** * @expectedException \InvalidArgumentException */ @@ -231,7 +259,7 @@ public function testGetCookiesWithInvalidArgument() { $bag = new ResponseHeaderBag(); - $cookies = $bag->getCookies('invalid_argument'); + $bag->getCookies('invalid_argument'); } /** @@ -302,4 +330,9 @@ public function provideMakeDispositionFail() array('attachment', 'föö.html'), ); } + + protected function assertSetCookieHeader($expected, ResponseHeaderBag $actual) + { + $this->assertRegExp('#^Set-Cookie:\s+'.preg_quote($expected, '#').'$#m', str_replace("\r\n", "\n", (string) $actual)); + } } diff --git a/src/Symfony/Component/HttpKernel/Client.php b/src/Symfony/Component/HttpKernel/Client.php index 80b1bd6cd34e7..fd02f37921078 100644 --- a/src/Symfony/Component/HttpKernel/Client.php +++ b/src/Symfony/Component/HttpKernel/Client.php @@ -207,20 +207,11 @@ protected function filterFiles(array $files) */ protected function filterResponse($response) { - $headers = $response->headers->all(); - if ($response->headers->getCookies()) { - $cookies = array(); - foreach ($response->headers->getCookies() as $cookie) { - $cookies[] = new DomCookie($cookie->getName(), $cookie->getValue(), $cookie->getExpiresTime(), $cookie->getPath(), $cookie->getDomain(), $cookie->isSecure(), $cookie->isHttpOnly()); - } - $headers['Set-Cookie'] = $cookies; - } - // this is needed to support StreamedResponse ob_start(); $response->sendContent(); $content = ob_get_clean(); - return new DomResponse($content, $response->getStatusCode(), $headers); + return new DomResponse($content, $response->getStatusCode(), $response->headers->all()); } } diff --git a/src/Symfony/Component/HttpKernel/Tests/ClientTest.php b/src/Symfony/Component/HttpKernel/Tests/ClientTest.php index b5d2c9cedd893..294a513038e90 100644 --- a/src/Symfony/Component/HttpKernel/Tests/ClientTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/ClientTest.php @@ -57,8 +57,8 @@ public function testFilterResponseConvertsCookies() $m->setAccessible(true); $expected = array( - 'foo=bar; expires=Sun, 15 Feb 2009 20:00:00 GMT; domain=http://example.com; path=/foo; secure; httponly', - 'foo1=bar1; expires=Sun, 15 Feb 2009 20:00:00 GMT; domain=http://example.com; path=/foo; secure; httponly', + 'foo=bar; expires=Sun, 15-Feb-2009 20:00:00 GMT; max-age='.(strtotime('Sun, 15-Feb-2009 20:00:00 GMT') - time()).'; path=/foo; domain=http://example.com; secure; httponly', + 'foo1=bar1; expires=Sun, 15-Feb-2009 20:00:00 GMT; max-age='.(strtotime('Sun, 15-Feb-2009 20:00:00 GMT') - time()).'; path=/foo; domain=http://example.com; secure; httponly', ); $response = new Response();