symphonycms/symphony-2

View on GitHub
symphony/lib/toolkit/class.frontendpage.php

Summary

Maintainability
F
5 days
Test Coverage
<?php

/**
 * @package toolkit
 */

/**
 * The `FrontendPage` class represents a page of the website that is powered
 * by Symphony. It takes the current URL and resolves it to a page as specified
 * in Symphony which involves deducing the parameters from the URL, ensuring
 * this page is accessible and exists, setting the correct Content-Type for the page
 * and executing any Datasources or Events attached to the page to generate a
 * string of HTML that is returned to the browser. If the resolved page does not exist
 * or the user is not allowed to view it, the appropriate 404/403 page will be shown
 * instead.
 */

class FrontendPage extends XSLTPage
{
    /**
     * An associative array of all the parameters for this page including
     * Symphony parameters, URL Parameters, DS Parameters and Page
     * parameters
     * @var array
     */
    public $_param = array();

    /**
     * The URL of the current page that is being Rendered as returned
     * by `getCurrentPage`
     *
     * @var string
     * @see boot#getCurrentPage()
     */
    private $_page;

    /**
     * An associative array of the resolved pages's data as returned from `tbl_pages`
     * with the keys mapping to the columns in that table. Additionally, 'file-location'
     * and 'type' are also added to this array
     *
     * @var array
     */
    private $_pageData;

    /**
     * Returns whether the user accessing this page is logged in as a Symphony
     * Author
     *
     * @since Symphony 2.2.1
     * @var boolean
     */
    private $is_logged_in = false;

    /**
     * When events are processed, the results of them often can't be reproduced
     * when debugging the page as they happen during `$_POST`. There is a Symphony
     * configuration setting that allows the event results to be appended as a HTML
     * comment at the bottom of the page source, so logged in Authors can view-source
     * page to see the result of an event. This variable holds the event XML so that it
     * can be appended to the page source if `display_event_xml_in_source` is set to 'yes'.
     * By default this is set to no.
     *
     * @var XMLElement
     */
    private $_events_xml;

    /**
     * Holds all the environment variables which include parameters set by
     * other Datasources or Events.
     * @var array
     */
    private $_env = array();

    /**
     * Hold all the data sources that must not output their parameters in the xml.
     * @var array
     */
    private $_xml_excluded_params = array();

    /**
     * Constructor function sets the `$is_logged_in` variable.
     */
    public function __construct()
    {
        parent::__construct();

        $this->is_logged_in = Frontend::instance()->isLoggedIn();
    }

    /**
     * Accessor function for the environment variables, aka `$this->_env`
     *
     * @return array
     */
    public function Env()
    {
        return $this->_env;
    }

    /**
     * Setter function for `$this->_env`, which takes an associative array
     * of environment information and replaces the existing `$this->_env`.
     *
     * @since Symphony 2.3
     * @param array $env
     *  An associative array of new environment values
     */
    public function setEnv(array $env = array())
    {
        $this->_env = $env;
    }

    /**
     * Accessor function for the resolved page's data (`$this->_pageData`)
     * as it lies in `tbl_pages`
     *
     * @return array
     */
    public function pageData()
    {
        return $this->_pageData;
    }

    /**
     * Accessor function for this current page URL, `$this->_page`
     *
     * @return string
     */
    public function Page()
    {
        return $this->_page;
    }

    /**
     * Accessor function for the current page params, `$this->_param`
     *
     * @since Symphony 2.3
     * @return array
     */
    public function Params()
    {
        return $this->_param;
    }

    /**
     * This function is called immediately from the Frontend class passing the current
     * URL for generation. Generate will resolve the URL to the specific page in the Symphony
     * and then execute all events and datasources registered to this page so that it can
     * be rendered. A number of delegates are fired during stages of execution for extensions
     * to hook into.
     *
     * @uses FrontendDevKitResolve
     * @uses FrontendOutputPreGenerate
     * @uses FrontendPreRenderHeaders
     * @uses FrontendOutputPostGenerate
     * @see __buildPage()
     * @param string $page
     * The URL of the current page that is being Rendered as returned by getCurrentPage
     * @throws Exception
     * @throws FrontendPageNotFoundException
     * @throws SymphonyException
     * @return string
     * The page source after the XSLT has transformed this page's XML. This would be
     * exactly the same as the 'view-source' from your browser
     */
    public function generate($page = null)
    {
        $full_generate = true;
        $devkit = null;
        $output = null;

        $this->addHeaderToPage('Cache-Control', 'no-cache, must-revalidate, max-age=0');
        $this->addHeaderToPage('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT');

        if ($this->is_logged_in) {
            /**
             * Allows a devkit object to be specified, and stop continued execution:
             *
             * @delegate FrontendDevKitResolve
             * @param string $context
             * '/frontend/'
             * @param boolean $full_generate
             *  Whether this page will be completely generated (ie. invoke the XSLT transform)
             *  or not, by default this is true. Passed by reference
             * @param mixed $devkit
             *  Allows a devkit to register to this page
             */
            Symphony::ExtensionManager()->notifyMembers('FrontendDevKitResolve', '/frontend/', array(
                'full_generate' => &$full_generate,
                'devkit'        => &$devkit
            ));
        }

        Symphony::Profiler()->sample('Page creation started');
        $this->_page = $page;
        $this->__buildPage();

        if ($full_generate) {
            /**
             * Immediately before generating the page. Provided with the page object, XML and XSLT
             * @delegate FrontendOutputPreGenerate
             * @param string $context
             * '/frontend/'
             * @param FrontendPage $page
             *  This FrontendPage object, by reference
             * @param XMLElement $xml
             *  This pages XML, including the Parameters, Datasource and Event XML, by reference as
             *  an XMLElement
             * @param string $xsl
             *  This pages XSLT, by reference
             */
            Symphony::ExtensionManager()->notifyMembers('FrontendOutputPreGenerate', '/frontend/', array(
                'page'  => &$this,
                'xml'   => &$this->_xml,
                'xsl'   => &$this->_xsl
            ));

            if (is_null($devkit)) {
                if (General::in_iarray('XML', $this->_pageData['type'])) {
                    $this->addHeaderToPage('Content-Type', 'text/xml; charset=utf-8');
                } elseif (General::in_iarray('JSON', $this->_pageData['type'])) {
                    $this->addHeaderToPage('Content-Type', 'application/json; charset=utf-8');
                } else {
                    $this->addHeaderToPage('Content-Type', 'text/html; charset=utf-8');
                }

                if (in_array('404', $this->_pageData['type'])) {
                    $this->setHttpStatus(self::HTTP_STATUS_NOT_FOUND);
                } elseif (in_array('403', $this->_pageData['type'])) {
                    $this->setHttpStatus(self::HTTP_STATUS_FORBIDDEN);
                }
            }

            // Lock down the frontend first so that extensions can easily remove these
            // headers if desired. RE: #2480
            $this->addHeaderToPage('X-Frame-Options', 'SAMEORIGIN');
            // Add more http security headers, RE: #2248
            $this->addHeaderToPage('X-Content-Type-Options', 'nosniff');
            $this->addHeaderToPage('X-XSS-Protection', '1; mode=block');

            /**
             * This is just prior to the page headers being rendered, and is suitable for changing them
             * @delegate FrontendPreRenderHeaders
             * @param string $context
             * '/frontend/'
             */
            Symphony::ExtensionManager()->notifyMembers('FrontendPreRenderHeaders', '/frontend/');

            $backup_param = $this->_param;
            $this->_param['current-query-string'] = General::wrapInCDATA($this->_param['current-query-string']);

            // In Symphony 2.4, the XML structure stays as an object until
            // the very last moment.
            Symphony::Profiler()->seed(precision_timer());
            if ($this->_xml instanceof XMLElement) {
                $this->setXML($this->_xml->generate(true, 0));
            }
            Symphony::Profiler()->sample('XML Generation', PROFILE_LAP);

            $output = parent::generate();
            $this->_param = $backup_param;

            Symphony::Profiler()->sample('XSLT Transformation', PROFILE_LAP);

            /**
             * Immediately after generating the page. Provided with string containing page source
             * @delegate FrontendOutputPostGenerate
             * @param string $context
             * '/frontend/'
             * @param string $output
             *  The generated output of this page, ie. a string of HTML, passed by reference
             */
            Symphony::ExtensionManager()->notifyMembers('FrontendOutputPostGenerate', '/frontend/', array('output' => &$output));

            if (is_null($devkit) && !$output) {
                $errstr = null;

                while (list(, $val) = $this->Proc->getError()) {
                    $errstr .= 'Line: ' . $val['line'] . ' - ' . $val['message'] . PHP_EOL;
                }

                Frontend::instance()->throwCustomError(
                    trim($errstr),
                    __('XSLT Processing Error'),
                    Page::HTTP_STATUS_ERROR,
                    'xslt',
                    array('proc' => clone $this->Proc)
                );
            }

            Symphony::Profiler()->sample('Page creation complete');
        }

        if (!is_null($devkit)) {
            $devkit->prepare($this, $this->_pageData, $this->_xml, $this->_param, $output);

            return $devkit->build();
        }

        // Display the Event Results in the page source if the user is logged
        // into Symphony, the page is not JSON and if it is enabled in the
        // configuration.
        if ($this->is_logged_in && !General::in_iarray('JSON', $this->_pageData['type']) && Symphony::Configuration()->get('display_event_xml_in_source', 'public') === 'yes') {
            $output .= PHP_EOL . '<!-- ' . PHP_EOL . $this->_events_xml->generate(true) . ' -->';
        }

        return $output;
    }

    /**
     * This function sets the page's parameters, processes the Datasources and
     * Events and sets the `$xml` and `$xsl` variables. This functions resolves the `$page`
     * by calling the `resolvePage()` function. If a page is not found, it attempts
     * to locate the Symphony 404 page set in the backend otherwise it throws
     * the default Symphony 404 page. If the page is found, the page's XSL utility
     * is found, and the system parameters are set, including any URL parameters,
     * params from the Symphony cookies. Events and Datasources are executed and
     * any parameters  generated by them are appended to the existing parameters
     * before setting the Page's XML and XSL variables are set to the be the
     * generated XML (from the Datasources and Events) and the XSLT (from the
     * file attached to this Page)
     *
     * @uses FrontendPageResolved
     * @uses FrontendParamsResolve
     * @uses FrontendParamsPostResolve
     * @see resolvePage()
     */
    private function __buildPage()
    {
        $start = precision_timer();

        if (!$page = $this->resolvePage()) {
            throw new FrontendPageNotFoundException;
        }

        /**
         * Just after having resolved the page, but prior to any commencement of output creation
         * @delegate FrontendPageResolved
         * @param string $context
         * '/frontend/'
         * @param FrontendPage $page
         *  An instance of this class, passed by reference
         * @param array $page_data
         *  An associative array of page data, which is a combination from `tbl_pages` and
         *  the path of the page on the filesystem. Passed by reference
         */
        Symphony::ExtensionManager()->notifyMembers('FrontendPageResolved', '/frontend/', array('page' => &$this, 'page_data' => &$page));

        $this->_pageData = $page;
        $path = explode('/', $page['path']);
        $root_page = is_array($path) ? array_shift($path) : $path;
        $current_path = explode(dirname(server_safe('SCRIPT_NAME')), server_safe('REQUEST_URI'), 2);
        $current_path = '/' . ltrim(end($current_path), '/');
        $split_path = explode('?', $current_path, 3);
        $current_path = rtrim(current($split_path), '/');
        $querystring = next($split_path);

        // Get max upload size from php and symphony config then choose the smallest
        $upload_size_php = ini_size_to_bytes(ini_get('upload_max_filesize'));
        $upload_size_sym = Symphony::Configuration()->get('max_upload_size', 'admin');
        $date = new DateTime();

        $this->_param = array(
            'today' => $date->format('Y-m-d'),
            'current-time' => $date->format('H:i'),
            'this-year' => $date->format('Y'),
            'this-month' => $date->format('m'),
            'this-day' => $date->format('d'),
            'timezone' => $date->format('P'),
            'timestamp' => $date->format('U'),
            'website-name' => Symphony::Configuration()->get('sitename', 'general'),
            'page-title' => $page['title'],
            'root' => URL,
            'workspace' => URL . '/workspace',
            'workspace-path' => DIRROOT . '/workspace',
            'http-host' => HTTP_HOST,
            'root-page' => ($root_page ? $root_page : $page['handle']),
            'current-page' => $page['handle'],
            'current-page-id' => $page['id'],
            'current-path' => ($current_path == '') ? '/' : $current_path,
            'parent-path' => '/' . $page['path'],
            'current-query-string' => self::sanitizeParameter($querystring),
            'current-url' => URL . $current_path,
            'upload-limit' => min($upload_size_php, $upload_size_sym),
            'symphony-version' => Symphony::Configuration()->get('version', 'symphony'),
        );

        if (isset($this->_env['url']) && is_array($this->_env['url'])) {
            foreach ($this->_env['url'] as $key => $val) {
                $this->_param[$key] = $val;
            }
        }

        if (is_array($_GET) && !empty($_GET)) {
            foreach ($_GET as $key => $val) {
                if (in_array($key, array('symphony-page', 'debug', 'profile'))) {
                    continue;
                }

                // If the browser sends encoded entities for &, ie. a=1&amp;b=2
                // this causes the $_GET to output they key as amp;b, which results in
                // $url-amp;b. This pattern will remove amp; allow the correct param
                // to be used, $url-b
                $key = preg_replace('/(^amp;|\/)/', null, $key);

                // If the key gets replaced out then it will break the XML so prevent
                // the parameter being set.
                $key = General::createHandle($key);
                if (!$key) {
                    continue;
                }

                // Handle ?foo[bar]=hi as well as straight ?foo=hi RE: #1348
                if (is_array($val)) {
                    $val = General::array_map_recursive(array('FrontendPage', 'sanitizeParameter'), $val);
                } else {
                    $val = self::sanitizeParameter($val);
                }

                $this->_param['url-' . $key] = $val;
            }
        }

        // Flatten parameters:
        General::flattenArray($this->_param);

        // Add Page Types to parameters so they are not flattened too early
        $this->_param['page-types'] = $page['type'];

        // Add Page events the same way
        $this->_param['page-events'] = explode(',', trim(str_replace('_', '-', $page['events']), ','));

        /**
         * Just after having resolved the page params, but prior to any commencement of output creation
         * @delegate FrontendParamsResolve
         * @param string $context
         * '/frontend/'
         * @param array $params
         *  An associative array of this page's parameters
         */
        Symphony::ExtensionManager()->notifyMembers('FrontendParamsResolve', '/frontend/', array('params' => &$this->_param));

        $xml_build_start = precision_timer();

        $xml = new XMLDocument('data');
        $xml->renderHeader();

        $events = new XMLElement('events');
        $this->processEvents($page['events'], $events);
        $xml->appendChild($events);

        $this->_events_xml = clone $events;

        $this->processDatasources($page['data_sources'], $xml);

        Symphony::Profiler()->seed($xml_build_start);
        Symphony::Profiler()->sample('XML Built', PROFILE_LAP);

        if (isset($this->_env['pool']) && is_array($this->_env['pool']) && !empty($this->_env['pool'])) {
            foreach ($this->_env['pool'] as $handle => $p) {
                if (!is_array($p)) {
                    $p = array($p);
                }

                // Check if the data source is excluded from xml output
                $dsName = current(explode('.', $handle));
                if ($dsName && $this->_xml_excluded_params[$dsName]) {
                    continue;
                }

                // Flatten and add all values
                General::flattenArray($p);
                $this->_param[$handle] = implode(', ', $p);
            }
        }

        /**
         * Access to the resolved param pool, including additional parameters provided by Data Source outputs
         * @delegate FrontendParamsPostResolve
         * @param string $context
         * '/frontend/'
         * @param array $params
         *  An associative array of this page's parameters
         */
        Symphony::ExtensionManager()->notifyMembers('FrontendParamsPostResolve', '/frontend/', array('params' => &$this->_param));

        $params = new XMLElement('params');
        foreach ($this->_param as $key => $value) {
            // To support multiple parameters using the 'datasource.field'
            // we explode the string and create handles from it parts.
            // This is because of a limitation where Lang::createHandle()
            // will strip '.' as it's technically punctuation.
            if (strpos($key, '.') !== false) {
                $parts = explode('.', $key);
                $key = implode('.', array_map(function ($part) {
                    return Lang::createHandle($part);
                }, $parts));
            } else {
                $key = Lang::createHandle($key);
            }

            $param = new XMLElement($key);

            // DS output params get flattened to a string, so get the original pre-flattened array
            if (isset($this->_env['pool'][$key])) {
                $value = $this->_env['pool'][$key];
            }

            if (is_array($value) && !(count($value) == 1 && empty($value[0]))) {
                foreach ($value as $key => $value) {
                    $item = new XMLElement('item', General::sanitize($value));
                    $item->setAttribute('handle', Lang::createHandle($value));
                    $param->appendChild($item);
                }
            } elseif (is_array($value)) {
                $param->setValue(General::sanitize($value[0]));
            } elseif (in_array($key, array('xsrf-token', 'current-query-string'))) {
                $param->setValue(General::wrapInCDATA($value));
            } else {
                $param->setValue(General::sanitize($value));
            }

            $params->appendChild($param);
        }
        $xml->prependChild($params);

        $this->setXML($xml);
        $xsl = '<?xml version="1.0" encoding="UTF-8"?>' .
               '<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">' .
               '    <xsl:import href="/' . rawurlencode(ltrim($page['filelocation'], '/')) . '"/>' .
               '</xsl:stylesheet>';

        $this->setXSL($xsl, false);

        Symphony::Profiler()->seed($start);
        Symphony::Profiler()->sample('Page Built', PROFILE_LAP);
    }

    /**
     * This function attempts to resolve the given page in to it's Symphony page. If no
     * page is given, it is assumed the 'index' is being requested. Before a page row is
     * returned, it is checked to see that if it has the 'admin' type, that the requesting
     * user is authenticated as a Symphony author. If they are not, the Symphony 403
     * page is returned (whether that be set as a user defined page using the page type
     * of 403, or just returning the Default Symphony 403 error page). Any URL parameters
     * set on the page are added to the `$env` variable before the function returns an
     * associative array of page details such as Title, Content Type etc.
     *
     * @uses FrontendPrePageResolve
     * @see __isSchemaValid()
     * @param string $page
     * The URL of the current page that is being Rendered as returned by `getCurrentPage()`.
     * If no URL is provided, Symphony assumes the Page with the type 'index' is being
     * requested.
     * @throws SymphonyException
     * @return array
     *  An associative array of page details
     */
    public function resolvePage($page = null)
    {
        if ($page) {
            $this->_page = $page;
        }

        $row = null;
        /**
         * Before page resolve. Allows manipulation of page without redirection
         * @delegate FrontendPrePageResolve
         * @param string $context
         * '/frontend/'
         * @param mixed $row
         * @param FrontendPage $page
         *  An instance of this FrontendPage
         */
        Symphony::ExtensionManager()->notifyMembers('FrontendPrePageResolve', '/frontend/', array('row' => &$row, 'page' => &$this->_page));

        // Default to the index page if no page has been specified
        if ((!$this->_page || $this->_page == '//') && is_null($row)) {
            $row = PageManager::fetchPageByType('index');

            // Not the index page (or at least not on first impression)
        } elseif (is_null($row)) {
            $page_extra_bits = array();
            $pathArr = preg_split('/\//', trim($this->_page, '/'), -1, PREG_SPLIT_NO_EMPTY);
            $handle = array_pop($pathArr);

            do {
                $path = implode('/', $pathArr);

                if ($row = PageManager::resolvePageByPath($handle, $path)) {
                    $pathArr[] = $handle;

                    break 1;
                } else {
                    $page_extra_bits[] = $handle;
                }
            } while (($handle = array_pop($pathArr)) !== null);

            // If the `$pathArr` is empty, that means a page hasn't resolved for
            // the given `$page`, however in some cases the index page may allow
            // parameters, so we'll check.
            if (empty($pathArr)) {
                // If the index page does not handle parameters, then return false
                // (which will give up the 404), otherwise treat the `$page` as
                // parameters of the index. RE: #1351
                $index = PageManager::fetchPageByType('index');

                if (!$this->__isSchemaValid($index['params'], $page_extra_bits)) {
                    return false;
                } else {
                    $row = $index;
                }

                // Page resolved, check the schema (are the parameters valid?)
            } elseif (!$this->__isSchemaValid($row['params'], $page_extra_bits)) {
                return false;
            }
        }

        // Nothing resolved, bail now
        if (!is_array($row) || empty($row)) {
            return false;
        }

        // Process the extra URL params
        $url_params = preg_split('/\//', $row['params'], -1, PREG_SPLIT_NO_EMPTY);

        foreach ($url_params as $var) {
            $this->_env['url'][$var] = null;
        }

        if (isset($page_extra_bits)) {
            if (!empty($page_extra_bits)) {
                $page_extra_bits = array_reverse($page_extra_bits);
            }

            for ($i = 0, $ii = count($page_extra_bits); $i < $ii; $i++) {
                $this->_env['url'][$url_params[$i]] = str_replace(' ', '+', $page_extra_bits[$i]);
            }
        }

        $row['type'] = PageManager::fetchPageTypes($row['id']);

        // Make sure the user has permission to access this page
        if (!$this->is_logged_in && in_array('admin', $row['type'])) {
            $row = PageManager::fetchPageByType('403');

            if (empty($row)) {
                Frontend::instance()->throwCustomError(
                    __('Please login to view this page.') . ' <a href="' . SYMPHONY_URL . '/login/">' . __('Take me to the login page') . '</a>.',
                    __('Forbidden'),
                    Page::HTTP_STATUS_FORBIDDEN
                );
            }

            $row['type'] = PageManager::fetchPageTypes($row['id']);
        }

        $row['filelocation'] = PageManager::resolvePageFileLocation($row['path'], $row['handle']);

        return $row;
    }

    /**
     * Given the allowed params for the resolved page, compare it to
     * params provided in the URL. If the number of params provided
     * is less than or equal to the number of URL parameters set for a page,
     * the Schema is considered valid, otherwise, it's considered to be false
     * a 404 page will result.
     *
     * @param string $schema
     *  The URL schema for a page, ie. article/read
     * @param array $bits
     *  The URL parameters provided from parsing the current URL. This
     *  does not include any `$_GET` or `$_POST` variables.
     * @return boolean
     *  true if the number of $schema (split by /) is less than the size
     *  of the $bits array.
     */
    private function __isSchemaValid($schema, array $bits)
    {
        $schema_arr = preg_split('/\//', $schema, -1, PREG_SPLIT_NO_EMPTY);

        return (count($schema_arr) >= count($bits));
    }

    /**
     * The processEvents function executes all Events attached to the resolved
     * page in the correct order determined by `__findEventOrder()`. The results
     * from the Events are appended to the page's XML. Events execute first,
     * before Datasources.
     *
     * @uses FrontendProcessEvents
     * @uses FrontendEventPostProcess
     * @param string $events
     *  A string of all the Events attached to this page, comma separated.
     * @param XMLElement $wrapper
     *  The XMLElement to append the Events results to. Event results are
     *  contained in a root XMLElement that is the handlised version of
     *  their name.
     * @throws Exception
     */
    private function processEvents($events, XMLElement &$wrapper)
    {
        /**
         * Manipulate the events array and event element wrapper
         * @delegate FrontendProcessEvents
         * @param string $context
         * '/frontend/'
         * @param array $env
         * @param string $events
         *  A string of all the Events attached to this page, comma separated.
         * @param XMLElement $wrapper
         *  The XMLElement to append the Events results to. Event results are
         *  contained in a root XMLElement that is the handlised version of
         *  their name.
         * @param array $page_data
         *  An associative array of page meta data
         */
        Symphony::ExtensionManager()->notifyMembers('FrontendProcessEvents', '/frontend/', array(
            'env' => $this->_env,
            'events' => &$events,
            'wrapper' => &$wrapper,
            'page_data' => $this->_pageData
        ));

        if (strlen(trim($events)) > 0) {
            $events = preg_split('/,\s*/i', $events, -1, PREG_SPLIT_NO_EMPTY);
            $events = array_map('trim', $events);

            if (!is_array($events) || empty($events)) {
                return;
            }

            $pool = array();

            foreach ($events as $handle) {
                $pool[$handle] = EventManager::create($handle, array('env' => $this->_env, 'param' => $this->_param));
            }

            uasort($pool, array($this, '__findEventOrder'));

            foreach ($pool as $handle => $event) {
                $startTime = precision_timer();
                $queries = Symphony::Database()->queryCount();

                if ($xml = $event->load()) {
                    if (is_object($xml)) {
                        $wrapper->appendChild($xml);
                    } else {
                        $wrapper->setValue(
                            $wrapper->getValue() . PHP_EOL . '    ' . trim($xml)
                        );
                    }
                }

                $queries = Symphony::Database()->queryCount() - $queries;
                Symphony::Profiler()->seed($startTime);
                Symphony::Profiler()->sample($handle, PROFILE_LAP, 'Event', $queries);
            }
        }

        /**
         * Just after the page events have triggered. Provided with the XML object
         * @delegate FrontendEventPostProcess
         * @param string $context
         * '/frontend/'
         * @param XMLElement $xml
         *  The XMLElement to append the Events results to. Event results are
         *  contained in a root XMLElement that is the handlised version of
         *  their name.
         */
        Symphony::ExtensionManager()->notifyMembers('FrontendEventPostProcess', '/frontend/', array('xml' => &$wrapper));
    }

    /**
     * This function determines the correct order that events should be executed in.
     * Events are executed based off priority, with `Event::kHIGH` priority executing
     * first. If there is more than one Event of the same priority, they are then
     * executed in alphabetical order. This function is designed to be used with
     * PHP's uasort function.
     *
     * @link http://php.net/manual/en/function.uasort.php
     * @param Event $a
     * @param Event $b
     * @return integer
     */
    private function __findEventOrder($a, $b)
    {
        if ($a->priority() == $b->priority()) {
            $a = $a->about();
            $b = $b->about();

            $handles = array($a['name'], $b['name']);
            asort($handles);

            return (key($handles) == 0) ? -1 : 1;
        }
        return $a->priority() > $b->priority() ? -1 : 1;
    }

    /**
     * Given an array of all the Datasources for this page, sort them into the
     * correct execution order and append the Datasource results to the
     * page XML. If the Datasource provides any parameters, they will be
     * added to the `$env` pool for use by other Datasources and eventual
     * inclusion into the page parameters.
     *
     * @param string $datasources
     *  A string of Datasource's attached to this page, comma separated.
     * @param XMLElement $wrapper
     *  The XMLElement to append the Datasource results to. Datasource
     *  results are contained in a root XMLElement that is the handlised
     *  version of their name.
     * @param array $params
     *  Any params to automatically add to the `$env` pool, by default this
     *  is an empty array. It looks like Symphony does not utilise this parameter
     *  at all
     * @throws Exception
     */
    public function processDatasources($datasources, XMLElement &$wrapper, array $params = array())
    {
        if (trim($datasources) == '') {
            return;
        }

        $datasources = preg_split('/,\s*/i', $datasources, -1, PREG_SPLIT_NO_EMPTY);
        $datasources = array_map('trim', $datasources);

        if (!is_array($datasources) || empty($datasources)) {
            return;
        }

        $this->_env['pool'] = $params;
        $pool = $params;
        $dependencies = array();

        foreach ($datasources as $handle) {
            $pool[$handle] = DatasourceManager::create($handle, array(), false);
            $dependencies[$handle] = $pool[$handle]->getDependencies();
        }

        $dsOrder = $this->__findDatasourceOrder($dependencies);

        foreach ($dsOrder as $handle) {
            $startTime = precision_timer();
            $queries = Symphony::Database()->queryCount();

            // default to no XML
            $xml = null;
            $ds = $pool[$handle];

            // Handle redirect on empty setting correctly RE: #1539
            try {
                $ds->processParameters(array('env' => $this->_env, 'param' => $this->_param));
            } catch (FrontendPageNotFoundException $e) {
                // Work around. This ensures the 404 page is displayed and
                // is not picked up by the default catch() statement below
                FrontendPageNotFoundExceptionRenderer::render($e);
            }

            /**
             * Allows extensions to execute the data source themselves (e.g. for caching)
             * and providing their own output XML instead
             *
             * @since Symphony 2.3
             * @delegate DataSourcePreExecute
             * @param string $context
             * '/frontend/'
             * @param DataSource $datasource
             *  The Datasource object
             * @param mixed $xml
             *  The XML output of the data source. Can be an `XMLElement` or string.
             * @param array $param_pool
             *  The existing param pool including output parameters of any previous data sources
             */
            Symphony::ExtensionManager()->notifyMembers('DataSourcePreExecute', '/frontend/', array(
                'datasource' => &$ds,
                'xml' => &$xml,
                'param_pool' => &$this->_env['pool']
            ));

            // if the XML is still null, an extension has not run the data source, so run normally
            // This is deprecated and will be replaced by execute in Symphony 3.0.0
            if (is_null($xml)) {
                $xml = $ds->execute($this->_env['pool']);
            }

            // If the data source does not want to output its xml, keep the info for later
            if (isset($ds->dsParamPARAMXML) && $ds->dsParamPARAMXML !== 'yes') {
                $this->_xml_excluded_params['ds-' . $ds->dsParamROOTELEMENT] = true;
            }

            if ($xml) {
                /**
                 * After the datasource has executed, either by itself or via the
                 * `DataSourcePreExecute` delegate, and if the `$xml` variable is truthy,
                 * this delegate allows extensions to modify the output XML and parameter pool
                 *
                 * @since Symphony 2.3
                 * @delegate DataSourcePostExecute
                 * @param string $context
                 * '/frontend/'
                 * @param DataSource $datasource
                 *  The Datasource object
                 * @param mixed $xml
                 *  The XML output of the data source. Can be an `XMLElement` or string.
                 * @param array $param_pool
                 *  The existing param pool including output parameters of any previous data sources
                 */
                Symphony::ExtensionManager()->notifyMembers('DataSourcePostExecute', '/frontend/', array(
                    'datasource' => $ds,
                    'xml' => &$xml,
                    'param_pool' => &$this->_env['pool']
                ));

                if ($xml instanceof XMLElement) {
                    $wrapper->appendChild($xml);
                } else {
                    $wrapper->appendChild(
                        '    ' . trim($xml) . PHP_EOL
                    );
                }
            }

            $queries = Symphony::Database()->queryCount() - $queries;
            Symphony::Profiler()->seed($startTime);
            Symphony::Profiler()->sample($handle, PROFILE_LAP, 'Datasource', $queries);
            unset($ds);
        }
    }

    /**
     * The function finds the correct order Datasources need to be processed in to
     * satisfy all dependencies that parameters can resolve correctly and in time for
     * other Datasources to filter on.
     *
     * @param array $dependenciesList
     *  An associative array with the key being the Datasource handle and the values
     *  being it's dependencies.
     * @return array
     *  The sorted array of Datasources in order of how they should be executed
     */
    private function __findDatasourceOrder($dependenciesList)
    {
        if (!is_array($dependenciesList) || empty($dependenciesList)) {
            return;
        }

        foreach ($dependenciesList as $handle => $dependencies) {
            foreach ($dependencies as $i => $dependency) {
                $dependency = explode('.', $dependency);
                $dependenciesList[$handle][$i] = reset($dependency);
            }
        }

        $orderedList = array();
        $dsKeyArray = $this->buildDatasourcePooledParamList(array_keys($dependenciesList));

        // 1. First do a cleanup of each dependency list, removing non-existant DS's and find
        //    the ones that have no dependencies, removing them from the list
        foreach ($dependenciesList as $handle => $dependencies) {
            $dependenciesList[$handle] = array_intersect($dsKeyArray, $dependencies);

            if (empty($dependenciesList[$handle])) {
                unset($dependenciesList[$handle]);
                $orderedList[] = str_replace('_', '-', $handle);
            }
        }

        // 2. Iterate over the remaining DS's. Find if all their dependencies are
        //    in the $orderedList array. Keep iterating until all DS's are in that list
        //    or there are circular dependencies (list doesn't change between iterations
        //    of the while loop)
        do {
            $last_count = count($dependenciesList);

            foreach ($dependenciesList as $handle => $dependencies) {
                if (General::in_array_all(array_map(function($a) {return str_replace('$ds-', '', $a);}, $dependencies), $orderedList)) {
                    $orderedList[] = str_replace('_', '-', $handle);
                    unset($dependenciesList[$handle]);
                }
            }
        } while (!empty($dependenciesList) && $last_count > count($dependenciesList));

        if (!empty($dependenciesList)) {
            $orderedList = array_merge($orderedList, array_keys($dependenciesList));
        }

        return array_map(function($a) {return str_replace('-', '_', $a);}, $orderedList);
    }

    /**
     * Given an array of datasource dependancies, this function will translate
     * each of them to be a valid datasource handle.
     *
     * @param array $datasources
     *  The datasource dependencies
     * @return array
     *  An array of the handlised datasources
     */
    private function buildDatasourcePooledParamList($datasources)
    {
        if (!is_array($datasources) || empty($datasources)) {
            return array();
        }

        $list = array();

        foreach ($datasources as $handle) {
            $rootelement = str_replace('_', '-', $handle);
            $list[] = '$ds-' . $rootelement;
        }

        return $list;
    }

    /**
     * Given a string (expected to be a URL parameter) this function will
     * ensure it is safe to embed in an XML document.
     *
     * @since Symphony 2.3.1
     * @param string $parameter
     *  The string to sanitize for XML
     * @return string
     *  The sanitized string
     */
    public static function sanitizeParameter($parameter)
    {
        return XMLElement::stripInvalidXMLCharacters($parameter);
    }
}