leilukin-tumbleblog/includes/helpers.php

3414 lines
104 KiB
PHP
Raw Normal View History

2024-06-20 14:10:42 +00:00
<?php
/**
* File: helpers
* Various functions used throughout the codebase.
*/
#---------------------------------------------
# Sessions
#---------------------------------------------
/**
* Function: session
* Begins Chyrp's custom session storage whatnots.
*
* Parameters:
* $secure - Send the cookie only over HTTPS?
*/
function session($secure = null): void {
if (session_status() == PHP_SESSION_ACTIVE) {
trigger_error(
__("Session cannot be started more than once."),
E_USER_NOTICE
);
return;
}
$handler = new Session();
session_set_save_handler($handler, true);
$parsed = parse_url(Config::current()->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 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 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:
* <Group::add> <User::add>
*/
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.
*/
function keywords($query, $plain, $table = null): array {
$trimmed = trim($query);
if (empty($trimmed))
return array(array(), array(), null);
$sql = SQL::current();
$trigger = Trigger::current();
# 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:
* <decamelize>
*/
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:
* <camelize>
*/
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->headlineAnchors = true;
$parser->enableNewlines = 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:-)" => "&#x1f607;",
"&gt;:-)" => "&#x1f608;",
">:-)" => "&#x1f608;",
":-)" => "&#x1f600;",
"^_^" => "&#x1f601;",
":-D" => "&#x1f603;",
";-)" => "&#x1f609;",
"&lt;3" => "&#x1f60d;",
"<3" => "&#x1f60d;",
"B-)" => "&#x1f60e;",
":-&gt;" => "&#x1f60f;",
":->" => "&#x1f60f;",
":-||" => "&#x1f62c;",
":-|" => "&#x1f611;",
"-_-" => "&#x1f612;",
":-/" => "&#x1f615;",
":-s" => "&#x1f616;",
":-*" => "&#x1f618;",
":-P" => "&#x1f61b;",
":-((" => "&#x1f629;",
":-(" => "&#x1f61f;",
";_;" => "&#x1f622;",
":-o" => "&#x1f62e;",
"O_O" => "&#x1f632;",
":-$" => "&#x1f633;",
"x_x" => "&#x1f635;",
":-x" => "&#x1f636;"
);
foreach ($emoji as $key => $value)
$text = str_replace(
$key,
'<span class="emoji">'.$value.'</span>',
$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(
"&amp;", "&#8216;", "&#8217;", "&#8220;", "&#8221;", "&#8211;", "&#8212;", "&",
"~", "`", "!", "@", "#", "$", "%", "^", "*", "(", ")", "_", "=", "+", "[", "{",
"]", "}", "\\", "|", ";", ":", "\"", "'", "", "", ",", "<", ".", ">", "/", "?"
);
$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;
$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 ($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 <link> element containing the endpoint.
if (preg_match_all("/<link [^>]+>/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 <a> element containing the endpoint.
if (preg_match_all("/<a [^>]+>/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 = "/<a(?= )[^>]* 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 (strpos($rel, "/") === 0)
$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.")
);
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 <code>upload_max_filesize</code> directive in php.ini."),
code:413
);
case UPLOAD_ERR_FORM_SIZE:
error(
__("Error"),
__("The uploaded file exceeds the <code>MAX_FILE_SIZE</code> 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:
* <add_scheme>
*/
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:
* <is_url>
*/
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 (
!in_array("ob_gzhandler", ob_list_handlers()) 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".
"&amp;login=".urlencode($user->login).
"&amp;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".
"&amp;issue=".$issue.
"&amp;login=".urlencode($user->login).
"&amp;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 = '<script src="'.
fix($config->chyrp_url."/includes/common.js", true).
'"></script>';
ob_start();
include $script;
$ob = ob_get_clean();
$trigger->call("javascripts_hash", $ob);
$trigger->filter($nonce, "javascripts_nonce");
return $common."\n<script nonce=\"".$nonce."\">".$ob."</script>\n";
}