<?php /** * This file is part of CodeIgniter 4 framework. * * (c) CodeIgniter Foundation <admin@codeigniter.com> * * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace CodeIgniter\Cookie; use ArrayAccess; use CodeIgniter\Cookie\Exceptions\CookieException; use Config\Cookie as CookieConfig; use DateTimeInterface; use InvalidArgumentException; use LogicException; use ReturnTypeWillChange; /** * A `Cookie` class represents an immutable HTTP cookie value object. * * Being immutable, modifying one or more of its attributes will return * a new `Cookie` instance, rather than modifying itself. Users should * reassign this new instance to a new variable to capture it. * * ```php * $cookie = new Cookie('test_cookie', 'test_value'); * $cookie->getName(); // test_cookie * * $cookie->withName('prod_cookie'); * $cookie->getName(); // test_cookie * * $cookie2 = $cookie->withName('prod_cookie'); * $cookie2->getName(); // prod_cookie * ``` */ class Cookie implements ArrayAccess, CloneableCookieInterface { /** * @var string */ protected $prefix = ''; /** * @var string */ protected $name; /** * @var string */ protected $value; /** * @var int */ protected $expires; /** * @var string */ protected $path = '/'; /** * @var string */ protected $domain = ''; /** * @var bool */ protected $secure = false; /** * @var bool */ protected $httponly = true; /** * @var string */ protected $samesite = self::SAMESITE_LAX; /** * @var bool */ protected $raw = false; /** * Default attributes for a Cookie object. The keys here are the * lowercase attribute names. Do not camelCase! * * @var array<string, mixed> */ private static $defaults = [ 'prefix' => '', 'expires' => 0, 'path' => '/', 'domain' => '', 'secure' => false, 'httponly' => true, 'samesite' => self::SAMESITE_LAX, 'raw' => false, ]; /** * A cookie name can be any US-ASCII characters, except control characters, * spaces, tabs, or separator characters. * * @var string * * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes * @see https://tools.ietf.org/html/rfc2616#section-2.2 */ private static $reservedCharsList = "=,; \t\r\n\v\f()<>@:\\\"/[]?{}"; /** * Set the default attributes to a Cookie instance by injecting * the values from the `CookieConfig` config or an array. * * @param array<string, mixed>|CookieConfig $config * * @return array<string, mixed> The old defaults array. Useful for resetting. */ public static function setDefaults($config = []) { $oldDefaults = self::$defaults; $newDefaults = []; if ($config instanceof CookieConfig) { $newDefaults = [ 'prefix' => $config->prefix, 'expires' => $config->expires, 'path' => $config->path, 'domain' => $config->domain, 'secure' => $config->secure, 'httponly' => $config->httponly, 'samesite' => $config->samesite, 'raw' => $config->raw, ]; } elseif (is_array($config)) { $newDefaults = $config; } // This array union ensures that even if passed `$config` is not // `CookieConfig` or `array`, no empty defaults will occur. self::$defaults = $newDefaults + $oldDefaults; return $oldDefaults; } //========================================================================= // CONSTRUCTORS //========================================================================= /** * Create a new Cookie instance from a `Set-Cookie` header. * * @throws CookieException * * @return static */ public static function fromHeaderString(string $cookie, bool $raw = false) { $data = self::$defaults; $data['raw'] = $raw; $parts = preg_split('/\;[\s]*/', $cookie); $part = explode('=', array_shift($parts), 2); $name = $raw ? $part[0] : urldecode($part[0]); $value = isset($part[1]) ? ($raw ? $part[1] : urldecode($part[1])) : ''; unset($part); foreach ($parts as $part) { if (strpos($part, '=') !== false) { [$attr, $val] = explode('=', $part); } else { $attr = $part; $val = true; } $data[strtolower($attr)] = $val; } return new static($name, $value, $data); } /** * Construct a new Cookie instance. * * @param string $name The cookie's name * @param string $value The cookie's value * @param array<string, mixed> $options The cookie's options * * @throws CookieException */ final public function __construct(string $name, string $value = '', array $options = []) { $options += self::$defaults; $options['expires'] = static::convertExpiresTimestamp($options['expires']); // If both `Expires` and `Max-Age` are set, `Max-Age` has precedence. if (isset($options['max-age']) && is_numeric($options['max-age'])) { $options['expires'] = time() + (int) $options['max-age']; unset($options['max-age']); } // to preserve backward compatibility with array-based cookies in previous CI versions $prefix = $options['prefix'] ?: self::$defaults['prefix']; $path = $options['path'] ?: self::$defaults['path']; $domain = $options['domain'] ?: self::$defaults['domain']; // empty string SameSite should use the default for browsers $samesite = $options['samesite'] ?: self::$defaults['samesite']; $raw = $options['raw']; $secure = $options['secure']; $httponly = $options['httponly']; $this->validateName($name, $raw); $this->validatePrefix($prefix, $secure, $path, $domain); $this->validateSameSite($samesite, $secure); $this->prefix = $prefix; $this->name = $name; $this->value = $value; $this->expires = static::convertExpiresTimestamp($options['expires']); $this->path = $path; $this->domain = $domain; $this->secure = $secure; $this->httponly = $httponly; $this->samesite = ucfirst(strtolower($samesite)); $this->raw = $raw; } //========================================================================= // GETTERS //========================================================================= /** * {@inheritDoc} */ public function getId(): string { return implode(';', [$this->getPrefixedName(), $this->getPath(), $this->getDomain()]); } /** * {@inheritDoc} */ public function getPrefix(): string { return $this->prefix; } /** * {@inheritDoc} */ public function getName(): string { return $this->name; } /** * {@inheritDoc} */ public function getPrefixedName(): string { $name = $this->getPrefix(); if ($this->isRaw()) { $name .= $this->getName(); } else { $search = str_split(self::$reservedCharsList); $replace = array_map('rawurlencode', $search); $name .= str_replace($search, $replace, $this->getName()); } return $name; } /** * {@inheritDoc} */ public function getValue(): string { return $this->value; } /** * {@inheritDoc} */ public function getExpiresTimestamp(): int { return $this->expires; } /** * {@inheritDoc} */ public function getExpiresString(): string { return gmdate(self::EXPIRES_FORMAT, $this->expires); } /** * {@inheritDoc} */ public function isExpired(): bool { return $this->expires === 0 || $this->expires < time(); } /** * {@inheritDoc} */ public function getMaxAge(): int { $maxAge = $this->expires - time(); return $maxAge >= 0 ? $maxAge : 0; } /** * {@inheritDoc} */ public function getPath(): string { return $this->path; } /** * {@inheritDoc} */ public function getDomain(): string { return $this->domain; } /** * {@inheritDoc} */ public function isSecure(): bool { return $this->secure; } /** * {@inheritDoc} */ public function isHTTPOnly(): bool { return $this->httponly; } /** * {@inheritDoc} */ public function getSameSite(): string { return $this->samesite; } /** * {@inheritDoc} */ public function isRaw(): bool { return $this->raw; } /** * {@inheritDoc} */ public function getOptions(): array { // This is the order of options in `setcookie`. DO NOT CHANGE. return [ 'expires' => $this->expires, 'path' => $this->path, 'domain' => $this->domain, 'secure' => $this->secure, 'httponly' => $this->httponly, 'samesite' => $this->samesite ?: ucfirst(self::SAMESITE_LAX), ]; } //========================================================================= // CLONING //========================================================================= /** * {@inheritDoc} */ public function withPrefix(string $prefix = '') { $this->validatePrefix($prefix, $this->secure, $this->path, $this->domain); $cookie = clone $this; $cookie->prefix = $prefix; return $cookie; } /** * {@inheritDoc} */ public function withName(string $name) { $this->validateName($name, $this->raw); $cookie = clone $this; $cookie->name = $name; return $cookie; } /** * {@inheritDoc} */ public function withValue(string $value) { $cookie = clone $this; $cookie->value = $value; return $cookie; } /** * {@inheritDoc} */ public function withExpires($expires) { $cookie = clone $this; $cookie->expires = static::convertExpiresTimestamp($expires); return $cookie; } /** * {@inheritDoc} */ public function withExpired() { $cookie = clone $this; $cookie->expires = 0; return $cookie; } /** * {@inheritDoc} */ public function withNeverExpiring() { $cookie = clone $this; $cookie->expires = time() + 5 * YEAR; return $cookie; } /** * {@inheritDoc} */ public function withPath(?string $path) { $path = $path ?: self::$defaults['path']; $this->validatePrefix($this->prefix, $this->secure, $path, $this->domain); $cookie = clone $this; $cookie->path = $path; return $cookie; } /** * {@inheritDoc} */ public function withDomain(?string $domain) { $domain = $domain ?? self::$defaults['domain']; $this->validatePrefix($this->prefix, $this->secure, $this->path, $domain); $cookie = clone $this; $cookie->domain = $domain; return $cookie; } /** * {@inheritDoc} */ public function withSecure(bool $secure = true) { $this->validatePrefix($this->prefix, $secure, $this->path, $this->domain); $this->validateSameSite($this->samesite, $secure); $cookie = clone $this; $cookie->secure = $secure; return $cookie; } /** * {@inheritDoc} */ public function withHTTPOnly(bool $httponly = true) { $cookie = clone $this; $cookie->httponly = $httponly; return $cookie; } /** * {@inheritDoc} */ public function withSameSite(string $samesite) { $this->validateSameSite($samesite, $this->secure); $cookie = clone $this; $cookie->samesite = ucfirst(strtolower($samesite)); return $cookie; } /** * {@inheritDoc} */ public function withRaw(bool $raw = true) { $this->validateName($this->name, $raw); $cookie = clone $this; $cookie->raw = $raw; return $cookie; } //========================================================================= // ARRAY ACCESS FOR BC //========================================================================= /** * Whether an offset exists. * * @param mixed $offset */ public function offsetExists($offset): bool { return $offset === 'expire' ? true : property_exists($this, $offset); } /** * Offset to retrieve. * * @param mixed $offset * * @throws InvalidArgumentException * * @return mixed */ #[ReturnTypeWillChange] public function offsetGet($offset) { if (! $this->offsetExists($offset)) { throw new InvalidArgumentException(sprintf('Undefined offset "%s".', $offset)); } return $offset === 'expire' ? $this->expires : $this->{$offset}; } /** * Offset to set. * * @param mixed $offset * @param mixed $value * * @throws LogicException */ public function offsetSet($offset, $value): void { throw new LogicException(sprintf('Cannot set values of properties of %s as it is immutable.', static::class)); } /** * Offset to unset. * * @param mixed $offset * * @throws LogicException */ public function offsetUnset($offset): void { throw new LogicException(sprintf('Cannot unset values of properties of %s as it is immutable.', static::class)); } //========================================================================= // CONVERTERS //========================================================================= /** * {@inheritDoc} */ public function toHeaderString(): string { return $this->__toString(); } /** * {@inheritDoc} */ public function __toString() { $cookieHeader = []; if ($this->getValue() === '') { $cookieHeader[] = $this->getPrefixedName() . '=deleted'; $cookieHeader[] = 'Expires=' . gmdate(self::EXPIRES_FORMAT, 0); $cookieHeader[] = 'Max-Age=0'; } else { $value = $this->isRaw() ? $this->getValue() : rawurlencode($this->getValue()); $cookieHeader[] = sprintf('%s=%s', $this->getPrefixedName(), $value); if ($this->getExpiresTimestamp() !== 0) { $cookieHeader[] = 'Expires=' . $this->getExpiresString(); $cookieHeader[] = 'Max-Age=' . $this->getMaxAge(); } } if ($this->getPath() !== '') { $cookieHeader[] = 'Path=' . $this->getPath(); } if ($this->getDomain() !== '') { $cookieHeader[] = 'Domain=' . $this->getDomain(); } if ($this->isSecure()) { $cookieHeader[] = 'Secure'; } if ($this->isHTTPOnly()) { $cookieHeader[] = 'HttpOnly'; } $samesite = $this->getSameSite(); if ($samesite === '') { // modern browsers warn in console logs that an empty SameSite attribute // will be given the `Lax` value $samesite = self::SAMESITE_LAX; } $cookieHeader[] = 'SameSite=' . ucfirst(strtolower($samesite)); return implode('; ', $cookieHeader); } /** * {@inheritDoc} */ public function toArray(): array { return [ 'name' => $this->name, 'value' => $this->value, 'prefix' => $this->prefix, 'raw' => $this->raw, ] + $this->getOptions(); } /** * Converts expires time to Unix format. * * @param DateTimeInterface|int|string $expires */ protected static function convertExpiresTimestamp($expires = 0): int { if ($expires instanceof DateTimeInterface) { $expires = $expires->format('U'); } if (! is_string($expires) && ! is_int($expires)) { throw CookieException::forInvalidExpiresTime(gettype($expires)); } if (! is_numeric($expires)) { $expires = strtotime($expires); if ($expires === false) { throw CookieException::forInvalidExpiresValue(); } } return $expires > 0 ? (int) $expires : 0; } //========================================================================= // VALIDATION //========================================================================= /** * Validates the cookie name per RFC 2616. * * If `$raw` is true, names should not contain invalid characters * as `setrawcookie()` will reject this. * * @throws CookieException */ protected function validateName(string $name, bool $raw): void { if ($raw && strpbrk($name, self::$reservedCharsList) !== false) { throw CookieException::forInvalidCookieName($name); } if ($name === '') { throw CookieException::forEmptyCookieName(); } } /** * Validates the special prefixes if some attribute requirements are met. * * @throws CookieException */ protected function validatePrefix(string $prefix, bool $secure, string $path, string $domain): void { if (strpos($prefix, '__Secure-') === 0 && ! $secure) { throw CookieException::forInvalidSecurePrefix(); } if (strpos($prefix, '__Host-') === 0 && (! $secure || $domain !== '' || $path !== '/')) { throw CookieException::forInvalidHostPrefix(); } } /** * Validates the `SameSite` to be within the allowed types. * * @throws CookieException * * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite */ protected function validateSameSite(string $samesite, bool $secure): void { if ($samesite === '') { $samesite = self::$defaults['samesite']; } if ($samesite === '') { $samesite = self::SAMESITE_LAX; } if (! in_array(strtolower($samesite), self::ALLOWED_SAMESITE_VALUES, true)) { throw CookieException::forInvalidSameSite($samesite); } if (strtolower($samesite) === self::SAMESITE_NONE && ! $secure) { throw CookieException::forInvalidSameSiteNone(); } } }