Skip to content

Commit acffc7a

Browse files
committed
🍪 cookie!
1 parent 2f3cd38 commit acffc7a

File tree

4 files changed

+476
-3
lines changed

4 files changed

+476
-3
lines changed

src/Cookie.php

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
<?php
2+
/**
3+
* Class Cookie
4+
*
5+
* @created 27.02.2024
6+
* @author smiley <smiley@chillerlan.net>
7+
* @copyright 2024 smiley
8+
* @license MIT
9+
*/
10+
11+
namespace chillerlan\HTTP\Utils;
12+
13+
use DateInterval, DateTime, DateTimeInterface, DateTimeZone, InvalidArgumentException, RuntimeException;
14+
use function idn_to_ascii, implode, in_array, mb_strtolower, rawurlencode, sprintf, str_replace, strtolower, trim;
15+
16+
/**
17+
* @see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1
18+
*/
19+
class Cookie{
20+
21+
public const RESERVED_CHARACTERS = ["\t", "\n", "\v", "\f", "\r", "\x0E", ' ', ',', ';', '='];
22+
23+
protected string $name;
24+
protected string $value;
25+
protected DateTimeInterface|null $expiry = null;
26+
protected int $maxAge = 0;
27+
protected string|null $domain = null;
28+
protected string|null $path = null;
29+
protected bool $secure = false;
30+
protected bool $httpOnly = false;
31+
protected string|null $sameSite = null;
32+
33+
/**
34+
*
35+
*/
36+
public function __construct(string $name, string|null $value = null){
37+
$this->withNameAndValue($name, ($value ?? ''));
38+
}
39+
40+
/**
41+
*
42+
*/
43+
public function __toString():string{
44+
$cookie = [sprintf('%s=%s', $this->name, $this->value)];
45+
46+
if($this->expiry !== null){
47+
48+
if($this->value === ''){
49+
// set a date in the past to delete the cookie
50+
$this->withExpiry(0);
51+
}
52+
53+
$cookie[] = sprintf('Expires=%s; Max-Age=%s', $this->expiry->format(DateTimeInterface::COOKIE), $this->maxAge);
54+
}
55+
56+
if($this->domain !== null){
57+
$cookie[] = sprintf('Domain=%s', $this->domain);
58+
}
59+
60+
if($this->path !== null){
61+
$cookie[] = sprintf('Path=%s', $this->path);
62+
}
63+
64+
if($this->secure === true){
65+
$cookie[] = 'Secure';
66+
}
67+
68+
if($this->httpOnly === true){
69+
$cookie[] = 'HttpOnly';
70+
}
71+
72+
if($this->sameSite !== null){
73+
74+
if($this->sameSite === 'none' && !$this->secure){
75+
throw new InvalidArgumentException('The same site attribute can only be "none" when secure is set to true');
76+
}
77+
78+
$cookie[] = sprintf('SameSite=%s', $this->sameSite);
79+
}
80+
81+
return implode('; ', $cookie);
82+
}
83+
84+
/**
85+
* @see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1
86+
* @see https://github.com/symfony/symfony/blob/de93ccde2a1be2a46dbc6e10d5541a0f07e22e33/src/Symfony/Component/HttpFoundation/Cookie.php#L100-L102
87+
*/
88+
public function withNameAndValue(string $name, string $value):static{
89+
$name = trim($name);
90+
91+
if($name === ''){
92+
throw new InvalidArgumentException('The cookie name cannot be empty.');
93+
}
94+
95+
if(str_replace(static::RESERVED_CHARACTERS, '', $name) !== $name){
96+
throw new InvalidArgumentException('The cookie name contains invalid (reserved) characters.');
97+
}
98+
99+
$this->name = $name;
100+
$this->value = rawurlencode(trim($value));
101+
102+
return $this;
103+
}
104+
105+
/**
106+
* @see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.1
107+
* @see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.2
108+
*/
109+
public function withExpiry(DateTimeInterface|DateInterval|int|null $expiry):static{
110+
111+
if($expiry === null){
112+
$this->expiry = null;
113+
$this->maxAge = 0;
114+
115+
return $this;
116+
}
117+
118+
$dt = (new DateTime)->setTimezone(new DateTimeZone('GMT'));
119+
$now = $dt->getTimestamp();
120+
121+
$this->expiry = match(true){
122+
$expiry instanceof DateTimeInterface => $expiry,
123+
$expiry instanceof DateInterval => $dt->add($expiry),
124+
// 0 is supposed to delete the cookie, set a magic number: 01-Jan-1970 12:34:56
125+
$expiry === 0 => $dt->setTimestamp(45296),
126+
// assuming a relative time interval
127+
$expiry < $now => $dt->setTimestamp($now + $expiry),
128+
// timestamp in the future (incl. now, which will delete the cookie)
129+
$expiry >= $now => $dt->setTimestamp($expiry),
130+
};
131+
132+
$this->maxAge = ($this->expiry->getTimestamp() - $now);
133+
134+
if($this->maxAge < 0){
135+
$this->maxAge = 0;
136+
}
137+
138+
return $this;
139+
}
140+
141+
/**
142+
* @see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.3
143+
*/
144+
public function withDomain(string|null $domain, bool|null $punycode = null):static{
145+
146+
if($domain !== null){
147+
$domain = mb_strtolower(trim($domain));
148+
149+
// take care of multibyte domain names (IDN)
150+
if($punycode === true){
151+
$domain = idn_to_ascii($domain);
152+
153+
if($domain === false){
154+
throw new RuntimeException('Could not convert the given domain to IDN'); // @codeCoverageIgnore
155+
}
156+
}
157+
}
158+
159+
$this->domain = $domain;
160+
161+
return $this;
162+
}
163+
164+
/**
165+
* @see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.4
166+
*/
167+
public function withPath(string|null $path):static{
168+
169+
if($path !== null){
170+
$path = trim($path);
171+
172+
if($path === ''){
173+
$path = '/';
174+
}
175+
}
176+
177+
$this->path = $path;
178+
179+
return $this;
180+
}
181+
182+
/**
183+
* @see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.5
184+
*/
185+
public function withSecure(bool $secure):static{
186+
$this->secure = $secure;
187+
188+
return $this;
189+
}
190+
191+
/**
192+
* @see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.6
193+
*/
194+
public function withHttpOnly(bool $httpOnly):static{
195+
$this->httpOnly = $httpOnly;
196+
197+
return $this;
198+
}
199+
200+
/**
201+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
202+
*/
203+
public function withSameSite(string|null $sameSite):static{
204+
205+
if($sameSite !== null){
206+
$sameSite = strtolower(trim($sameSite));
207+
208+
if(!in_array($sameSite, ['lax', 'strict', 'none'])){
209+
throw new InvalidArgumentException('The same site attribute must be "lax", "strict" or "none"');
210+
}
211+
}
212+
213+
$this->sameSite = $sameSite;
214+
215+
return $this;
216+
}
217+
218+
}

src/MessageUtil.php

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
namespace chillerlan\HTTP\Utils;
1414

1515
use Psr\Http\Message\{MessageInterface, RequestInterface, ResponseInterface, ServerRequestInterface};
16-
use RuntimeException, Throwable;
17-
use function call_user_func, extension_loaded, function_exists, gzdecode, gzinflate, gzuncompress, implode,
18-
in_array, json_decode, json_encode, simplexml_load_string, sprintf, strtolower, trim;
16+
use DateInterval, DateTimeInterface, RuntimeException, Throwable;
17+
use function array_map, explode, extension_loaded, function_exists, gzdecode, gzinflate, gzuncompress, implode,
18+
in_array, json_decode, json_encode, rawurldecode, simplexml_load_string, sprintf, strtolower, trim;
1919
use const JSON_THROW_ON_ERROR;
2020

2121
/**
@@ -181,4 +181,62 @@ public static function setContentTypeHeader(
181181
return $message->withHeader('Content-Type', $mime);
182182
}
183183

184+
/**
185+
* Adds a Set-Cookie header to a ResponseInterface (convenience)
186+
*
187+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
188+
*/
189+
public static function setCookie(
190+
ResponseInterface $message,
191+
string $name,
192+
string|null $value = null,
193+
DateTimeInterface|DateInterval|int|null $expiry = null,
194+
string|null $domain = null,
195+
string|null $path = null,
196+
bool $secure = false,
197+
bool $httpOnly = false,
198+
string|null $sameSite = null,
199+
):ResponseInterface{
200+
201+
$cookie = (new Cookie($name, $value))
202+
->withExpiry($expiry)
203+
->withDomain($domain)
204+
->withPath($path)
205+
->withSecure($secure)
206+
->withHttpOnly($httpOnly)
207+
->withSameSite($sameSite)
208+
;
209+
210+
return $message->withAddedHeader('Set-Cookie', (string)$cookie);
211+
}
212+
213+
/**
214+
* Attempts to extract and parse a cookie from a "Cookie" (user-agent) header
215+
*
216+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie
217+
*/
218+
public static function getCookiesFromHeader(MessageInterface $message):array|null{
219+
220+
if(!$message->hasHeader('Cookie')){
221+
return null;
222+
}
223+
224+
$header = trim($message->getHeaderLine('Cookie'));
225+
226+
if(empty($header)){
227+
return null;
228+
}
229+
230+
$cookies = [];
231+
232+
// some people apparently use regular expressions for this (:
233+
foreach(array_map('trim', explode(';', $header)) as $kv){
234+
[$name, $value] = array_map('trim', explode('=', $kv, 2));
235+
236+
$cookies[$name] = rawurldecode($value);
237+
}
238+
239+
return $cookies;
240+
}
241+
184242
}

0 commit comments

Comments
 (0)