<?php /*

 Composr
 Copyright (c) Christopher Graham, 2004-2024

 See docs/LICENSE.md for full licensing information.


 NOTE TO PROGRAMMERS:
   Do not edit this file. If you need to make changes, save your changed file to the appropriate *_custom folder
   **** If you ignore this advice, then your website upgrades (e.g. for bug fixes) will likely kill your changes ****

*/

/**
 * @license    http://opensource.org/licenses/cpal_1.0 Common Public Attribution License
 * @copyright  Christopher Graham
 * @package    commandr
 */

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__commandr()
{
    require_lang('commandr');
    require_code('users_active_actions');
    require_code('commandr_fs');
    require_code('xml');
}

/**
 * Commandr entry script.
 */
function commandr_script()
{
    if (!addon_installed('commandr')) {
        warn_exit(do_lang_tempcode('MISSING_ADDON', escape_html('commandr')));
    }

    if (is_cli()) {
        cms_disable_time_limit();
    }

    // Closed site
    if (!is_cli()) {
        $site_closed = get_option('site_closed');
        if (($site_closed != '0') && (!has_privilege(get_member(), 'access_closed_site')) && (!$GLOBALS['IS_ACTUALLY_ADMIN'])) {
            http_response_code(503);
            prepare_backend_response('text/plain');
            @exit(get_option('closed'));
        }

        prepare_backend_response();

        if ($GLOBALS['CURRENT_SHARE_USER'] !== null) {
            warn_exit(do_lang_tempcode('SHARED_INSTALL_PROHIBIT'));
        }

        // Session ID check, if saving
        if (!session_considered_confirmed()) {
            access_denied('SESSION', '', true);
        }

        if (!has_actual_page_access(get_member(), 'admin_commandr')) {
            require_lang('permissions');
            fatal_exit(do_lang_tempcode('ACCESS_DENIED__PAGE_ACCESS', escape_html($GLOBALS['FORUM_DRIVER']->get_username(get_member()))));
        }
    }

    require_code('input_filter_2');
    if (get_value('disable_modsecurity_workaround') !== '1') {
        modsecurity_workaround_enable();
    }

    // Executing a command from the command-line
    $command = post_param_string('command', is_cli() ? null : false);
    if ($command === null) {
        require_code('comcode_from_html');
        require_code('mail');

        $stdin = @fopen('php://stdin', 'rb');
        $stderr = @fopen('php://stderr', 'wb');
        $stdout = @fopen('php://stdout', 'wb');
        while (true) { // Goes on until ctrl+C
            fwrite($stdout, "\n> ");

            $command = fgets($stdin);
            if (trim($command) == 'exit') {
                break;
            }
            $temp = new Virtual_shell(trim($command));
            if (trim($temp->output[STREAM_STDHTML]) != '') {
                fwrite($stdout, trim(strip_comcode(semihtml_to_comcode(preg_replace('#<(\w+) [^<>]*>#', '<${1}>', $temp->output[STREAM_STDHTML])))));
            }
            if (trim($temp->output[STREAM_STDOUT]) != '') {
                fwrite($stdout, trim($temp->output[STREAM_STDOUT]));
            }
            if (trim($temp->output[STREAM_STDERR]) != '') {
                fwrite($stderr, trim($temp->output[STREAM_STDERR]));
            }
        }
        fclose($stdin);
        fclose($stderr);
        fclose($stdout);
    } else {
        require_code('failure');
        set_throw_errors();
        try {
            $temp = new Virtual_shell(trim($command));
            $temp->output_xml();
        } catch (Exception $e) {
            if (has_smart_quotes($command)) {
                $_stderr = do_lang('EVAL_ERROR') . do_lang('EVAL_ERROR_QUOTE_ISSUE');
            } else {
                $_stderr = do_lang('EVAL_ERROR');
            }

            @http_response_code(200);

            $output = '<' . '?xml version="1.0" encoding="' . escape_html(get_charset()) . '" ?' . '>
                <response>
                    <result>
                        <command>' . xmlentities(post_param_string('command', '')) . '</command>
                        <stdcommand></stdcommand>
                        <stdhtml><div xmlns="http://www.w3.org/1999/xhtml">' . ((get_param_integer('keep_fatalistic', 0) != 0) ? static_evaluate_tempcode(get_html_trace()) : '') . '</div></stdhtml>
                        <stdout>' . xmlentities($e->getMessage()) . '</stdout>
                        <stderr>' . xmlentities($_stderr) . '</stderr>
                    </result>
                </response>';

            if ($GLOBALS['XSS_DETECT']) {
                ocp_mark_as_escaped($output);
            }

            exit($output);
        }
    }

    cms_safe_exit_flow();
}

/**
 * Commandr.
 *
 * @package commandr
 */
class Virtual_shell
{
    public $current_input;
    public $parsed_input;
    public $parse_runtime;
    public $output;
    public $input_parameters;
    public $fs;

    /**
     * Constructor function. Starts command parsing on the supplied command.
     *
     * @param  string $inputted_command The inputted command, unparsed
     * @param  array $parameters An array of prior parameters
     */
    public function __construct(string $inputted_command, array $parameters = [])
    {
        if (!defined('MODE_NORMAL')) {
            define('MODE_NORMAL', 0); // Not in quotes
            define('MODE_QUOTES', 1); // In some quotes

            define('STREAM_IDENTIFIER', 0); // The stream identifier (optional): e.g. 3&2
            define('ASSIGNMENT', 1); // The stream assignment: e.g. >>
            define('REDIRECT_IDENTIFIER', 2); // The stream identifier: myfile

            define('REDIRECT_OVERWRITE', 0); // Overwrite redirect (>)
            define('REDIRECT_APPEND', 1); // Append redirect (>>)
            define('REDIRECT_INPUT', 2); // Input redirect (<)
            define('REDIRECT_PIPE', 3); // Pipe (|)

            define('STREAM_STDOUT', 2); // STDOUT (text-only) stream
            define('STREAM_STDHTML', 1); // STDHTML (XHTML) stream
            define('STREAM_STDCOMMAND', 0); // STDCOMMAND (JavaScript) stream
            define('STREAM_STDERR', 3); // STDERR (text-only error) stream

            define('SECTION_COMMAND', 0); // The command section: e.g. echo
            define('SECTION_OPTIONS', 1); // The options section: e.g. -r
            define('SECTION_PARAMETERS', 2); // The parameters section: e.g. "hello world!"
            define('SECTION_EXTRAS', 3); // The extras section (redirects): e.g. >>2

            define('COMMAND_NATIVE', 0); // Either a lone command or a script
            define('COMMAND_LONE', 1); // A lone command
            define('COMMAND_SCRIPT', 2); // A script
            define('COMMAND_PHP', 3); // A PHP command
            define('COMMAND_SQL', 4); // An SQL query
            define('COMMAND_SHELL', 5); // A shell command
        }

        $this->current_input = $inputted_command;
        $this->parsed_input = null;
        $this->parse_runtime = null;
        $this->output = [STREAM_STDCOMMAND => '', STREAM_STDHTML => '', STREAM_STDOUT => '', STREAM_STDERR => ''];
        $this->input_parameters = [];

        foreach ($parameters as $parameter_key => $parameter_value) {
            $this->input_parameters['{P' . $parameter_key . '}'] = $parameter_value;
        }

        $this->parse_input();
        // Remember not to return anything. Output should be collected using appropriate member functions
    }

    /**
     * Returns the parse tree for the command just parsed.
     *
     * @return ~array The parse tree (false: failure)
     */
    public function return_parse_tree()
    {
        if (count($this->parsed_input) >= 1) {
            return $this->parsed_input;
        }

        return false;
    }

    /**
     * Returns the output for the command just parsed.
     *
     * @return ~array The output (false: failure)
     */
    public function return_output()
    {
        if (count($this->output) >= 1) {
            return $this->output;
        }

        return false;
    }

    /**
     * Output an XML-RPC packet (hopefully) to the AJAX in the frontend.
     *
     * @return boolean Success?
     */
    public function output_xml() : bool
    {
        if (count($this->parsed_input) < 1) {
            return false;
        }

        http_response_code(200);

        if (is_object($this->output[STREAM_STDCOMMAND])) {
            $this->output[STREAM_STDCOMMAND] = $this->output[STREAM_STDCOMMAND]->evaluate();
        }
        if (is_object($this->output[STREAM_STDHTML])) {
            $this->output[STREAM_STDHTML] = $this->output[STREAM_STDHTML]->evaluate();
        }
        if (is_object($this->output[STREAM_STDOUT])) {
            $this->output[STREAM_STDOUT] = $this->output[STREAM_STDOUT]->evaluate();
        }
        if (is_object($this->output[STREAM_STDERR])) {
            $this->output[STREAM_STDERR] = $this->output[STREAM_STDERR]->evaluate();
        }

        $stdcommand = $this->output[STREAM_STDCOMMAND];

        if (is_array($stdcommand)) {
            $stdcommand = json_encode($stdcommand);
        }

        // Make the HTML not use non-XML entities
        $html_bak = $this->output[STREAM_STDHTML];
        $this->output[STREAM_STDHTML] = convert_bad_entities($this->output[STREAM_STDHTML], get_charset());

        $output = '<' . '?xml version="1.0" encoding="' . escape_html(get_charset()) . '" ?' . '>
    <!DOCTYPE xc:content [
    <!ENTITY euro "&#8364;">
    <!ENTITY ldquo "&#8220;">
    <!ENTITY rdquo "&#8221;">
    <!ENTITY lsquo "&#8216;">
    <!ENTITY rsquo "&#8217;">
    <!ENTITY dagger "&#8224;">
    <!ENTITY Dagger "&#8225;">
    <!ENTITY permil "&#8240;">
    <!ENTITY Scaron "&#352;">
    <!ENTITY scaron "&#353;">
    <!ENTITY Yuml "&#376;">
    <!ENTITY ndash "&#8211;">
    <!ENTITY mdash "&#8212;">
    <!ENTITY hellip "&#8230;">
    <!ENTITY copy "&#169;">
    <!ENTITY nbsp " ">
    <!ENTITY fnof "&#402;">
    <!ENTITY reg "&#174;">
    <!ENTITY trade "&#8482;">
    <!ENTITY raquo "&#187;">
    <!ENTITY frac14 "&#188;">
    <!ENTITY frac12 "&#189;">
    <!ENTITY frac34 "&#190;">
    <!ENTITY rarr "&#8594;">
    ]>
<response>
    <result>
        <command>' . xmlentities($this->current_input) . '</command>
        <stdcommand>' . xmlentities($stdcommand) . '</stdcommand>
        <stdhtml><div xmlns="http://www.w3.org/1999/xhtml">' . $this->output[STREAM_STDHTML] . '</div></stdhtml>
        <stdout>' . xmlentities($this->output[STREAM_STDOUT]) . '</stdout>
        <stderr>' . xmlentities($this->output[STREAM_STDERR]) . '</stderr>
    </result>
</response>';

        if ($GLOBALS['XSS_DETECT']) {
            if (ocp_is_escaped($html_bak)) {
                ocp_mark_as_escaped($output);
            }
        }

        echo $output;

        set_value('last_commandr_command', strval(time()));
        return true;
    }

    /**
     * Return the HTML rendering of the parsed command's output.
     *
     * @param  boolean $blank_ok Whether it is okay to have blank output
     * @return ~Tempcode The HTML (false: error)
     */
    public function output_html(bool $blank_ok = false)
    {
        if (count($this->parsed_input) < 1) {
            return false;
        }

        if (($this->output[STREAM_STDCOMMAND] == '') && ($this->output[STREAM_STDHTML] == '') && ($this->output[STREAM_STDOUT] == '')) {
            // Exit with an error
            if ($this->output[STREAM_STDERR] != '') {
                $this->output[STREAM_STDERR] = do_lang('PROBLEM_ACCESSING_RESPONSE') . "\n" . $this->output[STREAM_STDERR]; // Ugh... got to work with language strings designed for JavaScript
            } else {
                $this->output[STREAM_STDERR] = do_lang('TERMINAL_PROBLEM_ACCESSING_RESPONSE');
            }

            if ($blank_ok) {
                return new Tempcode();
            }
        } elseif ($this->output[STREAM_STDERR] != '') {
            $this->output[STREAM_STDERR] = do_lang('ERROR_NON_TERMINAL') . "\n" . $this->output[STREAM_STDERR]; // And again :-(
        }

        $stdcommand = $this->output[STREAM_STDCOMMAND];

        if (is_array($stdcommand)) {
            $stdcommand = json_encode($stdcommand);
        }

        $output = do_template('COMMANDR_COMMAND', [
            '_GUID' => 'a05ee6b75302f8ccd5ec9f3a24207521',
            'METHOD' => $this->current_input,
            'STDOUT' => $this->output[STREAM_STDOUT],
            'STDHTML' => $this->output[STREAM_STDHTML],
            'STDCOMMAND' => $stdcommand,
            'STDERR' => $this->output[STREAM_STDERR],
        ]);

        set_value('last_commandr_command', strval(time()));
        return $output;
    }

    /**
     * Extract the command name from the input.
     */
    protected function _extract_command()
    {
        if ($this->current_input == '') {
            $this->parsed_input[SECTION_COMMAND] = '';
            return;
        }

        // Extract the command (whatever type of command it may be)
        if ($this->current_input[$this->parse_runtime['parse_position']] == ':') {
            // It's PHP code
            if (strrpos($this->current_input, ';') === false) {
                $this->current_input .= ';';
            }
            $this->parsed_input[SECTION_COMMAND] = substr($this->current_input, $this->parse_runtime['parse_position'] + 1);

            $this->parse_runtime['parse_position'] = strlen($this->current_input);
            $this->parse_runtime['commandr_command'] = COMMAND_PHP;
        } elseif ($this->current_input[$this->parse_runtime['parse_position']] == '@') {
            // It's an SQL query
            $this->parsed_input[SECTION_COMMAND] = substr($this->current_input, $this->parse_runtime['parse_position'] + 1);

            $this->parse_runtime['parse_position'] = strlen($this->current_input);
            $this->parse_runtime['commandr_command'] = COMMAND_SQL;
        } elseif ($this->current_input[$this->parse_runtime['parse_position']] == '#') {
            // It's a shell command
            $this->parsed_input[SECTION_COMMAND] = substr($this->current_input, $this->parse_runtime['parse_position'] + 1);

            $this->parse_runtime['parse_position'] = strlen($this->current_input);
            $this->parse_runtime['commandr_command'] = COMMAND_SHELL;
        } else {
            // It's a normal command or a script...just fetch up to the next space: a command *should not* have spaces
            $next_space = strpos($this->current_input, ' ', $this->parse_runtime['parse_position']);

            if ($next_space !== false) {
                $this->parsed_input[SECTION_COMMAND] = substr($this->current_input, $this->parse_runtime['parse_position'], strpos($this->current_input, ' ', $this->parse_runtime['parse_position']) - $this->parse_runtime['parse_position']); // Fetch up to the next space
                $this->parse_runtime['parse_position'] += strlen($this->parsed_input[SECTION_COMMAND]) + 1; // We're assuming there's a space after the command
            } else {
                $this->parsed_input[SECTION_COMMAND] = $this->current_input; // Fetch the entire input
                $this->parse_runtime['parse_position'] += strlen($this->parsed_input[SECTION_COMMAND]); // This really is pointless
            }
            if (substr($this->parsed_input[SECTION_COMMAND], 0, 5) == '/bin/') {
                $this->parsed_input[SECTION_COMMAND] = substr($this->parsed_input[SECTION_COMMAND], 5);
            }
            if (strpos($this->parsed_input[SECTION_COMMAND], '/') !== false) {
                $this->parsed_input[SECTION_COMMAND] = '';
            }

            $this->parse_runtime['commandr_command'] = COMMAND_NATIVE;
        }

        // Parameter replacement
        $this->parsed_input[SECTION_COMMAND] = strtr($this->parsed_input[SECTION_COMMAND], $this->input_parameters);
    }

    /**
     * Extract options (switches) from the input.
     */
    protected function _extract_options()
    {
        // Add each option to the options array...an option *should* be prefixed with a dash ('-'), and can *optionally* have a value, shown through the use of equals ('=') - this can be a quoted value
        if (!defined('OUT_OPTION')) {
            define('OUT_OPTION', -1);
            define('IN_OPTION', 0);
            define('IN_OPTION_SYNTAX', 1);
            define('IN_OPTION_VALUE', 2);
        }

        $current_option = null;
        $option_mode = OUT_OPTION;

        while ($this->parse_runtime['parse_position'] < $this->parse_runtime['command_length']) {
            $next_char = $this->current_input[$this->parse_runtime['parse_position']];

            switch ($option_mode) {
                case OUT_OPTION:
                    // Options parsing hasn't started yet; the next character should be a dash ('-')
                    if ($next_char != '-') {
                        break 2; // This is *not* an option!
                    }
                    $option_mode = IN_OPTION;
                    $this->parse_runtime['parse_position']++;

                    break;
                case IN_OPTION:
                    // Get the name of the option, and add it to the options array
                    $space_pos = strpos($this->current_input, ' ', $this->parse_runtime['parse_position']);
                    $equals_pos = strpos($this->current_input, '=', $this->parse_runtime['parse_position']);

                    if (($space_pos !== false) && ($equals_pos !== false)) {
                        // Get the option name, using string functions (deciding where to cut the option name out, based upon whether the next equals is before the next space)
                        if ($equals_pos < $space_pos) {
                            $current_option = substr($this->current_input, $this->parse_runtime['parse_position'], $equals_pos - $this->parse_runtime['parse_position']);
                        } else {
                            $current_option = substr($this->current_input, $this->parse_runtime['parse_position'], $space_pos - $this->parse_runtime['parse_position']);
                            $current_option = strtr($current_option, $this->input_parameters); // Parameter replacement

                            $this->parsed_input[SECTION_OPTIONS][$current_option] = null;
                            $option_mode = OUT_OPTION;
                            $this->parse_runtime['parse_position'] += strlen($current_option) + 1;

                            break;
                        }
                    } elseif ($space_pos !== false) {
                        $current_option = substr($this->current_input, $this->parse_runtime['parse_position'], $space_pos - $this->parse_runtime['parse_position']); // Just take it up to the space
                        $current_option = strtr($current_option, $this->input_parameters); // Parameter replacement

                        $this->parsed_input[SECTION_OPTIONS][$current_option] = null;
                        $option_mode = OUT_OPTION;
                        $this->parse_runtime['parse_position'] += strlen($current_option) + 1; // Because there won't be an equals

                        break;
                    } elseif ($equals_pos !== false) {
                        $current_option = substr($this->current_input, $this->parse_runtime['parse_position'], $equals_pos - $this->parse_runtime['parse_position']); // Just take it up to the equals
                    } else {
                        $current_option = substr($this->current_input, $this->parse_runtime['parse_position']); // Just assume there's nothing else there, and grab the lot
                    }

                    // Parameter replacement
                    $current_option = strtr($current_option, $this->input_parameters);

                    $this->parsed_input[SECTION_OPTIONS][$current_option] = null;

                    $option_mode = IN_OPTION_SYNTAX;
                    $this->parse_runtime['parse_position'] += strlen($current_option);

                    break;
                case IN_OPTION_SYNTAX:
                    // Look for that elusive '='
                    if ($next_char != '=') {
                        break 2; // PANIC!!
                    }
                    $option_mode = IN_OPTION_VALUE;
                    $this->parse_runtime['parse_position']++;

                    break;
                case IN_OPTION_VALUE:
                    // Get the value, if applicable
                    if ($next_char == '"') {
                        // Quotes!
                        if ($this->parse_runtime['current_mode'] == MODE_NORMAL) {
                            // We are entering a quote system
                            $this->parse_runtime['current_mode'] = MODE_QUOTES;
                            $this->parse_runtime['parse_position']++;
                        } elseif (($this->parse_runtime['current_mode'] == MODE_QUOTES) && (!$this->parse_runtime['escape_used'])) {
                            // We are leaving a quote system, and the current (closing) quotes have *not* been escaped!
                            $this->parse_runtime['current_mode'] = MODE_NORMAL;
                            $this->parse_runtime['parse_position'] += 2; // Assuming there is only '" ' between here and the next option
                            $option_mode = OUT_OPTION;
                        } elseif (($this->parse_runtime['current_mode'] == MODE_QUOTES) && ($this->parse_runtime['escape_used'])) {
                            // We are adding an escaped quote to the current option value
                            $this->parsed_input[SECTION_OPTIONS][$current_option] .= $next_char;
                            $this->parse_runtime['parse_position']++;
                            $this->parse_runtime['escape_used'] = false;
                        } else {
                            break 2; // PANIC!!
                        }
                    } elseif ($next_char == '\\') {
                        // An escape character (currently only backslash ('\')) has been used
                        if ($this->parse_runtime['escape_used']) {
                            $this->parsed_input[SECTION_OPTIONS][$current_option] .= '\\'; // Add the backslash to the option value, as it has been escaped
                        }
                        $this->parse_runtime['escape_used'] = !$this->parse_runtime['escape_used']; // If the current backslash hasn't been backslashed, switch on the escape flag...in other words, invert the flag
                        $this->parse_runtime['parse_position']++;
                    } else {
                        if ($this->parse_runtime['current_mode'] == MODE_NORMAL) {
                            // Normal mode; business as usual (quotes have not been used, so we can just strip out the option value using string functions)
                            $space_pos = strpos($this->current_input, ' ', $this->parse_runtime['parse_position']);

                            if ($space_pos !== false) {
                                $this->parsed_input[SECTION_OPTIONS][$current_option] = substr($this->current_input, $this->parse_runtime['parse_position'], strpos($this->current_input, ' ', $this->parse_runtime['parse_position']) - $this->parse_runtime['parse_position']); // Get the value; up to the next space
                                $this->parse_runtime['parse_position'] += strlen($this->parsed_input[SECTION_OPTIONS][$current_option]) + 1; // Add the length of the option value, and one for the assumed space between here and the next option
                            } else {
                                $this->parsed_input[SECTION_OPTIONS][$current_option] = substr($this->current_input, $this->parse_runtime['parse_position']); // Just take until the end; there doesn't seem to be anything else
                                $this->parse_runtime['parse_position'] += strlen($this->parsed_input[SECTION_OPTIONS][$current_option]); // Pretty pointless
                            }

                            $option_mode = OUT_OPTION;
                        } elseif ($this->parse_runtime['current_mode'] == MODE_QUOTES) {
                            // We are adding the current letter to the quote system
                            $this->parsed_input[SECTION_OPTIONS][$current_option] .= $next_char;
                            $this->parse_runtime['parse_position']++;
                        } else {
                            break 2; // PANIC!!
                        }
                    }

                    // Parameter replacement
                    $this->parsed_input[SECTION_OPTIONS][$current_option] = strtr($this->parsed_input[SECTION_OPTIONS][$current_option], $this->input_parameters);

                    break;
                default:
                    break 2; // PANIC!!
            }
        }
    }

    /**
     * Extract parameters from the input.
     */
    protected function _extract_parameters()
    {
        // Add each parameter to the parameters array...a parameter *should not* have spaces unless it's a quoted value
        if (!defined('OUT_PARAMETER')) {
            define('OUT_PARAMETER', -1);
            define('IN_PARAMETER', 0);
        }

        $current_parameter = 0;
        $parameter_mode = OUT_PARAMETER;

        while ($this->parse_runtime['parse_position'] < $this->parse_runtime['command_length']) {
            $next_char = $this->current_input[$this->parse_runtime['parse_position']];

            switch ($parameter_mode) {
                case OUT_PARAMETER:
                    // Parameter parsing hasn't started yet; the next character should be a quote ('"'), or any other character apart from a space, really
                    $parameter_mode = IN_PARAMETER;

                    break;
                case IN_PARAMETER:
                    // Get the value, if applicable
                    if ($next_char == '"') {
                        // Quotes!
                        if ($this->parse_runtime['current_mode'] == MODE_NORMAL) {
                            // We are entering a quote system
                            $this->parse_runtime['current_mode'] = MODE_QUOTES;
                            $this->parsed_input[SECTION_PARAMETERS][$current_parameter] = '';
                            $this->parse_runtime['parse_position']++;
                        } elseif (($this->parse_runtime['current_mode'] == MODE_QUOTES) && (!$this->parse_runtime['escape_used'])) {
                            // We are leaving a quote system, and the current (closing) quotes have *not* been escaped!
                            $this->parse_runtime['current_mode'] = MODE_NORMAL;
                            $this->parse_runtime['parse_position'] += 2; // Assuming there is only '" ' between here and the next parameter
                            $parameter_mode = OUT_PARAMETER;
                            $current_parameter++;
                        } elseif (($this->parse_runtime['current_mode'] == MODE_QUOTES) && ($this->parse_runtime['escape_used'])) {
                            // We are adding an escaped quote to the current parameter value
                            $this->parsed_input[SECTION_PARAMETERS][$current_parameter] .= $next_char;
                            $this->parse_runtime['parse_position']++;
                            $this->parse_runtime['escape_used'] = false;
                        } else {
                            break 2; // PANIC!!
                        }
                    } elseif ($next_char == '\\') {
                        // An escape character (currently only backslash ('\')) has been used
                        if ($this->parse_runtime['escape_used']) {
                            $this->parsed_input[SECTION_PARAMETERS][$current_parameter] .= '\\'; // Add the backslash to the parameter value, as it has been escaped
                        }
                        $this->parse_runtime['escape_used'] = !$this->parse_runtime['escape_used']; // If the current backslash hasn't been backslashed, switch on the escape flag...in other words, invert the flag
                        $this->parse_runtime['parse_position']++;
                    } elseif ((($next_char == '>') || ($next_char == '<') || ($next_char == '1') || ($next_char == '2') || ($next_char == '3') || ($next_char == '4') || ($next_char == '|')) && ($this->current_input[$this->parse_runtime['parse_position'] - 1] == ' ') && (!$this->parse_runtime['escape_used']) && ($this->parse_runtime['current_mode'] != MODE_QUOTES)) {
                        // A character that is probably (hopefully) the precursor to some form of redirection, and is the first character in a block (i.e. it has a space in front of it)
                        if ($this->_check_is_redirection()) {
                            // This is indeed the precursor to some form of redirection
                            break 2;
                        } else {
                            // This is not part of redirection, assume it's just a normal character
                            if ($this->parse_runtime['current_mode'] == MODE_NORMAL) {
                                // Normal mode; business as usual (quotes have not been used, so we can just strip out the parameter value using string functions)
                                $space_pos = strpos($this->current_input, ' ', $this->parse_runtime['parse_position']);

                                if ($space_pos !== false) {
                                    $this->parsed_input[SECTION_PARAMETERS][$current_parameter] = substr($this->current_input, $this->parse_runtime['parse_position'], strpos($this->current_input, ' ', $this->parse_runtime['parse_position']) - $this->parse_runtime['parse_position']); // Get the value; up to the next space
                                    $this->parse_runtime['parse_position'] += strlen($this->parsed_input[SECTION_PARAMETERS][$current_parameter]) + 1; // Add the length of the parameter value, and one for the assumed space between here and the next parameter
                                } else {
                                    $this->parsed_input[SECTION_PARAMETERS][$current_parameter] = substr($this->current_input, $this->parse_runtime['parse_position']); // Just take until the end; there doesn't seem to be anything else
                                    $this->parse_runtime['parse_position'] += strlen($this->parsed_input[SECTION_PARAMETERS][$current_parameter]); // Pretty pointless
                                }

                                $parameter_mode = OUT_PARAMETER;
                                $current_parameter++;
                            } else {
                                break 2; // PANIC!!
                            }
                        }
                    } else {
                        if ($this->parse_runtime['current_mode'] == MODE_NORMAL) {
                            // Normal mode; business as usual (quotes have not been used, so we can just strip out the parameter value using string functions)
                            $space_pos = strpos($this->current_input, ' ', $this->parse_runtime['parse_position']);

                            if ($space_pos !== false) {
                                $this->parsed_input[SECTION_PARAMETERS][$current_parameter] = substr($this->current_input, $this->parse_runtime['parse_position'], strpos($this->current_input, ' ', $this->parse_runtime['parse_position']) - $this->parse_runtime['parse_position']); // Get the value; up to the next space
                                $this->parse_runtime['parse_position'] += strlen($this->parsed_input[SECTION_PARAMETERS][$current_parameter]) + 1; // Add the length of the parameter value, and one for the assumed space between here and the next parameter
                            } else {
                                $this->parsed_input[SECTION_PARAMETERS][$current_parameter] = substr($this->current_input, $this->parse_runtime['parse_position']); // Just take until the end; there doesn't seem to be anything else
                                $this->parse_runtime['parse_position'] += strlen($this->parsed_input[SECTION_PARAMETERS][$current_parameter]); // Pretty pointless
                            }

                            $parameter_mode = OUT_PARAMETER;
                            $current_parameter++;
                        } elseif ($this->parse_runtime['current_mode'] == MODE_QUOTES) {
                            // We are adding the current letter to the quote system
                            $this->parsed_input[SECTION_PARAMETERS][$current_parameter] .= $next_char;
                            $this->parse_runtime['parse_position']++;
                        } else {
                            break 2; // PANIC!!
                        }

                        $this->parse_runtime['escape_used'] = false;
                    }

                    // Parameter replacement
                    if (isset($this->parsed_input[SECTION_PARAMETERS][$current_parameter - 1])) {
                        $this->parsed_input[SECTION_PARAMETERS][$current_parameter - 1] = strtr($this->parsed_input[SECTION_PARAMETERS][$current_parameter - 1], $this->input_parameters);
                    }

                    break;
                default:
                    break 2; // PANIC!!
            }
        }
    }

    /**
     * Extract extra tokens from the input.
     */
    protected function _extract_extras()
    {
        // Add the extra instructions to the extras array
        if (!defined('OUT_EXTRA')) {
            define('OUT_EXTRA', -1);
            define('IN_EXTRA_SYNTAX_STREAM', 0);
            define('IN_EXTRA_SYNTAX_ASSIGNMENT', 1);
            define('IN_EXTRA_VALUE', 2);
        }

        $current_extra = 0;
        $extra_mode = OUT_EXTRA;

        while ($this->parse_runtime['parse_position'] < $this->parse_runtime['command_length']) {
            $next_char = $this->current_input[$this->parse_runtime['parse_position']];

            switch ($extra_mode) {
                case OUT_EXTRA:
                    // Extra parsing hasn't started yet; the next character should be a stream identifier, or assignment operator
                    if (($next_char != '>') && ($next_char != '<') && ($next_char != '1') && ($next_char != '2') && ($next_char != '3') && ($next_char != '4') && ($next_char != '|')) {
                        break 2;
                    }
                    $extra_mode = IN_EXTRA_SYNTAX_STREAM;

                    break;
                case IN_EXTRA_SYNTAX_STREAM:
                    // Fetch the stream identifiers, if they're present. If no stream identifier is present, default to stdout
                    if ($next_char == '1') {
                        $this->parsed_input[SECTION_EXTRAS][$current_extra][STREAM_IDENTIFIER][] = STREAM_STDOUT;
                    } elseif ($next_char == '2') {
                        $this->parsed_input[SECTION_EXTRAS][$current_extra][STREAM_IDENTIFIER][] = STREAM_STDHTML;
                    } elseif ($next_char == '3') {
                        $this->parsed_input[SECTION_EXTRAS][$current_extra][STREAM_IDENTIFIER][] = STREAM_STDCOMMAND;
                    } elseif ($next_char == '4') {
                        $this->parsed_input[SECTION_EXTRAS][$current_extra][STREAM_IDENTIFIER][] = STREAM_STDERR;
                    } elseif ($next_char != '&') {
                        // If we have anything other than an ampersand ('&'), continue to the ASSIGNMENT stage
                        $extra_mode = IN_EXTRA_SYNTAX_ASSIGNMENT;

                        break;
                    }

                    $this->parse_runtime['parse_position']++;

                    break;
                case IN_EXTRA_SYNTAX_ASSIGNMENT:
                    // Fetch the assignment operator
                    if ($next_char == '>') {
                        if ($this->current_input[$this->parse_runtime['parse_position'] + 1] == '>') {
                            // Append ('>>')
                            $this->parsed_input[SECTION_EXTRAS][$current_extra][ASSIGNMENT] = REDIRECT_APPEND;
                            $this->parse_runtime['parse_position']++;
                        } else {
                            // Overwrite ('>')
                            $this->parsed_input[SECTION_EXTRAS][$current_extra][ASSIGNMENT] = REDIRECT_OVERWRITE;
                        }
                    } elseif ($next_char == '<') {
                        // Input ('<')
                        $this->parsed_input[SECTION_EXTRAS][$current_extra][ASSIGNMENT] = REDIRECT_INPUT;
                    } elseif ($next_char == '|') {
                        // Pipe ('|')
                        $this->parsed_input[SECTION_EXTRAS][$current_extra][ASSIGNMENT] = REDIRECT_PIPE;
                    } else {
                        // If we have anything other than a greater-than ('>') or a less-than ('<'), continue to the REDIRECT_IDENTIFIER stage
                        $extra_mode = IN_EXTRA_VALUE;
                    }

                    $this->parse_runtime['parse_position']++;

                    break;
                case IN_EXTRA_VALUE:
                    // Get the value, if applicable
                    if ($next_char == '"') {
                        // Quotes!
                        if ($this->parse_runtime['current_mode'] == MODE_NORMAL) {
                            // We are entering a quote system
                            $this->parse_runtime['current_mode'] = MODE_QUOTES;
                            $this->parse_runtime['parse_position']++;
                        } elseif (($this->parse_runtime['current_mode'] == MODE_QUOTES) && (!$this->parse_runtime['escape_used'])) {
                            // We are leaving a quote system, and the current (closing) quotes have *not* been escaped!
                            $this->parse_runtime['current_mode'] = MODE_NORMAL;
                            $this->parse_runtime['parse_position'] += 2; // Assuming there is only '" ' between here and the next extra
                            $extra_mode = OUT_EXTRA;
                            $current_extra++;
                        } elseif (($this->parse_runtime['current_mode'] == MODE_QUOTES) && ($this->parse_runtime['escape_used'])) {
                            // We are adding an escaped quote to the current extra value
                            $this->parsed_input[SECTION_EXTRAS][$current_extra][REDIRECT_IDENTIFIER] .= $next_char;
                            $this->parse_runtime['parse_position']++;
                            $this->parse_runtime['escape_used'] = false;
                        } else {
                            break 2; // PANIC!!
                        }
                    } elseif ($next_char == '\\') {
                        // An escape character (currently only backslash ('\')) has been used
                        if ($this->parse_runtime['escape_used']) {
                            $this->parsed_input[SECTION_EXTRAS][$current_extra][REDIRECT_IDENTIFIER] .= '\\'; // Add the backslash to the extra value, as it has been escaped
                        }
                        $this->parse_runtime['escape_used'] = !$this->parse_runtime['escape_used']; // If the current backslash hasn't been backslashed, switch on the escape flag...in other words, invert the flag
                        $this->parse_runtime['parse_position']++;
                    } else {
                        if ($this->parse_runtime['current_mode'] == MODE_NORMAL) {
                            // Normal mode; business as usual (quotes have not been used, so we can just strip out the extra value using string functions)
                            $space_pos = strpos($this->current_input, ' ', $this->parse_runtime['parse_position']);

                            if ($space_pos !== false) {
                                $this->parsed_input[SECTION_EXTRAS][$current_extra][REDIRECT_IDENTIFIER] = substr($this->current_input, $this->parse_runtime['parse_position'], strpos($this->current_input, ' ', $this->parse_runtime['parse_position']) - $this->parse_runtime['parse_position']); // Get the value; up to the next space
                                $this->parse_runtime['parse_position'] += strlen($this->parsed_input[SECTION_EXTRAS][$current_extra][REDIRECT_IDENTIFIER]) + 1; // Add the length of the extra value, and one for the assumed space between here and the next extra
                            } else {
                                $this->parsed_input[SECTION_EXTRAS][$current_extra][REDIRECT_IDENTIFIER] = substr($this->current_input, $this->parse_runtime['parse_position']); // Just take until the end; there doesn't seem to be anything else
                                $this->parse_runtime['parse_position'] += strlen($this->parsed_input[SECTION_EXTRAS][$current_extra][REDIRECT_IDENTIFIER]); // Pretty pointless
                            }

                            $extra_mode = OUT_EXTRA;
                            $current_extra++;
                        } elseif ($this->parse_runtime['current_mode'] == MODE_QUOTES) {
                            // We are adding the current letter to the quote system
                            if (!isset($this->parsed_input[SECTION_EXTRAS][$current_extra][REDIRECT_IDENTIFIER])) {
                                $this->parsed_input[SECTION_EXTRAS][$current_extra][REDIRECT_IDENTIFIER] = '';
                            }
                            $this->parsed_input[SECTION_EXTRAS][$current_extra][REDIRECT_IDENTIFIER] .= $next_char;
                            $this->parse_runtime['parse_position']++;
                        } else {
                            break 2; // PANIC!!
                        }
                    }

                    break;
                default:
                    break 2; // PANIC!!
            }
        }
    }

    /**
     * Is the current block a valid redirection instruction?.
     *
     * @return boolean Redirection instruction?
     */
    protected function _check_is_redirection() : bool
    {
        // Take the current block (delimited by spaces (' ')), and check to see if it's a valid redirect instruction
        $start_pos = $this->parse_runtime['parse_position'];
        $end_pos = strpos($this->current_input, ' ', $this->parse_runtime['parse_position']);
        if ($end_pos === false) {
            return false;
        }

        $block = substr($this->current_input, $start_pos, $end_pos - $start_pos);

        if (($block == '<') || ($block == '>') || ($block == '>>') || ($block == '|')) {
            return true; // These are all simple redirect instructions that are easy to check
        }
        if (preg_match('#[1-4](&[1-4])*>(>)?#', $block) === 1) {
            return true;
        }
        return false;
    }

    /**
     * Parses input setup in constructor, and creates a parse tree.
     */
    public function parse_input()
    {
        /*We need to break the $this->parsed_input[SECTION_COMMAND] up into several distinct parts:
            - Command
            - Options
            - Parameters
            - Extras (e.g. I/O redirection / pipes)
        i.e.
            chmod -R 777      ./foobar >> output.txt
            COMMAND  OPTION   PARAMETER   PARAMETER   EXTRA
        */

        /*Output redirection:
            2&1> output.txt

            2&1         >     output.txt
            STREAM_IDENTIFIER ASSIGNMENT  REDIRECT_IDENTIFIER
        */

        $this->parse_runtime['command_length'] = strlen($this->current_input);
        $this->parse_runtime['parse_position'] = 0;
        $this->parse_runtime['current_mode'] = MODE_NORMAL;
        $this->parse_runtime['escape_used'] = false;
        $this->parse_runtime['commandr_command'] = COMMAND_NATIVE;

        $this->parsed_input[SECTION_COMMAND] = null;
        $this->parsed_input[SECTION_OPTIONS] = [];
        $this->parsed_input[SECTION_PARAMETERS] = [];
        $this->parsed_input[SECTION_EXTRAS] = [];

        $this->fs = object_factory('commandr_fs');

        // Start parsing with the command
        $this->_extract_command();
        if (trim($this->parsed_input[SECTION_COMMAND]) == '') {
            $this->output[STREAM_STDCOMMAND] = '';
            $this->output[STREAM_STDHTML] = '';
            $this->output[STREAM_STDOUT] = '';
            $this->output[STREAM_STDERR] = do_lang('NON_COMMAND');
            return;
        }

        if ($this->parse_runtime['commandr_command'] == COMMAND_NATIVE) {
            // Options (it's an Commandr command; not a PHP command)
            $this->_extract_options();
            $this->_extract_parameters();
        }
        // Extras
        $this->_extract_extras();

        // Pre-processing: follow any extras provided
        $i = 0;
        foreach ($this->parsed_input[SECTION_EXTRAS] as $extra) {
            if (($extra[ASSIGNMENT] == REDIRECT_PIPE) || ($extra[ASSIGNMENT] == REDIRECT_INPUT)) {
                $replacements = 0;

                if ($extra[ASSIGNMENT] == REDIRECT_PIPE) {
                    if (!isset($extra[STREAM_IDENTIFIER][0])) {
                        $extra[STREAM_IDENTIFIER] = [STREAM_STDOUT];
                    }
                    $virtual_shell = new Virtual_shell($extra[REDIRECT_IDENTIFIER]);
                    $virtual_output = $virtual_shell->return_output();
                    $pertinant_output = $virtual_output[$extra[STREAM_IDENTIFIER][count($extra[STREAM_IDENTIFIER]) - 1]];
                    if ($virtual_output[STREAM_STDERR] != '') {
                        $this->output[STREAM_STDERR] = $virtual_output[STREAM_STDERR];
                    }
                } else {
                    $pertinant_output = $this->fs->read_file($this->fs->_pwd_to_array($extra[REDIRECT_IDENTIFIER]));
                    if ($pertinant_output === false) {
                        $pertinant_output = '';
                    }
                }

                foreach ($this->parsed_input[SECTION_PARAMETERS] as $param_no => $parameter) {
                    // Do we have any "{".$i."}" parameters?
                    if ($parameter == '{' . strval($i) . '}') {
                        // NOTE: Might want to change this somehow so that other streams can be put through
                        $this->parsed_input[SECTION_PARAMETERS][$param_no] = $pertinant_output;
                        $replacements++;
                    }
                }

                if ($replacements == 0) {
                    // Just tag the input onto the end of the parameters array
                    // NOTE: See above
                    $this->parsed_input[SECTION_PARAMETERS][] = $pertinant_output;
                }

                $i++;
            }
        }

        // Handle the command: load up the relevant hook
        if ($this->parse_runtime['commandr_command'] == COMMAND_NATIVE) {
            // See if it's a lone command first
            $hooks = find_all_hooks('systems', 'commandr_commands');
            $hook_return = null;
            foreach (array_keys($hooks) as $hook) {
                if ($hook == $this->parsed_input[SECTION_COMMAND]) {
                    require_code('hooks/systems/commandr_commands/' . filter_naughty_harsh($hook));
                    $object = object_factory('Hook_commandr_command_' . filter_naughty_harsh($hook));
                    $hook_return = $object->run($this->parsed_input[SECTION_OPTIONS], $this->parsed_input[SECTION_PARAMETERS], $this->fs);
                    log_it('COMMANDR_COMMAND', $hook, implode(' ', $this->parsed_input[SECTION_PARAMETERS]));
                    $this->parse_runtime['commandr_command'] = COMMAND_LONE;
                    break;
                }
            }

            if ($hook_return !== null) {
                $this->output[STREAM_STDCOMMAND] = $hook_return[0];

                if (is_object($hook_return[1])) {
                    $this->output[STREAM_STDHTML] = $hook_return[1]->evaluate();
                } else {
                    $this->output[STREAM_STDHTML] = $hook_return[1];
                }
                $this->output[STREAM_STDOUT] = array_key_exists(2, $hook_return) ? $hook_return[2] : '';
                $this->output[STREAM_STDERR] = array_key_exists(2, $hook_return) ? $hook_return[3] : '';
            } else {
                // It's not a lone command; see if it's a script - check first in the main script dir
                if (file_exists(get_file_base() . '/data/modules/admin_commandr/' . filter_naughty_harsh($this->parsed_input[SECTION_COMMAND], true))) {
                    $script_file = get_file_base() . '/data/modules/admin_commandr/' . filter_naughty_harsh($this->parsed_input[SECTION_COMMAND], true); // It's in the main script dir
                } else {
                    $script_file = $this->_find_script_file($this->parsed_input[SECTION_COMMAND]); // Exhaustive search
                }

                if (($script_file !== false) && (is_readable($script_file))) {
                    // It *is* a script, so let's run it :)
                    $this->parse_runtime['commandr_command'] = COMMAND_SCRIPT;
                    $script_lines = cms_file_safe($script_file);

                    foreach ($script_lines as $script_line) {
                        if (strlen($script_line) > 0) {
                            $virtual_shell = new Virtual_shell($script_line, $this->parsed_input[SECTION_PARAMETERS]);
                            $script_output = $virtual_shell->return_output();
                            $this->output = $this->_combine_streams($this->output, $script_output);
                        }
                    }
                } else {
                    // Give up: it's not a command
                    $this->output[STREAM_STDCOMMAND] = '';
                    $this->output[STREAM_STDHTML] = '';
                    $this->output[STREAM_STDOUT] = '';
                    if ((substr(trim($this->parsed_input[SECTION_COMMAND]), -1) == ';') || (substr(trim($this->parsed_input[SECTION_COMMAND]), -1) == ')')) {
                        $this->output[STREAM_STDERR] = do_lang('NON_COMMAND_MISSING_COLON');
                    } else {
                        $this->output[STREAM_STDERR] = do_lang('NON_COMMAND');
                    }
                    return;
                }
            }
        } elseif ($this->parse_runtime['commandr_command'] == COMMAND_PHP) {
            // NOTE: This is done in a separate function to limit variable interaction (due to the PHP memory implemented)
            $this->_handle_php_command();
        } elseif ($this->parse_runtime['commandr_command'] == COMMAND_SQL) {
            // SQL command
            push_db_scope_check(false);
            $commandr_output = $GLOBALS['SITE_DB']->query($this->parsed_input[SECTION_COMMAND], null, 0, false, true);
            pop_db_scope_check();
            if ((is_array($commandr_output)) && (count($commandr_output) > 100)) {
                $commandr_output = $GLOBALS['SITE_DB']->query($this->parsed_input[SECTION_COMMAND], 100);
                $commandr_output[] = ['...' => '...'];
            }

            $this->output[STREAM_STDCOMMAND] = '';
            $this->output[STREAM_STDHTML] = '';
            $this->output[STREAM_STDOUT] = '';
            $this->output[STREAM_STDERR] = '';

            if ($commandr_output === null) {
                $this->output[STREAM_STDOUT] = do_lang('SUCCESS');
            } else {
                $this->output[STREAM_STDHTML] = $this->_array_to_html($commandr_output);
            }
        } elseif ($this->parse_runtime['commandr_command'] == COMMAND_SHELL) {
            // Shell command

            $this->output[STREAM_STDCOMMAND] = '';
            $this->output[STREAM_STDHTML] = '';
            if (php_function_allowed('shell_exec')) {
                $this->output[STREAM_STDOUT] = shell_exec($this->parsed_input[SECTION_COMMAND]);
            } else {
                $this->output[STREAM_STDOUT] = '';
            }
            $this->output[STREAM_STDERR] = '';
        }

        // Post-processing: follow any extras provided
        $old_output = $this->output;

        foreach ($this->parsed_input[SECTION_EXTRAS] as $extra) {
            if (!isset($extra[STREAM_IDENTIFIER][0])) {
                $extra[STREAM_IDENTIFIER] = [STREAM_STDOUT];
            }

            if (!isset($this->output[$extra[REDIRECT_IDENTIFIER]])) {
                // It's a file... so read in if it exists, else blank
                $this->output[$extra[REDIRECT_IDENTIFIER]] = $this->fs->read_file($this->fs->_pwd_to_array($extra[REDIRECT_IDENTIFIER]));
                if ($this->output[$extra[REDIRECT_IDENTIFIER]] === false) {
                    $this->output[$extra[REDIRECT_IDENTIFIER]] = '';
                }
            }

            $extra[ASSIGNMENT] = intval($extra[ASSIGNMENT]);

            foreach ($extra[STREAM_IDENTIFIER] as $stream_identifier) { // Multiple ones are always stored, but it only really makes sense for '>>'; otherwise we're just overwriting
                if ($extra[ASSIGNMENT] == REDIRECT_OVERWRITE) {
                    $this->output[$stream_identifier] = ''; // Because it's been taken away from here

                    $this->output[$extra[REDIRECT_IDENTIFIER]] = $old_output[$stream_identifier];
                } elseif ($extra[ASSIGNMENT] == REDIRECT_APPEND) {
                    $this->output[$stream_identifier] = ''; // Because it's been taken away from here

                    if ((is_object($this->output[$extra[REDIRECT_IDENTIFIER]])) && (is_object($old_output[$stream_identifier]))) {
                        $this->output[$extra[REDIRECT_IDENTIFIER]]->append($old_output[$stream_identifier]);
                    } elseif ((!is_object($this->output[$extra[REDIRECT_IDENTIFIER]])) && (is_object($old_output[$stream_identifier]))) {
                        $this->output[$extra[REDIRECT_IDENTIFIER]] = make_string_tempcode($this->output[$extra[REDIRECT_IDENTIFIER]]);
                        $this->output[$extra[REDIRECT_IDENTIFIER]]->append($old_output[$stream_identifier]);
                        $this->output[$extra[REDIRECT_IDENTIFIER]] = $this->output[$extra[REDIRECT_IDENTIFIER]]->evaluate();
                    } elseif ((is_object($this->output[$extra[REDIRECT_IDENTIFIER]])) && (!is_object($old_output[$stream_identifier]))) {
                        $old_output[$stream_identifier] = make_string_tempcode($old_output[$stream_identifier]);
                        $this->output[$extra[REDIRECT_IDENTIFIER]]->append($old_output[$stream_identifier]);
                    } else {
                        $this->output[$extra[REDIRECT_IDENTIFIER]] .= $old_output[$stream_identifier];
                    }
                }
            }

            if (($extra[ASSIGNMENT] == REDIRECT_OVERWRITE) || ($extra[ASSIGNMENT] == REDIRECT_APPEND)) {
                if (is_object($this->output[$extra[REDIRECT_IDENTIFIER]])) {
                    $this->output[$extra[REDIRECT_IDENTIFIER]] = $this->output[$extra[REDIRECT_IDENTIFIER]]->evaluate();
                }
                $this->fs->write_file($this->fs->_pwd_to_array($extra[REDIRECT_IDENTIFIER]), $this->output[$extra[REDIRECT_IDENTIFIER]]);
            }
        }
    }

    /**
     * Combine two streams regardless of their format.
     *
     * @param  array $stream1 Stream 1
     * @param  array $stream2 Stream 2
     * @return array Combined streams
     */
    protected function _combine_streams(array $stream1, array $stream2) : array
    {
        // Combine two streams, taking account of arrays, Tempcode and other stuff
        $stream_identifiers = [STREAM_STDCOMMAND, STREAM_STDHTML, STREAM_STDOUT, STREAM_STDERR];

        foreach ($stream_identifiers as $identifier) {
            if ((is_array($stream1[$identifier])) && (is_array($stream2[$identifier]))) {
                $stream1[$identifier] = array_merge($stream1[$identifier], $stream2[$identifier]);
            } else {
                if (is_array($stream1[$identifier])) {
                    if ($identifier == STREAM_STDHTML) {
                        $stream1[$identifier] = $this->_array_to_html($stream1[$identifier]);
                    } else {
                        $stream1[$identifier] = $this->_array_to_text($stream1[$identifier]);
                    }
                } elseif (is_array($stream2[$identifier])) {
                    if ($identifier == STREAM_STDHTML) {
                        $stream2[$identifier] = $this->_array_to_html($stream2[$identifier]);
                    } else {
                        $stream2[$identifier] = $this->_array_to_text($stream2[$identifier]);
                    }
                }

                if ((is_object($stream1[$identifier])) && (is_object($stream2[$identifier]))) {
                    $stream1[$identifier]->append($stream2[$identifier]);
                } elseif ((!is_object($stream1[$identifier])) && (is_object($stream2[$identifier]))) {
                    $stream1[$identifier] = make_string_tempcode($stream1[$identifier]);
                    $stream1[$identifier]->append($stream2[$identifier]);
                    $stream1[$identifier] = $stream1[$identifier]->evaluate();
                } elseif ((is_object($stream1[$identifier])) && (!is_object($stream2[$identifier]))) {
                    $stream2[$identifier] = make_string_tempcode($stream2[$identifier]);
                    $stream1[$identifier]->append($stream2[$identifier]);
                } else {
                    $stream1[$identifier] .= $stream2[$identifier];
                }
            }
        }

        // *Insert ghostbusters reference about crossing the streams*

        return $stream1;
    }

    /**
     * Convert an array to Tempcode for display.
     *
     * @param  array $array Array to display
     * @return Tempcode Tempcode for array
     */
    protected function _array_to_html(array $array) : object
    {
        // Convert an array to an HTML format
        $output = [];
        $key = null;
        foreach ($array as $key => $value) {
            if (is_array($value)) {
                $value = protect_from_escaping($this->_array_to_html($value));
            }
            $output[] = ['KEY' => is_string($key) ? $key : strval($key), 'VALUE' => is_string($value) ? $value : (($value === null) ? 'null' : (is_object($value) ? $value : strval($value)))];
        }
        return do_template('COMMANDR_ARRAY', ['_GUID' => 'ab75cdb77fa797d2e42185b51e34d857', 'ELEMENTS' => $output]);
    }

    /**
     * Convert an array to text for display.
     *
     * @param  array $array Array to display
     * @param  integer $indentation Global indentation
     * @return string Text representation of array
     */
    protected function _array_to_text(array $array, int $indentation = 0) : string
    {
        // Convert an array to a text format
        $output = $this->_do_indentation($indentation) . '[';
        foreach ($array as $key => $value) {
            if (is_array($value)) {
                $output .= "\n" . $this->_array_to_text($value, $indentation + 1);
            } else {
                $output .= "\n" . $this->_do_indentation($indentation + 1) . $key . ' -> ' . $value;
            }
        }
        $output .= "\n" . $this->_do_indentation($indentation) . ']';
        return $output;
    }

    /**
     * Return a specified number of tabs.
     *
     * @param  integer $indentation Number of tabs to return
     * @return string Tabs
     */
    protected function _do_indentation(int $indentation) : string
    {
        // Return some tabs
        $output = '';
        for ($i = 0; $i < $indentation; $i++) {
            $output .= "\t";
        }
        return $output;
    }

    /**
     * Handle a PHP command by executing it, dealing with variables from the class.
     */
    protected function _handle_php_command()
    {
        $commandr_output = mixed();

        // NOTE: Variables throughout this function use the $commandr_ prefix to avoid conflicts with any created through executing PHP commands from the CL
        if ($GLOBALS['CURRENT_SHARE_USER'] === null) {
            // Reload settings...

            if (array_key_exists('commandr_state', $_COOKIE)) {
                $_commandr_state_diff = base64_decode($_COOKIE['commandr_state']);
                $commandr_state_diff = @json_decode($_commandr_state_diff, true);
                if (!is_array($commandr_state_diff)) {
                    $commandr_state_diff = [];
                }
            } else {
                $commandr_state_diff = [];
            }

            if (array_key_exists('commandr_state_lang', $_COOKIE)) {
                $_commandr_state_lang_diff = base64_decode($_COOKIE['commandr_state_lang']);
                $commandr_state_lang_diff = @json_decode($_commandr_state_lang_diff, true);
                if (!is_array($commandr_state_lang_diff)) {
                    $commandr_state_lang_diff = [];
                }
            } else {
                $commandr_state_lang_diff = [];
            }
            foreach ($commandr_state_lang_diff as $commandr_lang) {
                if ((file_exists(get_custom_file_base() . '/lang_custom/' . fallback_lang() . '/' . $commandr_lang . '.ini')) || (file_exists(get_file_base() . '/lang/' . fallback_lang() . '/' . $commandr_lang . '.ini'))) {
                    require_lang($commandr_lang, null, null, true);
                }
            }

            if (array_key_exists('commandr_state_code', $_COOKIE)) {
                $_commandr_state_code_diff = base64_decode($_COOKIE['commandr_state_code']);
                $commandr_state_code_diff = @json_decode($_commandr_state_code_diff, true);
                if (!is_array($commandr_state_code_diff)) {
                    $commandr_state_code_diff = [];
                }
            } else {
                $commandr_state_code_diff = [];
            }

            $already_required = array_keys($GLOBALS['REQUIRED_CODE']);
            foreach ($commandr_state_diff as $commandr_key => $commandr_val) {
                if (!is_scalar($commandr_val)) {
                    continue;
                }
                if (preg_match('#^\w+$#', $commandr_key) != 0) {
                    continue;
                }

                if (is_bool($commandr_val)) {
                    cms_eval('$' . $commandr_key . '=' . ($commandr_val ? 'true' : 'false') . ';', 'Redeclare-boolean in Commandr');
                } elseif ((!is_integer($commandr_val)) && (!is_float($commandr_val))) {
                    cms_eval('$' . $commandr_key . '=\'' . addslashes($commandr_val) . '\';', 'Redeclare-string in Commandr');
                } else {
                    cms_eval('$' . $commandr_key . '=' . strval($commandr_val) . ';', 'Redeclare-number in Commandr');
                }
            }
            foreach ($commandr_state_code_diff as $commandr_code) {
                if ((file_exists(get_file_base() . '/sources_custom/' . $commandr_code . '.php')) && (!in_safe_mode()) || (file_exists(get_file_base() . '/sources/' . $commandr_code . '.php'))) {
                    require_code($commandr_code);
                }
            }

            // Run command...

            require_all_core_cms_code();

            global $SITE_DB, $FORUM_DB, $FORUM_DRIVER;

            $this->output[STREAM_STDERR] = '';

            cms_ini_set('ocproducts.xss_detect', '0');
            ob_start();
            $commandr_eval_output = cms_eval($this->parsed_input[SECTION_COMMAND], 'Commandr PHP-command');
            $commandr_output = ob_get_contents();
            if (($commandr_output == '') && ($commandr_eval_output !== false)) {
                $commandr_output = @strval($commandr_eval_output);
                if ($commandr_output == '') {
                    $commandr_output = do_lang('COMMAND_NO_OUTPUT');
                }
            }
            ob_end_clean();

            // Save settings...

            $cookie_size = strlen(json_encode($_COOKIE));
            if ($cookie_size < 4096) { // Be careful, large cookies can block Apache requests
                // Variables
                $commandr_env_neglect = ['SITE_DB', 'FORUM_DB', 'FORUM_DRIVER', 'GLOBALS', '_SERVER', '_COOKIE', '_GET', '_POST', '_ENV', '_FILES', '_REQUEST', '_SESSION', 'this', 'php_errormsg'/*LEGACY*/];
                $commandr_env_after = get_defined_vars();
                $commandr_env_changes = array_diff(array_keys($commandr_env_after), $commandr_env_neglect);
                $commandr_state_diff = [];
                foreach ($commandr_env_changes as $commandr_change) {
                    if ((substr($commandr_change, 0, 6) != 'commandr_') && (is_scalar($commandr_env_after[$commandr_change]))) {
                        $commandr_state_diff[$commandr_change] = $commandr_env_after[$commandr_change];
                    }
                }
                $data = base64_encode(json_encode($commandr_state_diff));
                if (strlen($data) < 4096) {
                    cms_setcookie('commandr_state', $data, true, false);
                }

                // Code includes
                $newly_required = array_diff(array_keys($GLOBALS['REQUIRED_CODE']), $already_required);
                $data = base64_encode(json_encode($newly_required));
                if (strlen($data) < 4096) {
                    cms_setcookie('commandr_state_code', $data, true, false);
                }

                // Lang file includes
                $data = base64_encode(json_encode(array_keys($GLOBALS['LANGS_REQUESTED'])));
                if (strlen($data) < 4096) {
                    cms_setcookie('commandr_state_lang', $data, true, false);
                }


                // ^ We use base64 encoding to work around inane modsecurity restrictions. We can't always work around modsecurity (GET/POST encoding would be too messy), but for cookies it is an easy win
            } else {
                cms_eatcookie('commandr_state');
            }
        } else {
            // Fake the PHP evaluation, because it's prohibited by a shared install
            $this->output[STREAM_STDERR] = do_lang('SHARED_INSTALL_PROHIBIT');
            $commandr_eval_output = true;
            $commandr_output = '';
        }

        // Put out output...

        $this->output[STREAM_STDCOMMAND] = '';
        $this->output[STREAM_STDHTML] = '';
        $this->output[STREAM_STDOUT] = '';

        if (is_object($commandr_output)) {
            $this->output[STREAM_STDHTML] = $commandr_output->evaluate();
        } elseif (is_array($commandr_output)) {
            $this->output[STREAM_STDHTML] = $this->_array_to_html($commandr_output);
        } else {
            $this->output[STREAM_STDOUT] = $commandr_output;
        }
        if ($commandr_eval_output === false) {
            if (has_smart_quotes($this->parsed_input[SECTION_COMMAND])) {
                $this->output[STREAM_STDERR] = do_lang('EVAL_ERROR') . do_lang('EVAL_ERROR_QUOTE_ISSUE');
            } else {
                $this->output[STREAM_STDERR] = do_lang('EVAL_ERROR');
            }
        }
        if ($commandr_output === null) {
            $this->output[STREAM_STDERR] = do_lang('NO_RESULTS');
        }
    }

    /**
     * Find a script file.
     *
     * @param  string $script_name Script name
     * @param  ?string $dir Directory (null: Commandr module data dir)
     * @return ~string Path or failure (false: failure)
     */
    protected function _find_script_file(string $script_name, ?string $dir = null)
    {
        require_code('files');

        if ($dir === null) {
            $dir = get_file_base() . '/data/modules/admin_commandr/';
        }
        $dh = @opendir($dir);
        if ($dh !== false) {
            while (($file = readdir($dh)) !== false) {
                if ($file == $script_name) {
                    return $dir . $script_name;
                }
                if ((is_dir($dir . $file)) && ($file != '.') && (!should_ignore_file('data/modules/admin_commandr/' . $file, IGNORE_ACCESS_CONTROLLERS))) {
                    $return = $this->_find_script_file($script_name, $dir . $file . '/');
                    if ($return) {
                        return $return;
                    }
                }
            }
            closedir($dh);
        }
        return false;
    }
}

/**
 * Template for a command's help.
 *
 * @param  string $command Command name
 * @param  array $options Options
 * @param  array $parameters Parameters (keys are the parameters, values are always set to true, i.e. it is an array of as many trues as there are parameters)
 * @return Tempcode Help template
 */
function do_command_help(string $command, array $options, array $parameters) : object
{
    $_options = [];
    $_parameters = [];

    foreach ($options as $option_name) {
        if ($option_name == 'h') {
            $_options['-h'] = do_lang('GENERAL_HELP');
        } else {
            $_options['-' . $option_name] = do_lang('CMD_' . cms_strtoupper_ascii($command) . '_HELP_' . cms_strtoupper_ascii($option_name));
        }
    }

    foreach (array_keys($parameters) as $parameter_number) {
        $_parameter = do_lang('CMD_' . cms_strtoupper_ascii($command) . '_HELP_PARAM_' . strval($parameter_number));
        if ($_parameter === null) {
            continue;
        }
        $matches = [];
        if (preg_match('#/sources/hooks/(.*)/(.*)/#', $_parameter, $matches) != 0) {
            $hooks = find_all_hooks($matches[1], $matches[2]);
            $_parameter .= ' (';
            foreach (array_keys($hooks) as $i => $hook) {
                if ($i != 0) {
                    $_parameter .= ', ';
                }
                $_parameter .= $hook;
            }
            $_parameter .= ')';
        }
        $_parameters[] = $_parameter;
    }

    return do_template('COMMANDR_HELP', [
        '_GUID' => '6abdbac52ae2a63f219f5d2e44687bb9',
        'INTRODUCTION' => do_lang_tempcode('CMD_' . cms_strtoupper_ascii($command) . '_HELP'),
        'OPTIONS' => $_options,
        'PARAMETERS' => $_parameters,
    ]);
}

/**
 * Put something non-Commandr in a standard box so it looks OK.
 *
 * @param  mixed $html HTML (string or Tempcode)
 * @return Tempcode Boxed HTML
 */
function commandr_make_normal_html_visible($html) : object
{
    return do_template('COMMANDR_BOX', ['_GUID' => '1a77370b0230fafda432c2d325d83ef1', 'HTML' => $html]);
}

/**
 * Find whether some text is using Unicode smart quotes.
 *
 * @param  string $str Text
 * @return boolean Whether it has Unicode smart quotes
 */
function has_smart_quotes(string $str) : bool
{
    if (get_charset() != 'utf-8') {
        return false;
    }
    if (strpos($str, "\u{2018}") !== false) {
        return true;
    }
    if (strpos($str, "\u{2019}") !== false) {
        return true;
    }
    if (strpos($str, "\u{201C}") !== false) {
        return true;
    }
    if (strpos($str, "\u{201D}") !== false) {
        return true;
    }
    return false;
}
