'A', 'b' => 'B', 'c' => 'C', 'd' => 'D', 'e' => 'E', 'f' => 'F', 'g' => 'G', 'h' => 'H', 'i' => 'I', 'j' => 'J', 'k' => 'K', 'l' => 'L', 'm' => 'M', 'n' => 'N', 'o' => 'O', 'p' => 'P', 'q' => 'Q', 'r' => 'R', 's' => 'S', 't' => 'T', 'u' => 'U', 'v' => 'V', 'w' => 'W', 'x' => 'X', 'y' => 'Y', 'z' => 'Z']; $ASCII_UCASE_MAP = ['A' => 'a', 'B' => 'b', 'C' => 'c', 'D' => 'd', 'E' => 'e', 'F' => 'f', 'G' => 'g', 'H' => 'h', 'I' => 'i', 'J' => 'j', 'K' => 'k', 'L' => 'l', 'M' => 'm', 'N' => 'n', 'O' => 'o', 'P' => 'p', 'Q' => 'q', 'R' => 'r', 'S' => 's', 'T' => 't', 'U' => 'u', 'V' => 'v', 'W' => 'w', 'X' => 'x', 'Y' => 'y', 'Z' => 'z']; global $FORCE_IMMEDIATE_LOG_IT; $FORCE_IMMEDIATE_LOG_IT = []; } /** * Find the base URL for documentation. * * @return URLPATH The base URL for documentation */ function get_brand_base_url() : string { $brand_url = function_exists('get_value') ? get_value('rebrand_base_url') : null; if (empty($brand_url)) { $brand_url = DEFAULT_BRAND_URL; } return $brand_url; } /** * Get a URL to a software tutorial. * * @param ?ID_TEXT $tutorial Name of a tutorial (null: don't include the page part) * @return URLPATH URL to a tutorial */ function get_tutorial_url(?string $tutorial) : string { $ret = get_brand_page_url(['page' => ($tutorial === null) ? 'abcdef' : $tutorial], 'docs' . strval(cms_version())); if ($tutorial === null) { $ret = str_replace('abcdef.htm', '', $ret); } return $ret; } /** * Get a URL to a homesite page. * * @param array $params URL map * @param ID_TEXT $zone Zone * @return URLPATH URL to page */ function get_brand_page_url(array $params, string $zone) : string { // Assumes brand site supports .htm URLs, which it should $base = get_brand_base_url() . (($zone == '') ? '' : '/') . $zone . '/'; if (isset($params['page'])) { $base .= urlencode(str_replace('_', '-', $params['page'])); if (isset($params['type'])) { $base .= '/' . urlencode(str_replace('_', '-', $params['type'])); if (isset($params['id'])) { $base .= '/' . urlencode(str_replace('_', '-', $params['id'])) . '.htm'; unset($params['id']); } else { $base .= '.htm'; } unset($params['type']); } else { $base .= '.htm'; } unset($params['page']); } else { $base .= '.htm'; } $query_string = ''; foreach ($params as $k => $v) { if ($query_string == '') { $query_string .= '?'; } else { $query_string .= '&'; } $query_string .= $k . '=' . cms_urlencode($v); } return $base . $query_string; } /** * Get the brand name. * * @return string The brand name */ function brand_name() : string { $brand_name = function_exists('get_value') ? get_value('rebrand_name', DEFAULT_BRAND_NAME) : DEFAULT_BRAND_NAME; return $brand_name; } /** * Get the file extension of the specified file. It returns without a dot. File extensions are considered never to themselves contain a dot. * * @param ?string $name The filename (null: unknown) * @param ?string $mime_type The mime-type (null: unknown) * @return string The filename extension (no dot) */ function get_file_extension(?string $name, ?string $mime_type = null) : string { // Get mime extension if we can if ($mime_type !== null) { require_code('mime_types'); $ext = get_ext_from_mime_type($mime_type); if ($ext !== null) { return $ext; } } if ($name === null) { return ''; } // Check if we have a dot $dot_pos = strrpos($name, '.'); if ($dot_pos === false) { return ''; } // Get everything after the last dot $parts = explode('.', $name); $ext = array_pop($parts); // Path separators after the last dot mean what we are looking at is actually not a file (or a file with no extension) if (strpos($ext, '/') !== false) { return ''; } if (strpos($ext, '\\') !== false) { return ''; } // If any query-string type characters exist, remove it plus everything after. $bad_parts = ['?', '&', '#']; foreach ($bad_parts as $part) { if (strpos($ext, $part) !== false) { $ext = substr($ext, 0, strpos($ext, $part)); } } return cms_strtolower_ascii($ext); } /** * Find whether we are using natural file-owner-based access for PHP. * Such access avoids us having to be messing with AFMs, world-writability, etc. * Note that this says nothing about what user the web server is running as, just what PHP is running as (those are not always the same). * Always will return false on Windows due to missing Posix - but there's no such thing as chmodding files for non-owners on Windows either. * * @return boolean Whether we have this */ function is_suexec_like() : bool { if (running_script('webdav')) { return true; // Has to assume so, as cannot intercede } if (GOOGLE_APPENGINE) { return false; } static $answer = null; if ($answer === null) { if (is_cli()) { $opts = getopt('', ['is_suexec_like::']); if (array_key_exists('is_suexec_like', $opts)) { $answer = true; } else { $answer = false; // As we cannot know } } else { $answer = ( (php_function_allowed('posix_getuid')) && (!isset($_SERVER['HTTP_X_MOSSO_DT'])) && (is_integer(@posix_getuid())) && (posix_getuid() == website_file_owner()) ) || (cms_is_writable(get_file_base() . (running_script('install') ? '/install.php' : '/sources/bootstrap.php'))); } } return $answer; } /** * Ensure that the specified file/folder is writeable for the FTP user (so that it can be deleted by the system), and should be called whenever a file is uploaded/created, or a folder is made. We call this function assuming we are giving world permissions. * * @param PATH $path The full pathname to the file/directory * @param ?integer $perms The permissions to make (not the permissions are reduced if the function finds that the file is owned by the web user [doesn't need world permissions then]) (null: default for file/dir) */ function fix_permissions(string $path, ?int $perms = null) { if ($perms === null) { $perms = is_dir($path) ? 0777 : 0666; } // If the file user is different to the web user, we need to make it world writeable if (!is_suexec_like()) { if ($perms == 0600) { @chmod($path, 0666); } else { @chmod($path, $perms); } } else { // Otherwise we do not if ($perms == 0666) { @chmod($path, 0644); } elseif ($perms == 0777) { @chmod($path, 0755); } else { @chmod($path, $perms); } } global $_CREATED_FILES; // From ocProducts PHP version, for development testing if (isset($_CREATED_FILES)) { foreach ($_CREATED_FILES as $i => $x) { if ($x == $path) { unset($_CREATED_FILES[$i]); } } } } /** * Reads entire file into an array. * Supports locking and character set conversion (using BOMs). * * @param PATH $path File path * @param ?string $default_charset The default character set if none is specified (null: website character set) * @return ~array The array (each line being an entry in the array, and newlines still attached) (false: error) */ function cms_file_safe(string $path, ?string $default_charset = null) { $c = cms_file_get_contents_safe($path, FILE_READ_LOCK | FILE_READ_BOM | FILE_READ_UNIXIFIED_TEXT, $default_charset); if ($c === false) { return false; } $lines = explode("\n", $c); return $lines; } /** * Get the contents of a file, with locking support. * * @param PATH $path File path * @param integer $flags FILE_READ_* flags * @param ?string $default_charset The default character set to assume if none is specified in the file (null: website character set) * @param ?integer $max_bytes Maximum number of bytes to read (null: read all bytes) * @return ~string File contents (false: error) */ function cms_file_get_contents_safe(string $path, int $flags = 0, ?string $default_charset = null, ?int $max_bytes = null) { $locking = ($flags & FILE_READ_LOCK) != 0; $handle_file_bom = ($flags & FILE_READ_BOM) != 0; $unixify_line_format = ($flags & FILE_READ_UNIXIFIED_TEXT) != 0; $tmp = @fopen($path, 'rb'); if ($tmp === false) { return false; } if ($locking) { flock($tmp, LOCK_SH); } $contents = stream_get_contents($tmp, ($max_bytes === null) ? -1 : $max_bytes); if ($locking) { flock($tmp, LOCK_UN); } fclose($tmp); if ($handle_file_bom) { $contents = handle_string_bom($contents, $default_charset); } if ($unixify_line_format) { $contents = unixify_line_format($contents); } return $contents; } /** * Find available byte-order-marks we support. * * @return array A map between character sets and BOM byte strings */ function _get_boms() : array { return [ 'utf-32' => hex2bin('fffe0000'), // LE, which is de facto standard that convert_to_internal_encoding assumes 'utf-32BE' => hex2bin('0000feff'), 'utf-16' => hex2bin('fffe'), // LE, which (...) 'utf-16BE' => hex2bin('feff'), 'utf-8' => hex2bin('efbbbf') , // No LE vs BE distinction for utf-8 'GB-18030' => hex2bin('84319533'), ]; } /** * Detect a BOM (Unicode byte-order-mark) from a string, and strip it. Return the altered string. * * @param string $contents Input string * @param ?string $default_charset The default character set to assume if none is specified in the input string (null: website character set) * @return string Altered string */ function handle_string_bom(string $contents, ?string $default_charset = null) : string { require_code('character_sets'); list($file_charset, $bom) = detect_string_bom($contents); if ($file_charset !== null) { $contents = substr($contents, strlen($bom)); } else { $file_charset = $default_charset; } $contents = convert_to_internal_encoding($contents, $file_charset); return $contents; } /** * Detect a BOM (Unicode byte-order-mark) from a string, and find the associated character set. * * @param string $contents Input string * @return array A pair: The character set (null is unknown), The BOM (null is unknown) */ function detect_string_bom(string $contents) : array { $file_charset = null; $bom_found = null; $boms = _get_boms(); $max_bom_len = 0; foreach ($boms as $bom) { if (strlen($bom) > $max_bom_len) { $max_bom_len = strlen($bom); } } $magic_data = substr($contents, 0, $max_bom_len); foreach ($boms as $charset => $bom) { if (substr($magic_data, 0, strlen($bom)) == $bom) { $file_charset = $charset; $bom_found = $bom; break; } } return [$file_charset, $bom_found]; } /** * Return the contents of the URL by downloading it over HTTP. If a byte limit is given, it will only download that many bytes. It outputs warnings, returning null, on error. * * @param URLPATH $url The URL to download * @param array $options Map of options (see the properties of the Source_HTTP_downloader class for what you may set) * @return ?string The data downloaded (null: error) */ function http_get_contents(string $url, array $options = []) : ?string { cms_profile_start_for('http_get_contents'); $ob = cms_http_request($url, $options); $ret = $ob->data; cms_profile_end_for('http_get_contents', $url); return $ret; } /** * Return the file in the URL by downloading it over HTTP. If a byte limit is given, it will only download that many bytes. It outputs warnings, returning null, on error. * * @param URLPATH $url The URL to download * @param array $options Map of options (see the properties of the Source_HTTP_downloader class for what you may set) * @return object Source_HTTP_downloader object, which can be checked for return data */ function cms_http_request(string $url, array $options = []) : object { require_code('http'); return _cms_http_request($url, $options); } /** * Make a low-level fsockopen request to an API. * Ideally, you should use a secure endpoint and encrypt the data to be sent using encrypt_message(). * * @param string $payload The payload to send as a JSON-encoded string (blank: make a GET request) * @param URLPATH $url The URL to call including the path and port * @param ?integer $error_code The error code returned (passed by reference) (null: No error) * @param string $error_message The error message returned (passed by reference) (blank: No error) * @param float $timeout The timeout in seconds * @return ?string response from the fsock (null: error) */ function cms_fsock_request(string $payload, string $url, ?int &$error_code = null, string &$error_message = '', float $timeout = 6.0) : ?string { cms_profile_start_for('cms_fsock_request'); require_code('encryption'); $hostname = cms_parse_url_safe($url, PHP_URL_HOST); if ($hostname === false) { return null; // NB: If any other calls would return false, this would have returned false } $fsock_hostname = $hostname; $port = cms_parse_url_safe($url, PHP_URL_PORT); if (is_encryption_available() && cms_parse_url_safe($url, PHP_URL_SCHEME) == 'https') { $fsock_hostname = 'tls://' . $hostname; } $path = cms_parse_url_safe($url, PHP_URL_PATH); $query = cms_parse_url_safe($url, PHP_URL_QUERY); if ($query != '') { $path .= '?' . $query; } $fsock = fsockopen($fsock_hostname, $port, $error_code, $error_message, $timeout); if ($fsock === false) { cms_profile_end_for('cms_fsock_request', 'COULD NOT OPEN; ' . $hostname . ':' . strval($port)); return null; } // Construct the HTTP request if ($payload != '') { $request = 'POST ' . $path . ' HTTP/1.1' . "\r\n"; $request .= 'Host: ' . $hostname . "\r\n"; $request .= 'Content-Type: application/json' . "\r\n"; $request .= 'Content-Length: ' . strval(strlen($payload)) . "\r\n"; $request .= 'Connection: close' . "\r\n\r\n"; $request .= $payload . "\r\n\r\n"; } else { $request = 'GET ' . $path . ' HTTP/1.1' . "\r\n"; $request .= 'Host: ' . $hostname . "\r\n"; $request .= 'Connection: close' . "\r\n\r\n"; } // Send the request $fwrite = fwrite($fsock, $request); if ($fwrite === false) { cms_profile_end_for('cms_fsock_request', 'COULD NOT WRITE; ' . $hostname . ':' . strval($port)); return null; } // Read the response $response = ''; while (!feof($fsock)) { $_response = fgets($fsock, 128); if (($_response === false) && ($response == '')) { // Could be false if there is nothing more to read, so this is only an error if we have no response data collected cms_profile_end_for('cms_fsock_request', 'COULD NOT READ; ' . $hostname . ':' . strval($port)); return null; } $response .= $_response; } fclose($fsock); cms_profile_end_for('cms_fsock_request', 'FINISHED; ' . $hostname . ':' . strval($port)); return $response; } /** * Find whether a file/directory is writeable. This function is designed to get past that the PHP is_writable function does not work properly on Windows. * * @param PATH $path The file path * @param boolean $aggressive Whether to perform an aggressive test (if necessary) where we actually try writing to the file * @return boolean Whether the file is writeable */ function cms_is_writable(string $path, bool $aggressive = false) : bool { if (cms_strtoupper_ascii(substr(PHP_OS, 0, 3)) != 'WIN') { return is_writable($path); } // Windows is more tricky... if (!file_exists($path)) { return false; } if (is_dir($path)) { /*if (false) { // ideal, but too dangerous as sometimes you can write files but not delete again $test = @fopen($path . '/cms.delete.me', 'wb'); if ($test !== false) { fclose($test); unlink($path . '/cms.delete.me'); return true; } return false; }*/ return is_writable($path); // imperfect unfortunately; but unlikely to cause a problem } $test = @fopen($path, 'a'); if ($test === false) { return false; } if ($aggressive) { $test_string = uniqid('writetest_', false); // Arbitrary data to write $bytes_written = @fwrite($test, $test_string); @fclose($test); // Clean-up if (is_numeric($bytes_written)) { $test = @fopen($path, 'r+'); if ($test === false) { fatal_exit('Could not clean up file ' . str_replace([get_custom_file_base() . '/', get_file_base() . '/'], ['', ''], $path) . ' after aggressive write test. You must manually remove ' . $test_string . ' from the end of this file immediately.'); } fseek($test, -$bytes_written, SEEK_END); $truncate = ftruncate($test, ftell($test)); // Truncate the file to remove test string if ($truncate === false) { fatal_exit('Could not clean up file ' . str_replace([get_custom_file_base() . '/', get_file_base() . '/'], ['', ''], $path) . ' after aggressive write test. You must manually remove ' . $test_string . ' from the end of this file immediately.'); } @fclose($test); } else { return false; } if (intval($bytes_written) == strlen($test_string)) { return true; } } else { @fclose($test); return true; } return false; } /** * Discern the cause of a file-write error, and show an appropriate error message. * * @param PATH $path File path that could not be written (full path, not relative) */ function intelligent_write_error(string $path) { require_code('files2'); _intelligent_write_error($path); } /** * Discern the cause of a file-write error, and return an appropriate error message. * * @param PATH $path File path that could not be written * @param boolean $force_hardcoded Whether to force a hard-coded error message, useful if we have not finished bootstrapping * @return mixed Message (string or Tempcode) */ function intelligent_write_error_inline(string $path, bool $force_hardcoded = false) { require_code('files2'); return _intelligent_write_error_inline($path, $force_hardcoded); } /** * Load a fresh output state. * * @sets_output_state * * @param boolean $just_tempcode Whether to only restore the Tempcode execution part of the state * @param boolean $true_blank Whether to go for a completely blank state (no defaults!), not just a default fresh state * @param boolean $initial Whether this is an initial state, and we can leave anything that is already set as set (handled on case-by-case basis) * * @ignore */ function _load_blank_output_state(bool $just_tempcode = false, bool $true_blank = false, bool $initial = false) { /* Now lots of stuff all relating to output state (unless commented, these GLOBALs should not be written to directly, we have API calls for it) */ if (!$just_tempcode) { global $HTTP_STATUS_CODE; /** Record of the HTTP status code being set. * * @sets_output_state * * @global string $HTTP_STATUS_CODE */ $HTTP_STATUS_CODE = 200; global $METADATA; $METADATA = []; global $ATTACHED_MESSAGES, $ATTACHED_MESSAGES_RAW, $LATE_ATTACHED_MESSAGES; if (!$initial || !isset($ATTACHED_MESSAGES)) { $ATTACHED_MESSAGES = null; } if (!$initial || !isset($ATTACHED_MESSAGES_RAW)) { /** Raw data of attached messages. * * @sets_output_state * * @global ?array $ATTACHED_MESSAGES_RAW */ $ATTACHED_MESSAGES_RAW = []; clear_infinite_loop_iterations('attach_message'); } $LATE_ATTACHED_MESSAGES = null; global $SEO_KEYWORDS, $SEO_DESCRIPTION, $SHORT_TITLE; $SEO_KEYWORDS = null; $SEO_DESCRIPTION = null; /** Shortened title to use only within header text and thus tag (if not set, $DISPLAYED_TITLE will be used). * * @sets_output_state * * @global ?string $SHORT_TITLE */ $SHORT_TITLE = null; global $BREADCRUMBS, $BREADCRUMB_SET_PARENTS, $DISPLAYED_TITLE, $FORCE_SET_TITLE, $BREADCRUMB_SET_SELF; $BREADCRUMBS = null; $BREADCRUMB_SET_PARENTS = []; /** The screen title that was set (i.e. <h1>). * * @sets_output_state * * @global ?string $DISPLAYED_TITLE */ $DISPLAYED_TITLE = null; $FORCE_SET_TITLE = false; $BREADCRUMB_SET_SELF = null; global $FEED_URLS; $FEED_URLS = []; global $REFRESH_URL, $FORCE_META_REFRESH, $QUICK_REDIRECT; $REFRESH_URL[0] = ''; $REFRESH_URL[1] = 0.0; $FORCE_META_REFRESH = false; $QUICK_REDIRECT = false; global $EXTRA_HEAD, $EXTRA_FOOT; $EXTRA_HEAD = null; $EXTRA_FOOT = null; global $HELPER_PANEL_TEXT, $HELPER_PANEL_TUTORIAL; $HELPER_PANEL_TEXT = ''; $HELPER_PANEL_TUTORIAL = ''; // Register basic CSS and JavaScript requirements global $JAVASCRIPT, $JAVASCRIPTS, $CSSS, $JAVASCRIPTS_DEFAULT; $JAVASCRIPT = null; /** List of required JavaScript files. * * @sets_output_state * * @global ?array $JAVASCRIPTS */ $JAVASCRIPTS = $true_blank ? [] : $JAVASCRIPTS_DEFAULT; /** List of required CSS files. * * @sets_output_state * * @global ?array $CSSS */ $CSSS = $true_blank ? [] : ['no_cache' => true, 'global' => true]; } global $CYCLES, $TEMPCODE_SETGET; /** Stores Tempcode CYCLE values during execution. * * @sets_output_state * * @global array $CYCLE */ $CYCLES = []; /** Stores Tempcode variable values during execution. * * @sets_output_state * * @global array $TEMPCODE_SETGET */ $TEMPCODE_SETGET = []; } /** * Push the output state on the stack and create a fresh one. * * @sets_output_state * * @param boolean $just_tempcode Whether to only restore the Tempcode execution part of the state * @param boolean $true_blank Whether to go for a completely blank state (no defaults!), not just a default fresh state */ function push_output_state(bool $just_tempcode = false, bool $true_blank = false) { global $OUTPUT_STATE_STACK, $OUTPUT_STATE_VARS; $current_state = []; foreach ($OUTPUT_STATE_VARS as $var) { $current_state[$var] = isset($GLOBALS[$var]) ? $GLOBALS[$var] : null; } array_push($OUTPUT_STATE_STACK, $current_state); _load_blank_output_state($just_tempcode, $true_blank); } /** * Restore the last output state on the stack, or a fresh one if none was pushed. * * @sets_output_state * * @param boolean $just_tempcode Whether to only restore the Tempcode execution part of the state * @param boolean $merge_current Whether to merge the current output state in (or take precedence when merging isn't applicable) * @param ?array $keep Settings to keep (not replace) / merge if possible (null: merge all) */ function restore_output_state(bool $just_tempcode = false, bool $merge_current = false, ?array $keep = null) { global $OUTPUT_STATE_STACK; $mergeable_arrays = ['METADATA' => true, 'JAVASCRIPTS' => true, 'CSSS' => true, 'TEMPCODE_SETGET' => true, 'CYCLES' => true, 'ATTACHED_MESSAGES_RAW' => true]; $mergeable_tempcode = ['EXTRA_HEAD' => true, 'EXTRA_FOOT' => true, 'JAVASCRIPT' => true, 'ATTACHED_MESSAGES' => true, 'LATE_ATTACHED_MESSAGES' => true]; $old_state = array_pop($OUTPUT_STATE_STACK); if ($old_state === null) { _load_blank_output_state($just_tempcode); } else { foreach ($old_state as $var => $val) { if ((!$just_tempcode) || ($var == 'CYCLES') || ($var == 'TEMPCODE_SETGET')) { $merge_array = (($merge_current) && (is_array($val)) && (isset($mergeable_arrays[$var]))); $merge_tempcode = (($merge_current) && (isset($val->codename/*faster than is_object*/, $mergeable_tempcode[$var]))); $mergeable = $merge_array || $merge_tempcode; if (($keep === null) || (!in_array($var, $keep)) || ($mergeable)) { if ($merge_array) { if ($GLOBALS[$var] === null) { $GLOBALS[$var] = []; } $GLOBALS[$var] = array_merge($val, $GLOBALS[$var]); } elseif ($merge_tempcode) { if ($GLOBALS[$var] === null) { $GLOBALS[$var] = new Tempcode(); } if ($val !== null) { $GLOBALS[$var]->attach($val); } } elseif ((!$merge_current) || (!isset($GLOBALS[$var])) || (cms_empty_safe($GLOBALS[$var])) || ($var == 'REFRESH_URL') || (($var == 'HTTP_STATUS_CODE') && ($GLOBALS['HTTP_STATUS_CODE'] == 200))) { $GLOBALS[$var] = $val; } } } } } } /** * Turn the Tempcode lump into a standalone page. * * @param Tempcode $middle The Tempcode to put into a nice frame * @param ?mixed $message 'Additional' message (null: none) * @param string $type The type of special message * @set inform warn "" * @param boolean $include_header_and_footer Whether to include the header/footer/panels * @param boolean $show_border Whether to include a full screen rendering layout (will be overridable by 'show_border' GET parameter if present or if main page view) * @return Tempcode Standalone page */ function globalise(object $middle, $message = null, string $type = '', bool $include_header_and_footer = false, bool $show_border = false) : object { if (!$include_header_and_footer) { // FUDGE $old = null; if (isset($_GET['wide_high'])) { $old = $_GET['wide_high']; } $_GET['wide_high'] = '1'; } require_code('site'); if ($message !== null) { attach_message($message, $type); } $show_border = (get_param_integer('show_border', $show_border ? 1 : 0) == 1); if (!$show_border && !running_script('index')) { $global = do_template('STANDALONE_HTML_WRAP', [ '_GUID' => 'fe818a6fb0870f0b211e8e52adb23f26', 'TITLE' => ($GLOBALS['DISPLAYED_TITLE'] === null) ? do_lang_tempcode('NA') : $GLOBALS['DISPLAYED_TITLE'], 'FRAME' => running_script('iframe'), 'TARGET' => '_self', 'CONTENT' => $middle, ]); $global->handle_symbol_preprocessing(); return $global; } global $TEMPCODE_CURRENT_PAGE_OUTPUTTING; global $DOING_OUTPUT_PINGS; if (headers_sent() && !$DOING_OUTPUT_PINGS) { $global = do_template('STANDALONE_HTML_WRAP', [ '_GUID' => 'd579b62182a0f815e0ead1daa5904793', 'TITLE' => ($GLOBALS['DISPLAYED_TITLE'] === null) ? do_lang_tempcode('NA') : $GLOBALS['DISPLAYED_TITLE'], 'FRAME' => false, 'TARGET' => '_self', 'CONTENT' => $middle, ]); } else { $global = do_template('GLOBAL_HTML_WRAP', [ '_GUID' => '592faa2c0e8bf2dc3492de2c11ca7131', 'MIDDLE' => $middle, ]); } $global->handle_symbol_preprocessing(); if ((!$include_header_and_footer) && ($old !== null)) { $_GET['wide_high'] = $old; } return $global; } /** * Attach some HTML to the screen footer. * * @sets_output_state * * @param mixed $data HTML to attach, provided in HTML format (string or Tempcode) */ function attach_to_screen_footer($data) { global $EXTRA_FOOT; if ($EXTRA_FOOT === null) { $EXTRA_FOOT = new Tempcode(); } $EXTRA_FOOT->attach($data); } /** * Add some metadata for the request. * It is taken by: * a) Getting directly from the passed $metadata. * b) Looking up the CMA hook for the given $content_type, and using that to dereference properties from $row. * * @sets_output_state * * @param array $metadata Extra metadata * @param ?array $row Content row to automatically grab data from, if we also have $content_type (null: unknown) * @param ?ID_TEXT $content_type Content type (null: unknown) * @param ?ID_TEXT $content_id Content ID, used for some cases of deeper probing (null: unknown) */ function set_extra_request_metadata(array $metadata, ?array $row = null, ?string $content_type = null, ?string $content_id = null) { // Add in specific data passed... // Pre-validation of stuff that may not be acceptable foreach ($metadata as $key => $val) { if ($val !== null) { require_code('templates'); $val = cms_trim($val); if ($val == '') { unset($metadata[$key]); } else { $metadata[$key] = $val; } } } if (isset($metadata['image'])) { if (!is_valid_opengraph_image($metadata['image'])) { unset($metadata['image']); // Cannot use it } } global $METADATA; $METADATA += $metadata; // First-set gets precedence. E.g. picture custom fields will only set 'image' if no valid image was found yet // Load up CMA hook... if ($content_type !== null) { require_code('content'); $cma_ob = get_content_object($content_type); if ($cma_ob !== null) { $cma_info = $cma_ob->info(); if ($cma_ob === null) { $content_type = null; } } else { $content_type = null; } } if (($row !== null) && ($content_type !== null)) { // Add in generic data... $cma_mappings = [ 'created' => 'add_time_field', 'modified' => 'edit_time_field', 'creator' => isset($cma_info['author_field']) ? 'author_field' : 'submitter_field', 'publisher' => 'submitter_field', 'views' => 'views_field', 'validated' => 'validated_field', ]; foreach ($cma_mappings as $meta_type => $cma_field) { if (!isset($METADATA[$meta_type])) { if (isset($cma_info[$cma_field]) && isset($row[$cma_info[$cma_field]])) { switch ($meta_type) { case 'created': case 'modified': $val_raw = strval($row[$cma_info[$cma_field]]); $val = date('Y-m-d', $row[$cma_info[$cma_field]]); break; case 'creator': case 'publisher': if ($cma_field == 'author_field') { $val_raw = $row[$cma_info[$cma_field]]; $val = $val_raw; } else { $val_raw = strval($row[$cma_info[$cma_field]]); $val = $GLOBALS['FORUM_DRIVER']->get_username($row[$cma_info[$cma_field]]); } break; case 'views': $val_raw = strval($row[$cma_info[$cma_field]]); $val = $val_raw; break; case 'validated': $val_raw = strval($row[$cma_info[$cma_field]]); $val = $val_raw; break; default: $val_raw = $row[$cma_info[$cma_field]]; $val = $val_raw; break; } if ($val !== null) { $METADATA[$meta_type] = $val; $METADATA[$meta_type . '_RAW'] = $val_raw; } } } } // Add in fields that are a bit more difficult... if (!isset($METADATA['title'])) { $val_raw = $cma_ob->get_title($row); if ($content_type === 'comcode_page') { // FUDGE if ($content_id === ':' . DEFAULT_ZONE_PAGE_NAME) { $val_raw = get_site_name(); } else { $val_raw = titleify($val_raw); } } $METADATA['title'] = $val_raw; } if (!isset($METADATA['description'])) { $METADATA['description'] = $cma_ob->get_description($row); } if (!isset($METADATA['type'])) { $METADATA['type'] = $cma_ob->get_content_type_universal_label($row); } if (!isset($METADATA['image'])) { $image_url = $cma_ob->get_image_url($row, (($content_type == 'comcode_page') && ($content_id === ':' . DEFAULT_ZONE_PAGE_NAME)) ? IMAGE_URL_FALLBACK_NONE : IMAGE_URL_FALLBACK_SOFT); if (($image_url != '') && (is_valid_opengraph_image($image_url))) { $METADATA['image'] = $image_url; } } if ((!isset($METADATA['video'])) && (isset($cma_info['video_generator']))) { list($METADATA['video'], $METADATA['video:width'], $METADATA['video:height'], $METADATA['video:type']) = call_user_func($cma_info['video_generator'], $row); } // Add all $cma_info $METADATA += $cma_info; unset($METADATA['db']); $METADATA['content_type_label_trans'] = $cma_ob->get_content_type_label($row); } // And let's throw in some more useful stuff... if ($content_type !== null) { $METADATA['content_type'] = $content_type; } if ($content_id !== null) { $METADATA['content_id'] = $content_id; } } /** * Find if an image is an Open Graph image. * * @param URLPATH $url The URL * @return boolean Whether it is */ function is_valid_opengraph_image(string $url) : bool { $ext = get_file_extension($url); require_code('images'); if (is_image($url, IMAGE_CRITERIA_OPENGRAPH)) { $test = cms_getimagesize_url($url, true/*We won't even bother with non-local files, too much computational overhead*/); if ($test !== false) { list($width, $height) = $test; if (($width >= 200) && ($height >= 200)) { return true; } } } return false; } /** * Set the HTTP status code for the request. * * @sets_output_state * * @param integer $code The HTTP status code */ function set_http_status_code(int $code) { global $HTTP_STATUS_CODE; $HTTP_STATUS_CODE = $code; // So we can keep track if ((!headers_sent()) && (function_exists('browser_matches')) && (!browser_matches('ie')) && (strpos($_SERVER['SERVER_SOFTWARE'], 'IIS') === false)) { http_response_code($code); } } /** * Search for a template. * * @param ID_TEXT $codename The codename of the template being loaded * @param ID_TEXT $theme The theme to use * @param string $suffix File type suffix of template file (e.g. .tpl) * @set .tpl .js .xml .txt .css * @param string $directory Subdirectory type to look in * @set templates javascript xml text css * @param boolean $non_custom_only Whether to only search in the default templates * @param boolean $fallback_other_themes Allow fallback to other themes, in case it is defined only in a specific theme we would not normally look in * @return ?array List of parameters needed for the _do_template function to be able to load the template (null: could not find the template) */ function find_template_place(string $codename, string $theme, string $suffix, string $directory, bool $non_custom_only = false, bool $fallback_other_themes = true) : ?array { global $FILE_ARRAY, $CURRENT_SHARE_USER; static $tp_cache = []; $sz = serialize([$codename, $theme, $suffix, $directory, $non_custom_only, $fallback_other_themes]); if (isset($tp_cache[$sz])) { return $tp_cache[$sz]; } $prefix_default = get_file_base() . '/themes/'; $prefix = ($theme == 'default' || $theme == 'admin') ? $prefix_default : (get_custom_file_base() . '/themes/'); if (!isset($FILE_ARRAY)) { if ((is_file($prefix . $theme . '/' . $directory . '_custom/' . $codename . $suffix)) && (!in_safe_mode()) && (!$non_custom_only)) { $place = [$theme, '/' . $directory . '_custom/', $suffix]; } elseif (is_file($prefix . $theme . '/' . $directory . '/' . $codename . $suffix)) { $place = [$theme, '/' . $directory . '/', $suffix]; } elseif (($CURRENT_SHARE_USER !== null) && ($theme !== 'default') && (is_file(get_file_base() . '/themes/' . $theme . '/' . $directory . '_custom/' . $codename . $suffix)) && (!$non_custom_only)) { $place = [$theme, '/' . $directory . '_custom/', $suffix]; } elseif (($CURRENT_SHARE_USER !== null) && ($theme !== 'default') && (is_file(get_file_base() . '/themes/' . $theme . '/' . $directory . '/' . $codename . $suffix))) { $place = [$theme, '/' . $directory . '/', $suffix]; } elseif (($CURRENT_SHARE_USER !== null) && (is_file(get_custom_file_base() . '/themes/default/' . $directory . '_custom/' . $codename . $suffix)) && (!$non_custom_only)) { $place = ['default', '/' . $directory . '_custom/', $suffix]; } elseif (($CURRENT_SHARE_USER !== null) && (is_file(get_custom_file_base() . '/themes/default/' . $directory . '/' . $codename . $suffix))) { $place = ['default', '/' . $directory . '/', $suffix]; } elseif ((is_file($prefix_default . 'default' . '/' . $directory . '_custom/' . $codename . $suffix)) && (!in_safe_mode()) && (!$non_custom_only)) { $place = ['default', '/' . $directory . '_custom/', $suffix]; } elseif (is_file($prefix_default . 'default' . '/' . $directory . '/' . $codename . $suffix)) { $place = ['default', '/' . $directory . '/', $suffix]; } else { $place = null; } if (($place === null) && (!$non_custom_only) && ($fallback_other_themes)) { // Get desperate, search in themes other than current and default $dh = opendir(get_file_base() . '/themes'); while (($possible_theme = readdir($dh))) { if (($possible_theme[0] !== '.') && ($possible_theme !== 'default') && ($possible_theme !== $theme) && ($possible_theme !== 'map.ini') && ($possible_theme !== 'index.html')) { $full_path = get_custom_file_base() . '/themes/' . $possible_theme . '/' . $directory . '_custom/' . $codename . $suffix; if (is_file($full_path)) { $place = [$possible_theme, '/' . $directory . '_custom/', $suffix]; break; } } } closedir($dh); } } else { $place = ['default', '/' . $directory . '/', $suffix]; } $tp_cache[$sz] = $place; return $place; } /** * Find whether panels and the header/footer areas won't be shown. * * @return BINARY Result */ function is_wide_high() : int { global $IS_WIDE_HIGH_CACHE; if ($IS_WIDE_HIGH_CACHE !== null) { return $IS_WIDE_HIGH_CACHE; } $IS_WIDE_HIGH_CACHE = get_param_integer('wide_high', get_param_integer('keep_wide_high', get_param_integer('wide_print', 0))); return $IS_WIDE_HIGH_CACHE; } /** * Find whether panels will be shown. * * @return BINARY Result */ function is_wide() : int { global $IS_WIDE_CACHE; if ($IS_WIDE_CACHE !== null) { return $IS_WIDE_CACHE; } global $ZONE; $IS_WIDE_CACHE = get_param_integer('wide', get_param_integer('keep_wide', (is_wide_high() == 1) ? 1 : 0)); if ($IS_WIDE_CACHE == 0) { return 0; } // Need to check it is allowed if (get_theme_option('supports_wide') == '0') { $IS_WIDE_CACHE = 0; return $IS_WIDE_CACHE; } return $IS_WIDE_CACHE; } /** * Fixes bad unicode (utf-8) in the input. Useful when input may be dirty, e.g. from a txt file, or from a potential hacker. * The fix is imperfect, it will actually treat the input as ISO-8859-1 if not valid utf-8, then reconvert. Some limited scrambling is considered better than a stack trace. * This function does nothing if we are not using utf-8. * * @param string $input Input string * @param boolean $definitely_unicode If we know the input is meant to be unicode * @return string Guaranteed valid utf-8, if we're using it, otherwise the same as the input string */ function fix_bad_unicode(string $input, bool $definitely_unicode = false) : string { // Fix bad unicode if (get_charset() == 'utf-8' || $definitely_unicode) { if (is_numeric($input) || preg_match('#[^\x00-\x7f]#', $input) == 0) { return $input; // No non-ASCII characters } $test_string = $input; // avoid being destructive $test_string = preg_replace('#[\x09\x0A\x0D\x20-\x7E]#', '', $test_string); // ASCII $test_string = preg_replace('#[\xC2-\xDF][\x80-\xBF]#', '', $test_string); // non-overlong 2-byte $test_string = preg_replace('#\xE0[\xA0-\xBF][\x80-\xBF]#', '', $test_string); // excluding overlongs $test_string = preg_replace('#[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}#', '', $test_string); // straight 3-byte $test_string = preg_replace('#\xED[\x80-\x9F][\x80-\xBF]#', '', $test_string); // excluding surrogates $test_string = preg_replace('#\xF0[\x90-\xBF][\x80-\xBF]{2}#', '', $test_string); // planes 1-3 $test_string = preg_replace('#[\xF1-\xF3][\x80-\xBF]{3}#', '', $test_string); // planes 4-15 $test_string = preg_replace('#\xF4[\x80-\x8F][\x80-\xBF]{2}#', '', $test_string); // plane 16 if ($test_string !== '') { // All ASCII/unicode characters stripped, so if anything is remaining it must be some kind of corruption require_code('character_sets'); $input = convert_to_internal_encoding($input, 'ISO-8859-1', 'utf-8'); } } return $input; } /** * Get string length, with utf-8 awareness where possible/required. * * @param string $in The string to get the length of * @param boolean $force Whether to force unicode as on * @return integer The string length */ function cms_mb_strlen(string $in, bool $force = false) : int { if (!$force && get_charset() != 'utf-8') { return strlen($in); } if ((function_exists('mb_strlen')) && (function_exists('get_value')) && (get_value('disable_mbstring') !== '1')) { return @mb_strlen($in, $force ? 'utf-8' : get_charset()); // @ is because there could be invalid unicode involved } if ((function_exists('iconv_strlen')) && (function_exists('get_value')) && (get_value('disable_iconv') !== '1')) { return @iconv_strlen($in, $force ? 'utf-8' : get_charset()); } return strlen($in); } /** * Return part of a string, with utf-8 awareness where possible/required. * * @param string $in The subject * @param integer $from The start position * @param ?integer $amount The length to extract (null: all remaining) * @param boolean $force Whether to force unicode as on * @return ~string String part (false: $start was over the end of the string) */ function cms_mb_substr(string $in, int $from, ?int $amount = null, bool $force = false) { if ($amount === null) { $amount = cms_mb_strlen($in, $force) - $from; } if ((!$force) && (get_charset() != 'utf-8')) { return substr($in, $from, $amount); } if ((function_exists('mb_substr')) && (function_exists('get_value')) && (get_value('disable_mbstring') !== '1')) { return @mb_substr($in, $from, $amount, $force ? 'utf-8' : get_charset()); } if ((function_exists('iconv_substr')) && (function_exists('get_value')) && (get_value('disable_iconv') !== '1')) { return @iconv_substr($in, $from, $amount, $force ? 'utf-8' : get_charset()); } $ret = substr($in, $from, $amount); $end = ord(substr($ret, -1)); if (($end >= 192) && ($end <= 223)) { $ret .= substr($in, $from + $amount, 1); } if ($from != 0) { $start = ord(substr($ret, 0, 1)); if (($start >= 192) && ($start <= 223)) { $ret = substr($in, $from - 1, 1) . $ret; } } return $ret; } /** * Make a string title-case, with utf-8 awareness where possible/required. * * @param string $in Subject * @return string Result */ function cms_mb_ucwords(string $in) : string { if (cms_strtoupper_ascii(get_charset()) == 'ISO-8859-1') { $ret = cms_ucwords_ascii($in); } elseif (function_exists('mb_convert_case')) { $ret = @mb_convert_case($in, MB_CASE_TITLE, get_charset()); } elseif (function_exists('ucwords')) { $ret = ucwords($in); // Relies on locale, which is not ideal } else { $ret = $in; } return $ret; } /** * Make a string's first character upper case, with utf-8 awareness where possible/required. * * @param string $in Subject * @return string Result */ function cms_mb_ucfirst(string $in) : string { if (cms_strtoupper_ascii(get_charset()) == 'ISO-8859-1') { $ret = cms_ucfirst_ascii($in); } elseif (function_exists('mb_convert_case')) { $ret = @mb_convert_case(cms_mb_substr($in, 0, 1), MB_CASE_UPPER, get_charset()) . cms_mb_substr($in, 1); } elseif (function_exists('ucfirst')) { $ret = ucfirst($in); // Relies on locale, which is not ideal } else { $ret = $in; } return $ret; } /** * Make a string lower case, with utf-8 awareness where possible/required. * * @param string $in Subject * @return string Result */ function cms_mb_strtolower(string $in) : string { if (cms_strtoupper_ascii(get_charset()) == 'ISO-8859-1') { $ret = cms_strtolower_ascii($in); } elseif (function_exists('mb_strtolower')) { $ret = @mb_strtolower($in, get_charset()); } elseif (function_exists('strtolower')) { $ret = strtolower($in); // Relies on locale, which is not ideal } else { $ret = $in; } return $ret; } /** * Make a string upper case, with utf-8 awareness where possible/required. * * @param string $in Subject * @return string Result */ function cms_mb_strtoupper(string $in) : string { if (cms_strtoupper_ascii(get_charset()) == 'ISO-8859-1') { $ret = cms_strtoupper_ascii($in); } elseif (function_exists('mb_strtoupper')) { $ret = @mb_strtoupper($in, get_charset()); } elseif (function_exists('strtoupper')) { $ret = strtoupper($in); // Relies on locale, which is not ideal } else { $ret = $in; } return $ret; } /** * Unicode-safe case-sensitive string comparison. * Note we have no cms_mb_strcasecmp because intl (Collator class) cannot do case-insensitive comparison. * * @param string $str1 The first string * @param string $str2 The second string * @return integer <0 if s1<s2, 0 if s1=s2, >1 if s1>s2 */ function cms_mb_strcmp(string $str1, string $str2) : int { if ((function_exists('collator_create')) && (function_exists('do_lang'))) { $collator = cms_collator_create(); if ($collator !== null) { collator_set_attribute($collator, Collator::NUMERIC_COLLATION, Collator::OFF); $cmp = collator_compare($collator, $str1, $str2); if ($cmp <= -1) { return -1; } if ($cmp >= 1) { return 1; } return 0; } } // Ideally we'd use strcoll, but that's case-sensitive and also doesn't work on Windows for Unicode, and also relies on unstable collations $cmp = strcmp($str1, $str2); if ($cmp <= -1) { return -1; } if ($cmp >= 1) { return 1; } return 0; } /** * Case-sensitive string comparisons using a "natural order" algorithm, Unicode-safe. * * @param string $str1 The first string * @param string $str2 The second string * @return integer <0 if s1<s2, 0 if s1=s2, >1 if s1>s2 */ function cms_mb_strnatcmp(string $str1, string $str2) : int { if (function_exists('collator_create')) { $collator = cms_collator_create(); if ($collator !== null) { collator_set_attribute($collator, Collator::NUMERIC_COLLATION, Collator::ON); $ret = collator_compare($collator, $str1, $str2); return $ret; } } $ret = strnatcmp($str1, $str2); return $ret; } /** * Create a collator for the configured locale. * * @return ?object Collator (null: none) */ function cms_collator_create() : ?object { static $collator = null; if ($collator !== null) { return $collator; } if (function_exists('collator_create')) { $locale_str = do_lang('locale'); if ($locale_str != '') { $locale_sections = explode(';', $locale_str); foreach ($locale_sections as $locale_section) { $parts = explode(':', $locale_section, 2); $locale = $parts[0]; if (count($parts) == 2) { $locale = $parts[1]; } $collator = collator_create($locale); if ($collator !== null) { if (collator_get_locale($collator, Locale::VALID_LOCALE) != 'root') { return $collator; } unset($collator); } } } } return null; } /** * Make a string upper case. * * @param string $str Subject * @return string Result */ function cms_strtoupper_ascii(string $str) : string { global $ASCII_LCASE_MAP; $ret = ''; $len = strlen($str); for ($i = 0; $i < $len; $i++) { $c = $str[$i]; $ret .= isset($ASCII_LCASE_MAP[$c]) ? $ASCII_LCASE_MAP[$c] : $c; } return $ret; } /** * Make a string lower case. * * @param string $str Subject * @return string Result */ function cms_strtolower_ascii(string $str) : string { global $ASCII_UCASE_MAP; $ret = ''; $len = strlen($str); for ($i = 0; $i < $len; $i++) { $c = $str[$i]; $ret .= isset($ASCII_UCASE_MAP[$c]) ? $ASCII_UCASE_MAP[$c] : $c; } return $ret; } /** * Make a string's first character lower case. * * @param string $str Subject * @return string Result */ function cms_lcfirst_ascii(string $str) : string { return cms_strtolower_ascii(substr($str, 0, 1)) . substr($str, 1); } /** * Make a string's first character upper case. * * @param string $str Subject * @return string Result */ function cms_ucfirst_ascii(string $str) : string { return cms_strtoupper_ascii(substr($str, 0, 1)) . substr($str, 1); } /** * Upper case the first character of each word in a string. * * @param string $str Subject * @return string Result */ function cms_ucwords_ascii(string $str) : string { $starting_word = true; global $ASCII_LCASE_MAP; $ret = ''; $len = strlen($str); for ($i = 0; $i < $len; $i++) { $c = $str[$i]; $is_whitespace = (trim($c) == ''); if (($starting_word) && (!$is_whitespace)) { $ret .= isset($ASCII_LCASE_MAP[$c]) ? $ASCII_LCASE_MAP[$c] : $c; $starting_word = false; } else { if ($is_whitespace) { $starting_word = true; } $ret .= $c; } } return $ret; } /** * Sort an array of Unicode strings. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything. * * @param array $array The array * @param integer $sort_flags Sort flags */ function cms_mb_sort(array &$array, int $sort_flags = 0) { usort($array, ((($sort_flags & SORT_NATURAL) != 0) ? 'cms_mb_strnatcmp' : 'cms_mb_strcmp')); } /** * Sort an array of Unicode strings in reverse order. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything. * * @param array $array The array to sort * @param integer $sort_flags Sort flags */ function cms_mb_rsort(array &$array, int $sort_flags = 0) { cms_mb_sort($array, $sort_flags); $array = array_reverse($array); } /** * Sort an array of Unicode strings and maintain index association. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything. * * @param array $array Array * @param integer $sort_flags Sort flags */ function cms_mb_asort(array &$array, int $sort_flags = 0) { uasort($array, ((($sort_flags & SORT_NATURAL) != 0) ? 'cms_mb_strnatcmp' : 'cms_mb_strcmp')); } /** * Sort an array of Unicode strings in reverse order and maintain index association. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything. * * @param array $array Array * @param integer $sort_flags Sort flags */ function cms_mb_arsort(array &$array, int $sort_flags = 0) { cms_mb_asort($array, $sort_flags); $array = array_reverse($array); } /** * Sort an array by Unicode key. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything. * * @param array $array The array to sort * @param integer $sort_flags Sort flags */ function cms_mb_ksort(array &$array, int $sort_flags = 0) { uksort($array, ((($sort_flags & SORT_NATURAL) != 0) ? 'cms_mb_strnatcmp' : 'cms_mb_strcmp')); } /** * Sort an array by Unicode key in reverse order. Assumes SORT_FLAG_CASE because our Unicode sorting cannot do case-sensitive, only the SORT_NATURAL flag does anything. * * @param array $array The array to sort * @param integer $sort_flags Sort flags */ function cms_mb_krsort(array &$array, int $sort_flags = 0) { cms_mb_ksort($array, $sort_flags); $array = array_reverse($array); } /** * Find if we a string is ASCII, and hence we can use non-UTF-safe functions on it. * * @param string $x String to test * @return boolean Whether it is ASCII */ function is_ascii_string(string $x) : bool { $l = strlen($x); for ($i = 0; $i < $l; $i++) { if (ord($x[$i]) >= 128) { return false; } } return true; } /** * Find whether we have no forum on this website. * * @return boolean Whether we have no forum on this website */ function has_no_forum() : bool { if (get_forum_type() == 'none') { return true; } if ((get_forum_type() == 'cns') && (!addon_installed('cns_forum'))) { return true; } return false; } /** * Check to see if an addon is installed. * Will also include check database tables for addons that are hook-based, just in case filesystem and database got out of sync. * For addons with no addon_registry hook can also check the database (if requested via $check_hookless). * * @param ID_TEXT $addon_name The addon name * @param boolean $check_hookless Whether to check addons with no addon_registry hook (it's very rare to need this; will skip $deep_scan) * @param boolean $deep_scan Do a deep scan of the database to see if the addon is fully installed * @param boolean $disabled_scan Consider whether the addon is set as disabled * @param boolean $force_custom Whether to forcefully check custom hooks even if in safe mode * @return boolean Whether it is */ function addon_installed(string $addon_name, bool $check_hookless = false, bool $deep_scan = true, bool $disabled_scan = true, bool $force_custom = false) : bool { global $ADDON_INSTALLED_CACHE; if (empty($ADDON_INSTALLED_CACHE)) { if (!in_safe_mode()) { if (function_exists('persistent_cache_get')) { $ADDON_INSTALLED_CACHE = persistent_cache_get('ADDONS_INSTALLED'); } } } if (isset($ADDON_INSTALLED_CACHE[$addon_name][$check_hookless][$deep_scan][$disabled_scan])) { return $ADDON_INSTALLED_CACHE[$addon_name][$check_hookless][$deep_scan][$disabled_scan]; } $page = get_param_string('page', '', INPUT_FILTER_GET_COMPLEX); // Not get_page_name for bootstrap order reasons $check_custom = (($force_custom) || (!in_safe_mode()) || ($page == 'admin-addons')); // TODO: use new $force_custom parameter on admin-addons page // Check addon_registry hook $addon_name = filter_naughty($addon_name, true); global $FILE_ARRAY; if (@is_array($FILE_ARRAY)) { // quick installer $answer = file_array_exists('sources/hooks/systems/addon_registry/' . $addon_name . '.php'); if ((!$answer) && ($check_custom)) { $answer = file_array_exists('sources_custom/hooks/systems/addon_registry/' . $addon_name . '.php'); } } else { $answer = is_file(get_file_base() . '/sources/hooks/systems/addon_registry/' . $addon_name . '.php'); if ((!$answer) && ($check_custom)) { $answer = is_file(get_file_base() . '/sources_custom/hooks/systems/addon_registry/' . $addon_name . '.php'); } } if ((!$GLOBALS['IN_MINIKERNEL_VERSION']) && (($check_hookless || $deep_scan || $disabled_scan))) { require_code('database'); // Check addons table // NB: addons without a hook are always custom, so only run if we are also checking custom (bundled addons always have a hook) if ((!$answer) && ($check_hookless) && ($check_custom)) { $test = $GLOBALS['SITE_DB']->query_select_value_if_there('addons', 'addon_name', ['addon_name' => $addon_name]); if ($test !== null) { $answer = true; } // NB: Won't check tables because we don't know them for hookless addons (no addon_registry hook) } else { if (($answer) && ($deep_scan)) { // Do a full scan to see if the addon is fully installed; check tables defined in database manifest static $data = []; if ($data === null) { require_code('zones'); $hooks = find_all_hook_obs('systems', 'database_manifest', 'Hook_database_manifest_'); foreach ($hooks as $addon => $ob) { if (!method_exists($ob, 'db_meta')) { continue; } $data[$addon] = $ob->db_meta(); } } if (isset($data[$addon_name]) && array_key_exists('tables', $data[$addon_name])) { require_code('database'); foreach ($data[$addon_name]['tables'] as $table_name => $table_details) { $db = get_db_for($table_name); if (!$db->table_exists($table_name)) { $answer = false; break; } } } } } if (($answer) && ($disabled_scan)) { global $VALUES_FULLY_LOADED; if (($VALUES_FULLY_LOADED) && (get_value('addon_disabled_' . $addon_name) === '1')) { $answer = false; } } } $ADDON_INSTALLED_CACHE[$addon_name][$check_hookless][$deep_scan][$disabled_scan] = $answer; if (function_exists('persistent_cache_set')) { if ((!$GLOBALS['IN_MINIKERNEL_VERSION']) && (!in_safe_mode())) { persistent_cache_set('ADDONS_INSTALLED', $ADDON_INSTALLED_CACHE); } } return $answer; } /** * Check to see if an addon is installed. If not, install return an error message with a link to manage addons. * * @param ID_TEXT $addon_name The addon name * @param Tempcode $error_msg Put an error message in here * @return boolean Whether it is */ function addon_installed__messaged(string $addon_name, object &$error_msg) : bool { if (!addon_installed($addon_name)) { $_error_msg = do_lang('MISSING_ADDON', escape_html($addon_name)); $addon_manage_url = build_url(['page' => 'admin_addons'], get_module_zone('admin_addons')); $error_msg = do_lang_tempcode('BROKEN_ADDON_REMEDIES', $_error_msg, escape_html(find_script('upgrader')), escape_html(static_evaluate_tempcode($addon_manage_url))); return false; } return true; } /** * Convert a float to a "technical string representation of a float". Inverted with floatval. * * @param float $num The number * @param integer $decs_wanted The number of decimals to keep * @param boolean $only_needed_decs Whether to trim trailing zeros * @return string The string converted */ function float_to_raw_string(float $num, int $decs_wanted = 2, bool $only_needed_decs = false) : string { $str = number_format($num, $decs_wanted, '.', ''); $dot_pos = strpos($str, '.'); $decs_here = ($dot_pos === false) ? 0 : (strlen($str) - $dot_pos - 1); if ($decs_here < $decs_wanted) { for ($i = 0; $i < $decs_wanted - $decs_here; $i++) { $str .= '0'; } } elseif ($decs_here > $decs_wanted) { $str = substr($str, 0, strlen($str) - $decs_here + $decs_wanted); if ($decs_wanted == 0 && !$only_needed_decs) { $str = rtrim($str, '.'); } } if ($only_needed_decs && $decs_wanted != 0) { $str = rtrim(rtrim($str, '0'), '.'); } return $str; } /** * Format the given float number as a nicely formatted string. Inverted with float_unformat. * * @param float $val The value to format * @param integer $decs_wanted The number of fractional digits * @param boolean $only_needed_decs Whether to trim trailing zeros * @return string Nicely formatted string */ function float_format(float $val, int $decs_wanted = 2, bool $only_needed_decs = false) : string { $str = number_format($val, $decs_wanted, do_lang('locale_decimal_point'), do_lang('locale_thousands_sep')); $dot_pos = strpos($str, '.'); $decs_here = ($dot_pos === false) ? 0 : (strlen($str) - $dot_pos - 1); if ($decs_here < $decs_wanted) { for ($i = 0; $i < $decs_wanted - $decs_here; $i++) { $str .= '0'; } } elseif ($decs_here > $decs_wanted) { $str = substr($str, 0, strlen($str) - $decs_here + $decs_wanted); if ($decs_wanted == 0) { $str = rtrim($str, '.'); } } if ($only_needed_decs && $decs_wanted != 0) { $str = rtrim(rtrim($str, '0'), '.'); } return $str; } /** * Take the given formatted float number and convert it to a native float. The inverse of float_format. * * @param string $str The formatted float number using the locale * @param boolean $includes_thousands_sep Whether we expect a thousands separator, knowing this means we can be a bit smarter * @return float Native float */ function float_unformat(string $str, bool $includes_thousands_sep = true) : float { // Simplest case? if (preg_match('#^\d+$#', $str) != 0) { // E.g. "123" return floatval($str); } if ($includes_thousands_sep) { // We can assume a "." is a decimal point then? if (preg_match('#^\d+\.\d+$#', $str) != 0) { // E.g. "123.456" return floatval($str); } } // Looks like English-format? It couldn't be anything else because thousands_sep always comes before decimal_point if (preg_match('#^[\d,]+\.\d+$#', $str) != 0) { // E.g. "123,456.789" return floatval($str); } // Now it must e E.g. "123.456,789" or "123.456", or something from another language which uses other separators... if (do_lang('locale_thousands_sep') != '') { $str = str_replace(do_lang('locale_thousands_sep'), '', $str); } $str = str_replace(do_lang('locale_decimal_point'), '.', $str); return floatval($str); } /** * Format the given integer number as a nicely formatted string (using the locale). * * @param integer $val The value to format * @param ?integer $dps Number of decimal points to show when simplifying down (null: show the full number) * @return string Nicely formatted string */ function integer_format(int $val, ?int $dps = null) : string { $ldp = do_lang('locale_decimal_point'); $lts = do_lang('locale_thousands_sep'); if ($dps !== null) { $units = ['INTEGER_UNITS_billions' => 1000000000, 'INTEGER_UNITS_millions' => 1000000, 'INTEGER_UNITS_thousands' => 1000]; foreach ($units as $lang_string => $threshold) { if ($val >= $threshold) { return do_lang($lang_string, number_format(floatval($val) / floatval($threshold), $dps, $ldp, $lts)); } } } return number_format(floatval($val), 0, $ldp, $lts); } /** * Require all code relating to the Conversr forum. */ function cns_require_all_forum_stuff() { require_lang('cns'); require_code('cns_members'); require_code('cns_topics'); require_code('cns_posts'); require_code('cns_groups'); require_code('cns_forums'); require_code('cns_general'); if (addon_installed('cns_multi_moderations')) { require_code('cns_multi_moderations'); } } /** * Require all code relating to the core content management. * This is a useful shortcut when writing code to create content, e.g. when preparing content to push live programmatically. */ function require_all_core_cms_code() { require_code('content2'); require_code('menus2'); require_code('permissions2'); require_code('permissions3'); require_code('fields'); if (addon_installed('awards')) { require_code('awards'); } require_code('submit'); require_code('config2'); require_code('attachments3'); require_code('zones3'); } /** * Create file with unique file name, but works around compatibility issues between servers. Note that the file is NOT automatically deleted. You should also delete it using "@unlink", as some servers have problems with permissions. * * @param string $prefix The prefix of the temporary file name * @param boolean $software_temp_only Whether to always save to the software temp directory and not PHP's * @return ~string The name of the temporary file (false: error) */ function cms_tempnam(string $prefix = 'cms', bool $software_temp_only = false) { require_code('files2'); return _cms_tempnam($prefix, $software_temp_only); } /** * Parse a URL and return its components. * Wrapper around parse_url that adds standard defaults for missing components. * * @param string $url The URL to parse * @param integer $component The component to get (-1 get all in an array) * @return ?~mixed A map of details about the URL (false: URL cannot be parsed) (null: missing component) */ function cms_parse_url_safe(string $url, int $component = -1) { $ret = parse_url($url, $component); if ($ret === false) { return false; } if ($component != -1) { if ($ret === null) { switch ($component) { case PHP_URL_SCHEME: return 'http'; case PHP_URL_PORT: return (cms_strtolower_ascii(cms_parse_url_safe($url, PHP_URL_SCHEME)) == 'https') ? 443 : 80; case PHP_URL_PATH: case PHP_URL_QUERY: case PHP_URL_FRAGMENT: return ''; } } } else { if (!array_key_exists('scheme', $ret)) { $ret['scheme'] = 'http'; } if (!array_key_exists('port', $ret)) { $ret['port'] = (cms_strtolower_ascii($ret['scheme']) == 'https') ? 443 : 80; } if (!array_key_exists('path', $ret)) { $ret['path'] = ''; } if (!array_key_exists('query', $ret)) { $ret['query'] = ''; } if (!array_key_exists('fragment', $ret)) { $ret['fragment'] = ''; } } return $ret; } /** * Make a value suitable for use in an XML ID. * * @param string $param The value to escape * @param boolean $simplified Generate simplified IDs * @return string The escaped value */ function fix_id(string $param, bool $simplified = false) : string { if (preg_match('#^[A-Za-z][\w]*$#', $param) !== 0) { return $param; // Optimisation } if ($simplified) { if (get_charset() == 'utf-8') { $param = str_replace("\u{0394}", 'delta', $param); } } $length = strlen($param); $new = ''; $flip_case_previous = false; for ($i = 0; $i < $length; $i++) { $char = $param[$i]; $flip_case = true; switch ($char) { case '[': $new .= $simplified ? '' : '_opensquare_'; break; case ']': $new .= $simplified ? '' : '_closesquare_'; break; case ''': case '\'': $new .= $simplified ? '' : '_apostophe_'; break; case '-': $new .= $simplified ? '' : '_minus_'; break; case ' ': $new .= $simplified ? '' : '_space_'; break; case '+': $new .= $simplified ? '' : '_plus_'; break; case '*': $new .= $simplified ? '' : '_star_'; break; case '/': $new .= $simplified ? '' : '__'; break; case '%': $new .= '_percent_'; break; default: if (($simplified) && (in_array($char, ['(' , ')', ',']))) { break; } $ascii = ord($char); if (($i !== 0) && ($char === '_')) { $new .= '_'; } elseif ((($ascii >= 48) && ($ascii <= 57)) || (($ascii >= 65) && ($ascii <= 90)) || (($ascii >= 97) && ($ascii <= 122))) { // Alphanumeric $flip_case = false; $new .= ($flip_case_previous && $simplified) ? cms_strtoupper_ascii($char) : $char; } elseif (!$simplified) { $new .= '_' . strval($ascii) . '_'; } break; } $flip_case_previous = $flip_case; } if ($simplified) { $new = str_replace('_', '', $new); } if ($new === '') { $new = 'zero_length'; } if ($new[0] === '_') { $new = 'und_' . $new; } return $new; } /** * See if the current URL matches the given software match-keys. * * @param mixed $match_keys Match keys (comma-separated list of match-keys, or array of) * @param boolean $support_post Check against POSTed data too * @param ?array $current_params Parameters to check against (null: get from environment GET/POST) - if set, $support_post is ignored) * @param ?ID_TEXT $current_zone_name Current zone name (null: get from environment) * @param ?ID_TEXT $current_page_name Current page name (null: get from environment) * @return boolean Whether there is a match */ function match_key_match($match_keys, bool $support_post = false, ?array $current_params = null, ?string $current_zone_name = null, ?string $current_page_name = null) : bool { $req_func = $support_post ? 'either_param_string' : 'get_param_string'; if ($current_zone_name === null) { global $IN_SELF_ROUTING_SCRIPT; if (!$IN_SELF_ROUTING_SCRIPT) { return false; } $current_zone_name = get_zone_name(); } if ($current_page_name === null) { $current_page_name = get_page_name(); } static $cache = []; $sz_key = serialize([$match_keys, $support_post, $current_params, $current_zone_name, $current_page_name]); if (isset($cache[$sz_key])) { return $cache[$sz_key]; } $potentials = is_array($match_keys) ? $match_keys : explode(',', $match_keys); foreach ($potentials as $potential) { $parts = is_array($potential) ? $potential : explode(':', $potential); // Allow the first 2 parts to be omitted, but if so we need to insert them back in here $prepend_zone = ((!isset($parts[0])) || (strpos($parts[0], '=') !== false)); if ($prepend_zone) { $parts = array_merge(['_WILD'], $parts); } $prepend_page = ((!isset($parts[1])) || (strpos($parts[1], '=') !== false)); if ($prepend_page) { $parts = array_merge(array_slice($parts, 0, 1), ['_WILD'], array_slice($parts, 1)); } if (($parts[0] == '_WILD') || ($parts[0] == '_SEARCH')) { $parts[0] = $current_zone_name; } if (($parts[1] == '_WILD') || (($parts[1] == '_WILD_NOT_START') && ($current_page_name != get_zone_default_page($parts[0])))) { $parts[1] = $current_page_name; } if (($parts[0] == 'site') && (get_option('single_public_zone') == '1')) { $parts[0] = ''; } $zone_matches = (($parts[0] == $current_zone_name) || ((strpos($parts[0], '*') !== false) && (simulated_wildcard_match($current_zone_name, $parts[0], true)))); $page_matches = ((($parts[1] == '') && ($current_page_name == get_zone_default_page($current_zone_name))) || ($parts[1] == $current_page_name) || ((strpos($parts[1], '*') !== false) && (simulated_wildcard_match($current_page_name, $parts[1], true)))); if (($zone_matches) && ($page_matches)) { $bad = false; for ($i = 2; $i < count($parts); $i++) { if ($parts[$i] != '') { if (($i == 2) && (strpos($parts[$i], '=') === false)) { $parts[$i] = 'type=' . $parts[$i]; } elseif (($i == 3) && (strpos($parts[$i], '=') === false)) { $parts[$i] = 'id=' . $parts[$i]; } } $subparts = explode('=', $parts[$i]); if ($subparts[0] == 'type') { $default = 'browse'; } else { $default = ''; } if (count($subparts) != 2) { $bad = true; continue; } $env_val = ($current_params === null) ? call_user_func_array($req_func, [$subparts[0], null]) : (isset($current_params[$subparts[0]]) ? $current_params[$subparts[0]] : null); if ($subparts[1] == '_WILD') { if ($env_val !== null) { $subparts[1] = $env_val; // null won't match to a wildcard } } else { if ($env_val === null) { $env_val = $default; } } if ($env_val !== $subparts[1]) { $bad = true; continue; } } if (!$bad) { $cache[$sz_key] = true; return true; } } } $cache[$sz_key] = false; return false; } /** * Get the name of the page in the URL or active script. * * @return ID_TEXT The current page/script name */ function get_page_or_script_name() : string { global $IN_SELF_ROUTING_SCRIPT; if ($IN_SELF_ROUTING_SCRIPT) { return get_page_name(); } return current_script(); } /** * Get the name of the page in the URL (by convention: the current page). * This works on the basis of the 'page' parameter and does not require index.php be the active script. * It will do dash to underscore substitution as required. * * @return ID_TEXT The current page name */ function get_page_name() : string { global $PAGE_NAME_CACHE; if (isset($PAGE_NAME_CACHE)) { return $PAGE_NAME_CACHE; } global $ZONE, $BOOTSTRAPPING; static $getting_page_name = false; if ($getting_page_name) { return 'unknown'; } $getting_page_name = true; $page = get_param_string('page', '', INPUT_FILTER_GET_COMPLEX); if (strlen($page) > 80) { warn_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('af9ff824131551eb8ed3c53d45b99e0d'))); } if (($page == '') && ($ZONE !== null)) { $page = $ZONE['zone_default_page']; if ($page === null) { $page = ''; } } if (strpos($page, '..') !== false) { $page = filter_naughty($page); } $simplified_algorithm = $BOOTSTRAPPING; // fix_page_name_dashing calls request_page, which won't work reliably during bootstrapping if ($simplified_algorithm) { $page = str_replace('-', '_', $page); } else { $page = fix_page_name_dashing(get_zone_name(), $page); } if (!$getting_page_name) { // It's been changed by process_url_monikers, which was called indirectly by fix_page_name_dashing return $PAGE_NAME_CACHE; } if (($ZONE !== null) && (!$simplified_algorithm)) { $PAGE_NAME_CACHE = $page; } $getting_page_name = false; return $page; } /** * Fix a page name that may have been given dashes for SEO reasons. * * @param string $zone Zone * @param string $page Page * @return string The fixed page name */ function fix_page_name_dashing(string $zone, string $page) : string { if (strpos($page, '/') !== false) { return $page; // It's a moniker that hasn't been processed yet } // Fix page-name dashes if needed if (strpos($page, '-') !== false) { require_code('site'); $test = _request_page($page, $zone, null, null, false); if ($test === false) { $_page = str_replace('-', '_', $page); $test = _request_page($_page, $zone); if ($test !== false) { $page = $_page; } } } return $page; } /** * Peek at a stack element. * * @param array $array The stack to peek in * @param integer $depth_down The depth into the stack we are peaking * @return mixed The result of the peeking */ function array_peek(array $array, int $depth_down = 1) { $count = count($array); if ($count - $depth_down < 0) { return null; } return $array[$count - $depth_down]; } /** * Take a list of maps, and make one of the values of each array the index of a map to the map. * * list_to_map is very useful for handling query results. * Let's imagine you get the result of SELECT id,title FROM sometable. * list_to_map turns the array of rows into a map between the id key and each row. * * @param ?string $map_value The key key of our maps that reside in our map (null: serialize the whole map) * @param array $list The list of maps * @return array The collapsed map */ function list_to_map(?string $map_value, array $list) : array { $i = 0; $new_map = []; foreach ($list as $map) { $key = ($map_value === null) ? serialize($map) : $map[$map_value]; $new_map[$key] = $map; $i++; } if ($i > 0) { return $new_map; } return []; } /** * Take a list of maps of just two elements, and make it into a single map. * * @param string $key The key key of our maps that reside in our map * @param string $value The value key of our maps that reside in our map * @param array $list The map of maps * @return array The collapsed map */ function collapse_2d_complexity(string $key, string $value, array $list) : array { return array_column($list, $value, $key); } /** * Take a list of maps of just one element, and make it into a single map. * * @param ?string $key The key of our maps that reside in our map (null: first key) * @param array $list The map of maps * @return array The collapsed map */ function collapse_1d_complexity(?string $key, array $list) : array { if ($key !== null) { return array_column($list, $key); // Faster } $new_array = []; foreach ($list as $map) { if ($key === null) { $new_array[] = array_shift($map); } else { $new_array[] = $map[$key]; } } return $new_array; } /** * Sort a list of maps by the string length of a particular key ID in the maps. * * @param array $rows List of maps to sort * @param mixed $sort_key Either an integer sort key (to sort by integer key ID of contained arrays) or a String sort key (to sort by string key ID of contained arrays) */ function sort_maps_by__strlen(array $rows, $sort_key) { global $M_SORT_KEY; $M_SORT_KEY = $sort_key; if (count($rows) < 2) { if (($GLOBALS['DEV_MODE']) && (count($rows) == 1)) { call_user_func('_strlen_sort', current($rows), current($rows)); // Just to make sure there's no crash bug in the sort function } return; } uasort($rows, '_strlen_sort'); } /** * Helper function for usort to sort a list by string length. * * @param mixed $a The first string or array of strings to compare * @param mixed $b The second string or array of strings to compare * @return integer The comparison result (0 for equal, -1 for less, 1 for more) * @ignore */ function _strlen_sort($a, $b) : int { if (!isset($a)) { $a = ''; } if (!isset($b)) { $b = ''; } if ($a == $b) { return 0; } if (!is_string($a)) { global $M_SORT_KEY; return (strlen($a[$M_SORT_KEY]) < strlen($b[$M_SORT_KEY])) ? -1 : 1; } return (strlen($a) < strlen($b)) ? -1 : 1; } /** * Helper function for usort to sort a list by string length in reverse order. * * @param mixed $a The first string or array of strings to compare * @param mixed $b The second string or array of strings to compare * @return integer The comparison result (0 for equal, -1 for less, 1 for more) * @ignore */ function _strlen_reverse_sort($a, $b) : int { if (!isset($a)) { $a = ''; } if (!isset($b)) { $b = ''; } if ($a == $b) { return 0; } if (!is_string($a)) { global $M_SORT_KEY; return (strlen($a[$M_SORT_KEY]) > strlen($b[$M_SORT_KEY])) ? -1 : 1; } return (strlen($a) > strlen($b)) ? -1 : 1; } /** * Sort a list of maps by a particular key ID in the maps. Does not (and should not) preserve list indices, but does preserve associative key indices. * * @param array $rows List of maps to sort * @param mixed $sort_keys Either an integer sort key (to sort by integer key ID of contained arrays) or a Comma-separated list of sort keys (to sort by string key ID of contained arrays; prefix '!' a key to reverse the sort order for it) * @param boolean $preserve_order_if_possible Don't shuffle order unnecessarily (i.e. do a merge sort) * @param boolean $natural Whether to do a natural sort */ function sort_maps_by(array &$rows, $sort_keys, bool $preserve_order_if_possible = false, bool $natural = false) { if (empty($rows)) { return; } global $M_SORT_KEY, $M_SORT_NATURAL; $M_SORT_KEY = $sort_keys; $M_SORT_NATURAL = $natural; if ($preserve_order_if_possible) { merge_sort($rows, '_multi_sort'); } else { if (count($rows) < 2) { if (($GLOBALS['DEV_MODE']) && (count($rows) == 1)) { call_user_func('_multi_sort', current($rows), current($rows)); // Just to make sure there's no crash bug in the sort function } return; } $first_key = key($rows); if ((is_integer($first_key)) && (array_unique(array_map('is_integer', array_keys($rows))) === [true])) { usort($rows, '_multi_sort'); } else { uasort($rows, '_multi_sort'); } } } /** * Do a user sort, preserving order where reordering not needed. Based on a PHP manual comment at http://php.net/manual/en/function.usort.php. * Roughly equivalent to PHP's uasort. * * @param array $array Sort array * @param mixed $cmp_function Comparison function */ function merge_sort(array &$array, $cmp_function = 'cms_mb_strcmp') { // Arrays of size<2 require no action. if (count($array) < 2) { if (($GLOBALS['DEV_MODE']) && (count($array) == 1)) { call_user_func($cmp_function, current($array), current($array)); // Just to make sure there's no crash bug in the sort function } return; } // Split the array in half $halfway = intval(floatval(count($array)) / 2.0); $array1 = array_slice($array, 0, $halfway); $array2 = array_slice($array, $halfway); // Recurse to sort the two halves merge_sort($array1, $cmp_function); merge_sort($array2, $cmp_function); // If all of $array1 is <= all of $array2, just append them. if (call_user_func($cmp_function, end($array1), reset($array2)) < 1) { $array = array_merge($array1, $array2); return; } // Merge the two sorted arrays into a single sorted array $array = []; reset($array1); reset($array2); $ptr1 = 0; $ptr2 = 0; $cnt1 = count($array1); $cnt2 = count($array2); while (($ptr1 < $cnt1) && ($ptr2 < $cnt2)) { if (call_user_func($cmp_function, current($array1), current($array2)) < 1) { $key = key($array1); if (is_integer($key)) { $array[] = current($array1); } else { $array[$key] = current($array1); } $ptr1++; next($array1); } else { $key = key($array2); if (is_integer($key)) { $array[] = current($array2); } else { $array[$key] = current($array2); } $ptr2++; next($array2); } } // Merge the remainder while ($ptr1 < $cnt1) { $key = key($array1); if (is_integer($key)) { $array[] = current($array1); } else { $array[$key] = current($array1); } $ptr1++; next($array1); } while ($ptr2 < $cnt2) { $key = key($array2); if (is_integer($key)) { $array[] = current($array2); } else { $array[$key] = current($array2); } $ptr2++; next($array2); } } /** * Helper function to sort a list of maps by the value at $key in each of those maps. * * @param array $a The first to compare * @param array $b The second to compare * @return integer The comparison result (0 for equal, -1 for less, 1 for more) * @ignore */ function _multi_sort(array $a, array $b) : int { global $M_SORT_KEY, $M_SORT_NATURAL; $keys = explode(',', is_string($M_SORT_KEY) ? $M_SORT_KEY : strval($M_SORT_KEY)); $first_key = $keys[0]; if ($first_key[0] === '!') { $first_key = substr($first_key, 1); } if (($a === null) && ($b === null)) { return 0; } elseif ($a === null) { return ($first_key[0] === '!') ? 1 : -1; } elseif ($b === null) { return ($first_key[0] === '!') ? -1 : 1; } $key_cnt = count($keys); // String version if ((is_string($a[$first_key])) || (is_object($a[$first_key]))) { $ret = 0; do { $key = array_shift($keys); $key_cnt--; $backwards = ($key[0] === '!'); if ($backwards) { $key = substr($key, 1); } $av = $a[$key]; $bv = $b[$key]; if (is_object($av)) { $av = $av->evaluate(); } if (is_object($bv)) { $bv = $bv->evaluate(); } if ((is_numeric($av)) && (is_numeric($bv)) || $M_SORT_NATURAL) { $ret = cms_mb_strnatcmp(@strval($av), @strval($bv)); } else { $ret = cms_mb_strcmp(@strval($av), @strval($bv)); } if ($backwards) { $ret *= -1; } } while (($key_cnt !== 0) && ($ret === 0)); return $ret; } // Non-string version do { $key = array_shift($keys); $key_cnt--; $backwards = ($key[0] === '!'); if ($backwards) { $key = substr($key, 1); } $av = $a[$key]; $bv = $b[$key]; $ret = ($av > $bv) ? 1 : (($av == $bv) ? 0 : -1); if ($backwards) { $ret *= -1; } } while (($key_cnt !== 0) && ($ret === 0)); return $ret; } /** * Strip HTML and PHP tags from a string. * Equivalent to PHP's strip_tags, except adds support for tags_as_allow being false. * * @param string $str Subject * @param string $tags Comma-separated list of tags (blank: none) * @param boolean $tags_as_allow Whether tags represents a safelist (set for false to allow all by default and make $tags a blocklist) * @return string Result */ function cms_strip_tags(string $str, string $tags = '', bool $tags_as_allow = true) : string { global $STRIP_TAGS_TAGS, $STRIP_TAGS_TAGS_AS_ALLOW; $STRIP_TAGS_TAGS = $tags; $STRIP_TAGS_TAGS_AS_ALLOW = $tags_as_allow; return preg_replace_callback('#</?([^\s<>]+)(\s[^<>]*)?' . '>#', '_cms_strip_tags_callback', $str); } /** * Used by cms_strip_tags to handle whether to strip a tag. * * @param array $matches Array of matches * @return string Substituted tag text * * @ignore */ function _cms_strip_tags_callback(array $matches) : string { global $STRIP_TAGS_TAGS, $STRIP_TAGS_TAGS_AS_ALLOW; $tag_covered = stripos($STRIP_TAGS_TAGS, '<' . $matches[1] . '>'); if ((($STRIP_TAGS_TAGS_AS_ALLOW) && ($tag_covered !== false)) || ((!$STRIP_TAGS_TAGS_AS_ALLOW) && ($tag_covered === false))) { return $matches[0]; } return ''; } /** * Attempt to get the clean IP address of the current user. * Note we do not consider potential proxying because that can easily be forged, and even if not it could be some local IP that is not unique enough. * The software has support for deproxying in global.php, which will adjust REMOTE_ADDR well in advance of this function being called. * * @param integer $amount The number of groups to include in the IP address (rest will be replaced with *'s). For IP6, this is doubled. * @set 1 2 3 4 * @param ?IP $ip IP address to use, normally left null (null: current user's) * @return IP The users IP address (blank: could not find a valid one) */ function get_ip_address(int $amount = 4, ?string $ip = null) : string { if ($ip === null) { $ip = $_SERVER['REMOTE_ADDR']; } if ($ip == '') { $ip = '127.0.0.1'; // Running on CLI, perhaps } global $SITE_INFO; if (($amount == 3) && (!empty($SITE_INFO['full_ip_addresses']))) { // Extra configurable security $amount = 4; } return normalise_ip_address($ip, $amount); } /** * Get possible hostnames of a localhost machine. * Also see get_server_names_and_ips(). * * @return array Hostnames and IP addresses */ function get_localhost_names() : array { return [ 'localhost', ]; } /** * Get possible hostnames and IP addresses of a localhost machine. * Also see get_server_names(). * * @return array Hostnames */ function get_localhost_names_and_ips() : array { return array_unique(array_merge(get_localhost_ips(), get_localhost_names())); } /** * See if an IP address is local (localhost or on non-routable LAN). * * @param IP $ip_address IP address * @return boolean Whether the IP address is local */ function ip_address_is_local(string $ip_address) : bool { return ((in_array($ip_address, get_localhost_ips())) || (substr($ip_address, 0, 3) == '10.') || (substr($ip_address, 0, 8) == '192.168.')); } /** * Find whether a machine is local (localhost or on non-routable LAN), rather than a live-site. * Also see is_our_server(). * * @param ?IP $ip_or_hostname IP address or hostname (null: currently requested hostname) * @return boolean If it is running locally */ function is_local_machine(?string $ip_or_hostname = null) : bool { if ($ip_or_hostname === null) { $ip_or_hostname = get_request_hostname(); } return (ip_address_is_local($ip_or_hostname)) || (in_array($ip_or_hostname, get_localhost_names())); } /** * Make our best guess on what IP addresses connections from server to server will use. * Also see get_server_ips(). * * @return IP IP address */ function get_server_external_looparound_ip() : string { if (get_option('ip_forwarding') == '1') { $server_ips = get_server_ips(true); $ip_address = $server_ips[0]; } else { $ip_address = cms_gethostbyname(get_base_url_hostname()); } return $ip_address; } /** * Get possible IP addresses of the server. * In order of what is most likely what the server considers itself. The first entry may be used for server loopback connections. * Also see get_localhost_ips(). * * @param boolean $local_interface_only Whether to only get IP addresses that are on a local network interface * @return array IP addresses */ function get_server_ips(bool $local_interface_only = false) : array { static $arr = null; if ($arr === null) { $arr = []; if (!empty($_SERVER['SERVER_ADDR'])) { $arr[] = $_SERVER['SERVER_ADDR']; } if (!empty($_SERVER['LOCAL_ADDR'])) { $arr[] = $_SERVER['LOCAL_ADDR']; } if (!$local_interface_only) { $hostnames = get_server_names(false); foreach ($hostnames as $hostname) { $test = cms_gethostbyname($hostname); if ($test != $hostname) { $arr[] = $test; } } } $arr = array_merge($arr, get_localhost_ips()); $arr = array_unique($arr); } return $arr; } /** * Get possible hostnames of the server. * In order of what is most likely what the server considers itself. * Also see get_domain() and get_request_hostname() and get_base_url_hostname() and get_localhost_names(). * * @param boolean $include_non_web_names Whether to include names that may not be for a web domain * @param boolean $include_equivalents Whether to include www vs non-www equivalents * @return array Host names */ function get_server_names(bool $include_non_web_names = true, bool $include_equivalents = true) : array { $arr = []; if ($include_non_web_names) { if ($_SERVER['SERVER_NAME'] != '') { $arr[] = $_SERVER['SERVER_NAME']; } } if (!empty($_SERVER['HTTP_HOST'])) { $arr[] = preg_replace('#:.*#', '', $_SERVER['HTTP_HOST']); } if ($include_non_web_names) { $arr[] = gethostname(); } global $SITE_INFO; if (!empty($SITE_INFO['domain'])) { $arr[] = $SITE_INFO['domain']; } if (!empty($SITE_INFO['base_url'])) { $tmp = cms_parse_url_safe($SITE_INFO['base_url'], PHP_URL_HOST); if (!empty($tmp)) { $arr[] = $tmp; } } if (get_custom_base_url() != get_base_url()) { $tmp = cms_parse_url_safe(get_custom_base_url(), PHP_URL_HOST); if (!empty($tmp)) { $arr[] = $tmp; } } $zl = strlen('ZONE_MAPPING_'); foreach ($SITE_INFO as $key => $_val) { if ($key !== '' && $key[0] === 'Z' && substr($key, 0, $zl) === 'ZONE_MAPPING_') { $arr[] = $_val[0]; } } if ($include_non_web_names) { $arr = array_merge($arr, get_localhost_names()); } $arr = array_unique($arr); $arr2 = []; foreach ($arr as $domain) { $arr2[] = $domain; // www vs non-www equivalents if ($include_equivalents) { if (preg_match('#^[\d:.]$#', $domain) == 0) { // If not an IP address if (preg_match('#^www\.#', $domain) != 0) { $arr2[] = substr($domain, 4); } else { $arr2[] = 'www.' . $domain; } } } } return array_unique($arr2); } /** * Get possible hostnames of the server. * Also see get_localhost_names(). * * @return array Hostnames and IP addresses */ function get_server_names_and_ips() : array { return array_unique(array_merge(get_server_ips(), get_server_names())); } /** * Find whether a machine is our server. * Also see is_local_machine(). * * @param ?IP $ip_or_hostname IP address or hostname (null: current user's IP address) * @return boolean If it is our server */ function is_our_server(?string $ip_or_hostname = null) : bool { if ($ip_or_hostname === null) { $ip_or_hostname = get_ip_address(); } $arr = get_server_names_and_ips(); return in_array($ip_or_hostname, $arr); } /** * Exit with debug data, only for a specific IP address. * * @param IP $ip IP address of tester * @param mixed $data Data to display (no HTML escaping is done in this function) */ function me_debug(string $ip, $data) { if (get_ip_address() == $ip) { @var_dump($data); exit(); } } /** * Get a string of the users web browser. * * @return string The web browser string */ function get_browser_string() : string { static $ret = null; if ($ret === null) { $ret = $_SERVER['HTTP_USER_AGENT']; $ret = str_replace(' (' . get_os_string() . ')', '', $ret); } return $ret; } /** * Get the user's operating system. * * @return string The operating system string */ function get_os_string() : string { static $ret = null; if ($ret === null) { $ret = ''; // E.g. Mozilla/4.5 [en] (X11; U; Linux 2.2.9 i586) // We need to get the stuff in the parentheses $matches = []; if (preg_match('#\(([^\)]*)\)#', $_SERVER['HTTP_USER_AGENT'], $matches) != 0) { $ret = $matches[1]; $ret = preg_replace('#^compatible; (MSIE[^;]*; )?#', '', $ret); } } return $ret; } /** * Find if the system scheduler is installed. * * @param boolean $absolutely_sure Whether the system scheduler really needs to be installed (if set to false it will be assumed installed on dev-mode) * @return boolean Whether The system scheduler is installed */ function cron_installed(bool $absolutely_sure = false) : bool { $test = get_param_integer('keep_has_cron', null); if ($test !== null) { return $test == 1; } if (!$absolutely_sure) { if ($GLOBALS['DEV_MODE']) { return true; } } // Crude to allow web request Cron, but we will $web_request_scheduler = get_option('enable_web_request_scheduler'); if ($web_request_scheduler == '1') { return true; } $last_cron = get_value('last_cron'); if ($last_cron === null) { return false; } return intval($last_cron) > (time() - 60 * 60 * 5); } /** * Compare two IP addresses for potential correlation. Not as simple as equality due to '*' syntax. * * @param string $wild The general IP address that is potentially wildcarded * @param IP $full The specific IP address we are checking * @return boolean Whether the IP addresses correlate */ function compare_ip_address(string $wild, string $full) : bool { $full = normalise_ip_address($full); if ($full == '') { return false; } if (strpos($full, '.') !== false) { return _compare_ip_address($wild, explode('.', $full), '.'); } return _compare_ip_address($wild, explode(':', $full), ':'); } /** * Compare two IP addresses for potential correlation, pre-exploded. Not as simple as equality due to '*' syntax. * * @param string $wild The general IP address that is potentially wildcarded * @param array $full_parts The exploded parts of the specific IP address we are checking * @param string $delimiter The delimiter * @return boolean Whether the IP addresses correlate */ function _compare_ip_address(string $wild, array $full_parts, string $delimiter) : bool { $wild = normalise_ip_address($wild, 4); if ($wild == '') { return false; } $wild_parts = explode($delimiter, $wild); foreach ($wild_parts as $i => $wild_part) { if (($wild_part != '*') && ($wild_part != $full_parts[$i])) { return false; } } return true; } /** * Check to see if an IP address is banned. * * @param string $ip The IP address to check for banning * @param boolean $force_db Force check via database only * @param boolean $handle_uncertainties Handle uncertainties (used for the external bans - if true, we may return null, showing we need to do an external check). Only works with $force_db. * @param ?boolean $is_unbannable Whether the IP is unbannable by spam standards; on an exclusion list or has a negative ban (returned by reference) (null: not set yet by caller) * @param ?integer $ban_until When the ban will expire, will always be more than the current timestamp (null: not set yet by caller / no expiration) * @param boolean $check_caching Whether to check internal run-time caching (disable if doing automated tests) * @return ?boolean Whether the IP address is banned (null: unknown) */ function ip_banned(string $ip, bool $force_db = false, bool $handle_uncertainties = false, ?bool &$is_unbannable = null, ?int &$ban_until = null, bool $check_caching = true) : ?bool { // NB: This function will make the first query called, so we will be a bit smarter, checking for errors $is_unbannable = false; $ban_until = null; static $cache = []; $sz = serialize([$force_db, $handle_uncertainties]); if ((isset($cache[$sz][$ip])) && ($check_caching)) { $is_unbannable = $cache[$sz][$ip]['is_unbannable']; $ban_until = $cache[$sz][$ip]['ban_until']; return $cache[$sz][$ip]['ret']; } // Edge case: No securitylogging addon if (!addon_installed('securitylogging')) { $ret = false; $cache[$sz][$ip] = [ 'is_unbannable' => $is_unbannable, 'ban_until' => $ban_until, 'ret' => $ret, ]; return $ret; } // Edge case: Commonly invalid IP if ($ip == '') { $ret = false; $cache[$sz][$ip] = [ 'is_unbannable' => $is_unbannable, 'ban_until' => $ban_until, 'ret' => $ret, ]; return $ret; } // Edge case: Excluded via config option (different from checking i_ban_positive===false, as this option is referenced in antispam.php too) $_exclusions = get_option('spam_check_exclusions'); $exclusions = explode(',', $_exclusions); foreach ($exclusions as $exclusion) { if (trim($ip) == $exclusion) { $ret = false; $is_unbannable = true; $cache[$sz][$ip] = [ 'is_unbannable' => $is_unbannable, 'ban_until' => $ban_until, 'ret' => $ret, ]; return $ret; } } // Read in IP bans - we need to check both DB and .htaccess for completeness (expiring bans will not be in .htaccess) $ip_bans = (function_exists('persistent_cache_get') && ($check_caching)) ? persistent_cache_get('IP_BANS') : null; if ($ip_bans === null) { $tables = ['banned_ip', 'usersubmitban_ip'/*LEGACY*/]; $ip_bans = null; foreach ($tables as $table) { $_ip_bans = $GLOBALS['SITE_DB']->table_exists($table, true); if (!$_ip_bans) { continue; } if ($ip_bans === null) { $ip_bans = []; } $start = 0; $max = 100; do { $rows = $GLOBALS['SITE_DB']->query_select($table, ['*'], [], '', $max, $start); foreach ($rows as $row) { $ip_bans[] = $row; } $start += $max; } while (!empty($rows)); break; } if ($ip_bans === null) { critical_error('DATABASE_FAIL'); } persistent_cache_set('IP_BANS', $ip_bans); } global $SITE_INFO; if ((is_file(get_file_base() . '/.htaccess')) && ((!$force_db) && (((isset($SITE_INFO['known_suexec'])) && ($SITE_INFO['known_suexec'] == '1')) || (cms_is_writable(get_file_base() . '/.htaccess'))))) { $bans = []; $htaccess_file = cms_file_get_contents_safe(get_file_base() . '/.htaccess', FILE_READ_LOCK); $ban_count = preg_match_all('#\n(Require not ip|Require not Ip) (.*)#i', $htaccess_file, $bans); // We define 'ip' and 'Ip' due to Turkish issue for ($i = 0; $i < $ban_count; $i++) { $ip_bans[$bans[2][$i]] = ['ip' => ip_apache_to_wild($bans[2][$i])]; } } // Get IP components, used for wildcard checks $ip4 = (strpos($ip, '.') !== false); if ($ip4) { $ip_parts = explode('.', $ip); } else { $ip_parts = explode(':', $ip); } // Check all bans in the database $server_ips = null; foreach ($ip_bans as $ban) { if ((isset($ban['i_ban_until'])) && ($ban['i_ban_until'] < time())) { $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'banned_ip WHERE i_ban_until IS NOT NULL AND i_ban_until<' . strval(time())); continue; } if ((($ip4) && (_compare_ip_address($ban['ip'], $ip_parts, '.'))) || ((!$ip4) && (_compare_ip_address($ban['ip'], $ip_parts, ':')))) { // Found a ban, which may be a positive or negative ban... // Edge case: Do not consider bans for anything that would match the server's own IP or a localhost IP (may only match as a wildcard) if ($server_ips === null) { $server_ips = get_server_ips(); } foreach ($server_ips as $server_ip) { if (compare_ip_address($ban['ip'], $server_ip)) { continue 2; } } // Get result if (array_key_exists('i_ban_positive', $ban)) { $ret = ($ban['i_ban_positive'] == 1); if (!$ret) { $is_unbannable = true; } $ban_until = $ban['i_ban_until']; } else { // LEGACY $ret = true; $ban_until = null; } $cache[$sz][$ip] = [ 'is_unbannable' => $is_unbannable, 'ban_until' => $ban_until, 'ret' => $ret, ]; return $ret; } } // Nothing found $ret = $handle_uncertainties ? null : false; $cache[$sz][$ip] = [ 'is_unbannable' => $is_unbannable, 'ban_until' => $ban_until, 'ret' => $ret, ]; return $ret; } /** * Convert Apache netmask syntax in IP addresses to simple software wildcard syntax. * * @param IP $ip The IP address (potentially encoded with netmask syntax) * @return string The software-style IP wildcard */ function ip_apache_to_wild(string $ip) : string { $matches = []; if (preg_match('#^(.*)/(\d+)$#', $ip, $matches) == 0) { $ip = normalise_ip_address($ip, 4); if ($ip == '') { return ''; } return $ip; } $ip_section = $matches[1]; $range_bits = $matches[2]; $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'; } if (($range_bits % $bits_per_part) != 0) { return ''; } $parts = explode($delimiter, $ip_section); $ip = ''; $bits_used = 0; foreach ($parts as $part) { if ($ip != '') { $ip .= $delimiter; } if ($bits_used >= $range_bits) { if ($part != $expected_blank_part) { return ''; } $ip .= '*'; } else { $ip .= $part; } $bits_used += $bits_per_part; } return $ip; } /** * Log an action. * * @param ID_TEXT $type The type of activity just carried out (a language string codename) * @param ?SHORT_TEXT $a The most important parameter of the activity (e.g. D) (null: none) * @param ?SHORT_TEXT $b A secondary (perhaps, human readable) parameter of the activity (e.g. caption) (null: none) * @param boolean $return_id Whether to return an ID to the log entry (forces immediate logging, rather than at script end) * @return ?AUTO_LINK Log ID (null: did not save a log) */ function log_it(string $type, ?string $a = null, ?string $b = null, bool $return_id = false) : ?int { require_code('global4'); global $RELATED_WARNING_ID; $related_warning_id = $RELATED_WARNING_ID; if ($return_id) { return _log_it($type, $a, $b, $related_warning_id); } if (peek_force_immediate_log_it()) { _log_it($type, $a, $b, $related_warning_id); return null; } cms_register_shutdown_function_safe(function () use ($type, $a, $b, $related_warning_id) { return _log_it($type, $a, $b, $related_warning_id); }); return null; } /** * Define whether log_it should forcefully execute immediately instead of trying to register itself in a shutdown function. * * @param boolean $value Whether log_it should forcefully log immediately */ function push_force_immediate_log_it(bool $value) { global $FORCE_IMMEDIATE_LOG_IT; $FORCE_IMMEDIATE_LOG_IT[] = $value; } /** * Remove a push_force_immediate_log_it value off of the end of our chain. */ function pop_force_immediate_log_it() { global $FORCE_IMMEDIATE_LOG_IT; array_pop($FORCE_IMMEDIATE_LOG_IT); } /** * Check whether log_it should be forcefully running immediately instead of registering as a shutdown function. * * @return boolean Whether log_it should run immediately */ function peek_force_immediate_log_it() : bool { global $FORCE_IMMEDIATE_LOG_IT; return end($FORCE_IMMEDIATE_LOG_IT); } /** * Escape a string to fit within PHP double quotes. * * @param string $in String in * @return string Resultant string */ function php_addslashes(string $in) : string { global $PHP_REP_FROM, $PHP_REP_TO; return str_replace($PHP_REP_FROM, $PHP_REP_TO, $in); } /** * Update the member tracker for the currently viewing user. * * @param ID_TEXT $page The page * @param ID_TEXT $type The type * @param ID_TEXT $id The ID */ function member_tracking_update(string $page, string $type, string $id) { if (get_value('disable_member_tracking') === '1') { return; } if (mt_rand(0, 100) == 1) { cms_register_shutdown_function_safe(function () { if (!$GLOBALS['SITE_DB']->table_is_locked('member_tracking')) { $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'member_tracking WHERE mt_time<' . strval(time() - 60 * intval(get_option('users_online_time')))); } }); } cms_register_shutdown_function_safe(function () use ($page, $type, $id) { $GLOBALS['SITE_DB']->query_insert_or_replace( 'member_tracking', [ 'mt_cache_username' => $GLOBALS['FORUM_DRIVER']->get_username(get_member(), true), 'mt_time' => time(), ], [ 'mt_member_id' => get_member(), 'mt_page' => $page, 'mt_type' => $type, 'mt_id' => $id, ] ); }); } /** * Find whether the current user is invisible. * * @return boolean Whether the current user is invisible */ function is_invisible() : bool { global $SESSION_CACHE; $s = get_session_id(); return (isset($SESSION_CACHE[$s])) && ($SESSION_CACHE[$s]['session_invisible'] == 1); } /** * Find the session tracking codes, most relevant first. * * @param ?EMAIL $email_address A new user's e-mail address, to be used to track an inviter/recommender (null: N/A) * @return array The tracking codes */ function find_session_tracking_codes(?string $email_address = null) : array { $tracking_codes = []; $tracking_code = get_param_string('_t', ''); if ($tracking_code != '') { $tracking_codes = array_merge($tracking_codes, explode(',', $tracking_code)); } if (addon_installed('stats')) { static $tracking_code_rows = null; if ($tracking_code_rows === null) { $tracking_code_rows = $GLOBALS['SITE_DB']->query_select('stats', ['tracking_code', 'date_and_time'], ['session_id' => get_session_id()], ' AND ' . db_string_not_equal_to('tracking_code', '') . ' ORDER BY date_and_time DESC'); } foreach ($tracking_code_rows as $tracking_code_row) { $tracking_codes = array_merge($tracking_codes, explode(',', $tracking_code_row['tracking_code'])); } } if ((addon_installed('recommend')) && (!empty($email_address))) { // This is not strictly a tracking code (it won't come up in the stats system for example), but we roll it into this function for simplicity static $inviter = false; if ($inviter === false) { $_inviter = $GLOBALS['FORUM_DB']->query_select('f_invites', ['i_invite_member', 'i_time'], ['i_email_address' => $email_address], 'ORDER BY i_time DESC', 1); $inviter = array_key_exists(0, $_inviter) ? $_inviter[0]['i_invite_member'] : null; } if ($inviter !== null) { $tracking_codes[] = strval($inviter); } } return array_unique($tracking_codes); } /** * Get the number of users on the site in the last 5 minutes. The function also maintains the statistic via the sessions table. * * @return integer The number of users on the site */ function get_num_users_site() : int { if (get_value('disable_user_online_counting') === '1') { return 1; } global $NUM_USERS_SITE_CACHE, $PEAK_USERS_EVER_CACHE, $PEAK_USERS_WEEK_CACHE; $users_online_time_seconds = 60 * intval(get_option('users_online_time')); $NUM_USERS_SITE_CACHE = get_value_newer_than('users_online', time() - $users_online_time_seconds / 2); /* Refreshes half way through the user online time, to approximate accuracy */ if ($NUM_USERS_SITE_CACHE === null) { $NUM_USERS_SITE_CACHE = get_value('users_online'); $count = 0; require_code('users2'); get_users_online(false, null, $count); $NUM_USERS_SITE_CACHE = strval($count); if (!$GLOBALS['SITE_DB']->table_is_locked('values')) { set_value('users_online', $NUM_USERS_SITE_CACHE); } } if (addon_installed('stats')) { // Store a peak record if there is one $PEAK_USERS_EVER_CACHE = get_value('user_peak'); if (($PEAK_USERS_EVER_CACHE === null) || ($PEAK_USERS_EVER_CACHE == '')) { $_peak_users_user = $GLOBALS['SITE_DB']->query_select_value_if_there('usersonline_track', 'MAX(peak)'); $PEAK_USERS_EVER_CACHE = ($_peak_users_user === null) ? $NUM_USERS_SITE_CACHE : strval($_peak_users_user); if (!$GLOBALS['SITE_DB']->table_is_locked('values')) { set_value('user_peak', $PEAK_USERS_EVER_CACHE); } } if (intval($NUM_USERS_SITE_CACHE) > intval($PEAK_USERS_EVER_CACHE)) { // New record $GLOBALS['SITE_DB']->query_insert_or_replace('usersonline_track', ['peak' => intval($NUM_USERS_SITE_CACHE)], ['date_and_time' => time()]); if (!$GLOBALS['SITE_DB']->table_is_locked('values')) { set_value('user_peak', $NUM_USERS_SITE_CACHE); } } // Store a 7-day-cycle peak record if we've made one $PEAK_USERS_WEEK_CACHE = get_value_newer_than('user_peak_week', time() - $users_online_time_seconds / 2); $store_anyway = false; if (($PEAK_USERS_WEEK_CACHE === null) || ($PEAK_USERS_WEEK_CACHE == '')) { $store_anyway = true; } if ((intval($NUM_USERS_SITE_CACHE) > intval($PEAK_USERS_WEEK_CACHE)) || ($store_anyway)) { $PEAK_USERS_WEEK_CACHE = $NUM_USERS_SITE_CACHE; // But also delete anything else in the last 7 days that was less than the new weekly peak record, to keep the stats clean (we only want 7 day peaks to be stored) $GLOBALS['SITE_DB']->query('DELETE FROM ' . get_table_prefix() . 'usersonline_track WHERE date_and_time>' . strval(time() - 60 * 60 * 24 * 7) . ' AND peak<=' . $PEAK_USERS_WEEK_CACHE); // Set record for week set_value('user_peak_week', $PEAK_USERS_WEEK_CACHE); $GLOBALS['SITE_DB']->query_insert('usersonline_track', ['date_and_time' => time(), 'peak' => intval($PEAK_USERS_WEEK_CACHE)], false, true); // errors suppressed in case of race condition } } return intval($NUM_USERS_SITE_CACHE); } /** * Get the largest amount of users ever to be on the site at the same time. * * @return integer The number of peak users */ function get_num_users_peak() : int { global $PEAK_USERS_EVER_CACHE; return intval($PEAK_USERS_EVER_CACHE); } /** * Escape certain special characters in the provided string, so that it can be embedded as text within HTML. * * @param string $string The input string * @return string The escaped string */ function escape_html(string $string) : string { /*if ($GLOBALS['XSS_DETECT']) { Useful for debugging if (ocp_is_escaped($string)) { @var_dump(debug_backtrace()); @exit('String double-escaped'); } }*/ global $XSS_DETECT, $ESCAPE_HTML_OUTPUT, $DECLARATIONS_STATE; $ret = @htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE); if (defined('I_UNDERSTAND_XSS') && !$DECLARATIONS_STATE[I_UNDERSTAND_XSS]) { $ESCAPE_HTML_OUTPUT[$ret] = true; } if ($XSS_DETECT) { ocp_mark_as_escaped($ret); } return $ret; } /** * Escape certain special characters in the provided string, so that it can be embedded as HTML within Comcode. * * @param mixed $string The input string * @return mixed The escaped string */ function escape_html_in_comcode($string) { $is_tempcode = is_object($string); if ($is_tempcode) { $string = $string->evaluate(); } $ret = preg_replace('#\[(/html)#i', '[$1', $string); if ($is_tempcode) { $ret = make_string_tempcode($ret); } return $ret; } /** * See's if the current browser matches some special property code. Assumes users are keeping up on newish browsers (as defined in tut_browsers). * * @param string $code The property code * @set android ios wysiwyg windows mac linux odd_os mobile ie gecko safari odd_browser chrome bot simplified_attachments_ui itunes * @param ?string $comcode Comcode that might be WYSIWYG edited; used to determine whether WYSIWYG may load when we'd prefer it to not do so (null: none) * @return boolean Whether there is a match */ function browser_matches(string $code, ?string $comcode = null) : bool { static $browser_matches_cache = []; if (isset($browser_matches_cache[$code])) { return $browser_matches_cache[$code]; } $browser = cms_strtolower_ascii(get_browser_string()); $os = cms_strtolower_ascii(get_os_string()); $is_safari = strpos($browser, 'applewebkit') !== false; $is_chrome = strpos($browser, 'chrome/') !== false; $is_gecko = (strpos($browser, 'gecko') !== false) && !$is_safari; $is_ie = ((strpos($browser, 'msie') !== false) || (strpos($browser, 'trident') !== false) || (strpos($browser, 'edge/') !== false)); switch ($code) { case 'simplified_attachments_ui': $browser_matches_cache[$code] = (get_option('simplified_attachments_ui') == '1') && (get_option('complex_uploader') == '1'); return $browser_matches_cache[$code]; case 'itunes': $browser_matches_cache[$code] = (get_param_integer('itunes', 0) == 1) || (strpos($browser, 'itunes') !== false); return $browser_matches_cache[$code]; case 'bot': $browser_matches_cache[$code] = (get_bot_type() !== null); return $browser_matches_cache[$code]; case 'android': $browser_matches_cache[$code] = strpos($browser, 'android') !== false; return $browser_matches_cache[$code]; case 'ios': $browser_matches_cache[$code] = strpos($browser, 'iphone') !== false || strpos($browser, 'ipad') !== false; return $browser_matches_cache[$code]; case 'wysiwyg': if ((get_option('wysiwyg') == '0') || ((is_mobile()) && (($comcode === null) || (strpos($comcode, 'html]') === false)))) { $browser_matches_cache[$code] = false; return false; } $browser_matches_cache[$code] = (strpos($browser, 'android') === false); // Using CKEditor, which does not yet support Android return $browser_matches_cache[$code]; case 'windows': $browser_matches_cache[$code] = (strpos($os, 'windows') !== false) || (strpos($os, 'win32') !== false); return $browser_matches_cache[$code]; case 'mac': $browser_matches_cache[$code] = strpos($os, 'mac') !== false; return $browser_matches_cache[$code]; case 'linux': $browser_matches_cache[$code] = strpos($os, 'linux') !== false; return $browser_matches_cache[$code]; case 'odd_os': $browser_matches_cache[$code] = (strpos($os, 'windows') === false) && (strpos($os, 'mac') === false) && (strpos($os, 'linux') === false); return $browser_matches_cache[$code]; case 'mobile': $browser_matches_cache[$code] = is_mobile(); return $browser_matches_cache[$code]; case 'ie': $browser_matches_cache[$code] = $is_ie; return $browser_matches_cache[$code]; case 'chrome': $browser_matches_cache[$code] = $is_chrome; return $browser_matches_cache[$code]; case 'gecko': $browser_matches_cache[$code] = $is_gecko; return $browser_matches_cache[$code]; case 'safari': $browser_matches_cache[$code] = $is_safari; return $browser_matches_cache[$code]; case 'odd_browser': $browser_matches_cache[$code] = !$is_safari && !$is_gecko && !$is_ie; return $browser_matches_cache[$code]; } // Should never get here return false; } /** * Look at the user's browser, and decide if they are viewing on a mobile device or not. * A mobile device can generally be considered a touchscreen device with a screen smaller than a desktop/laptop. * There is no perfect delineation. * * @param ?string $user_agent The user agent (null: get from environment, current user's browser) * @param boolean $include_tablets Whether to include tablets (which generally would be served the desktop version of a website) * @param boolean $truth Whether to always tell the truth (even if the current page does not have mobile support) * @return boolean Whether the user is using a mobile device */ function is_mobile(?string $user_agent = null, bool $include_tablets = false, bool $truth = false) : bool { static $is_mobile_cache = []; if (isset($is_mobile_cache[$user_agent][$include_tablets][$truth])) { return $is_mobile_cache[$user_agent][$include_tablets][$truth]; } if ((!function_exists('get_option')) || (get_theme_option('mobile_support') == '0')) { if (function_exists('get_option')) { $is_mobile_cache[null][false][false] = false; $is_mobile_cache[null][false][true] = false; $is_mobile_cache[null][true][false] = false; $is_mobile_cache[null][true][true] = false; $is_mobile_cache[$user_agent][false][false] = false; $is_mobile_cache[$user_agent][false][true] = false; $is_mobile_cache[$user_agent][true][false] = false; $is_mobile_cache[$user_agent][true][true] = false; } return false; } if ($user_agent === null) { $user_agent = $_SERVER['HTTP_USER_AGENT']; $user_agent_given = false; } else { $user_agent_given = true; } // If we are listing mobile pages, check to see if the current page was not listed global $SITE_INFO; if (((!isset($SITE_INFO['assume_full_mobile_support'])) || ($SITE_INFO['assume_full_mobile_support'] != '1')) && (isset($GLOBALS['FORUM_DRIVER'])) && (!$truth) && (running_script('index')) && (($theme = $GLOBALS['FORUM_DRIVER']->get_theme()) != 'default')) { $mobile_pages = get_theme_option('mobile_pages'); if ($mobile_pages != '') { $page = get_param_string('page', ''); // We intentionally do not use get_page_name, as that requires URL Monikers to work, which are not available early in boot (as needed by static cache) if ( (substr($mobile_pages, 0, 1) == '#' && substr($mobile_pages, -1) == '#' && preg_match($mobile_pages, get_zone_name() . ':' . $page) == 0) || ((substr($mobile_pages, 0, 1) != '#' || substr($mobile_pages, -1) != '#') && preg_match('#(^|,)\s*' . str_replace('#', '\#', preg_quote($page)) . '\s*(,|$)#', $mobile_pages) == 0 && preg_match('#(^|,)\s*' . str_replace('#', '\#', preg_quote(get_zone_name() . ':' . $page)) . '\s*(,|$)#', $mobile_pages) == 0) ) { $is_mobile_cache[null][false][false] = false; $is_mobile_cache[null][true][false] = false; $is_mobile_cache[$user_agent][false][false] = false; $is_mobile_cache[$user_agent][true][false] = false; return false; } } } // If specified by URL if (!$user_agent_given && !$truth) { $val = get_param_integer('keep_mobile', null); if ($val !== null) { $result = ($val == 1); if (isset($GLOBALS['FORUM_DRIVER'])) { $is_mobile_cache[null][false][false] = $result; $is_mobile_cache[null][true][false] = $result; } return $result; } } // The set of browsers (also change in static_cache.php) $mobile_agents = [ // Implication by technology claims 'WML', 'WAP', 'Wap', 'MIDP', // Mobile Information Device Profile // Generics 'Mobile', 'Smartphone', // Well known/important browsers/brands 'Mobile Safari', // Usually Android 'Android', 'iPhone', 'iPod', 'Opera Mobi', 'Opera Mini', 'BlackBerry', 'Windows Phone', 'nook browser', // Barnes and Noble ]; $tablets = [ 'iPad', 'tablet', 'kindle', 'silk', ]; if (((!isset($SITE_INFO['no_extra_mobiles'])) || ($SITE_INFO['no_extra_mobiles'] != '1')) && (is_file(get_file_base() . '/text_custom/mobile_devices.txt'))) { require_code('files'); $mobile_devices = cms_parse_ini_file_fast((get_file_base() . '/text_custom/mobile_devices.txt')); foreach ($mobile_devices as $key => $val) { if ($val == 1) { $mobile_agents[] = $key; } else { $tablets[] = $key; } } } // The regular test $is_tablet = (preg_match('/(' . implode('|', $tablets) . ')/i', $user_agent) != 0) || (strpos($user_agent, 'Android') !== false) && (strpos($user_agent, 'Mobile') === false); $result = (preg_match('/(' . implode('|', $mobile_agents) . ')/i', $user_agent) != 0) && (($include_tablets) || (!$is_tablet)); if (isset($GLOBALS['FORUM_DRIVER'])) { $is_mobile_cache[$user_agent][$include_tablets][$truth] = $result; } return $result; } /** * Get the name of a webcrawler bot, or null if no bot detected. * * @param ?string $agent User agent (null: read from environment) * @return ?string Webcrawling bot name (null: not a bot) */ function get_bot_type(?string $agent = null) : ?string { $agent_given = ($agent !== null); if (!$agent_given) { global $BOT_TYPE_CACHE; if ($BOT_TYPE_CACHE !== false) { return $BOT_TYPE_CACHE; } $agent = $_SERVER['HTTP_USER_AGENT']; } if (strpos($agent, 'WebKit') !== false || strpos($agent, 'Trident') !== false || strpos($agent, 'MSIE') !== false || strpos($agent, 'Firefox') !== false || strpos($agent, 'Opera') !== false) { if (strpos($agent, 'bot') === false) { // Quick exit path if (!$agent_given) { $BOT_TYPE_CACHE = null; } return null; } } $agent = cms_strtolower_ascii($agent); global $BOT_MAP_CACHE, $SITE_INFO; if ($BOT_MAP_CACHE === null) { if (((!isset($SITE_INFO['no_extra_bots'])) || ($SITE_INFO['no_extra_bots'] != '1')) && (is_file(get_file_base() . '/text_custom/bots.txt'))) { require_code('files'); $BOT_MAP_CACHE = cms_parse_ini_file_fast(get_file_base() . '/text_custom/bots.txt'); } else { $BOT_MAP_CACHE = [ 'zyborg' => 'Looksmart', 'googlebot' => 'Google', 'mediapartners-google' => 'Google Adsense', 'teoma' => 'Teoma', 'jeeves' => 'Ask Jeeves', 'ultraseek' => 'Infoseek', 'ia_archiver' => 'Alexa/Archive.org', 'msnbot' => 'Bing/MSN', 'bingbot' => 'Bing', 'mantraagent' => 'Looksmart', 'wisenutbot' => 'Looksmart', 'paros' => 'Paros', 'sqworm' => 'Aol.com', 'baidu' => 'Baidu', 'facebookexternalhit' => 'Facebook', 'yandex' => 'Yandex', 'daum' => 'Daum', 'ahrefsbot' => 'Ahrefs', 'mj12bot' => 'Majestic-12', 'blexbot' => 'webmeup', 'duckduckbot' => 'DuckDuckGo', 'semrushbot' => 'Semrush', 'twitterbot' => 'Twitter', 'exabot' => 'Exalead', 'petalbot' => 'Huawei', 'coccocbot' => 'Coc Coc', 'slurp' => 'Yahoo', 'applebot' => 'Apple', 'uptimerobot' => 'UptimeRobot', 'discordbot' => 'Discord', 'linkedinbot' => 'LinkedIn', 'bytespider' => 'ByteDance', 'datanyze' => 'Datanyze', 'rogerbot' => 'Moz', 'dotbot' => 'DotBot', 'seoscanners' => 'SEO Scanners', 'sitebulb' => 'Sitebulb', 'screaming frog seo spider' => 'Screaming Frog', ]; } } foreach ($BOT_MAP_CACHE as $id => $name) { if ($name == '') { continue; } if (strpos($agent, $id) !== false) { if (!$agent_given) { $BOT_TYPE_CACHE = $name; } return $name; } } // Fallback in case we might have missed likely bots in our list if ((strpos($agent, 'bot') !== false) || (strpos($agent, 'spider') !== false)) { $to_a = strpos($agent, ' '); if ($to_a === false) { $to_a = strlen($agent); } $to_b = strpos($agent, '/'); if ($to_b === false) { $to_b = strlen($agent); } $name = substr($agent, 0, min($to_a, $to_b)); if (!$agent_given) { $BOT_TYPE_CACHE = $name; } return $name; } if (!$agent_given) { $BOT_TYPE_CACHE = null; } return null; } /** * Determine whether the user's browser supports cookies or not. * * @return boolean Whether the user has definitely got cookies */ function has_cookies() : bool // Will fail on users first visit, but then will catch on { static $has_cookies_cache = null; if ($has_cookies_cache !== null) { return $has_cookies_cache; } /*if (($GLOBALS['DEV_MODE']) && (get_param_integer('keep_debug_has_cookies', 0) == 0) && (!running_script('commandr'))) We know this works by now, was tested for years. Causes annoyance when developing { $_COOKIE = []; return false; }*/ if (isset($_COOKIE['has_cookies'])) { $has_cookies_cache = true; return true; } if (running_script('index')) { $result = cms_setcookie('has_cookies', '1', 'ESSENTIAL', false, false); $has_cookies_cache = $result; return $result; } $has_cookies_cache = false; return false; } /** * Get whether the current user explicitly allowed cookies in the cookie consent notice. * * @param ID_TEXT $category The cookie category to check * @return boolean Whether cookies were allowed */ function allowed_cookies(string $category = 'ESSENTIAL') : bool { if (!isset($_COOKIE['cc_cookie'])) { return false; } $cookie_consent_data_parsed = urldecode($_COOKIE['cc_cookie']); $cookie_consent_data = @json_decode($cookie_consent_data_parsed, true); if ($cookie_consent_data === false) { return false; } if (!isset($cookie_consent_data['categories'])) { return false; } if (!in_array($category, $cookie_consent_data['categories'])) { return false; } return true; } /** * Determine whether the user's browser supports JavaScript or not. * Unfortunately this function will only return true once a user has been to the site more than once... JavaScript will set a cookie, indicating it works. * * @return boolean Whether the user has definitely got JavaScript */ function has_js() : bool { if (!function_exists('get_option')) { return true; } if (get_option('detect_javascript') == '0') { return true; } if (get_param_integer('keep_has_js', 0) == 1) { return true; } if (get_param_integer('keep_has_js', null) === 0) { return false; } return (isset($_COOKIE['has_js'])) && ($_COOKIE['has_js'] == '1'); } /** * Get a word-filtered version of the specified text. * * @param string $text Text to filter * @return string Filtered version of the input text */ function wordfilter_text(string $text) : string { if (!addon_installed('wordfilter')) { return $text; } if ($GLOBALS['IN_MINIKERNEL_VERSION']) { return $text; } require_code('wordfilter'); return check_wordfilter($text, null, true); } /** * Assign this to explicitly declare that a variable may be of mixed type, and initialise to null. * * @return ?mixed Of mixed type (null: default) */ function mixed() { return null; } /** * Get meta information for specified resource. * * @param ID_TEXT $type The type of resource (e.g. download) * @param ID_TEXT $id The ID of the resource * @return array A tuple: The first element is the meta keyword string for the specified resource, the second is the meta description string, the third is meta keywords that are for syndication only */ function seo_meta_get_for(string $type, string $id) : array { $cache = function_exists('persistent_cache_get') ? persistent_cache_get(['seo', $type, $id]) : null; if ($cache !== null) { return $cache; } $where = ['meta_for_type' => $type, 'meta_for_id' => $id]; $cache = ['', '', '']; $rows = $GLOBALS['SITE_DB']->query_select('seo_meta_keywords', ['meta_keyword', 'id', 'sort_order'], $where, 'ORDER BY sort_order'); foreach ($rows as $row) { if ($cache[0] != '') { $cache[0] .= ','; } $keyword = get_translated_text($row['meta_keyword']); if (substr($keyword, 0, 1) == '#') { $keyword = substr($keyword, 1); $cache[2] .= $keyword; } $cache[0] .= $keyword; } $rows = $GLOBALS['SITE_DB']->query_select('seo_meta', ['meta_description'], $where, '', 1); if (array_key_exists(0, $rows)) { $cache[1] = get_translated_text($rows[0]['meta_description']); } persistent_cache_set(['seo', $type, $id], $cache); return $cache; } /** * Load the specified resource's meta information into the system for use on this page. * Also, if the title is specified then this is used for the page title. * * @sets_output_state * * @param ID_TEXT $type The type of resource (e.g. download) * @param ID_TEXT $id The ID of the resource * @param ?string $title The page-specific title to use, in Comcode or plain-text format with possible HTML entities included [Comcode will later be stripped] (null: none) */ function seo_meta_load_for(string $type, string $id, ?string $title = null) { if (!$GLOBALS['IS_VIRTUALISED_REQUEST']) { $result = seo_meta_get_for($type, $id); global $SEO_KEYWORDS, $SEO_DESCRIPTION, $SHORT_TITLE; if ($result[0] != '') { $SEO_KEYWORDS = array_map('trim', explode(',', trim($result[0], ','))); } if ($result[1] != '') { $SEO_DESCRIPTION = $result[1]; } if ($title !== null) { set_short_title(str_replace('–', '-', str_replace('©', '(c)', str_replace(''', '\'', $title)))); } } // Otherwise don't bother (this is an optimisation) } /** * Get Tempcode for tags, based on loaded up from SEO keywords (seo_meta_load_for). * * @param ?ID_TEXT $limit_to The search code for this tag content (e.g. downloads) (null: there is none) * @param ?array $the_tags Explicitly pass a list of tags instead (null: use loaded ones) * @return Tempcode Loaded tag output (or blank if there are none) */ function get_loaded_tags(?string $limit_to = null, ?array $the_tags = null) : object { if (get_value('disable_tags') === '1') { return new Tempcode(); } if (!addon_installed('search')) { return new Tempcode(); } if ($the_tags === null) { global $SEO_KEYWORDS; $the_tags = $SEO_KEYWORDS; } $tags = []; if ($the_tags !== null) { $search_limiter_no = ['all_defaults' => '1']; if ($limit_to !== null) { $search_limiter_no['search_' . $limit_to] = '1'; $search_limiter_no['all_defaults'] = '0'; } if ($limit_to !== null) { $search_limiter_yes = []; $search_limiter_yes['search_' . $limit_to] = '1'; $search_limiter_yes['all_defaults'] = '0'; } else { $search_limiter_yes = $search_limiter_no; } foreach ($the_tags as $tag) { $tag = trim($tag); if ($tag == '') { continue; } $tags[] = [ 'TAG' => $tag, 'LINK_LIMITEDSCOPE' => build_url(['page' => 'search', 'type' => 'results', 'content' => '"' . $tag . '"', 'only_search_meta' => '1'] + $search_limiter_yes, get_module_zone('search')), 'LINK_FULLSCOPE' => build_url(['page' => 'search', 'type' => 'results', 'content' => '"' . $tag . '"', 'only_search_meta' => '1'] + $search_limiter_no, get_module_zone('search')), ]; } } return do_template('TAGS', ['_GUID' => '2cd542a245bc7d1c3f10e858e8fc5159', 'TAGS' => $tags, 'TYPE' => ($limit_to === null) ? '' : $limit_to]); } /** * Get the default page for a zone. * * @param ID_TEXT $zone_name Zone name * @param boolean $zone_missing Whether the zone is missing (returned by reference) * @return ID_TEXT Default page */ function get_zone_default_page(string $zone_name, bool &$zone_missing = false) : string { $zone_missing = false; if ($zone_name == '_SELF') { $zone_name = get_zone_name(); } /*$p_test = function_exists('persistent_cache_get') ? persistent_cache_get(['ZONE', $zone_name]) : null; Better to get from ALL_ZONES_TITLED, less cache volume if ($p_test !== null) { return $p_test['zone_default_page']; }*/ global $ZONE; if (($ZONE !== null) && ($ZONE['zone_name'] == $zone_name) && ($ZONE['zone_default_page'] !== null)) { return $ZONE['zone_default_page']; } global $ZONE_DEFAULT_PAGES_CACHE; if (!isset($ZONE_DEFAULT_PAGES_CACHE[$zone_name])) { $_zone_default_page = null; if (function_exists('persistent_cache_get')) { $temp = persistent_cache_get('ALL_ZONES_TITLED'); if ($temp !== null) { $_zone_default_page = []; foreach ($temp as $_temp) { list($_zone_name, , $zone_default_page) = $_temp; $_zone_default_page[] = ['zone_name' => $_zone_name, 'zone_default_page' => $zone_default_page]; } } } if ($_zone_default_page === null) { if (running_script('install')) { // Can't run $SITE_DB in installer $ZONE_DEFAULT_PAGES_CACHE[$zone_name] = DEFAULT_ZONE_PAGE_NAME; return DEFAULT_ZONE_PAGE_NAME; } $_zone_default_page = $GLOBALS['SITE_DB']->query_select('zones', ['zone_name', 'zone_default_page', 'zone_title'], []/*Load multiple so we can cache for performance ['zone_name' => $zone_name]*/, 'ORDER BY zone_title', 50/*reasonable limit; zone_title is sequential for default zones*/); } foreach ($_zone_default_page as $zone_row) { $ZONE_DEFAULT_PAGES_CACHE[$zone_row['zone_name']] = $zone_row['zone_default_page']; } if (!isset($ZONE_DEFAULT_PAGES_CACHE[$zone_name])) { $zone_missing = true; $ZONE_DEFAULT_PAGES_CACHE[$zone_name] = DEFAULT_ZONE_PAGE_NAME; } } return $ZONE_DEFAULT_PAGES_CACHE[$zone_name]; } /** * Turn a boring codename, into a "pretty" title. * * @param ID_TEXT $boring The codename * @return string The title */ function titleify(string $boring) : string { $ret = $boring; if (strpos($ret, '/') !== false || strpos($ret, '\\') !== false) { $ret = preg_replace('#([/\\\\])#', ' ${1} ', $ret); } $ret = cms_ucwords_ascii(trim(str_replace('_', ' ', $ret))); $acronyms = [ 'CMS', 'CPF', 'CSV', 'CNS', 'URL', 'ID', 'UI', 'HTML', 'MSN', 'LDAP', 'SMS', 'SSL', 'XML', 'CSS', 'SEO', 'JavaScript', 'TTL', ]; foreach ($acronyms as $acronym) { if (stripos($ret, $acronym) !== false) { $ret = cms_preg_replace_safe('#(^|\s)' . preg_quote($acronym, '#') . '(\s|$)#i', '$1' . $acronym . '$2', $ret); } } if (strpos($ret, 'Ecommerce') !== false) { $ret = str_replace('Ecommerce', addon_installed('ecommerce') ? do_lang('ecommerce:ECOMMERCE') : 'eCommerce', $ret); } if (strpos($ret, 'Cpfs') !== false) { $ret = str_replace('Cpfs', do_lang('cns:CUSTOM_PROFILE_FIELDS'), $ret); } if (strpos($ret, 'Captcha') !== false) { $ret = str_replace('Captcha', addon_installed('captcha') ? do_lang('captcha:CAPTCHA') : 'CAPTCHA', $ret); } $ret = str_replace('Adminzone', do_lang('ADMIN_ZONE'), $ret); $ret = str_replace('Emails', do_lang('EMAILS'), $ret); $ret = str_replace('Phpinfo', 'PHP-Info', $ret); $ret = str_replace('CNS', 'Conversr', $ret); if (strpos($ret, 'Default Set') !== false) { $ret = str_replace('Default Set/cartoons', do_lang('cns:AVATARS_CARTOONS'), $ret); $ret = str_replace('Default Set/thematic', do_lang('cns:AVATARS_THEMATIC'), $ret); $ret = str_replace('Default Set', do_lang('cns:AVATARS_MISC'), $ret); } if ($GLOBALS['XSS_DETECT'] && ocp_is_escaped($boring)) { ocp_mark_as_escaped($ret); } return $ret; } /** * Propagate Filtercode through links. * * @param ID_TEXT $prefix Prefix for main filter environment variable * @return array Extra URL mappings */ function propagate_filtercode(string $prefix = '') : array { $active_filter = either_param_string(($prefix == '') ? 'active_filter' : ($prefix . '_active_filter'), '', INPUT_FILTER_NONE); $map = []; if ($active_filter != '') { $map['active_filter'] = $active_filter; foreach (array_keys($_GET + $_POST) as $key) { if (substr($key, 0, 7) == 'filter_') { $map[$key] = either_param_string($key, '', INPUT_FILTER_NONE); } } } return $map; } /** * Propagate Filtercode through page-links. * * @return string Extra page-link mappings */ function propagate_filtercode_page_link() : string { $map = propagate_filtercode(); $_map = ''; foreach ($map as $key => $val) { $_map .= ':' . $key . '=' . urlencode($val); } return $_map; } /** * Make some text fractionally editable (i.e. inline editable). * * @param ID_TEXT $content_type Content type * @param mixed $id Content ID * @param mixed $title Content title (either unescaped string, or Compiled Comcode [i.e. Tempcode]) * @return Tempcode Inline editable HTML to put into output */ function make_fractionable_editable(string $content_type, $id, $title) : object { require_code('content'); $ob = get_content_object($content_type); $info = $ob->info(); $title_field = array_key_exists('title_field_post', $info) ? $info['title_field_post'] : $info['title_field']; if (is_array($title_field)) { return $title; } $parameters = [ is_object($title) ? $title->evaluate() : $title, array_key_exists('edit_page_link_field', $info) ? $info['edit_page_link_field'] : preg_replace('#^\w\w?_#', '', $title_field), array_key_exists('edit_page_link_pattern_post', $info) ? str_replace('_WILD', is_integer($id) ? strval($id) : $id, $info['edit_page_link_pattern_post']) : preg_replace('#:_(.*)#', ':__${1}', str_replace('_WILD', is_integer($id) ? strval($id) : $id, $info['edit_page_link_pattern'])), (array_key_exists('title_field_supports_comcode', $info) && $info['title_field_supports_comcode']) ? '1' : '0', ]; return directive_tempcode('FRACTIONAL_EDITABLE', is_object($title) ? $title : escape_html($title), $parameters); } /** * Find whether a fractional edit is underway. * * @return boolean Whether a fractional (inline) edit is underway */ function fractional_edit() : bool { return post_param_integer('fractional_edit', 0) == 1; } /** * Convert some HTML to plain text. * * @param string $in HTML * @return string Plain text */ function strip_html(string $in) : string { if ((strpos($in, '<') === false) && (strpos($in, '&') === false)) { return $in; // Optimisation } $text = $in; // Normalise line breaks $text = cms_preg_replace_safe('#\s+#', ' ', $text); $text = cms_preg_replace_safe('#(<br(\s[^<>]*)?' . '>)#i', '$1' . "\n", $text); // Special stuff to strip $search = [ '#<script[^>]*?' . '>.*?</script>#si', // Strip out JavaScript '#<style[^>]*?' . '>.*?</style>#siU', // Strip style tags properly '#<![\s\S]*?--[ \t\n\r]*>#', // Strip multi-line comments including CDATA ]; $text = preg_replace($search, '', $text); // ASCII conversion if (get_charset() != 'utf-8') { $text = str_replace(['–', '—', '…', '·', '“', '”', '‘', '’'], ['-', '-', '...', '|', '"', '"', "'", "'"], $text); } require_code('webstandards'); global $TAGS_BLOCK; $_block_tags = '(' . implode('|', array_keys($TAGS_BLOCK)) . ')'; // Remove leading/trailing space from block tags $text = cms_preg_replace_safe('#\s*(<' . $_block_tags . '(\s[^<>]*)?' . '>)#i', '$1', $text); $text = cms_preg_replace_safe('#(</' . $_block_tags . '>)\s*#i', '$1', $text); // Add space before block tags $text = cms_preg_replace_safe('#([^\s])(<' . $_block_tags . '>)#i', '$1 $2', $text); // Add space after block tags $text = cms_preg_replace_safe('#(</' . $_block_tags . '>)([^\s])#i', '$1 $3', $text); // Strip remaining HTML tags $text = strip_tags($text); $text = @html_entity_decode($text, ENT_QUOTES); // Trim each line (as spacing can't be perfected) $text = preg_replace('#^ *(.*?) *$#m', '$1', $text); return $text; } /** * Convert GUIDs to IDs in some text. * * @param string $text Input text * @return string Output text */ function convert_guids_to_ids(string $text) : string { if (!addon_installed('commandr')) { return $text; } $matches = []; $num_matches = preg_match_all('#^{?([0-9a-fA-F]){8}(-([0-9a-fA-F]){4}){3}-([0-9a-fA-F]){12}}?$#', $text, $matches); if ($num_matches != 0) { require_code('resource_fs'); $guids = []; for ($i = 0; $i < $num_matches; $i++) { $guids[] = $matches[0][$i]; } $mappings = find_ids_via_guids($guids); foreach ($mappings as $guid => $id) { $text = str_replace($guid, $id, $text); } } return $text; } /** * Set if a mass-import is in progress. * * @param boolean $doing_mass_import If it is */ function set_mass_import_mode(bool $doing_mass_import = true) { global $MASS_IMPORT_HAPPENING; $MASS_IMPORT_HAPPENING = $doing_mass_import; } /** * Find if a mass-import is in progress. * * @return boolean If it is */ function get_mass_import_mode() : bool { global $MASS_IMPORT_HAPPENING; return $MASS_IMPORT_HAPPENING; } /** * Run some code. Bail out on failure. * We cannot always use this over 'eval' because the code will run in a separate scope. * * @param string $code Code to eval * @param string $context A context, used in error messages, to help understand where errors may be * @param boolean $trigger_error Trigger fatal error; even if false an error will be attached * @return mixed The error response */ function cms_eval(string $code, string $context, bool $trigger_error = true) { push_suppress_error_death(true); if (function_exists('error_clear_last')) { error_clear_last(); } $errormsg_before = error_get_last(); try { $result = eval($code); $attach_manually = false; $errormsg = cms_error_get_last(); $errorline = 0; if (($errormsg == '') || ($errormsg === $errormsg_before)) { $errormsg = ''; $errorline = 0; } } catch (Exception $e) { $result = false; $attach_manually = true; $errormsg = $e->getMessage(); $errorline = $e->getLine(); } catch (Error $e) { $result = false; $attach_manually = true; $errormsg = $e->getMessage(); $errorline = $e->getLine(); } pop_suppress_error_death(); if (($result === false) && ($errormsg != '')) { // It is possible for this to trigger incorrectly. If we've "@"d something, and explicitly returned false, the hidden error will come through. if ($trigger_error) { fatal_exit(protect_from_escaping(escape_html($context) . ': ' . $errormsg . ' (line ' . strval($errorline) . ', eval\'d code)')); } else { if (($attach_manually) && (get_option('error_handling_errors') != 'SKIP')) { attach_message(protect_from_escaping(escape_html($context) . ': ' . $errormsg), 'notice'); // Won't attach naturally and won't show in a fatal error, so we must attach it } // other errors will have still been attached anyway (depending on error_handling_* configuration) } } return $result; } /** * Exit if we are running on a Google App Engine application (live or development). */ function appengine_general_guard() { if (GOOGLE_APPENGINE) { warn_exit(do_lang_tempcode('NOT_ON_GOOGLE_APPENGINE')); } } /** * Exit if we are running on a live Google App Engine application. */ function appengine_live_guard() { if (appengine_is_live()) { warn_exit(do_lang_tempcode('NOT_ON_LIVE_GOOGLE_APPENGINE')); } } /** * Update a catalogue content field reference, to a new value. * * @param ID_TEXT $type Content type * @param ID_TEXT $from Old value * @param ID_TEXT $to New value */ function update_catalogue_content_ref(string $type, string $from, string $to) { if ($GLOBALS['SITE_DB']->driver->has_update_joins()) { $GLOBALS['SITE_DB']->query_update('catalogue_fields f JOIN ' . $GLOBALS['SITE_DB']->get_table_prefix() . 'catalogue_efv_short v ON v.cf_id=f.id', ['cv_value' => $to], ['cv_value' => $from, 'cf_type' => $type]); } else { $fields = $GLOBALS['SITE_DB']->query_select('catalogue_fields', ['id'], ['cf_type' => $type]); foreach ($fields as $field) { $GLOBALS['SITE_DB']->query_update('catalogue_efv_short', ['cv_value' => $to], ['cv_value' => $from, 'cf_id' => $field['id']]); } } } /** * Start a profiling block, for a specified identifier (of your own choosing). * * @param ID_TEXT $identifier Identifier */ function cms_profile_start_for(string $identifier) { require_code('profiler'); _cms_profile_start_for($identifier); } /** * End a profiling block, for a specified identifier (of your own choosing - but you must have started it with cms_profile_start_for). * * @param ID_TEXT $identifier Identifier * @param ?string $specifics Longer details of what happened (e.g. a specific SQL query that ran) (null: none provided) */ function cms_profile_end_for(string $identifier, ?string $specifics = null) { require_code('profiler'); _cms_profile_end_for($identifier, $specifics); } /** * Get the conventional name of a parameter for a particular file identifier. * HTTP POST parameters will have 'e_' prepended to this. * * @param ID_TEXT $file File identifier * @return ID_TEXT Parameter name * * @ignore */ function get_dynamic_file_parameter(string $file) : string { return str_replace(['/', ':', '.'], ['__', '__', '__'], $file); } /** * Whether smart decaching is enabled. It is slightly inefficient but makes site development easier for people. * * @param boolean $support_temporary_disable Support it being temporarily disabled * @return boolean If smart decaching is enabled */ function support_smart_decaching(bool $support_temporary_disable = false) : bool { if ($support_temporary_disable) { global $DISABLE_SMART_DECACHING_TEMPORARILY; if ($DISABLE_SMART_DECACHING_TEMPORARILY) { return false; } } static $has_in_url = null; if ($has_in_url === null) { $has_in_url = (get_param_integer('keep_smart_decaching', 0) == 1); } if ($has_in_url) { return true; } global $SITE_INFO; if (!empty($SITE_INFO['disable_smart_decaching'])) { if ($SITE_INFO['disable_smart_decaching'] == '1') { return false; } static $has_temporary = null; if ($has_temporary === null) { $has_temporary = false; $matches = []; if (preg_match('#^(\d+):(.*)$#', $SITE_INFO['disable_smart_decaching'], $matches) != 0) { $time = intval($matches[1]); $path = $matches[2]; if (is_file($path) && filemtime($path) > time() - $time) { $has_temporary = true; } } } return $has_temporary; } return true; // By default it is on } /** * For performance reasons disable smart decaching for cases that allow it to be disabled temporarily (it does a lot of file system checks). */ function disable_smart_decaching_temporarily() { global $DISABLE_SMART_DECACHING_TEMPORARILY; $DISABLE_SMART_DECACHING_TEMPORARILY = true; } /** * Find if the current request has POST fields worth considering/propagating. Very standard framework fields will be ignored. * * @return boolean Whether it does */ function has_interesting_post_fields() : bool { foreach (array_keys($_POST) as $field_name) { if (!is_string($field_name)) { $field_name = strval($field_name); } if (!is_control_field($field_name, false, true)) { return true; } } return false; } /** * Find if a POST field is a password field. * * @param string $field_name Field to check * @return boolean If it's a password field */ function is_password_field(string $field_name) : bool { $password_fields = [ 'password', 'confirm_password', 'password_confirm', 'edit_password', 'decrypt', ]; return in_array($field_name, $password_fields); } /** * Find if a POST field is some kind of special control field rather than interesting standalone data. * * @param string $field_name Field to check * @param boolean $include_email_metafields Consider e-mail metafields as control fields * @param boolean $include_login_fields Consider login fields as control fields * @param array $extra_boring_fields Additional boring fields to skip * @param boolean $include_password_fields Consider password fields as control fields * @return boolean If it's a control field */ function is_control_field(string $field_name, bool $include_email_metafields = false, bool $include_login_fields = false, array $extra_boring_fields = [], bool $include_password_fields = false) : bool { // NB: Keep this function synced with the copy of it in static_export.php if ($include_password_fields) { if (is_password_field($field_name)) { return true; } } $boring_fields = [ // Passed through metadata 'id', // Passed through data 'stub', // Passed through context 'from_url', 'http_referer', 'redirect', 'redirect_passon', // Image buttons send these 'x', 'y', // Relating to uploads/attachments 'MAX_FILE_SIZE', // Relating to preview 'perform_webstandards_check', // Relating to posting form/WYSIWYG 'posting_ref_id', 'f_face', 'f_colour', 'f_size', // Relating to security 'session_id', 'csrf_token', 'js_token', 'y' . md5(get_base_url() . ': antispam'), 'captcha', 'g-recaptcha-response', // Data relaying for Suhosin workaround 'post_data', ]; if ($include_email_metafields) { $boring_fields = array_merge($boring_fields, [ 'subject', 'title', 'name', 'email', 'to_members_email', 'to_written_name', ]); } if ($include_login_fields) { $boring_fields = array_merge($boring_fields, [ 'username', 'password', 'remember_me', 'login_invisible', ]); } if (in_array($field_name, $boring_fields)) { return true; } if (in_array($field_name, $extra_boring_fields)) { return true; } $prefixes = [ // Standard hidden-fields convention '_', // Form metadata 'label_for__', 'description_for__', 'tick_on_form__', 'require__', 'comcode_page_hint_', // Relating to uploads/attachments 'hid_file_id_', 'hidFileName_', // Relating to preview 'pre_f_', 'tempcodecss__', 'comcode__', // Relating to permissions setting 'access_', ]; if ($include_email_metafields) { $prefixes = array_merge($prefixes, [ 'field_tagged__', ]); } foreach ($prefixes as $prefix) { if (substr($field_name, 0, strlen($prefix)) == $prefix) { return true; } } $suffixes = [ // Relating to posting form/WYSIWYG '_parsed', '__is_wysiwyg', ]; foreach ($suffixes as $suffix) { if (substr($field_name, -strlen($suffix)) == $suffix) { return true; } } $substrings = [ // Passed through metadata 'confirm', ]; foreach ($substrings as $substring) { if (strpos($field_name, $substring) !== false) { return true; } } if (($field_name[0] == 'x') && (strlen($field_name) == 33)) { return true; } return false; } /** * Apply escaping for an HTTP header. * * @param string $str Text to insert into header * @param boolean $within_quotes Text is between quotes * @return string Escaped text */ function escape_header(string $str, bool $within_quotes = false) : string { if ($within_quotes) { $str = addslashes($str); } return str_replace(["\r", "\n"], ['', ''], $str); } /** * Find if running as CLI (i.e. on the command prompt). This implies admin credentials (web users can't initiate a CLI call), and text output. * * @return boolean Whether running as CLI */ function is_cli() : bool { return (php_function_allowed('php_sapi_name')) && (php_sapi_name() == 'cli') && ($_SERVER['REMOTE_ADDR'] == ''); } /** * Get login URL. * * @return array A triple: URL to log in from, URL to direct login form posts to, URL to join from */ function get_login_url() : array { $_lead_source_description = either_param_string('_lead_source_description', ''); if ($_lead_source_description == '') { global $METADATA; $_lead_source_description = (isset($METADATA['real_page']) ? $METADATA['real_page'] : get_page_name()) . ' (' . get_self_url_easy() . ')'; } if (has_interesting_post_fields() || (get_page_name() == 'join') || (currently_logging_in()) || (get_page_name() == 'lost_password')) { $_this_url = build_url(['page' => ''], '_SELF', ['keep_session' => true]); } else { $_this_url = build_url(['page' => '_SELF'], '_SELF', ['keep_session' => true, 'redirect' => true], true); } $url_map = ['page' => 'login', 'type' => 'browse']; if ((has_interesting_post_fields()) || (get_option('page_after_login') == '')) { $url_map['redirect'] = protect_url_parameter($_this_url); } $full_url = build_url($url_map, get_module_zone('login')); $url_map = ['page' => 'login', 'type' => 'login']; if ((has_interesting_post_fields()) || (get_option('page_after_login') == '')) { $url_map['redirect'] = protect_url_parameter($_this_url); } $login_url = build_url($url_map, get_module_zone('login')); $join_url = null; switch (get_forum_type()) { case 'cns': $join_url = build_url(['page' => 'join', '_lead_source_description' => $_lead_source_description, 'redirect' => protect_url_parameter($_this_url)], get_module_zone('join')); break; case 'none': $join_url = ''; break; default: $join_url = $GLOBALS['FORUM_DRIVER']->join_url(true); if (is_string($join_url)) { $join_url = make_string_tempcode($join_url); } break; } return [$full_url, $login_url, $join_url]; } /** * Find the filesystem owner of the website. * Will always return 0 on Windows. * * @return integer Owner */ function website_file_owner() : int { if (running_script('install')) { $path_to_check = get_file_base() . '/install.php'; } else { $path_to_check = get_file_base() . '/sources/bootstrap.php'; } return fileowner($path_to_check); } /** * Find the filesystem group of the website. * Will always return 0 on Windows. * * @return integer Group */ function website_file_group() : int { if (running_script('install')) { $path_to_check = get_file_base() . '/install.php'; } else { $path_to_check = get_file_base() . '/sources/bootstrap.php'; } return filegroup($path_to_check); } /** * Find the creation time of the website. * * @return TIME Time */ function website_creation_time() : int { if (running_script('install')) { $path_to_check = get_file_base() . '/install.php'; } else { $path_to_check = get_file_base() . '/sources/bootstrap.php'; } return filemtime($path_to_check); } /** * Find whether a particular feature is currently maintained (only works with particular pre-determined feature codes). * * @param ID_TEXT $code Feature * @return boolean Maintained status */ function is_maintained(string $code) : bool { static $cache = null; if ($cache === null) { $cache = []; global $FILE_ARRAY; if (@is_array($FILE_ARRAY)) { $file = file_array_get('data/maintenance_status.csv'); file_put_contents('php://memory', $file); $path = 'php://memory'; } else { $path = get_file_base() . '/data/maintenance_status.csv'; } require_code('files_spreadsheets_read'); $sheet_reader = Source_spreadsheet_reader::spreadsheet_open_read($path, 'maintenance_status.csv'); while (($row = $sheet_reader->read_row()) !== false) { $cache[$row['Codename']] = !empty($row['Current active sponsor']); } $sheet_reader->close(); } if (isset($cache[$code])) { return $cache[$code]; } return true; } /** * Tack on a message to some text if a feature is not maintained. * * @param ID_TEXT $code Feature * @param mixed $text Text to show, provided in HTML format (string or Tempcode) * @return Tempcode Text */ function is_maintained_description(string $code, $text) : object { if (is_string($text)) { $text = protect_from_escaping($text); } if (!is_maintained($code)) { return do_lang_tempcode('NON_MAINTAINED_STATUS', $text, make_string_tempcode(escape_html(get_brand_base_url())), escape_html($code)); } return $text; } /** * Find if a forum post is a spacer post. * * @param string $post The spacer post * @return array A pair: Whether it is, and the language it is in */ function is_spacer_post(string $post) : array { if (substr($post, 0, 10) == '[semihtml]') { $post = substr($post, 10); } $langs = find_all_langs(); foreach (array_keys($langs) as $lang) { $matcher = do_lang('SPACER_POST_MATCHER', null, null, null, $lang); if (substr($post, 0, strlen($matcher)) == $matcher) { return [true, $lang]; } } return [false, get_site_default_lang()]; } /** * Prepare an argument for use literally in a command. Works around common PHP restrictions. * * @param string $arg The argument * @return string Escaped */ function cms_escapeshellarg(string $arg) : string { if (php_function_allowed('escapeshellarg')) { return escapeshellarg($arg); } return "'" . addslashes(str_replace([chr(0), "'"], ['', "'\"'\"'"], $arg)) . "'"; } /** * Get the Internet host name corresponding to a given IP address. Wrapper around gethostbyaddr. * * @param string $ip_address IP address * @return string Host name OR IP address if failed to look up */ function cms_gethostbyaddr(string $ip_address) : string { $hostname = ''; if ((php_function_allowed('shell_exec')) && (function_exists('get_value')) && (get_value('slow_php_dns') === '1')) { if ($hostname == '') { $results = shell_exec('host ' . cms_escapeshellarg($ip_address)); if (is_string($results)) { $hostname = trim(preg_replace('#^.* #', '', $results)); } } } if ($hostname == '') { if (php_function_allowed('gethostbyaddr')) { $hostname = @gethostbyaddr($ip_address); } } if ($hostname == '') { $hostname = $ip_address; } return rtrim($hostname, '.'); // Normalise with no trailing dot } /** * Get the IP address corresponding to a given Internet host name. Wrapper around gethostbyname. * * @param string $hostname Host name * @return string IP address OR host name if failed to look up */ function cms_gethostbyname(string $hostname) : string { $ip_address = ''; if ((php_function_allowed('shell_exec')) && (function_exists('get_value')) && (get_value('slow_php_dns') === '1')) { if ($ip_address == '') { if (strpos(PHP_OS, 'Linux') !== false) { $ip_address = trim(preg_replace('# .*$#', '', shell_exec('getent hosts ' . cms_escapeshellarg($hostname)))); } } if ($ip_address == '') { $shell_result = shell_exec('host ' . cms_escapeshellarg($hostname)); if (is_string($shell_result)) { $ip_address = preg_replace('#^.*has IPv6 address [\da-f:]+.*#s', '$1', $shell_result); if (preg_match('#^[\da-f:]+$#', $ip_address) == 0) { $ip_address = ''; } if ($ip_address == '') { $ip_address = preg_replace('#^.*has address (\d+\.\d+\.\d+\.\d+).*#s', '$1', $shell_result); if (preg_match('#^[\d\.]+$#', $ip_address) == 0) { $ip_address = ''; } } } } } if ($ip_address == '') { if (php_function_allowed('dns_get_record')) { $dns = @dns_get_record($hostname, DNS_AAAA); if (isset($dns[0]['ipv6'])) { $ip_address = $dns[0]['ipv6']; } } if (($ip_address == '') && php_function_allowed('gethostbyname')) { $ip_address = @gethostbyname($hostname); } } if (empty($ip_address)) { $ip_address = $hostname; } return $ip_address; } /** * Unpack some bytes to an integer, so we can do some bitwise arithmetic on them. * Assumes unsigned, unless you request 4 bytes. * * @param string $str Input string * @param ?integer $bytes How many bytes to read (null: as many as there are in $str) * @set 1 2 4 * @param boolean $little_endian Whether to use little endian (Intel order) as opposed to big endian (network/natural order) * @return integer Read integer */ function cms_unpack_to_uinteger(string $str, ?int $bytes = null, bool $little_endian = false) : int { if ($bytes === null) { $bytes = strlen($str); } switch ($bytes) { case 1: $result = unpack('C', $str); break; case 2: $result = unpack($little_endian ? 'v' : 'n', $str); break; case 4: $result = unpack($little_endian ? 'V' : 'N', $str); break; default: warn_exit(do_lang_tempcode('INTERNAL_ERROR', escape_html('51d7436056d356a595e7e7427bff4c79'))); } return $result[1]; } /** * Perform a regular expression match. * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces). * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'. * * @param string $pattern The pattern * @param string $subject The subject string * @param ?array $matches Where matches will be put (note that it is a list of maps, except the arrays are turned inside out) (null: do not store matches). Note that this is actually passed by reference, but is also optional. (null: don't gather) * @param integer $flags Either 0, or PREG_OFFSET_CAPTURE * @param integer $offset Offset to start from. Usually use with 'A' modifier to anchor it (using '^' in the pattern will not work) * @return ~integer The number of matches (false: error) */ function cms_preg_match_safe(string $pattern, string $subject, ?array &$matches = null, int $flags = 0, int $offset = 0) { $pattern .= 'D'; if (get_charset() == 'utf-8') { $result = @preg_match($pattern . 'u', $subject, $matches, $flags, $offset); if ($result !== false) { return $result; } } return preg_match($pattern, $subject, $matches, $flags, $offset); } /** * Array entries that match the pattern. * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces). * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'. * * @param string $pattern The pattern * @param array $subject The subject strings * @param integer $flags Either 0, or PREG_GREP_INVERT * @return array Matches */ function cms_preg_grep_safe(string $pattern, array $subject, int $flags = 0) : array { $pattern .= 'D'; if (get_charset() == 'utf-8') { $result = @preg_grep($pattern . 'u', $subject, $flags); if ($result !== false) { return $result; } } return preg_grep($pattern, $subject, $flags); } /** * Perform a global regular expression match. * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces). * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'. * * @param string $pattern The pattern * @param string $subject The subject string * @param ?array $matches Where matches will be put (note that it is a list of maps, except the arrays are turned inside out). Note that this is actually passed by reference, but is also optional. (null: don't gather) * @param integer $flags Either 0, or PREG_OFFSET_CAPTURE * @return ~integer The number of matches (false: error) */ function cms_preg_match_all_safe(string $pattern, string $subject, ?array &$matches, int $flags = 0) { $pattern .= 'D'; if (get_charset() == 'utf-8') { $result = @preg_match_all($pattern . 'u', $subject, $matches, $flags); if ($result !== false) { return $result; } } return preg_match_all($pattern, $subject, $matches, $flags); } /** * Perform a regular expression search and replace. * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces). * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'. * * @param mixed $pattern The pattern (string or array) * @param mixed $replacement The replacement string (string or array) * @param string $subject The subject string * @param integer $limit The limit of replacements (-1: no limit) * @return ~string The string with replacements made (false: error) */ function cms_preg_replace_safe($pattern, $replacement, string $subject, int $limit = -1) { $pattern .= 'D'; if (get_charset() == 'utf-8') { $result = @preg_replace($pattern . 'u', $replacement, $subject, $limit); if ($result !== false) { return $result; } } return preg_replace($pattern, $replacement, $subject, $limit); } /** * Perform a regular expression search and replace using a callback. * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces). * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'. * * @param string $pattern The pattern * @param mixed $callback The callback * @param string $subject The subject string * @param integer $limit The limit of replacements (-1: no limit) * @return ~string The string with replacements made (false: error) */ function cms_preg_replace_callback_safe(string $pattern, $callback, string $subject, int $limit = -1) { $pattern .= 'D'; if (get_charset() == 'utf-8') { $result = @preg_replace_callback($pattern . 'u', $callback, $subject, $limit); if ($result !== false) { return $result; } } return preg_replace_callback($pattern, $callback, $subject, $limit); } /** * Initialise a simple structure for use with cms_preg_safety_guard_ok to protect us within complex regexp loops that keep going so long as stuff is changing. * * @return array Data structure (meant to be a black box) */ function cms_preg_safety_guard_init() : array { $guard = ['i' => 0, 'init_time' => time()]; return $guard; } /** * Protect us from infinite loop bugs within complex regexp loops that keep going so long as stuff is changing. * * @param array $guard Data structure from cms_preg_safety_guard_init * @return boolean Whether it is safe to keep looping */ function cms_preg_safety_guard_ok(array &$guard) : bool { if ($guard['i'] > 100 || $guard['init_time'] < time() - 5) { // Too thorny, do not continue if ($GLOBALS['DEV_MODE']) { fatal_exit('Bailed out on a regex infinite loop'); } return false; } $guard['i']++; return true; } /** * Split string by a regular expression. * Automatically applies utf-8 if possible and appropriate. \s is not actually Unicode-safe, for example (as it matches non-breaking-spaces). * Also automatically applies the 'D' modifier so that trailing blank lines don't mess with '$'. * * @param string $pattern The pattern * @param string $subject The subject * @param integer $max_splits The maximum number of splits to make (-1: no limit) * @param ?integer $mode The special mode (null: none) * @return array The array due to splitting */ function cms_preg_split_safe(string $pattern, string $subject, int $max_splits = -1, ?int $mode = null) : array { $pattern .= 'D'; if (get_charset() == 'utf-8') { $result = @preg_split($pattern . 'u', $subject, $max_splits, $mode); if ($result !== false) { return $result; } } return preg_split($pattern, $subject, $max_splits, $mode); } /** * Put out some benign HTTP output. * FastCGI seems to have a weird issue with 'slowish spiky process not continuing with output' - this works around it. Not ideal as would break headers in any subsequent code. */ function send_http_output_ping() { global $DOING_OUTPUT_PINGS; $DOING_OUTPUT_PINGS = true; if ((running_script('index')) && (!is_cli())) { if (!headers_sent()) { disable_output_compression(); // Otherwise the output handler will squash flushes cms_ob_end_clean(); // Otherwise flushing won't help } echo ' '; cms_flush_safe(); } } /** * Set the PHP time limit. * You will rarely want to use this standalone, as cms_extend_time_limit is more appropriate. * However, in computationally-expensive library code this is useful for restoring the execution time to what it was once your code has finished. * * @param integer $secs Number of seconds to set (likely a TIME_LIMIT_EXTEND_* constant) * @return integer The old time limit */ function cms_set_time_limit(int $secs) : int { $previous = intval(ini_get('max_execution_time')); if (php_function_allowed('set_time_limit')) { @set_time_limit($secs); } return $previous; } /** * Disable the PHP time limit. * * @return integer The old time limit */ function cms_disable_time_limit() : int { return cms_set_time_limit(0); } /** * Extend the PHP time limit. * * @param integer $secs Number of seconds to extend (likely a TIME_LIMIT_EXTEND_* constant) * @return integer The old time limit */ function cms_extend_time_limit(int $secs) : int { $previous = intval(ini_get('max_execution_time')); if ($previous == 0) { return 0; } return cms_set_time_limit($previous + $secs); } /** * Register a function for execution on shutdown. * Does not call it if shutdown functions are not available on this server. * * @param mixed $callback Callback * @param ?mixed $param_a Parameter (null: not used) * @param ?mixed $param_b Parameter (null: not used) * @param ?mixed $param_c Parameter (null: not used) * @param ?mixed $param_d Parameter (null: not used) * @param ?mixed $param_e Parameter (null: not used) * @param ?mixed $param_f Parameter (null: not used) * @param ?mixed $param_g Parameter (null: not used) * @param ?mixed $param_h Parameter (null: not used) * @param ?mixed $param_i Parameter (null: not used) * @param ?mixed $param_j Parameter (null: not used) * @param ?mixed $param_k Parameter (null: not used) * @param ?mixed $param_l Parameter (null: not used) * @param ?mixed $param_m Parameter (null: not used) * @return boolean Whether it scheduled for later (as normally expected) */ function cms_register_shutdown_function_if_available($callback, $param_a = null, $param_b = null, $param_c = null, $param_d = null, $param_e = null, $param_f = null, $param_g = null, $param_h = null, $param_i = null, $param_j = null, $param_k = null, $param_l = null, $param_m = null) : bool { if (function_exists('register_shutdown_function')) { $args = [$callback, $param_a, $param_b, $param_c, $param_d, $param_e, $param_f, $param_g, $param_h, $param_i, $param_j, $param_k, $param_l, $param_m]; while ((!empty($args)) && (end($args) === null)) { array_pop($args); } call_user_func_array('register_shutdown_function', $args); return true; } return false; } /** * Register a function for execution on shutdown. * Calls it immediately if shutdown functions are not reliable on this server. * * @param mixed $callback Callback * @param ?mixed $param_a Parameter (null: not used) * @param ?mixed $param_b Parameter (null: not used) * @param ?mixed $param_c Parameter (null: not used) * @param ?mixed $param_d Parameter (null: not used) * @param ?mixed $param_e Parameter (null: not used) * @param ?mixed $param_f Parameter (null: not used) * @param ?mixed $param_g Parameter (null: not used) * @param ?mixed $param_h Parameter (null: not used) * @param ?mixed $param_i Parameter (null: not used) * @param ?mixed $param_j Parameter (null: not used) * @param ?mixed $param_k Parameter (null: not used) * @param ?mixed $param_l Parameter (null: not used) * @param ?mixed $param_m Parameter (null: not used) * @return boolean Whether it scheduled for later (as normally expected) */ function cms_register_shutdown_function_safe($callback, $param_a = null, $param_b = null, $param_c = null, $param_d = null, $param_e = null, $param_f = null, $param_g = null, $param_h = null, $param_i = null, $param_j = null, $param_k = null, $param_l = null, $param_m = null) : bool { if (function_exists('get_value') && get_value('avoid_register_shutdown_function') === '1' || !function_exists('register_shutdown_function')) { $args = [$param_a, $param_b, $param_c, $param_d, $param_e, $param_f, $param_g, $param_h, $param_i, $param_j, $param_k, $param_l, $param_m]; while ((!empty($args)) && (end($args) === null)) { array_pop($args); } call_user_func_array($callback, $args); return false; } if (function_exists('register_shutdown_function')) { $args = [$callback, $param_a, $param_b, $param_c, $param_d, $param_e, $param_f, $param_g, $param_h, $param_i, $param_j, $param_k, $param_l, $param_m]; while ((!empty($args)) && (end($args) === null)) { array_pop($args); } call_user_func_array('register_shutdown_function', $args); return true; } return false; // Should never get here } /** * See how much to implement a view count, based on smart metrics. * * @param string $table Table to update in * @param integer $view_count Current view count * @return integer How much to increment the view count by */ function statistical_update_model(string $table, int $view_count) : int { if (get_db_type() == 'xml') { // Too much overhead return 0; } if (get_bot_type() !== null) { return 0; } if (get_value('disable_view_counts') === '1') { return 0; } if ((get_value('disable_view_counts') === '-1') && ($GLOBALS['FORUM_DRIVER']->is_staff(get_member()))) { return 0; } if ($GLOBALS['SITE_DB']->table_is_locked($table)) { return 0; } /* Randomly update view count using an algorithm that updates less often the more views we have. This reduces the number of queries where we reasonably expect more frequent hits on something. For example, let's say something currently has 1,000 views. On each hit, there is a 1/50 chance we update the view count, and when updated, we increase by 50. This averages out over time to about the view count we expect while significantly decreasing queries. */ if (get_value('statistical_update_model') == '1') { $st_increment = max(1, intval(round(floatval($view_count) / 20.0))); } else { $st_increment = 1; } if ($st_increment == 0) { return 0; } if (mt_rand(1, $st_increment) != 1) { return 0; } return $st_increment; } /** * Create (or expire) a cookie, inside the software's cookie environment. * * @param string $name The name of the cookie * @param string $value The value to store in the cookie (blank: delete the cookie) * @param ID_TEXT $category The cookie consent category of this cookie * @param boolean $session Whether it is a session cookie (gets removed once the browser window closes) * @param boolean $httponly Whether the cookie should not be readable by JavaScript * @param ?float $days Days to store; not applicable for session cookies unless expiring it (null: default) (-14: expire the cookie) * @return boolean The result of the PHP setcookie command */ function cms_setcookie(string $name, string $value, string $category = 'NON-ESSENTIAL', bool $session = false, bool $httponly = true, ?float $days = null) : bool { // In development mode, check for and warn against inconsistencies between this function call and privacy hooks if ($GLOBALS['DEV_MODE'] && function_exists('find_all_hook_obs') && function_exists('attach_message')) { require_code('privacy'); require_lang('privacy'); $matches = 0; $hook_obs = find_all_hook_obs('systems', 'privacy', 'Hook_privacy_'); foreach ($hook_obs as $hook => $ob) { $info = $ob->info(); if (($info === null) || (!isset($info['cookies'])) || (count($info['cookies']) == 0)) { continue; } foreach ($info['cookies'] as $_name => $cookie_info) { $regex = str_replace('\*', '.*', preg_quote($_name, '/')); if (preg_match('/' . $regex . '/', $name) == 0) { continue; } if ($cookie_info === null) { continue; } $matches++; if ($cookie_info['category'] != $category) { attach_message(do_lang_tempcode('COOKIE_INCONSISTENCY_CATEGORY', escape_html($name)), 'warn', false, true); } if ($cookie_info['session'] != $session) { attach_message(do_lang_tempcode('COOKIE_INCONSISTENCY_SESSION', escape_html($name)), 'warn', false, true); } if ($cookie_info['httponly'] != $httponly) { attach_message(do_lang_tempcode('COOKIE_INCONSISTENCY_HTTPONLY', escape_html($name)), 'warn', false, true); } } } if ($matches == 0) { attach_message(do_lang_tempcode('COOKIE_INCONSISTENCY_UNDEFINED', escape_html($name)), 'warn', false, true); } if ($matches > 1) { attach_message(do_lang_tempcode('COOKIE_INCONSISTENCY_MULTIPLE', escape_html($name), escape_html(integer_format($matches))), 'warn', false, true); } } // User rejected cookies; eat the existing cookie and bail out if (($value != '') && (!allowed_cookies($category))) { cms_setcookie($name, '', $category, $session, $httponly, -14.0); return false; } static $cache = []; $sz = serialize([$name, $value, $session, $httponly]); if (isset($cache[$sz])) { return $cache[$sz]; } if ($days === null) { $days = get_cookie_days(); } $expires = (($session && ($days >= 0.0)) ? 0 : (time() + intval($days * 24.0 * 60.0 * 60.0))); $path = get_cookie_path(); if ($path == '') { $base_url = get_base_url(); $pos = strpos($base_url, '/'); if ($pos === false) { $path = '/'; } else { $path = substr($base_url, $pos) . '/'; } } $domain = get_cookie_domain(); $secure = (substr(get_base_url(), 0, 8) === 'https://'); if (version_compare(PHP_VERSION, '7.3.0', '>=')) { // LEGACY $options = [ 'expires' => $expires, 'path' => $path, 'domain' => $domain, 'secure' => $secure, 'httponly' => $httponly, ]; // Stops HTTP POSTs from external sites inheriting cookie value. // Note that Lax is not necessarily the same as setting no value. // That said for Chrome 80+ all are Lax by default. // Tracked at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite $options['samesite'] = 'Lax'; $output = @call_user_func_array('setcookie', [$name, $value, $options]); } else { $output = @setcookie($name, $value, $expires, $path, $domain, $secure, $httponly); } if ($name != 'has_cookies') { $_COOKIE[$name] = $value; } if (($days < 0.0) || ($value == '')) { unset($_COOKIE[$name]); } $cache[$sz] = $output; return $output; } /** * Deletes a cookie (if it exists), from within the site's cookie environment. * This should rarely ever be used as it causes large headers and may not work properly for some types of cookies. * * @param string $name The name of the cookie */ function cms_eatcookie(string $name) { $expire = time() - 100000; // Note the negative number must be greater than 13*60*60 to account for maximum timezone difference // Gather data //$hostname = get_request_hostname(); //$hostname_no_www = preg_replace('#^www\.#', '', $hostname); $secure = (substr(get_base_url(), 0, 8) === 'https://'); require_code('privacy'); $hook_obs = find_all_hook_obs('systems', 'privacy', 'Hook_privacy_'); $cookie_properties = []; foreach ($hook_obs as $hook => $hook_ob) { $info = $hook_ob->info(); if ($info === null) { continue; } foreach ($info['cookies'] as $_name => $cookie_info) { if ($cookie_info === null) { continue; } // We need to escape expressions except the wildcard. $cookie_properties[str_replace('\*', '.*', preg_quote($_name, '/'))] = $cookie_info; } } // Paths to try $paths = [get_cookie_path()]; $paths = array_unique($paths); // Domains to try $domains = [ get_cookie_domain(), ]; $domains = array_unique($domains); // Try common combinations foreach ($domains as $domain) { foreach ($paths as $path) { foreach ($cookie_properties as $regex => $properties) { if (preg_match('/' . $regex . '/', $name) == 0) { continue; } if (version_compare(PHP_VERSION, '7.3.0', '>=')) { // LEGACY $options = [ 'expires' => $expire, 'path' => $path, 'domain' => $domain, 'secure' => $secure, 'httponly' => $properties['httponly'], ]; // Stops HTTP POSTs from external sites inheriting cookie value. // Note that Lax is not necessarily the same as setting no value. // That said for Chrome 80+ all are Lax by default. // Tracked at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite $options['samesite'] = 'Lax'; @call_user_func_array('setcookie', [$name, '', $options]); } else { @setcookie($name, '', $expire, $path, $domain, $secure, $properties['httponly']); } } @setcookie($name, '', $expire, $path, $domain); } } unset($_COOKIE[$name]); // Remove from $_COOKIE superglobal } /** * Go through all passed-in cookies and send a request to delete any which have been rejected or orphaned. */ function erase_rejected_cookies() { // Get cookie consent information about the user if (!isset($_COOKIE['cc_cookie'])) { return; } $cookie_consent_data_parsed = urldecode($_COOKIE['cc_cookie']); $cookie_consent_data = @json_decode($cookie_consent_data_parsed, true); if ($cookie_consent_data === false) { return; } if (!isset($cookie_consent_data['categories'])) { return; } // Grab a map of defined software cookies to their categories require_code('privacy'); $hook_obs = find_all_hook_obs('systems', 'privacy', 'Hook_privacy_'); $cookie_categories = []; foreach ($hook_obs as $hook => $hook_ob) { $info = $hook_ob->info(); if ($info === null) { continue; } foreach ($info['cookies'] as $name => $cookie_info) { if (!isset($cookie_info['category'])) { continue; } // We need to escape expressions except the wildcard. $cookie_categories[str_replace('\*', '.*', preg_quote($name, '/'))] = $cookie_info['category']; } } // Iterate over every passed cookie foreach ($_COOKIE as $name => $value) { if ($name == 'cc_cookie') { continue; } $matched_something = false; foreach ($cookie_categories as $cookie_regex => $cookie_category) { if (preg_match('/' . $cookie_regex . '/', $name) == 0) { continue; } $matched_something = true; // Delete cookies which we rejected if (!allowed_cookies($cookie_category)) { cms_eatcookie($name); } } // Delete orphaned cookies if ($matched_something === false) { cms_eatcookie($name); } } } /** * Convert a parameter set from a an array (for PHP code) to a string (for templates). * * @param array $map The parameters / acceptable parameter pattern * @return string The parameters / acceptable parameter pattern, as template safe parameter */ function comma_list_arr_to_str(array $map) : string { ksort($map); $str = ''; foreach ($map as $key => $val) { if ($str != '') { $str .= ','; } if ((is_integer($key)) && (strpos($val, '=') !== false)) { // {$BLOCK} style, i.e. a list not a map list($_key, $_val) = explode('=', $val, 2); $str .= $_key . '=' . str_replace('=', '\=', str_replace(',', '\,', str_replace('\\', '\\\\', $_val))); } else { if (!is_string($key)) { $key = strval($key); } $str .= $key . '=' . str_replace('=', '\=', str_replace(',', '\,', str_replace('\\', '\\\\', $val))); } } return $str; } /** * Convert a parameter set from a string (for templates) to an array (for PHP code). * * @param string $str The parameters / acceptable parameter pattern, as template safe parameter * @param boolean $block_symbol_style Whether to leave in block symbol style (i.e. like {$BLOCK} would take, a list not a map) * @return array The parameters / acceptable parameter pattern */ function comma_list_str_to_arr(string $str, bool $block_symbol_style = false) : array { if ($str == '') { return []; } if (($GLOBALS['XSS_DETECT']) && (ocp_is_escaped($str))) { $ocp_is_escaped = true; } else { $ocp_is_escaped = false; } $map = []; $len = strlen($str); if ($block_symbol_style) { // Get the parts without splitting keys and values, leave escaping as-is $escaped = false; $in_key = true; $key = ''; $val = ''; for ($i = 0; $i < $len; $i++) { $c = $str[$i]; if ($in_key) { if ($c == ',') { if ($ocp_is_escaped) { ocp_mark_as_escaped($key); } $map[] = $key; $key = ''; continue; } elseif ($c == '=') { $in_key = false; } else { $key .= $c; } } else { if ($escaped) { $escaped = false; $val .= '\\' . $c; } else { if ($c == '\\') { $escaped = true; } elseif ($c == ',') { $in_key = true; if ($ocp_is_escaped) { ocp_mark_as_escaped($key); ocp_mark_as_escaped($val); } $map[] = $key . '=' . $val; $key = ''; $val = ''; } else { $val .= $c; } } } } if ($in_key) { if ($ocp_is_escaped) { ocp_mark_as_escaped($key); } $map[] = $key; } else { if ($ocp_is_escaped) { ocp_mark_as_escaped($key); ocp_mark_as_escaped($val); } $map[] = $key . '=' . $val; } } else { $escaped = false; $in_key = true; $key = ''; $val = ''; for ($i = 0; $i < $len; $i++) { $c = $str[$i]; if ($in_key) { if ($c == ',') { if ($ocp_is_escaped) { ocp_mark_as_escaped($key); } $map[] = $key; $key = ''; continue; } elseif ($c == '=') { $in_key = false; } else { $key .= $c; } } else { if ($escaped) { $escaped = false; $val .= $c; } else { if ($c == '\\') { $escaped = true; } elseif ($c == ',') { $in_key = true; if ($ocp_is_escaped) { ocp_mark_as_escaped($key); ocp_mark_as_escaped($val); } if ($key == '' && isset($map[$key])) { $map[] = $val; } else { $map[$key] = $val; } $key = ''; $val = ''; } else { $val .= $c; } } } } if ($in_key) { if ($ocp_is_escaped) { ocp_mark_as_escaped($key); } $map[] = $key; } else { if ($ocp_is_escaped) { ocp_mark_as_escaped($key); ocp_mark_as_escaped($val); } if ($key == '' && isset($map[$key])) { $map[] = $val; } else { $map[$key] = $val; } } } return $map; } /** * 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_custom_file_base() . '\\', get_file_base() . '/', get_file_base() . '\\'], ['', '', '', ''], $text); } /** * Validate the given URL sorting parameters and transform them into usable information. * This should be used on all sortables interfaces. * * @param ID_TEXT $content_type The content type on which we are sorting * @param string $url_sort The URL sort string * @param ?array $allowed_sorts List of allowed sort types (null: default set for the content type) * @param boolean $strict_error Provide a hack-attack error on invalid input * @return array A tuple: The SQL-style sort order, The sort direction, the sort type based on the URL sort string */ function process_sorting_params(string $content_type, string $url_sort, ?array $allowed_sorts = null, bool $strict_error = true) : array { require_code('content'); $object = get_content_object($content_type); $info = $object->info(); if ($info === null) { warn_exit(do_lang_tempcode('NO_SUCH_CONTENT_TYPE', escape_html($content_type))); } return handle_abstract_sorting($url_sort, $info, $allowed_sorts, $strict_error); } /** * Performs lots of magic to make sure data encodings are converted correctly. Input, and output too (as often stores internally in UTF or performs automatic dynamic conversions from internal to external charsets). * * @param boolean $known_utf8 Whether we know we are working in utf-8. This is the case for AJAX calls. */ function convert_request_data_encodings(bool $known_utf8 = false) { global $VALID_ENCODING, $CONVERTED_ENCODING; $VALID_ENCODING = true; if ($CONVERTED_ENCODING) { return; // Already done it } if (preg_match('#^[\x00-\x7F]*$#', serialize($_POST) . serialize($_GET) . serialize($_FILES)) != 0) { // Simple case, all is ASCII $CONVERTED_ENCODING = true; return; } require_code('character_sets'); _convert_request_data_encodings($known_utf8); } /** * Randomly shuffle the order of an array's items while preserving its keys. * * @param array $array The array to be shuffled, passed by reference * @return boolean Always returns true */ function cms_shuffle_assoc(array &$array) : bool { // Shuffle the keys $keys = array_keys($array); shuffle($keys); // Build a new array with the order of the shuffled keys $new_array = []; foreach($keys as $key) { $new_array[$key] = $array[$key]; } // Assign the new array to our original array $array = $new_array; return true; } /** * Use this function to count iterations for potential infinite loops. * * @param ID_TEXT $codename A codename to use for this check, such as a function name * @param array $args An array of arguments to determine if this is a unique call, usually parameters passed to the function (func_get_args) * @param integer $allowed_iterations The number of times we are allowed to call this with the same $codename and $args before this is considered an infinite loop * @param boolean $actually_exit Whether we want to bail on critical error if an infinite loop occurs * @return boolean Whether an infinite loop is occurring */ function check_for_infinite_loop(string $codename, array $args, int $allowed_iterations = 10, bool $actually_exit = true) : bool { global $CHECK_FOR_INFINITE_LOOP; // Global in case we want to reset iteration count global $HAS_LOOPED_INFINITELY; $hash = md5(serialize($args)); // Prepare global array tracker if (!isset($CHECK_FOR_INFINITE_LOOP[$codename])) { $CHECK_FOR_INFINITE_LOOP[$codename] = []; } if (!isset($CHECK_FOR_INFINITE_LOOP[$codename][$hash])) { $CHECK_FOR_INFINITE_LOOP[$codename][$hash] = 0; } // Increment count and handle if we surpassed the allowed number of iterations $CHECK_FOR_INFINITE_LOOP[$codename][$hash]++; if ($CHECK_FOR_INFINITE_LOOP[$codename][$hash] > $allowed_iterations) { // Fatally exit if we already triggered an infinite loop for something else if ($HAS_LOOPED_INFINITELY) { require_code('critical_errors'); critical_error('INFINITE_LOOP', $codename, true); } $HAS_LOOPED_INFINITELY = true; require_code('failure'); // Use a more helpful (and relayed) error message, which includes serialised arguments, for the developers and staff $dev_error = do_lang('_INFINITE_LOOP_HALTED', comcode_escape($codename), comcode_escape(serialize($args))); if ((function_exists('error_log')) && (php_function_allowed('error_log'))) { @error_log('Composr: CRITICAL ' . $dev_error, 0); } relay_error_notification($dev_error); // Do not include serialised arguments in the actual UI as it may contain sensitive information if ($actually_exit) { require_code('critical_errors'); critical_error('INFINITE_LOOP', $codename, true); } else { return true; } } return false; } /** * Reset the number of iterations performed on a check_for_infinite_loop call. * This should be called when applicable, say, if a cache was cleared. * * @param ID_TEXT $codename The codename to clear * @param ?array $args Reset iterations on the call which used these arguments (null: reset everything on $codename) */ function clear_infinite_loop_iterations(string $codename, ?array $args = null) { global $CHECK_FOR_INFINITE_LOOP; if ($args === null) { unset($CHECK_FOR_INFINITE_LOOP[$codename]); return; } $hash = md5(serialize($args)); unset($CHECK_FOR_INFINITE_LOOP[$codename][$hash]); } /** * Encode a string using base64, but with additional options. * * @param LONG_TEXT $data The data to encode * @param boolean $url_safe Whether to output base64url format instead, which is URL (parameters only) and file safe * @param boolean $hashed Whether to hash $data with SHA-256 first and then encode the hash * @param boolean $salted Whether to salt the data for SHA-256 hashing using the site salt; ignored if $hashed is false * @return SHORT_TEXT The base64 or base64url data */ function cms_base64_encode(string $data, bool $url_safe = false, bool $hashed = false, bool $salted = false) : string { if ($hashed === true) { if ($salted === true) { require_code('crypt'); $data = hash_hmac('sha256', $data, get_site_salt(), true); } else { $data = hash('sha256', $data, true); } } $data = base64_encode($data); if ($url_safe) { $data = str_replace(['/', '+', '='], ['_', '-', ''], $data); } return $data; } /** * Encode a string into base64url (base64 which is URL and file friendly). * This is just a shortcut for cms_base64_encode. Be aware of case sensitivity and only use this in parameters. * * @param LONG_TEXT $data The data to decode * @return string The decoded data */ function base64url_encode(string $data) : string { return cms_base64_encode($data, true); } /** * Decode a URL-safe base64 encoded string. * * @param LONG_TEXT $data The data to decode * @return string The decoded data */ function base64url_decode(string $data) : string { $data = str_replace(['_', '-'], ['/', '+'], $data); $data .= '='; return base64_decode($data); }