get_string(SetupConfig::THEME, "default"); if (!file_exists("themes/$theme")) { $theme = "default"; } return $theme; } function contact_link(?string $contact = null): ?string { global $config; $text = $contact ?? $config->get_string('contact_link'); if (is_null($text)) { return null; } if ( str_starts_with($text, "http:") || str_starts_with($text, "https:") || str_starts_with($text, "mailto:") ) { return $text; } if (str_contains($text, "@")) { return "mailto:$text"; } if (str_contains($text, "/") && mb_substr($text, 0, 1) != "/") { return "https://$text"; } return $text; } /** * Check if HTTPS is enabled for the server. */ function is_https_enabled(): bool { // check forwarded protocol if (is_trusted_proxy() && !empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https') { $_SERVER['HTTPS'] = 'on'; } return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); } /** * Compare two Block objects, used to sort them before being displayed */ function blockcmp(Block $a, Block $b): int { if ($a->position == $b->position) { return 0; } else { return ($a->position > $b->position) ? 1 : -1; } } /** * Figure out PHP's internal memory limit */ function get_memory_limit(): int { global $config; // thumbnail generation requires lots of memory $default_limit = 8 * 1024 * 1024; // 8 MB of memory is PHP's default. $shimmie_limit = $config->get_int(MediaConfig::MEM_LIMIT); if ($shimmie_limit < 3 * 1024 * 1024) { // we aren't going to fit, override $shimmie_limit = $default_limit; } /* Get PHP's configured memory limit. Note that this is set to -1 for NO memory limit. https://ca2.php.net/manual/en/ini.core.php#ini.memory-limit */ $memory = parse_shorthand_int(ini_get("memory_limit")); if ($memory == -1) { // No memory limit. // Return the larger of the set limits. return max($shimmie_limit, $default_limit); } else { // PHP has a memory limit set. if ($shimmie_limit > $memory) { // Shimmie wants more memory than what PHP is currently set for. // Attempt to set PHP's memory limit. if (ini_set("memory_limit", "$shimmie_limit") === false) { /* We can't change PHP's limit, oh well, return whatever its currently set to */ return $memory; } $memory = parse_shorthand_int(ini_get("memory_limit")); } // PHP's memory limit is more than Shimmie needs. return $memory; // return the current setting } } /** * Check if PHP has the GD library installed */ function check_gd_version(): int { $gdversion = 0; if (function_exists('gd_info')) { $gd_info = gd_info(); if (substr_count($gd_info['GD Version'], '2.')) { $gdversion = 2; } elseif (substr_count($gd_info['GD Version'], '1.')) { $gdversion = 1; } } return $gdversion; } /** * Check whether ImageMagick's `convert` command * is installed and working */ function check_im_version(): int { $convert_check = exec("convert --version"); return (empty($convert_check) ? 0 : 1); } function is_trusted_proxy(): bool { $ra = $_SERVER['REMOTE_ADDR'] ?? "0.0.0.0"; if (!defined("TRUSTED_PROXIES")) { return false; } // @phpstan-ignore-next-line - TRUSTED_PROXIES is defined in config foreach (TRUSTED_PROXIES as $proxy) { // @phpstan-ignore-next-line - TRUSTED_PROXIES is defined in config if ($ra === $proxy) { // check for "unix:" before checking IPs return true; } if (ip_in_range($ra, $proxy)) { return true; } } return false; } function is_bot(): bool { $ua = $_SERVER["HTTP_USER_AGENT"] ?? "No UA"; return ( str_contains($ua, "Googlebot") || str_contains($ua, "YandexBot") || str_contains($ua, "bingbot") || str_contains($ua, "msnbot") || str_contains($ua, "PetalBot") ); } /** * Get real IP if behind a reverse proxy */ function get_real_ip(): string { $ip = $_SERVER['REMOTE_ADDR']; if ($ip == "unix:") { $ip = "0.0.0.0"; } if (is_trusted_proxy()) { if (isset($_SERVER['HTTP_X_REAL_IP'])) { if (filter_var_ex($ip, FILTER_VALIDATE_IP)) { $ip = $_SERVER['HTTP_X_REAL_IP']; } } if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { $ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); $last_ip = $ips[count($ips) - 1]; if (filter_var_ex($last_ip, FILTER_VALIDATE_IP)) { $ip = $last_ip; } } } return $ip; } /** * Get the currently active IP, masked to make it not change when the last * octet or two change, for use in session cookies and such */ function get_session_ip(Config $config): string { $mask = $config->get_string("session_hash_mask", "255.255.0.0"); $addr = get_real_ip(); $addr = \Safe\inet_ntop(inet_pton_ex($addr) & inet_pton_ex($mask)); return $addr; } /** * A shorthand way to send a TextFormattingEvent and get the results. */ function format_text(string $string): string { $event = send_event(new TextFormattingEvent($string)); return $event->formatted; } /** * Take a map of string to string-or-array, return only the string-to-string subset * * @param array $map * @return array */ function only_strings(array $map): array { $out = []; foreach ($map as $k => $v) { if (is_string($v)) { $out[$k] = $v; } } return $out; } /** * Generates the path to a file under the data folder based on the file's hash. * This process creates subfolders based on octet pairs from the file's hash. * The calculated folder follows this pattern data/$base/octet_pairs/$hash * @param string $base * @param string $hash * @param bool $create * @param int $splits The number of octet pairs to split the hash into. Caps out at strlen($hash)/2. * @return string */ function warehouse_path(string $base, string $hash, bool $create = true, int $splits = WH_SPLITS): string { $dirs = [DATA_DIR, $base]; $splits = min($splits, strlen($hash) / 2); for ($i = 0; $i < $splits; $i++) { $dirs[] = substr($hash, $i * 2, 2); } $dirs[] = $hash; $pa = join_path(...$dirs); if ($create && !file_exists(dirname($pa))) { mkdir(dirname($pa), 0755, true); } return $pa; } /** * Determines the path to the specified file in the data folder. */ function data_path(string $filename, bool $create = true): string { $filename = join_path("data", $filename); if ($create && !file_exists(dirname($filename))) { mkdir(dirname($filename), 0755, true); } return $filename; } class FetchException extends \Exception { } /** * @return array */ function fetch_url(string $url, string $mfile): array { global $config; if ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "curl" && function_exists("curl_init")) { $ch = curl_init($url); assert($ch !== false); $fp = \Safe\fopen($mfile, "w"); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); # curl_setopt($ch, CURLOPT_VERBOSE, 1); curl_setopt($ch, CURLOPT_HEADER, 1); curl_setopt($ch, CURLOPT_REFERER, $url); curl_setopt($ch, CURLOPT_USERAGENT, "Shimmie-".VERSION); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0); $response = curl_exec($ch); if ($response === false) { throw new FetchException("cURL failed: ".curl_error($ch)); } if ($response === true) { // we use CURLOPT_RETURNTRANSFER, so this should never happen throw new FetchException("cURL failed successfully??"); } $header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE); $header_text = trim(substr($response, 0, $header_size)); $headers = http_parse_headers(implode("\n", \Safe\preg_split('/\R/', $header_text))); $body = substr($response, $header_size); curl_close($ch); fwrite($fp, $body); fclose($fp); } elseif ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "wget") { $s_url = escapeshellarg($url); $s_mfile = escapeshellarg($mfile); system("wget --no-check-certificate $s_url --output-document=$s_mfile"); if (!file_exists($mfile)) { throw new FetchException("wget failed"); } $headers = []; } elseif ($config->get_string(UploadConfig::TRANSLOAD_ENGINE) === "fopen") { $fp_in = @fopen($url, "r"); $fp_out = fopen($mfile, "w"); if (!$fp_in || !$fp_out) { throw new FetchException("fopen failed"); } $length = 0; while (!feof($fp_in) && $length <= $config->get_int(UploadConfig::SIZE)) { $data = \Safe\fread($fp_in, 8192); $length += strlen($data); fwrite($fp_out, $data); } fclose($fp_in); fclose($fp_out); $headers = http_parse_headers(implode("\n", $http_response_header)); } else { throw new FetchException("No transload engine configured"); } if (filesize($mfile) == 0) { @unlink($mfile); throw new FetchException("No data found in $url -- perhaps the site has hotlink protection?"); } return $headers; } /** * @return string[] */ function path_to_tags(string $path): array { $matches = []; $tags = []; if (preg_match("/\d+ - (.+)\.([a-zA-Z0-9]+)/", basename($path), $matches)) { $tags = explode(" ", $matches[1]); } $path = str_replace("\\", "/", $path); $path = str_replace(";", ":", $path); $path = str_replace("__", " ", $path); $path = dirname($path); if ($path == "\\" || $path == "/" || $path == ".") { $path = ""; } $category = ""; foreach (explode("/", $path) as $dir) { $category_to_inherit = ""; foreach (explode(" ", $dir) as $tag) { $tag = trim($tag); if ($tag == "") { continue; } if (substr_compare($tag, ":", -1) === 0) { // This indicates a tag that ends in a colon, // which is for inheriting to tags on the subfolder $category_to_inherit = $tag; } else { if ($category != "" && !str_contains($tag, ":")) { // This indicates that category inheritance is active, // and we've encountered a tag that does not specify a category. // So we attach the inherited category to the tag. $tag = $category.$tag; } $tags[] = $tag; } } // Category inheritance only works on the immediate subfolder, // so we hold a category until the next iteration, and then set // it back to an empty string after that iteration $category = $category_to_inherit; } return $tags; } /** * @return string[] */ function get_dir_contents(string $dir): array { assert(!empty($dir)); if (!is_dir($dir)) { return []; } return array_diff( \Safe\scandir($dir), ['..', '.'] ); } function remove_empty_dirs(string $dir): bool { $result = true; $items = get_dir_contents($dir); ; foreach ($items as $item) { $path = join_path($dir, $item); if (is_dir($path)) { $result = $result && remove_empty_dirs($path); } else { $result = false; } } if ($result === true) { $result = rmdir($dir); } return $result; } /** * @return string[] */ function get_files_recursively(string $dir): array { $things = get_dir_contents($dir); $output = []; foreach ($things as $thing) { $path = join_path($dir, $thing); if (is_file($path)) { $output[] = $path; } else { $output = array_merge($output, get_files_recursively($path)); } } return $output; } /** * Returns amount of files & total size of dir. * * @return array{"path": string, "total_files": int, "total_mb": string} */ function scan_dir(string $path): array { $bytestotal = 0; $nbfiles = 0; $ite = new \RecursiveDirectoryIterator( $path, \FilesystemIterator::KEY_AS_PATHNAME | \FilesystemIterator::CURRENT_AS_FILEINFO | \FilesystemIterator::SKIP_DOTS ); foreach (new \RecursiveIteratorIterator($ite) as $filename => $cur) { try { $filesize = $cur->getSize(); $bytestotal += $filesize; $nbfiles++; } catch (\RuntimeException $e) { // This usually just means that the file got eaten by the import continue; } } $size_mb = $bytestotal / 1048576; // to mb $size_mb = number_format($size_mb, 2, '.', ''); return ['path' => $path, 'total_files' => $nbfiles, 'total_mb' => $size_mb]; } /** * because microtime() returns string|float, and we only ever want float */ function ftime(): float { return (float)microtime(true); } /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\ * Debugging functions * \* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ $_shm_load_start = ftime(); /** * Collects some debug information (execution time, memory usage, queries, etc) * and formats it to stick in the footer of the page. */ function get_debug_info(): string { $d = get_debug_info_arr(); $debug = "Took {$d['time']} seconds (db:{$d['dbtime']}) and {$d['mem_mb']}MB of RAM"; $debug .= "; Used {$d['files']} files and {$d['query_count']} queries"; $debug .= "; Sent {$d['event_count']} events"; $debug .= "; {$d['cache_hits']} cache hits and {$d['cache_misses']} misses"; $debug .= "; Shimmie version {$d['version']}"; return $debug; } /** * Collects some debug information (execution time, memory usage, queries, etc) * * @return array */ function get_debug_info_arr(): array { global $cache, $config, $_shm_event_count, $database, $_shm_load_start; if ($config->get_string("commit_hash", "unknown") == "unknown") { $commit = ""; } else { $commit = " (".$config->get_string("commit_hash").")"; } return [ "time" => round(ftime() - $_shm_load_start, 2), "dbtime" => round($database->dbtime, 2), "mem_mb" => round(((memory_get_peak_usage(true) + 512) / 1024) / 1024, 2), "files" => count(get_included_files()), "query_count" => $database->query_count, // "query_log" => $database->queries, "event_count" => $_shm_event_count, "cache_hits" => $cache->get("__etc_cache_hits"), "cache_misses" => $cache->get("__etc_cache_misses"), "version" => VERSION . $commit, ]; } /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\ * Request initialisation stuff * \* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ /** * @param string[] $files */ function require_all(array $files): void { foreach ($files as $filename) { require_once $filename; } } function _load_core_files(): void { require_all(array_merge( zglob("core/*.php"), zglob("core/imageboard/*.php"), zglob("ext/*/info.php") )); } function _load_extension_files(): void { ExtensionInfo::load_all_extension_info(); Extension::determine_enabled_extensions(); require_all(zglob("ext/{".Extension::get_enabled_extensions_as_string()."}/main.php")); } function _load_theme_files(): void { $theme = get_theme(); require_once('themes/'.$theme.'/page.class.php'); require_once('themes/'.$theme.'/themelet.class.php'); require_all(zglob("ext/{".Extension::get_enabled_extensions_as_string()."}/theme.php")); require_all(zglob('themes/'.$theme.'/{'.Extension::get_enabled_extensions_as_string().'}.theme.php')); } function _set_up_shimmie_environment(): void { global $tracer_enabled; if (file_exists("images") && !file_exists("data/images")) { die_nicely("Upgrade error", "As of Shimmie 2.7 images and thumbs should be moved to data/images and data/thumbs"); } if (TIMEZONE) { date_default_timezone_set(TIMEZONE); } if (DEBUG) { error_reporting(E_ALL); } // The trace system has a certain amount of memory consumption every time it is used, // so to prevent running out of memory during complex operations code that uses it should // check if tracer output is enabled before making use of it. // @phpstan-ignore-next-line - TRACE_FILE is defined in config $tracer_enabled = !is_null('TRACE_FILE'); } /** * Used to display fatal errors to the web user. */ function _fatal_error(\Exception $e): void { $version = VERSION; $message = $e->getMessage(); $phpver = phpversion(); //$hash = exec("git rev-parse HEAD"); //$h_hash = $hash ? "

Hash: $hash" : ""; //'.$h_hash.' if (PHP_SAPI === 'cli' || PHP_SAPI == 'phpdbg') { print("Trace: "); $t = array_reverse($e->getTrace()); foreach ($t as $n => $f) { $c = $f['class'] ?? ''; $t = $f['type'] ?? ''; $i = $f['file'] ?? 'unknown file'; $l = $f['line'] ?? -1; $a = implode(", ", array_map("Shimmie2\stringer", $f['args'] ?? [])); print("$n: {$i}({$l}): {$c}{$t}{$f['function']}({$a})\n"); } print("Message: $message\n"); if (is_a($e, DatabaseException::class)) { print("Query: {$e->query}\n"); print("Args: ".var_export($e->args, true)."\n"); } print("Version: $version (on $phpver)\n"); } else { $query = is_a($e, DatabaseException::class) ? $e->query : null; $code = is_a($e, SCoreException::class) ? $e->http_code : 500; $q = ""; if (is_a($e, DatabaseException::class)) { $q .= "

Query: " . html_escape($query); $q .= "

Args: " . html_escape(var_export($e->args, true)); } if ($code >= 500) { error_log("Shimmie Error: $message (Query: $query)\n{$e->getTraceAsString()}"); } header("HTTP/1.0 $code Error"); echo ' Internal error - SCore-'.$version.'

Internal Error

Message: '.html_escape($message).' '.$q.'

Version: '.$version.' (on '.$phpver.')

Stack Trace:

'.$e->getTraceAsString().'
'; } } function _get_user(): User { global $config, $page; $my_user = null; if (isset($_SERVER['HTTP_AUTHORIZATION'])) { $parts = explode(" ", $_SERVER['HTTP_AUTHORIZATION'], 2); if (count($parts) == 2 && $parts[0] == "Bearer") { $parts = explode(":", $parts[1], 2); if (count($parts) == 2) { $my_user = User::by_session($parts[0], $parts[1]); } } } if (is_null($my_user) && $page->get_cookie("user") && $page->get_cookie("session")) { $my_user = User::by_session($page->get_cookie("user"), $page->get_cookie("session")); } if (is_null($my_user)) { $my_user = User::by_id($config->get_int("anon_id", 0)); } return $my_user; } /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\ * HTML Generation * \* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ /** * Give a HTML string which shows an IP (if the user is allowed to see IPs), * and a link to ban that IP (if the user is allowed to ban IPs) * * FIXME: also check that IP ban ext is installed */ function show_ip(string $ip, string $ban_reason): string { global $user; $u_reason = url_escape($ban_reason); $u_end = url_escape("+1 week"); $ban = $user->can(Permissions::BAN_IP) ? ", Ban" : ""; $ip = $user->can(Permissions::VIEW_IP) ? $ip.$ban : ""; return $ip; } /** * Make a form tag with relevant auth token and stuff */ function make_form(string $target, bool $multipart = false, string $form_id = "", string $onsubmit = "", string $name = ""): string { global $user; $at = $user->get_auth_token(); $extra = empty($form_id) ? '' : 'id="'. $form_id .'"'; if ($multipart) { $extra .= " enctype='multipart/form-data'"; } if ($onsubmit) { $extra .= ' onsubmit="'.$onsubmit.'"'; } if ($name) { $extra .= ' name="'.$name.'"'; } return '
'. ''; } const BYTE_DENOMINATIONS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; function human_filesize(int $bytes, int $decimals = 2): string { $factor = floor((strlen(strval($bytes)) - 1) / 3); return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @BYTE_DENOMINATIONS[$factor]; } /** * Generates a unique key for the website to prevent unauthorized access. */ function generate_key(int $length = 20): string { $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; $randomString = ''; for ($i = 0; $i < $length; $i++) { $randomString .= $characters [rand(0, strlen($characters) - 1)]; } return $randomString; } function shm_tempnam(string $prefix = ""): string { if (!is_dir("data/temp")) { mkdir("data/temp"); } $temp = \Safe\realpath("data/temp"); return \Safe\tempnam($temp, $prefix); } /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *\ * Load balancing * \* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ function load_balance_url(string $tmpl, string $hash, int $n = 0): string { $matches = []; if (preg_match("/(.*){(.*)}(.*)/", $tmpl, $matches)) { $pre = $matches[1]; $opts = $matches[2]; $post = $matches[3]; $nodes = parse_load_balancer_config($opts); $choice = choose_load_balancer_node($nodes, $hash, $n); $tmpl = $pre . $choice . $post; } return $tmpl; } /** * "foo=1,bar=2,baz=3" -> ['foo' => 1, 'bar' => 2, 'baz' => 3] * * @param string $s * @throws \Shimmie2\InvalidInput * @return array */ function parse_load_balancer_config(string $s): array { $nodes = []; foreach (explode(",", $s) as $opt) { $parts = explode("=", $opt); $parts_count = count($parts); if ($parts_count === 2) { $opt_val = $parts[0]; $opt_weight = (int)$parts[1]; } elseif ($parts_count === 1) { $opt_val = $parts[0]; $opt_weight = 1; } else { throw new InvalidInput("Invalid load balancer weights: $s"); } $nodes[$opt_val] = $opt_weight; } return $nodes; } /** * Choose a node from a list of nodes based on a key. * * @param array $nodes * @param string $key * @param int $n * @return string */ function choose_load_balancer_node(array $nodes, string $key, int $n = 0): string { if (count($nodes) === 0) { throw new InvalidInput("No load balancer nodes to choose from"); } // create a list of [score, node] pairs $results = []; foreach ($nodes as $node => $weight) { // hash the node + key as an unsigned 32-bit integer $u32hash = hexdec(hash("murmur3a", "$node: $key")); // turn that into a float between 0 and 1 $f32hash = ($u32hash + 1) / (1 << 32); // $hash * $weight gives an exponential bias to higher-weighted nodes, // 1/log($hash)*$weight gives a uniform distribution across the range $score = (1.0 / -log($f32hash)) * $weight; $results[] = [$score, $node]; } // sort by score, highest first rsort($results); // return the highest node, fall back to the second-highest, etc return $results[$n % count($results)][1]; }