* @copyright 2007 Kevin Locke * @license http://www.opensource.org/licenses/mit-license.php MIT License * @version SVN: $Id: HTTP_Accept.php 22 2007-10-06 18:46:45Z kevin $ * @link http://pear.php.net/package/HTTP_Accept */ /** * HTTP_Accept class for dealing with the HTTP 'Accept' header * * This class is intended to be used to parse the HTTP Accept header into * usable information and provide a simple API for dealing with that * information. * * The parsing of this class is designed to follow RFC 2616 to the letter, * any deviations from that standard are bugs and should be reported to the * maintainer. * * Often the class will be used very simply as * * getQuality("image/png") > $accept->getQuality("image/jpeg")) * // Send PNG image * else * // Send JPEG image * ?> * * * However, for browsers which do not accurately describe their preferences, * it may be necessary to check if a MIME Type is explicitly listed in their * Accept header, in addition to being preferred to another type * * * isMatchExact("application/xhtml+xml")) * // Client specifically asked for this type at some quality level * ?> * * * * @category HTTP * @package HTTP_Accept * @access public * @link http://pear.php.net/package/HTTP_Accept */ class HTTP_Accept { /** * Array of types and their associated parameters, extensions, and quality * factors, as represented in the Accept: header. * Indexed by [type][subtype][index], * and contains 'PARAMS', 'QUALITY', and 'EXTENSIONS' keys for the * parameter set, quality factor, and extensions set respectively. * Note: Since type, subtype, and parameters are case-insensitive * (RFC 2045 5.1) they are stored as lower-case. * * @var array * @access private */ var $acceptedtypes = array(); /** * Regular expression to match a token, as defined in RFC 2045 * * @var string * @access private */ var $_matchtoken = '(?:[^[:cntrl:]()<>@,;:\\\\"\/\[\]?={} \t]+)'; /** * Regular expression to match a quoted string, as defined in RFC 2045 * * @var string * @access private */ var $_matchqstring = '(?:"[^\\\\"]*(?:\\\\.[^\\\\"]*)*")'; /** * Constructs a new HTTP_Accept object * * Initializes the HTTP_Accept class with a given accept string * or creates a new (empty) HTTP_Accept object if no string is given * * Note: The behavior is a little strange here to accomodate * missing headers (to be interpreted as accept all) as well as * new empty objects which should accept nothing. This means that * HTTP_Accept("") will be different than HTTP_Accept() * * @access public * @return object HTTP_Accept * @param string $acceptstring The value of an Accept: header * Will often be $_SERVER['HTTP_ACCEPT'] * Note: If get_magic_quotes_gpc is on, * run stripslashes() on the string first */ function HTTP_Accept() { if (func_num_args() == 0) { // User wishes to create empty HTTP_Accept object $this->acceptedtypes = array( '*' => array( '*' => array ( 0 => array( 'PARAMS' => array(), 'QUALITY' => 0, 'EXTENSIONS' => array() ) ) ) ); return; } $acceptstring = trim(func_get_arg(0)); if (empty($acceptstring)) { // Accept header empty or not sent, interpret as "*/*" $this->acceptedtypes = array( '*' => array( '*' => array ( 0 => array( 'PARAMS' => array(), 'QUALITY' => 1, 'EXTENSIONS' => array() ) ) ) ); return; } $matches = preg_match_all( '/\s*('.$this->_matchtoken.')\/' . // typegroup/ '('.$this->_matchtoken.')' . // subtype '((?:\s*;\s*'.$this->_matchtoken.'\s*' . // parameter '(?:=\s*' . // optional =value '(?:'.$this->_matchqstring.'|'.$this->_matchtoken.'))?)*)/', // value $acceptstring, $acceptedtypes, PREG_SET_ORDER); if ($matches == 0) { // Malformed Accept header $this->acceptedtypes = array( '*' => array( '*' => array ( 0 => array( 'PARAMS' => array(), 'QUALITY' => 1, 'EXTENSIONS' => array() ) ) ) ); return; } foreach ($acceptedtypes as $accepted) { $typefamily = strtolower($accepted[1]); $subtype = strtolower($accepted[2]); // */subtype is invalid according to grammar in section 14.1 // so we ignore it if ($typefamily == '*' && $subtype != '*') continue; // Parse all arguments of the form "key=value" $matches = preg_match_all('/;\s*('.$this->_matchtoken.')\s*' . '(?:=\s*' . '('.$this->_matchqstring.'|'. $this->_matchtoken.'))?/', $accepted[3], $args, PREG_SET_ORDER); $params = array(); $quality = -1; $extensions = array(); foreach ($args as $arg) { array_shift($arg); if (!empty($arg[1])) { // Strip quotes (Note: Can't use trim() in case "text\"") $len = strlen($arg[1]); if ($arg[1][0] == '"' && $arg[1][$len-1] == '"' && $len > 1) { $arg[1] = substr($arg[1], 1, $len-2); $arg[1] = stripslashes($arg[1]); } } else if (!isset($arg[1])) { $arg[1] = null; } // Everything before q=# is a parameter, after is an extension if ($quality >= 0) { $extensions[$arg[0]] = $arg[1]; } else if ($arg[0] == 'q') { $quality = (float)$arg[1]; if ($quality < 0) $quality = 0; else if ($quality > 1) $quality = 1; } else { $arg[0] = strtolower($arg[0]); // Values required for parameters, // assume empty-string for missing values if (isset($arg[1])) $params[$arg[0]] = $arg[1]; else $params[$arg[0]] = ""; } } if ($quality < 0) $quality = 1; else if ($quality == 0) continue; if (!isset($this->acceptedtypes[$typefamily])) $this->acceptedtypes[$typefamily] = array(); if (!isset($this->acceptedtypes[$typefamily][$subtype])) $this->acceptedtypes[$typefamily][$subtype] = array(); $this->acceptedtypes[$typefamily][$subtype][] = array('PARAMS' => $params, 'QUALITY' => $quality, 'EXTENSIONS' => $extensions); } if (!isset($this->acceptedtypes['*'])) $this->acceptedtypes['*'] = array(); if (!isset($this->acceptedtypes['*']['*'])) $this->acceptedtypes['*']['*'] = array( 0 => array( 'PARAMS' => array(), 'QUALITY' => 0, 'EXTENSIONS' => array() ) ); } /** * Gets the accepted quality factor for a given MIME Type * * Note: If there are multiple best matches * (e.g. "text/html;level=4;charset=utf-8" matching both "text/html;level=4" * and "text/html;charset=utf-8"), it returns the lowest quality factor as * a conservative estimate. Further, if the ambiguity is between parameters * and extensions (e.g. "text/html;level=4;q=1;ext=foo" matching both * "text/html;level=4" and "text/html;q=1;ext=foo") the parameters take * precidence. * * Usage Note: If the quality factor for all supported media types is 0, * RFC 2616 specifies that applications SHOULD send an HTTP 406 (not * acceptable) response. * * @access public * @return double the quality value for the given MIME Type * Quality values are in the range [0,1] where 0 means * "not accepted" and 1 is "perfect quality". * @param string $mimetype The MIME Type to query ("text/html") * @param array $params Parameters of Type to query ([level => 4]) * @param array $extensions Extension parameters to query */ function getQuality($mimetype, $params = array(), $extensions = array()) { $type = explode("/", $mimetype); $supertype = strtolower($type[0]); $subtype = strtolower($type[1]); if ($params == null) $params = array(); if ($extensions == null) $extensions = array(); if (empty($this->acceptedtypes[$supertype])) { if ($supertype == '*') return 0; else return $this->getQuality("*/*", $params, $extensions); } if (empty($this->acceptedtypes[$supertype][$subtype])) { if ($subtype == '*') return $this->getQuality("*/*", $params, $extensions); else return $this->getQuality("$supertype/*", $params, $extensions); } $params = array_change_key_case($params, CASE_LOWER); $matches = $this->_findBestMatchIndices($supertype, $subtype, $params, $extensions); if (count($matches) == 0) { if ($subtype != '*') return $this->getQuality("$supertype/*", $params, $extensions); else if ($supertype != '*') return $this->getQuality("*/*", $params, $extensions); else return 0; } $minquality = 1; foreach ($matches as $match) if ($this->acceptedtypes[$supertype][$subtype][$match]['QUALITY'] < $minquality) $minquality = $this->acceptedtypes[$supertype][$subtype][$match]['QUALITY']; return $minquality; } /** * Determines if there is an exact match for the specified MIME Type * * @access public * @return boolean true if there is an exact match to the given * values, false otherwise. * @param string $mimetype The MIME Type to query (e.g. "text/html") * @param array $params Parameters of Type to query (e.g. [level => 4]) * @param array $extensions Extension parameters to query */ function isMatchExact($mimetype, $params = array(), $extensions = array()) { $type = explode("/", $mimetype); $supertype = strtolower($type[0]); $subtype = strtolower($type[1]); if ($params == null) $params = array(); if ($extensions == null) $extensions = array(); return $this->_findExactMatchIndex($supertype, $subtype, $params, $extensions) >= 0; } /** * Gets a list of all MIME Types explicitly accepted, sorted by quality * * @access public * @return array list of MIME Types explicitly accepted, sorted * in decreasing order of quality factor */ function getTypes() { $qvalues = array(); $types = array(); foreach ($this->acceptedtypes as $typefamily => $subtypes) { if ($typefamily == '*') continue; foreach ($subtypes as $subtype => $variants) { if ($subtype == '*') continue; $maxquality = 0; foreach ($variants as $variant) if ($variant['QUALITY'] > $maxquality) $maxquality = $variant['QUALITY']; if ($maxquality > 0) { $qvalues[] = $maxquality; $types[] = $typefamily.'/'.$subtype; } } } array_multisort($qvalues, SORT_DESC, SORT_NUMERIC, $types, SORT_DESC, SORT_STRING); return $types; } /** * Gets the parameter sets for a given mime type, sorted by quality. * Only parameter sets where the extensions set is empty will be returned. * * @access public * @return array list of sets of name=>value parameter pairs * in decreasing order of quality factor * @param string $mimetype The MIME Type to query ("text/html") */ function getParameterSets($mimetype) { $type = explode("/", $mimetype); $supertype = strtolower($type[0]); $subtype = strtolower($type[1]); if (!isset($this->acceptedtypes[$supertype]) || !isset($this->acceptedtypes[$supertype][$subtype])) return array(); $qvalues = array(); $paramsets = array(); foreach ($this->acceptedtypes[$supertype][$subtype] as $acceptedtype) { if (count($acceptedtype['EXTENSIONS']) == 0) { $qvalues[] = $acceptedtype['QUALITY']; $paramsets[] = $acceptedtype['PARAMS']; } } array_multisort($qvalues, SORT_DESC, SORT_NUMERIC, $paramsets, SORT_DESC, SORT_STRING); return $paramsets; } /** * Gets the extension sets for a given mime type, sorted by quality. * Only extension sets where the parameter set is empty will be returned. * * @access public * @return array list of sets of name=>value extension pairs * in decreasing order of quality factor * @param string $mimetype The MIME Type to query ("text/html") */ function getExtensionSets($mimetype) { $type = explode("/", $mimetype); $supertype = strtolower($type[0]); $subtype = strtolower($type[1]); if (!isset($this->acceptedtypes[$supertype]) || !isset($this->acceptedtypes[$supertype][$subtype])) return array(); $qvalues = array(); $extensionsets = array(); foreach ($this->acceptedtypes[$supertype][$subtype] as $acceptedtype) { if (count($acceptedtype['PARAMS']) == 0) { $qvalues[] = $acceptedtype['QUALITY']; $extensionsets[] = $acceptedtype['EXTENSIONS']; } } array_multisort($qvalues, SORT_DESC, SORT_NUMERIC, $extensionsets, SORT_DESC, SORT_STRING); return $extensionsets; } /** * Adds a type to the set of accepted types * * @access public * @param string $mimetype The MIME Type to add (e.g. "text/html") * @param double $quality The quality value for the given MIME Type * Quality values are in the range [0,1] where * 0 means "not accepted" and 1 is * "perfect quality". * @param array $params Parameters of the type to add (e.g. [level => 4]) * @param array $extensions Extension parameters of the type to add */ function addType($mimetype, $quality = 1, $params = array(), $extensions = array()) { $type = explode("/", $mimetype); $supertype = strtolower($type[0]); $subtype = strtolower($type[1]); if ($params == null) $params = array(); if ($extensions == null) $extensions = array(); $index = $this->_findExactMatchIndex($supertype, $subtype, $params, $extensions); if ($index >= 0) { $this->acceptedtypes[$supertype][$subtype][$index]['QUALITY'] = $quality; } else { if (!isset($this->acceptedtypes[$supertype])) $this->acceptedtypes[$supertype] = array(); if (!isset($this->acceptedtypes[$supertype][$subtype])) $this->acceptedtypes[$supertype][$subtype] = array(); $this->acceptedtypes[$supertype][$subtype][] = array('PARAMS' => $params, 'QUALITY' => $quality, 'EXTENSIONS' => $extensions); } } /** * Removes a type from the set of accepted types * * @access public * @param string $mimetype The MIME Type to remove (e.g. "text/html") * @param array $params Parameters of the type to remove (e.g. [level => 4]) * @param array $extensions Extension parameters of the type to remove */ function removeType($mimetype, $params = array(), $extensions = array()) { $type = explode("/", $mimetype); $supertype = strtolower($type[0]); $subtype = strtolower($type[1]); if ($params == null) $params = array(); if ($extensions == null) $extensions = array(); $index = $this->_findExactMatchIndex($supertype, $subtype, $params, $extensions); if ($index >= 0) { $this->acceptedtypes[$supertype][$subtype] = array_merge(array_slice($this->acceptedtypes[$supertype][$subtype], 0, $index), array_slice($this->acceptedtypes[$supertype][$subtype], $index+1)); } } /** * Gets a string representation suitable for use in an HTTP Accept header * * @access public * @return string a string representation of this object */ function __toString() { $accepted = array(); $qvalues = array(); foreach ($this->acceptedtypes as $supertype => $subtypes) { foreach ($subtypes as $subtype => $entries) { foreach ($entries as $entry) { $accepted[] = array('TYPE' => "$supertype/$subtype", 'QUALITY' => $entry['QUALITY'], 'PARAMS' => $entry['PARAMS'], 'EXTENSIONS' => $entry['EXTENSIONS']); $qvalues[] = $entry['QUALITY']; } } } array_multisort($qvalues, SORT_DESC, SORT_NUMERIC, $accepted); $str = ""; foreach ($accepted as $accept) { // Skip the catchall value if it is 0, since this is implied if ($accept['TYPE'] == '*/*' && $accept['QUALITY'] == 0 && count($accept['PARAMS']) == 0 && count($accept['EXTENSIONS'] == 0)) continue; $str = $str.$accept['TYPE'].';'; foreach ($accept['PARAMS'] as $param => $value) { if (preg_match('/^'.$this->_matchtoken.'$/', $value)) $str = $str.$param.'='.$value.';'; else $str = $str.$param.'="'.addcslashes($value,'"\\').'";'; } if ($accept['QUALITY'] < 1 || !empty($accept['EXTENSIONS'])) $str = $str.'q='.$accept['QUALITY'].';'; foreach ($accept['EXTENSIONS'] as $extension => $value) { if (preg_match('/^'.$this->_matchtoken.'$/', $value)) $str = $str.$extension.'='.$value.';'; else $str = $str.$extension.'="'.addcslashes($value,'"\\').'";'; } $str[strlen($str)-1] = ','; } return rtrim($str, ','); } /** * Finds the index of an exact match for the specified MIME Type * * @access private * @return int the index of an exact match if found, * -1 otherwise * @param string $supertype The general MIME Type to find (e.g. "text") * @param string $subtype The MIME subtype to find (e.g. "html") * @param array $params Parameters of Type to find ([level => 4]) * @param array $extensions Extension parameters to find */ function _findExactMatchIndex($supertype, $subtype, $params, $extensions) { if (empty($this->acceptedtypes[$supertype]) || empty($this->acceptedtypes[$supertype][$subtype])) return -1; $params = array_change_key_case($params, CASE_LOWER); $parammatches = array(); foreach ($this->acceptedtypes[$supertype][$subtype] as $index => $typematch) if ($typematch['PARAMS'] == $params && $typematch['EXTENSIONS'] == $extensions) return $index; return -1; } /** * Finds the indices of the best matches for the specified MIME Type * * A "match" in this context is an exact type match and no extraneous * matches for parameters or extensions (so the best match for * "text/html;level=4" may be "text/html" but not the other way around). * * "Best" is interpreted as the entries that match the most * parameters and extensions (the sum of the number of matches) * * @access private * @return array an array of the indices of the best matches * (empty if no matches) * @param string $supertype The general MIME Type to find (e.g. "text") * @param string $subtype The MIME subtype to find (e.g. "html") * @param array $params Parameters of Type to find ([level => 4]) * @param array $extensions Extension parameters to find */ function _findBestMatchIndices($supertype, $subtype, $params, $extensions) { $bestmatches = array(); $bestlength = 0; if (empty($this->acceptedtypes[$supertype]) || empty($this->acceptedtypes[$supertype][$subtype])) return $bestmatches; foreach ($this->acceptedtypes[$supertype][$subtype] as $index => $typematch) { if (count(array_diff_assoc($typematch['PARAMS'], $params)) == 0 && count(array_diff_assoc($typematch['EXTENSIONS'], $extensions)) == 0) { $length = count($typematch['PARAMS']) + count($typematch['EXTENSIONS']); if ($length > $bestlength) { $bestmatches = array($index); $bestlength = $length; } else if ($length == $bestlength) { $bestmatches[] = $index; } } } return $bestmatches; } } // vim: set ts=4 sts=4 sw=4 et: ?>