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