<?php
/**
 * OO AJAX Implementation for PHP
 *
 * @category   HTML
 * @package    AJAX
 * @author     Joshua Eichorn <josh@bluga.net>
 * @copyright  2005 Joshua Eichorn
 * @license    http://www.opensource.org/licenses/lgpl-license.php  LGPL
 * @version    Release: @package_version@
 */

/**
 * Require the main AJAX library
 */
require_once 'HTML/AJAX.php';

/**
 * Class for creating an external AJAX server
 *
 * Can be used in 2 different modes, registerClass mode where you create an instance of the server and add the classes that will be registered
 * and then run handle request
 *
 * Or you can extend it and add init{className} methods for each class you want to export
 *
 * Client js generation is exposed through 2 _GET params client and stub
 *  Setting the _GET param client to `all` will give you all the js classes needed
 *  Setting the _GET param stub to `all` will give you stubs of all registered classes, you can also set it too just 1 class
 *
 * @category   HTML
 * @package    AJAX
 * @author     Joshua Eichorn <josh@bluga.net>
 * @copyright  2005 Joshua Eichorn
 * @license    http://www.opensource.org/licenses/lgpl-license.php  LGPL
 * @version    Release: @package_version@
 * @link       http://pear.php.net/package/PackageName
 */
class HTML_AJAX_Server 
{

    /**
     * Client options array if set to true the code looks at _GET
     * @var bool|array
     */
    var $options = true;

    /**
     * HTML_AJAX instance
     * @var HTML_AJAX
     */
    var $ajax;

    /**
     * Set to true if your extending the server to add init{className methods}
     * @var boolean
     * @access  public
     */
    var $initMethods = false;

    /**
     * Location on filesystem of client javascript library
     * @var false|string if false the default pear data dir location is used
     */
    var $clientJsLocation = false;

    /** 
     * An array of options that tell the server howto Cache output
     *
     * The rules are functions that make etag hash used to see if the client needs to download updated content
     * If you extend this class you can make your own rule function the naming convention is _cacheRule{RuleName}
     *
     * <code>
     * array(
     *  'httpCacheClient' => true,   // send 304 headers for responses to ?client=* requests
     *  'ClientCacheRule' => 'File', // create a hash from file names and modified times, options: file|content
     *  'ClientCacheExpects'=> 'files', // what type of content to send to the hash function, options: files|classes|content
     *  'httpCacheStub'   => true,   // send 304 headers for responses to ?stub=* requests
     *  'StubCacheRule'   => 'Api',  // create a hash from the exposed api, options: api|content
     *  'StubCacheExpects'=> 'classes', // what type of content to send to the hash function, options: files|classes|content
     * )
     * </code>
     *
     * @var array
     * @access  public
     */
    var $cacheOptions = array(
        'httpCacheClient'       => true, 
        'ClientCacheRule'       => 'file',
        'ClientCacheExpects'    => 'files',
        'httpCacheStub'         => true, 
        'StubCacheRule'         => 'api', 
        'StubCacheExpects'      => 'classes', 
        );

    /**
     * Compression Options
     *
     * <code>
     * array(
     *  'enabled'   => false,   // enable compression
     *  'type'      => 'gzip'   // the type of compression to do, options: gzip
     * )
     * </code>
     *
     * @var array
     * @access public
     */
    var $compression = array(
        'enabled'       => false,
        'type'          => 'gzip'
    );

    /**
     * Javascript library names and there path 
     *
     * the return of $this->clientJsLocation(), is prepended before running readfile on them
     *
     * @access  public
     * @var array
     */
    var $javascriptLibraries = array(
        'all'           =>  'HTML_AJAX.js',
        'html_ajax'     =>  'HTML_AJAX.js',
        'html_ajax_lite'=>  'HTML_AJAX_lite.js',
        'json'          =>  'serializer/JSON.js',
        'request'       =>  'Request.js',
        'main'          =>  array('Compat.js','Main.js','clientPool.js'),
        'httpclient'    =>  'HttpClient.js',
        'dispatcher'    =>  'Dispatcher.js',
        'util'          =>  'util.js',
        'loading'       =>  'Loading.js',
        'phpserializer' =>  'serializer/phpSerializer.js',
        'urlserializer' =>  'serializer/UrlSerializer.js',
        'haserializer'  =>  'serializer/haSerializer.js',
        'clientpool'    =>  'clientPool.js',
        'iframe'        =>  'IframeXHR.js',
        'alias'         =>  'Alias.js',
        'queues'        =>  'Queue.js',
        'behavior'      =>  array('behavior/behavior.js','behavior/cssQuery-p.js'),

        // rules to help you use a minimal library set
        'standard'      =>  array('Compat.js','clientPool.js','util.js','Main.js','HttpClient.js','Request.js','serializer/JSON.js',
                                    'Loading.js','serializer/UrlSerializer.js','Alias.js','behavior/behavior.js','behavior/cssQuery-p.js'),
        'jsonrpc'       =>  array('Compat.js','util.js','Main.js','clientPool.js','HttpClient.js','Request.js','serializer/JSON.js'),
        'proxyobjects'  =>  array('Compat.js','util.js','Main.js','clientPool.js','Request.js','serializer/JSON.js','Dispatcher.js'),

        // BC rules
        'priorityqueue' =>  'Queue.js',
        'orderedqueue'  =>  'Queue.js',
    );

    /**
     * Custom paths to use for javascript libraries, if not set {@link clientJsLocation} is used to find the system path
     *
     * @access public
     * @var array
     * @see registerJsLibrary
     */
    var $javascriptLibraryPaths = array();

    /**
     * Array of className => init methods to call, generated from constructor from initClassName methods
     *
     * @access protected
     */
    var $_initLookup = array();
    

    /**
     * Constructor creates the HTML_AJAX instance
     *
     * @param string $serverUrl (Optional) the url the client should be making a request too
     */
    function HTML_AJAX_Server($serverUrl = false) 
    {
        $this->ajax = new HTML_AJAX();

        // parameters for HTML::AJAX
        $parameters = array('stub', 'client');

        // keep in the query string all the parameters that don't belong to AJAX
        // we remove all string like "parameter=something&". Final '&' can also
        // be '&amp;' (to be sure) and is optional. '=something' is optional too.
        $querystring = '';
        if (isset($_SERVER['QUERY_STRING'])) {
            $querystring = preg_replace('/(' . join('|', $parameters) . ')(?:=[^&]*(?:&(?:amp;)?|$))?/', '', $this->ajax->_getServer('QUERY_STRING'));
        }

        // call the server with this query string
        if ($serverUrl === false) {
            $serverUrl = htmlentities($this->ajax->_getServer('PHP_SELF'));
        }

        if (substr($serverUrl,-1) != '?') {
            $serverUrl .= '?';
        }
        $this->ajax->serverUrl =  $serverUrl . htmlentities($querystring);
        
        $methods = get_class_methods($this);
        foreach($methods as $method) {
            if (preg_match('/^init([a-zA-Z0-9_]+)$/',$method,$match)) {
                $this->_initLookup[strtolower($match[1])] = $method;
            }
        }
    }

    /**
     * Handle a client request, either generating a client or having HTML_AJAX handle the request
     *
     * @return boolean true if request was handled, false otherwise
     */
    function handleRequest() 
    {
        if ($this->options == true) {
            $this->_loadOptions();
        }
        //basically a hook for iframe but allows processing of data earlier
        $this->ajax->populatePayload();
        if (!isset($_GET['c']) && (count($this->options['client']) > 0 || count($this->options['stub']) > 0) ) {
            $this->generateClient();
            return true;
        } else {
            if (!empty($_GET['c'])) {
                $this->_init($this->_cleanIdentifier($this->ajax->_getVar('c')));
            }
            return $this->ajax->handleRequest();
        }
    }

    /**
     * Register method passthrough to HTML_AJAX
     *
     * @see HTML_AJAX::registerClass for docs
     */
    function registerClass(&$instance, $exportedName = false, $exportedMethods = false) 
    {
        $this->ajax->registerClass($instance,$exportedName,$exportedMethods);
    }

    /**
     * Change default serialization - important for exporting classes
     *
     * I wanted this for the xml serializer :)
     */
    function setSerializer($type) 
    {
        $this->ajax->serializer = $type;
        $this->ajax->unserializer = $type;
    }

    /**
     * Register a new js client library
     *
     * @param string          $libraryName name you'll reference the library as
     * @param string|array    $fileName   actual filename with no path, for example customLib.js
     * @param string|false    $path   Optional, if not set the result from jsClientLocation is used
     */
    function registerJSLibrary($libraryName,$fileName,$path = false) {
        $libraryName = strtolower($libraryName);
        $this->javascriptLibraries[$libraryName] = $fileName;

        if ($path !== false) {
            $this->javascriptLibraryPaths[$libraryName] = $path;
        }
    }

    /**
     * Register init methods from an external class
     *
     * @param object    $instance an external class with initClassName methods
     */
    function registerInitObject(&$instance) {
        $instance->server =& $this;
        $methods = get_class_methods($instance);
        foreach($methods as $method) {
            if (preg_match('/^init([a-zA-Z0-9_]+)$/',$method,$match)) {
                $this->_initLookup[strtolower($match[1])] = array(&$instance,$method);
            }
        }
    }

    /**
     * Register a callback to be exported to the client
     *
     * This function uses the PHP callback pseudo-type
     *
     */
    function registerPhpCallback($callback)
    {
        if (!is_callable($callback)) {
            // invalid callback
            return false;
        }
        
        if (is_array($callback) && is_object($callback[0])) {
            // object method
            $this->registerClass($callback[0], strtolower(get_class($callback[0])), array($callback[1]));
            return true;
        }
        
        // static callback
        $this->ajax->registerPhpCallback($callback);
    }

    /**
     * Generate client js
     *
     * @todo    this is going to need tests to cover all the options
     */
    function generateClient() 
    {
        $headers = array();

        ob_start();

        // create a list list of js files were going to need to output
        // index is the full file and so is the value, this keeps duplicates out of $fileList
        $fileList = array();

        if(!is_array($this->options['client'])) {
            $this->options['client'] = array();
        }
        foreach($this->options['client'] as $library) {
            if (isset($this->javascriptLibraries[$library])) {
                $lib = (array)$this->javascriptLibraries[$library];
                foreach($lib as $file) {
                    if (isset($this->javascriptLibraryPaths[$library])) {
                        $fileList[$this->javascriptLibraryPaths[$library].$file] = $this->javascriptLibraryPaths[$library].$file;
                    }
                    else {
                        $fileList[$this->clientJsLocation().$file] = $this->clientJsLocation().$file;
                    }
                }
            }
        }

        // do needed class init if were running an init server
        if(!is_array($this->options['stub'])) {
            $this->options['stub'] = array();
        }
        $classList = $this->options['stub'];
        if ($this->initMethods) {
            if (isset($this->options['stub'][0]) && $this->options['stub'][0] === 'all') {
                    $this->_initAll();
            } else {
                foreach($this->options['stub'] as $stub) {
                    $this->_init($stub);
                }
            }
        }
        if (isset($this->options['stub'][0]) && $this->options['stub'][0] === 'all') {
            $classList = array_keys($this->ajax->_exportedInstances);
        }

        // if were doing stub and client we have to wait for both ETags before we can compare with the client
        $combinedOutput = false;
        if ($classList != false && count($classList) > 0 && count($fileList) > 0) {
            $combinedOutput = true;
        }


        if ($classList != false && count($classList) > 0) {

            // were setup enough to make a stubETag if the input it wants is a class list
            if ($this->cacheOptions['httpCacheStub'] && 
                $this->cacheOptions['StubCacheExpects'] == 'classes') 
            {
                $stubETag = $this->_callCacheRule('Stub',$classList);
            }

            // if were not in combined output compare etags, if method returns true were done
            if (!$combinedOutput && isset($stubETag)) {
                if ($this->_compareEtags($stubETag)) {
                    ob_end_clean();
                    return;
                }
            }

            // output the stubs for all the classes in our list
            foreach($classList as $class) {
                    echo $this->ajax->generateClassStub($class);
            }

            // if were cacheing and the rule expects content make a tag and check it, if the check is true were done
            if ($this->cacheOptions['httpCacheStub'] && 
                $this->cacheOptions['StubCacheExpects'] == 'content') 
            {
                $stubETag = $this->_callCacheRule('Stub',ob_get_contents());
            }

            // if were not in combined output compare etags, if method returns true were done
            if (!$combinedOutput && isset($stubETag)) {
                if ($this->_compareEtags($stubETag)) {
                    ob_end_clean();
                    return;
                }
            }
        }

        if (count($fileList) > 0) {
            // if were caching and need a file list build our jsETag
            if ($this->cacheOptions['httpCacheClient'] && 
                $this->cacheOptions['ClientCacheExpects'] === 'files') 
            {
                $jsETag = $this->_callCacheRule('Client',$fileList);

            }

            // if were not in combined output compare etags, if method returns true were done
            if (!$combinedOutput && isset($jsETag)) {
                if ($this->_compareEtags($jsETag)) {
                    ob_end_clean();
                    return;
                }
            }

            // output the needed client js files
            foreach($fileList as $file) {
                $this->_readFile($file);
            }

            // if were caching and need content build the etag
            if ($this->cacheOptions['httpCacheClient'] && 
                $this->cacheOptions['ClientCacheExpects'] === 'content') 
            {
                $jsETag = $this->_callCacheRule('Client',ob_get_contents());
            }

            // if were not in combined output compare etags, if method returns true were done
            if (!$combinedOutput && isset($jsETag)) {
                if ($this->_compareEtags($jsETag)) {
                    ob_end_clean();
                    return;
                }
            }
            // were in combined output, merge the 2 ETags and compare
            else if (isset($jsETag) && isset($stubETag)) {
                if ($this->_compareEtags(md5($stubETag.$jsETag))) {
                    ob_end_clean();
                    return;
                }
            }
        }


        // were outputting content, add our length header and send the output
        $length = ob_get_length();
        $output = ob_get_contents();
        ob_end_clean();

        if ($this->ajax->packJavaScript) {
            $output = $this->ajax->packJavaScript($output);
            $length = strlen($output);
        }

        if ($this->compression['enabled'] && $this->compression['type'] == 'gzip' && strpos($_SERVER["HTTP_ACCEPT_ENCODING"], "gzip") !== false) {
            $output = gzencode($output,9);
            $length = strlen($output);
            $headers['Content-Encoding'] = 'gzip';
        }

        if ($length > 0 && $this->ajax->_sendContentLength()) { 
            $headers['Content-Length'] = $length;
        }
        $headers['Content-Type'] = 'text/javascript; charset=utf-8';
        $this->ajax->_sendHeaders($headers);
        echo($output);
    }

    /**
     * Run readfile on input with basic error checking
     *
     * @param   string  $file   file to read
     * @access  private
     * @todo    is addslashes enough encoding for js?
     */
    function _readFile($file) 
    {
        if (file_exists($file)) {
            readfile($file);
        } else {
            $file = addslashes($file);
            echo "alert('Unable to find javascript file: $file');";
        }
    }

    /**
     * Get the location of the client js
     * To override the default pear datadir location set $this->clientJsLocation
     *
     * @return  string
     */
    function clientJsLocation() 
    {
        if (!$this->clientJsLocation) {
            $path = realpath(dirname(__FILE__).DIRECTORY_SEPARATOR.'js').DIRECTORY_SEPARATOR;
            return $path;
        } else {
            return $this->clientJsLocation;
        }
    }

    /**
     * Set the location of the client js
     *
     * @access  public
     * @param   string  $location   Location
     * @return  void
     */
    function setClientJsLocation($location) 
    {
        $this->clientJsLocation = $location;
    }

    /**
     * Set the path to a Javascript libraries
     *
     * @access  public
     * @param   string  $library    Library name
     * @param   string  $path       Path
     * @return  void
     */
    function setJavascriptLibraryPath($library, $path) 
    {
        $this->javascriptLibraryPaths[$library] = $path;
    }

    /**
     * Set the path to more than one Javascript libraries at once
     *
     * @access  public
     * @param   array   $paths  Paths
     * @return  void
     */
    function setJavascriptLibraryPaths($paths) 
    {
        if (is_array($paths)) {
            $this->javascriptLibraryPaths = array_merge($this->javascriptLibraryPaths, $paths);
        }
    }

    /**
     * Load options from _GET
     *
     * @access private
     */
    function _loadOptions() 
    {
        $this->options = array('client'=>array(),'stub'=>array());
        if (isset($_GET['client'])) {
            $clients = explode(',',$this->ajax->_getVar('client'));
            $client = array();
            foreach($clients as $val) {
                $cleanVal = $this->_cleanIdentifier($val);
                if (!empty($cleanVal)) {
                    $client[] = strtolower($cleanVal);
                }
            }

            if (count($client) > 0) {
                $this->options['client'] = $client;
            }
        }
        if (isset($_GET['stub'])) {
            $stubs = explode(',',$this->ajax->_getVar('stub'));
            $stub = array();
            foreach($stubs as $val) {
                $cleanVal = $this->_cleanIdentifier($val);
                if (!empty($cleanVal)) {
                    $stub[] = strtolower($cleanVal);
                }
            }

            if (count($stub) > 0) {
                $this->options['stub'] = $stub;
            }
        }
    }

    /**
     * Clean an identifier like a class name making it safe to use
     *
     * @param   string  $input
     * @return  string
     * @access  private
     */
    function _cleanIdentifier($input) {
            return trim(preg_replace('/[^A-Za-z_0-9]/','',$input));
    }

    /**
     * Run every init method on the class
     *
     * @access private
     */
    function _initAll() 
    {
        if ($this->initMethods) {
            foreach($this->_initLookup as $class => $method) {
                $this->_init($class);
            }
        }
    }

    /**
     * Init one class
     *
     * @param   string  $className
     * @access private
     */
    function _init($className) 
    {
        $className = strtolower($className);
        if ($this->initMethods) {
            if (isset($this->_initLookup[$className])) {
                $method =& $this->_initLookup[$className];
                if (is_array($method)) {
                    call_user_func($method);
                }
                else {
                    $this->$method();
                }
            } else {
                trigger_error("Could find an init method for class: " . $className);
            }
        }
    }

    /**
     * Generate a hash from a list of files
     *
     * @param   array   $files  file list
     * @return  string  a hash that can be used as an etag
     * @access  private
     */
    function _cacheRuleFile($files) {
        $signature = "";
        foreach($files as $file) {
            if (file_exists($file)) {
                $signature .= $file.filemtime($file);
            }
        }
        return md5($signature);
    }

    /**
     * Generate a hash from the api of registered classes
     *
     * @param   array   $classes class list
     * @return  string  a hash that can be used as an etag
     * @access  private
     */
    function _cacheRuleApi($classes) {
        $signature = "";
        foreach($classes as $class) {
            if (isset($this->ajax->_exportedInstances[$class])) {
                $signature .= $class.implode(',',$this->ajax->_exportedInstances[$class]['exportedMethods']);
            }
        }
        return md5($signature);
    }

    /**
     * Generate a hash from the raw content
     *
     * @param   array   $content
     * @return  string  a hash that can be used as an etag
     * @access  private
     */
    function _cacheRuleContent($content) {
        return md5($content);
    }

    /**
     * Send cache control headers
     * @access  private
     */
    function _sendCacheHeaders($etag,$notModified) {
        header('Cache-Control: must-revalidate');
        header('ETag: '.$etag);
        if ($notModified) {
            header('HTTP/1.0 304 Not Modified',false,304);
        }
    }

    /**
     * Compare eTags
     *
     * @param   string  $serverETag server eTag
     * @return  boolean
     * @access  private
     */
    function _compareEtags($serverETag) {
        if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
    		if (strcmp($this->ajax->_getServer('HTTP_IF_NONE_MATCH'),$serverETag) == 0) {
                $this->_sendCacheHeaders($serverETag,true);
                return true;
            }
    	}
        $this->_sendCacheHeaders($serverETag,false);
        return false;
    }

    /**
     * Call a cache rule and return its retusn
     *
     * @param   string  $rule Stub|Client
     * @param   mixed   $payload
     * @return  boolean
     * @access  private
     * @todo    decide if error checking is needed
     */
    function _callCacheRule($rule,$payload) {
        $method = '_cacheRule'.$this->cacheOptions[$rule.'CacheRule'];
        return call_user_func(array(&$this,$method),$payload);
    }
}
/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
?>
