contao / core

Contao 3 → see contao/contao for Contao 4
GNU Lesser General Public License v3.0
490 stars 213 forks source link

Provide a class for handling AJAX #4415

Open qzminski opened 12 years ago

qzminski commented 12 years ago

How about providing a new class for AJAX things, so we don't have to download Andreas' extension (http://www.contao.org/en/extension-list/view/ajax.10010039.en.html) every time? Perhaps you can bundle this extension into core?

qzminski commented 11 years ago

I think it could be easily solved by the structure like below.

The TL_AJAX array should contain the "action" keyword as a key and the callback array as the value. We would require the "action" $_GET or $_POST parameter for each request.

The Ajax class would be the heart of the whole mechanism. It would provide basic methods like Ajax::output() for successful requests, as well as Ajax::outputError() for bad requests. If the class would extend the controller, then we would be also able to fetch modules and content elements, e.g. by getFrontendModule() method. If you worry about the page access permissions, then I'd go with a custom ajax parameter (e.g. pageId=123) and a custom method Ajax::generatePage() or Ajax::checkPagePermissions().

// config/config.php
$GLOBALS['TL_AJAX']['myclass_search'] = array('MyClass', 'executeSearch');

// the ajax $_POST request
http://www.website.com/music-library/search.html?action=myclass_search&keywords=foobar

// MyClass.php
class MyClass extends \Ajax
{
    public function executeSearch()
    {
        if (\Input::post('keywords'))
        {
            // some code goes here...

            $this->output(json_encode($arrResults));
        }

        $this->outputError();
    }
}
contaolove commented 9 years ago

Hi there. Since I'm very interested at this topic and I would support a core solution for die feature I wanna show you how currently my solution is with AJAX requests for frontend. It's an mix between aschempps contao-ajax (thanks very much!) and some enhancements.

All AJAX requests from MooTools/jQuery are running through and library which are extending the libraries ajax method with the current page id and request token. My intention is to use POST requests only to the following URL (Nearly same behaviour then cron.php).

I'm very sorry for the huge amount of code. I haven't uploaded the extension until yet to github. It would be very nice to get some feedback for this kind of working with AJAX in frontend and maybe you can point me to some weakness of this classes/files.

A sample AJAX request (POST)

var $.ajax('system/ajax/ajax.php',
{
  method: 'POST',
  async: true,
  data: { 
    REQUEST_TOKEN: '....',
    REQUEST_PAGE: 2
    REQUEST_CONTROLLER: 'member',
    REQUEST_METHOD: 'getGender'
});

Redirect all requests to ajax.php

File: system/ajax/ajax.php

// Patch system eg. for flash upload, this allows to transmit Session-ID and User-Auth Key using GET-Paramenters
@ini_set('session.use_only_cookies', '0');
if (isset($_GET['FE_USER_AUTH']))
{
    $_COOKIE['FE_USER_AUTH'] = $_GET['FE_USER_AUTH'];
}

// ajax.php is a frontend script
define('TL_MODE', 'FE');

// Start the session so we can access known request tokens
@session_start();

// Allow do bypass the token check if a known token is passed in
if (isset($_GET['bypassToken']) && ((is_array($_SESSION['REQUEST_TOKEN'][TL_MODE]) && in_array($_POST['REQUEST_TOKEN'], $_SESSION['REQUEST_TOKEN'][TL_MODE])) || $_SESSION['REQUEST_TOKEN'][TL_MODE] == $_POST['REQUEST_TOKEN']))
{
    define('BYPASS_TOKEN_CHECK', true);
}

// Close session so Contao's initalization routine can use ini_set()
session_write_close();

// Initialize the system
require('../initialize.php');

// Run the controller
$ajax = new \MyClass\FrontendAjax;
$ajax->run();

FrontendAjax performs as the proper AJAX handler

File: system/modules/myextension/library/MyNamespace/FrontendAjax.php

namespace MyNamespace;
use MyNamespace\Ajax;

/**
 * Frontend ajax handler
 */
class FrontendAjax extends \PageRegular
{

    /**
     * Initialize the object (do not remove)
     */
    public function __construct()
    {
        // Load the user object before calling the parent constructor
        $this->import('FrontendUser', 'User');
        parent::__construct();

        // Check whether a user is logged in
        define('BE_USER_LOGGED_IN', $this->getLoginStatus('BE_USER_AUTH'));
        define('FE_USER_LOGGED_IN', $this->getLoginStatus('FE_USER_AUTH'));
    }

    /**
     * Run the controller
     */
    public function run()
    {
        // Since initialize.php only checks the request token if there is any POST data,
        // it is possible to perform an POST/GET request without any POST vars and get
        // for example the TL_CONFIG. So if there is no POST/GET data we exit here.
        // Otherwise if there is POST data available, request is secured through the
        // Contao request token system.
        if (!$_POST && !$_GET)
        {
            header('HTTP/1.1 412 Precondition failed');
            die('Invalid ajax call.');
        }

        // Convert all variables to cleaned POST variables
        $this->convertToPostAndCleanUp();

        // Authenticate the user
        $this->User->authenticate();

        // Check if there is a page ID
        if (\Input::post('REQUEST_PAGE') > 0)
        {
            global $objPage;
            $intPage = \Input::post('REQUEST_PAGE');
            $objPage = \PageModel::findWithDetails($intPage);

            // Exit if no page details can be loaded
            if (null === $objPage)
            {
                header('HTTP/1.1 412 Precondition failed');
                die('No page object could be loaded.');
            }

            // Exit if page is not published
            if (!strlen($objPage->published) || !strlen($objPage->rootIsPublic))
            {
                header('HTTP/1.1 412 Precondition failed');
                die('Page not published.');
            }

            // Show page to guests only
            if (strlen($objPage->guests) && FE_USER_LOGGED_IN && !BE_USER_LOGGED_IN && !$objPage->protected)
            {
                header('HTTP/1.1 412 Precondition failed');
                die('Request page is for guests only.');
            }

            // Protected page
            if (strlen($objPage->protected) && !BE_USER_LOGGED_IN)
            {
                // Check if user is logged in
                if (false !== FE_USER_LOGGED_IN)
                {
                    header('HTTP/1.1 412 Precondition failed');
                    die('Request page is protected.');
                }

                // Check if user is within allowed groups
                $groups = deserialize($objPage->groups);

                if (empty($groups) || !is_array($groups) || count(array_intersect($groups, $this->User->groups)) < 1)
                {
                    header('HTTP/1.1 412 Precondition failed');
                    die('Request page is forbidden.');
                }
            }

            // Page is shown from-to
            if (!BE_USER_LOGGED_IN && ($objPage->start != '' && $objPage->start > time()) || ($objPage->stop != '' && $objPage->stop < time()))
            {
                header('HTTP/1.1 412 Precondition failed');
                die('Request page is forbidden.');
            }

            // Load page config
            $objPage = $this->loadPageConfig($objPage);
        }

        // Set language from _GET
        if (strlen(\Input::post('REQUEST_LANGUAGE')))
        {
            $GLOBALS['TL_LANGUAGE'] = \Input::post('REQUEST_LANGUAGE');
        }

        // Disallow special hooks
        unset($GLOBALS['TL_HOOKS']['outputFrontendTemplate']);
        unset($GLOBALS['TL_HOOKS']['parseFrontendTemplate']);

        // Load the default language file
        \System::loadLanguageFile('default');

        // Check if there is a controller
        if (\Input::post('REQUEST_CONTROLLER') == '')
        {
            header('HTTP/1.1 412 Precondition Failed');
            die('Invalid AJAX controller.');
        }

        // Load the AJAX controller
        $strController = Ajax::getClassForController(\Input::post('REQUEST_CONTROLLER'));
        $objController = new $strController();

        // Check if there is a method
        if (\Input::post('REQUEST_METHOD') == '')
        {
            header('HTTP/1.1 412 Precondition Failed');
            die('Invalid AJAX method.');
        }

        if (!method_exists($objController, \Input::post('REQUEST_METHOD')))
        {
            header('HTTP/1.1 412 Precondition Failed');
            die('AJAX method not implemented.');
        }

        // Execute the AJAX method
        $strMethod = \Input::post('REQUEST_METHOD');
        $varOutput = $objController->$strMethod();

        // Exit here
        $this->output($varOutput);

        header('HTTP/1.1 412 Precondition Failed');
        die('Invalid AJAX call.');
    }

    /**
     * Replace insert tags and output data either in raw format or json encoded.
     * @param   mixed   $varOutput
     * @return  mixed   $varOutput
     */
    protected function output($varValue)
    {
        // Output as raw format to use another type then JSON
        if (is_array($varValue) && $varValue['raw'])
        {
            header('Content-Type: ' . $varValue['header']);
            echo $this->replaceTags($varValue['content']);
            exit;
        }

        // Output as JSON
        $varValue = array
        (
            'token'     => REQUEST_TOKEN,
            'content'   => $this->replaceTags($varValue),
            'tstamp'    => time()
        );

        header('Content-Type: application/json');
        echo json_encode($varValue);
        exit;
    }

    /**
     * Recursivly replace insert tags
     */
    protected function replaceTags($varValue)
    {
        if (is_array($varValue))
        {
            foreach ($varValue as $k => $v)
            {
                $varValue[$k] = $this->replaceTags($v);
            }

            return $varValue;
        }
        elseif (is_object($varValue))
        {
            return $varValue;
        }

        return $this->replaceInsertTags($varValue);
    }

    /**
     * Convert all GET to POST and clean up the POST vars
     */
    protected function convertToPostAndCleanUp()
    {
        // $_POST
        if (!is_array($_GET) && is_array($_POST))
        {
            foreach ($arrPost as $k => $v)
            {
                \Input::setPost($k, \Input::post($k));
            }

            return;
        }

        // $_GET
        if (!empty($_GET))
        {
            foreach ($_GET as $k => $v)
            {
                \Input::setPost($k, \Input::get($k));
                unset($_GET[$k]);
            }
        }

        return;
    }

    /**
     * Load system configuration into page object
     * @param \Database\Result
     */
    protected function loadPageConfig($objPage)
    {
        // Use the global date format if none is set
        if ($objPage->dateFormat == '')
        {
            $objPage->dateFormat = $GLOBALS['TL_CONFIG']['dateFormat'];
        }

        if ($objPage->timeFormat == '')
        {
            $objPage->timeFormat = $GLOBALS['TL_CONFIG']['timeFormat'];
        }

        if ($objPage->datimFormat == '')
        {
            $objPage->datimFormat = $GLOBALS['TL_CONFIG']['datimFormat'];
        }

        // Set the admin e-mail address
        if ($objPage->adminEmail != '')
        {
            list($GLOBALS['TL_ADMIN_NAME'], $GLOBALS['TL_ADMIN_EMAIL']) = \System::splitFriendlyName($objPage->adminEmail);
        }
        else
        {
            list($GLOBALS['TL_ADMIN_NAME'], $GLOBALS['TL_ADMIN_EMAIL']) = \System::splitFriendlyName($GLOBALS['TL_CONFIG']['adminEmail']);
        }

        // Define the static URL constants
        define('TL_FILES_URL', ($objPage->staticFiles != '' && !$GLOBALS['TL_CONFIG']['debugMode']) ? $objPage->staticFiles . TL_PATH . '/' : '');
        define('TL_SCRIPT_URL', ($objPage->staticSystem != '' && !$GLOBALS['TL_CONFIG']['debugMode']) ? $objPage->staticSystem . TL_PATH . '/' : '');
        define('TL_PLUGINS_URL', ($objPage->staticPlugins != '' && !$GLOBALS['TL_CONFIG']['debugMode']) ? $objPage->staticPlugins . TL_PATH . '/' : '');

        $objLayout = $this->getPageLayout($objPage);

        if (null !== $objLayout)
        {
            // Get the page layout
            $objPage->template      = strlen($objLayout->template) ? $objLayout->template : 'fe_page';
            $objPage->templateGroup = $objLayout->templates;

            // Store the output format
            list($strFormat, $strVariant) = explode('_', $objLayout->doctype);
            $objPage->outputFormat  = $strFormat;
            $objPage->outputVariant = $strVariant;
        }

        $GLOBALS['TL_LANGUAGE'] = $objPage->language;
        return $objPage;
    }
}

Register classes in config.php

I register classes to a AJAX controller like aschempp does it in Isotope. File: system/modules/myextension/config/config.php

\MyNamespace\Ajax::register('member', 'MyNamespace\Ajax\Member');

The AJAX controller

File: system/modules/myextension/library/MyNamespace/Ajax.php

namespace MyNamespace;

/**
 * Ajax controller
 */
abstract class Ajax
{

    /**
     * List of all ajax methods
     * @var array
     */
    protected static $arrMethods = array();

    /**
     * Register a ajax method
     * @param   string
     * @param   string
     */
    public static function register($strName, $strClass)
    {
        if ($strName == '' || $strClass == '')
        {
            throw new \Exception('$strName and $strClass must be defined');
        }

        static::$arrMethods[$strName] = $strClass;
    }

    /**
     * Unregister a ajax method
     * @param   string
     * @param   string
     */
    public static function unregister($strName)
    {
        if ($strName == '')
        {
            throw new \Exception('$strName must be defined');
        }

        unset(static::$arrMethods[$strName]);
    }

    /**
     * Get list of ajax methods
     * @return  array
     */
    public static function getMethods()
    {
        return static::$arrMethods;
    }

    /**
     * Get class name for given controller
     * @param   string
     * @return  string
     */
    public static function getClassForController($strName)
    {
        if (!strlen(static::$arrMethods[$strName]))
        {
            throw new \Exception('Controller "$strName" cannot be found for initialization');
        }

        return static::$arrMethods[$strName];
    }
}

At least a sample AJAX class

File: system/modules/myextension/library/Ajax/Member.php

namespace MyNamespace\Ajax;

/**
 * Ajax class 'member'
 */
class Member extends \MyNamespace\Ajax
{

    // Test function
    public function getGender()
    {
        // Some DB queries
        return 'Gender: ' . $GLOBALS['TL_LANG']['MSC']['male'];
    }
}
qzminski commented 9 years ago

Thanks for contributing. It could be an extension for Contao 3 but it will not be introduced in core since there will be no next minor version of Contao 3. For Contao 4 this code does not apply however.

discordier commented 9 years ago

Having put the thing into a github repository would make examining it more easy but as @qzminski already said, something like this will not be added to Contao 3 anymore and Contao 4 does not need it, as there you define your own routes in the symfony way.