/ */ class P4Cms_Cache_Frontend_Action extends Zend_Cache_Core { const SESSION_NAMESPACE = 'p4cms.cache.action'; /** * This frontend specific options * * ====> (boolean) content_type_memorization : * - pass true to memorize the value of the Content-Type header and replay it * when cache is hit. Defaults to true. * * ====> (array) memorize_headers : * - an array of strings corresponding to some HTTP headers name. Listed headers * will be stored with cache datas and "replayed" when the cache is hit * * ====> (array) default_options : * - an associative array of default options : * - (boolean) cache : cache is on by default if true * - (boolean) compress : if server and client support, conten will be gzip'ed. * if the served page utilizes the css or js aggregators * it is critical this be enabled. * - (boolean) cache_with_XXX (XXXX = 'get', 'post', 'session', etc) : * if true, cache is still on even if the item has value(s) * if false, cache is off if the item has value(s) * - (boolean) make_id_with_XXX (XXXX = 'get', 'post', 'session', etc) : * if true, we have to use the value(s) of the specified item to make cache validator * if false, the cache validator won't be dependent of the value(s) of the specified item * - (int) specific_lifetime : cache specific lifetime * (false => global lifetime is used, null => infinite lifetime, * integer => this lifetime is used), this "lifetime" is probably only * usefull when used with "actions" array * - (array) tags : array of tags (strings) * - (int) priority : integer between 0 (very low priority) and 10 (maximum priority) used by * some particular backends * * ====> (array) actions : * - an associative array to set options only for some actions * - keys are // in route format (e.g. module/foo-bar/action) * - values are associative array with specific options to set if the action matches * (see default_options for the list of available options) * * @var array $_specificOptions */ protected $_specificOptions = array( 'content_type_memorization' => true, 'memorize_headers' => array(), 'actions' => array(), 'default_options' => array( 'cache_with_get' => false, 'cache_with_post' => false, 'cache_with_session' => false, 'cache_with_files' => false, 'cache_with_cookies' => true, 'cache_with_username' => false, 'cache_with_rolename' => true, 'cache_with_locale' => true, 'make_id_with_get' => true, 'make_id_with_post' => true, 'make_id_with_session' => true, 'make_id_with_files' => true, 'make_id_with_cookies' => false, 'make_id_with_username' => false, 'make_id_with_rolename' => true, 'make_id_with_locale' => true, 'compress' => true, 'cache' => true, 'specific_lifetime' => false, 'tags' => array(), 'priority' => null ) ); /** * When we push something into cache we will merge the default options, * action specific options and these active options together. Add items, * such as tags, to the activeOptions during execution so they can take * affect when storing the final result or testing for validity. * * @var array $_activeOptions */ protected $_activeOptions = array(); protected $_cancel = false; protected $_ignoredSessionVariables = null; protected $_baseUrl = null; protected $_username = null; protected $_rolenames = null; protected $_locale = null; protected static $_session = null; /** * Constructor; allows the base options to be set. * * @param array $options Associative array of options */ public function __construct(array $options = array()) { // merge in any passed options while (list($name, $value) = each($options)) { $name = strtolower($name); switch ($name) { case 'actions': case 'default_options': case 'content_type_memorization': $this->_specificOptions[$name] = $this->_mergeOptions( $this->_specificOptions[$name], $value ); break; default: $this->setOption($name, $value); } } // this has to be on or action cache will break $this->setOption('automatic_serialization', true); } /** * Start the cache. If a cached entry is present for the current request * it will be served out and execution halted (unless do not die is passed). * If no suitable cached entry can be found the output buffer is setup so * we can attemp to capture a copy of the request at completion. * * @param bool $doNotLoad Skip reading from cache, but still try to write. * @param bool $doNotDie For unit testing only! * @return bool True if the cache is hit (false else) * @todo consider having a flag to enable/disable cache hit/miss headers */ public function start($doNotLoad = false, $doNotDie = false) { $this->_cancel = false; // attempt to read out the stored action options using the URI $options = $doNotLoad ? false : $this->load($this->_makeUriId()); // if we could retreive the options; try and read the actual data out $dataId = $options ? $this->_makeDataId($options) : false; $data = ($options && $dataId) ? $this->load($dataId) : false; // if we can read the cached options and data out; serve it if ($data) { $content = $data['content']; $headers = $data['headers']; if (!headers_sent()) { // output that this was a cache hit header('X-Page-Cache: Hit'); // if client included an etag and we have a match the client // already has a copy of the content so we exit early. // otherwise sends the etag to assist in future requests. if ($this->_handleEtag($data, $doNotDie)) { return true; } // send any cached headers foreach ($headers as $key => $headerCouple) { $name = $headerCouple[0]; $value = $headerCouple[1]; header("$name: $value"); } } echo $content; if ($doNotDie) { return true; } die(); } // if we made it this far there was no cache hit. // connect the output buffer so we can attempt to store // the response at completion. ob_start(array($this, '_flush')); ob_implicit_flush(false); return false; } /** * Cancel the current caching process * * @todo stop output buffering when this is called */ public function cancel() { $this->_cancel = true; } /** * Add a session variable key to the list we will ignore. * The key can be a simple top level key name such as 'foo' or you may * utilize unquoted array syntax to specify a child key such as: * 'foo[bar]' or 'foo[woozle][wobble]'. * * @param string $key The key of the session variable to ignore * @return P4Cms_Cache_Frontend_Page To maintain a fluent interface */ public function addIgnoredSessionVariable($key) { $curr = $this->getIgnoredSessionVariables(); $new = array($key); $new = array_merge($new, $curr); $this->setIgnoredSessionVariables( array_merge($this->getIgnoredSessionVariables(), array($key)) ); return $this; } /** * Returns the list of session variable keys which will be ignored. * * The list is itself stored in the session under our SESSION_NAMESPACE * value. The SESSION_NAMESPACE is always ignored though it will not be * returned by this accessor unless manually added to the ignored keys. * * @return array The list of session variable keys we will ignore */ public function getIgnoredSessionVariables() { if ($this->_ignoredSessionVariables === null) { $this->_ignoredSessionVariables = static::_getSession()->ignoredSessionVariables ?: array(); } return $this->_ignoredSessionVariables; } /** * Cause the list of ignored session variable keys to contain only the passed keys. * See addIgnoredSessionVariable for details on the individual key format. * * @param array $keys An array of strings representing session variable keys to ignore * @return P4Cms_Cache_Frontend_Page To maintain a fluent interface */ public function setIgnoredSessionVariables(array $keys) { foreach ($keys as $key) { if (!$this->_isValidIgnoreKey($key)) { throw new InvalidArgumentException( "Ignored session variable keys can only contain " . "a-z, A-Z, 0-9, '_', '-', '.', '[', ']' and ' '." ); } } // filter for unique values and re-index array. $this->_ignoredSessionVariables = array_values(array_unique($keys)); static::_getSession()->ignoredSessionVariables = $this->_ignoredSessionVariables; return $this; } /** * Add the specified tag to the active options. * * @param string $tag The tag to add */ public function addTag($tag) { return $this->addTags(array($tag)); } /** * Add the specified tags to the active options. * * @param array $tags The tags to add */ public function addTags(array $tags) { static::_validateTagsArray($tags); // ensure tags option is initialized if (!isset($this->_activeOptions['tags'])) { $this->_activeOptions['tags'] = array(); } // mix in the new tags ensure we don't have duplicates $this->_activeOptions['tags'] = array_unique( array_merge($this->_activeOptions['tags'], $tags) ); return $this; } /** * Get the current list of tags. * * @return array Array of tags */ public function getTags() { return isset($this->_activeOptions['tags']) ? $this->_activeOptions['tags'] : array(); } /** * Get the base url set on this instance. * * @return string|null The base URL */ public function getBaseUrl() { return $this->_baseUrl; } /** * Set a base url on this instance. * * @param string|null $baseUrl The base url to use * @return P4Cms_Cache_Frontend_Page To maintain a fluent interface */ public function setBaseUrl($baseUrl) { if (!is_string($baseUrl) && !is_null($baseUrl)) { throw new InvalidArgumentException('Base URL must be a string or null'); } $this->_baseUrl = $baseUrl; return $this; } /** * Get the username set on this instance. * * @return string|null The username */ public function getUsername() { return $this->_username; } /** * Set a username on this instance. * * @param string|null $username The username to use * @return P4Cms_Cache_Frontend_Page To maintain a fluent interface */ public function setUsername($username) { if (!is_string($username) && !is_null($username)) { throw new InvalidArgumentException('Username must be a string or null.'); } $this->_username = $username; return $this; } /** * Get the rolenames set on this instance. * * @return array|null The role names */ public function getRolenames() { return $this->_rolenames; } /** * Set rolenames on this instance. * * @param array|null $rolenames The rolenames to use * @return P4Cms_Cache_Frontend_Page To maintain a fluent interface */ public function setRolenames($rolenames) { if ((!is_array($rolenames) && !is_null($rolenames)) || (is_array($rolenames) && in_array(false, array_map('is_string', $rolenames))) ) { throw new InvalidArgumentException('Role names must be an array of strings or null'); } $this->_rolenames = $rolenames; return $this; } /** * Callback for output buffering (shouldn't really be called manually) * If the current response is for a known action, and our options allow * caching this response, pushes a copy into cache for later usage. * * @param string $content Buffered output * @return string Data to send to browser */ public function _flush($content) { if ($this->_cancel) { return $content; } $request = Zend_Controller_Front::getInstance()->getRequest(); // though we should always get back a request; be defensive if (!$request) { return $content; } $action = $request->getModuleName() . '/' . $request->getControllerName() . '/' . $request->getActionName(); // if this action isn't present return if (!isset($this->_specificOptions['actions'][$action])) { return $content; } // request was potentially cachable but missed; include a header headers_sent() ?: header('X-Page-Cache: Miss'); // starting with default options, mix in the // actions options and any active options $options = $this->_specificOptions['default_options']; $options = $this->_mergeOptions($options, $this->_specificOptions['actions'][$action]); $options = $this->_mergeOptions($options, $this->_activeOptions); // if our cache is disabled or we cannot create a data id return $dataId = $this->_makeDataId($options); if (!$options['cache'] || !$dataId) { return $content; } // gzip content if compression is active and supported. // adds the Content-Encoding header to allow decoding. if ($options['compress'] && !headers_sent() && $this->_canCompress()) { $content = gzencode($content, 9); header('Content-Encoding: gzip'); $this->_specificOptions['memorize_headers'][] = 'Content-Encoding'; } // ensure content type is memorized if requested if ($this->_specificOptions['content_type_memorization']) { $this->_specificOptions['memorize_headers'][] = 'Content-Type'; } // if we made it this far we have a cache-able response gather the data $storedHeaders = array(); $keepHeaders = array_map('strtolower', $this->_specificOptions['memorize_headers']); $keepHeaders = array_unique($keepHeaders); foreach (headers_list() as $header) { $headerParts = explode(':', $header, 2); $headerName = trim(array_shift($headerParts)); $headerValue = trim(array_shift($headerParts)); if (in_array(strtolower($headerName), $keepHeaders)) { $storedHeaders[] = array($headerName, $headerValue); } } // ensure a copy of the options are stored based on the request URI. $this->save( $options, $this->_makeUriId(), array(), $options['specific_lifetime'], $options['priority'] ); // store the actual data under the dataId (this is generated based on the options). $data = array( 'content' => $content, 'headers' => $storedHeaders, 'etag' => '"' . md5($content . serialize($storedHeaders)) . '"' ); $this->save( $data, $dataId, $options['tags'], $options['specific_lifetime'], $options['priority'] ); // ensure the etag header is sent and exit at this point if // the client included a matching etag in their request $this->_handleEtag($data); return $content; } /** * This method will take care of sending the passed etag back out to the * client. It will also send a 304 not modified header and die if the * client has included a matching etag in their request. * * @param array $data an array with 'etag' key * @param bool $doNotDie for unit testing; if true we will simply return true * instead of die'ing and the caller should then exit. * @return bool true if etag matched, indicates no response need be * sent (by default we die prior to return in this case), * false otherwise */ protected function _handleEtag($data, $doNotDie = false) { // normalize array input to a string or false if not present $etag = isset($data['etag']) ? $data['etag'] : false; // if we don't have an etag passed in or headers // have been sent we cannot continue if (!$etag || headers_sent()) { return false; } // remove the cache-control headers that get set // by php session_cache_limiter functionality. header_remove('Expires'); header_remove('Cache-Control'); header_remove('Pragma'); // ensure the etag is sent back to client. header('ETag: ' . $data['etag']); // if the browser sent an etag; send back // not modified and die if its valid if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] == $data['etag'] ) { header('HTTP/1.1 304 Not Modified'); if ($doNotDie) { return true; } die(); } return false; } /** * This method will make the URI based ID for the current * request. If caching occurs there will also be an instance * * @return string The cache ID to use */ protected function _makeUriId() { $requestUri = $_SERVER['REQUEST_URI']; // strip the baseurl from the request uri if present if ($this->getBaseUrl() && strpos($requestUri, $this->getBaseUrl()) == 0) { $requestUri = substr($requestUri, strlen($this->getBaseUrl())); } return 'action_' . md5($requestUri); } /** * This method will generate a data id based on the passed options. * When we are reading an entry out of cache we first pull its * options using the uri id and then use this method to generate * a data id based on the uri and options we found. * * @param array $options The action options to use * @return string|bool The data ID or false if this request shouldn't be cached */ protected function _makeDataId($options) { $components = array('Username', 'Rolename', 'Get', 'Post', 'Session', 'Files', 'Cookies', 'Locale'); $result = ''; foreach ($components as $component) { $lower = strtolower($component); $partialResult = $this->_makePartialDataId( $component, isset($options['cache_with_' . $lower]) ? $options['cache_with_' . $lower] : true, isset($options['make_id_with_' . $lower]) ? $options['make_id_with_' . $lower] : false ); if ($partialResult === false) { return false; } $result = $result . $partialResult; } // if compression is enabled adjust ID to indicate its use if ($options['compress']) { $result .= $this->_canCompress(); } return 'action_data_' . md5($this->_makeUriId() . $result); } /** * Generates the data id chunk (or false) for the given paramater. * * @param string $param Paramater name * @param bool $allow If true, cache is still on even if there are some variables present * @param bool $include If true, we have to use the content of the param to make a partial id * @return string|false Partial id (string) or false if validation has failed */ protected function _makePartialDataId($param, $allow, $include) { $value = null; switch ($param) { case 'Get': $value = $_GET; break; case 'Post': $value = $_POST; break; case 'Cookies': if (isset($_COOKIE)) { $value = $_COOKIE; } else { $value = null; } break; case 'Files': $value = $_FILES; break; case 'Username': $value = $this->getUsername(); // if the value was important and is unknown, abort caching if ((!$allow || $include) && $value === null) { return false; } // Swap in null for empty strings to maintain normal flow. if (!strlen($value)) { $value = null; } break; case 'Rolename': $value = $this->getRolenames(); // if the value was important and is unknown, abort caching if ((!$allow || $include) && $value === null) { return false; } break; case 'Session': // If a user has no cookies, they have no session, provide // an early exit to avoid starting one needlessly. if (!count($_COOKIE)) { break; } $value = $this->_removeIgnoredSessionVariables(); break; case 'Locale': // read out the locale if we don't already have it. // we cache the value the first time we encounter it to avoid // breaking caching in the unlikely circumstance the answer // changes during a cache-miss request. $this->_locale = $this->_locale ?: Zend_Locale::findLocale(); $value = $this->_locale; break; default: return false; } if ($allow) { if ($include) { return serialize($value); } return ''; } // if we made it here the value isn't allowed // fail if anything is present if (count($value) > 0) { return false; } return ''; } /** * Merge options recursively; same approach as the protected * method in Zend_Application. * * @param array $array1 the defaults * @param mixed $array2 over-riding options to merge in * @return array The merged options */ protected function _mergeOptions(array $array1, $array2 = null) { if (is_array($array2)) { foreach ($array2 as $key => $val) { if (is_array($array2[$key])) { $array1[$key] = (array_key_exists($key, $array1) && is_array($array1[$key])) ? $this->_mergeOptions($array1[$key], $array2[$key]) : $array2[$key]; } else { $array1[$key] = $val; } } } return $array1; } /** * Will remove the ignored session variables from $_SESSION variables. * * Further, any empty values will be removed recursively as these are * also ignored. * * @return array|null the session variables stripped of ignored/empty values. */ protected function _removeIgnoredSessionVariables() { // ensure our session variable is always ignored // calling getIgnoredSessionVariables has the side effect // of ensuring the session is started; we must do this // prior to accessing the $_SESSION super global. $ignoredKeys = array_merge( $this->getIgnoredSessionVariables() ?: array(), array(static::SESSION_NAMESPACE) ); $session = $_SESSION ?: array(); // remove all ignored session keys from the session foreach ($ignoredKeys as $key) { // 'ignore keys' should be in the form of 'foo' or 'foo[bar][baz]' // transform them to look like '[foo]' or '[foo][bar][baz]' $key = preg_replace('/([^\[]+)(\[.*)?/', '[\\1]\\2', $key); // last stage of the transform, add single quotes around keys // changing our "[foo][bar]" style string to "['foo']['bar']" $key = str_replace(array('[', ']'), array("['", "']"), $key); // attempt to clear the session variable with this key eval('unset($session' . $key . ');'); } // use a recursive callback to filter out all empty entries from session $recursiveEmpty = function($item) use (&$recursiveEmpty) { if (is_array($item)) { return array_filter($item, $recursiveEmpty); } if (count($item)) { return true; } }; $session = array_filter($session, $recursiveEmpty); return $session; } /** * Return the static session object, initializing if necessary. * * @return Zend_Session_Namespace */ protected static function _getSession() { if (!static::$_session instanceof Zend_Session_Namespace) { static::$_session = new Zend_Session_Namespace(static::SESSION_NAMESPACE); } return static::$_session; } /** * Ensure the ignore key only contains the characters: * a-z, A-Z, 0-9, '_', '-', '.', '[', ']', ' ' * * @param string $key The ignore key to validate * @return bool True if ignore key is valid, false otherwise */ protected function _isValidIgnoreKey($key) { return is_string($key) && preg_match("/^[\w\.\-_\[\] ]+$/", $key); } /** * Checks if PHP and the active client both support compression. * * @return bool true if compression is possible false otherwise */ protected function _canCompress() { // can't compress if php lacks gzip support if (!function_exists('gzencode')) { return false; } // given php is capable; base decision on client support $accept = isset($_SERVER['HTTP_ACCEPT_ENCODING']) ? $_SERVER['HTTP_ACCEPT_ENCODING'] : ''; return strpos($accept, 'gzip') !== false; } }