<?php
// $Id: Cookie_Jar.php,v 1.2 2003/01/22 12:25:30 k1m Exp $
// +----------------------------------------------------------------------+
// | Cookie Jar class 0.2 |
// +----------------------------------------------------------------------+
// | Author: Keyvan Minoukadeh - keyvan@k1m.com - http://www.keyvan.net |
// +----------------------------------------------------------------------+
// | PHP class for handling cookies (as defined by the Netscape spec: |
// | <http://wp.netscape.com/newsref/std/cookie_spec.html>) |
// +----------------------------------------------------------------------+
// | This program is free software; you can redistribute it and/or |
// | modify it under the terms of the GNU General Public License |
// | as published by the Free Software Foundation; either version 2 |
// | of the License, or (at your option) any later version. |
// | |
// | This program is distributed in the hope that it will be useful, |
// | but WITHOUT ANY WARRANTY; without even the implied warranty of |
// | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
// | GNU General Public License for more details. |
// +----------------------------------------------------------------------+
if (!defined('HTTPNAV_ROOT')) define('HTTPNAV_ROOT', dirname(__FILE__).'/');
require_once(HTTPNAV_ROOT.'Debug.php');
/**
* Cookie Jar class
*
* This class should be used to handle cookies (storing cookies from HTTP response messages, and
* sending out cookies in HTTP request messages).
*
* This class is mainly based on Cookies.pm <http://search.cpan.org/author/GAAS/libwww-perl-5.65/
* lib/HTTP/Cookies.pm> from the libwww-perl collection <http://www.linpro.no/lwp/>.
* Unlike Cookies.pm, this class only supports the Netscape cookie spec
* <http://wp.netscape.com/newsref/std/cookie_spec.html>, not RFC 2965.
*
* I've been looking at both the Netscape cookie spec and RFC 2965, a lot of the functions will
* be based on RFC 2965 simply because it covers details missed out by the netscape cookie spec.
*
* Please consider this class in 'alpha' state at the moment, I've still got a lot of testing
* to do, and will need to compare cookie handling with some existing browsers.
* Any feedback appreciated.
*
* Example:
* $options = array(
* 'file_persistent' => 'cookies.txt',
* 'autosave' => true
* );
* $jar =& new Cookie_Jar($options);
* $jar->add_cookie_header($my_request);
* $jar->destroy();
*
* See test_Cookie_Jar.php file for usage examples
*
* CHANGES:
* * 0.2 (20-Jan-2002)
* - Modified add_cookie_header() to call the push_header() method of the HTTP Request class.
* - Modified extract_cookies() to call the get_header_array() method of the HTTP Response class.
* - Requires a very simple Debug class (Debug.php), use Debug::on() and Debug::off()
* * 0.1 (09-Dec-2002)
* - Initial release
*
* TODO:
* - testing
*
* @author Keyvan Minoukadeh <keyvan@k1m.com>
* @version 0.2
*/
class Cookie_Jar
{
/**
* Cookies - array containing all cookies.
*
* Cookies are stored like this:
* [domain][path][name] = array
* where array is:
* 0 => value, 1 => secure, 2 => expires
*
* @var array
* @access private
*/
var $cookies = array();
/**
* Cookie options
* @var array
* @access private
*/
var $options = array();
/**
* Constructor - will accept an associative array holding the cookie jar options
* @param array $options
*/
function Cookie_Jar($options=null)
{
if (isset($options)) {
$this->set_option($options);
$this->load();
}
}
/**
* Add cookie header - adds the relevant cookie header to the request message
*
* @param object $request request object
* @return void
*/
function add_cookie_header(&$request)
{
$url =& $request->get_url();
$domain = $this->_get_host($request, $url);
$request_secure = ($url->get_scheme() == 'https');
$request_path = urldecode($url->get_path());
// add "Cookie" header to request
$param = array('domain'=>$domain, 'path'=>$request_path, 'secure'=>$request_secure);
// push_header() will add a cookie header withour overwriting any existing cookie headers
if ($cookies = $this->get_matching_cookies($param)) {
$request->push_header('Cookie', $cookies);
}
}
/**
* Get matching cookies
*
* Only use this method if you cannot use add_cookie_header(), for example, if you want to use
* this cookie jar class without using the request class.
*
* @param array $param associative array containing 'domain', 'path', 'secure' keys
* @return string
* @see add_cookie_header()
*/
function get_matching_cookies($param)
{
// RFC 2965 notes:
// If multiple cookies satisfy the criteria above, they are ordered in
// the Cookie header such that those with more specific Path attributes
// precede those with less specific. Ordering with respect to other
// attributes (e.g., Domain) is unspecified.
$domain = $param['domain'];
if (strpos($domain, '.') === false) $domain .= '.local';
$request_path = $param['path'];
if ($request_path == '') $request_path = '/';
$request_secure = $param['secure'];
$now = time();
$matched_cookies = array();
// domain - find matching domains
Debug::debug('Finding matching domains for '.$domain, __FILE__, __LINE__);
while (strpos($domain, '.') !== false) {
if (isset($this->cookies[$domain])) {
Debug::debug(' domain match found: '.$domain);
$cookies =& $this->cookies[$domain];
} else {
$domain = $this->_reduce_domain($domain);
continue;
}
// paths - find matching paths starting from most specific
Debug::debug(' - Finding matching paths for '.$request_path);
$paths = array_keys($cookies);
usort($paths, array(&$this, '_cmp_length'));
foreach ($paths as $path) {
// continue to next cookie if request path does not path-match cookie path
if (!$this->_path_match($request_path, $path)) continue;
// loop through cookie names
Debug::debug(' path match found: '.$path);
foreach ($cookies[$path] as $name => $values) {
// if this cookie is secure but request isn't, continue to next cookie
if ($values[1] && !$request_secure) continue;
// if cookie is not a session cookie and has expired, continue to next cookie
if (is_int($values[2]) && ($values[2] < $now)) continue;
// cookie matches request
Debug::debug(' cookie match: '.$name.'='.$values[0]);
$matched_cookies[] = $name.'='.$values[0];
}
}
$domain = $this->_reduce_domain($domain);
}
// return cookies
return implode('; ', $matched_cookies);
}
/**
* Extract cookies - extracts cookies from the HTTP response message.
* @param object $response
* @return void
*/
function extract_cookies(&$response)
{
$set_cookies = $response->get_header_array('Set-Cookie');
if (!$set_cookies) return;
$request =& $response->get_request();
$url = $request->get_url();
$request_host = $this->_get_host($request, $url);
$request_path = urldecode($url->get_path());
$param = array('host'=>$request_host, 'path'=>$request_path);
$this->parse_set_cookies($set_cookies, $param);
}
/**
* Parse Set-Cookie values.
*
* Only use this method if you cannot use extract_cookies(), for example, if you want to use
* this cookie jar class without using the response class.
*
* @param array $set_cookies array holding 1 or more "Set-Cookie" header values
* @param array $param associative array containing 'host', 'path' keys
* @return void
* @see extract_cookies()
*/
function parse_set_cookies($set_cookies, $param)
{
if (count($set_cookies) == 0) return;
$request_host = $param['host'];
if (strpos($request_host, '.') === false) $request_host .= '.local';
$request_path = $param['path'];
if ($request_path == '') $request_path = '/';
//
// loop through set-cookie headers
//
foreach ($set_cookies as $set_cookie) {
Debug::debug('Parsing: '.$set_cookie);
// temporary cookie store (before adding to jar)
$tmp_cookie = array();
$param = explode(';', $set_cookie);
// loop through params
for ($x=0; $x<count($param); $x++) {
$key_val = explode('=', $param[$x], 2);
if (count($key_val) != 2) {
// if the first param isn't a name=value pair, continue to the next set-cookie
// header
if ($x == 0) continue 2;
// check for secure flag
if (strtolower(trim($key_val[0])) == 'secure') $tmp_cookie['secure'] == true;
// continue to next param
continue;
}
list($key, $val) = array_map('trim', $key_val);
// first name=value pair is the cookie name and value
// the name and value are stored under 'name' and 'value' to avoid conflicts
// with later parameters.
if ($x == 0) {
$tmp_cookie = array('name'=>$key, 'value'=>$val);
continue;
}
$key = strtolower($key);
if (in_array($key, array('expires', 'path', 'domain', 'secure'))) {
$tmp_cookie[$key] = $val;
}
}
//
// set cookie
//
// check domain
if (isset($tmp_cookie['domain']) && ($tmp_cookie['domain'] != $request_host) &&
($tmp_cookie['domain'] != ".$request_host")) {
$domain = $tmp_cookie['domain'];
if ((strpos($domain, '.') === false) && ($domain != 'local')) {
Debug::debug(' - domain "'.$domain.'" has no dot and is not a local domain');
continue;
}
if (preg_match('/\.[0-9]+$/', $domain)) {
Debug::debug(' - domain "'.$domain.'" appears to be an ip address');
continue;
}
if (strpos($domain, 0, 1) != '.') $domain = ".$domain";
if (!$this->_domain_match($request_host, $domain)) {
Debug::debug(' - request host "'.$request_host.'" does not domain-match "'.$domain.'"');
continue;
}
} else {
// if domain is not specified in the set-cookie header, domain will default to
// the request host
$domain = $request_host;
}
// check path
if (isset($tmp_cookie['path']) && ($tmp_cookie['path'] != '')) {
$path = urldecode($tmp_cookie['path']);
if (!$this->_path_match($request_path, $path)) {
Debug::debug(' - request path "'.$request_path.'" does not path-match "'.$path.'"');
continue;
}
} else {
$path = $request_path;
$path = substr($path, 0, strrpos($path, '/'));
if ($path == '') $path = '/';
}
// check if secure
$secure = (isset($tmp_cookie['secure'])) ? true : false;
// check expiry
if (isset($tmp_cookie['expires'])) {
if (($expires = strtotime($tmp_cookie['expires'])) < 0) {
$expires = null;
}
} else {
$expires = null;
}
// set cookie
$this->set_cookie($domain, $path, $tmp_cookie['name'], $tmp_cookie['value'], $secure, $expires);
}
}
/**
* Set option - set cookie jar options.
*
* RECOGNISED OPTIONS:
* - option name values(s) description
* ------------------------------------------------------------------------------
* - file_persistent string persistent cookie file location
* - file_session string session cookie file location
* - autosave bool save cookies when destroy() is called
*
* @param mixed $option option name to set, or associative array to replace all existing options
* @param string $value option value, null to delete option
*/
function set_option($option, $value=null)
{
if (is_array($option)) {
$this->options = $option;
return;
}
if (!isset($value)) {
if (isset($this->options[$option])) unset($this->options[$option]);
return;
}
$this->options[$option] = $value;
return;
}
/**
* Get option value
* @param string $option option name
* @return string false if option not found
*/
function get_option($option)
{
return (isset($this->options[$option])) ? $this->options[$option] : false;
}
/**
* Set Cookie
* @param string $domain
* @param string $path
* @param string $name cookie name
* @param string $value cookie value
* @param bool $secure
* @param int $expires expiry time (null if session cookie, <= 0 will delete cookie)
* @return void
*/
function set_cookie($domain, $path, $name, $value, $secure=false, $expires=null)
{
if ($domain == '') return;
if ($path == '') return;
if ($name == '') return;
// check if cookie needs to go
if (isset($expires) && ($expires <= 0)) {
if (isset($this->cookies[$domain][$path][$name])) unset($this->cookies[$domain][$path][$name]);
return;
}
if ($value == '') return;
$this->cookies[$domain][$path][$name] = array($value, $secure, $expires);
return;
}
/**
* Clear cookies - [domain [,path [,name]]] - call method with no arguments to clear all cookies.
* @param string $domain
* @param string $path
* @param string $name
* @return void
*/
function clear($domain=null, $path=null, $name=null)
{
if (!isset($domain)) {
$this->cookies = array();
} elseif (!isset($path)) {
if (isset($this->cookies[$domain])) unset($this->cookies[$domain]);
} elseif (!isset($name)) {
if (isset($this->cookies[$domain][$path])) unset($this->cookies[$domain][$path]);
} elseif (isset($name)) {
if (isset($this->cookies[$domain][$path][$name])) unset($this->cookies[$domain][$path][$name]);
}
}
/**
* Clear session cookies - clears cookies which have no expiry time set
*/
function clear_session_cookies()
{
$callback = create_function('&$jar, $parts, $param',
'if (is_null($parts[\'expires\'])) '.
'$jar->clear($parts[\'domain\'], $parts[\'path\'], $parts[\'name\']);'."\n".
'return true;');
$this->scan($callback);
}
/**
* Scan - goes through all cookies passing the values through the callback function.
*
* The callback function can be a method from another object (eg. array(&$my_obj, 'my_method')).
* The callback function should accept 3 arguments:
* 1- A reference to the cookie jar object (&$jar)
* 2- An array holding all cookie parts, array is associative with the following keys:
* ('domain','path','name','value','expires','secure')
* 3- An optional parameter which can be used for whatever your function wants :),
* even though you might not have a use for this parameter, you need to define
* your function to accept it. (Note: you can have this parameter be passed by reference)
* The callback function should return a boolean, a value of 'true' will tell scan() you want
* it to continue with the rest of the cookies, 'false' will tell scan() to not send any more
* cookies to your callback function.
*
* Example:
* // $jar is our cookie jar with some cookies loaded
* $name_to_delete = 'bla';
* $jar->scan('delete_name', $name_to_delete);
*
* // our callback function defined here
* function delete_name(&$jar, $cookie_parts, $name_to_delete) {
* if ($cookie_parts['name'] == $name_to_delete) {
* $jar->clear($cookie_parts['domain'], $cookie_parts['path'], $cookie_parts['name']);
* }
* // must return true to tell scan() to continue with cookies
* return true;
* }
*
* @param mixed $callback function name, or array holding an object and the method to call.
* @param mixed $param passed as the 3rd argument to $callback
*/
function scan($callback, &$param)
{
if (is_array($callback)) $method =& $callback[1];
$cookies =& $this->cookies;
$domains = array_keys($cookies);
sort($domains);
foreach ($domains as $domain) {
$paths = array_keys($cookies[$domain]);
usort($paths, array(&$this, '_cmp_length'));
foreach ($paths as $path) {
foreach($cookies[$domain][$path] as $name => $value) {
$parts = array(
'domain' => $domain,
'path' => $path,
'name' => $name,
'value' => $value[0],
'secure' => $value[1],
'expires' => $value[2]
);
if (is_string($callback)) {
$res = $callback($this, $parts, $param);
} else {
$res = $callback[0]->$method($this, $parts, $param);
}
if (!$res) return;
}
}
}
}
/**
* Load - loads cookies from a netscape style cookies file.
* @param string $file location of the file, or leave blank to use options
* @return bool
*/
function load($file=null)
{
$success = true;
if (isset($file)) return $this->_load($file);
if ($file = $this->get_option('file_persistent')) {
$success = $this->_load($file);
}
if ($file = $this->get_option('file_session')) {
$succes = ($this->_load($file) && $success);
}
return $success;
}
/**
* Save cookies using files specified in the options.
* @return bool
*/
function save()
{
$success1 = true;
$success2 = true;
if ($this->get_option('file_persistent')) $success1 = $this->save_persistent_cookies();
if ($this->get_option('file_session')) $success2 = $this->save_session_cookies();
return ($success1 && $success2);
}
/**
* Save session cookies
* @param string $file file to save to, leave out to use the "file_session" option value
* @return bool
*/
function save_session_cookies($file=null)
{
$file = (isset($file)) ? $file : $this->get_option('file_session');
return $this->_save('session:'.$file);
}
/**
* Save persistent cookies
* @param string $file file to save to, leave out to use the "file_persistent" option value
* @return bool
*/
function save_persistent_cookies($file=null)
{
$file = (isset($file)) ? $file : $this->get_option('file_persistent');
return $this->_save('persistent:'.$file);
}
/**
* Destroy - an opplication using a cookie jar must call this method when it has
* finished with the cookies.
*/
function destroy()
{
if ($this->get_option('autosave')) $this->save();
$this->clear();
}
/**
* Save - saves cookies to disk
* @param string $type_file either: session:/path/to/cookies or persistent:/path/to/cookies
* @return bool
* @access private
*/
function _save($type_file)
{
// extract file and type
list($type, $file) = explode(':', $type_file, 2);
Debug::debug('** Saving '.$type.' cookies to "'.$file.'" **');
// check if file is writable
if (!$file || !is_writable($file)) {
trigger_error('File "'.$file.'" is not writable', E_USER_WARNING);
return false;
}
$data = '# HTTP Cookie File
# http://www.netscape.com/newsref/std/cookie_spec.html
# This is a generated file! Do not edit.
';
// build up cookie list
$option = array('type' => $type, 'string' => $data);
$this->scan(array(&$this, '_as_string_callback'), $option);
$data = $option['string'];
$fp = fopen($file, 'w');
flock($fp, LOCK_EX);
fwrite($fp, $data);
flock($fp, LOCK_UN);
fclose($fp);
return true;
}
/**
* Callback method to build up a netscape style cookies file.
* @param object $jar referenc to cookie jar (passed by scan())
* @param array $parts cookie parts supplied by scan()
* @param array $option holds type of cookies to return (session or persistent), and actual
* string as it builds up.
* @return bool
* @access private
*/
function _as_string_callback(&$jar, $parts, &$option)
{
if (is_null($parts['expires']) && ($option['type'] == 'persistent')) return true;
if (is_int($parts['expires']) && ($option['type'] == 'session')) return true;
if (is_int($parts['expires']) && (time() > $parts['expires'])) return true;
$p = array();
$p[] = str_replace("\t", ' ', $parts['domain']);
$p[] = (substr($parts['domain'], 0, 1) == '.') ? 'TRUE' : 'FALSE';
$p[] = str_replace("\t", ' ', $parts['path']);
$p[] = ($parts['secure']) ? 'TRUE' : 'FALSE';
$p[] = (is_null($parts['expires'])) ? 'session' : $parts['expires'];
$p[] = str_replace("\t", ' ', $parts['name']);
$p[] = str_replace("\t", ' ', $parts['value']);
$option['string'] .= implode("\t", $p)."\n";
return true;
}
/**
* Compare string length - used for sorting
* @access private
* @return int
*/
function _cmp_length($a, $b)
{
$la = strlen($a); $lb = strlen($b);
if ($la == $lb) return 0;
return ($la > $lb) ? -1 : 1;
}
/**
* Reduce domain
* @param string $domain
* @return string
* @access private
*/
function _reduce_domain($domain)
{
if ($domain == '') return '';
if (substr($domain, 0, 1) == '.') return substr($domain, 1);
return substr($domain, strpos($domain, '.'));
}
/**
* Path match - check if path1 path-matches path2
*
* From RFC 2965:
* For two strings that represent paths, P1 and P2, P1 path-matches P2
* if P2 is a prefix of P1 (including the case where P1 and P2 string-
* compare equal). Thus, the string /tec/waldo path-matches /tec.
* @param string $path1
* @param string $path2
* @return bool
* @access private
*/
function _path_match($path1, $path2)
{
return (substr($path1, 0, strlen($path2)) == $path2);
}
/**
* Domain match - check if domain1 domain-matches domain2
*
* A few extracts from RFC 2965:
* * A Set-Cookie2 from request-host y.x.foo.com for Domain=.foo.com
* would be rejected, because H is y.x and contains a dot.
*
* * A Set-Cookie2 from request-host x.foo.com for Domain=.foo.com
* would be accepted.
*
* * A Set-Cookie2 with Domain=.com or Domain=.com., will always be
* rejected, because there is no embedded dot.
*
* * A Set-Cookie2 from request-host example for Domain=.local will
* be accepted, because the effective host name for the request-
* host is example.local, and example.local domain-matches .local.
*
* I'm ignoring the first point for now (must check to see how other browsers handle
* this rule for Set-Cookie headers)
*
* @param string $domain1
* @param string $domain2
* @return bool
* @access private
*/
function _domain_match($domain1, $domain2)
{
$domain1 = strtolower($domain1);
$domain2 = strtolower($domain2);
while (strpos($domain1, '.') !== false) {
if ($domain1 == $domain2) return true;
$domain1 = $this->_reduce_domain($domain1);
continue;
}
return false;
}
/**
* Get host - get host from the 'Host' header of a HTTP request, or the URL
* @param object $request
* @param object $url
* @return string
* @access private
*/
function _get_host(&$request, &$url)
{
if ($host = $request->get_header_string('Host', 1)) {
if ($port_pos = strpos($host, ':')) return substr($host, 0, $port_pos);
if ($port_pos === false) return $host;
}
return $url->get_host();
}
/**
* Load - loads cookies from a netscape style cookies file.
* @param string $file location of the file
* @return bool
* @access private
*/
function _load($file)
{
Debug::debug('** Loading "'.$file.'" **');
// check if file is readable
if (($file == '') || !is_readable($file)) {
trigger_error('File "'.$file.'" is unreadable', E_USER_WARNING);
return false;
}
$data = file($file);
for ($x=0; $x<count($data); $x++) {
$line = trim($data[$x]);
// move on if line is a comment or empty
if (($line == '') || ($line == '#')) continue;
$parts = explode("\t", $line);
if (count($parts) != 7) continue;
list($domain, , $path, $secure, $expires, $name, $value) = $parts;
$secure = ($secure == 'TRUE');
// because the netscape style cookie files are used for persistent cookies
// you can't store session cookies (which might be useful for scripted
// HTTP sessions). Using this cookie jar class you'll have an option
// to save session cookies in a separate file with a minor change:
// the expires field will simply hold the string "session".
$expires = ($expires == 'session') ? null : (int)$expires;
$this->set_cookie($domain, $path, $name, $value, $secure, $expires);
}
return true;
}
}
?>
|