<?php namespace ProcessWire;

/**
 * Provides methods for managing cookies via the $input->cookie API variable
 * 
 * #pw-summary Enables getting, setting and removing cookies from the ProcessWire API using `$input->cookie`.
 * 
 * #pw-body =
 * 
 * - Whether getting or setting, cookie values are always strings.
 * - Values retrieved from `$input->cookie` are user input (like PHP’s $_COOKIE) and need to be sanitized and validated by you.
 * - When removing/unsetting cookies, the path, domain, secure, and httponly options must be the same as when the cookie was set,
 *   as a result, it’s good to have these things predefined in `$config->cookieOptions` rather than setting during runtime.
 * - Note that this class does not manage PW’s session cookies. 
 *
 * ~~~~~
 * // setting cookies
 * $input->cookie->foo = 'bar';
 * $input->cookie->set('foo', 'bar'); // same as above
 * $input->cookie['foo'] = 'bar'; // same as above
 * 
 * // setting cookies, with options
 * $input->cookie->set('foo', bar', 86400); // live for 1 day
 * $input->cookie->options('age', 3600); // any further set() cookies live for 1 hour (3600s)
 * $input->cookie->set('foo', 'bar'); // uses setting from above options() call
 * 
 * // getting cookies 
 * $bar = $input->cookie->foo;
 * $bar = $input->cookie['foo']; // same as above
 * $bar = $input->cookie('foo'); // same as above
 * $bar = $input->cookie->get('foo'); // same as above
 * $bar = $input->cookie->text('foo'); // sanitize with text() sanitizer
 * 
 * // removing cookies
 * unset($input->cookie->foo);
 * $input->cookie->remove('foo'); // same as above
 * $input->cookie->set('foo', null); // same as above
 * $input->cookie->removeAll(); // remove all cookies
 * 
 * // to modify default cookie settings, add this to your /site/config.php file and edit:
 * $config->cookieOptions = [
 * 
 *   // Max age of cookies in seconds or 0 to expire with session 
 *   // 3600=1 hour, 86400=1 day, 604800=1 week, 2592000=30 days, etc.
 *   'age' => 604800,
 * 
 *   // Cookie path/URL or null for PW installation’s root URL 
 *   'path' => null,
 * 
 *   // Cookie domain: null for current hostname, true for all subdomains of current domain, 
 *   // domain.com for domain and all subdomains (same as true), www.domain.com for www subdomain
 *   // and additional hosts off www subdomain (i.e. dev.www.domain.com)
 *   'domain' => null,
 * 
 *   // Transmit cookies only over secure HTTPS connection? 
 *   // Specify true, false, or null to auto-detect (uses true for cookies set when HTTPS).
 *   'secure' => null,
 * 
 *   // Cookie SameSite value: When set to “Lax” cookies are preserved on GET requests to this site
 *   // originated from external links. May also be “Strict” or “None”. The 'secure' option is
 *   // required for “None”. Default value is “Lax”. Available in PW 3.0.178+.
 *   'samesite' => 'Lax',
 * 
 *   // Make cookies accessible by HTTP (ProcessWire/PHP) only? 
 *   // When true, cookie is http/server-side only and not visible to client-side JS code.
 *   'httponly' => false,
 * 
 *   // If set cookie fails (perhaps due to output already sent), 
 *   // attempt to set at beginning of next request? 
 *   'fallback' => true, 
 * ];
 * ~~~~~
 * 
 *
 * ProcessWire 3.x, Copyright 2020 by Ryan Cramer
 * https://processwire.com
 *
 */ 

class WireInputDataCookie extends WireInputData {

	/**
	 * Are we initialized?
	 * 
	 * @var bool
	 * 
	 */
	protected $init = false;

	/**
	 * Default cookie options
	 * 
	 * @var array
	 * 
	 */
	protected $defaultOptions = array(
		'age' => 0, 
		'expire' => null, 
		'path' => null,
		'domain' => null,
		'secure' => null,
		'httponly' => false, 
		'samesite' => 'Lax',
		'fallback' => true,
	);

	/**
	 * Cookie options specifically set at runtime
	 * 
	 * @var array
	 * 
	 */
	protected $options = array();

	/**
	 * Cookie names not allowed to be set or removed (i.e. session cookies)
	 * 
	 * @var array
	 * 
	 */
	protected $skipCookies = array();
	
	/**
	 * Construct
	 *
	 * @param array $input Associative array of variables to store
	 * @param bool $lazy Use lazy loading?
	 *
	 */
	public function __construct(&$input = array(), $lazy = false) {
		if($lazy) {} // lazy option not used by cookie
		parent::__construct($input, false);
	}

	/**
	 * Initialize and set any pending cookies from previous request
	 * 
	 * #pw-internal
	 * 
	 * @since 3.0.141
	 * 
	 */
	public function init() {
		$this->init = true;
		/** @var Session $session */
		$session = $this->wire('session');
		$cookies = $session->getFor($this, '');
		if(!empty($cookies)) {
			$this->setArray($cookies);
			$session->removeAllFor($this);
		}
	}

	/**
	 * Get or set cookie options
	 *
	 * - Omit all arguments to get current options.
	 * - Specify string for $key (and omit $value) to get the value of one option.
	 * - Specify both $key and $value arguments to set one option.
	 * - Specify associative array for $key (and omit $value) to set multiple options.
	 * 
	 * Options you can get or set: 
	 * 
	 * - `age` (int): Max age of cookies in seconds or 0 to expire with session. For example: 3600=1 hour, 86400=1 day,
	 *    604800=1 week, 2592000=30 days, etc. (default=0, expire with session)
	 * - `expire` (int|string): If you prefer to use an expiration date rather than the `age` option, specify a unix timestamp (int),
	 *    ISO-8601 date string, or any date string recognized by PHP’s strtotime(), like "2020-11-03" or +1 WEEK", etc. (default=null).
	 *    Please note the expire option was added in 3.0.159, previous versions should use the `age` option only.
	 * - `path` (string|null): Cookie path/URL or null for PW installation’s root URL. (default=null)
	 * - `secure` (bool|null): Transmit cookies only over secure HTTPS connection? Specify true or false, or use null to auto-detect,
	 *    which uses true for cookies set when HTTPS is detected. (default=null)
	 * - `httponly` (bool): When true, cookie is visible to PHP/ProcessWire only and not visible to client-side JS code. (default=false)
	 * - `fallback` (bool): If set cookie fails (perhaps due to output already sent), attempt to set at beginning of next request? (default=true)
	 * - `domain` (string|bool|null): Cookie domain, specify one of the following: `null` or blank string for current hostname [default],
	 *    boolean `true` for all subdomains of current domain, `domain.com` for domain.com and *.domain.com [same as true], `www.domain.com`
	 *    for www subdomain and and hostnames off of it, like dev.www.domain.com. (default=null, current hostname)
	 *
	 * @param string|array|null $key
	 * @param string|array|int|float|null $value
	 * @return string|array|int|float|null|$this
	 * @since 3.0.141
	 *
	 */
	public function options($key = null, $value = null) {
		if($key === null) {
			// get all
			return $this->options;
		} else if(is_array($key) && $value === null) {
			// set multiple
			$this->options = array_merge($this->options, $key);
		} else if($value === null) {
			// get one
			return isset($this->options[$key]) ? $this->options[$key] : null;
		} else {
			// set one
			$this->options[$key] = $value;
		}
		return $this;
	}

	/**
	 * Set a cookie (directly)
	 * 
	 * To set options for setting cookie, use $input->cookie->options(key, value); or $config->cookieOptions(key, value);
	 * Note that options set from $input->cookie->options take precedence over those set to $config. 
	 * 
	 * @param string $key Cookie name
	 * @param array|float|int|null|string $value Cookie value
	 * 
	 */
	public function __set($key, $value) {
		
		if(!$this->init) {
			// initial set of existing cookies that are present from constructor
			parent::__set($key, $value);
			return;
		}
		
		$this->setCookie($key, $value, array()); 
	}
	
	/**
	 * Get a cookie value
	 * 
	 * Gets a previously set cookie value or null if cookie not present or expired.
	 * Cookies are a type of user input, so always sanitize (and validate where appropriate) any values. 
	 * 
	 * ~~~~~
	 * $val = $input->cookie->foo;
	 * $val = $input->cookie->get('foo'); // same as above
	 * $val = $input->cookie->text('foo'); // get and use text sanitizer
	 * ~~~~~
	 * 
	 * @param string $key Name of cookie to get
	 * @param array|int|string $options Options not currently used, but available for descending classes or future use
	 * @return string|int|float|array|null $value
	 *
	 */
	public function get($key, $options = array()) {
		return parent::get($key, $options); 
	}
	
	/**
	 * Set a cookie (optionally with options)
	 * 
	 * The defaults or previously set options from an `options()` method call are used for any `$options` not specified.
	 * 
	 * ~~~~~
	 * $input->cookie->foo = 'bar'; // set with default options (expires with session)
	 * $input->cookie->set('foo', 'bar'); // same as above
	 * $input->cookie->set('foo', bar', 86400); // expire after 86400 seconds (1 day)
	 * $input->cookie->set('foo', 'bar', [ // set with options
	 *   'age' => 86400, 
	 *   'path' => $page->url,
	 *   'httponly' => true, 
	 * ]); 
	 * ~~~~~
	 *
	 * @param string $key Cookie name
	 * @param string $value Cookie value
	 * @param array|int|string $options Specify int for `age` option, string for `expire` option, or array for multiple options:
	 * - `age` (int): Max age of cookies in seconds or 0 to expire with session. For example: 3600=1 hour, 86400=1 day,
	 *    604800=1 week, 2592000=30 days, etc. (default=0, expire with session)
	 * - `expire` (int|string): If you prefer to use an expiration date rather than the `age` option, specify a unix timestamp (int),
	 *    ISO-8601 date string, or any date string recognized by PHP’s strtotime(), like "2020-11-03" or +1 WEEK", etc. (default=null).
	 *    Please note the expire option was added in 3.0.159, previous versions should use the `age` option only.
	 * - `path` (string|null): Cookie path/URL or null for PW installation’s root URL. (default=null)
	 * - `secure` (bool|null): Transmit cookies only over secure HTTPS connection? Specify true or false, or use null to auto-detect,
	 *    which uses true for cookies set when HTTPS is detected. (default=null)
	 * - `httponly` (bool): When true, cookie is visible to PHP/ProcessWire only and not visible to client-side JS code. (default=false)
	 * - `fallback` (bool): If set cookie fails (perhaps due to output already sent), attempt to set at beginning of next request? (default=true)
	 * - `domain` (string|bool|null): Cookie domain, specify one of the following: `null` or blank string for current hostname [default],
	 *    boolean `true` for all subdomains of current domain, `domain.com` for domain.com and *.domain.com [same as true], `www.domain.com`
	 *    for www subdomain and and hostnames off of it, like dev.www.domain.com. (default=null, current hostname)
	 * @return $this
	 * @since 3.0.141 
	 *
	 */
	public function set($key, $value, $options = array()) {
		
		if(!$this->init) {
			parent::__set($key, $value);
			return $this;
		}
		
		if(!is_array($options)) { 
			if(is_int($options) || ctype_digit("$options")) {
				$age = (int) $options;
				$options = array('age' => $age);
			} else if(!empty($options) && is_string($options)) {
				$expire = $options;
				$options = array('expire' => $expire);
			} else {
				$options = array();
			}
		}
		
		$this->setCookie($key, $value, $options);
		
		return $this;
	}

	/**
	 * Remove a cookie value by name
	 *
	 * @param string $key Name of cookie variable to remove value for
	 * @return WireInputDataCookie|WireInputData|$this
	 *
	 */
	public function remove($key) {
		return parent::remove($key);
	}

	/**
	 * Remove all cookies (other than those required for current session)
	 *
	 * @return $this|WireInputData
	 *
	 */
	public function removeAll() {
		foreach($this as $key => $value) {
			$this->offsetUnset($key);
		}
		return $this;
	}

	/**
	 * Set a cookie with options and return success state
	 * 
	 * This is the same as the `set()` mehod except for the following: 
	 * 
	 *  - It returns a boolean (success state) rather than reference to $this.
	 *  - An $options array argument is required.
	 *  - It does not accept a max age in place of $options argument. 
	 *
	 * #pw-internal
	 * 
	 * @param string $key Name of cookie to set
	 * @param string|array|int|float $value Value to place in cookie
	 * @param array $options Optionally override options from $config->cookieOptions and any previously set from an options() call:
	 * - `age` (int): Max age of cookies in seconds or 0 to expire with session. For example: 3600=1 hour, 86400=1 day,
	 *    604800=1 week, 2592000=30 days, etc. (default=0, expire with session)
	 * - `expire` (int|string): If you prefer to use an expiration date rather than the `age` option, specify a unix timestamp (int), 
	 *    ISO-8601 date string, or any date string recognized by PHP’s strtotime(), like "2020-11-03" or +1 WEEK", etc. (default=null).
	 *    Please note the expire option was added in 3.0.159, previous versions should use the `age` option only. 
	 * - `path` (string|null): Cookie path/URL or null for PW installation’s root URL. (default=null)
	 * - `secure` (bool|null): Transmit cookies only over secure HTTPS connection? Specify true or false, or use null to auto-detect, 
	 *    which uses true for cookies set when HTTPS is detected. (default=null)
	 * - `samesite` (string): SameSite value, one of 'Lax' (default), 'Strict' or 'None'. (default='Lax') 3.0.178+
	 * - `httponly` (bool): When true, cookie is visible to PHP/ProcessWire only and not visible to client-side JS code. (default=false)
	 * - `fallback` (bool): If set cookie fails (perhaps due to output already sent), attempt to set at beginning of next request? (default=true)
	 * - `domain` (string|bool|null): Cookie domain, specify one of the following: `null` or blank string for current hostname [default],
	 *    boolean `true` for all subdomains of current domain, `domain.com` for domain.com and *.domain.com [same as true], `www.domain.com` 
	 *    for www subdomain and and hostnames off of it, like dev.www.domain.com. (default=null)
	 * @return bool Returns true on success or false if cookie could not be set in this request and has been queued for next request
	 * @since 3.0.159
	 *
	 */
	public function setCookie($key, $value, array $options) {
		
		$config = $this->wire()->config;
		$options = array_merge($this->defaultOptions, $config->cookieOptions, $this->options, $options);
	
		$path = $options['path'] === null || $options['path'] === true ? $config->urls->root : $options['path'];
		$secure = $options['secure'] === null ? (bool) $config->https : (bool) $options['secure'];
		$httponly = (bool) $options['httponly'];
		$domain = $options['domain'];
		$remove = $value === null;
		$expires = null;
		$samesite = $options['samesite'] ? ucfirst(strtolower($options['samesite'])) : 'Lax';
		
		if($samesite === 'None') {
			$secure = true;
		} else if(!in_array($samesite, array('Lax', 'Strict', 'None'), true)) {
			$samesite = 'Lax';
		}
		
		if(!empty($options['expire'])) {
			if(is_string($options['expire']) && !ctype_digit($options['expire'])) {
				$expires = strtotime($options['expire']);
			} else {
				$expires = (int) $options['expire'];
			}
		}
		
		if(empty($expires)) {
			$expires = $options['age'] ? time() + (int) $options['age'] : 0;
		}
		
		if(!$this->allowSetCookie($key)) return false;

		// determine what to use for the domain argument
		if($domain === null) {
			// use current/origin http host only
			// http://www.faqs.org/rfcs/rfc6265.html - 4.1.2.3.
			// “If the server omits the Domain attribute, the user agent will return the cookie only to the origin server.”
			$domain = '';
		} else if($domain === true) {
			// allow all subdomains off current domain
			$parts = explode('.', $config->httpHost);
			$domain = count($parts) > 1 ? implode('.', array_slice($parts, -2)) : $config->httpHost;
		}

		// remove port from domain, as it is not compatible with setcookie()
		if(strpos($domain, ':') !== false) list($domain,) = explode(':', $domain, 2);

		// check if cookie should be deleted
		if($remove) list($value, $expires) = array('', 1); 

		// set the cookie
		if(PHP_VERSION_ID < 70300) {
			$result = setcookie($key, $value, $expires, "$path; SameSite=$samesite", $domain, $secure, $httponly);
		} else {
			$result = setcookie($key, $value, array(
				'expires' => $expires,
				'path' => $path,
				'domain' => $domain,
				'secure' => $secure,
				'httponly' => $httponly,
				'samesite' => $samesite,
			));
		}

		if($result === false && $options['fallback']) {
			// output must have already started, set at construct on next request
			$this->wire()->session->setFor($this, $key, $value);
		}

		if($remove) {
			parent::offsetUnset($key);
			unset($_COOKIE[$key]); 
		} else {
			parent::__set($key, $value);
			$_COOKIE[$key] = $value;
		}
		
		return $result;
	}
	
	/**
	 * Unset a cookie value
	 * 
	 * #pw-internal
	 * 
	 * @param mixed $key
	 * 
	 */
	#[\ReturnTypeWillChange] 
	public function offsetUnset($key) {
		if(!$this->allowSetCookie($key)) return;
		parent::offsetUnset($key);
		$this->setCookie($key, null, array());
		unset($_COOKIE[$key]);
	}

	/**
	 * Allow cookie with given name to be set or unset?
	 * 
	 * @param string $name
	 * @return bool
	 * 
	 */
	protected function allowSetCookie($name) {
		if(empty($this->skipCookies)) $this->skipCookies = $this->wire('session')->getCookieNames();
		return in_array($name, $this->skipCookies) ? false : true;
	}
}


