Proxy eKontaktu

Wprowadzenie

W ramach rozwoju systemów informatycznych na Politechnice Gdańskiej zachodzi potrzeba tworzenia aplikacji internetowych z własną logiką biznesową oraz bazami danych. Jednocześnie wymogi spójności wymagają, aby takie aplikacje współdzieliły szatę graficzną z innymi systemami informatycznymi eKontaktu oraz wyświetlały się jako strony tego systemu. Z tego powodu potrzebne jest wbudowanie do systemu eKontakt mechanizmu proxy umożliwiającego wyświetlenie zawartości pochodzących z zewnętrznych aplikacji jako stron danego portalu lub serwisu.

Zasada działania

W portalach opartych o system eKontakt będzie możliwość (dla administratorów) zarejestrowania zewnętrznych aplikacji, które dodadzą do portalu strony generowane zewnętrznie. Strony takie składane będą z z określonej ilości elementów (dopasowanej do wybranej szaty graficznej), których zawartość przekazywana będzie w formie obiektu JSON.

Rejestracja aplikacji polegać będzie na podaniu adresu URL aplikacji zewnętrznej oraz docelowego adresu URL generowanych przez nią stron w systemie eKontakt.

W momencie w którym klient połączy się z adresem skonfigurowanym jako obsługiwany przez aplikację zewnętrzną, jego zapytanie zostanie przekierowane do aplikacji, która przetworzy je i odpowie przesyłając obiekt JSON. System proxy eKontakt przygotuje i wyświetli stronę w oparciu o informację pochodzące z aplikacji. Klient będzie miał możliwość wchodzić w interakcję ze stroną, a zapytania HTTP (GET, POST, etc.) przekierowywane będą do aplikacji, wynik zwracany wyświetlany będzie na ekranie klienta jako strony portalu eKontakt.

Rys. 1. Schemat połączeń w przypadku gdy aplikacja zwraca obiekt JSON, który jest integrowany do strony, która będzie wyświetlana użytkownikowi

Rys. 2. Schemat połączeń w przypadku gdy aplikacja zwraca zasób inny niż text/html lub application/x-ekontakt-json.

API

Funkcjonalność proxy odpowiada za przekierowanie zapytania do zewnętrznej aplikacji. Przekierowuje ona całe zapytanie wraz z wszystkimi nagłówkami oraz dwoma dodatkowymi:

Uwierzytelnienie użytkownika przez aplikację może się dodatkowo odbyć przy pomocy mechanizmu CAS Proxy. W tym wypadku przekazywane byłoby pole 'X-Ekontakt-User-PGT' zawierające wartość kodu Proxy Granting Ticket (PGT) systemu CAS.

Opdowiedź serwera aplikacji

Odpowiedź zwracana przez aplikację będzie zasobem (grafika, JavaScript, etc.) lub obiektem JSON przekazywany jako typ MIME oznaczony jako:

Odpowiedź w formacie JSON będzie dekodowana przez eKontakt i w przypadku błędnego obiektu JSON zwrócona zostanie odpowiedź zawierająca nagłówek HTML 502: Bad Gateway. Przykładowa odpowiedź z serwera aplikacji ma postać:

{   
    "content": "treść HTML do umieszczenia a elemencie Content",
    "nav":  "elementy nawigacyjne",
    "specjal01": "pole o nazwie specjal01"
}

Po otrzymaniu takiej odpowiedzi moduł proxy wypełni wszystkie pola, strony zawartością, którą otrzymał jako odpowiedź. Pola, które nie zostały wypełnione mają pozostać puste. Tak przygotowana strona ma zostać wyświetlona użytkownikowi.

Demonstracyjna implementacja

<?php
/**
 * eKontakt Application Proxy Server Demo
 * 
 * This file contains a demonstrative version of the eKontakt Application Proxy server. 
 * The protocol of the server assumes that for all incoming requests:
 * 
 * 1) Requested Proxy URL will be mapped onto the appropriate Application Server URL
 * 2) The request will be forwarded to the Application Server with the addition of two header fields:
 *      *) X-Requested-With - always populated by the string 'eKontaktProxy'
 *      *) X-Ekontakt-User  - a JSON encoded object containing information about the user currently
 *                            logged into the eKontakt CMS
 * 3) The response of the Application Server will be intercepted and treated differently depending 
 *    on the headers contained in the response:
 *      *) HTTP redirects (HTTP 301,302,303 and 307) will cause a remapping of the location header
 *      *) HTTP client errors (HTTP 4xx) will display appropriate error pages
 *      *) HTTP server errors (HTTP 5xx) will display an error page with the status 502 Bad Gateway
 * 4) Successful responses (2xx and 304) will cause the body if the response to be treated 
 *      differently depending on the response MIME type
 *      *) text/html and application/x-ekontakt-json responses will be treated as JSON objects 
 *          containing all parts of an eKontakt page. The JSON notation will be parsed and displayed
 *          in an appropriate template
 *      *) the content of responses with other MIME types will be streamed to the client in chunks 
 *          (so as to avoid unnecessary resource consumption)
 *
 * @author Michał Karzyński
 * @copyright 2011, Centrum Usług Informatycznych, Politechnika Gdańska
 *
 */


/**
* The Request class handles the incoming Client (browser) request 
* and gives access to the parameters of the request.
*/
class Request
{
    // Headers of the request are stored in an associative array
    private $headers_hash;
    
    /**
     * Constructor of the Request object.
     * Pre-populates the associative array $headers_hash
     * @return  Request - object representing the incoming HTTP request
     */
    public function __construct()
    {
        $this->headers_hash = getallheaders();
    }
    
    /**
     * Returns the protocol used to make the request
     *
     * @return string HTTP or HTTPS
     */
    public function get_protocol()
    {
        $ssl = empty($_SERVER["HTTPS"]) ? ''
            : ($_SERVER["HTTPS"] == "on") ? "s"
            : "";
            
        $protocol = explode("/", $_SERVER["SERVER_PROTOCOL"], 2);
        $protocol = strtolower($protocol[0]) . $ssl;
        return $protocol;
    }
    
    /**
     * Returns the full URI of the requested resource
     *
     * @return string protocol://server/path?request_get_params
     */
    public function get_uri()
    {
        return $this->get_protocol()."://".$_SERVER['SERVER_NAME'].$_SERVER['REQUEST_URI'];
    }
    
    /**
     * Returns the full URI of the requested resource
     *
     * @return string protocol://server/path?request_get_params
     */
    public function get_method()
    {
        return $_SERVER['REQUEST_METHOD'];
    }
    
    /**
     * Allows a custom header to be added to the Request object
     *
     * @param   string    name of the header field
     * @param   string    value of the header field
     */
    public function add_header($field, $value)
    {
        $this->headers_hash[$field] = $value;
    }
    
    /**
     * Returns all headers of the Request as an associative array
     *
     * @return array headers (field => value)
     */
    public function get_headers()
    {
        return $this->headers_hash;
    }
    
    /**
     * Returns all headers of the Request formatted as text of the HTTP request
     *
     * @return string headers (field: value\r\n)
     */
    public function get_headers_text()
    {
        $headers_text = '';
        foreach ($this->get_headers() as $key => $value) {
            $headers_text .= "$key: $value\r\n";
        }
        return $headers_text;
    }
    
    /**
     * Returns the body of the request containing for instance POST data, files attached, etc.
     *
     * @return string body of request
     */
    public function get_body()
    {
        return file_get_contents("php://input");
    }
}

/**
* The Response object represents the answer generated by the Application Server
*/
class Response
{
    // Headers represented as an array (one array entry per header line)
    private $headers;
    // Content of the Response is not read into memory unless needed. 
    // The open socket of the connection is stored in this value.
    private $content_socket;
    
    /**
     * Constructor of the Response object.
     * 
     * @param   array    Headers represented as an array (one array entry per header line)
     * @param   resource Open HTTP socket, pointer set to beginning of the response body
     * @return  Response 
     */
    public function __construct($headers, $content_socket)
    {
        $this->headers = $headers;
        $this->content_socket = $content_socket;
    }
    
    /**
     * Returns the content of the response as a string.
     * 
     * @return  string contains the whole content of the response. CAUTION: may be memory-intensive.
     */
    private function get_content()
    {
        $content = '';
        while (!feof($this->content_socket)) {
            $content = fread($this->content_socket, 8192);
        }
        return $content;
    }
    
    /**
     * Stream contents of the response to php://output
     */
    private function stream_content()
    {
        while (!feof($this->content_socket)) {
            echo fread($this->content_socket, 8192);
        }
    }
    
    /**
     * Passes Application Server response to the Client
     * 
     * If response MIME type is application/x-ekontakt-json or text/html, generates an HTML page
     * based on a JSON object contained in the response content.
     * Other MIME types are streamed to php://output
     */
    public function render()
    {
        $content_type_parsable = false;
        foreach ($this->headers as $header) {
            @list($field, $value) = explode(":", $header, 2);
            if($field == 'Content-Type')
            {
                $content_type_parsable = (
                    (trim($value) == 'application/x-ekontakt-json') ||
                    (substr(trim($value), 0, 10) == 'text/html')
                );
            }
            header($header, false);
        }

        if($content_type_parsable)
        {
            $content_json = $this->get_content();
            try 
            {
                $content = json_decode($content_json);
            } 
            catch (Exception $e) 
            {
                throw new EkontaktProxyException("Could not parse the JSON object returned by the application server. Received:\n$content_json");
            }
            ?>
<!DOCTYPE html>

<html lang="en">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>eKontakt Proxy</title>
    <?php @print($content->head); ?>
</head>
<body>
    <?php @print($content->body); ?>
</body>
</html>
<?php            
        }
        else
        {
            $this->stream_content();
        }
    }
}

/**
* eKontakt Application Proxy Server
*/
class Proxy
{
    // In order to remap the URL of the incoming request to the application server URL, 
    // we are provided with two URLs. The first is the URL of the current proxy server
    private $APP_PROXY_URL  = 'http://proxy_server/';
    // The second is the destination application server URL
    private $APP_SERVER_URL = 'http://app_server/';
    
    /**
     * Remaps a given Proxy URL to the corresponding Application URL
     *
     * @param  string URL of a resource requested from the Proxy server
     * @return string URL of the corresponding resource on the Application server
     */
    function map_url($url)
    {
        $count = 1;
        return str_replace($this->APP_PROXY_URL, $this->APP_SERVER_URL, $url, $count);
    }
    
    /**
     * Given the incoming request, contact the Application server and return its response 
     * as a Response object.
     *
     * @param  Request  The incoming client request
     * @return Response The response of the Application server
     */
    function handle($request)
    {
        // Prepare request headders
        $request->add_header('Connection', 'Close');
        $request->add_header('X-Requested-With', 'eKontaktProxy');
        $request->add_header('X-Ekontakt-User', '{ user_json_object }');
        
        // Determine the destination host and resource path
        $application_url  = $this->map_url($request->get_uri());
        $application      = explode('/', $application_url, 4);
        $application_host = $application[2];
        $application_path = '/'.$application[3];
        $application_port = 80; //@todo: determine port depending on $request->get_protocol()
        
        // Open a socket connection to the Application server
        $socket = fsockopen($application_host, $application_port, $errno, $errst);
        if(!$socket) 
            throw new EkontaktProxyException("Could not connect to application server. ($errno, $errst)");
        
        // Send HTTP request, headers and data
        fwrite($socket, $request->get_method()." $application_path HTTP/1.1\r\n");
        fwrite($socket, $request->get_headers_text());
        fwrite($socket, "\r\n"); //Empty line to indicate end of headers
        fwrite($socket, $request->get_body());
        
        // Read the first line of server response
        $response_line   = trim(fgets($socket, 8192));
        $response_status = explode(' ', $response_line, 3);
        $response_status = (int)($response_status[1]);
        
        // React to server response codes
        switch ($response_status) {
            case 200: // Response status: HTTP/1.1 200 OK
            case 201: // Response status: HTTP/1.1 201 Created
            case 202: // Response status: HTTP/1.1 202 Accepted
            case 203: // Response status: HTTP/1.1 203 Non-Authoritative Information
            case 206: // Response status: HTTP/1.1 206 Partial Content
            case 304: // Response status: HTTP/1.1 304 Not Modified
                // 2xx Codes (and 304) are allowed to pass through the proxy uninterrupted
                break;
            
            case 301: // Response status: HTTP/1.1 301 Moved Permanently
            case 302: // Response status: HTTP/1.1 302 Found
            case 303: // Response status: HTTP/1.1 303 See Other
            case 307: // Response status: HTTP/1.1 307 Temporary Redirect
                // Redirect codes should be intercepted so that the Location header can be parsed
                throw new EkontaktProxyException("Application server provided a redirect code : $response_line.");
                // @todo: Handle Redirects
                break;
            
            case 400: // Response status: HTTP/1.1 400 Bad Request
            case 401: // Response status: HTTP/1.1 401 Unauthorized
            case 403: // Response status: HTTP/1.1 403 Forbidden
            case 404: // Response status: HTTP/1.1 404 Not Found
                // 4xx Codes should display an error page
                // @todo: Handle other 4xx codes
                break;
            
            default:
                // Other errors (e.g. 5xx) cause a 502 Bad Gateway error of the proxy server
                throw new EkontaktProxyException("Unexpected response from application server: $response_line.");
                break;
        }
        

        // Read HTTP response headers
        $response_headers = array($response_line);
        while (!feof($socket)) {
            $response_line = trim(fgets($socket, 8192));
            if (empty($response_line)) break;
            else $response_headers[]=$response_line;
        }
        
        // Create and return a response object containing headers and an open socket
        return new Response($response_headers, $socket);
    }
}

/**
* For demonstration purposes all Proxy errors throw this exception and halt script execution
* @todo: proper error handling
*/
class EkontaktProxyException extends Exception
{
    function __construct($error_message)
    {
        header('HTTP/1.0 502 Bad Gateway');
        print 'eKontakt Proxy error: ' . $error_message;
        die();
    }
}


$request  = new Request();
$proxy    = new Proxy();
$response = $proxy->handle($request);
$response->render();

?>

Zawartość pliku .htaccess potrzebnego do funkcjonownia powyższego demo:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /server/path/to/ekontakt/proxy_server/index.php [L]