1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus framework.
7: *
8: * (c) John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Cookie;
15:
16: use Nexus\Clock\Clock;
17: use Nexus\Clock\Extension\ImmutableClockAware;
18: use Psr\Clock\ClockInterface;
19:
20: /**
21: * An immutable HTTP cookie object.
22: */
23: final class Cookie implements CookieInterface
24: {
25: use ImmutableClockAware;
26:
27: private string $prefix;
28:
29: /**
30: * @var non-empty-string
31: */
32: private string $name;
33:
34: private string $value;
35:
36: /**
37: * @var non-empty-string
38: */
39: private string $path;
40:
41: private string $domain;
42:
43: /**
44: * @var int<0, max>
45: */
46: private int $expires;
47:
48: private bool $secure;
49: private bool $httpOnly;
50:
51: /**
52: * @var self::SAMESITE_LAX|self::SAMESITE_NONE|self::SAMESITE_STRICT
53: */
54: private string $sameSite;
55:
56: private bool $raw;
57: private bool $partitioned;
58:
59: /**
60: * @var array{
61: * prefix: string,
62: * max-age: int<0, max>,
63: * expires: int<0, max>,
64: * path: string,
65: * domain: string,
66: * secure: bool,
67: * httponly: bool,
68: * samesite: string,
69: * raw: bool,
70: * partitioned: bool,
71: * }
72: */
73: private static array $defaultOptions = [
74: 'prefix' => '',
75: 'max-age' => 0,
76: 'expires' => 0,
77: 'path' => '/',
78: 'domain' => '',
79: 'secure' => false,
80: 'httponly' => true,
81: 'samesite' => self::SAMESITE_LAX,
82: 'raw' => false,
83: 'partitioned' => false,
84: ];
85:
86: /**
87: * @param string $name The name of the cookie
88: * @param string $value The value of the cookie
89: * @param null|ClockInterface $clock The clock instance to use for time calculations. Defaults to the system clock.
90: * @param array{
91: * prefix?: string,
92: * max-age?: int<0, max>,
93: * expires?: \DateTimeInterface|int<0, max>|string,
94: * path?: string,
95: * domain?: string,
96: * secure?: bool,
97: * httponly?: bool,
98: * samesite?: null|string,
99: * raw?: bool,
100: * partitioned?: bool,
101: * } $options An associative array of options for the cookie.
102: * - `prefix`: A prefix to be added to the cookie name.
103: * - `max-age`: The maximum age of the cookie in seconds.
104: * - `expires`: The expiration time of the cookie (in seconds, a `DateTimeInterface` object, or string).
105: * - `path`: The path on the server in which the cookie will be available on.
106: * - `domain`: The domain that the cookie is available to.
107: * - `secure`: Indicates whether the cookie should only be transmitted over a secure HTTPS connection.
108: * - `httponly`: Indicates whether the cookie is accessible only through the HTTP protocol.
109: * - `samesite`: The SameSite attribute of the cookie (e.g., 'Strict', 'Lax', 'None') or `null` for default.
110: * - `raw`: Indicates whether the cookie value should be sent as-is without URL encoding.
111: * - `partitioned`: Indicates whether the cookie is tied to a top-level site in a cross-site context.
112: */
113: public function __construct(
114: string $name,
115: string $value = '',
116: array $options = [],
117: private readonly ?ClockInterface $clock = null,
118: ) {
119: if ('deleted' === $value) {
120: $value = '';
121: }
122:
123: $options = [...self::$defaultOptions, ...$options];
124: $expires = self::expiresTimestamp($options['expires']);
125:
126: // If both `Expires` and `Max-Age` attributes are set, `Max-Age` takes precedence.
127: if (isset($options['max-age']) && $options['max-age'] > 0) {
128: $expires = max($this->now() + $options['max-age'], 0);
129: }
130:
131: $this->expires = $expires;
132:
133: $prefix = $options['prefix'];
134: $path = '' === $options['path'] ? '/' : $options['path'];
135: $domain = $options['domain'];
136:
137: if ('' === $options['samesite']) {
138: $options['samesite'] = null;
139: }
140:
141: $sameSite = $options['samesite'] ?? self::SAMESITE_LAX;
142: $secure = $options['secure'];
143: $httpOnly = $options['httponly'];
144: $raw = $options['raw'];
145: $partitioned = $options['partitioned'];
146:
147: if (str_starts_with($name, CookieValidator::SECURE_PREFIX)) {
148: $name = substr($name, \strlen(CookieValidator::SECURE_PREFIX));
149: $prefix = CookieValidator::SECURE_PREFIX;
150: } elseif (str_starts_with($name, CookieValidator::HOST_PREFIX)) {
151: $name = substr($name, \strlen(CookieValidator::HOST_PREFIX));
152: $prefix = CookieValidator::HOST_PREFIX;
153: }
154:
155: CookieValidator::validateName($name, $raw);
156: CookieValidator::validatePartitioned($partitioned, $secure, $sameSite, $prefix);
157: CookieValidator::validatePrefix($prefix, $secure, $path, $domain);
158: CookieValidator::validateSameSite($sameSite, $secure);
159:
160: $this->name = $name;
161: $this->value = $value;
162: $this->prefix = $prefix;
163: $this->path = $path;
164: $this->domain = $domain;
165: $this->secure = $secure;
166: $this->httpOnly = $httpOnly;
167: $this->sameSite = $sameSite;
168: $this->raw = $raw;
169: $this->partitioned = $partitioned;
170: }
171:
172: #[\Override]
173: public function __toString(): string
174: {
175: $cookieParts = [];
176:
177: if ('' === $this->value) {
178: $cookieParts[] = \sprintf('%s=deleted', $this->getPrefixedName());
179: $cookieParts[] = 'Max-Age=0';
180: $cookieParts[] = 'Expires=Thu, 01 Jan 1970 00:00:00 GMT';
181: } else {
182: $value = $this->raw ? $this->value : rawurlencode($this->value);
183: $cookieParts[] = \sprintf('%s=%s', $this->getPrefixedName(), $value);
184:
185: if (0 !== $this->expires) {
186: $cookieParts[] = \sprintf('Max-Age=%d', $this->getMaxAge());
187: $cookieParts[] = \sprintf('Expires=%s', gmdate(DATE_RFC7231, $this->expires));
188: }
189: }
190:
191: $cookieParts[] = \sprintf('Path=%s', $this->path);
192:
193: if ('' !== $this->domain) {
194: $cookieParts[] = \sprintf('Domain=%s', $this->domain);
195: }
196:
197: if ($this->secure) {
198: $cookieParts[] = 'Secure';
199: }
200:
201: if ($this->httpOnly) {
202: $cookieParts[] = 'HttpOnly';
203: }
204:
205: $cookieParts[] = \sprintf('SameSite=%s', $this->sameSite);
206:
207: if ($this->partitioned) {
208: $cookieParts[] = 'Partitioned';
209: }
210:
211: return implode('; ', $cookieParts);
212: }
213:
214: #[\Override]
215: public static function fromHeader(string $header, bool $raw = false): self
216: {
217: $options = self::$defaultOptions;
218: $options['raw'] = $raw;
219:
220: $parts = preg_split('/;\s*/', $header);
221: \assert(\is_array($parts));
222:
223: [$name, $value] = explode('=', array_shift($parts), 2);
224: $name = $raw ? $name : urldecode($name);
225: $value = $raw ? $value : urldecode($value);
226:
227: foreach ($parts as $part) {
228: if (str_contains($part, '=')) {
229: [$attribute, $attrValue] = explode('=', $part, 2);
230: } else {
231: $attribute = $part;
232: $attrValue = true;
233: }
234:
235: $attribute = strtolower($attribute);
236:
237: if (\array_key_exists($attribute, $options)) {
238: if ('max-age' === $attribute) {
239: $options[$attribute] = max((int) $attrValue, 0);
240: } elseif (\in_array($attribute, ['secure', 'httponly', 'raw', 'partitioned'], true)) {
241: $options[$attribute] = filter_var($attrValue, FILTER_VALIDATE_BOOL);
242: } else {
243: \assert(\is_string($attrValue));
244: $options[$attribute] = $attrValue;
245: }
246: }
247: }
248:
249: return new self($name, $value, $options);
250: }
251:
252: #[\Override]
253: public function getPrefix(): string
254: {
255: return $this->prefix;
256: }
257:
258: #[\Override]
259: public function getName(): string
260: {
261: return $this->name;
262: }
263:
264: #[\Override]
265: public function getPrefixedName(): string
266: {
267: $name = $this->prefix;
268:
269: if ($this->raw) {
270: $name .= $this->name;
271: } else {
272: $search = str_split(CookieValidator::RESERVED_CHARS_LIST);
273: $replace = array_map(rawurlencode(...), $search);
274:
275: $name .= str_replace($search, $replace, $this->name);
276: }
277:
278: return $name;
279: }
280:
281: #[\Override]
282: public function getValue(): string
283: {
284: return $this->value;
285: }
286:
287: #[\Override]
288: public function getPath(): string
289: {
290: return $this->path;
291: }
292:
293: #[\Override]
294: public function getDomain(): string
295: {
296: return $this->domain;
297: }
298:
299: #[\Override]
300: public function getMaxAge(): int
301: {
302: return max($this->expires - $this->now(), 0);
303: }
304:
305: #[\Override]
306: public function getExpiresTimestamp(): int
307: {
308: return $this->expires;
309: }
310:
311: #[\Override]
312: public function isExpired(): bool
313: {
314: return 0 === $this->expires || $this->expires <= $this->now();
315: }
316:
317: #[\Override]
318: public function isSecure(): bool
319: {
320: return $this->secure;
321: }
322:
323: #[\Override]
324: public function isHttpOnly(): bool
325: {
326: return $this->httpOnly;
327: }
328:
329: #[\Override]
330: public function isRaw(): bool
331: {
332: return $this->raw;
333: }
334:
335: #[\Override]
336: public function isPartitioned(): bool
337: {
338: return $this->partitioned;
339: }
340:
341: #[\Override]
342: public function getSameSite(): string
343: {
344: return $this->sameSite;
345: }
346:
347: #[\Override]
348: public function getOptions(): array
349: {
350: return [
351: 'expires' => $this->expires,
352: 'path' => $this->path,
353: 'domain' => $this->domain,
354: 'secure' => $this->secure,
355: 'httponly' => $this->httpOnly,
356: 'samesite' => $this->sameSite,
357: ];
358: }
359:
360: #[\Override]
361: public function withPrefix(string $prefix): self
362: {
363: CookieValidator::validatePrefix($prefix, $this->secure, $this->path, $this->domain);
364:
365: $cookie = clone $this;
366: $cookie->prefix = $prefix;
367:
368: return $cookie;
369: }
370:
371: #[\Override]
372: public function withName(string $name): self
373: {
374: CookieValidator::validateName($name, $this->raw);
375:
376: $cookie = clone $this;
377: $cookie->name = $name;
378:
379: return $cookie;
380: }
381:
382: #[\Override]
383: public function withValue(string $value): self
384: {
385: if ('deleted' === $value) {
386: $value = '';
387: }
388:
389: $cookie = clone $this;
390: $cookie->value = $value;
391:
392: return $cookie;
393: }
394:
395: #[\Override]
396: public function withPath(string $path): self
397: {
398: $path = '' === $path ? '/' : $path;
399: CookieValidator::validatePrefix($this->prefix, $this->secure, $path, $this->domain);
400:
401: $cookie = clone $this;
402: $cookie->path = $path;
403:
404: return $cookie;
405: }
406:
407: #[\Override]
408: public function withDomain(string $domain): self
409: {
410: CookieValidator::validatePrefix($this->prefix, $this->secure, $this->path, $domain);
411:
412: $cookie = clone $this;
413: $cookie->domain = $domain;
414:
415: return $cookie;
416: }
417:
418: #[\Override]
419: public function withMaxAge(int $maxAge): self
420: {
421: if (0 > $maxAge) {
422: throw new \InvalidArgumentException('Max-Age must be greater than or equal to 0.');
423: }
424:
425: $cookie = clone $this;
426: $cookie->expires = max($this->now() + $maxAge, 0);
427:
428: return $cookie;
429: }
430:
431: #[\Override]
432: public function withExpiresTime(\DateTimeInterface|int|string $expires): self
433: {
434: $cookie = clone $this;
435: $cookie->expires = self::expiresTimestamp($expires);
436:
437: return $cookie;
438: }
439:
440: #[\Override]
441: public function withSecure(bool $secure): self
442: {
443: CookieValidator::validatePartitioned($this->partitioned, $secure, $this->sameSite, $this->prefix);
444: CookieValidator::validatePrefix($this->prefix, $secure, $this->path, $this->domain);
445: CookieValidator::validateSameSite($this->sameSite, $secure);
446:
447: $cookie = clone $this;
448: $cookie->secure = $secure;
449:
450: return $cookie;
451: }
452:
453: #[\Override]
454: public function withHttpOnly(bool $httpOnly): self
455: {
456: $cookie = clone $this;
457: $cookie->httpOnly = $httpOnly;
458:
459: return $cookie;
460: }
461:
462: #[\Override]
463: public function withSameSite(?string $sameSite): self
464: {
465: if ('' === $sameSite) {
466: $sameSite = null;
467: }
468:
469: $sameSite ??= self::SAMESITE_LAX;
470: CookieValidator::validateSameSite($sameSite, $this->secure);
471:
472: $cookie = clone $this;
473: $cookie->sameSite = $sameSite;
474:
475: return $cookie;
476: }
477:
478: #[\Override]
479: public function withRaw(bool $raw): self
480: {
481: CookieValidator::validateName($this->name, $raw);
482:
483: $cookie = clone $this;
484: $cookie->raw = $raw;
485:
486: return $cookie;
487: }
488:
489: #[\Override]
490: public function withPartitioned(bool $partitioned): self
491: {
492: CookieValidator::validatePartitioned($partitioned, $this->secure, $this->sameSite, $this->prefix);
493:
494: $cookie = clone $this;
495: $cookie->partitioned = $partitioned;
496:
497: return $cookie;
498: }
499:
500: /**
501: * @return int<0, max>
502: */
503: private static function expiresTimestamp(\DateTimeInterface|int|string $expires): int
504: {
505: if ($expires instanceof \DateTimeInterface) {
506: $expires = $expires->getTimestamp();
507: }
508:
509: if (\is_string($expires)) {
510: $expires = strtotime($expires);
511:
512: if (false === $expires) {
513: throw new \InvalidArgumentException('Expires time is not a valid date string.');
514: }
515: }
516:
517: return max($expires, 0);
518: }
519: }
520: