3414 lines
104 KiB
PHP
3414 lines
104 KiB
PHP
<?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:-)" => "😇",
|
||
">:-)" => "😈",
|
||
">:-)" => "😈",
|
||
":-)" => "😀",
|
||
"^_^" => "😁",
|
||
":-D" => "😃",
|
||
";-)" => "😉",
|
||
"<3" => "😍",
|
||
"<3" => "😍",
|
||
"B-)" => "😎",
|
||
":->" => "😏",
|
||
":->" => "😏",
|
||
":-||" => "😬",
|
||
":-|" => "😑",
|
||
"-_-" => "😒",
|
||
":-/" => "😕",
|
||
":-s" => "😖",
|
||
":-*" => "😘",
|
||
":-P" => "😛",
|
||
":-((" => "😩",
|
||
":-(" => "😟",
|
||
";_;" => "😢",
|
||
":-o" => "😮",
|
||
"O_O" => "😲",
|
||
":-$" => "😳",
|
||
"x_x" => "😵",
|
||
":-x" => "😶"
|
||
);
|
||
|
||
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(
|
||
"&", "‘", "’", "“", "”", "–", "—", "&",
|
||
"~", "`", "!", "@", "#", "$", "%", "^", "*", "(", ")", "_", "=", "+", "[", "{",
|
||
"]", "}", "\\", "|", ";", ":", "\"", "'", "—", "–", ",", "<", ".", ">", "/", "?"
|
||
);
|
||
|
||
$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".
|
||
"&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 = '<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";
|
||
}
|