artabro/wire/core/WireInputDataCookie.php
2024-08-27 11:35:37 +02:00

468 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 PHPs $_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, its good to have these things predefined in `$config->cookieOptions` rather than setting during runtime.
* - Note that this class does not manage PWs 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 installations 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 2023 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;
$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 PHPs 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 installations 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 PHPs 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 installations 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 PHPs 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 installations 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;
}
}