url); fallback($parsed["scheme"], "http"); fallback($parsed["host"], $_SERVER['SERVER_NAME']); if (!is_bool($secure)) $secure = ($parsed["scheme"] == "https"); $options = array( "lifetime" => COOKIE_LIFETIME, "expires" => time() + COOKIE_LIFETIME, "path" => "/", "domain" => $parsed["host"], "secure" => $secure, "httponly" => true, "samesite" => "Lax" ); $options_params = $options; $options_cookie = $options; unset($options_params["expires"]); unset($options_cookie["lifetime"]); session_set_cookie_params($options_params); session_name("ChyrpSession"); session_start(); if (isset($_COOKIE['ChyrpSession'])) setcookie(session_name(), session_id(), $options_cookie); } /** * Function: logged_in * Mask for Visitor::logged_in(). */ function logged_in(): bool { if (!class_exists("Visitor")) return false; if (func_num_args()) return false; return Visitor::logged_in(); } /** * Function: authenticate * Mask for Session::hash_token(). */ function authenticate(): bool|string { if (!class_exists("Session")) return false; if (func_num_args()) return false; return Session::hash_token(); } #--------------------------------------------- # Routing #--------------------------------------------- /** * Function: redirect * Redirects to the supplied URL and exits immediately. * * Parameters: * $url - The absolute or relative URL to redirect to. * $code - Numeric HTTP status code to set (optional). */ function redirect($url, $code = null)/*: never*/{ if (!substr_count($url, "://")) $url = url($url); switch ($code) { case 301: header($_SERVER['SERVER_PROTOCOL']." 301 Moved Permanently"); break; case 303: header($_SERVER['SERVER_PROTOCOL']." 303 See Other"); break; case 307: header($_SERVER['SERVER_PROTOCOL']." 307 Temporary Redirect"); break; case 308: header($_SERVER['SERVER_PROTOCOL']." 308 Permanent Redirect"); break; default: header($_SERVER['SERVER_PROTOCOL']." 302 Found"); } header("Location: ".unfix($url, true)); exit; } /** * Function: show_403 * Shows an error message with a 403 HTTP header. * * Parameters: * $title - The title for the error dialog (optional). * $body - The message for the error dialog (optional). */ function show_403($title = "", $body = "")/*: never*/{ $title = oneof($title, __("Forbidden")); $body = oneof($body, __("You do not have permission to access this resource.")); $theme = Theme::current(); $main = MainController::current(); if (!MAIN or !$theme->file_exists("pages".DIR."403")) error($title, $body, code:403); header($_SERVER['SERVER_PROTOCOL']." 403 Forbidden"); $main->feed = false; # Tell the controller not to serve feeds. $main->display("pages".DIR."403", array("reason" => $body), $title); exit; } /** * Function: show_404 * Shows an error message with a 404 HTTP header. * * Parameters: * $title - The title for the error dialog (optional). * $body - The message for the error dialog (optional). */ function show_404($title = "", $body = "")/*: never*/{ $title = oneof($title, __("Not Found")); $body = oneof($body, __("The requested resource was not found.")); $theme = Theme::current(); $main = MainController::current(); if (!MAIN or !$theme->file_exists("pages".DIR."404")) error($title, $body, code:404); header($_SERVER['SERVER_PROTOCOL']." 404 Not Found"); $main->feed = false; # Tell the controller not to serve feeds. $main->display("pages".DIR."404", array("reason" => $body), $title); exit; } /** * Function: url * Mask for Route::url(). */ function url($url, $controller = null): string { if (!class_exists("Route")) return $url; return Route::url($url, $controller); } /** * Function: self_url * Returns an absolute URL for the current request. */ function self_url(): string { $parsed = parse_url(Config::current()->url); $origin = fallback($parsed["scheme"], "http")."://". fallback($parsed["host"], $_SERVER['SERVER_NAME']); if (isset($parsed["port"])) $origin.= ":".$parsed["port"]; return fix($origin.$_SERVER['REQUEST_URI'], true); } /** * Function: htaccess_conf * Creates the .htaccess file for Chyrp Lite or overwrites an existing file. * * Parameters: * $url_path - The URL path to MAIN_DIR for the RewriteBase directive. * * Returns: * True if no action was needed, bytes written on success, false on failure. */ function htaccess_conf($url_path = null): int|bool { $url_path = oneof( $url_path, parse_url(Config::current()->chyrp_url, PHP_URL_PATH), "/" ); $filepath = MAIN_DIR.DIR.".htaccess"; $template = INCLUDES_DIR.DIR."htaccess.conf"; if (!is_file($template) or !is_readable($template)) return false; $htaccess = preg_replace( '~%\\{CHYRP_PATH\\}/?~', ltrim($url_path."/", "/"), file_get_contents($template) ); if (!file_exists($filepath)) return @file_put_contents($filepath, $htaccess); if (!is_file($filepath) or !is_readable($filepath)) return false; if ( !preg_match( "~".preg_quote($htaccess, "~")."~", file_get_contents($filepath) ) ) return @file_put_contents($filepath, $htaccess); return true; } /** * Function: caddyfile_conf * Creates the caddyfile for Chyrp Lite or overwrites an existing file. * * Parameters: * $url_path - The URL path to MAIN_DIR for the rewrite directive. * * Returns: * True if no action was needed, bytes written on success, false on failure. */ function caddyfile_conf($url_path = null): int|bool { $url_path = oneof( $url_path, parse_url(Config::current()->chyrp_url, PHP_URL_PATH), "/" ); $filepath = MAIN_DIR.DIR."caddyfile"; $template = INCLUDES_DIR.DIR."caddyfile.conf"; if (!is_file($template) or !is_readable($template)) return false; $caddyfile = preg_replace( '~\\{chyrp_path\\}/?~', ltrim($url_path."/", "/"), file_get_contents($template) ); if (!file_exists($filepath)) return @file_put_contents($filepath, $caddyfile); if (!is_file($filepath) or !is_readable($filepath)) return false; if ( !preg_match( "~".preg_quote($caddyfile, "~")."~", file_get_contents($filepath) ) ) return @file_put_contents($filepath, $caddyfile); return true; } /** * Function: nginx_conf * Creates the nginx configuration for Chyrp Lite or overwrites an existing file. * * Parameters: * $url_path - The URL path to MAIN_DIR for the location directive. * * Returns: * True if no action was needed, bytes written on success, false on failure. */ function nginx_conf($url_path = null): int|bool { $url_path = oneof( $url_path, parse_url(Config::current()->chyrp_url, PHP_URL_PATH), "/" ); $filepath = MAIN_DIR.DIR."include.conf"; $template = INCLUDES_DIR.DIR."nginx.conf"; if (!is_file($template) or !is_readable($template)) return false; $caddyfile = preg_replace( '~\\$chyrp_path/?~', ltrim($url_path."/", "/"), file_get_contents($template) ); if (!file_exists($filepath)) return @file_put_contents($filepath, $caddyfile); if (!is_file($filepath) or !is_readable($filepath)) return false; if ( !preg_match( "~".preg_quote($caddyfile, "~")."~", file_get_contents($filepath) ) ) return @file_put_contents($filepath, $caddyfile); return true; } #--------------------------------------------- # Localization #--------------------------------------------- /** * Function: locales * Returns an array of locale choices for the "chyrp" domain. */ function locales(): array { # Ensure the default locale is always present in the list. $locales = array( array( "code" => "en_US", "name" => lang_code("en_US") ) ); $dir = new DirectoryIterator(INCLUDES_DIR.DIR."locale"); foreach ($dir as $item) { if (!$item->isDot() and $item->isDir()) { $dirname = $item->getFilename(); if ($dirname == "en_US") continue; if (preg_match("/^[a-z]{2}(_|-)[a-z]{2}$/i", $dirname)) $locales[] = array( "code" => $dirname, "name" => lang_code($dirname) ); } } return $locales; } /** * Function: set_locale * Sets the locale with fallbacks for platform-specific quirks. * * Parameters: * $locale - The locale name, e.g. @en_US@, @uk_UA@, @fr_FR@ */ function set_locale($locale = "en_US"): void { $list = array( $locale.".UTF-8", $locale.".utf-8", $locale.".UTF8", $locale.".utf8" ); if (class_exists("Locale")) { # Generate a locale string for Windows. $list[] = Locale::getDisplayLanguage($locale, "en_US"). "_". Locale::getDisplayRegion($locale, "en_US"). ".utf8"; # Set the ICU locale. Locale::setDefault($locale); } # Set the PHP locale. @putenv("LC_ALL=".$locale); setlocale(LC_ALL, $list); if (DEBUG) error_log("LOCALE ".setlocale(LC_CTYPE, 0)); } /** * Function: get_locale * Gets the current locale setting. * * Notes: * Does not use setlocale() because the return value is non-normative. */ function get_locale(): string { if ( INSTALLING or !file_exists(INCLUDES_DIR.DIR."config.json.php") ) { return isset($_REQUEST['locale']) ? $_REQUEST['locale'] : "en_US" ; } return Config::current()->locale; } /** * Function: load_translator * Sets the path for a gettext translation domain. * * Parameters: * $domain - The name of this translation domain. * $locale - The path to the locale directory. */ function load_translator($domain, $locale): void { if (USE_GETTEXT_SHIM and class_exists("Translation")) { Translation::current()->load($domain, $locale); return; } if (function_exists("bindtextdomain")) bindtextdomain($domain, $locale); if (function_exists("bind_textdomain_codeset")) bind_textdomain_codeset($domain, "UTF-8"); } /** * Function: lang_code * Converts a language code to a localised display name. * * Parameters: * $code - The language code to convert. * * Returns: * A localised display name, e.g. "English (United States)". */ function lang_code($code): string { return class_exists("Locale") ? Locale::getDisplayName($code, $code) : $code ; } /** * Function: lang_base * Extracts the primary language subtag for the supplied code. * * Parameters: * $code - The language code to extract from. * * Returns: * The primary subtag for this code, e.g. "en" from "en_US". */ function lang_base($code): string { $code = str_replace("_", "-", $code); $tags = explode("-", $code); return ($tags === false) ? "en" : $tags[0] ; } /** * Function: text_direction * Returns the correct text direction for the supplied language code. * * Parameters: * $code - The language code. * * Returns: * Either the string "ltr" or "rtl". */ function text_direction($code): string { $base = lang_base($code); switch ($base) { case 'ar': case 'he': $dir = "rtl"; break; default: $dir = "ltr"; } return $dir; } /** * Function: __ * Translates a string using gettext. * * Parameters: * $text - The string to translate. * $domain - The translation domain to read from. * * Returns: * The translated string or the original. */ function __($text, $domain = "chyrp"): string { if (USE_GETTEXT_SHIM) return Translation::current()->text( $domain, $text ); if (function_exists("dgettext")) return dgettext( $domain, $text ); return $text; } /** * Function: _p * Translates a plural (or not) form of a string. * * Parameters: * $single - Singular string. * $plural - Pluralized string. * $number - The number to judge by. * $domain - The translation domain to read from. * * Returns: * The translated string or the original. */ function _p($single, $plural, $number, $domain = "chyrp"): string { $int = (int) $number; if (USE_GETTEXT_SHIM) return Translation::current()->text( $domain, $single, $plural, $int ); if (function_exists("dngettext")) return dngettext( $domain, $single, $plural, $int ); return ($int != 1) ? $plural : $single ; } /** * Function: _f * Translates a string with sprintf() formatting. * * Parameters: * $string - String to translate and format. * $args - One arg or an array of arguments to format with. * $domain - The translation domain to read from. * * Returns: * The translated string or the original. */ function _f($string, $args = array(), $domain = "chyrp"): string { $args = (array) $args; array_unshift($args, __($string, $domain)); return call_user_func_array("sprintf", $args); } /** * Function: _w * Formats and internationalizes a string that isn't a regular time() value. * * Parameters: * $formatting - The date()-compatible formatting. * $when - A time() value or string to be strtotime() converted. * * Returns: * An internationalized time/date string with the supplied formatting. */ function _w($formatting, $when): string|false { static $locale; $time = is_numeric($when) ? $when : strtotime($when) ; if (!class_exists("IntlDateFormatter")) return date($formatting, $time); if (!isset($locale)) $locale = get_locale(); $formatter = new IntlDateFormatter( $locale, IntlDateFormatter::FULL, IntlDateFormatter::FULL, get_timezone(), IntlDateFormatter::GREGORIAN, convert_datetime($formatting) ); return $formatter->format($time); } #--------------------------------------------- # Time/Date #--------------------------------------------- /** * Function: when * Formats a string that isn't a regular time() value. * * Parameters: * $formatting - The formatting for date(). * $when - A time() value or string to be strtotime() converted. * * Returns: * A time/date string with the supplied formatting. */ function when($formatting, $when): string|false { $time = is_numeric($when) ? $when : strtotime($when) ; return date($formatting, $time); } /** * Function: datetime * Formats datetime for SQL queries. * * Parameters: * $when - A timestamp (optional). * * Returns: * A standard datetime string. */ function datetime($when = null): string|false { fallback($when, time()); $time = is_numeric($when) ? $when : strtotime($when) ; return date("Y-m-d H:i:s", $time); } /** * Function: now * Alias to strtotime, for prettiness like now("+1 day"). */ function now($when): string|false { return strtotime($when); } /** * Function: convert_datetime * Converts datetime formatting from PHP to ICU format. * * Parameters: * $formatting - The datetime formatting. * * See Also: * https://unicode-org.github.io/icu/userguide/format_parse/datetime/ * https://www.php.net/manual/en/datetime.format.php */ function convert_datetime($formatting): string { return strtr($formatting, array( "A" => "'A'", "a" => "a", "B" => "'B'", "b" => "'b'", "C" => "'C'", "c" => "'c'", "D" => "EEE", "d" => "dd", "E" => "'E'", "e" => "VV", "F" => "MMMM", "f" => "'f'", "G" => "H", "g" => "h", "H" => "HH", "h" => "hh", "I" => "'I'", "i" => "mm", "J" => "'J'", "j" => "d", "K" => "'K'", "k" => "'k'", "L" => "'L'", "l" => "EEEE", "M" => "MMM", "m" => "MM", "N" => "'N'", "n" => "M", "O" => "xx", "o" => "'o'", "P" => "xxx", "p" => "XXX", "Q" => "'Q'", "q" => "'q'", "R" => "'R'", "r" => "'r'", "S" => "'S'", "s" => "ss", "T" => "zzz", "t" => "'t'", "U" => "'U'", "u" => "SSSSSS", "V" => "'V'", "v" => "SSS", "W" => "'W'", "w" => "'w'", "X" => "'X'", "x" => "'x'", "Y" => "yyyy", "y" => "yy", "Z" => "'Z'", "z" => "D" )); } /** * Function: timezones * Returns an array of timezone identifiers. */ function timezones(): array { $timezones = array(); $zone_list = timezone_identifiers_list(DateTimeZone::ALL); foreach ($zone_list as $zone) { $name = str_replace( array("_", "St "), array(" ", "St. "), $zone ); $timezones[] = array( "code" => $zone, "name" => $name ); } return $timezones; } /** * Function: set_timezone * Sets the timezone for all date/time functions. * * Parameters: * $timezone - The timezone to set. */ function set_timezone($timezone = "Atlantic/Reykjavik"): bool { $result = date_default_timezone_set($timezone); if (DEBUG) error_log("TIMEZONE ".get_timezone()); return $result; } /** * Function: get_timezone * Gets the timezone for all date/time functions. */ function get_timezone(): string { return date_default_timezone_get(); } #--------------------------------------------- # Variable Manipulation #--------------------------------------------- /** * Function: fallback * Sets the supplied variable if it is not already set. * * Parameters: * &$variable - The variable to set and return. * * Returns: * The value that was assigned to the variable. * * Notes: * Additional arguments supplied to this function will be considered as * candidate values. The variable will be set to the value of the first * non-empty argument, or the last, or null if no arguments are supplied. */ function fallback(&$variable): mixed { if (is_bool($variable)) return $variable; $unset = ( !isset($variable) or $variable === array() or (is_string($variable) and trim($variable) === "") ); if (!$unset) return $variable; $fallback = null; $args = func_get_args(); array_shift($args); foreach ($args as $arg) { $fallback = $arg; $nonempty = ( isset($arg) and $arg !== array() and ( !is_string($arg) or (is_string($arg) and trim($arg) !== "") ) ); if ($nonempty) break; } return $variable = $fallback; } /** * Function: oneof * Inspects the supplied arguments and returns the first substantial value. * * Returns: * The first substantial value in the set, or the last, or null. * * Notes: * Some type combinations will halt inspection of the full set: * - All types are comparable with null. * - All scalar types are comparable. * - Arrays, objects, and resources are incomparable with other types. */ function oneof(): mixed { $last = null; $args = func_get_args(); foreach ($args as $index => $arg) { $unset = ( !isset($arg) or $arg === array() or (is_string($arg) and trim($arg) === "") or (is_object($arg) and empty($arg)) or is_datetime_zero($arg) ); if (!$unset) return $arg; $last = $arg; if ($index + 1 == count($args)) break; $next = $args[$index + 1]; # Using simple type comparison wouldn't work too well here, e.g: # oneof("", 1) should return 1 regardless of the type difference. $incomparable = ( (is_array($arg) and !is_array($next)) or (!is_array($arg) and is_array($next)) or (is_object($arg) and !is_object($next)) or (!is_object($arg) and is_object($next)) or (is_resource($arg) and !is_resource($next)) or (!is_resource($arg) and is_resource($next)) ); # A null value invalidates the incomparability test. if (isset($arg) and isset($next) and $incomparable) return $arg; } return $last; } /** * Function: derezz * Strips tags and junk from the supplied string and tests it for emptiness. * * Parameters: * &$string - The string, supplied by reference. * * Returns: * Whether or not the stripped string is empty. * * Notes: * Useful for data that will be stripped later on by its model * but which needs to be tested for uniqueness/emptiness first. * * See Also: * */ function derezz(&$string): bool { $string = str_replace("\x00..\x1f", "", strip_tags($string)); return ($string == ""); } /** * Function: token * Salt and hash a unique token using the supplied data. * * Parameters: * $items - An array of items to hash. * * Returns: * A unique token salted with the site's secure hashkey. */ function token($items): string { return sha1( implode((array) $items). Config::current()->secure_hashkey ); } /** * Function: crc24 * Performs a 24-bit cyclic redundancy check. * * Parameters: * $str - The data to check. * $polynomial - The polynomial to use. * $ini - The initial remainder value. * $xor - The value for the final XOR. * * Returns: * The integer value of the check result. */ function crc24($str, $polynomial = 0x864cfb, $ini = 0xb704ce, $xor = 0): int { $crc = $ini; for ($i = 0; $i < strlen($str); $i++) { $c = ord($str[$i]); $crc ^= $c << 16; for ($j = 0; $j < 8; $j++) { $crc = (($crc << 1) & 0xffffffff); if ($crc & 0x1000000) $crc ^= $polynomial; } } return ($crc ^ $xor) & 0xffffff; } /** * Function: slug * Generates a random slug value for posts and pages. * * Parameters: * $length - The number of characters to generate. * * Returns: * A string of the requested length. */ function slug($length): string { return strtolower(random($length)); } /** * Function: random * Generates a string of alphanumeric random characters. * * Parameters: * $length - The number of characters to generate. * * Returns: * A string of the requested length. * * Notes: * Uses a cryptographically secure pseudo-random method. */ function random($length): string { $input = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; $range = strlen($input) - 1; $chars = ""; for ($i = 0; $i < $length; $i++) $chars.= $input[random_int(0, $range)]; return $chars; } /** * Function: shorthand_bytes * Decode shorthand bytes notation from php.ini. * * Parameters: * $value - The value returned by ini_get(). * * Returns: * A byte value or the input if decoding failed. */ function shorthand_bytes($value): mixed { switch (substr($value, -1)) { case "K": case "k": return (int) $value * 1024; case "M": case "m": return (int) $value * 1048576; case "G": case "g": return (int) $value * 1073741824; default: return $value; } } /** * Function: set_max_time * Sets the PHP time limit to MAX_TIME_LIMIT. */ function set_max_time(): void { $const = MAX_TIME_LIMIT; $limit = ini_get("max_execution_time"); if ($limit === 0) return; if ($limit < $const) set_time_limit(MAX_TIME_LIMIT); } /** * Function: set_max_memory * Sets the PHP memory limit to MAX_MEMORY_LIMIT. */ function set_max_memory(): void { $const = shorthand_bytes(MAX_MEMORY_LIMIT); $limit = shorthand_bytes(ini_get("memory_limit")); if ($limit < $const) ini_set("memory_limit", MAX_MEMORY_LIMIT); } /** * Function: timer_start * Starts the internal timer and returns the microtime. */ function timer_start(): float { static $timer; if (!isset($timer)) $timer = microtime(true); return $timer; } /** * Function: timer_stop * Returns the elapsed time since the timer started. * * Parameters: * $precision - Round to n decimal places. * * Returns: * A formatted number with the requested $precision. */ function timer_stop($precision = 3): string { $elapsed = microtime(true) - timer_start(); return number_format($elapsed, $precision, ".", ""); } /** * Function: match_any * Try to match a string against an array of regular expressions. * * Parameters: * $try - An array of regular expressions, or a single regular expression. * $haystack - The string to test. * * Returns: * Whether or not the match succeeded. */ function match_any($try, $haystack): bool { foreach ((array) $try as $needle) { if (preg_match($needle, $haystack)) return true; } return false; } /** * Function: autoload * Autoload PSR-0 classes on demand by scanning lib directories. * * Parameters: * $class - The name of the class to load. */ function autoload($class): void { $filepath = str_replace( array("_", "\\", "\0"), array(DIR, DIR, ""), ltrim($class, "\\") ).".php"; $libpath = INCLUDES_DIR. DIR."lib". DIR.$filepath; if (is_file($libpath)) { require $libpath; return; } if (INSTALLING or UPGRADING) return; $config = Config::current(); foreach ($config->enabled_modules as $module) { $modpath = MODULES_DIR. DIR.$module. DIR."lib". DIR.$filepath; if (is_file($modpath)) { require $modpath; return; } } } /** * Function: keywords * Parse keyword searches for values in specific database columns. * * Parameters: * $query - The query to parse. * $plain - WHERE syntax to search for non-keyword queries. * $table - Check this table to ensure the keywords are valid. * * Returns: * An array containing an array of "WHERE" conditions, an array * of "WHERE" parameters, and "ORDER BY" clause for the results. * Non-keyword text will be parameterized as array[1][":query"]. */ function keywords($query, $plain, $table = null): array { $trimmed = trim($query); if (empty($trimmed)) return array(array(), array(), null); $sql = SQL::current(); $trigger = Trigger::current(); # Add ESCAPE clause to LIKE operators without one. $plain = preg_replace( "/( LIKE :query(?! ESCAPE))($| )/", "$1 ESCAPE '|'$2", $plain ); # PostgreSQL: use ILIKE operator for case-insensitivity. if ($sql->adapter == "pgsql") $plain = str_replace(" LIKE ", " ILIKE ", $plain); $strings = array(); # Non-keyword values found in the query. $keywords = array(); # Keywords (attr:val;) found in the query. $where = array(); # Parameters validated and added to WHERE. $filters = array(); # Table column filters to be validated. $params = array(); # Parameters for the non-keyword filter. $ordering = array(); # Requested ordering for the query results. $columns = !empty($table) ? $sql->select($table)->fetch() : array() ; foreach ( preg_split("/\s(?=\w+:)|;/", $query, -1, PREG_SPLIT_NO_EMPTY ) as $fragment) { if (!substr_count($fragment, ":")) $strings[] = trim($fragment); else $keywords[] = trim($fragment); } $dates = array( "year", "month", "day", "hour", "minute", "second" ); $created_at = array( "year" => "____", "month" => "__", "day" => "__", "hour" => "__", "minute" => "__", "second" => "__" ); $joined_at = array( "year" => "____", "month" => "__", "day" => "__", "hour" => "__", "minute" => "__", "second" => "__" ); # Contextual conversions of some keywords. foreach ($keywords as $keyword) { list($attr, $val) = explode(":", $keyword); if ($attr == "password") { # Prevent searches for hashed passwords. $strings[] = $attr; } elseif ( $attr == "author" and isset($columns["user_id"]) ) { # Filter by "author" (login). $user = new User(array("login" => $val)); $where["user_id"] = ($user->no_results) ? 0 : $user->id ; } elseif ( $attr == "group" and isset($columns["group_id"]) ) { # Filter by group name. $group = new Group(array("name" => $val)); $where["group_id"] = ($group->no_results) ? 0 : $group->id ; } elseif ( in_array($attr, $dates) and isset($columns["created_at"]) ) { # Filter by date/time of creation. $created_at[$attr] = $val; $where["created_at LIKE"] = ( $created_at["year"]."-". $created_at["month"]."-". $created_at["day"]." ". $created_at["hour"].":". $created_at["minute"].":". $created_at["second"]."%" ); } elseif ( in_array($attr, $dates) and isset($columns["joined_at"]) ) { # Filter by date/time of joining. $joined_at[$attr] = $val; $where["joined_at LIKE"] = ( $joined_at["year"]."-". $joined_at["month"]."-". $joined_at["day"]." ". $joined_at["hour"].":". $joined_at["minute"].":". $joined_at["second"]."%" ); } elseif ( $attr == "ASC" and !is_numeric($val) and isset($columns[$val]) ) { # Ascending order. $ordering[] = $val." ASC"; } elseif ( $attr == "DESC" and !is_numeric($val) and isset($columns[$val]) ) { # Descending order. $ordering[] = $val." DESC"; } else { # Key => Val expression. $filters[$attr] = $val; } } # Check the keywords are valid columns of the table. foreach ($filters as $attr => $val) { if (isset($columns[$attr])) { # Column exists: add Key => Val expression. $where[$attr] = $val; } else { # No such column: add to non-keyword values. $strings[] = $attr.":".$val; } } if (!empty($strings)) { $where[] = $plain; $params[":query"] = "%".implode(" ", $strings)."%"; } $order = empty($ordering) ? null : implode(", ", $ordering) ; $search = array($where, $params, $order); $trigger->filter($search, "keyword_search", $query, $plain); return $search; } #--------------------------------------------- # String Manipulation #--------------------------------------------- /** * Function: pluralize * Pluralizes a word. * * Parameters: * $string - The lowercase string to pluralize. * $number - A number to determine pluralization. * * Returns: * The supplied word with a trailing "s" added, * or the correct non-normative pluralization. */ function pluralize($string, $number = null): string { $uncountable = array( "audio", "equipment", "fish", "information", "money", "moose", "news", "rice", "series", "sheep", "species" ); if ($number == 1) return $string; if (in_array($string, $uncountable)) return $string; $replacements = array( "/person/i" => "people", "/^(wom|m)an$/i" => "\\1en", "/child/i" => "children", "/cow/i" => "kine", "/goose/i" => "geese", "/datum$/i" => "data", "/(penis)$/i" => "\\1es", "/(ax|test)is$/i" => "\\1es", "/(octop|vir)us$/i" => "\\1ii", "/(cact)us$/i" => "\\1i", "/(alias|status)$/i" => "\\1es", "/(bu)s$/i" => "\\1ses", "/(buffal|tomat)o$/i" => "\\1oes", "/([ti])um$/i" => "\\1a", "/sis$/i" => "ses", "/(hive)$/i" => "\\1s", "/([^aeiouy]|qu)y$/i" => "\\1ies", "/^(ox)$/i" => "\\1en", "/(matr|vert|ind)(?:ix|ex)$/i" => "\\1ices", "/(x|ch|ss|sh)$/i" => "\\1es", "/([m|l])ouse$/i" => "\\1ice", "/(quiz)$/i" => "\\1zes" ); $replaced = preg_replace( array_keys($replacements), array_values($replacements), $string, 1 ); if ($replaced == $string) $replaced = $string."s"; return $replaced; } /** * Function: depluralize * Singularizes a word. * * Parameters: * $string - The lowercase string to depluralize. * $number - A number to determine depluralization. * * Returns: * The supplied word with trailing "s" removed, * or the correct non-normative singularization. */ function depluralize($string, $number = null): string { $uncountable = array("news", "series", "species"); if (isset($number) and $number != 1) return $string; if (in_array($string, $uncountable)) return $string; $replacements = array( "/people/i" => "person", "/^(wom|m)en$/i" => "\\1an", "/children/i" => "child", "/kine/i" => "cow", "/geese/i" => "goose", "/data$/i" => "datum", "/(penis)es$/i" => "\\1", "/(ax|test)es$/i" => "\\1is", "/(octopi|viri|cact)i$/i" => "\\1us", "/(alias|status)es$/i" => "\\1", "/(bu)ses$/i" => "\\1s", "/(buffal|tomat)oes$/i" => "\\1o", "/([ti])a$/i" => "\\1um", "/ses$/i" => "sis", "/(hive)s$/i" => "\\1", "/([^aeiouy]|qu)ies$/i" => "\\1y", "/^(ox)en$/i" => "\\1", "/(vert|ind)ices$/i" => "\\1ex", "/(matr)ices$/i" => "\\1ix", "/(x|ch|ss|sh)es$/i" => "\\1", "/([ml])ice$/i" => "\\1ouse", "/(quiz)zes$/i" => "\\1" ); $replaced = preg_replace( array_keys($replacements), array_values($replacements), $string, 1 ); if ($replaced == $string and substr($string, -1) == "s") $replaced = substr($string, 0, -1); return $replaced; } /** * Function: normalize * Attempts to normalize newlines and whitespace into single spaces. * * Returns: * The normalized string. */ function normalize($string): string { return trim(preg_replace("/[\s\n\r\t]+/", " ", $string)); } /** * Function: camelize * Converts a string to camel-case. * * Parameters: * $string - The string to camelize. * $keep_spaces - Convert underscores to spaces? * * Returns: * A CamelCased string. * * See Also: * */ function camelize($string, $keep_spaces = false): string { $lowercase = strtolower($string); $deunderscore = str_replace("_", " ", $lowercase); $dehyphen = str_replace("-", " ", $deunderscore); $camelized = ucwords($dehyphen); if (!$keep_spaces) $camelized = str_replace(" ", "", $camelized); return $camelized; } /** * Function: decamelize * Undoes camel-case conversion. * * Parameters: * $string - The string to decamelize. * * Returns: * A de_camel_cased string. * * See Also: * */ function decamelize($string): string { return strtolower( preg_replace("/([a-z])([A-Z])/", "\\1_\\2", $string) ); } /** * Function: truncate * Truncates a string to the requested number of characters or less. * * Parameters: * $text - The string to be truncated. * $length - Truncate the string to this number of characters. * $ellipsis - A string to place at the truncation point. * $exact - Split words to return the exact length requested? * $encoding - The character encoding of the string and ellipsis. * * Returns: * A truncated string with ellipsis appended. */ function truncate( $text, $length = 100, $ellipsis = null, $exact = false, $encoding = "UTF-8" ): string { if (mb_strlen($text, $encoding) <= $length) return $text; if (!isset($ellipsis)) $ellipsis = mb_chr(0x2026, $encoding); $breakpoint = $length - mb_strlen($ellipsis, $encoding); $truncation = mb_substr($text, 0, $breakpoint, $encoding); $remainder = mb_substr($text, $breakpoint, null, $encoding); if (!$exact and !preg_match("/^\s/", $remainder)) $truncation = preg_replace( "/(.+)\s.*/s", "$1", $truncation ); return $truncation.$ellipsis; } /** * Function: markdown * Implements the Markdown content parsing filter. * * Parameters: * $text - The body of the post/page to parse. * $context - Model instance for context (optional). * * Returns: * The text with Markdown formatting applied. * * See Also: * https://github.com/commonmark/CommonMark * https://github.github.com/gfm/ * https://chyrplite.net/wiki/Chyrp-Flavoured-Markdown.html */ function markdown($text, $context = null): string { static $parser; if (!isset($parser)) { $parser = new \xenocrat\markdown\ChyrpMarkdown(); $parser->convertTabsToSpaces = false; $parser->html5 = true; $parser->keepListStartNumber = true; $parser->keepReversedList = true; $parser->headlineAnchors = true; $parser->enableNewlines = false; $parser->renderCheckboxInputs = false; $parser->disallowedRawHTML = false; } if ($context instanceof Model) { $name = strtolower(get_class($context)); $parser->setContextId($name."-".$context->id); } else { $parser->setContextId(""); } return $parser->parse($text); } /** * Function: emote * Converts emoticons to Unicode emoji HTML entities. * * Parameters: * $text - The body of the post/page to parse. * * Returns: * The text with emoticons replaced by emoji. * * See Also: * http://www.unicode.org/charts/PDF/U1F600.pdf */ function emote($text): string { $emoji = array( "o:-)" => "😇", ">:-)" => "😈", ">:-)" => "😈", ":-)" => "😀", "^_^" => "😁", ":-D" => "😃", ";-)" => "😉", "<3" => "😍", "<3" => "😍", "B-)" => "😎", ":->" => "😏", ":->" => "😏", ":-||" => "😬", ":-|" => "😑", "-_-" => "😒", ":-/" => "😕", ":-s" => "😖", ":-*" => "😘", ":-P" => "😛", ":-((" => "😩", ":-(" => "😟", ";_;" => "😢", ":-o" => "😮", "O_O" => "😲", ":-$" => "😳", "x_x" => "😵", ":-x" => "😶" ); foreach ($emoji as $key => $value) $text = str_replace( $key, ''.$value.'', $text ); return $text; } /** * Function: fix * Neutralizes HTML and quotes in strings for display. * * Parameters: * $string - String to fix. * $quotes - Encode quotes? * $double - Encode encoded? * * Returns: * A sanitized version of the string. */ function fix($string, $quotes = false, $double = false): string { $quotes = ($quotes) ? ENT_QUOTES : ENT_NOQUOTES ; return htmlspecialchars( (string) $string, $quotes | ENT_HTML5, "UTF-8", $double ); } /** * Function: unfix * Undoes neutralization of HTML and quotes in strings. * * Parameters: * $string - String to unfix. * $all - Decode all entities? * * Returns: * An unsanitary version of the string. */ function unfix($string, $all = false): string { return ($all) ? html_entity_decode( (string) $string, ENT_QUOTES | ENT_HTML5, "UTF-8" ) : htmlspecialchars_decode( (string) $string, ENT_QUOTES | ENT_HTML5 ) ; } /** * Function: sanitize * Sanitizes a string of troublesome characters, typically for use in URLs. * * Parameters: * $string - The string to sanitize - must be ASCII or UTF-8! * $lowercase - Force the string to lowercase? * $strict - Remove all characters except "-" and alphanumerics? * $truncate - Number of characters to truncate to (0 to disable). * * Returns: * A sanitized version of the string. */ function sanitize( $string, $lowercase = true, $strict = false, $truncate = 100 ): string { $strip = array( "&", "‘", "’", "“", "”", "–", "—", "&", "~", "`", "!", "@", "#", "$", "%", "^", "*", "(", ")", "_", "=", "+", "[", "{", "]", "}", "\\", "|", ";", ":", "\"", "'", "—", "–", ",", "<", ".", ">", "/", "?" ); $utf8mb = array( # Latin-1 Supplement. chr(194).chr(170) => "a", chr(194).chr(186) => "o", chr(195).chr(128) => "A", chr(195).chr(129) => "A", chr(195).chr(130) => "A", chr(195).chr(131) => "A", chr(195).chr(132) => "A", chr(195).chr(133) => "A", chr(195).chr(134) => "AE", chr(195).chr(135) => "C", chr(195).chr(136) => "E", chr(195).chr(137) => "E", chr(195).chr(138) => "E", chr(195).chr(139) => "E", chr(195).chr(140) => "I", chr(195).chr(141) => "I", chr(195).chr(142) => "I", chr(195).chr(143) => "I", chr(195).chr(144) => "D", chr(195).chr(145) => "N", chr(195).chr(146) => "O", chr(195).chr(147) => "O", chr(195).chr(148) => "O", chr(195).chr(149) => "O", chr(195).chr(150) => "O", chr(195).chr(153) => "U", chr(195).chr(154) => "U", chr(195).chr(155) => "U", chr(195).chr(156) => "U", chr(195).chr(157) => "Y", chr(195).chr(158) => "TH", chr(195).chr(159) => "s", chr(195).chr(160) => "a", chr(195).chr(161) => "a", chr(195).chr(162) => "a", chr(195).chr(163) => "a", chr(195).chr(164) => "a", chr(195).chr(165) => "a", chr(195).chr(166) => "ae", chr(195).chr(167) => "c", chr(195).chr(168) => "e", chr(195).chr(169) => "e", chr(195).chr(170) => "e", chr(195).chr(171) => "e", chr(195).chr(172) => "i", chr(195).chr(173) => "i", chr(195).chr(174) => "i", chr(195).chr(175) => "i", chr(195).chr(176) => "d", chr(195).chr(177) => "n", chr(195).chr(178) => "o", chr(195).chr(179) => "o", chr(195).chr(180) => "o", chr(195).chr(181) => "o", chr(195).chr(182) => "o", chr(195).chr(184) => "o", chr(195).chr(185) => "u", chr(195).chr(186) => "u", chr(195).chr(187) => "u", chr(195).chr(188) => "u", chr(195).chr(189) => "y", chr(195).chr(190) => "th", chr(195).chr(191) => "y", chr(195).chr(152) => "O", # Latin Extended-A. chr(196).chr(128) => "A", chr(196).chr(129) => "a", chr(196).chr(130) => "A", chr(196).chr(131) => "a", chr(196).chr(132) => "A", chr(196).chr(133) => "a", chr(196).chr(134) => "C", chr(196).chr(135) => "c", chr(196).chr(136) => "C", chr(196).chr(137) => "c", chr(196).chr(138) => "C", chr(196).chr(139) => "c", chr(196).chr(140) => "C", chr(196).chr(141) => "c", chr(196).chr(142) => "D", chr(196).chr(143) => "d", chr(196).chr(144) => "D", chr(196).chr(145) => "d", chr(196).chr(146) => "E", chr(196).chr(147) => "e", chr(196).chr(148) => "E", chr(196).chr(149) => "e", chr(196).chr(150) => "E", chr(196).chr(151) => "e", chr(196).chr(152) => "E", chr(196).chr(153) => "e", chr(196).chr(154) => "E", chr(196).chr(155) => "e", chr(196).chr(156) => "G", chr(196).chr(157) => "g", chr(196).chr(158) => "G", chr(196).chr(159) => "g", chr(196).chr(160) => "G", chr(196).chr(161) => "g", chr(196).chr(162) => "G", chr(196).chr(163) => "g", chr(196).chr(164) => "H", chr(196).chr(165) => "h", chr(196).chr(166) => "H", chr(196).chr(167) => "h", chr(196).chr(168) => "I", chr(196).chr(169) => "i", chr(196).chr(170) => "I", chr(196).chr(171) => "i", chr(196).chr(172) => "I", chr(196).chr(173) => "i", chr(196).chr(174) => "I", chr(196).chr(175) => "i", chr(196).chr(176) => "I", chr(196).chr(177) => "i", chr(196).chr(178) => "IJ", chr(196).chr(179) => "ij", chr(196).chr(180) => "J", chr(196).chr(181) => "j", chr(196).chr(182) => "K", chr(196).chr(183) => "k", chr(196).chr(184) => "k", chr(196).chr(185) => "L", chr(196).chr(186) => "l", chr(196).chr(187) => "L", chr(196).chr(188) => "l", chr(196).chr(189) => "L", chr(196).chr(190) => "l", chr(196).chr(191) => "L", chr(197).chr(128) => "l", chr(197).chr(129) => "L", chr(197).chr(130) => "l", chr(197).chr(131) => "N", chr(197).chr(132) => "n", chr(197).chr(133) => "N", chr(197).chr(134) => "n", chr(197).chr(135) => "N", chr(197).chr(136) => "n", chr(197).chr(137) => "N", chr(197).chr(138) => "n", chr(197).chr(139) => "N", chr(197).chr(140) => "O", chr(197).chr(141) => "o", chr(197).chr(142) => "O", chr(197).chr(143) => "o", chr(197).chr(144) => "O", chr(197).chr(145) => "o", chr(197).chr(146) => "OE", chr(197).chr(147) => "oe", chr(197).chr(148) => "R", chr(197).chr(149) => "r", chr(197).chr(150) => "R", chr(197).chr(151) => "r", chr(197).chr(152) => "R", chr(197).chr(153) => "r", chr(197).chr(154) => "S", chr(197).chr(155) => "s", chr(197).chr(156) => "S", chr(197).chr(157) => "s", chr(197).chr(158) => "S", chr(197).chr(159) => "s", chr(197).chr(160) => "S", chr(197).chr(161) => "s", chr(197).chr(162) => "T", chr(197).chr(163) => "t", chr(197).chr(164) => "T", chr(197).chr(165) => "t", chr(197).chr(166) => "T", chr(197).chr(167) => "t", chr(197).chr(168) => "U", chr(197).chr(169) => "u", chr(197).chr(170) => "U", chr(197).chr(171) => "u", chr(197).chr(172) => "U", chr(197).chr(173) => "u", chr(197).chr(174) => "U", chr(197).chr(175) => "u", chr(197).chr(176) => "U", chr(197).chr(177) => "u", chr(197).chr(178) => "U", chr(197).chr(179) => "u", chr(197).chr(180) => "W", chr(197).chr(181) => "w", chr(197).chr(182) => "Y", chr(197).chr(183) => "y", chr(197).chr(184) => "Y", chr(197).chr(185) => "Z", chr(197).chr(186) => "z", chr(197).chr(187) => "Z", chr(197).chr(188) => "z", chr(197).chr(189) => "Z", chr(197).chr(190) => "z", chr(197).chr(191) => "s" # Generate additional substitution keys: # E.g. echo implode(",", unpack("C*", "€")); ); # Strip tags, remove punctuation and HTML entities. $clean = str_replace( $strip, "", strip_tags($string) ); # Trim. $clean = trim($clean); # Replace spaces with hyphen-minus. $clean = preg_replace("/\s+/", "-", $clean); if ($strict) { # Substitute UTF-8 multi-byte encodings. if (preg_match("/[\x80-\xff]/", $clean)) $clean = strtr($clean, $utf8mb); # Remove non-ASCII characters that remain. $clean = preg_replace("/[^a-zA-Z0-9\\-]/", "", $clean); } if ($lowercase) $clean = mb_strtolower($clean, "UTF-8"); if ($truncate) $clean = mb_substr($clean, 0, $truncate, "UTF-8"); return $clean; } /** * Function: sanitize_html * Sanitizes HTML to disable scripts and obnoxious attributes. * * Parameters: * $string - String containing HTML to sanitize. * * Returns: * A version of the string containing only valid tags * and whitelisted attributes essential to tag function. */ function sanitize_html($text): string { # Strip invalid tags. $text = preg_replace( "/<([^a-z\/!]|\/(?![a-z])|!(?!--))[^>]*>/i", " ", $text ); # Strip style tags. $text = preg_replace( "/<\/?style[^>]*>/i", " ", $text ); # Strip script tags. $text = preg_replace( "/<\/?script[^>]*>/i", " ", $text ); # Strip attributes from each tag, unless essential to its function. return preg_replace_callback( "/<([a-z][a-z0-9]*)[^>]*?( ?\/)?>/i", function ($element) { fallback($element[2], ""); $name = strtolower($element[1]); $whitelist = ""; preg_match_all( "/ ([a-z]+)=(\"[^\"]+\"|\'[^\']+\')/i", $element[0], $attributes, PREG_SET_ORDER ); foreach ($attributes as $attribute) { $label = strtolower($attribute[1]); $content = trim($attribute[2], "\"'"); switch ($label) { case "src": $array = array( "audio", "iframe", "img", "source", "track", "video" ); if (in_array($name, $array) and is_url($content)) $whitelist.= $attribute[0]; break; case "href": $array = array( "a", "area" ); if (in_array($name, $array) and is_url($content)) $whitelist.= $attribute[0]; break; case "alt": $array = array( "area", "img" ); if (in_array($name, $array)) $whitelist.= $attribute[0]; break; case "dir": case "lang": $whitelist.= $attribute[0]; break; } } return "<".$element[1].$whitelist.$element[2].">"; }, $text ); } #--------------------------------------------- # Remote Fetches #--------------------------------------------- /** * Function: get_remote * Retrieve the contents of a URL. * * Parameters: * $url - The URL of the resource to be retrieved. * $redirects - The maximum number of redirects to follow. * $timeout - The maximum number of seconds to wait. * $headers - Include response headers with the content? * $post - Set the request type to POST instead of GET? * $data - An array or urlencoded string of POST data. * * Returns: * The response content, or false on failure. */ function get_remote( $url, $redirects = 0, $timeout = 10, $headers = false, $post = false, $data = null ): string|false { $config = Config::current(); $url = add_scheme($url); $host = parse_url($url, PHP_URL_HOST); if ($host === false) return false; if (is_unsafe_ip($host) and !GET_REMOTE_UNSAFE) return false; if (!function_exists("curl_version")) return false; $cver = curl_version(); $curl = @curl_init($url); if ($curl === false) return false; $opts = array( CURLOPT_CAINFO => INCLUDES_DIR.DIR."cacert.pem", CURLOPT_RETURNTRANSFER => true, CURLOPT_VERBOSE => false, CURLOPT_FAILONERROR => false, CURLOPT_HEADER => (bool) $headers, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => (int) $redirects, CURLOPT_TIMEOUT => (int) $timeout, CURLOPT_USERAGENT => CHYRP_IDENTITY, CURLOPT_HTTPHEADER => array( "From: ".$config->email, "Referer: ".$config->url ) ); if ( defined('CURLSSLOPT_NATIVE_CA') and version_compare($cver["version"], "7.71", ">=") ) { $opts[CURLOPT_SSL_OPTIONS] = CURLSSLOPT_NATIVE_CA; } if ($post) { $opts[CURLOPT_POST] = true; $opts[CURLOPT_POSTFIELDS] = $data; } if (!@curl_setopt_array($curl, $opts)) return false; $response = @curl_exec($curl); $errornum = curl_errno($curl); $errormsg = curl_error($curl); $type = $post ? "POST" : "GET" ; $result = oneof($errormsg, "OK"); curl_close($curl); if (DEBUG) error_log($type." ".$url." (".$result.")"); return $errornum ? false : $response ; } /** * Function: webmention_send * Sends Webmentions to the URLs discovered in a string. * * Parameters: * $string - The string to crawl for Webmention URLs. * $post - The post this string belongs to. * $limit - Execution time limit in seconds (optional). */ function webmention_send($string, $post, $limit = 30): void { foreach (grab_urls($string) as $url) { # Have we exceeded the time limit? if (timer_stop() > $limit) break; $endpoint = webmention_discover(unfix($url, true)); if ($endpoint === false) continue; if (DEBUG) error_log("WEBMENTION @ ".$endpoint." (".$url.")"); $wm_array = array( "source" => unfix($post->url()), "target" => $url ); $wm_query = http_build_query($wm_array); get_remote( url:$endpoint, timeout:3, post:true, data:$wm_query ); } } /** * Function: webmention_receive * Receives and validates Webmentions. * * Parameters: * $source - The sender's URL. * $target - The URL of our post. */ function webmention_receive($source, $target): void { $trigger = Trigger::current(); # No need to continue without a responder for the Webmention trigger. if (!$trigger->exists("webmention")) error( __("Error"), __("Webmention support is disabled for this site."), code:503 ); if (!is_url($source)) error( __("Error"), __("The URL for your page is not valid."), code:400 ); if (!is_url($target)) error( __("Error"), __("The URL for our page is not valid."), code:400 ); if (DEBUG) error_log( "WEBMENTION received; source:".$source." target:".$target ); $source_url = add_scheme(unfix($source, true)); $target_url = add_scheme(unfix($target, true)); if ($target == $source) error( __("Error"), __("The source and target URLs cannot be the same."), code:400 ); $post = Post::from_url($target_url); if ($post->no_results) error( __("Error"), __("We have not published at that URL."), code:404 ); # Retrieve the page that linked here. $content = get_remote( url:$source_url, redirects:0, timeout:10 ); if (empty($content)) error( __("Error"), __("You have not published at that URL."), code:404 ); if (strpos($content, $target) === false) error( __("Error"), __("Your page does not link to our page."), code:400 ); $trigger->call("webmention", $post, $source, $target); } /** * Function: webmention_discover * Determines if a URL is capable of receiving Webmentions. * * Parameters: * $url - The URL to check. * $redirects - The maximum number of redirects to follow. * * Returns: * The Webmention endpoint URL, or false on failure. */ function webmention_discover($url, $redirects = 3): string|false { $response = get_remote( url:$url, redirects:$redirects, timeout:3, headers:true ); if ($response === false) return false; $parts = explode("\r\n\r\n", $response, 2); if (count($parts) < 2) return false; $headers = $parts[0]; $content = $parts[1]; if (preg_match("/^Link: *<(.+)> *; *rel=\"webmention\"/im", $headers, $match)) { $endpoint = trim($match[1]); # Absolute URL? if (is_url($endpoint)) return $endpoint; # Relative URL? return merge_urls($url, $endpoint); } # Check if the content is UTF-8 encoded text/html before continuing. if (!preg_match("/^Content-Type: *text\/html *; *charset=UTF-8/im", $headers)) return false; # Check for element containing the endpoint. if (preg_match_all("/]+>/i", $content, $links, PREG_PATTERN_ORDER)) { foreach ($links[0] as $link) { if (!preg_match("/ rel=(\"webmention\"|\'webmention\')/i", $link)) continue; if (preg_match("/ href=(\"[^\"]+\"|\'[^\']+\')/i", $link, $href)) { $endpoint = unfix(trim($href[1], "\"'")); # Absolute URL? if (is_url($endpoint)) return $endpoint; # Relative URL? return merge_urls($url, $endpoint); } } } # Check for element containing the endpoint. if (preg_match_all("/]+>/i", $content, $anchors, PREG_PATTERN_ORDER)) { foreach ($anchors[0] as $anchor) { if (!preg_match("/ rel=(\"webmention\"|\'webmention\')/i", $anchor)) continue; if (preg_match("/ href=(\"[^\"]+\"|\'[^\']+\')/i", $anchor, $href)) { $endpoint = unfix(trim($href[1], "\"'")); # Absolute URL? if (is_url($endpoint)) return $endpoint; # Relative URL? return merge_urls($url, $endpoint); } } } return false; } /** * Function: grab_urls * Crawls a string and grabs hyperlinks from it. * * Parameters: * $string - The string to crawl. * * Returns: * An array of all URLs found in the string. */ function grab_urls($string): array { $urls = array(); $regx = "/]* href=(\"[^\"]+\"|\'[^\']+\')[^>]*>.+?<\/a>/i"; if (preg_match_all($regx, $string, $matches, PREG_PATTERN_ORDER)) $urls = $matches[1]; foreach ($urls as &$url) $url = trim($url, " \"'"); return array_filter(array_unique($urls), "is_url"); } /** * Function: merge_urls * Combines a base URL and relative path into a target URL. * * Parameters: * $base - The base URL. * $rel - The relative path. * * Returns: * A merged target URL, or false on failure. * * Notes: * Does not attempt to resolve dot segments in the path. */ function merge_urls($base, $rel) { extract(parse_url(add_scheme($base)), EXTR_SKIP); fallback($path, "/"); fallback($scheme, "http"); if (!isset($host)) return false; if ($rel == "") return add_scheme($base); $end = strrpos($path, "/"); $len = strlen($path); # Reduce the base path by one segment if the path doesn't end with "/". if ($end !== ($len - 1)) $path = substr($path, 0, $end + 1); # Append the relative path, or replace the path if rel begins with "/". if (str_starts_with($rel, "/")) $path = $rel; else $path.= $rel; return $scheme."://".$host. (isset($port) ? ":".$port : ""). $path; } #--------------------------------------------- # Extensions #--------------------------------------------- /** * Function: load_info * Loads an extension's info.php file and returns an array of attributes. */ function load_info($filepath): array { if (is_file($filepath) and is_readable($filepath)) $info = include $filepath; if (!isset($info) or gettype($info) != "array") $info = array(); fallback($info["name"], fix(basename(dirname($filepath)))); fallback($info["version"], ""); fallback($info["url"], ""); fallback($info["description"], ""); fallback($info["author"], array("name" => "", "url" => "")); fallback($info["confirm"]); fallback($info["uploader"], false); fallback($info["conflicts"], array()); fallback($info["dependencies"], array()); fallback($info["notifications"], array()); $info["conflicts"] = (array) $info["conflicts"]; $info["dependencies"] = (array) $info["dependencies"]; $info["notifications"] = (array) $info["notifications"]; $uploads_path = MAIN_DIR.Config::current()->uploads_path; if ($info["uploader"]) { if (!is_dir($uploads_path)) $info["notifications"][] = __("Please create the uploads directory."); elseif (!is_writable($uploads_path)) $info["notifications"][] = __("Please make the uploads directory writable."); } return $info; } /** * Function: init_extensions * Initialize all Modules and Feathers. */ function init_extensions(): void { $config = Config::current(); # Instantiate all Modules. foreach ($config->enabled_modules as $module) { $class_name = camelize($module); $filepath = MODULES_DIR.DIR.$module.DIR.$module.".php"; if (!is_file($filepath) or !is_readable($filepath)) { cancel_module( $module, _f("%s module is missing.", $class_name) ); continue; } load_translator($module, MODULES_DIR.DIR.$module.DIR."locale"); require $filepath; if (!is_subclass_of($class_name, "Modules")) { cancel_module( $module, _f("%s module is damaged.", $class_name) ); continue; } Modules::$instances[$module] = new $class_name; Modules::$instances[$module]->safename = $module; } # Instantiate all Feathers. foreach ($config->enabled_feathers as $feather) { $class_name = camelize($feather); $filepath = FEATHERS_DIR.DIR.$feather.DIR.$feather.".php"; if (!is_file($filepath) or !is_readable($filepath)) { cancel_feather( $feather, _f("%s feather is missing.", $class_name) ); continue; } load_translator($feather, FEATHERS_DIR.DIR.$feather.DIR."locale"); require $filepath; if (!is_subclass_of($class_name, "Feathers")) { cancel_feather( $feather, _f("%s feather is damaged.", $class_name) ); continue; } Feathers::$instances[$feather] = new $class_name; Feathers::$instances[$feather]->safename = $feather; } # Initialize all Modules. foreach (Modules::$instances as $module) { if (method_exists($module, "__init")) $module->__init(); } # Initialize all Feathers. foreach (Feathers::$instances as $feather) { if (method_exists($feather, "__init")) $feather->__init(); } } /** * Function: module_enabled * Determines if a module is currently enabled and not cancelled. * * Parameters: * $name - The non-camelized name of the module. * * Returns: * Whether or not the supplied module is enabled. */ function module_enabled($name): bool { return ( !empty(Modules::$instances[$name]) and empty(Modules::$instances[$name]->cancelled) ); } /** * Function: feather_enabled * Determines if a feather is currently enabled and not cancelled. * * Parameters: * $name - The non-camelized name of the feather. * * Returns: * Whether or not the supplied feather is enabled. */ function feather_enabled($name): bool { return ( !empty(Feathers::$instances[$name]) and empty(Feathers::$instances[$name]->cancelled) ); } /** * Function: cancel_module * Temporarily declares a module cancelled (disabled). * * Parameters: * $target - The non-camelized name of the module. * $reason - Why was execution cancelled? * * Notes: * A module can cancel itself in its __init() method. */ function cancel_module($target, $reason = ""): void { $message = empty($reason) ? _f("Execution of %s has been cancelled.", camelize($target)) : $reason ; if (isset(Modules::$instances[$target])) Modules::$instances[$target]->cancelled = true; if (DEBUG) error_log($message); } /** * Function: cancel_feather * Temporarily declares a feather cancelled (disabled). * * Parameters: * $target - The non-camelized name of the feather. * $reason - Why was execution cancelled? * * Notes: * A feather can cancel itself in its __init() method. */ function cancel_feather($target, $reason = ""): void { $message = empty($reason) ? _f("Execution of %s has been cancelled.", camelize($target)) : $reason ; if (isset(Feathers::$instances[$target])) Feathers::$instances[$target]->cancelled = true; if (DEBUG) error_log($message); } #--------------------------------------------- # Upload Management #--------------------------------------------- /** * Function: upload * Validates and moves an uploaded file to the uploads directory. * * Parameters: * $file - The POST method upload array, e.g. $_FILES['userfile']. * $filter - An array of valid extensions (case-insensitive). * * Returns: * The filename of the upload relative to the uploads directory. */ function upload($file, $filter = null): string { $uploads_path = MAIN_DIR.Config::current()->uploads_path; $filename = upload_filename($file['name'], $filter); if ($filename === false) error( __("Error"), __("Uploaded file is of an unsupported type."), code:415 ); if (!is_uploaded_file($file['tmp_name'])) show_403( __("Access Denied"), __("Only uploaded files are accepted.") ); if (!is_dir($uploads_path)) error( __("Error"), __("Upload path does not exist.") ); if (!is_writable($uploads_path)) error( __("Error"), __("Upload path is not writable.") ); if (!move_uploaded_file($file['tmp_name'], $uploads_path.$filename)) error( __("Error"), __("Failed to write file to disk.") ); return $filename; } /** * Function: upload_from_url * Copies a file from a remote URL to the uploads directory. * * Parameters: * $url - The URL of the resource to be copied. * $redirects - The maximum number of redirects to follow. * $timeout - The maximum number of seconds to wait. * * Returns: * The filename of the copied file, or false on failure. */ function upload_from_url($url, $redirects = 3, $timeout = 10): string|false { if (!preg_match("~[^ /\?]+(?=($|\?))~", $url, $match)) return false; $filename = upload_filename($match[0]); if ($filename === false) return false; $contents = get_remote($url, $redirects, $timeout); if ($contents === false) return false; $uploads_path = MAIN_DIR.Config::current()->uploads_path; if (!is_dir($uploads_path)) error( __("Error"), __("Upload path does not exist.") ); if (!is_writable($uploads_path)) error( __("Error"), __("Upload path is not writable.") ); if (!@file_put_contents($uploads_path.$filename, $contents)) error( __("Error"), __("Failed to write file to disk.") ); return $filename; } /** * Function: uploaded * Generates an absolute URL or filesystem path to an uploaded file. * * Parameters: * $filename - Filename relative to the uploads directory. * $url - Whether to return a URL or a filesystem path. * * Returns: * The supplied filename prepended with URL or filesystem path. */ function uploaded($filename, $url = true): string { $config = Config::current(); return ($url) ? fix( $config->chyrp_url. str_replace(DIR, "/", $config->uploads_path). urlencode($filename), true ) : MAIN_DIR.$config->uploads_path.$filename ; } /** * Function: uploaded_search * Returns an array of files discovered in the uploads directory. * * Parameters: * $search - A search term. * $filter - An array of valid extensions (case insensitive). * $sort - One of "name", "type", "size", or "modified". */ function uploaded_search( $search = "", $filter = array(), $sort = "name" ): array { $config = Config::current(); $results = array(); if (!empty($filter)) { foreach ($filter as &$entry) $entry = preg_quote($entry, "/"); } $patterns = !empty($filter) ? implode("|", $filter) : ".+" ; $dir = new DirectoryIterator(MAIN_DIR.$config->uploads_path); foreach ($dir as $item) { if ($item->isFile()) { $filename = $item->getFilename(); if (!preg_match("/.+\.($patterns)$/i", $filename)) continue; if (stripos($filename, $search) === false) continue; $results[] = array( "name" => $filename, "type" => $item->getExtension(), "size" => $item->getSize(), "modified" => $item->getMTime() ); } } function build_sorter($sort) { switch ($sort) { case "size": case "modified": return function ($a, $b) use ($sort) { if ($a[$sort] == $b[$sort]) return 0; return ($a[$sort] < $b[$sort]) ? -1 : 1 ; }; break; case "name": case "type": return function ($a, $b) use ($sort) { return strnatcmp($a[$sort], $b[$sort]); }; break; default: return function ($a, $b) { return strnatcmp($a["name"], $b["name"]); }; } } usort($results, build_sorter($sort)); return $results; } /** * Function: upload_tester * Tests uploaded file information to determine if the upload was successful. * * Parameters: * $file - The POST method upload array, e.g. $_FILES['userfile']. * * Returns: * True for a successful upload or false if no file was uploaded. * * Notes: * $_POST and $_FILES are empty if post_max_size directive is exceeded. */ function upload_tester($file): bool { $success = false; $results = array(); $maximum = Config::current()->uploads_limit; # Recurse to test multiple uploads file by file using a one-dimensional array. if (is_array($file['name'])) { for ($i = 0; $i < count($file['name']); $i++) $results[] = upload_tester( array( 'name' => $file['name'][$i], 'type' => $file['type'][$i], 'tmp_name' => $file['tmp_name'][$i], 'error' => $file['error'][$i], 'size' => $file['size'][$i] ) ); return (!in_array(false, $results)); } switch ($file['error']) { case UPLOAD_ERR_OK: $success = true; break; case UPLOAD_ERR_NO_FILE: $success = false; break; case UPLOAD_ERR_INI_SIZE: error( __("Error"), __("The uploaded file exceeds the upload_max_filesize directive in php.ini."), code:413 ); case UPLOAD_ERR_FORM_SIZE: error( __("Error"), __("The uploaded file exceeds the MAX_FILE_SIZE directive in the HTML form."), code:413 ); case UPLOAD_ERR_PARTIAL: error( __("Error"), __("The uploaded file was only partially uploaded."), code:400 ); case UPLOAD_ERR_NO_TMP_DIR: error( __("Error"), __("Missing a temporary folder.") ); case UPLOAD_ERR_CANT_WRITE: error( __("Error"), __("Failed to write file to disk.") ); case UPLOAD_ERR_EXTENSION: error( __("Error"), __("File upload was stopped by a PHP extension.") ); default: error( __("Error"), _f("File upload failed with error %d.", $file['error']) ); } if ($file['size'] > ($maximum * 1000000)) error( __("Error"), _f("The uploaded file exceeds the maximum size of %d Megabytes allowed by this site.", $maximum), code:413 ); return $success; } /** * Function: upload_filename * Generates a sanitized unique name for an uploaded file. * * Parameters: * $filename - The filename to make unique. * $filter - An array of valid extensions (case insensitive). * * Returns: * A sanitized unique filename, or false on failure. */ function upload_filename($filename, $filter = array()): string|false { if (empty($filter)) $filter = upload_filter_whitelist(); foreach ($filter as &$entry) $entry = preg_quote($entry, "/"); $patterns = implode("|", $filter); # Return false if a valid basename and extension is not extracted. if (!preg_match("/(.+)(\.($patterns))$/i", $filename, $matches)) return false; $sanitized = oneof( sanitize($matches[1], true, true, 80), md5($filename) ); $count = 1; $extension = $matches[3]; $unique = $sanitized.".".$extension; while (file_exists(uploaded($unique, false))) { $count++; $unique = $sanitized."-".$count.".".$extension; } return $unique; } /** * Function: upload_filter_whitelist * Returns an array containing a default list of allowed file extensions. */ function upload_filter_whitelist(): array { return array( # Binary and text formats: "bin", "exe", "txt", "rtf", "vtt", "md", "pdf", "epub", "mobi", "kfx", # Archive and compression formats: "zip", "tar", "rar", "gz", "bz2", "7z", "dmg", "cab", "iso", "udf", # Image formats: "jpg", "jpeg", "png", "webp", "gif", "avif", "tif", "tiff", "heif", "bmp", # Video and audio formats: "mpg", "mpeg", "mp2", "mp3", "mp4", "m4a", "m4v", "ogg", "oga", "ogv", "mka", "mkv", "mov", "avi", "wav", "webm", "flac", "aif", "aiff", "3gp", "spx", "ts" ); } /** * Function: delete_upload * Deletes an uploaded file. * * Parameters: * $filename - Filename relative to the uploads directory. * * Returns: * Whether or not the file was deleted successfully. */ function delete_upload($filename): bool { $filename = str_replace(array(DIR, "/"), "", $filename); if ($filename == "") return false; $filepath = uploaded($filename, false); if (file_exists($filepath)) { Trigger::current()->call("delete_upload", $filename); return @unlink($filepath); } return false; } #--------------------------------------------- # Input Validation and Processing #--------------------------------------------- /** * Function: password_strength * Award a numeric score for the strength of a password. * * Parameters: * $password - The password string to score. * * Returns: * A numeric score for the strength of the password. */ function password_strength($password = ""): int { $score = 0; if (empty($password)) return $score; # Calculate the frequency of each char in the password. $frequency = array_count_values(str_split($password)); # Award each unique char and punish more than 10 occurrences. foreach ($frequency as $occurrences) $score += (11 - $occurrences); # Award bonus points for different character types. $variations = array( "digits" => preg_match("/\d/", $password), "lower" => preg_match("/[a-z]/", $password), "upper" => preg_match("/[A-Z]/", $password), "nonWords" => preg_match("/\W/", $password) ); $score += (array_sum($variations) - 1) * 10; return intval($score); } /** * Function: is_url * Does the string look like a web URL? * * Parameters: * $string - The string to analyse. * * Returns: * Whether or not the string matches the criteria. * * Notes: * Recognises FQDN, IPv4 and IPv6. * * See Also: * */ function is_url($string): bool { if ( !is_string($string) and !$string instanceof Stringable ) return false; return ( preg_match( '~^(https?://)?([a-z0-9]([a-z0-9\-\.]*[a-z0-9])?\.[a-z]{2,63}\.?)(:[0-9]{1,5})?($|/)~i', $string ) or preg_match( '~^(https?://)?([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})(:[0-9]{1,5})?($|/)~', $string ) or preg_match( '~^(https?://)?(\[[a-f0-9\:]{3,39}\])(:[0-9]{1,5})?($|/)~i', $string ) ); } /** * Function: add_scheme * Prefixes a URL with a scheme if none was detected. * * Parameters: * $url - The URL to analyse. * $scheme - Force this scheme (optional). * * Returns: * URL prefixed with a default or supplied scheme. * * See Also: * */ function add_scheme($url, $scheme = null): string { preg_match('~^([a-z]+://)?(.+)~i', $url, $match); $match[1] = isset($scheme) ? $scheme : oneof($match[1], "http://") ; return $url = $match[1].$match[2]; } /** * Function: is_email * Does the string look like an email address? * * Parameters: * $string - The string to analyse. * * Notes: * Recognises FQDN, IPv4 and IPv6. * * Returns: * Whether or not the string matches the criteria. */ function is_email($string): bool { if ( !is_string($string) and !$string instanceof Stringable ) return false; return ( preg_match( '/^[^\\\\ <>@]+@([a-z0-9]([a-z0-9\-\.]*[a-z0-9])?\.[a-z]{2,63}\.?)$/i', $string ) or preg_match( '/^[^\\\\ <>@]+@([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})$/', $string ) or preg_match( '/^[^\\\\ <>@]+@(\[[a-f0-9\:]{3,39}\])$/i', $string ) ); } /** * Function: is_unsafe_ip * Is the string a private or reserved IP address? * * Parameters: * $string - The string to analyse. * * Returns: * Whether or not the string matches the criteria. */ function is_unsafe_ip($string): bool { if ( !is_string($string) and !$string instanceof Stringable ) return false; if (preg_match('/^\[[a-fA-F0-9\:]{3,39}\]$/', $string)) $string = substr($string, 1, -1); if (!filter_var($string, FILTER_VALIDATE_IP)) return false; return ( !filter_var( $string, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ); } /** * Function: is_datetime_zero * Is the string a SQL datetime "zero" variant? * * Parameters: * $string - The string to analyse. * * Returns: * Whether or not the string matches the criteria. */ function is_datetime_zero($string): bool { if ( !is_string($string) and !$string instanceof Stringable ) return false; foreach (SQL_DATETIME_ZERO_VARIANTS as $variant) { if (strcmp($variant, $string) === 0) return true; } return false; } /** * Function: generate_captcha * Generates a captcha form element. * * Returns: * A string containing HTML elements to add to a form. */ function generate_captcha(): string { Trigger::current()->call("before_generate_captcha"); foreach (get_declared_classes() as $class) { if (in_array("CaptchaProvider", class_implements($class))) return call_user_func($class."::generateCaptcha"); } return ""; } /** * Function: check_captcha * Checks the response to a captcha. * * Returns: * Whether or not the captcha was defeated. */ function check_captcha(): bool { Trigger::current()->call("before_check_captcha"); foreach (get_declared_classes() as $class) { if (in_array("CaptchaProvider", class_implements($class))) return call_user_func($class."::checkCaptcha"); } return true; } #--------------------------------------------- # Responding to Requests #--------------------------------------------- /** * Function: esce * Outputs an escaped echo for JavaScripts. * * Parameters: * $variable - The variable to echo. * * Notes: * Strings are escaped with backslashes, * booleans expanded to "true" or "false". */ function esce($variable): void { if ( !is_scalar($variable) and !$variable instanceof Stringable ) return; if (is_bool($variable)) { echo ($variable) ? "true" : "false" ; } else { echo addslashes((string) $variable); } } /** * Function: json_set * JSON encodes a value and checks for errors. * * Parameters: * $value - The value to be encoded. * $options - A bitmask of encoding options. * $depth - Recursion depth for encoding. * * Returns: * A JSON encoded string or false on failure. */ function json_set($value, $options = 0, $depth = 512): string|false { $encoded = json_encode($value, $options, $depth); if (json_last_error()) trigger_error( _f("JSON encoding error: %s", fix(json_last_error_msg(), false, true)), E_USER_WARNING ); return $encoded; } /** * Function: json_get * JSON decodes a value and checks for errors. * * Parameters: * $value - The UTF-8 string to be decoded. * $assoc - Convert objects into associative arrays? * $depth - Recursion depth for decoding. * $options - A bitmask of decoding options. * * Returns: * A JSON decoded value of the appropriate PHP type. */ function json_get($value, $assoc = false, $depth = 512, $options = 0): mixed { $decoded = json_decode($value, $assoc, $depth, $options); if (json_last_error()) trigger_error( _f("JSON decoding error: %s", fix(json_last_error_msg(), false, true)), E_USER_WARNING ); return $decoded; } /** * Function: json_response * Send a structured JSON response. * * Parameters: * $text - A string containing a response message. * $data - Arbitrary data to be sent with the response. */ function json_response($text = null, $data = null): void { header("Content-Type: application/json; charset=UTF-8"); echo json_set(array("text" => $text, "data" => $data)); } /** * Function: file_attachment * Send a file attachment to the visitor. * * Parameters: * $contents - The bitstream to be delivered to the visitor. * $filename - The name to be applied to the content upon download. */ function file_attachment($contents = "", $filename = "caconym"): void { $safename = addslashes($filename); header("Content-Type: application/octet-stream"); header("Content-Disposition: attachment; filename=\"".$safename."\""); if (!USE_COMPRESSION and !ini_get("zlib.output_compression")) header("Content-Length: ".strlen($contents)); echo $contents; } /** * Function: zip_archive * Creates a basic flat Zip archive from an array of items. * * Parameters: * $array - An associative array of names and contents. * * Returns: * A Zip archive. * * See Also: * https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT */ function zip_archive($array): string { $file = ""; $cdir = ""; $eocd = ""; # Generate MS-DOS date/time format. $now = getdate(); $time = ( 0 | $now["seconds"] >> 1 | $now["minutes"] << 5 | $now["hours"] << 11 ); $date = ( 0 | $now["mday"] | $now["mon"] << 5 | ($now["year"] - 1980) << 9 ); foreach ($array as $name => $orig) { # Remove directory separators. $name = str_replace(array("\\", "/"), "", $name); $comp = $orig; $method = "\x00\x00"; if (strlen($name) > 0xffff or strlen($orig) > 0xffffffff) trigger_error( __("Failed to create Zip archive."), E_USER_WARNING ); if (function_exists("gzcompress")) { $zlib = gzcompress($orig, 6, ZLIB_ENCODING_DEFLATE); if ($zlib !== false) { # Trim ZLIB header and checksum from the deflated data. $zlib = substr(substr($zlib, 0, strlen($zlib) - 4), 2); if (strlen($zlib) < strlen($orig)) { $comp = $zlib; $method = "\x08\x00"; } } } $head = "\x50\x4b\x03\x04"; # Local file header signature. $head.= "\x14\x00"; # Version needed to extract. $head.= "\x00\x00"; # General purpose bit flag. $head.= $method; # Compression method. $head.= pack("v", $time); # Last mod file time. $head.= pack("v", $date); # Last mod file date. $nlen = strlen($name); $olen = strlen($orig); $clen = strlen($comp); $crc = crc32($orig); $head.= pack("V", $crc); # CRC-32. $head.= pack("V", $clen); # Compressed size. $head.= pack("V", $olen); # Uncompressed size. $head.= pack("v", $nlen); # File name length. $head.= pack("v", 0); # Extra field length. $cdir.= "\x50\x4b\x01\x02"; # Central file header signature. $cdir.= "\x00\x00"; # Version made by. $cdir.= "\x14\x00"; # Version needed to extract. $cdir.= "\x00\x00"; # General purpose bit flag. $cdir.= $method; # Compression method. $cdir.= pack("v", $time); # Last mod file time. $cdir.= pack("v", $date); # Last mod file date. $cdir.= pack("V", $crc); # CRC-32. $cdir.= pack("V", $clen); # Compressed size. $cdir.= pack("V", $olen); # Uncompressed size. $cdir.= pack("v", $nlen); # File name length. $cdir.= pack("v", 0); # Extra field length. $cdir.= pack("v", 0); # File comment length. $cdir.= pack("v", 0); # Disk number start. $cdir.= pack("v", 0); # Internal file attributes. $cdir.= pack("V", 32); # External file attributes. $cdir.= pack("V", strlen($file)); # Relative offset of local header. $cdir.= $name; $file.= $head.$name.$comp; } $eocd.= "\x50\x4b\x05\x06"; # End of central directory signature. $eocd.= "\x00\x00"; # Number of this disk. $eocd.= "\x00\x00"; # Disk with start of central directory. $eocd.= pack("v", count($array)); # Entries on this disk. $eocd.= pack("v", count($array)); # Total number of entries. $eocd.= pack("V", strlen($cdir)); # Size of the central directory. $eocd.= pack("V", strlen($file)); # Offset of start of central directory. $eocd.= "\x00\x00"; # ZIP file comment length. return $file.$cdir.$eocd; } /** * Function: email * Sends an email using PHP's mail() function or an alternative. */ function email(): bool { if (!Config::current()->email_correspondence) return false; $function = "mail"; Trigger::current()->filter($function, "send_mail"); return call_user_func_array($function, func_get_args()); } /** * Function: email_activate_account * Sends an activation email to a newly registered user. * * Parameters: * $user - The user to receive the email. */ function email_activate_account($user): bool { $config = Config::current(); $trigger = Trigger::current(); $url = $config->url."/?action=activate". "&login=".urlencode($user->login). "&token=".token($user->login); if ($trigger->exists("correspond_activate_account")) return $trigger->call("correspond_activate_account", $user, $url); $headers = array( "Content-Type" => "text/plain; charset=UTF-8", "From" => $config->email, "X-Mailer" => CHYRP_IDENTITY ); $subject = _f("Activate your account at %s", $config->name); $message = _f("Hello, %s.", $user->login). "\r\n". "\r\n". __("You are receiving this message because you registered a new account."). "\r\n". "\r\n". __("Visit this link to activate your account:"). "\r\n". unfix($url); return email($user->email, $subject, $message, $headers); } /** * Function: email_reset_password * Sends a password reset email to a user. * * Parameters: * $user - The user to receive the email. */ function email_reset_password($user): bool { $config = Config::current(); $trigger = Trigger::current(); $issue = strval(time()); $url = $config->url."/?action=reset_password". "&issue=".$issue. "&login=".urlencode($user->login). "&token=".token(array($issue, $user->login)); if ($trigger->exists("correspond_reset_password")) return $trigger->call("correspond_reset_password", $user, $url); $headers = array( "Content-Type" => "text/plain; charset=UTF-8", "From" => $config->email, "X-Mailer" => CHYRP_IDENTITY ); $subject = _f("Reset your password at %s", $config->name); $message = _f("Hello, %s.", $user->login). "\r\n". "\r\n". __("You are receiving this message because you requested a new password."). "\r\n". "\r\n". __("Visit this link to reset your password:"). "\r\n". unfix($url); return email($user->email, $subject, $message, $headers); } /** * Function: javascripts * Returns inline JavaScript for core functionality and extensions. */ function javascripts(): string { $config = Config::current(); $route = Route::current(); $theme = Theme::current(); $trigger = Trigger::current(); $visitor = Visitor::current(); $nonce = ""; $script = (ADMIN) ? MAIN_DIR.DIR."admin".DIR."javascripts".DIR."admin.js.php" : INCLUDES_DIR.DIR."main.js.php" ; $common = ''; ob_start(); include $script; $ob = ob_get_clean(); $trigger->call("javascripts_hash", $ob); $trigger->filter($nonce, "javascripts_nonce"); return $common."\n\n"; }