appserver-io/webserver

View on GitHub
src/AppserverIo/WebServer/Modules/Rewrite/Entities/Rule.php

Summary

Maintainability
C
1 day
Test Coverage
<?php

/**
 * \AppserverIo\WebServer\Modules\Rewrite\Entities\Rule
 *
 * 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    Bernhard Wick <bw@appserver.io>
 * @copyright 2017 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\Modules\Rewrite\Entities;

use AppserverIo\Psr\HttpMessage\Protocol;
use AppserverIo\Http\HttpResponseStates;
use AppserverIo\Psr\HttpMessage\ResponseInterface;
use AppserverIo\WebServer\Modules\Rewrite\Dictionaries\RuleFlags;
use AppserverIo\Server\Dictionaries\ServerVars;
use AppserverIo\Server\Interfaces\RequestContextInterface;

/**
 * AppserverIo\WebServer\Modules\Rewrite\Entities\Rule
 *
 * This class provides an object based representation of a rewrite rule, including logic for testing, applying
 * and handeling conditions.
 *
 * @author    Bernhard Wick <bw@appserver.io>
 * @copyright 2017 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 Rule
{

    /**
     * The allowed values the $type member my assume
     *
     * @var array $allowedTypes
     */
    protected $allowedTypes = array();

    /**
     * The type of rule we have.
     * This might be "relative", "absolute" or "url"
     *
     * @var string $type
     */
    protected $type;

    /**
     * The condition string
     *
     * @var string $conditionString
     */
    protected $conditionString;

    /**
     * The sorted conditions we have to check
     *
     * @var array $sortedConditions
     */
    protected $sortedConditions = array();

    /**
     * Will hold the backreferences of the condition(s) which matched
     *
     * @var array $matchingBackreferences
     */
    protected $matchingBackreferences = array();

    /**
     * Will hold the backreferences of the applied targets
     *
     * @var array $targetBackreferences
     */
    protected $targetBackreferences = array();

    /**
     * The target to rewrite the REDIRECT_URL to
     *
     * @var string $target
     */
    protected $target;

    /**
     * The flag we have to take into consideration when working with the rule
     *
     * @var string $flagString
     */
    protected $flagString;

    /**
     * All flags sorted and brought in relation with their potential parameters
     *
     * @var array $sortedFlags
     */
    protected $sortedFlags;

    /**
     * The default operand we will check all conditions against if none was given explicitly
     *
     * @const string DEFAULT_OPERAND
     */
    const DEFAULT_OPERAND = '@$X_REQUEST_URI';

    /**
     * This constant by which conditions are separated and marked as or-combined
     *
     * @const string CONDITION_OR_DELIMITER
     */
    const CONDITION_OR_DELIMITER = '{OR}';

    /**
     * This constant by which conditions are separated and marked as and-combined (the default)
     *
     * @const string CONDITION_AND_DELIMITER
     */
    const CONDITION_AND_DELIMITER = '{AND}';

    /**
     * This constant is used to separate flags from each other
     *
     * @const string FLAG_DELIMITER
     */
    const FLAG_DELIMITER = ',';

    /**
     * This constant is used to separate flags from their potential parameters
     *
     * @const string FLAG_PARAMETER_ASSIGNER
     */
    const FLAG_PARAMETER_ASSIGNER = '=';

    /**
     * Default constructor
     *
     * @param string       $conditionString Condition string e.g. "^_Resources/.*" or "-f{OR}-d{OR}-d@$REQUEST_FILENAME"
     * @param string|array $target          The target to rewrite to, might be null if we should do nothing
     * @param string       $flagString      A flag string which might be added to to the rule e.g. "L" or "C,R"
     */
    public function __construct($conditionString, $target, $flagString)
    {
        // Set the raw string properties and append our default operand to the condition string
        $this->conditionString = $conditionString;
        $conditionString .= $this->getDefaultOperand();
        $this->target = $target;
        $this->flagString = $flagString;

        // Get the sorted flags, should be easy to break up
        $this->sortedFlags = $this->sortFlags($this->flagString);

        // Set our default values here
        $this->allowedTypes = array(
            'relative',
            'absolute',
            'url'
        );
        $this->matchingBackreferences = array();

        // filter the condition string using our regex, but first of all we will append the default operand
        $conditionPieces = array();
        preg_match_all('`(.*?)@(\$[0-9a-zA-Z_]+)`', $conditionString, $conditionPieces);
        // The first index is always useless, unset it to avoid confusion
        unset($conditionPieces[0]);

        // Conditions are kind of sorted now, we can split them up into condition actions and their operands
        $conditionActions = $conditionPieces[1];
        $conditionOperands = $conditionPieces[2];

        // Iterate over the condition piece arrays, trim them and build our array of sorted condition objects
        for ($i = 0; $i < count($conditionActions); $i ++) {
            // Trim whatever we got here as the string might be a bit dirty
            $actionString = trim($conditionActions[$i], self::CONDITION_OR_DELIMITER . '|' . self::CONDITION_AND_DELIMITER);

            // Collect all and-combined pieces of the conditionstring
            $andActionStringPieces = explode(self::CONDITION_AND_DELIMITER, $actionString);

            // Iterate through them and build up conditions or or-combined condition groups
            foreach ($andActionStringPieces as $andActionStringPiece) {
                // Everything is and-combined (plain array) unless combined otherwise (with a "{OR}" symbol)
                // If we find an or-combination we will make a deeper array within our sorted condition array
                if (strpos($andActionStringPiece, self::CONDITION_OR_DELIMITER) !== false) {
                    // Collect all or-combined conditions into a separate array
                    $actionStringPieces = explode(self::CONDITION_OR_DELIMITER, $andActionStringPiece);

                    // Iterate over the pieces we found and create a condition for each of them
                    $entry = array();
                    foreach ($actionStringPieces as $actionStringPiece) {
                        // Get a new condition instance
                        $entry[] = new Condition($conditionOperands[$i], $actionStringPiece, array_keys($this->sortedFlags));
                    }
                } else {
                    // Get a new condition instance
                    $entry = new Condition($conditionOperands[$i], $andActionStringPiece, array_keys($this->sortedFlags));
                }

                $this->sortedConditions[] = $entry;
            }
        }
    }

    /**
     * Sort the flag string as we will have a bad time parsing the string over and over again
     *
     * @param string $flagString The unsorted string of flags
     *
     * @return array
     */
    protected function sortFlags($flagString)
    {
        // if the flag string is empty we can return an empty array
        if (empty($flagString)) {
            return array();
        }

        $flags = array();
        foreach (explode(self::FLAG_DELIMITER, $flagString) as $flag) {
            $flagPieces = explode(self::FLAG_PARAMETER_ASSIGNER, $flag);

            // Set the pieces (if any)
            if (array_key_exists(1, $flagPieces)) {
                $flags[$flagPieces[0]] = $flagPieces[1];
            } else {
                $flags[$flagPieces[0]] = null;
            }
        }

        return $flags;
    }

    /**
     * Will return the default operand of this action
     *
     * @return string
     */
    protected function getDefaultOperand()
    {
        return self::DEFAULT_OPERAND;
    }

    /**
     * Will resolve the directive's parts by substituting placeholders with the corresponding backreferences
     *
     * @param array $backreferences The backreferences used for resolving placeholders
     *
     * @return void
     */
    public function resolve(array $backreferences)
    {
        // We have to resolve backreferences in three steps.
        // First of all we have to resolve the backreferences based on the server vars
        $this->resolveConditions($backreferences);

        // Second we have to produce the regex based backreferences from the now semi-resolved conditions
        $conditionBackreferences = array_merge($this->getBackreferences(), $backreferences);

        // Last but not least we have to resolve the conditions against the regex backreferences
        $this->resolveConditions($conditionBackreferences);
    }

    /**
     * Will resolve the directive's parts by substituting placeholders with the corresponding backreferences
     *
     * @param array $backreferences The backreferences used for resolving placeholders
     *
     * @return void
     */
    protected function resolveConditions(array $backreferences)
    {
        // Iterate over all conditions and resolve them too
        foreach ($this->sortedConditions as $key => $sortedCondition) {
            // If we got an array we have to iterate over it separately, but be aware they are or-combined
            if (is_array($sortedCondition)) {
                // These are or-combined conditions but we have to resolve them too
                foreach ($sortedCondition as $orKey => $orCombinedCondition) {
                    // Resolve the condition
                    $orCombinedCondition->resolve($backreferences);
                    $this->sortedConditions[$key][$orKey] = $orCombinedCondition;
                }
            } else {
                // Resolve the condition
                $sortedCondition->resolve($backreferences);
                $this->sortedConditions[$key] = $sortedCondition;
            }
        }
    }

    /**
     * Will return true if the rule applies, false if not
     *
     * @return bool
     */
    public function matches()
    {
        // We will iterate over all conditions (and the or-combined condition groups) and if there is a non-matching
        // condition or condition group we will fail
        foreach ($this->sortedConditions as $sortedCondition) {
            // If we got an array we have to iterate over it separately, but be aware they are or-combined
            if (is_array($sortedCondition)) {
                // These are or-combined conditions, so break if we match one
                $orGroupMatched = false;
                foreach ($sortedCondition as $orCombinedCondition) {
                    if ($orCombinedCondition->matches()) {
                        $orGroupMatched = true;
                        $this->matchingBackreferences = array_merge($this->matchingBackreferences, $orCombinedCondition->getBackreferences());
                        break;
                    }
                }

                // Did one condition within this group match?
                if ($orGroupMatched === false) {
                    return false;
                }
            } elseif (! $sortedCondition->matches()) {
                // The single conditions have to match as they are and-combined
                return false;
            } else {
                $this->matchingBackreferences = array_merge($this->matchingBackreferences, $sortedCondition->getBackreferences());
            }
        }

        // We are still here, this sounds good
        return true;
    }

    /**
     * Initiates the module
     *
     * @param \AppserverIo\Server\Interfaces\RequestContextInterface $requestContext       The request's context
     * @param \AppserverIo\Psr\HttpMessage\ResponseInterface         $response             The response instance
     * @param array                                                  $serverBackreferences Server backreferences
     *
     * @throws \InvalidArgumentException
     *
     * @return boolean
     */
    public function apply(RequestContextInterface $requestContext, ResponseInterface $response, array $serverBackreferences)
    {
        // First of all we have to resolve the target string with the backreferences of the matching condition
        // Separate the keys from the values so we can use them in str_replace
        // And also mix in the server's backreferences for good measure
        $this->matchingBackreferences = array_merge($this->matchingBackreferences, $serverBackreferences);
        $backreferenceHolders = array_keys($this->matchingBackreferences);
        $backreferenceValues = array_values($this->matchingBackreferences);

        // If we got a target map (flag "M") we have to resolve the target string we have to use first
        // The following checks will be treated as an additional condition
        if (array_key_exists(RuleFlags::MAP, $this->sortedFlags) && ! empty($this->sortedFlags[RuleFlags::MAP])) {
            // Get our map key for better readability
            $mapKey = str_replace(array_keys($this->matchingBackreferences), $this->matchingBackreferences, $this->sortedFlags[RuleFlags::MAP]);

            // Still here? That sounds good. Get the needed target string now
            if (isset($this->target[$mapKey])) {
                $this->target = $this->target[$mapKey];
            } else {
                // Empty string, we will do nothing with this rule
                $this->target = '';

                // Also clear any L-flag we might find, we could not find what we are looking for so we should not end
                if (array_key_exists(RuleFlags::LAST, $this->sortedFlags)) {
                    unset($this->sortedFlags[RuleFlags::LAST]);
                }
            }
        }

        // Back to our rule...
        // If the target string is empty we do not have to do anything
        if (! empty($this->target)) {
            // Just make sure that you check for the existence of the query string first, as it might not be set
            $queryFreeRequestUri = $requestContext->getServerVar(ServerVars::X_REQUEST_URI);
            if ($requestContext->hasServerVar(ServerVars::QUERY_STRING)) {
                $queryFreeRequestUri = str_replace('?' . $requestContext->getServerVar(ServerVars::QUERY_STRING), '', $queryFreeRequestUri);

                // Set the "redirect" query string as a backup as we might change the original
                $requestContext->setServerVar('REDIRECT_QUERY_STRING', $requestContext->getServerVar(ServerVars::QUERY_STRING));
                $this->targetBackreferences['$REDIRECT_QUERY_STRING'] = $requestContext->getServerVar(ServerVars::QUERY_STRING);
            }
            $requestContext->setServerVar('REDIRECT_URL', $queryFreeRequestUri);
            $this->targetBackreferences['$REDIRECT_URL'] = $queryFreeRequestUri;

            // Substitute the backreferences in our operation
            $this->target = str_replace($backreferenceHolders, $backreferenceValues, $this->target);

            // We have to find out what type of rule we got here
            if (is_readable($this->target)) {
                // We have an absolute file path!
                $this->type = 'absolute';

                // Set the REQUEST_FILENAME path
                $requestContext->setServerVar(ServerVars::REQUEST_FILENAME, $this->target);
                $this->targetBackreferences['$' .  ServerVars::REQUEST_FILENAME] = $this->target;
            } elseif (filter_var($this->target, FILTER_VALIDATE_URL) !== false) {
                // We have a complete URL!
                $this->type = 'url';
            } else {
                // Last but not least we might have gotten a relative path (most likely)
                // Build up the REQUEST_FILENAME from DOCUMENT_ROOT and X_REQUEST_URI (without the query string)
                $this->type = 'relative';

                // Setting the X_REQUEST_URI for internal communication
                // Requested uri always has to begin with a slash
                $this->target = '/' . ltrim($this->target, '/');
                $requestContext->setServerVar(ServerVars::X_REQUEST_URI, $this->target);
                $this->targetBackreferences['$' .  ServerVars::X_REQUEST_URI] = $this->target;

                // Only change the query string if we have one in our target string
                if (strpos($this->target, '?') !== false) {
                    $queryString = substr(strstr($this->target, '?'), 1);
                    $requestContext->setServerVar(ServerVars::QUERY_STRING, $queryString);
                    $this->targetBackreferences['$' .  ServerVars::QUERY_STRING] = $queryString;
                }
            }

            // do we have to make a redirect?
            // if so we have to have to set the status code accordingly and dispatch the response
            if (array_key_exists(RuleFlags::REDIRECT, $this->sortedFlags)) {
                $this->prepareRedirect($requestContext, $response);
            }

            // Lets tell them that we successfully made a redirect
            $requestContext->setServerVar(ServerVars::REDIRECT_STATUS, '200');
            $this->targetBackreferences['$' .  ServerVars::REDIRECT_STATUS] = '200';
        }

        // If we got the "LAST"-flag we have to end here, so return false
        if (array_key_exists(RuleFlags::LAST, $this->sortedFlags)) {
            return false;
        }

        // Still here? That sounds good
        return true;
    }

    /**
     * Will prepare a response for a redirect.
     * This includes setting the new target, the appropriate status code and dispatching it to break the
     * module chain
     *
     * @param \AppserverIo\Server\Interfaces\RequestContextInterface $requestContext The request's context
     * @param \AppserverIo\Psr\HttpMessage\ResponseInterface         $response       The response instance to be prepared
     *
     * @return void
     */
    protected function prepareRedirect($requestContext, ResponseInterface $response)
    {
        // if we got a specific status code we have to filter it and apply it if possible
        $statusCode = 301;
        $proposedStatusCode = $this->sortedFlags[RuleFlags::REDIRECT];
        if (is_numeric($proposedStatusCode) && $proposedStatusCode >= 300 && $proposedStatusCode < 400) {
            $statusCode = $proposedStatusCode;
        }

        // there might be work to be done depending on whether or not we got a complete URL
        if ($this->type === 'relative') {
            $newTarget = $requestContext->getServerVar(ServerVars::REQUEST_SCHEME);
            $newTarget .= '://';
            $newTarget .= $requestContext->getServerVar(ServerVars::HTTP_HOST);
            $this->target = $newTarget . $this->getTarget();
        }

        // set enhance uri to response
        $response->addHeader(Protocol::HEADER_LOCATION, $this->target);
        // send redirect status
        $response->setStatusCode($statusCode);
        // set response state to be dispatched after this without calling other modules process
        $response->setState(HttpResponseStates::DISPATCH);
    }

    /**
     * Will collect all backreferences based on regex typed conditions
     *
     * @return array
     */
    public function getBackreferences()
    {
        // Iterate over all conditions and collect their backreferences
        $backreferences = array();
        foreach ($this->sortedConditions as $key => $sortedCondition) {
            // If we got an array we have to iterate over it separately, but be aware they are or-combined
            if (is_array($sortedCondition)) {
                // These are or-combined conditions but we have to resolve them too
                foreach ($sortedCondition as $orCombinedCondition) {
                    // Get the backreferences of this condition
                    $backreferences = array_merge($backreferences, $orCombinedCondition->getBackreferences());
                }
            } else {
                // Get the backreferences of this condition
                $backreferences = array_merge($backreferences, $sortedCondition->getBackreferences());
            }
        }

        return $backreferences;
    }

    /**
     * Will collect all backreferences based on this rule instance being applied to current server vars
     *
     * @return array
     */
    public function getTargetBackreferences()
    {
        return $this->targetBackreferences;
    }

    /**
     * Getter function for the condition string
     *
     * @return string
     */
    public function getConditionString()
    {
        return $this->conditionString;
    }

    /**
     * Getter function for the flag string
     *
     * @return string
     */
    public function getFlagString()
    {
        return $this->flagString;
    }

    /**
     * Getter function for the target string
     *
     * @return string
     */
    public function getTarget()
    {
        return $this->target;
    }
}