<?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    core
 */

/**
 * Standard code module initialisation function.
 *
 * @ignore
 */
function init__failure()
{
    global $DONE_ONE_WEB_SERVICE;
    $DONE_ONE_WEB_SERVICE = false;

    global $THROWING_ERRORS;
    $THROWING_ERRORS = false;

    if (!defined('MAX_STACK_TRACE_VALUE_LENGTH')) {
        define('MAX_STACK_TRACE_VALUE_LENGTH', (get_param_integer('keep_fatalistic', 0) == 2) ? 10000 : 300);
    }

    /** Whether we want errors to result in simple text responses. Useful for AJAX scripts.
     *
     * @global boolean $WANT_TEXT_ERRORS
     */
    global $WANT_TEXT_ERRORS;
    $cli = false;
    if (function_exists('is_cli')) {
        $cli = is_cli();
    }
    $WANT_TEXT_ERRORS = $cli;

    global $RUNNING_TASK;
    $RUNNING_TASK = false;

    global $BLOCK_CORE_DEVELOPERS_ERROR_EMAILS;
    if (!isset($BLOCK_CORE_DEVELOPERS_ERROR_EMAILS)) {
        $BLOCK_CORE_DEVELOPERS_ERROR_EMAILS = false;
    }
}

/**
 * Give the user an option to see a stack trace by adding in a link, but only if they have permission.
 */
function suggest_fatalistic()
{
    if ((may_see_stack_traces()) && (get_param_integer('keep_fatalistic', 0) == 0) && (running_script('index'))) {
        require_code('urls');
        if ($_SERVER['REQUEST_METHOD'] != 'POST') {
            $stack_trace_url = build_url(['page' => '_SELF', 'keep_fatalistic' => 1], '_SELF', [], true);
            $st = do_lang_tempcode('WARN_TO_STACK_TRACE', escape_html($stack_trace_url->evaluate()));
        } elseif (empty($_FILES) || function_exists('is_plupload') && is_plupload()) {
            $stack_trace_url = build_url(['page' => '_SELF', 'keep_fatalistic' => 1], '_SELF', [], true);
            $p = build_keep_post_fields();
            $p->attach(symbol_tempcode('INSERT_FORM_POST_SECURITY'));
            $st = do_lang_tempcode('WARN_TO_STACK_TRACE_2', escape_html($stack_trace_url->evaluate()), $p->evaluate());
        } else {
            $stack_trace_url = build_url(['page' => '', 'keep_fatalistic' => 1], '');
            $st = do_lang_tempcode('WARN_TO_STACK_TRACE_3', escape_html($stack_trace_url->evaluate()));
        }
        require_code('site');
        attach_message($st, 'inform');
    }
}

/**
 * Terminate with an error caused by unzipping.
 *
 * @param  integer $errno The ZIP error number
 * @param  boolean $mzip Whether mzip was used
 * @return Tempcode Error message
 */
function zip_error(int $errno, bool $mzip = false) : object
{
    $zip_file_function_errors = [
        // Based on comment from php.net
        'ZIPARCHIVE::ER_MULTIDISK' => 'Multi-disk zip archives not supported.',
        'ZIPARCHIVE::ER_RENAME' => 'Renaming temporary file failed.',
        'ZIPARCHIVE::ER_CLOSE' => 'Closing zip archive failed',
        'ZIPARCHIVE::ER_SEEK' => 'Seek error',
        'ZIPARCHIVE::ER_READ' => 'Read error',
        'ZIPARCHIVE::ER_WRITE' => 'Write error',
        'ZIPARCHIVE::ER_CRC' => 'CRC error',
        'ZIPARCHIVE::ER_ZIPCLOSED' => 'Containing zip archive was closed',
        'ZIPARCHIVE::ER_NOENT' => 'No such file.',
        'ZIPARCHIVE::ER_EXISTS' => 'File already exists',
        'ZIPARCHIVE::ER_OPEN' => 'Can\'t open file',
        'ZIPARCHIVE::ER_TMPOPEN' => 'Failure to create temporary file.',
        'ZIPARCHIVE::ER_ZLIB' => 'Zlib error',
        'ZIPARCHIVE::ER_MEMORY' => 'Memory allocation failure',
        'ZIPARCHIVE::ER_CHANGED' => 'Entry has been changed',
        'ZIPARCHIVE::ER_COMPNOTSUPP' => 'Compression method not supported.',
        'ZIPARCHIVE::ER_EOF' => 'Premature EOF',
        'ZIPARCHIVE::ER_INVAL' => 'Invalid argument',
        'ZIPARCHIVE::ER_NOZIP' => 'Not a zip archive',
        'ZIPARCHIVE::ER_INTERNAL' => 'Internal error',
        'ZIPARCHIVE::ER_INCONS' => 'ZIP archive inconsistent',
        'ZIPARCHIVE::ER_REMOVE' => 'Can\'t remove file',
        'ZIPARCHIVE::ER_DELETED' => 'Entry has been deleted',
    ];
    $errmsg = 'unknown';
    foreach ($zip_file_function_errors as $const_name => $error_message) {
        if ((defined($const_name)) && (@constant($const_name)) == $errno) {
            $errmsg = $error_message;
        }
    }
    return do_lang_tempcode($mzip ? 'ZIP_ERROR_MZIP' : 'ZIP_ERROR', $errmsg);
}

/**
 * Handle invalid integer parameter values.
 *
 * @param  string $name The parameter deemed to have an invalid value somehow
 * @param  ?string $ret The value of the parameter deemed invalid (null: we known we can't recover)
 * @param  boolean $posted Whether the parameter is a POST parameter
 * @return string Fixed parameter (usually the function won't return [instead will give an error], but in special cases, it can filter an invalid return)
 * @ignore
 */
function _param_invalid_integer(string $name, ?string $ret, bool $posted) : string
{
    // Invalid params can happen for many reasons:
    //  [/url] getting onto the end of URLs by bad URL extractors getting URLs out of Comcode
    //  Spiders trying to ascend directory trees, and forcing index.php into the integer position of URL Schemes
    //  Spiders that don't understand entity decoding
    //  People copying and pasting text shown after URLs as part of the URL itself
    //  New line characters getting pasted in (weird, but it's happened-- think might be some kind of screen-reader browser)
    //  People typing the wrong URLs for many reasons
    // Therefore we can't really treat it as a hack-attack, even though that would be preferable.

    static $param_invalid_looping = [];
    if (isset($param_invalid_looping[$name])) {
        return '0'; // stop loop, e.g. with keep_fatalistic=<corruptvalue>
    }
    $param_invalid_looping[$name] = true;

    if ($ret !== null) {
        // Try and recover by stripping junk off...
        $test = preg_replace('#[^\d]+$#', '', $ret);
        if (is_numeric($test)) {
            return $test;
        }
    }

    require_code('global3');
    set_http_status_code(400);

    require_code('lang');
    require_code('tempcode');

    if (function_exists('url_monikers_enabled') && !url_monikers_enabled() && $name == 'id') {
        warn_exit(do_lang_tempcode('javascript:NOT_INTEGER_URL_MONIKERS')); // Complaining about non-integers is just confusing
    }

    warn_exit(do_lang_tempcode('javascript:NOT_INTEGER'));

    unset($param_invalid_looping[$name]);

    return '';
}

/**
 * Complain about a field being missing.
 *
 * @param  string $name The name of the parameter
 * @param  ?boolean $posted Whether the parameter is a POST parameter (null: undetermined)
 * @param  array $array The array we're extracting parameters from
 */
function improperly_filled_in(string $name, ?bool $posted, array $array)
{
    require_code('tempcode');

    require_code('global3');
    set_http_status_code(400);

    if ($posted !== false) {
        improperly_filled_in_post($name);
    }

    if ($name == 'username') {
        warn_exit(do_lang_tempcode('NO_PARAMETER_SENT_SPECIAL', escape_html($name)));
    }

    if ((!isset($array[$name])) && (($name == 'id') || ($name == 'type')) && (!headers_sent())) {
        set_http_status_code(404);
    }
    warn_exit(do_lang_tempcode('NO_PARAMETER_SENT', escape_html(post_param_string('label_for__' . $name, escape_html($name)))));
}

/**
 * Complain about a POST field being missing.
 *
 * @param  string $name The name of the parameter
 */
function improperly_filled_in_post(string $name)
{
    require_code('global3');
    set_http_status_code(400);

    if ((empty($_POST)) && (get_option('user_postsize_errors') == '1')) {
        require_code('files');
        $upload_max_filesize = (ini_get('upload_max_filesize') == '0') ? do_lang('NA') : clean_file_size(php_return_bytes(ini_get('upload_max_filesize')));
        $post_max_size = (ini_get('post_max_size') == '0') ? do_lang('NA') : clean_file_size(php_return_bytes(ini_get('post_max_size')));
        warn_exit(do_lang_tempcode((get_param_integer('uploading', 0) == 1) ? 'SHOULD_HAVE_BEEN_POSTED_FILE_ERROR' : 'SHOULD_HAVE_BEEN_POSTED', escape_html($name), escape_html($post_max_size), escape_html($upload_max_filesize)));
    }

    // We didn't give some required input
    warn_exit(do_lang_tempcode('NO_PARAMETER_SENT', escape_html($name)));
}

/**
 * Called by 'cms_error_handler'. Software error handler (hooked into PHP error system).
 *
 * @param  ID_TEXT $type Error type indicator (tiny human-readable text string)
 * @set error warning notice deprecated
 * @param  integer $errno The error code-number
 * @param  PATH $errstr The error message
 * @param  string $errfile The file the error occurred in
 * @param  integer $errline The line the error occurred on
 * @param  integer $syslog_type The syslog type (used by GAE logging)
 * @param  string $handling_method How to handle the error
 * @set LOG ATTACH FATAL
 * @ignore
 */
function _cms_error_handler(string $type, int $errno, string $errstr, string $errfile, int $errline, int $syslog_type, string $handling_method)
{
    $fatal = (!peek_suppress_error_death()) && ($handling_method == 'FATAL');

    if ($fatal) {
        // Turn off MSN, as this increases stability
        if ((array_key_exists('MSN_DB', $GLOBALS)) && ($GLOBALS['MSN_DB'] !== null)) {
            $GLOBALS['FORUM_DB'] = $GLOBALS['MSN_DB'];
            $GLOBALS['MSN_DB'] = null;
        }
    }

    // Generate error message
    $outx = '<strong>' . cms_strtoupper_ascii($type) . '</strong> [' . strval($errno) . '] ' . $errstr . ' in ' . $errfile . ' on line ' . strval($errline) . '<br />' . "\n";
    if (class_exists('Tempcode')) {
        if ($fatal) {
            $trace = get_html_trace();
        } else {
            $trace = new Tempcode();
        }
        $out = $outx . $trace->evaluate();
    } else {
        $out = $outx;
    }

    require_code('urls');
    $php_error_label = $errstr . ' in ' . $errfile . ' on line ' . strval($errline) . ' @ ' . get_self_url_easy(true);
    if ((!empty($_SERVER['REQUEST_METHOD'])) && ($_SERVER['REQUEST_METHOD'] != 'GET')) {
        $php_error_label .= ' [' . $_SERVER['REQUEST_METHOD'] . ']';
    }

    $may_log_error = ((!running_script('cron_bridge')) || (@filemtime(get_custom_file_base() . '/data_custom/errorlog.php') < time() - 60 * 5)) && (!throwing_errors());

    if ($may_log_error) {
        // Put into error log
        if ((function_exists('syslog')) && (GOOGLE_APPENGINE)) {
            syslog($syslog_type, $php_error_label);
        }
        if (php_function_allowed('error_log')) {
            switch (cms_strtoupper_ascii($type)) {
                case 'ERROR':
                case 'FATAL ERROR':
                    @error_log('PHP: ' . ($fatal ? 'CRITICAL' : 'ERROR') . ' ' . $php_error_label, 0);
                    break;
                case 'WARNING':
                case 'NOTICE':
                case 'DEPRECATED':
                    @error_log('PHP: WARNING ' . $php_error_label, 0);
                    break;
            }
        }

        // Send a notification
        if ($type != 'error') { // We want to get out of this process ASAP for [fatal] errors, so skip the notification in that case.
            $trace = get_html_trace();
            relay_error_notification($php_error_label . '[html]' . $trace->evaluate() . '[/html]');
        }
    }

    // Apply security to filter what is shown
    $errstr = _sanitise_error_msg($errstr);
    switch ($errno) {
        case E_USER_ERROR:
        case E_USER_WARNING:
        case E_USER_NOTICE:
            break;

        default:
            if ((!has_privilege(get_member(), 'see_php_errors')) && (!$GLOBALS['DEV_MODE'])) {
                $errstr = do_lang('INTERNAL_ERROR');
            }
            break;
    }

    // Display in appropriate way
    if ($fatal) {
        $error_str = 'PHP: ' . cms_strtoupper_ascii($type) . ' [' . strval($errno) . '] ' . $errstr . ' in ' . $errfile . ' on line ' . strval($errline);

        if (throwing_errors()) {
            throw new CMSException($error_str);
        }

        if ($type == 'error') {
            critical_error('EMERGENCY', escape_html($error_str));
        }

        _generic_exit($error_str, 'FATAL_SCREEN', false, false, 500);
    } elseif ($handling_method == 'ATTACH') {
        require_code('site');
        attach_message(protect_from_escaping($out), 'warn'/*any level of unexpected coding-level error is a 'warning' in the software*/);
    }
}

/**
 * Get the Tempcode for a warn page.
 *
 * @param  Tempcode $title The title of the warn page
 * @param  mixed $text The text to put on the warn page, provided in plain-text format or HTML Tempcode
 * @param  boolean $provide_back Whether to provide a back button
 * @param  boolean $support_match_key_messages Whether match key messages / redirects should be supported
 * @return Tempcode The warn page
 *
 * @ignore
 */
function _warn_screen(object $title, $text, bool $provide_back = true, bool $support_match_key_messages = false) : object
{
    $text_eval = is_object($text) ? $text->evaluate() : $text;

    $tmp = _look_for_match_key_message($text_eval, !$support_match_key_messages);
    if ($tmp !== null) {
        $text = $tmp;
    }

    if (strpos($text_eval, do_lang('MISSING_RESOURCE_SUBSTRING')) !== false) {
        require_code('global3');
        set_http_status_code(404);
        if ($_SERVER['HTTP_REFERER'] != '') {
            relay_error_notification($text_eval . ' ' . do_lang('REFERRER', $_SERVER['HTTP_REFERER'], substr(get_browser_string(), 0, 255)), false, 'error_occurred_missing_resource');
        }
    }

    if (get_param_integer('keep_fatalistic', 0) != 0) {
        _generic_exit($text, 'FATAL_SCREEN', false, false, 500);
    }

    return do_template('WARN_SCREEN', [
        '_GUID' => 'a762a7ac8cd08623a0ed6413d9250d97',
        'TITLE' => $title,
        'WEBSERVICE_RESULT' => get_webservice_result($text),
        'TEXT' => $text,
        'PROVIDE_BACK' => $provide_back,
    ]);
}

/**
 * Strip privileged data from an error message.
 *
 * @param  string $text The error message
 * @return string Sanitised error message
 *
 * @ignore
 */
function _sanitise_error_msg(string $text) : string
{
    // Strip paths, for security reasons
    return str_replace([get_custom_file_base() . '/', get_file_base() . '/'], ['', ''], $text);
}

/**
 * Do a terminal execution on a defined page type.
 *
 * @param  mixed $text The error message, provided in plain-text format or as HTML via do_lang_tempcode/protect_from_escaping (string or Tempcode)
 * @param  ID_TEXT $template Name of the terminal page template
 * @set INFORM_SCREEN WARN_SCREEN FATAL_SCREEN
 * @param  ?boolean $support_match_key_messages Whether match key messages / redirects should be supported (null: detect)
 * @param  boolean $log_error Whether to log the error
 * @param  ?integer $http_status HTTP status to set (null: none, unless it's a missing resource error in which case 404)
 * @param  ?Tempcode $title Title to use show (null: default)
 * @param  ?URLPATH $image_url Image to show (only works for INFORM_SCREEN and WARN_SCREEN) (null: default)
 * @ignore
 * @exits
 */
function _generic_exit($text, string $template, ?bool $support_match_key_messages = false, bool $log_error = false, ?int $http_status = null, ?object $title = null, ?string $image_url = null)
{
    if (($template == 'WARN_SCREEN') && ((get_param_integer('keep_fatalistic', 0) != 0) || (running_script('commandr')))) {
        _generic_exit($text, 'FATAL_SCREEN', false, $log_error, $http_status);
    }

    if (throwing_errors()) {
        throw new CMSException($text);
    }

    cms_ob_end_clean(); // Emergency output, potentially, so kill off any active buffer

    if (is_object($text)) {
        $text = $text->evaluate();
        $text = _sanitise_error_msg($text);
        $text = protect_from_escaping($text);
    } else {
        $text = _sanitise_error_msg(htmlentities($text)); // Intentionally not using escape_html, as may not be defined yet
    }

    $see_php_errors = ($GLOBALS['DEV_MODE'] || function_exists('has_privilege') && has_privilege(get_member(), 'see_php_errors'));
    if ($see_php_errors) {
        if ($template == 'FATAL_SCREEN') {
            // Supplement error message with some useful info
            if ((function_exists('cms_version_pretty')) && (function_exists('get_self_url_easy'))) {
                $sup = ' (version: ' . cms_version_pretty() . ', PHP version: ' . PHP_VERSION . ', URL: ' . get_self_url_easy(true) . ')';
            } else {
                $sup = '';
            }
            if (is_object($text)) {
                if ($text->pure_lang === true) {
                    $sup = htmlentities($sup); // Intentionally not using escape_html, as may not be defined yet
                }
                $text->attach($sup);
            } else {
                $text .= $sup;
            }
        }
    }

    $text_eval = is_object($text) ? $text->evaluate() : $text;

    global $EXITING, $MICRO_BOOTUP, $BOOTSTRAPPING;
    if (($EXITING >= 1) || (!function_exists('get_member')) || (!function_exists('get_screen_title')) || (!function_exists('do_lang')) || (running_script('upgrader')) || (!class_exists('Tempcode')) || ($MICRO_BOOTUP) || ($BOOTSTRAPPING)) {
        if (($EXITING == 2) && (!$BOOTSTRAPPING) && (function_exists('may_see_stack_traces')) && (may_see_stack_traces()) && ($GLOBALS['HAS_SET_ERROR_HANDLER'])) {
            die_html_trace($text_eval);
        } else { // Failed even in die_html_trace
            critical_error('EMERGENCY', $text_eval);
        }
    }
    $EXITING++;

    if ($http_status !== null) {
        set_http_status_code($http_status);
    }

    if ($see_php_errors) {
        if (!headers_sent()) {
            require_code('firephp');
            if (function_exists('fb_wrap')) {
                fb_wrap($template . ': ' . $text_eval);
            }
        }
    }

    if ($log_error) {
        require_code('urls');
        $php_error_label = $text_eval . ' @ ' . get_self_url_easy(true);
        if ((!empty($_SERVER['REQUEST_METHOD'])) && ($_SERVER['REQUEST_METHOD'] != 'GET')) {
            $php_error_label .= ' [' . $_SERVER['REQUEST_METHOD'] . ']';
        }

        $may_log_error = ((!running_script('cron_bridge')) || (@filemtime(get_custom_file_base() . '/data_custom/errorlog.php') < time() - (60 * 5)));

        if ($may_log_error) {
            if ((function_exists('syslog')) && (GOOGLE_APPENGINE)) {
                syslog(LOG_ERR, $php_error_label);
            }
            if (php_function_allowed('error_log')) {
                switch ($template) {
                    case 'INFORM_SCREEN':
                        @error_log(brand_name() . ': INFO ' . $php_error_label, 0);
                        break;
                    case 'WARN_SCREEN':
                        @error_log(brand_name() . ': WARNING ' . $php_error_label, 0);
                        break;
                    case 'FATAL_SCREEN':
                        @error_log(brand_name() . ': ERROR ' . $php_error_label, 0);
                        break;
                }
            }

            $trace = get_html_trace();
            relay_error_notification('[html]' . $text_eval . $trace->evaluate() . '[/html]');
        }
    }

    global $RUNNING_TASK;
    if ($RUNNING_TASK) {
        require_code('notifications');
        require_lang('tasks');
        $n_subject = do_lang('_TASK_FAILED_SUBJECT');
        $n_message = do_notification_lang('TASK_FAILED_BODY', '[semihtml]' . $text_eval . '[/semihtml]');
        dispatch_notification('task_completed', null, $n_subject, $n_message, [get_member()], A_FROM_SYSTEM_PRIVILEGED, ['priority' => 2, 'send_immediately' => true]);
    }

    if ($support_match_key_messages === null) {
        $support_match_key_messages = in_array($text_eval, [do_lang('NO_ENTRIES'), do_lang('NO_CATEGORIES')]);
    }
    $tmp = _look_for_match_key_message($text_eval, false, !$support_match_key_messages);
    if ($tmp !== null) {
        $text = $tmp;
    }

    require_code('global3');

    global $HTTP_STATUS_CODE;

    global $WANT_TEXT_ERRORS;
    if ($WANT_TEXT_ERRORS) {
        @header('Content-Type: text/plain; charset=' . get_charset());
        if ($HTTP_STATUS_CODE == 200) {
            set_http_status_code(500);
        }
        cms_ini_set('ocproducts.xss_detect', '0');
        @debug_print_backtrace();
        exit((is_object($text) ? strip_html($text->evaluate()) : $text) . "\n");
    }

    @header('Content-Type: text/html; charset=' . get_charset());
    @header('Content-Disposition: inline');

    if ((function_exists('do_lang')) && (strpos($text_eval, do_lang('MISSING_RESOURCE_SUBSTRING')) !== false)) {
        set_http_status_code(404);
        if ($_SERVER['HTTP_REFERER'] != '') {
            relay_error_notification($text_eval . ' ' . do_lang('REFERRER', $_SERVER['HTTP_REFERER'], substr(get_browser_string(), 0, 255)), false, 'error_occurred_missing_resource');
        }
    }

    if ((array_key_exists('MSN_DB', $GLOBALS)) && ($GLOBALS['MSN_DB'] !== null)) {
        $GLOBALS['FORUM_DB'] = $GLOBALS['MSN_DB'];
        $GLOBALS['MSN_DB'] = null;
    }

    if ((get_forum_type() == 'cns') && (get_db_type() != 'xml'/*Too intensive*/) && (isset($GLOBALS['FORUM_DRIVER']))) {
        require_code('cns_groups');
        $restrict_answer = cns_get_best_group_property($GLOBALS['FORUM_DRIVER']->get_members_groups(get_member()), 'flood_control_submit_secs');
        $GLOBALS['FORUM_DB']->query_update('f_members', ['m_last_submit_time' => time() - $restrict_answer - 1], ['id' => get_member()], '', 1);
    }

    if ($title === null) {
        if (($template == 'INFORM_SCREEN') && (is_object($GLOBALS['DISPLAYED_TITLE']))) {
            $title = get_screen_title($GLOBALS['DISPLAYED_TITLE'], false);
        } else {
            $title = get_screen_title(($template == 'INFORM_SCREEN') ? 'MESSAGE' : 'ERROR_OCCURRED');
        }
    }

    if ($template == 'FATAL_SCREEN') {
        $webservice_result = get_webservice_result($text);
        $may_see_trace = may_see_stack_traces();
        if ($may_see_trace) {
            $trace = get_html_trace();
        } else {
            $trace = new Tempcode();
        }
    } else {
        $webservice_result = null;
        $may_see_trace = null;
        $trace = null;
    }

    $middle = do_template($template, [
        'TITLE' => $title,
        'TEXT' => $text,
        'PROVIDE_BACK' => true,
        'WEBSERVICE_RESULT' => $webservice_result,
        'MAY_SEE_TRACE' => $may_see_trace,
        'TRACE' => $trace,
        'IMAGE_URL' => $image_url,
    ]);
    $echo = globalise($middle, null, '', true);
    $echo->evaluate_echo();
    exit();
}

/**
 * Find if a hackattack specifier matches.
 *
 * @param  array $specifier The hackattack specifier
 * @param  ID_TEXT $reason The reason for the hack-attack. This has to be a language string codename
 * @param  SHORT_TEXT $reason_param_a A parameter for the hack-attack language string (this should be based on a unique ID, preferably)
 * @param  SHORT_TEXT $reason_param_b A more illustrative parameter, which may be anything (e.g. a title)
 * @return boolean Whether it matches
 * @ignore
 * @exits
 */
function _log_hack_attack_matches(array $specifier, string $reason, string $reason_param_a, string $reason_param_b) : bool
{
    return ($specifier['codename'] == $reason) &&
        (($specifier['param_a_pattern'] === null) || (simulated_wildcard_match($reason_param_a, $specifier['param_a_pattern'], true, true))) &&
        (($specifier['param_b_pattern'] === null) || (simulated_wildcard_match($reason_param_b, $specifier['param_b_pattern'], true, true)));
}

/**
 * Log a hackattack, then displays an error message. It also attempts to send an e-mail to the staff alerting them of the hackattack.
 *
 * @param  ID_TEXT $reason The reason for the hack-attack. This has to be a language string codename
 * @param  SHORT_TEXT $reason_param_a A parameter for the hack-attack language string (this should be based on a unique ID, preferably)
 * @param  SHORT_TEXT $reason_param_b A more illustrative parameter, which may be anything (e.g. a title)
 * @ignore
 * @exits
 */
function _log_hack_attack_and_exit(string $reason, string $reason_param_a = '', string $reason_param_b = '')
{
    // Default control settings
    $silent_to_user = false;
    $silent_to_staff_notifications = false;
    $silent_to_staff_log = false;
    $percentage_score = 100;

    // Read control from XML
    require_code('input_filter');
    list(, , $hackattack_specifiers) = load_advanced_banning();
    foreach ($hackattack_specifiers as $specifier) {
        if (_log_hack_attack_matches($specifier, $reason, $reason_param_a, $reason_param_b)) {
            if ($specifier['silent_to_user'] !== null) {
                $silent_to_user = $specifier['silent_to_user'];
            }
            if ($specifier['silent_to_staff_notifications'] !== null) {
                $silent_to_staff_notifications = $specifier['silent_to_staff_notifications'];
            }
            if ($specifier['silent_to_staff_log'] !== null) {
                $silent_to_staff_log = $specifier['silent_to_staff_log'];
            }
            if ($specifier['percentage_score'] !== null) {
                $percentage_score = $specifier['percentage_score'];
            }
        }
    }

    // HTTP statuses...

    if (!$GLOBALS['BOOTSTRAPPING']) {
        attach_to_screen_header('<meta name="robots" content="noindex" />'); // XHTMLXHTML
    }

    if (!$silent_to_user) {
        require_code('global3');
        set_http_status_code(403); // Stop spiders ever storing the URL that caused this
    }

    // Special case: no securitylogging addon...

    if (!addon_installed('securitylogging')) {
        if ($silent_to_user) {
            return;
        }
        warn_exit(do_lang_tempcode('HACK_ATTACK_USER'));
    }

    // Work out basic metadata...

    $ip = get_ip_address();
    if ((function_exists('get_member')) && (!$GLOBALS['BOOTSTRAPPING'])) {
        $id = get_member();
        $username = $GLOBALS['FORUM_DRIVER']->get_username($id);
    } else {
        $id = db_get_first_id();
        $username = ((function_exists('do_lang')) && (!$GLOBALS['BOOTSTRAPPING'])) ? do_lang('UNKNOWN') : 'Unknown';
    }

    $url = $_SERVER['REQUEST_URI'];

    // Automatic ban needed?...

    $count = @floatval($GLOBALS['SITE_DB']->query_select_value('hackattack', 'SUM(percentage_score)', ['ip' => $ip]) + $percentage_score) / 100.0;
    $hack_threshold = intval(get_option('hack_ban_threshold'));
    if ((array_key_exists('FORUM_DRIVER', $GLOBALS)) && (function_exists('get_member')) && ($GLOBALS['FORUM_DRIVER']->is_super_admin(get_member()))) {
        $count = 0.0;
    }

    $post = json_encode($_POST);

    $new_row = [
        'user_agent' => cms_mb_substr(get_browser_string(), 0, 255),
        'referer' => cms_mb_substr($_SERVER['HTTP_REFERER'], 0, 255),
        'user_os' => cms_mb_substr(get_os_string(), 0, 255),
        'reason' => $reason,
        'reason_param_a' => cms_mb_substr($reason_param_a, 0, 255),
        'reason_param_b' => cms_mb_substr($reason_param_b, 0, 255),
        'url' => cms_mb_substr($url, 0, 255),
        'data_post' => $post,
        'member_id' => $id,
        'date_and_time' => time(),
        'ip' => $ip,
        'percentage_score' => $percentage_score,
        'silent_to_staff_log' => $silent_to_staff_log ? 1 : 0,
    ];

    $ip_ban_todo = null;
    if (($count >= floatval($hack_threshold)) && (get_option('autoban') != '0') && ($GLOBALS['SITE_DB']->query_select_value_if_there('unbannable_ip', 'ip', ['ip' => $ip]) === null)) {
        // Test we're not banning a good bot...

        if ((!is_our_server($ip)) && (!is_unbannable_bot_dns($ip)) && (!is_unbannable_bot_ip($ip))) {
            // Prepare message about a ban...

            $rows = $GLOBALS['SITE_DB']->query_select('hackattack', ['*'], ['ip' => $ip], 'ORDER BY date_and_time');
            $rows[] = $new_row;

            $summary = '[list]';
            $syndicate_as_spammer = false;
            foreach ($rows as $row) {
                foreach ($hackattack_specifiers as $specifier) {
                    if (_log_hack_attack_matches($specifier, $row['reason'], $row['reason_param_a'], $row['reason_param_b'])) {
                        if ($specifier['syndicate_as_spammer'] === true) {
                            $syndicate_as_spammer = true;
                        }
                    }
                }

                $full_reason = do_lang($row['reason'], '[tt]' . comcode_escape($row['reason_param_a']) . '[/tt]', '[tt]' . comcode_escape($row['reason_param_b']) . '[/tt]', null, get_site_default_lang());
                $summary .= "\n" . '[*]' . $full_reason . "\n[tt]" . comcode_escape($row['url']) . "[/tt]\n" . get_timezoned_date_time($row['date_and_time']);
            }
            $summary .= "\n" . '[/list]';

            // Send report to anti-spam partners...

            if ($syndicate_as_spammer) {
                require_code('failure_spammers');
                syndicate_spammer_report($ip, is_guest() ? '' : $GLOBALS['FORUM_DRIVER']->get_username(get_member()), $GLOBALS['FORUM_DRIVER']->get_member_email_address(get_member()), do_lang('SPAM_REPORT_TRIGGERED_SPAM_HEURISTICS'));
            }

            // Add ban...

            $ban_happened = add_ip_ban($ip, $full_reason);

            // Prepare notification text...

            $_ip_ban_url = build_url(['page' => 'admin_ip_ban', 'type' => 'browse'], get_module_zone('admin_ip_ban'), [], false, false, true);
            $ip_ban_url = $_ip_ban_url->evaluate();

            if ($ban_happened) {
                $ip_ban_todo = do_lang('AUTO_BAN_HACK_MESSAGE', $ip, integer_format($hack_threshold, 0), [$summary, $ip_ban_url], get_site_default_lang());
            }
        }
    }

    // Add hackattack...

    $GLOBALS['SITE_DB']->query_insert('hackattack', $new_row);

    // Send notification...

    if ((function_exists('do_lang')) && (!$GLOBALS['BOOTSTRAPPING'])) {
        require_code('notifications');

        $reason_full = do_lang($reason, $reason_param_a, $reason_param_b, null, get_site_default_lang());

        $_stack_trace = get_html_trace();
        $stack_trace = escape_html_in_comcode($_stack_trace->evaluate());

        $date = get_timezoned_date_time(time(), false, false, $GLOBALS['FORUM_DRIVER']->get_guest_id());

        $message = do_notification_template(
            'HACK_ATTEMPT_MAIL',
            [
                '_GUID' => '6253b3c42c5e6c70d20afa9d1f5b40bd',
                'STACK_TRACE' => $stack_trace,
                'USER_AGENT' => get_browser_string(),
                'REFERER' => $_SERVER['HTTP_REFERER'],
                'USER_OS' => get_os_string(),
                'REASON' => $reason_full,
                'IP' => $ip,
                'ID' => strval($id),
                'USERNAME' => $username,
                'TIME_RAW' => strval(time()),
                'DATE' => $date,
                'URL' => $url,
                'POST' => $post,
            ],
            get_site_default_lang(),
            false,
            null,
            '.txt',
            'text'
        );

        // Hack-attack notification...

        if (!$silent_to_staff_notifications) {
            $subject = do_lang('HACK_ATTACK_SUBJECT', $ip, null, null, get_site_default_lang());
            dispatch_notification('core_staff:hack_attack', null, $subject, $message->evaluate(get_site_default_lang()), null, A_FROM_SYSTEM_PRIVILEGED);
        }

        // IP ban notification (if applicable)...

        if ($ip_ban_todo !== null) {
            $subject = do_lang('AUTO_BAN_SUBJECT', $ip, null, null, get_site_default_lang());
            dispatch_notification('core_staff:auto_ban', null, $subject, $ip_ban_todo, null, A_FROM_SYSTEM_PRIVILEGED);
        }
    }

    // Finish...

    if ($silent_to_user) {
        return;
    }

    if ((function_exists('do_lang')) && (!$GLOBALS['BOOTSTRAPPING'])) {
        if ($GLOBALS['DEV_MODE']) {
            fatal_exit(do_lang('HACK_ATTACK'));
        }
        warn_exit(do_lang_tempcode('HACK_ATTACK_USER'));
    }

    require_code('critical_errors');
    critical_error('EMERGENCY', 'Suspected hack attempt averted');
}

/**
 * Find if an IP is unbannable due to mapping to a known bot-DNS.
 *
 * @param  IP $ip The IP address
 * @return boolean Whether it is unbannable
 */
function is_unbannable_bot_dns(string $ip) : bool
{
    $dns = cms_gethostbyaddr($ip);
    if ($dns == $ip) {
        return false;
    }

    $resolved = cms_gethostbyname($dns);

    if (($resolved === $ip) || ($resolved == $dns)) { // Verify it's not faking the DNS (either it resolves back, or there's no forward resolution on the domain name anyway)
        $dns_lists = [
            get_file_base() . '/text/unbannable_dns.txt',
            get_file_base() . '/text_custom/unbannable_dns.txt',
        ];

        foreach ($dns_lists as $dns_list) {
            if (is_file($dns_list)) {
                $dns_list_array = cms_file_safe($dns_list);
                foreach ($dns_list_array as $_dns_suffix) {
                    if (trim($_dns_suffix) === '') {
                        continue;
                    }

                    if (substr($dns, -strlen($_dns_suffix) - 1) == '.' . $_dns_suffix) {
                        return true;
                    }
                }
            }
        }
    }

    return false;
}

/**
 * Find if an IP is unbannable due to being a known bot-IP.
 *
 * @param  IP $ip The IP address
 * @return boolean Whether it is unbannable
 */
function is_unbannable_bot_ip(string $ip) : bool
{
    $ip_lists = [
        get_file_base() . '/text/unbannable_ips.txt',
        get_file_base() . '/text_custom/unbannable_ips.txt',
    ];

    $ip_stack = [];
    $ip_bits = explode((strpos($ip, '.') !== false) ? '.' : ':', $ip);
    foreach ($ip_bits as $i => $ip_bit) {
        $buildup = '';
        for ($j = 0; $j <= $i; $j++) {
            if ($buildup != '') {
                $buildup .= (strpos($ip, '.') !== false) ? '.' : ':';
            }
            $buildup .= $ip_bits[$j];
        }
        $ip_stack[] = $buildup;
    }

    foreach ($ip_lists as $ip_list) {
        if (is_file($ip_list)) {
            $ip_list_array = cms_file_safe($ip_list);
            foreach ($ip_stack as $ip_s) {
                foreach ($ip_list_array as $_ip_list_array) {
                    if (trim($_ip_list_array) === '') {
                        continue;
                    }

                    if (((strpos($ip_s, '/') !== false) && (function_exists('ip_cidr_check')) && (ip_cidr_check($ip_s, $_ip_list_array))) || ($ip_s == $_ip_list_array)) {
                        return true;
                    }
                }
            }
        }
    }

    return false;
}

/**
 * Add an IP-ban.
 *
 * @param  IP $ip The IP address to ban (potentially encoded with *'s)
 * @param  LONG_TEXT $descrip Explanation for ban
 * @param  ?TIME $ban_until When to ban until (null: no limit)
 * @param  boolean $ban_positive Whether this is a positive ban (as opposed to a cached negative)
 * @param  boolean $check_caching Whether to check internal run-time caching (disable if doing automated tests)
 * @return boolean Whether a change actually happened
 */
function add_ip_ban(string $ip, string $descrip = '', ?int $ban_until = null, bool $ban_positive = true, bool $check_caching = true) : bool
{
    // Edge case: No securitylogging addon
    if (!addon_installed('securitylogging')) {
        return false;
    }

    // Edge case: Invalid IP
    require_code('type_sanitisation');
    if (!is_valid_ip($ip, true)) {
        return false;
    }

    // Some reasons we cannot ban it?
    require_code('global4');
    $is_unbannable_existing = null;
    $ban_until_existing = null;
    $already_banned = ip_banned($ip, true, false, $is_unbannable_existing, $ban_until_existing, $check_caching);
    if ($is_unbannable_existing) {
        return false; // Don't allow automatically banning of what is marked as unbannable
    }
    if (($already_banned) && ($ban_until !== null) && (($ban_until_existing === null) || ($ban_until < $ban_until_existing))) {
        return false; // Don't allow automatically shortening of an existing ban period
    }

    // Ban it
    $GLOBALS['SITE_DB']->query_delete('banned_ip', ['ip' => $ip], '', 1);
    $GLOBALS['SITE_DB']->query_insert('banned_ip', ['ip' => $ip, 'i_descrip' => $descrip, 'i_ban_until' => $ban_until, 'i_ban_positive' => $ban_positive ? 1 : 0], false, true); // To stop weird race-like conditions
    persistent_cache_delete('IP_BANS');
    if ((cms_is_writable(get_file_base() . '/.htaccess')) && (is_null($ban_until)) && ($ban_positive)) {
        $contents = cms_file_get_contents_safe(get_file_base() . '/.htaccess', FILE_READ_UNIXIFIED_TEXT);
        $ip_cleaned = ip_wild_to_apache($ip);
        if (($ip_cleaned != '') && (stripos($contents, "\n" . 'Require not ip ' . $ip_cleaned) === false)) {
            require_code('files');

            $contents = str_ireplace('# Require not ip xxx.xx.x.x (leave this comment here!)', '# Require not ip xxx.xx.x.x (leave this comment here!)' . "\n" . 'Require not ip ' . $ip_cleaned, $contents);

            cms_file_put_contents_safe(get_file_base() . '/.htaccess', $contents, FILE_WRITE_FIX_PERMISSIONS | FILE_WRITE_SYNC_FILE);
        }
    }

    return true;
}

/**
 * Convert simple software wildcard syntax in IP addresses to Apache netmask syntax.
 *
 * @param  IP $ip The IP address (potentially encoded with *'s)
 * @return string The Apache-style IP
 */
function ip_wild_to_apache(string $ip) : string
{
    $ip = normalise_ip_address($ip, 4);
    if ($ip == '') {
        return '';
    }

    if (strpos($ip, '*') === false) {
        return $ip;
    }

    $ipv6 = (strpos($ip, ':') !== false);
    if ($ipv6) {
        $delimiter = ':';
        $bits_per_part = 16;
        $expected_blank_part = '0000';
    } else {
        $delimiter = '.';
        $bits_per_part = 8;
        $expected_blank_part = '0';
    }
    $parts = explode($delimiter, $ip);
    $ip_section = '';
    $range_bits = 0;
    foreach ($parts as $i => $part) {
        if ($i > 0) {
            $ip_section .= $delimiter;
        }
        if ($part == '*') {
            $ip_section .= $expected_blank_part;
        } else {
            $ip_section .= $part;
            $range_bits += $bits_per_part;
        }
    }
    return $ip_section . '/' . strval($range_bits);
}

/**
 * Remove an IP-ban.
 *
 * @param  IP $ip The IP address to unban (potentially encoded with *'s, although this will only unban an exact matching wildcard ban)
 */
function remove_ip_ban(string $ip)
{
    if (!addon_installed('securitylogging')) {
        return;
    }

    $GLOBALS['SITE_DB']->query_delete('banned_ip', ['ip' => $ip], '', 1);
    persistent_cache_delete('IP_BANS');
    if (cms_is_writable(get_file_base() . '/.htaccess')) {
        $contents = cms_file_get_contents_safe(get_file_base() . '/.htaccess', FILE_READ_UNIXIFIED_TEXT);
        $ip_cleaned = ip_wild_to_apache($ip);
        if ($ip_cleaned != '') {
            require_code('files');

            $contents = str_ireplace("\n" . 'Require not ip ' . $ip_cleaned . "\n", "\n", $contents);

            cms_file_put_contents_safe(get_file_base() . '/.htaccess', $contents, FILE_WRITE_FIX_PERMISSIONS | FILE_WRITE_SYNC_FILE);
        }
    }
    $GLOBALS['SITE_DB']->query_delete('hackattack', ['ip' => $ip]);
}

/**
 * Lookup error on the homesite, to see if there is more information.
 *
 * @param  mixed $error_message The error message (string or Tempcode)
 * @return ?string The result from the web service (null: no result)
 */
function get_webservice_result($error_message) : ?string
{
    if (get_base_url_hostname() == parse_url(get_brand_base_url(), PHP_URL_HOST)) {
        return null; // Don't look up on itself if this is the homesite
    }

    if (is_local_machine()) {
        return null; // In case of no Internet connection
    }

    if ((!function_exists('has_zone_access')) || (!has_zone_access(get_member(), 'adminzone'))) {
        return null; // No permission
    }

    require_code('files');
    require_code('files2');
    global $DONE_ONE_WEB_SERVICE;
    if ((isset($GLOBALS['DOWNLOAD_LEVEL']) && ($GLOBALS['DOWNLOAD_LEVEL'] > 0)) || ($DONE_ONE_WEB_SERVICE)) {
        return null;
    }
    $DONE_ONE_WEB_SERVICE = true;

    if (is_object($error_message)) {
        $error_message = $error_message->evaluate();
    }

    if ($GLOBALS['HTTP_STATUS_CODE'] == 401) {
        return null;
    }

    // Get message IN ENGLISH
    if (user_lang() != fallback_lang()) {
        global $LANGUAGE_STRINGS_CACHE;
        foreach ($LANGUAGE_STRINGS_CACHE as $_) {
            foreach ($_ as $key => $val) {
                $regexp = preg_replace('#\\\{\d+\\\}#', '.*', preg_quote($val, '#'));
                if ($regexp != '.*') {
                    if (preg_match('#' . $regexp . '#', $error_message) != 0) {
                        $_error_message = do_lang($key, '', '', '', fallback_lang(), false);
                        if ($_error_message !== null) {
                            $error_message = $_error_message;
                        }
                        break;
                    }
                }
            }
        }
    }

    // Certain thing(s) are too common and should not result in queries
    if (strpos($error_message, 'was referenced') !== false) {
        return null;
    }

    // Talk to web service
    $brand = get_value('rebrand_name');
    if ($brand === null) {
        $brand = 'Composr';
    }

    require_code('version2');
    require_code('http');
    $url = get_brand_base_url() . '/uploads/website_specific/composr.app/scripts/errorservice.php?version=' . urlencode(get_version_dotted()) . '&error_message=' . urlencode($error_message) . '&product=' . urlencode($brand);
    list($http_result) = cache_and_carry('cms_http_request', [$url, ['convert_to_internal_encoding' => true, 'trigger_error' => false]], 60 * 24 * 31/*once a month*/);

    if (!is_object($http_result)) {
        return null;
    }

    if ($http_result->download_mime_type != 'text/plain') {
        return null;
    }

    if ($http_result->data == '') {
        return null;
    }
    if (function_exists('ocp_mark_as_escaped')) {
        ocp_mark_as_escaped($http_result->data);
    }
    return $http_result->data;
}

/**
 * Log an error message and send a notification about it (to site staff but not to the core developers).
 * Generally used when a web API fails.
 *
 * @param  string $errormsg A error message
 * @param  ID_TEXT $notification_category The error_occurred notification category
 */
function cms_error_log(string $errormsg, string $notification_category = 'error_occurred')
{
    if (php_function_allowed('error_log')) {
        @error_log($errormsg);
    }
    relay_error_notification(escape_html($errormsg), false, $notification_category);
}

/**
 * Relay an error message, if appropriate, to e-mail listeners (sometimes core developers, and site staff).
 *
 * @param  string $text A error message (in HTML)
 * @param  boolean $developers Also send to core developers
 * @param  ID_TEXT $notification_category The error_occurred notification category
 */
function relay_error_notification(string $text, bool $developers = true, string $notification_category = 'error_occurred')
{
    if (isset($GLOBALS['SENDING_MAIL']) && $GLOBALS['SENDING_MAIL']) {
        return;
    }

    if (!function_exists('require_lang')) {
        return;
    }

    // Make sure we don't send too many error e-mails
    $send_error_email = true;
    if ((function_exists('get_value')) && (!$GLOBALS['BOOTSTRAPPING']) && (array_key_exists('SITE_DB', $GLOBALS)) && ($GLOBALS['SITE_DB'] !== null)) {
        $num = intval(get_value('num_error_mails_' . date('Y-m-d'), null, true)) + 1;
        if ($num == 51) { // TODO: turn this into a value? #5399
            $send_error_email = false; // We've sent too many error e-mails today, but we might still want to send this to developers as we use fsock instead
        }
        $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'values_elective WHERE the_name LIKE \'' . db_encode_like('num\_error\_mails\_%') . '\'');
        persistent_cache_delete('VALUES');
        set_value('num_error_mails_' . date('Y-m-d'), strval($num), true);
    }

    require_code('urls');
    require_code('tempcode');

    $error_url = get_self_url_easy(true);

    global $BLOCK_CORE_DEVELOPERS_ERROR_EMAILS;

    require_code('notifications');
    require_code('comcode');
    $mail = do_notification_lang('ERROR_MAIL', comcode_escape($error_url), $text, $developers ? '?' : get_ip_address(), get_site_default_lang());
    if ($send_error_email) {
        dispatch_notification('error_occurred', $notification_category, do_lang('ERROR_OCCURRED_SUBJECT', get_page_or_script_name(), $developers ? '?' : get_ip_address(), null, get_site_default_lang()), $mail, null, A_FROM_SYSTEM_PRIVILEGED);
    }

    $error_message = strip_html(explode("\n\n", $text)[0]);

    if (
        ($mail !== null) &&
        ($developers) &&
        (get_option('send_error_emails_developers') == '1') &&
        (!$BLOCK_CORE_DEVELOPERS_ERROR_EMAILS) &&
        (!running_script('cron_bridge')) &&
        ($text != '!') &&
        (strpos($error_message, '_custom/') === false) &&
        (strpos($error_message, '_custom\\') === false) &&
        (strpos($error_message, 'FTP server error') === false) && // LDAP error, misconfiguration
        (strpos($error_message, 'Search: Operations error') === false) && // LDAP error, misconfiguration
        (strpos($error_message, 'Can\'t contact LDAP server') === false) && // LDAP error, network issue
        (strpos($error_message, 'Unknown: failed to open stream') === false) && // Comes up on some free webhosts
        (strpos($error_message, 'failed with: Connection refused') === false) && // Memcache error
        (strpos($error_message, 'data/commandr.php') === false) && // Could be a user input error
        (strpos($error_message, '.less problem') === false) &&
        (strpos($error_message, '/mini') === false) &&
        (strpos($error_message, 'A transaction for the wrong IPN e-mail went through') === false) &&
        (strpos($error_message, 'XCache var cache was not initialized properly') === false) && // Cache issue
        (strpos($error_message, 'has been disabled for security reasons') === false) &&
        (strpos($error_message, 'max_questions')/*mysql limit*/ === false) &&
        (strpos($error_message, 'Error at offset') === false) &&
        (strpos($error_message, 'expects parameter 1 to be a valid path, string given') === false) && // Misconfigured path or URL
        (strpos($error_message, 'gd-png: fatal libpng error') === false) && // PHP extension error
        (strpos($error_message, 'No word lists can be found for the language &quot;en&quot;') === false) && // EN is the default, so almost certainly a misconfiguration
        (strpos($error_message, 'Unable to allocate memory for pool') === false) &&
        (strpos($error_message, 'Out of memory') === false) &&
        (strpos($error_message, 'Can\'t open file') === false) &&
        (strpos($error_message, 'INSERT command denied to user') === false) && // Locked out database
        (strpos($error_message, 'Disk is full writing') === false) &&
        (strpos($error_message, 'Disk quota exceeded') === false) &&
        (strpos($error_message, 'Lock wait timeout exceeded') === false) &&
        (strpos($error_message, 'No space left on device') === false) &&
        (strpos($error_message, 'from storage engine') === false) &&
        (strpos($error_message, 'Lost connection to MySQL server') === false) &&
        (strpos($error_message, 'The SELECT would examine more than MAX_JOIN_SIZE rows') === false) &&
        (strpos($error_message, 'Unable to save result set') === false) &&
        (strpos($error_message, 'Deadlock found when trying to get lock; try restarting transaction') === false) &&
        (strpos($error_message, 'MySQL client ran out of memory') === false) &&
        (strpos($error_message, 'Server shutdown in progress') === false) &&
        (strpos($error_message, '.MAI') === false) && // MariaDB
        (strpos($error_message, '.MAD') === false) && // MariaDB
        (strpos($error_message, '.MYI') === false) && // MySQL
        (strpos($error_message, '.MYD') === false) && // MySQL
        (strpos($error_message, 'syntax error, unexpected') === false) && // MySQL full-text parsing error
        (strpos($error_message, 'MySQL server has gone away') === false) &&
        (strpos($error_message, 'Incorrect key file') === false) &&
        (strpos($error_message, 'Too many connections') === false) &&
        (strpos($error_message, 'duplicate key in table') === false) &&
        (strpos($error_message, 'Incorrect string value') === false) &&
        (strpos($error_message, 'Too many words in a FTS phrase or proximity search') === false) &&
        (strpos($error_message, 'Can\'t create/write to file') === false) &&  // MySQL
        (strpos($error_message, 'Error writing file') === false) && // E.g. cannot PHP create a temporary file
        (strpos($error_message, 'possibly out of free disk space') === false) &&
        (strpos($error_message, 'Illegal mix of collations') === false) &&
        (strpos($error_message, 'Query execution was interrupted') === false) &&
        (strpos($error_message, 'The MySQL server is running with the --read-only option so it cannot execute this statement') === false) &&
        (strpos($error_message, 'marked as crashed and should be repaired') === false) && // Can be fixed with the database repair tool
        (strpos($error_message, 'Can\'t find record in') === false) &&
        (strpos($error_message, 'connect to') === false) &&
        (strpos($error_message, 'Access denied for') === false) &&
        (strpos($error_message, 'command denied for') === false) && // MySQL
        (strpos($error_message, 'was deadlocked on lock resources with another process') === false) && // SQL Server
        (strpos($error_message, 'Unknown database') === false) &&
        (strpos($error_message, 'headers already sent') === false) &&
        (strpos($error_message, 'Your TaxCloud API trial period has expired') === false) &&
        (strpos($error_message, 'Resource temporarily unavailable') === false) &&
        (strpos($error_message, 'Broken pipe') === false) &&
        (strpos($error_message, 'Interrupted system call') === false) &&
        (preg_match('#php\.net.*SSL3_GET_SERVER_CERTIFICATE:certificate #', $error_message) == 0) && // Missing certificates on server
        (preg_match('#Maximum execution time of \d+ seconds#', $error_message) == 0) &&
        (preg_match('#Out of memory \(allocated (1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|18|19|20|21|22|23|24)\d{6}\)#', $error_message) == 0) &&
        (strpos($error_message, 'is marked as crashed and last') === false) &&
        (strpos($error_message, 'failed to open stream: Permission denied') === false) &&
        ((strpos($error_message, 'Maximum execution time') === false) || ((strpos($error_message, '/js_') === false) && (strpos($error_message, '/caches_filesystem.php') === false) && (strpos($error_message, '/files2.php') === false))) &&
        ((strpos($error_message, 'doesn\'t exist') === false) || ((strpos($error_message, 'import') === false))) &&
        ((strpos($error_message, 'No such file or directory') === false) || ((strpos($error_message, 'admin_setupwizard') === false))) &&
        (strpos($error_message, 'File(/tmp/) is not within the allowed path') === false) &&
        (preg_match('#Could not convert -?\d+(\.\d+)?#', $error_message) == 0) && // Currency conversion; likely no API key was set up
        (strpos($error_message, 'Cannot write to ') === false) &&
        (strpos($error_message, 'telemetry: ') === false)
    ) {
        // Send the error securely to the core developers (telemetry) using an encrypted raw fsock request
        require_code('encryption');

        if (is_encryption_enabled_telemetry()) {
            require_code('version');
            $__payload = [
                'website_url' => get_base_url(),
                'error_message' => strip_comcode($mail),
                'version' => cms_version_pretty(), // Encrypted and contains full version
            ];
            $_payload = encrypt_data_telemetry(serialize($__payload));
            $_payload['version'] = cms_version_number(); // Decrypted major/minor for use in determining which key pair to use
            $payload = json_encode($_payload);

            if ($payload === false) {
                cms_error_log(brand_name() . ' telemetry: WARNING Failed to JSON encode the error to send to the developers.');
            } else {
                $url = get_brand_base_url() . '/data_custom/composr_homesite_web_service.php?call=relay_error_notification';
                $error_code = null;
                $error_message = '';
                $response = cms_fsock_request($payload, $url, $error_code, $error_message);
                if (($response === null) || ($error_message != '')) {
                    cms_error_log(brand_name() . ' telemetry: WARNING Could not forward error to the developers. ' . $error_message . (($response === null) ? '' : escape_html($response)));
                }
                $matches = [];
                if (preg_match('#\srelayed_error_id=(\d*)\s#', $response, $matches) != 0) {
                    if ((php_function_allowed('error_log')) && (file_exists(get_custom_file_base() . '/data_custom/errorlog.php')) && (cms_is_writable(get_custom_file_base() . '/data_custom/errorlog.php'))) {
                        @error_log('TELEMETRY ' . strval($matches[1]) . "\n", 3, get_file_base() . '/data_custom/errorlog.php');
                    }
                }
            }
        }
    }
    if (($developers) && (get_value('agency_email_address') !== null) && ($send_error_email)) {
        require_code('mail');
        $agency_email_address = get_value('agency_email_address');
        dispatch_mail(cms_version_pretty() . ': ' . do_lang('ERROR_OCCURRED_SUBJECT', get_page_or_script_name(), null, null, get_site_default_lang()), $mail, [$agency_email_address], '', '', '', ['no_cc' => true, 'as_admin' => true]);
    }
}

/**
 * Find whether the current user may see stack traces.
 *
 * @return boolean Whether the current user may see stack traces
 */
function may_see_stack_traces() : bool
{
    if ($GLOBALS['CURRENT_SHARE_USER'] !== null) {
        return true; // Demonstratr exception
    }
    if ((function_exists('is_cli')) && (is_cli())) {
        return true;
    }
    if ((function_exists('running_script')) && (running_script('upgrader'))) {
        return true;
    }
    if (!function_exists('get_member')) {
        return false;
    }
    if (!function_exists('has_privilege')) {
        return false;
    }
    if ($GLOBALS['IS_ACTUALLY_ADMIN']) {
        return true;
    }

    return ($GLOBALS['DEV_MODE']) || (has_privilege(get_member(), 'see_stack_trace'));
}

/**
 * Echo an error message, and a debug back-trace of the current execution stack. Use this for debugging purposes.
 *
 * @param  string $message An error message
 */
function die_html_trace(string $message)
{
    $_trace = debug_backtrace();
    $trace = '<div class="box guid-{_GUID}"><div class="box-inner"><h2>Stack trace&hellip;</h2>';
    foreach ($_trace as $i => $stage) {
        if ($i > 20) {
            break;
        }

        $traces = '';
        foreach ($stage as $key => $value) {
            $_value = put_value_in_stack_trace($value);

            global $SITE_INFO;
            if ((isset($SITE_INFO['db_site_password'])) && (strlen($SITE_INFO['db_site_password']) > 4)) {
                $_value = str_replace($SITE_INFO['db_site_password'], '(password removed)', $_value);
            }
            if ((isset($SITE_INFO['db_forums_password'])) && (strlen($SITE_INFO['db_forums_password']) > 4)) {
                $_value = str_replace($SITE_INFO['db_forums_password'], '(password removed)', $_value);
            }

            $traces .= cms_ucfirst_ascii($key) . ' -> ' . $_value . '<br />' . "\n";
        }
        $trace .= '<p>' . $traces . '</p>' . "\n";
    }
    $trace .= '</div></div>';

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

    critical_error('EMERGENCY', $message . $trace);
}

/**
 * Prepare a value for display in a stack trace.
 *
 * @param  mixed $value Complex value
 * @return string String version
 */
function put_value_in_stack_trace($value) : string
{
    try {
        if (($value === null) || (is_array($value) && (strlen(json_encode($value)) > MAX_STACK_TRACE_VALUE_LENGTH))) {
            $_value = gettype($value);
        } elseif (is_object($value) && (is_a($value, 'Tempcode'))) {
            $max_bytes = 500;
            if ($value->is_smaller_than($max_bytes)) { // Don't display Tempcode if it's really long, and would use too much memory/space
                $_value = 'Tempcode -> ...';
            } else {
                $_value = $value->evaluate();
                if (!is_string($_value)) {
                    $_value = 'Tempcode -> ' . gettype($_value);
                } else {
                    $_value = 'Tempcode -> ' . $_value;
                }
            }
        } elseif ((is_array($value)) || (is_object($value))) {
            $_value = json_encode($value);
        } elseif (is_string($value)) {
            $_value = '\'' . php_addslashes($value) . '\'';
        } elseif (is_float($value)) {
            $_value = float_to_raw_string($value);
        } elseif (is_integer($value)) {
            $_value = integer_format($value);
        } elseif (is_bool($value)) {
            $_value = $value ? 'true' : 'false';
        } else {
            $_value = strval($value);
        }
    } catch (Exception $e) { // Can happen for SimpleXMLElement or PDO
        $_value = '...';
    }

    global $SITE_INFO;
    if ((isset($SITE_INFO['db_site_password'])) && (strlen($SITE_INFO['db_site_password']) > 4)) {
        $_value = str_replace($SITE_INFO['db_site_password'], '(password removed)', $_value);
    }
    if ((isset($SITE_INFO['db_forums_password'])) && (strlen($SITE_INFO['db_forums_password']) > 4)) {
        $_value = str_replace($SITE_INFO['db_forums_password'], '(password removed)', $_value);
    }

    return escape_html($_value);
}

/**
 * Return a debugging back-trace of the current execution stack. Use this for debugging purposes.
 *
 * @return Tempcode Debugging backtrace
 */
function get_html_trace() : object
{
    require_code('templates');

    push_suppress_error_death(true);
    $_trace = debug_backtrace();
    $trace = [];
    foreach ($_trace as $i => $stage) {
        $traces = [];
        //if (in_array($stage['function'], ['get_html_trace', 'cms_error_handler', 'fatal_exit'])) continue;  Hinders more than helps
        $file = '';
        $line = '';
        $_value = mixed();
        $__value = mixed();
        foreach ($stage as $key => $__value) {
            if ($key == 'file') {
                $file = str_replace('\'', '', $__value);
            } elseif ($key == 'line') {
                $line = strval($__value);
            }

            if ($key == 'args') {
                $_value = new Tempcode();
                foreach ($__value as $param) {
                    if (!((is_array($param)) && (array_key_exists('GLOBALS', $param)))) { // Some versions of PHP give the full environment as parameters. This will cause a recursive issue when outputting due to GLOBALS->ENV chaining.
                        $_value->attach(paragraph(put_value_in_stack_trace($param)));
                    }
                }
            } else {
                $_value = put_value_in_stack_trace($__value);
            }

            $traces[] = ['LINE' => $line, 'FILE' => $file, 'KEY' => cms_ucfirst_ascii($key), 'VALUE' => $_value];
        }
        $trace[] = ['TRACES' => $traces];
    }
    pop_suppress_error_death();

    $post = [];
    if (count($_POST) < 200) {
        foreach ($_POST as $key => $val) {
            if (is_integer($key)) {
                $key = strval($key);
            }

            if (is_password_field($key)) {
                continue;
            }

            $post[$key] = $val;
        }
    }

    return do_template('STACK_TRACE', ['_GUID' => '9620695fb8c3e411a6a4926432cea64f', 'POST' => $post, 'TRACE' => $trace]);
}

/**
 * See if a match-key message affects the error context we are in. May also internally trigger a redirect.
 *
 * @param  string $natural_text Message screen text that is about to be displayed
 * @param  boolean $only_if_zone Only if it is a zone-level match-key
 * @param  boolean $only_text_match Whether to only consider text matches, not match-key matches
 * @return ?Tempcode The message (null: no change)
 * @ignore
 */
function _look_for_match_key_message(string $natural_text, bool $only_if_zone = false, bool $only_text_match = false) : ?object
{
    // Protect against potentially fatal loops if there's an error with match_key_messages
    static $done_once = false;
    if ($done_once) {
        return null;
    }
    $done_once = true;

    if (!isset($GLOBALS['SITE_DB'])) {
        return null;
    }
    $match_keys = $GLOBALS['SITE_DB']->query_select('match_key_messages', ['*']);
    sort_maps_by__strlen($match_keys, 'k_match_key');
    $match_keys = array_reverse($match_keys);
    foreach ($match_keys as $match_key) {
        if ($only_if_zone) {
            if ((substr($match_key['k_match_key'], -6) != ':_WILD') && (substr($match_key['k_match_key'], -2) != ':*')) {
                continue;
            }
        }

        $pass = false;

        $matches = [];
        if (preg_match('#^((.*) )?"(.*)"$#', $match_key['k_match_key'], $matches) != 0) {
            if (strpos($natural_text, $matches[3]) !== false) {
                if ($matches[1] == '') {
                    $pass = true;
                } else {
                    $pass = match_key_match($matches[2]); // An AND condition essentially
                }
            }
        } else {
            if (!$only_text_match) {
                if (match_key_match($match_key['k_match_key'])) {
                    $pass = true;
                }
            }
        }

        if ($pass) {
            $message_raw = get_translated_text($match_key['k_message']);
            $message = get_translated_tempcode('match_key_messages', $match_key, 'k_message');

            if ($message->evaluate() == '') {
                continue;
            }

            // Maybe it is actually a redirect
            if ((strpos($message_raw, "\n") === false) && (strpos($message_raw, ' ') === false)) {
                if (preg_match('#^https?://#', $message_raw) != 0) { // Looks like a URL
                    $url = $message_raw;
                    require_code('site2');
                    assign_refresh($url, 0.0); // redirect_screen not used because there is already a legitimate output screen happening
                    $message = do_lang_tempcode('_REDIRECTING');
                } elseif (preg_match('#^[' . URL_CONTENT_REGEXP . ']*:[' . URL_CONTENT_REGEXP . ']*#', $message_raw) != 0) { // Looks like a page-link
                    list($zone, $map, $hash) = page_link_decode($message_raw);
                    if ((isset($map['error_message'])) && ($map['error_message'] == '')) {
                        $map['error_message'] = $natural_text;
                    }
                    $url = static_evaluate_tempcode(build_url($map, $zone, [], false, false, false, $hash));
                    require_code('site2');
                    assign_refresh($url, 0.0); // redirect_screen not used because there is already a legitimate output screen happening
                    $message = do_lang_tempcode('_REDIRECTING');
                }
            }

            return $message;
        }
    }
    return null;
}

/**
 * Show a helpful access-denied page. Has a login ability if it senses that logging in could curtail the error.
 * Note that this function should only be used for cases where access is being denied based on credentials, not for when access is blocked for other reasons (such as to enforce flow security). warn_exit(do_lang_tempcode('INTERNAL_ERROR')) is for those situations, or log_hack_attack_and_exit.
 *
 * @param  ID_TEXT $class The class of error (e.g. PRIVILEGE)
 * @param  string $param The parameter given to the error message
 * @param  boolean $force_login Force the user to login (even if perhaps they are logged in already)
 * @ignore
 */
function _access_denied(string $class, string $param, bool $force_login)
{
    require_code('global3');
    set_http_status_code(401); // Stop spiders ever storing the URL that caused this

    if ((running_script('messages')) && (get_param_string('action', 'new') == 'new') && (addon_installed('chat'))) { // FUDGE: Architecturally hackerish chat erroring. We do this as a session may have expired while the background message checker is running (e.g. after a computer unsuspend) and we don't want to leave it doing relatively intensive access-denied pages responses
        require_code('chat_poller');
        chat_null_exit();
    }

    require_lang('permissions');
    require_lang('cns_config');

    if (strpos($class, ' ') !== false) {
        $message = make_string_tempcode($class);
    } else {
        if ($class == 'PRIVILEGE') {
            $param = do_lang('PRIVILEGE_' . $param);
        }
        $message = do_lang_tempcode('ACCESS_DENIED__' . $class, escape_html($GLOBALS['FORUM_DRIVER']->get_username(get_member())), escape_html($param));
    }

    $_message = _look_for_match_key_message($message->evaluate(), strpos($class, 'ZONE') !== false);
    if ($_message !== null) {
        $message = $_message;
    }

    // Run hooks, if any exist
    $hooks = find_all_hook_obs('systems', 'upon_access_denied', 'Hook_upon_access_denied_');
    foreach ($hooks as $ob) {
        $ob->run($class, $param, $force_login);
    }

    if (throwing_errors()) {
        throw new CMSException($message);
    }

    if (($GLOBALS['IS_ACTUALLY_ADMIN']) && (get_param_integer('keep_fatalistic', 0) != 0)) {
        fatal_exit($message);
    }

    if (((is_guest()) && ((running_script('attachment')) || (running_script('dload')) || (running_script('index')))) || ($force_login)) { // Show login screen if appropriate
        // We do want to supply a nice login screen for attachment/dload scripts because they are sometimes externally linked to (e.g. in e-mails or hotlinks)
        // Otherwise we want flat access denied due to a flat request/response model
        // NB: Also see similar running_script lines in globalise function

        cms_ob_end_clean(); // Emergency output, potentially, so kill off any active buffer

        $redirect = get_self_url(true, false);

        require_code('users_active_actions');
        $middle = generate_login_screen(get_screen_title('_LOGIN'), '_self', $redirect);

        require_code('site');
        if ((get_value('no_tech_login_messages') !== '1') && (!is_guest())) {
            attach_message($message, 'warn');
        }
        $echo = globalise($middle, null, '', true);
        $echo->evaluate_echo();
        exit();
    }

    warn_exit($message); // Or if no login screen, just show normal error screen
}

/**
 * Show a ban screen.
 *
 * @param  ?string $reasoned_ban The reasoned ban type (null: none)
 * @exits
 */
function banned_exit(?string $reasoned_ban = null)
{
    $text = do_lang_tempcode('YOU_ARE_BANNED');

    if ($reasoned_ban !== null) {
        require_code('input_filter');
        list(, $reasoned_bans) = load_advanced_banning();
        if (array_key_exists($reasoned_ban, $reasoned_bans)) {
            $_reasoned_ban = $reasoned_bans[$reasoned_ban];

            $http_status = $_reasoned_ban['http_status'];

            if ($_reasoned_ban['message'] !== null) {
                $text = comcode_to_tempcode(str_replace('{IP_ADDRESS}', get_ip_address(), $_reasoned_ban['message']), null, true);
            }

            $_GET['wide_high'] = '1'; // FUDGE

            if ($_reasoned_ban['redirect_url'] !== null) {
                $title = get_screen_title($_reasoned_ban['title'], false);
                redirect_exit($_reasoned_ban['redirect_url'], $title, $text);
            }

            if (!empty($_reasoned_ban['title'])) {
                $title = get_screen_title($_reasoned_ban['title'], false);
            } else {
                $title = null;
            }

            $image_url = $_reasoned_ban['image_url'];
            if ($image_url !== null) {
                if (url_is_local($image_url)) {
                    $image_url = get_custom_base_url() . '/' . $image_url;
                }
            }

            warn_exit($text, false, false, $http_status, $title, $image_url);
        }
    }

    warn_exit($text);
}

/**
 * Specify if errors from PHP and the software should all be thrown as exceptions rather than resulting in HTML exit screens.
 *
 * @param  boolean $_throwing_errors Whether we should throw errors
 */
function set_throw_errors(bool $_throwing_errors = true)
{
    global $THROWING_ERRORS;
    $THROWING_ERRORS = $_throwing_errors;
}

/**
 * Find whether we should throw errors, rather than create HTML exit screens with the error messages / correction screens.
 *
 * @return boolean Whether to are throwing errors
 */
function throwing_errors() : bool
{
    global $THROWING_ERRORS;
    return $THROWING_ERRORS;
}

/**
 * A software exception.
 *
 * @package core
 */
class CMSException extends Exception
{
    /**
     * Constructor.
     *
     * @param  mixed $msg Error message (Tempcode containing HTML, or string containing non-HTML)
     */
    public function __construct($msg)
    {
        if (is_object($msg)) {
            $msg = strip_html($msg->evaluate());
        }

        parent::__construct($msg);
    }
}
