<?php
namespace Neos\Flow\Http;

/*
 * This file is part of the Neos.Flow package.
 *
 * (c) Contributors of the Neos Project - www.neos.io
 *
 * This package is Open Source Software. For the full copyright and license
 * information, please view the LICENSE file which was distributed with this
 * source code.
 */

use Neos\Flow\Annotations as Flow;

/**
 * Represents a HTTP Cookie as of RFC 6265
 *
 * @api
 * @see http://tools.ietf.org/html/rfc6265
 * @Flow\Proxy(false)
 */
class Cookie
{
    /**
     * A token as per RFC 2616, Section 2.2
     */
    const PATTERN_TOKEN = '/^([\x21\x23-\x27\x2A-\x2E0-9A-Z\x5E-\x60a-z\x7C\x7E]+)$/';

    /**
     * The max age pattern as per RFC 6265, Section 5.2.2
     */
    const PATTERN_MAX_AGE = '/^\-?\d+$/';

    /**
     * A simplified pattern for a basically valid domain (<subdomain>) as per RFC 6265, 4.1.1 / RFC 1034, 3.5 + RFC 1123, 2.1
     */
    const PATTERN_DOMAIN = '/^([a-z0-9]+[a-z0-9.-]*[a-z0-9])$|([0-9\.]+[0-9])$/i';

    /**
     * A path as per RFC 6265, 4.1.1
     */
    const PATTERN_PATH = '/^([\x20-\x3A\x3C-\x7E])+$/';

    const SAMESITE_NONE = 'none';
    const SAMESITE_LAX = 'lax';
    const SAMESITE_STRICT = 'strict';

    /**
     * Cookie Name, a token (RFC 6265, 4.1.1)
     * @var string
     */
    protected $name;

    /**
     * @var string
     */
    protected $value;

    /**
     * Unix timestamp of the expiration date / time or 0 for "session" expiration (RFC 6265, 4.1.2.1)
     * @var integer
     */
    protected $expiresTimestamp;

    /**
     * Number of seconds until the cookie expires (RFC 6265, 4.1.2.2)
     * @var integer
     */
    protected $maximumAge;

    /**
     * Hosts to which this cookie will be sent (RFC 6265, 4.1.2.3)
     * @var string
     */
    protected $domain;

    /**
     * @var string
     */
    protected $path;

    /**
     * @var boolean
     */
    protected $secure;

    /**
     * @var boolean
     */
    protected $httpOnly;

    /**
     * Possible values: none, lax, or strict (RFC 6265bis-05, 8.8)
     *
     * sameSite=strict
     *   Cookie will only be sent in a first-party context, cookie is ignored on the initial request to your site.
     *   This is a good setting when you have cookies relating to functionality, such as changing a password
     * sameSite=lax
     *   Cookie will only be sent in a top-level navigations.
     *   This is a good setting when you need the cookie on the initial request, such as session login via cookie
     * sameSite=none
     *   Cookie will be sent in a third-party context.
     *   This is a good setting when you need the cookie in cors ajax request, such as providing an api with session login via cookie
     *
     * @var string
     */
    protected $sameSite;

    /**
     * Constructs a new Cookie object
     *
     * @param string $name The cookie name as a valid token (RFC 2616)
     * @param mixed $value The value to store in the cookie. Must be possible to cast into a string.
     * @param integer|\DateTime $expires Date and time after which this cookie expires.
     * @param integer $maximumAge Number of seconds until the cookie expires.
     * @param string $domain The host to which the user agent will send this cookie
     * @param string $path The path describing the scope of this cookie
     * @param boolean $secure If this cookie should only be sent through a "secure" channel by the user agent
     * @param boolean $httpOnly If this cookie should only be used through the HTTP protocol
     * @param string $sameSite If this cookie should restricted to a first-party or top-level navigation or third-party context
     * @api
     * @throws \InvalidArgumentException
     */
    public function __construct($name, $value = null, $expires = 0, $maximumAge = null, $domain = null, $path = '/', $secure = false, $httpOnly = true, $sameSite = null)
    {
        if (preg_match(self::PATTERN_TOKEN, $name) !== 1) {
            throw new \InvalidArgumentException('The parameter "name" passed to the Cookie constructor must be a valid token as per RFC 2616, Section 2.2.', 1345101977);
        }
        if ($expires instanceof \Datetime) {
            $expires = $expires->getTimestamp();
        }
        if (!is_integer($expires)) {
            throw new \InvalidArgumentException('The parameter "expires" passed to the Cookie constructor must be a unix timestamp or a DateTime object.', 1345108785);
        }
        if ($maximumAge !== null && !is_integer($maximumAge)) {
            throw new \InvalidArgumentException('The parameter "maximumAge" passed to the Cookie constructor must be an integer value.', 1345108786);
        }
        if ($domain !== null && preg_match(self::PATTERN_DOMAIN, $domain) !== 1) {
            throw new \InvalidArgumentException('The parameter "domain" passed to the Cookie constructor must be a valid domain as per RFC 6265, Section 4.1.2.3.', 1345116246);
        }
        if ($path !== null && preg_match(self::PATTERN_PATH, $path) !== 1) {
            throw new \InvalidArgumentException('The parameter "path" passed to the Cookie constructor must be a valid path as per RFC 6265, Section 4.1.1.', 1345123078);
        }
        if (!\in_array($sameSite, [self::SAMESITE_LAX, self::SAMESITE_STRICT, self::SAMESITE_NONE, null], true)) {
            throw new \InvalidArgumentException('The parameter "sameSite" passed to the Cookie constructor must be a valid samesite value. Possible values are "none", "strict", "lax" and null', 1584955500);
        }

        $this->name = $name;
        $this->value = $value;
        $this->expiresTimestamp = $expires;
        $this->maximumAge = $maximumAge;
        $this->domain = $domain;
        $this->path = $path;
        $this->secure = ($secure == true) || $sameSite === self::SAMESITE_NONE;
        $this->httpOnly = ($httpOnly == true);
        $this->sameSite = $sameSite;
    }

    /**
     * Creates a cookie (an instance of this class) by a provided
     * raw header string like "foo=507d9f20317a5; path=/; domain=.example.org"
     * This is is an implementation of the algorithm explained in RFC 6265, Section 5.2
     * A basic statement of this algorithm is to "ignore the set-cookie-string entirely"
     * in case a required condition is not met. In these cases this function will return NULL
     * rather than the created cookie.
     *
     * @param string $header The Set-Cookie string without the actual "Set-Cookie:" part
     * @return Cookie
     * @see http://tools.ietf.org/html/rfc6265
     */
    public static function createFromRawSetCookieHeader($header)
    {
        $nameValueAndUnparsedAttributes = explode(';', $header, 2);
        $expectedNameValuePair = $nameValueAndUnparsedAttributes[0];
        $unparsedAttributes = isset($nameValueAndUnparsedAttributes[1]) ? $nameValueAndUnparsedAttributes[1] : '';

        if (strpos($expectedNameValuePair, '=') === false) {
            return null;
        }
        $cookieNameAndValue = explode('=', $expectedNameValuePair, 2);
        $cookieName = trim($cookieNameAndValue[0]);
        $cookieValue = isset($cookieNameAndValue[1]) ? trim($cookieNameAndValue[1]) : '';
        if ($cookieName === '') {
            return null;
        }

        $expiresAttribute = 0;
        $maxAgeAttribute = null;
        $domainAttribute = null;
        $pathAttribute = null;
        $secureAttribute = false;
        $httpOnlyAttribute = true;
        $sameSite = null;

        if ($unparsedAttributes !== '') {
            foreach (explode(';', $unparsedAttributes) as $cookieAttributeValueString) {
                $attributeNameAndValue = explode('=', $cookieAttributeValueString, 2);
                $attributeName = trim($attributeNameAndValue[0]);
                $attributeValue = isset($attributeNameAndValue[1]) ? trim($attributeNameAndValue[1]) : '';
                switch (strtoupper($attributeName)) {
                    case 'EXPIRES':
                        try {
                            $expiresAttribute = new \DateTime($attributeValue);
                        } catch (\Exception $exception) {
                            // as of RFC 6265 Section 5.2.1, a non parsable Expires date should result into
                            // ignoring, but since the Cookie constructor relies on it, we'll
                            // assume a Session cookie with an expiry date of 0.
                            $expiresAttribute = 0;
                        }
                    break;
                    case 'MAX-AGE':
                        if (preg_match(self::PATTERN_MAX_AGE, $attributeValue) === 1) {
                            $maxAgeAttribute = intval($attributeValue);
                        }
                    break;
                    case 'DOMAIN':
                        if ($attributeValue !== '') {
                            $domainAttribute = strtolower(ltrim($attributeValue, '.'));
                        }
                    break;
                    case 'PATH':
                        if ($attributeValue === '' || substr($attributeValue, 0, 1) !== '/') {
                            $pathAttribute = '/';
                        } else {
                            $pathAttribute = $attributeValue;
                        }
                    break;
                    case 'SECURE':
                        $secureAttribute = true;
                    break;
                    case 'HTTPONLY':
                        $httpOnlyAttribute = true;
                    break;
                    case 'SAMESITE':
                        if (\in_array(strtolower($attributeValue), [self::SAMESITE_LAX, self::SAMESITE_STRICT, self::SAMESITE_NONE], true)) {
                            $sameSite = strtolower($attributeValue);
                        }
                        if (strtolower($attributeValue) === self::SAMESITE_NONE) {
                            $secureAttribute = true;
                        }
                    break;
                }
            }
        }

        $cookie = new static(
            $cookieName,
            $cookieValue,
            $expiresAttribute,
            $maxAgeAttribute,
            $domainAttribute,
            $pathAttribute,
            $secureAttribute,
            $httpOnlyAttribute,
            $sameSite
        );

        return $cookie;
    }

    /**
     * Returns the name of this cookie
     *
     * @return string The cookie name
     * @api
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * Returns the value of this cookie
     *
     * @return mixed
     * @api
     */
    public function getValue()
    {
        return $this->value;
    }

    /**
     * Sets the value of this cookie
     *
     * @param mixed $value The new value
     * @return void
     * @api
     */
    public function setValue($value)
    {
        $this->value = $value;
    }

    /**
     * Returns the date and time of the Expires attribute, if any.
     *
     * Note that this date / time is returned as a unix timestamp, no matter what
     * the format was originally set through the constructor of this Cookie.
     *
     * The special case "no expiration time" is returned in form of a zero value.
     *
     * @return integer A unix timestamp or 0
     * @api
     */
    public function getExpires()
    {
        return $this->expiresTimestamp;
    }

    /**
     * Returns the number of seconds until the cookie expires, if defined.
     *
     * This information is rendered as the Max-Age attribute (RFC 6265, 4.1.2.2).
     * Note that not all browsers support this attribute.
     *
     * @return integer The maximum age in seconds, or NULL if none has been defined.
     * @api
     */
    public function getMaximumAge()
    {
        return $this->maximumAge;
    }

    /**
     * Returns the domain this cookie is valid for.
     *
     * @return string The domain name
     * @api
     */
    public function getDomain()
    {
        return $this->domain;
    }

    /**
     * Returns the path this cookie is valid for.
     *
     * @return string The path
     * @api
     */
    public function getPath()
    {
        return $this->path;
    }

    /**
     * Tells if the cookie was flagged to be sent over "secure" channels only.
     *
     * This security measure only has a limited effect. Please read RFC 6265 Section 8.6
     * for more details.
     *
     * @return boolean State of the "Secure" attribute
     * @api
     */
    public function isSecure()
    {
        return $this->secure;
    }

    /**
     * Tells if this cookie should only be used through the HTTP protocol.
     *
     * @return boolean State of the "HttpOnly" attribute
     * @api
     */
    public function isHttpOnly()
    {
        return $this->httpOnly;
    }

    /**
     * Returns the SameSite of this cookie
     *
     * @return string|null
     * @api
     */
    public function getSameSite()
    {
        return $this->sameSite;
    }

    /**
     * Marks this cookie for removal.
     *
     * On executing this method, the expiry time of this cookie is set to a point
     * in time in the past triggers the removal of the cookie in the user agent.
     *
     * @return void
     */
    public function expire()
    {
        $this->expiresTimestamp = 202046400;
        $this->maximumAge = 0;
    }

    /**
     * Tells if this cookie is expired and will be removed in the user agent when it
     * received the response containing this cookie.
     *
     * @return boolean True if this cookie is expired
     */
    public function isExpired()
    {
        return ($this->expiresTimestamp !== 0 && $this->expiresTimestamp < time());
    }

    /**
     * Renders the field value suitable for a HTTP "Set-Cookie" header.
     *
     * @return string
     */
    public function __toString()
    {
        if ($this->value === false) {
            $value = 0;
        } else {
            $value = $this->value;
        }

        $cookiePair = sprintf('%s=%s', $this->name, urlencode($value));
        $attributes = '';

        if ($this->expiresTimestamp !== 0) {
            $attributes .= '; Expires=' . gmdate('D, d-M-Y H:i:s T', $this->expiresTimestamp);
        }

        if ($this->maximumAge !== null && $this->maximumAge > 0) {
            $attributes .= '; Max-Age=' . $this->maximumAge;
        }

        if ($this->domain !== null) {
            $attributes .= '; Domain=' . $this->domain;
        }

        $attributes .= '; Path=' . $this->path;

        if ($this->secure) {
            $attributes .= '; Secure';
        }

        if ($this->httpOnly) {
            $attributes .= '; HttpOnly';
        }

        if ($this->sameSite) {
            $attributes .= '; SameSite=' . $this->sameSite;
        }

        return $cookiePair . $attributes;
    }
}
