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}