Action.php #1

  • //
  • guest/
  • perforce_software/
  • chronicle/
  • main/
  • library/
  • P4Cms/
  • Cache/
  • Frontend/
  • Action.php
  • View
  • Commits
  • Open Download .zip Download (29 KB)
<?php
/**
 * The Action Cache operates in a similar manner to Zend's Page Cache frontend
 * but defines caching rules based on the module/controller/action instead of URI.
 *
 * Zend's page cache utilizes uri regex matching to determine cachability; this
 * approach isn't compatible with custom URLs. They also don't support things like
 * username, rolename, filtering session variables, on the fly tagging or base url.
 *
 * Our Action cache allows you to set rules based on the module/controller/action
 * the request ends up being routed to. We store the options used for the cached
 * action under the request URI. These options are then used to make a seperate
 * data id that holds the actual cached page and headers. Using this approach
 * allows the final data url to include things like the active user's rolenames
 * thereby storing, and serving, multiple versions of a given page.
 *
 * @copyright   2011 Perforce Software. All rights reserved.
 * @license     Please see LICENSE.txt in top-level folder of this distribution.
 * @version     <release>/<patch>
 */
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 <module>/<controller>/<action> 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;
    }
}
# Change User Description Committed
#1 16170 perforce_software Move Chronicle files to follow new path scheme for branching.
//guest/perforce_software/chronicle/library/P4Cms/Cache/Frontend/Action.php
#1 8972 Matt Attaway Initial add of the Chronicle source code