appserver-io/webserver

View on GitHub
src/AppserverIo/WebServer/ConnectionHandlers/HttpConnectionHandler.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

/**
 * \AppserverIo\WebServer\ConnectionHandlers\HttpConnectionHandler
 *
 * NOTICE OF LICENSE
 *
 * This source file is subject to the Open Software License (OSL 3.0)
 * that is available through the world-wide-web at this URL:
 * http://opensource.org/licenses/osl-3.0.php
 *
 * PHP version 5
 *
 * @author    Johann Zelger <jz@appserver.io>
 * @copyright 2015 TechDivision GmbH <info@appserver.io>
 * @license   http://opensource.org/licenses/osl-3.0.php Open Software License (OSL 3.0)
 * @link      https://github.com/appserver-io/webserver
 * @link      http://www.appserver.io
 */

namespace AppserverIo\WebServer\ConnectionHandlers;

use AppserverIo\Server\Dictionaries\EnvVars;
use AppserverIo\Server\Dictionaries\ModuleHooks;
use AppserverIo\Server\Dictionaries\ServerVars;
use AppserverIo\Server\Interfaces\ConnectionHandlerInterface;
use AppserverIo\Server\Interfaces\ServerContextInterface;
use AppserverIo\Server\Interfaces\RequestContextInterface;
use AppserverIo\Server\Interfaces\WorkerInterface;
use AppserverIo\Psr\Socket\SocketInterface;
use AppserverIo\Psr\Socket\SocketReadException;
use AppserverIo\Psr\Socket\SocketReadTimeoutException;
use AppserverIo\Psr\Socket\SocketServerException;
use AppserverIo\Psr\HttpMessage\Protocol;
use AppserverIo\Http\HttpRequest;
use AppserverIo\Http\HttpResponse;
use AppserverIo\Http\HttpPart;
use AppserverIo\Http\HttpProtocol;
use AppserverIo\Http\HttpQueryParser;
use AppserverIo\Http\HttpRequestParser;
use AppserverIo\Http\HttpResponseStates;

/**
 * Class HttpConnectionHandler
 *
 * @author Johann Zelger <jz@appserver.io>
 * @copyright 2015 TechDivision GmbH <info@appserver.io>
 * @license http://opensource.org/licenses/osl-3.0.php Open Software License (OSL 3.0)
 * @link https://github.com/appserver-io/webserver
 * @link http://www.appserver.io
 */
class HttpConnectionHandler implements ConnectionHandlerInterface
{

    /**
     * Defines the read length for http connections
     *
     * @var int
     */
    const HTTP_CONNECTION_READ_LENGTH = 2048;

    /**
     * Holds parser instance
     *
     * @var \AppserverIo\Http\HttpRequestParserInterface
     */
    protected $parser;

    /**
     * Holds the server context instance
     *
     * @var \AppserverIo\Server\Interfaces\ServerContextInterface
     */
    protected $serverContext;

    /**
     * Holds the request's context instance
     *
     * @var \AppserverIo\Server\Interfaces\RequestContextInterface
     */
    protected $requestContext;

    /**
     * Holds an array of modules to use for connection handler
     *
     * @var array
     */
    protected $modules;

    /**
     * Holds errors page template
     *
     * @var string
     */
    protected $errorsPageTemplate;

    /**
     * Holds the connection instance
     *
     * @var \AppserverIo\Psr\Socket\SocketInterface
     */
    protected $connection;

    /**
     * Holds the worker instance
     *
     * @var \AppserverIo\Server\Interfaces\WorkerInterface
     */
    protected $worker;

    /**
     * Flag if a shutdown function was registered or not
     *
     * @var boolean
     */
    protected $hasRegisteredShutdown = false;

    /**
     * Inits the connection handler by given context and params
     *
     * @param \AppserverIo\Server\Interfaces\ServerContextInterface $serverContext The server's context
     * @param array                                                 $params        The params for connection handler
     *
     * @return void
     */
    public function init(ServerContextInterface $serverContext, array $params = null)
    {
        // set server context
        $this->serverContext = $serverContext;

        // set params
        $this->errorsPageTemplate = $params["errorsPageTemplate"];

        // init http request object
        $httpRequest = new HttpRequest();

        // init http response object
        $httpResponse = new HttpResponse();
        // set default response headers
        $httpResponse->setDefaultHeaders(array(
            Protocol::HEADER_SERVER                 => $this->getServerConfig()->getSoftware(),
            Protocol::HEADER_CONNECTION             => Protocol::HEADER_CONNECTION_VALUE_CLOSE,
            Protocol::HEADER_X_FRAME_OPTIONS        => Protocol::HEADER_X_FRAME_OPTIONS_VALUE_DENY,
            Protocol::HEADER_X_XSS_PROTECTION       => Protocol::HEADER_X_XSS_PROTECTION_VALUE_ON,
            Protocol::HEADER_X_CONTENT_TYPE_OPTIONS => Protocol::HEADER_X_CONTENT_TYPE_OPTIONS_VALUE_NOSNIFF
        ));

        // setup http parser
        $this->parser = new HttpRequestParser($httpRequest, $httpResponse);
        $this->parser->injectQueryParser(new HttpQueryParser());
        $this->parser->injectPart(new HttpPart());

        // setup request context

        // get request context type
        $requestContextType = $this->getServerConfig()->getRequestContextType();

        /**
         * @var \AppserverIo\Server\Interfaces\RequestContextInterface $requestContext
         */
        // instantiate and init request context
        $this->requestContext = new $requestContextType();
        $this->requestContext->init($this->getServerConfig());
    }

    /**
     * Injects all needed modules for connection handler to process
     *
     * @param array $modules An array of Modules
     *
     * @return void
     */
    public function injectModules($modules)
    {
        $this->modules = $modules;
    }

    /**
     * Returns all needed modules as array for connection handler to process
     *
     * @return array An array of Modules
     */
    public function getModules()
    {
        return $this->modules;
    }

    /**
     * Returns a specific module instance by given name
     *
     * @param string $name The modules name to return an instance for
     *
     * @return \AppserverIo\WebServer\Interfaces\HttpModuleInterface|null
     */
    public function getModule($name)
    {
        if (isset($this->modules[$name])) {
            return $this->modules[$name];
        }
    }

    /**
     * Returns the server context instance
     *
     * @return \AppserverIo\Server\Interfaces\ServerContextInterface
     */
    public function getServerContext()
    {
        return $this->serverContext;
    }

    /**
     * Returns the request's context instance
     *
     * @return \AppserverIo\Server\Interfaces\RequestContextInterface
     */
    public function getRequestContext()
    {
        return $this->requestContext;
    }

    /**
     * Returns the server's configuration
     *
     * @return \AppserverIo\Server\Interfaces\ServerConfigurationInterface
     */
    public function getServerConfig()
    {
        return $this->getServerContext()->getServerConfig();
    }

    /**
     * Returns the parser instance
     *
     * @return \AppserverIo\Http\HttpRequestParserInterface
     */
    public function getParser()
    {
        return $this->parser;
    }

    /**
     * Returns the connection used to handle with
     *
     * @return \AppserverIo\Psr\Socket\SocketInterface
     */
    protected function getConnection()
    {
        return $this->connection;
    }

    /**
     * Returns the worker instance which starte this worker thread
     *
     * @return \AppserverIo\Server\Interfaces\WorkerInterface
     */
    protected function getWorker()
    {
        return $this->worker;
    }

    /**
     * Returns the template for errors page to render
     *
     * @return string
     */
    public function getErrorsPageTemplate()
    {
        return $this->errorsPageTemplate;
    }

    /**
     * Handles the connection with the connected client in a proper way the given
     * protocol type and version expects for example.
     *
     * @param \AppserverIo\Psr\Socket\SocketInterface        $connection The connection to handle
     * @param \AppserverIo\Server\Interfaces\WorkerInterface $worker     The worker how started this handle
     *
     * @return bool Weather it was responsible to handle the firstLine or not.
     * @throws \Exception
     */
    public function handle(SocketInterface $connection, WorkerInterface $worker)
    {
        // register shutdown handler once to avoid strange memory consumption problems
        $this->registerShutdown();

        // add connection ref to self
        $this->connection = $connection;
        $this->worker = $worker;

        $serverConfig = $this->getServerConfig();

        // get instances for short calls
        $requestContext = $this->getRequestContext();

        $parser = $this->getParser();

        // Get our query parser
        $queryParser = $parser->getQueryParser();

        // Get the request and response
        $request = $parser->getRequest();
        $response = $parser->getResponse();

        // init keep alive settings
        $keepAliveTimeout = (int) $serverConfig->getKeepAliveTimeout();
        $keepAliveMax = (int) $serverConfig->getKeepAliveMax();

        // init keep alive connection flag
        $keepAliveConnection = false;

        // init the request parser
        $parser->init();

        do {
            // try to handle request if its a http request
            try {
                // reset connection info to server vars
                $requestContext->setServerVar(ServerVars::REMOTE_ADDR, $connection->getAddress());
                $requestContext->setServerVar(ServerVars::REMOTE_PORT, $connection->getPort());

                // start time measurement for keep-alive timeout
                $keepaliveStartTime = microtime(true);

                // time settings
                $requestContext->setServerVar(ServerVars::REQUEST_TIME, time());

                /**
                 * Todo: maybe later on there have to be other time vars too especially for rewrite module.
                 *
                 * REQUEST_TIME_FLOAT
                 * TIME_YEAR
                 * TIME_MON
                 * TIME_DAY
                 * TIME_HOUR
                 * TIME_MIN
                 * TIME_SEC
                 * TIME_WDAY
                 * TIME
                 */

                // process modules by hook REQUEST_PRE
                $this->processModules(ModuleHooks::REQUEST_PRE);

                // init keep alive connection flag
                $keepAliveConnection = false;

                // set first line from connection
                $line = $connection->readLine(self::HTTP_CONNECTION_READ_LENGTH, $keepAliveTimeout);

                /**
                 * In the interest of robustness, servers SHOULD ignore any empty
                 * line(s) received where a Request-Line is expected.
                 * In other words, if
                 * the server is reading the protocol stream at the beginning of a
                 * message and receives a CRLF first, it should ignore the CRLF.
                 *
                 * @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.1
                 */
                if (in_array($line, array("\r\n", "\n"))) {
                    // ignore the first CRLF and go on reading the expected start-line.
                    $line = $connection->readLine(self::HTTP_CONNECTION_READ_LENGTH);
                }

                // parse read line
                $parser->parseStartLine($line);

                /**
                 * Parse headers in a proper way
                 *
                 * @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2
                 */
                $messageHeaders = '';
                while (!in_array($line, array("\r\n", "\n"))) {
                    // read next line
                    $line = $connection->readLine();
                    // enhance headers
                    $messageHeaders .= $line;
                }

                // parse headers
                $parser->parseHeaders($messageHeaders);

                // process connection type keep-alive
                if (strcasecmp($request->getHeader(Protocol::HEADER_CONNECTION), Protocol::HEADER_CONNECTION_VALUE_KEEPALIVE) === 0) {
                    // calculate keep-alive idle time for comparison with keep-alive timeout
                    $keepAliveIdleTime = microtime(true) - $keepaliveStartTime;
                    // only if max connections or keep-alive timeout not reached yet
                    if (($keepAliveMax > 0) && ($keepAliveIdleTime < $keepAliveTimeout)) {
                        // enable keep alive connection
                        $keepAliveConnection = true;
                        // set keep-alive headers
                        $response->addHeader(Protocol::HEADER_CONNECTION, Protocol::HEADER_CONNECTION_VALUE_KEEPALIVE);
                        $response->addHeader(Protocol::HEADER_KEEP_ALIVE, "timeout: $keepAliveTimeout, max: $keepAliveMax");
                        // decrease keep-alive max
                        -- $keepAliveMax;
                    }
                }

                // check if message body will be transmitted
                if ($request->hasHeader(Protocol::HEADER_CONTENT_LENGTH)) {
                    // get content-length header
                    if (($contentLength = (int) $request->getHeader(Protocol::HEADER_CONTENT_LENGTH)) > 0) {
                        // check if given content length is not greater than post_max_size from php ini
                        if ($this->getPostMaxSize() < $contentLength) {
                            // throw 500 server error
                            throw new \Exception(sprintf("Post max size '%s' exceeded", $this->getPostMaxSize(false)), 500);
                        }
                        // copy connection stream to body stream by given content length
                        $request->copyBodyStream($connection->getConnectionResource(), $contentLength);
                        // get content out for oldschool query parsing todo: refactor query parsing
                        $content = $request->getBodyContent();
                        // check if request has to be parsed depending on Content-Type header
                        if ($queryParser->isParsingRelevant($request->getHeader(Protocol::HEADER_CONTENT_TYPE))) {
                            // initialize the array for the matches
                            $boundaryMatches = array();
                            // checks if request has multipart formdata or not
                            preg_match('/boundary=(.*)$/', $request->getHeader(Protocol::HEADER_CONTENT_TYPE), $boundaryMatches);
                            // check if boundaryMatches are found
                            // todo: refactor content string var to be able to use bodyStream
                            if (count($boundaryMatches) > 0) {
                                $parser->parseMultipartFormData($content);
                            } else {
                                $queryParser->parseStr($content);
                            }
                        }
                    }
                }

                // set parsed query and multipart form params to request
                $request->setParams($queryParser->getResult());

                // init connection & protocol server vars
                $this->initServerVars();

                // process modules by hook REQUEST_POST
                $this->processModules(ModuleHooks::REQUEST_POST);

                // if no module dispatched response throw internal server error 500
                if (! $response->hasState(HttpResponseStates::DISPATCH)) {
                    throw new \Exception('Response state is not dispatched', 500);
                }

            } catch (SocketReadTimeoutException $e) {
                // break the request processing due to client timeout
                break;
            } catch (SocketReadException $e) {
                // break the request processing due to peer reset
                break;
            } catch (SocketServerException $e) {
                // break the request processing
                break;
            } catch (\Exception $e) {
                // set status code given by exception
                // if 0 is comming set 500 by default
                $response->setStatusCode($e->getCode() ? $e->getCode() : 500);
                $this->renderErrorPage($e);
            }

            // process modules by hook RESPONSE_PRE
            $this->processModules(ModuleHooks::RESPONSE_PRE);

            // send response to connected client
            $this->prepareResponse();

            // send response to connected client
            $this->sendResponse();

            // process modules by hook RESPONSE_POST
            $this->processModules(ModuleHooks::RESPONSE_POST);

            // check if keep alive-loop is finished to close connection before log access and init vars
            // to avoid waiting on non keep alive requests for that
            if ($keepAliveConnection !== true) {
                $connection->close();
            }

            // log informations for access log etc...
            $this->logAccess();

            // init context vars afterwards to avoid performance issues
            $requestContext->initVars();

            // init the request parser for next request
            $parser->init();
        } while ($keepAliveConnection === true);

        // close connection if not closed yet
        $connection->close();
    }

    /**
     * Processes modules logic by given hook
     *
     * @param int $hook The hook identifier to process logic for
     *
     * @return void
     */
    protected function processModules($hook)
    {
        // get object refs to local vars
        $requestContext = $this->getRequestContext();
        $modules = $this->getModules();
        $request = $this->getParser()->getRequest();
        $response = $this->getParser()->getResponse();

        // interate all modules and call process by given hook
        foreach ($modules as $module) {
            /* @var $module \AppserverIo\WebServer\Interfaces\HttpModuleInterface */
            // process modules logic by hook
            $module->process($request, $response, $requestContext, $hook);
            // break chain if hook type is REQUEST_POST and response state is DISPATCH
            if ($hook === ModuleHooks::REQUEST_POST && $response->hasState(HttpResponseStates::DISPATCH)) {
                // break out
                break;
            }
        }
    }

    /**
     * Renders error page by given exception
     *
     * @param \Exception $exception The exception object
     *
     * @return void
     */
    public function renderErrorPage(\Exception $exception)
    {
        // get response ref to local var for template rendering
        $response = $this->getParser()->getResponse();
        // check if template is given and exists
        if (($errorsPageTemplatePath = $this->getRequestContext()->getServerVar(ServerVars::SERVER_ERRORS_PAGE_TEMPLATE_PATH)) && is_file($errorsPageTemplatePath)) {
            // render errors page
            ob_start();
            require $errorsPageTemplatePath;
            $errorsPage = ob_get_clean();
        } else {
            // build up error message manually without template
            $errorsPage = $response->getStatusCode() . ' ' . $response->getStatusReasonPhrase() . PHP_EOL . PHP_EOL . $exception->__toString() . PHP_EOL . PHP_EOL . strip_tags($this->getRequestContext()->getServerVar(ServerVars::SERVER_SIGNATURE));
        }
        // add content type to text/html
        $response->addHeader(HttpProtocol::HEADER_CONTENT_TYPE, HttpProtocol::HEADER_CONTENT_TYPE_VALUE_TEXT_HTML);
        // append errors page to response body
        $response->appendBodyStream($errorsPage);
    }

    /**
     * Prepares the response object to be ready for delivery
     *
     * @return void
     */
    public function prepareResponse()
    {
        // get local var refs
        $response = $this->getParser()->getResponse();

        // prepare headers in response object to be ready for delivery
        $response->prepareHeaders();
    }

    /**
     * Sends response to connected client
     *
     * @return void
     */
    public function sendResponse()
    {
        // get local var refs
        $response = $this->getParser()->getResponse();
        $inputStream = $response->getBodyStream();
        $connection = $this->getConnection();
        // try to rewind stream
        @rewind($inputStream);
        // write response status-line + headers
        $connection->write($response->getStatusLine() . $response->getHeaderString());
        // stream response to client connection
        while ($readContent = fread($inputStream, 4096)) {
            $connection->write($readContent);
        }
    }

    /**
     * Logs access information from request and response
     *
     * @return void
     */
    public function logAccess()
    {
        // get object refs to local var
        $request = $this->getParser()->getRequest();
        $response = $this->getParser()->getResponse();
        $requestContext = $this->getRequestContext();
        $serverContext = $this->getServerContext();
        $connection = $this->getConnection();
        $accessLogger = null;

        // lookup for dynamic logger configuration or take default access logger
        if ($requestContext->hasEnvVar(EnvVars::LOGGER_ACCESS)) {
            $accessLogger = $serverContext->getLogger($requestContext->getEnvVar(EnvVars::LOGGER_ACCESS));
        } else {
            $accessLogger = $serverContext->getLogger();
        }

        // log access information if AccessLogger exists
        if ($accessLogger) {
            // init datetime instance with current time and timezone
            $datetime = new \DateTime('now');
            // log access
            $accessLogger->info(
                sprintf(
                    /* This logs in apaches default combined format */
                    /* LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined */
                    '%s - - [%s] "%s %s %s" %s %s "%s" "%s"' . PHP_EOL,
                    $connection->getAddress(),
                    $datetime->format('d/M/Y:H:i:s O'),
                    $request->getMethod(),
                    $request->getUri(),
                    $request->getVersion(),
                    $response->getStatusCode(),
                    $response->hasHeader(Protocol::HEADER_CONTENT_LENGTH) ? $response->getHeader(Protocol::HEADER_CONTENT_LENGTH) : '-',
                    $request->hasHeader(Protocol::HEADER_REFERER) ? $request->getHeader(Protocol::HEADER_REFERER) : '-',
                    $request->hasHeader(Protocol::HEADER_USER_AGENT) ? $request->getHeader(Protocol::HEADER_USER_AGENT) : '-'
                )
            );
        }
    }

    /**
     * Inits the server vars by parsed request
     *
     * @return void
     */
    public function initServerVars()
    {

        // get request context to local var reference
        $requestContext = $this->getRequestContext();
        // get request to local var reference
        $request = $this->getParser()->getRequest();

        // set http protocol because this is the http connection class which implements http 1.1
        $requestContext->setServerVar(ServerVars::SERVER_PROTOCOL, Protocol::VERSION_1_1);

        // get http host to set server name var but trim the root domain
        $serverName = rtrim($request->getHeader(Protocol::HEADER_HOST), '.');
        if (strpos($serverName, ':') !== false) {
            $serverName = rtrim(strstr($serverName, ':', true), '.');
        }

        // set server name var
        $requestContext->setServerVar(ServerVars::SERVER_NAME, $serverName);

        // set http headers to server vars
        foreach ($request->getHeaders() as $headerName => $headerValue) {
            // set server vars by request
            $requestContext->setServerVar('HTTP_' . str_replace('-', '_', strtoupper($headerName)), $headerValue);
        }

        // set request method, query-string, uris and scheme
        $requestContext->setServerVar(ServerVars::REQUEST_METHOD, $request->getMethod());
        $requestContext->setServerVar(ServerVars::QUERY_STRING, $request->getQueryString());
        $requestContext->setServerVar(ServerVars::REQUEST_URI, $request->getUri());
        $requestContext->setServerVar(ServerVars::X_REQUEST_URI, $request->getUri());
        // this is the http connection handler, therefor we will rely on the https flag
        if ($requestContext->hasServerVar(ServerVars::HTTPS) && $requestContext->getServerVar(ServerVars::HTTPS) === ServerVars::VALUE_HTTPS_ON) {
            $requestContext->setServerVar(ServerVars::REQUEST_SCHEME, 'https');
        } else {
            $requestContext->setServerVar(ServerVars::REQUEST_SCHEME, 'http');
        }
    }

    /**
     * Registers the shutdown function in this context
     *
     * @return void
     */
    public function registerShutdown()
    {
        // register shutdown handler once to avoid strange memory consumption problems
        if ($this->hasRegisteredShutdown === false) {
            register_shutdown_function(array(
                &$this,
                "shutdown"
            ));
            $this->hasRegisteredShutdown = true;
        }
    }

    /**
     * Does shutdown logic for worker if something breaks in process
     *
     * @return void
     */
    public function shutdown()
    {
        // get refs to local vars
        $requestContext = $this->getRequestContext();
        $connection = $this->getConnection();
        $worker = $this->getWorker();
        $request = $this->getParser()->getRequest();
        $response = $this->getParser()->getResponse();
        $response->init();

        // check if connections is still alive
        if ($connection) {
            // call current fileahandler module's shutdown hook if exists
            if ($requestContext->hasServerVar(ServerVars::SERVER_HANDLER) &&
                $fileHandleModule = $this->getModule($requestContext->getServerVar(ServerVars::SERVER_HANDLER))
            ) {
                $fileHandleModule->process($request, $response, $requestContext, ModuleHooks::SHUTDOWN);
            }

            // check if filehandle module has not handled the shutdown and set the response state to dispatched
            // so do default shutdown / error handling for current worker process
            if (!$response->hasState(HttpResponseStates::DISPATCH)) {
                // set response code to 500 Internal Server Error
                $response->setStatusCode($response->getStatusCode());

                // add this header to prevent .php request to be cached
                $response->addHeader(Protocol::HEADER_EXPIRES, '19 Nov 1981 08:52:00 GMT');
                $response->addHeader(Protocol::HEADER_CACHE_CONTROL, 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0');
                $response->addHeader(Protocol::HEADER_PRAGMA, 'no-cache');

                // get last error array
                $lastError = error_get_last();

                // check if it was a fatal error
                if (!is_null($lastError) && $lastError['type'] === 1) {
                    // set response code to 500 Internal Server Error
                    $response->setStatusCode(500);
                    $errorMessage = 'PHP Fatal error: ' . $lastError['message'] . ' in ' . $lastError['file'] . ' on line ' . $lastError['line'];
                    $this->renderErrorPage(new \RuntimeException($errorMessage, 500));
                }
            }

            // send response before shutdown
            $this->sendResponse();

            // close client connection
            $connection->close();
        }

        // check if worker is given
        if ($worker) {
            // call shutdown process on worker to respawn
            $this->getWorker()->shutdown();
        }
    }

    /**
     * Returns max post size in bytes if flag given
     *
     * @param boolean $asBytes If the return value should be bytes or string formated unit as given in ini
     *
     * @return int|string
     */
    public function getPostMaxSize($asBytes = true)
    {
        $postMaxSizeIniValue = ini_get('post_max_size');
        if ($asBytes === true) {
            $ini_v = trim($postMaxSizeIniValue);
            $s = array('g'=> 1<<30, 'm' => 1<<20, 'k' => 1<<10);
            return intval($ini_v) * ($s[strtolower(substr($ini_v, -1))] ?: 1);
        }
        return $postMaxSizeIniValue;
    }
}