<?php

namespace iThemesSecurity\Strauss\Patchstack;

use iThemesSecurity\Strauss\Patchstack\Response;
use iThemesSecurity\Strauss\Patchstack\Request;
use iThemesSecurity\Strauss\Patchstack\Extensions\ExtensionInterface;

class Processor
{
    /**
     * The firewall rules to process.
     *
     * @var array
     */
    private $firewallRules = [];

    /**
     * The whitelist rules to process.
     *
     * @var array
     */
    private $whitelistRules = [];

    /**
     * The options of the engine.
     *
     * @var array
     */
    private $options = [
        'autoblockAttempts' => 10,
        'autoblockMinutes' => 30,
        'autoblockTime' => 60,
        'whitelistKeysRules' => [],
        'mustUsePluginCall' => false
    ];

    /**
     * The extension that will process specific logic for the CMS.
     *
     * @var ExtensionInterface
     */
    private $extension;

    /**
     * The captured request that needs to be inspected.
     *
     * @var Request
     */
    private $request;

    /**
     * The response that will be sent, depending on the action executed by the processor.
     *
     * @var Response
     */
    private $response;

    /**
     * Creates a new processor instance.
     *
     * @param ExtensionInterface $extension
     * @param array $firewallRules
     * @param array $whitelistRules
     * @param array $options
     * @return void
     */
    public function __construct(
        ExtensionInterface $extension,
        $firewallRules = [],
        $whitelistRules = [],
        $options = []
    ) {
        $this->extension = $extension;
        $this->firewallRules = $firewallRules;
        $this->whitelistRules = $whitelistRules;
        $this->options = array_merge($this->options, $options);

        $this->request = new Request($this->options, $this->extension);
        $this->response = new Response($this->options);
    }

    /**
     * Magic getter for the options.
     *
     * @param string $name
     * @return mixed
     */
    public function __get($name)
    {
        return isset($this->options[$name]) ? $this->options[$name] : null;
    }

    /**
     * Launch the firewall. First we determine if the user is blocked and whitelisted, then go through
     * all of the firewall rules.
     *
     * Will return true if $mustExit is false and all of the rules were processed without a positive detection.
     *
     * @param boolean $mustExit
     * @return boolean
     */
    public function launch($mustExit = true)
    {
        // Determine if the user is temporarily blocked from the site before we do anything else.
        $isWhitelisted = $this->extension->canBypass($this->mustUsePluginCall);
        if (!$isWhitelisted && $this->extension->isBlocked($this->autoblockMinutes, $this->autoblockTime, $this->autoblockAttempts)) {
            $this->extension->forceExit(22);
        }

        // Determine if the firewall and whitelist rules were parsed properly.
        if (!is_array($this->firewallRules) || !is_array($this->whitelistRules)) {
            return true;
        }

        // Determine if we have any firewall and/or whitelist rules loaded.
        if (count($this->firewallRules) == 0 && count($this->whitelistRules) == 0) {
            return true;
        }

        // Merge the rules together. First iterate through the whitelist rules because
        // we want to whitelist the request if there's a whitelist rule match.
        $rules = array_merge($this->whitelistRules, $this->firewallRules);

        // Iterate through all the firewall rules.
        foreach ($rules as $rule) {
            // Should never happen.
            if (!isset($rule['rules']) || empty($rule['rules'])) {
                continue;
            }

            // If this rule should respect the whitelist, we check this before we continue.
            if (isset($rule['bypass_whitelist']) && ($rule['bypass_whitelist'] === 0 || $rule['bypass_whitelist'] === false) && $isWhitelisted) {
                continue;
            }

            // If the rule contains matching type we cannot call during mu-plugins, skip.
            $hasWpAction = $this->hasWpAction($rule['rules']);
            if (defined('PS_FW_MU_RAN') && !$hasWpAction || $this->mustUsePluginCall && $hasWpAction) {
                continue;
            }

            // Execute the firewall rule.
            $rule_hit = $this->executeFirewall($rule['rules']);

            // If the payload did not match the rule, continue on to the next rule.
            if (!$rule_hit) {
                continue;
            }

            // Capture the POST data for logging purposes.
            if ($rule['type'] != 'WHITELIST') {
                $postData = $this->request->getParameterValues('log');
            }

            // Determine what action to perform.
            if ($rule['type'] == 'BLOCK') {
                $this->extension->logRequest($rule['id'], $postData, 'BLOCK');

                // Do we have to exit the page or simply return false?
                if ($mustExit) {
                    $this->extension->forceExit($rule['id']);
                } else {
                    return false;
                }
            } elseif ($rule['type'] == 'LOG') {
                $this->extension->logRequest($rule['id'], $postData, 'LOG');
            } elseif ($rule['type'] == 'REDIRECT') {
                $this->extension->logRequest($rule['id'], $postData, 'REDIRECT');
                $this->response->redirect($rule['type_params'], $mustExit);
            } elseif ($rule['type'] == 'WHITELIST') {
                return $mustExit;
            }
        }

        return true;
    }

    /**
     * Execute the firewall rules.
     * 
     * @param array $rules
     * @return bool
     */
    public function executeFirewall($rules)
    {
        // Count number of inclusive rules, if any.
        $inclusiveCount = 0;
        if (count($rules) > 1) {
            $inclusiveCount = $this->getInclusiveCount($rules);
        }

        // Keep track of how many inclusive rule hits.
        $inclusiveHits = 0;

        // Loop through all of the conditions for this rule.
        foreach ($rules as $rule) {
            // Parameter must always be present.
            if (!isset($rule['parameter'])) {
                continue;
            }

            // Cast to an array so we can iterate through all parameters.
            if (!is_array($rule['parameter'])) {
                $parameters = [$rule['parameter']];
            } else {
                $parameters = $rule['parameter'];
            }

            // Iterate through all parameters.
            foreach ($parameters as $parameter) {
                // Extract the value of the paramater that we want.
                $values = $this->request->getParameterValues($parameter);
                if (is_null($values) && $parameter !== false && $parameter != 'rules') {
                    continue;
                }

                // For special parameter values we just set the array to a single null value.
                if ($parameter === false || $parameter == 'rules') {
                    $values = [null];
                }

                // For all field matches, we want to execute the rule against it.
                foreach ($values as $value) {
                    // Apply mutations, if any.
                    if (isset($rule['mutations']) && is_array($rule['mutations'])) {
                        $value = $this->request->applyMutation($rule['mutations'], $value);
                        if (is_null($value)) {
                            continue;
                        }
                    }

                    // Perform the matching.
                    if (isset($rule['match']) && is_array($rule['match']) || isset($rule['rules'])) {

                        // Do we have to process child-rules?
                        if (isset($rule['rules'])) {
                            $match = $this->executeFirewall($rule['rules']);
                        } else {
                            $match = $this->matchParameterValue($rule['match'], $value);
                        }

                        // Is the rule a match?
                        if ($match) {
                            // In case there are multiple rules, they may require chained AND conditions.
                            if ($inclusiveCount <= 1 || !isset($rule['inclusive']) || $rule['inclusive'] !== true) {
                                return true;
                            } else {
                                $inclusiveHits++;
                                break 2;
                            }
                        }
                    }
                }
            }
        }

        // In case we hit all of the AND conditions.
        if ($inclusiveCount > 1 && $inclusiveHits >= $inclusiveCount) {
            return true;
        }

        return false;
    }

    /**
     * Get the number of inclusive rules as part of the rule group.
     * 
     * @param array $rules
     * @return int
     */
    public function getInclusiveCount($rules)
    {
        if (count($rules) == 1) {
            return 1;
        }

        $count = 0;
        foreach ($rules as $rule) {
            if (isset($rule['inclusive']) && $rule['inclusive'] === true) {
                $count++;
            }
        }

        return $count;
    }

    /**
     * With the given parameter value, attempt to match it.
     * 
     * @param mixed $match
     * @param mixed $value
     * @return bool
     */
    public function matchParameterValue($match, $value)
    {
        // Take some of the parameters for easy access.
        $matchType = isset($match['type']) ? $match['type'] : null;
        $matchValue = isset($match['value']) ? $match['value'] : null;

        // Perform a match depending on the given match type.
        // If a scalar matches another scalar (loose).
        if ($matchType == 'equals' && is_scalar($value) && is_scalar($matchValue)) {
            return $matchValue == $value;
        }

        // If a scalar matches another scaler (strict).
        if ($matchType == 'equals_strict' && is_scalar($value) && is_scalar($matchValue)) {
            return $matchValue === $value;
        }

        // If a scalar is bigger than another scalar.
        if ($matchType == 'more_than' && is_scalar($value) && is_scalar($matchValue)) {
            return $value > $matchValue;
        }

        // If a scalar is less than another scalar.
        if ($matchType == 'less_than' && is_scalar($value) && is_scalar($matchValue)) {
            return $value < $matchValue;
        }

        // If the parameter is present at all.
        if ($matchType == 'isset') {
            return true;
        }

        // If a scalar is a ctype alnum with underscores, dashes and spaces.
        if ($matchType == 'ctype_special' && is_scalar($value) && $value != '') {
            $value = str_replace([' ', '_', '-', ','], '', $value);
            $isClean = (bool) (@preg_match('/^[\w$\x{0080}-\x{FFFF}]*$/u', $value) > 0);
            return $isClean === $matchValue;
        }

        // If a scaler is a ctype digit.
        if ($matchType == 'ctype_digit' && is_scalar($value) && $value != '') {
            return @ctype_digit($value) === $matchValue;
        }

        // If a scaler is a ctype alnum.
        if ($matchType == 'ctype_alnum' && is_scalar($value) && $value != '') {
            $isClean = (bool) (@preg_match('/^[\w$\x{0080}-\x{FFFF}]*$/u', $value) > 0);
            return $isClean === $matchValue;
        }

        // If a scalar is numeric.
        if ($matchType == 'is_numeric' && is_scalar($value) && $value != '') {
            return @is_numeric($value) === $matchValue;
        }

        // If a scalar contains a value.
        if (($matchType == 'contains' || $matchType == 'stripos') && is_scalar($value)) {
            return @stripos($value, $matchValue) !== false;
        }

        // If a scalar does not contain a value.
        if ($matchType == 'not_contains' && is_scalar($value)) {
            return @stripos($value, $matchValue) === false;
        }

        // If a scalar contains single or double quotes.
        if (($matchType == 'quotes' || $matchType == 'inline_js_xss') && is_scalar($value)) {
            return @stripos($value, '"') !== false || @stripos($value, "'") !== false;
        }

        // If a string matches a regular expression.
        if ($matchType == 'regex' && is_string($matchValue) && is_scalar($value)) {
            return @preg_match($matchValue, @urldecode($value)) === 1;
        }

        // If the user does not have a WP privilege.
        if ($matchType == 'current_user_cannot' && is_scalar($matchValue) && function_exists('current_user_can') && function_exists('wp_get_current_user') && !$this->mustUsePluginCall) {
            return @!current_user_can($matchValue);
        }

        // If a value is in an array.
        if ($matchType == 'in_array' && !is_array($value) && is_array($matchValue)) {
            return @in_array($value, $matchValue);
        }

        // If a value is not in an array.
        if ($matchType == 'not_in_array' && !is_array($value) && is_array($matchValue)) {
            return @!in_array($value, $matchValue);
        }

        // If an array of values is in another array of values.
        if ($matchType == 'array_in_array' && is_array($value) && is_array($matchValue)) {
            return @count(@array_intersect($value, $matchValue)) > 0;
        }

        // If a specific parameter key matches a sub-match condition.
        if ($matchType == 'array_key_value' && isset($match['key'], $match['match'])) {

            // To support arrays for the key matching type value.
            $keys = is_array($match['key']) ? $match['key'] : [$match['key']];

            // Iterate through all keys.
            foreach ($keys as $key) {
                $values = $this->request->getParameterValues($key, $value);
                if (!is_array($values)) {
                    continue;
                }
    
                foreach ($values as $val) {
                    if ($this->matchParameterValue($match['match'], $val)) {
                        return true;
                    }
                }
            }

            return false;
        }

        // If the user provided value does not match the current hostname.
        if ($matchType == 'hostname' && is_string($value)) {
            if (empty($value)) {
                return false;
            }

            // If there's no protocol we add it.
            if (substr($value, 0, 4) != 'http') {
                $value = 'https://' . $value;
            }

            // We only care about the hostname.
            $host = @parse_url($value, PHP_URL_HOST);
            if (!$host) {
                return true;
            }

            return $host !== $this->extension->getHostName();
        }

        // If any of the uploaded files in the parameter matches a sub-match condition.
        if ($matchType == 'file_contains' && isset($match['match'])) {
            // Extract all tmp_names.
            if (isset($value['tmp_name'])) {
                $files = $value['tmp_name'];
                if (!is_array($files)) {
                    $files = [$files];
                }
            } else {
                $files = array_column($value, 'tmp_name');
            }
            
            // No need to continue if there are no files.
            if (is_array($files) && count($files) === 0) {
                return false;
            }

            // Cast all tmp_names to a single-dimension array.
            $files = $this->request->getArrayValues($files, '', 'array');
            if (is_array($files) && count($files) === 0) {
                return false;
            }

            // Get the contents of the files.
            $contents = '';
            foreach ($files as $file) {
                $contents .= (string) @file_get_contents($file);
            }

            // Now attempt to match it.
            return $this->matchParameterValue($match['match'], $contents);
        }

        // If a scalar passes a run through wp_kses_post.
        if ($matchType == 'general_xss' && is_scalar($value) && function_exists('wp_kses_post')) {
            return $value != @\wp_kses_post($value);
        }

        // If a scalar passes a run through inline_js_xss.
        if ($matchType == 'inline_xss' && is_scalar($value)) {
            if (@stripos($value, '"') === false && @stripos($value, "'") === false) {
                return false;
            }
        
            if (@stripos($value, '>') !== false || @stripos($value, '=') !== false) {
                return true;
            }
        
            return false;
        }

        return false;
    }

    /**
     * Determine if the rules contain an action that should not be executed under the mu-plugins context.
     * 
     * @param array $rules
     * @return boolean
     */
    private function hasWpAction($rules)
    {
        $functions = ['current_user_cannot'];

        if (isset($rules['rules'])) {
            if ($this->hasWpAction($rules['rules'])) {
                return true;
            }
        }

        foreach ($rules as $rule) {
            if (!isset($rule['match'], $rule['match']['type'])) {
                continue;
            }

            if (in_array($rule['match']['type'], $functions)) {
                return true;
            }
        }

        return false;
    }
}
