包含:登录失败锁定、90天密码有效期、30分钟会话超时、 强制改密、登录审计日志、屏幕水印、企业背景图、 备案信息固定底部、favicon、JS空集合保护、 会话过期体验优化(403 JSON)、display_errors 关闭、 固定 key 根治 Integrity check failed 等全部改动 注意:config.inc.php/.htaccess/.user.ini 含敏感信息, 通过 .gitignore 排除,勿推送到公开仓库。
3082 lines
87 KiB
PHP
3082 lines
87 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
namespace MRBS;
|
|
|
|
use DateInterval;
|
|
use DateTimeZone;
|
|
use Email\Parse;
|
|
use InvalidArgumentException;
|
|
use MRBS\Form\Field;
|
|
use MRBS\Form\FieldSelect;
|
|
use MRBS\ICalendar\RFC5545;
|
|
use IntlDateFormatter;
|
|
use MRBS\Intl\IntlDateFormatterFactory;
|
|
use MRBS\Intl\IntlDatePatternGenerator;
|
|
use PHPMailer\PHPMailer\PHPMailer;
|
|
use TypeError;
|
|
|
|
require_once "mrbs_sql.inc";
|
|
|
|
|
|
|
|
/**
|
|
* A wrapper for Language::getVocab() to avoid (a) having to replace
|
|
* get_vocab() everywhere, and (b) having to use the long method call.
|
|
*/
|
|
function get_vocab(string $tag, ...$values) : string
|
|
{
|
|
return Language::getInstance()->getVocab($tag, Language::getInstance()->getWebLang(), ...$values);
|
|
}
|
|
|
|
|
|
/**
|
|
* A wrapper for Language::getVocab() to avoid (a) having to replace
|
|
* get_vocab() everywhere, and (b) having to use the long method call.
|
|
*/
|
|
function get_mail_vocab(string $tag, ...$values) : string
|
|
{
|
|
return Language::getInstance()->getVocab($tag, Language::getInstance()->getMailLang(), ...$values);
|
|
}
|
|
|
|
|
|
/**
|
|
* A wrapper for get_vocab() that escapes the returned string for safe use in JavaScript.
|
|
*/
|
|
function get_js_vocab(string $tag, ...$values) : string
|
|
{
|
|
return escape_js(get_vocab($tag, ...$values));
|
|
}
|
|
|
|
|
|
/**
|
|
* Get a localised booking type name.
|
|
*/
|
|
function get_type_vocab(string $type) : string
|
|
{
|
|
return get_vocab("type.$type");
|
|
}
|
|
|
|
|
|
/**
|
|
* Get the localised field name for a user-defined table column.
|
|
*
|
|
* Looks for a tag of the format tablename.columnname (where tablename is
|
|
* stripped of the table prefix) and if it can't find a string for that tag will
|
|
* return the column name
|
|
*/
|
|
function get_loc_field_name(string $table, string $name) : string
|
|
{
|
|
$tag = get_table_short_name($table) . ".$name";
|
|
$result = get_vocab($tag);
|
|
// If there's a string in the vocab array for $tag use that,
|
|
// otherwise just use the fieldname
|
|
return ($result === $tag) ? $name : $result;
|
|
}
|
|
|
|
|
|
/**
|
|
* Generates the Byte Order Mark (BOM) for a specific character encoding.
|
|
*
|
|
* @param string $charset The character encoding for which the BOM is required. Supported values are 'utf-8' and 'utf-16'.
|
|
* @return string The generated BOM string for the specified character encoding.
|
|
*/
|
|
function get_bom(string $charset) : string
|
|
{
|
|
switch(mb_strtolower($charset))
|
|
{
|
|
case 'utf-8':
|
|
return pack('CCC', 0xEF, 0xBB, 0xBF);
|
|
break;
|
|
case 'utf-16':
|
|
return pack('S', 0xFEFF);
|
|
break;
|
|
default:
|
|
throw new InvalidArgumentException("Unsupported character encoding: $charset");
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Removes extra whitespace from the given string by replacing multiple
|
|
* consecutive whitespace characters with a single space.
|
|
*
|
|
* Useful for sanitising user input before it is inserted into the database, eg when the
|
|
* user has accidentally typed "Joe Smith" instead of "Joe Smith"
|
|
*/
|
|
function remove_extra_whitespace(string $string) : string
|
|
{
|
|
return preg_replace('/\s+/', ' ', $string);
|
|
}
|
|
|
|
|
|
/**
|
|
* Constrains an integer value within the range defined by the specified number of bytes.
|
|
*
|
|
* Useful for making sure that integers will fit within the range for a database type.
|
|
*
|
|
* @param int $n The integer value to be constrained.
|
|
* @param int<4,4> $bytes The number of bytes defining the range. Currently, only 4 bytes are supported.
|
|
* @param bool $unsigned Flag indicating whether the range should be treated as unsigned. Defaults to false.
|
|
* @return int The constrained integer value within the valid range.
|
|
*/
|
|
function constrain_int(int $n, int $bytes, bool $unsigned=false) : int
|
|
{
|
|
if (!$unsigned && ($bytes == 4))
|
|
{
|
|
$max_limit = 0x7fffffff;
|
|
$min_limit = -$max_limit;
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidArgumentException("Other combinations not yet supported");
|
|
}
|
|
|
|
return max(min($n, $max_limit), $min_limit);
|
|
}
|
|
|
|
|
|
/**
|
|
* Performs a case-insensitive check if a value exists in an array.
|
|
*
|
|
* @param mixed $needle The searched value.
|
|
* @param array $haystack The array to search in.
|
|
* @param bool $strict [optional] If true, also checks the type of the needle in the haystack.
|
|
* @return bool Returns true if the needle is found in the haystack, false otherwise.
|
|
*/
|
|
function in_arrayi($needle, array $haystack, bool $strict=false) : bool
|
|
{
|
|
return in_array(
|
|
mb_strtolower($needle),
|
|
array_map('mb_strtolower', $haystack),
|
|
$strict
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* Checks if a given string ends with any of the strings in the provided array.
|
|
*
|
|
* @param string $haystack The string to search in.
|
|
* @param array $needles An array of strings to check as endings for the haystack.
|
|
* @return bool Returns true if the haystack ends with any of the strings in needles, otherwise false.
|
|
*/
|
|
function str_ends_with_array(string $haystack, array $needles) : bool
|
|
{
|
|
foreach ($needles as $needle)
|
|
{
|
|
if (str_ends_with($haystack, $needle))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
|
|
/**
|
|
* Generates a random token as a hexadecimal string.
|
|
*
|
|
* @param int $length The desired length of the random token.
|
|
* This length represents the number of bytes to generate before converting to hexadecimal,
|
|
* so the resulting string will be twice as long.
|
|
*
|
|
* @return string A random token as a hexadecimal string. The method uses multiple strategies
|
|
* for randomness, falling back to less secure options if necessary.
|
|
*/
|
|
function generate_token(int $length) : string
|
|
{
|
|
if (function_exists('random_bytes'))
|
|
{
|
|
try
|
|
{
|
|
return bin2hex(random_bytes($length)); // PHP 7 and above
|
|
}
|
|
catch (\Exception $e)
|
|
{
|
|
// random_bytes() will throw an Exception if an appropriate source of randomness cannot be found.
|
|
// If that happens, just fall through and use one of the other methods.
|
|
}
|
|
}
|
|
|
|
if (function_exists('openssl_random_pseudo_bytes'))
|
|
{
|
|
return bin2hex(openssl_random_pseudo_bytes($length));
|
|
}
|
|
|
|
return md5(uniqid((string)rand(), true));
|
|
}
|
|
|
|
|
|
// Returns a name in the format last_name first_name for sorting
|
|
function get_sortable_name(?string $name) : ?string
|
|
{
|
|
global $sort_users_by_last_name;
|
|
|
|
if (!isset($name))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (empty($sort_users_by_last_name))
|
|
{
|
|
return $name;
|
|
}
|
|
|
|
// Look for anything in brackets (ordinary or square) at the
|
|
// end of the name and treat it as a suffix. For example if
|
|
// the display name is "Joe Bloggs (visitor)" then the last name
|
|
// is really "Bloggs".
|
|
$pattern = '/\s*\(.*\)$|[.*]$/';
|
|
if (preg_match($pattern, $name, $matches))
|
|
{
|
|
$suffix = $matches[0];
|
|
$name = preg_replace($pattern, '', $name);
|
|
}
|
|
|
|
|
|
$tokens = explode(' ', $name);
|
|
|
|
// Get rid of other whitespace (eg tabs)
|
|
$tokens = array_map('trim', $tokens);
|
|
|
|
// Get the last name
|
|
$result = array_pop($tokens);
|
|
|
|
// Add back in the first names
|
|
while (null !== ($token = array_shift($tokens)))
|
|
{
|
|
if ($token !== '') // weeds out multiple spaces in a name
|
|
{
|
|
$result .= ' ' . $token;
|
|
}
|
|
}
|
|
|
|
// Add back in any suffix
|
|
if (isset($suffix))
|
|
{
|
|
$result .= $suffix;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
function compare_display_names(string $name1, string $name2) : int
|
|
{
|
|
$collator = new \Collator(Language::getInstance()->getWebLocale());
|
|
$collator->setStrength(\Collator::SECONDARY); // Case-insensitive, but accent-sensitive
|
|
return $collator->compare(get_sortable_name($name1), get_sortable_name($name2));
|
|
}
|
|
|
|
|
|
/**
|
|
* Compares two strings using a locale-aware, case-insensitive comparison.
|
|
* This function determines the relative order of two strings based on
|
|
* the current locale settings.
|
|
*
|
|
* @return int<-1,1> Returns 0 if the usernames are equal, -1 if $username1
|
|
* is less than $username2, and 1 if $username1 is greater than $username2.
|
|
*/
|
|
function compare_usernames(string $username1, string $username2) : int
|
|
{
|
|
$collator = new \Collator(Language::getInstance()->getWebLocale());
|
|
$collator->setStrength(\Collator::SECONDARY); // Case-insensitive, but accent-sensitive
|
|
return $collator->compare($username1, $username2);
|
|
}
|
|
|
|
|
|
// Tests whether the request has come via an Ajax call or not.
|
|
function is_ajax() : bool
|
|
{
|
|
global $server;
|
|
|
|
return (isset($server['HTTP_X_REQUESTED_WITH']) &&
|
|
(mb_strtolower($server['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest'));
|
|
}
|
|
|
|
|
|
function get_type_options(bool $include_admin_types) : array
|
|
{
|
|
global $booking_types, $auth;
|
|
|
|
$result = array();
|
|
|
|
if (isset($booking_types))
|
|
{
|
|
foreach ($booking_types as $key)
|
|
{
|
|
if (!$include_admin_types &&
|
|
isset($auth['admin_only_types']) &&
|
|
in_array($key, $auth['admin_only_types']))
|
|
{
|
|
continue;
|
|
}
|
|
$result[$key] = get_type_vocab($key);
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
// Converts an array of types into a string
|
|
function get_type_names(?array $types, string $glue=', ') : string
|
|
{
|
|
if (empty($types))
|
|
{
|
|
return '';
|
|
}
|
|
|
|
$names = array();
|
|
|
|
foreach($types as $type)
|
|
{
|
|
$names[] = get_type_vocab($type);
|
|
}
|
|
|
|
return implode($glue, $names);
|
|
}
|
|
|
|
|
|
// Returns the maximum length, in characters, of a column, or NULL if the maximum
|
|
// length is not known. Should only be called after any database upgrades have been
|
|
// performed as the results are stored in a static variable and so the maximum lengths
|
|
// may change after an upgrade. $column is of the form, <table_short_name.column_name>,
|
|
// for example, 'area.area_name'.
|
|
function maxlength(string $column) : ?int
|
|
{
|
|
static $maxlengths = array();
|
|
|
|
list($table_short_name, $column_name) = explode('.', $column);
|
|
|
|
if (!isset($maxlengths[$table_short_name]))
|
|
{
|
|
$maxlengths[$table_short_name] = array();
|
|
|
|
// Check to see if the table exists, as it won't yet if it's about
|
|
// to be created as part of an upgrade
|
|
if (db()->table_exists(_tbl($table_short_name)))
|
|
{
|
|
// Find the maximum length of the CHAR and VARCHAR fields (we won't
|
|
// worry about TEXT fields)
|
|
$field_info = db()->field_info(_tbl($table_short_name));
|
|
|
|
foreach ($field_info as $field)
|
|
{
|
|
if (($field['nature'] == 'character') &&
|
|
isset($field['length']) &&
|
|
($field['length'] < 256))
|
|
{
|
|
$maxlengths[$table_short_name][$field['name']] = (int) $field['length'];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return (isset($maxlengths[$table_short_name][$column_name])) ? $maxlengths[$table_short_name][$column_name] : null;
|
|
}
|
|
|
|
|
|
// Truncate any fields that have a maximum length as a precaution.
|
|
// Although the MAXLENGTH attribute is used in the <input> tag, this can
|
|
// sometimes be ignored by the browser, for example by Firefox when
|
|
// autocompletion is used. The user could also edit the HTML and remove
|
|
// the MAXLENGTH attribute. Another problem is that the <datalist> tag
|
|
// does not accept a maxlength attribute. Passing an oversize string to some
|
|
// databases (eg some versions of PostgreSQL) results in an SQL error,
|
|
// rather than silent truncation of the string.
|
|
//
|
|
// We truncate to a maximum number of UTF8 characters rather than bytes.
|
|
// This is OK in current versions of MySQL and PostgreSQL, though in earlier
|
|
// versions of MySQL (I haven't checked PostgreSQL) this could cause problems
|
|
// as a VARCHAR(n) was n bytes long rather than n characters.
|
|
function truncate(string $value, string $column) : string
|
|
{
|
|
if (null !== ($maxlength = maxlength($column)))
|
|
{
|
|
return mb_substr($value, 0, $maxlength);
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
|
|
function get_microtime()
|
|
{
|
|
if (function_exists('microtime'))
|
|
{
|
|
list($usec, $sec) = explode(" ", microtime());
|
|
return ((float)$usec + (float)$sec);
|
|
}
|
|
else
|
|
{
|
|
return time();
|
|
}
|
|
}
|
|
|
|
|
|
function function_disabled(string $name) : bool
|
|
{
|
|
$disabled = preg_split('/\s*,\s*/', ini_get('disable_functions'));
|
|
return in_array($name, $disabled);
|
|
}
|
|
|
|
|
|
function function_available(string $name) : bool
|
|
{
|
|
return function_exists($name) && !function_disabled($name);
|
|
}
|
|
|
|
|
|
// Set the default timezone. If $tz is not set, then the default MRBS
|
|
// timezone from the config file is used.
|
|
function mrbs_default_timezone_set(?string $tz=null) : void
|
|
{
|
|
global $area_defaults;
|
|
|
|
if (!isset($tz))
|
|
{
|
|
if (isset($area_defaults['timezone']))
|
|
{
|
|
$tz = $area_defaults['timezone'];
|
|
}
|
|
else
|
|
{
|
|
// This should have been picked up before now, but just in case ...
|
|
|
|
// We don't just use a default default timezone such as UTC because then
|
|
// people would start running into DST problems with their bookings.
|
|
$message = 'MRBS configuration error: $timezone has not been set.';
|
|
// Use die() rather than Errors::fatalError() because unless we have set the timezone
|
|
// PHP starts complaining bitterly if we try and do anything remotely complicated.
|
|
die($message);
|
|
}
|
|
}
|
|
|
|
if (!date_default_timezone_set($tz))
|
|
{
|
|
$message = "MRBS configuration error: invalid timezone '$tz'";
|
|
die($message); // See comment above about use of die()
|
|
}
|
|
|
|
}
|
|
|
|
|
|
// Get the default timezone.
|
|
// The MRBS wrapper round date_default_timezone_get() is redundant now. It used to
|
|
// cater for PHP servers that don't have date_default_timezone_get(), but now that
|
|
// the minimum PHP version is >= 5.1.0, there are none.
|
|
function mrbs_default_timezone_get() : string
|
|
{
|
|
return date_default_timezone_get();
|
|
}
|
|
|
|
|
|
function format_compound_name(string $username, string $display_name) : string
|
|
{
|
|
return ($username === $display_name) ? $username : get_vocab('compound_name', $username, $display_name);
|
|
}
|
|
|
|
|
|
// Gets the username and display name, if different. Useful in places such as search and
|
|
// report results, where you have to search by username - because that's what's stored in
|
|
// the entry - but you are interested in the display name.
|
|
function get_compound_name(?string $username) : string
|
|
{
|
|
if (isset($username))
|
|
{
|
|
$display_name = auth()->getDisplayName($username);
|
|
return (isset($display_name) && ($display_name !== '')) ? format_compound_name($username, $display_name) : $username;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
|
|
function get_cookie_path()
|
|
{
|
|
global $cookie_path_override, $server;
|
|
|
|
if (isset($cookie_path_override))
|
|
{
|
|
$cookie_path = $cookie_path_override;
|
|
}
|
|
else
|
|
{
|
|
// REQUEST_URI isn't set on all PHP systems, so fall back to PHP_SELF
|
|
$cookie_path = $server['REQUEST_URI'] ?? $server['PHP_SELF'];
|
|
// Strip off everything after the last '/'
|
|
$cookie_path = preg_replace('/[^\/]*$/', '', $cookie_path);
|
|
// Some cases have been observed of the cookie path being set to, for example,
|
|
// '/mrbs/ajax/' or '/mrbs/js/'. It's not clear how this can happen (possibly
|
|
// some kind of timing issue when an Ajax request arrives before the main page??)
|
|
// but in case it does, strip off any trailing 'ajax/' or 'js/' string.
|
|
$cookie_path = preg_replace('/(ajax|js)\/$/', '', $cookie_path);
|
|
}
|
|
|
|
return $cookie_path;
|
|
}
|
|
|
|
|
|
/**
|
|
* Gets the url path from either `$_SERVER['PHP_SELF']` or `$_SERVER['REQUEST_URI']`, whichever
|
|
* happens to be non-empty. (Some systems will not have one or the other variable set).
|
|
*
|
|
* @return string The url path, or the empty string if none can be found.
|
|
*/
|
|
function url_path() : string
|
|
{
|
|
global $server;
|
|
|
|
if (isset($server['PHP_SELF']) && ($server['PHP_SELF'] !== ''))
|
|
{
|
|
$result = $server['PHP_SELF'];
|
|
}
|
|
elseif (isset($server['REQUEST_URI']) && ($server['REQUEST_URI'] !== ''))
|
|
{
|
|
// The REQUEST_URI includes the query string, so we need to strip it off
|
|
$result = parse_url($server['REQUEST_URI'], PHP_URL_PATH);
|
|
if ($result === false)
|
|
{
|
|
trigger_error('Cannot parse $_SERVER["REQUEST_URI"] of "' . $server['REQUEST_URI'] . '"', E_USER_NOTICE);
|
|
$result = '';
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!is_cli())
|
|
{
|
|
trigger_error('$_SERVER["PHP_SELF"] and $_SERVER["REQUEST_URI"] are both empty. Check your server configuration.', E_USER_NOTICE);
|
|
}
|
|
$result = '';
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the base URL for MRBS. If the config variable $url_base is set, then that
|
|
* overrides the automatic calculation. A '/' will be appended to the end of the string,
|
|
* unless it is empty or already ends in a '/'.
|
|
*/
|
|
function url_base() : string
|
|
{
|
|
global $url_base, $server;
|
|
|
|
// Use the $url_base if there is one and it is well-formed.
|
|
if (isset($url_base))
|
|
{
|
|
$parsed_url = parse_url($url_base);
|
|
if ($parsed_url === false)
|
|
{
|
|
trigger_error('Cannot parse $url_base of "' . $url_base . '"', E_USER_WARNING);
|
|
}
|
|
}
|
|
|
|
// Otherwise construct it from the $_SERVER variables if we can.
|
|
if (empty($parsed_url) && isset($server['HTTP_HOST']))
|
|
{
|
|
$parsed_url = [
|
|
'host' => $server['HTTP_HOST'],
|
|
'path' => dirname(url_path())
|
|
];
|
|
}
|
|
|
|
// If we haven't got any URL parts, then return an empty string.
|
|
if (empty($parsed_url))
|
|
{
|
|
$result = '';
|
|
}
|
|
// Otherwise tidy up the URL parts and rebuild it.
|
|
else
|
|
{
|
|
// Add a scheme if we haven't got one
|
|
if (!isset($parsed_url['scheme']))
|
|
{
|
|
$parsed_url['scheme'] = is_https() ? 'https' : 'http';
|
|
}
|
|
// Remove any query and fragment (which shouldn't be there anyway, but just in case)
|
|
unset($parsed_url['query'], $parsed_url['fragment']);
|
|
// Rebuild the URL
|
|
$result = build_url($parsed_url);
|
|
// Add a trailing '/' if there isn't one already.
|
|
if (!str_ends_with($result, '/'))
|
|
{
|
|
$result .= '/';
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
/**
|
|
* Check that the URL is only a local URL. This is used to prevent open redirects.
|
|
*
|
|
* @return string|null The URL if it is a local URL, otherwise null.
|
|
*/
|
|
function url_validate_local(string $url) : ?string
|
|
{
|
|
// Check that it's a well-formed URL
|
|
if (false === ($parsed_url = parse_url($url)))
|
|
{
|
|
trigger_error('Cannot parse URL "' . $url . '"', E_USER_WARNING);
|
|
return null;
|
|
}
|
|
|
|
$base_url = url_base();
|
|
if (false === ($parsed_base = parse_url($base_url)))
|
|
{
|
|
trigger_error('Cannot parse $base_url of "' . $base_url . '"', E_USER_WARNING);
|
|
return null;
|
|
}
|
|
|
|
// If one of the components before the path is set and doesn't match the base URL's component, then this is not a local URL.
|
|
foreach (['scheme', 'user', 'pass', 'host', 'port'] as $component)
|
|
{
|
|
if (isset($parsed_url[$component]) && ($parsed_url[$component] !== '') && ($parsed_url[$component] !== $parsed_base[$component]))
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
|
|
|
|
function is_https() : bool
|
|
{
|
|
global $server;
|
|
|
|
if (!empty($server['HTTP_X_FORWARDED_PROTO']))
|
|
{
|
|
return ($server['HTTP_X_FORWARDED_PROTO'] === 'https');
|
|
}
|
|
elseif (!empty($server['HTTP_X_FORWARDED_PROTOCOL']))
|
|
{
|
|
return ($server['HTTP_X_FORWARDED_PROTOCOL'] === 'https');
|
|
}
|
|
elseif (!empty($server['HTTP_X_FORWARDED_SSL']))
|
|
{
|
|
return (strcasecmp($server['HTTP_X_FORWARDED_SSL'], 'on') === 0);
|
|
}
|
|
|
|
return (isset($server['HTTPS']) &&
|
|
!empty($server['HTTPS']) &&
|
|
(strcasecmp($server['HTTPS'], 'off') !== 0));
|
|
}
|
|
|
|
|
|
// Returns the current page. If the page ends in $suffix this will be cut off.
|
|
// The existing query string is optionally added.
|
|
function this_page(bool $with_query_string=false, string $suffix='') : string
|
|
{
|
|
global $server;
|
|
|
|
if ((!isset($server['SCRIPT_NAME']) || ($server['SCRIPT_NAME'] === '') && !is_cli()))
|
|
{
|
|
trigger_error('$_SERVER["SCRIPT_NAME"] is empty. Check your server configuration.', E_USER_NOTICE);
|
|
}
|
|
|
|
// We use basename() because the full name causes problems when reverse proxies are being used.
|
|
$result = basename($server['SCRIPT_NAME'], $suffix);
|
|
|
|
// Get the query string if required
|
|
if ($with_query_string &&
|
|
isset($server['QUERY_STRING']) &&
|
|
($server['QUERY_STRING'] !== ''))
|
|
{
|
|
$query_string = $server['QUERY_STRING'];
|
|
}
|
|
else
|
|
{
|
|
$query_string = '';
|
|
}
|
|
|
|
parse_str($query_string, $query_string_parts);
|
|
|
|
// Add the query string back in
|
|
if (!empty($query_string_parts))
|
|
{
|
|
$result .= '?' . http_build_query($query_string_parts, '', '&');
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
/**
|
|
* Format a timestamp in RFC 7231 format, for HTTP headers.
|
|
* E.g. Wed, 28 Jul 2010 12:43:58 GMT
|
|
*/
|
|
function rfc7231_date(int $timestamp) : string
|
|
{
|
|
// We cannot use the DATE_RFC7231 constant because it is deprecated in PHP 8.5.
|
|
return gmdate("D, d M Y H:i:s \\G\\M\\T", $timestamp);
|
|
}
|
|
|
|
|
|
/**
|
|
* Rebuild a url that has been decomposed into parts by parse_url().
|
|
*
|
|
* @param array<string,string> $parts
|
|
*/
|
|
function build_url(array $parts) : string
|
|
{
|
|
// See https://stackoverflow.com/questions/4354904/php-parse-url-reverse-parsed-url#answers
|
|
return (isset($parts['scheme']) ? "{$parts['scheme']}:" : '') .
|
|
((isset($parts['user']) || isset($parts['host'])) ? '//' : '') .
|
|
(isset($parts['user']) ? "{$parts['user']}" : '') .
|
|
(isset($parts['pass']) ? ":{$parts['pass']}" : '') .
|
|
(isset($parts['user']) ? '@' : '') .
|
|
(isset($parts['host']) ? "{$parts['host']}" : '') .
|
|
(isset($parts['port']) ? ":{$parts['port']}" : '') .
|
|
(isset($parts['path']) ? "{$parts['path']}" : '') .
|
|
(isset($parts['query']) ? "?{$parts['query']}" : '') .
|
|
(isset($parts['fragment']) ? "#{$parts['fragment']}" : '');
|
|
}
|
|
|
|
|
|
/**
|
|
* A wrapper for sending a Location header. This function will urlencode and add
|
|
* the site parameter as necessary.
|
|
*
|
|
* @param string $location The URL to redirect to. This must be a local URL.
|
|
* @return never
|
|
*/
|
|
function location_header(string $location) : void
|
|
{
|
|
global $debug;
|
|
|
|
if ($debug && (version_compare(MRBS_MIN_PHP_VERSION, '8.1') >= 0))
|
|
{
|
|
trigger_error("The return type can be changed to 'never'.", E_USER_NOTICE);
|
|
}
|
|
|
|
// Check that the URL is a local URL. If it isn't, then we don't want to redirect to it.
|
|
{
|
|
if (null === ($location = url_validate_local($location)))
|
|
{
|
|
trigger_error("Cannot redirect to URL '$location' because it is not a local URL", E_USER_WARNING);
|
|
$location = 'index.php';
|
|
}
|
|
}
|
|
|
|
// Add the site parameter if necessary
|
|
$location = multisite($location);
|
|
|
|
// Encode the query string if there is one
|
|
$parts = parse_url($location);
|
|
|
|
if (isset($parts['query']))
|
|
{
|
|
parse_str($parts['query'], $result);
|
|
$parts['query'] = http_build_query($result, '', '&');
|
|
}
|
|
|
|
header('Location: ' . build_url($parts));
|
|
exit;
|
|
}
|
|
|
|
|
|
/**
|
|
* Add a query parameter in a URL. If the parameter already exists, it will be overwritten.
|
|
* This function does not currently handle array parameters, eg 'room[]'.
|
|
*
|
|
* @param string|int $value
|
|
*/
|
|
function add_query_parameter(string $location, string $name, $value) : string
|
|
{
|
|
if (str_ends_with($name, '[]'))
|
|
{
|
|
// TODO: handle arrays
|
|
throw new \Exception("Cannot yet handle array parameters");
|
|
}
|
|
|
|
$parts = parse_url($location);
|
|
|
|
if ($parts === false)
|
|
{
|
|
throw new \Exception("Could not parse the URL '$location'");
|
|
}
|
|
|
|
if (!isset($parts['query']))
|
|
{
|
|
$parts['query'] = '';
|
|
}
|
|
|
|
parse_str($parts['query'], $result);
|
|
$result[$name] = $value;
|
|
$parts['query'] = http_build_query($result, '', '&');
|
|
return build_url($parts);
|
|
}
|
|
|
|
|
|
/**
|
|
* Remove a query parameter from a URL. If the parameter doesn't exist, nothing happens.
|
|
* This function does not currently handle array parameters, eg 'room[]'.
|
|
*/
|
|
function remove_query_parameter(string $location, string $name) : string
|
|
{
|
|
if (str_ends_with($name, '[]'))
|
|
{
|
|
// TODO: handle arrays
|
|
throw new \Exception("Cannot yet handle array parameters");
|
|
}
|
|
|
|
$parts = parse_url($location);
|
|
|
|
if ($parts === false)
|
|
{
|
|
throw new \Exception("Could not parse the URL '$location'");
|
|
}
|
|
|
|
if (!isset($parts['query']))
|
|
{
|
|
$parts['query'] = '';
|
|
}
|
|
|
|
parse_str($parts['query'], $result);
|
|
unset($result[$name]);
|
|
$parts['query'] = http_build_query($result, '', '&');
|
|
return build_url($parts);
|
|
}
|
|
|
|
|
|
// Adds the site parameter in the query string if necessary
|
|
function multisite(string $location) : string
|
|
{
|
|
global $multisite, $site;
|
|
|
|
// Add the site, if there is one, to the query string
|
|
if ($multisite && isset($site) && ($site !== ''))
|
|
{
|
|
return add_query_parameter($location, 'site', $site);
|
|
}
|
|
|
|
return $location;
|
|
}
|
|
|
|
|
|
// Add a version parameter to a filename in order to force the use of the new
|
|
// file instead of the cached version
|
|
function add_version(string $filename) : string
|
|
{
|
|
$url_path = parse_url($filename, PHP_URL_PATH);
|
|
|
|
if (is_readable($url_path))
|
|
{
|
|
$mtime = filemtime($url_path);
|
|
if ($mtime !== false)
|
|
{
|
|
return add_query_parameter($filename, 'v', $mtime);
|
|
}
|
|
}
|
|
|
|
return $filename;
|
|
}
|
|
|
|
|
|
// A little helper function to send an "Expires" header. Just one
|
|
// parameter, the number of seconds in the future to set the expiry.
|
|
// If $seconds is <= 0, then caching is disabled.
|
|
function expires_header(int $seconds) : void
|
|
{
|
|
global $debug;
|
|
|
|
if (!$debug && ($seconds > 0))
|
|
{
|
|
// We also send a couple of extra headers as the "Expires" header alone
|
|
// does not always result in caching.
|
|
header("Expires: " . rfc7231_date(time() + $seconds));
|
|
header("Pragma: cache");
|
|
header("Cache-Control: max-age=$seconds");
|
|
}
|
|
else
|
|
{
|
|
// Make sure that caching is disabled. Setting the "Expires" header
|
|
// alone doesn't always turn off caching.
|
|
header("Pragma: no-cache"); // HTTP 1.0
|
|
header("Expires: Mon, 26 Jul 1997 05:00:00 GMT"); // Date in the past
|
|
}
|
|
}
|
|
|
|
|
|
// Outputs the HTTP headers, passed in the array $headers, followed by
|
|
// a set of headers to set the cache expiry date. If $expiry_seconds <= 0
|
|
// then caching is disabled.
|
|
function http_headers(array $headers, int $expiry_seconds=0) : void
|
|
{
|
|
foreach ($headers as $header)
|
|
{
|
|
header($header);
|
|
}
|
|
|
|
expires_header($expiry_seconds);
|
|
}
|
|
|
|
|
|
// Prints a very simple header. This may be necessary on occasions, such as
|
|
// during a database upgrade, when some of the features that the normal
|
|
// header uses are not yet available.
|
|
function print_simple_header() : void
|
|
{
|
|
print_header(null, true);
|
|
}
|
|
|
|
|
|
// Print the page header
|
|
// $context is an associative array indexed by 'view', 'view_all', 'year', 'month', 'day', 'area' and 'room',
|
|
// any of which can be NULL.
|
|
// When $omit_login is true the Login link is omitted.
|
|
function print_header(?array $context=null, bool $simple=false, bool $omit_login=false) : void
|
|
{
|
|
global $theme;
|
|
|
|
static $done_header = false;
|
|
|
|
if ($done_header)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Load the print_theme_header function appropriate to the theme. If there
|
|
// isn't one then fall back to the default header.
|
|
if (is_readable("Themes/$theme/header.inc"))
|
|
{
|
|
include_once "Themes/$theme/header.inc";
|
|
}
|
|
if (!function_exists(__NAMESPACE__ . "\\print_theme_header"))
|
|
{
|
|
require_once "Themes/default/header.inc";
|
|
}
|
|
|
|
// Now go and do it
|
|
print_theme_header($context, $simple, $omit_login);
|
|
|
|
$done_header = true;
|
|
}
|
|
|
|
|
|
// Print the standard footer, currently very simple. Pass $and_exit as
|
|
// TRUE to exit afterwards
|
|
function print_footer(bool $and_exit=false) : void
|
|
{
|
|
global $theme;
|
|
|
|
// Load the print_theme_footer function appropriate to the theme. If there
|
|
// isn't one then fall back to the default footer.
|
|
if (is_readable("Themes/$theme/footer.inc"))
|
|
{
|
|
include_once "Themes/$theme/footer.inc";
|
|
}
|
|
if (!function_exists(__NAMESPACE__ . "\\print_theme_footer"))
|
|
{
|
|
require_once "Themes/default/footer.inc";
|
|
}
|
|
|
|
print_theme_footer();
|
|
|
|
if ($and_exit)
|
|
{
|
|
exit(0);
|
|
}
|
|
}
|
|
|
|
|
|
// Converts a duration of $dur seconds into a duration of
|
|
// $dur $units
|
|
// $max_unit can be set to 'seconds', 'minutes', 'hours', etc. and
|
|
// is used to specify a maximum size of unit to use. For example if
|
|
// it is set to 'minutes', 3600 seconds will give 60 minutes rather
|
|
// than 1 hour.
|
|
function toTimeString(int &$dur, &$units, bool $translate=true, ?string $max_unit=null)
|
|
{
|
|
if (!isset($max_unit))
|
|
{
|
|
$max_unit = 'years';
|
|
}
|
|
|
|
if (($max_unit != 'seconds') && (abs($dur) >= 60) && ($dur % 60 == 0))
|
|
{
|
|
$dur /= 60;
|
|
|
|
if (($max_unit != 'minutes') && (abs($dur) >= 60) && ($dur % 60 == 0))
|
|
{
|
|
$dur /= 60;
|
|
|
|
if(($max_unit != 'hours') && ((abs($dur) >= 24) && ($dur % 24 == 0)))
|
|
{
|
|
$dur /= 24;
|
|
|
|
if(($max_unit != 'days') && ((abs($dur) >= DAYS_PER_WEEK) && ($dur % DAYS_PER_WEEK == 0)))
|
|
{
|
|
$dur /= DAYS_PER_WEEK;
|
|
|
|
if (($max_unit != 'weeks') && ((abs($dur) >= 52) && ($dur % 52 == 0)))
|
|
{
|
|
$dur /= 52;
|
|
$units = 'years';
|
|
}
|
|
else
|
|
{
|
|
$units = 'weeks';
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$units = 'days';
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$units = 'hours';
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$units = 'minutes';
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$units = 'seconds';
|
|
}
|
|
|
|
// Limit any floating point values to three decimal places
|
|
if (is_float($dur))
|
|
{
|
|
$dur = sprintf('%.3f', $dur);
|
|
$dur = rtrim($dur, '0'); // removes trailing zeros
|
|
}
|
|
|
|
// Translate into local language if required
|
|
if ($translate)
|
|
{
|
|
$units = get_vocab($units);
|
|
}
|
|
}
|
|
|
|
|
|
// Converts a time period of $units into seconds, when it is originally
|
|
// expressed in $dur_units. (Almost the inverse of toTimeString(),
|
|
// but note that toTimeString() can do language translation)
|
|
function fromTimeString(&$units, $dur_units)
|
|
{
|
|
if (!isset($units) || !isset($dur_units))
|
|
{
|
|
return;
|
|
}
|
|
|
|
switch($dur_units)
|
|
{
|
|
/** @noinspection PhpMissingBreakStatementInspection */
|
|
case 'years':
|
|
$units *= 52;
|
|
// Fall through
|
|
/** @noinspection PhpMissingBreakStatementInspection */
|
|
case 'weeks':
|
|
$units *= DAYS_PER_WEEK;
|
|
// Fall through
|
|
/** @noinspection PhpMissingBreakStatementInspection */
|
|
case 'days':
|
|
$units *= 24;
|
|
// Fall through
|
|
/** @noinspection PhpMissingBreakStatementInspection */
|
|
case 'hours':
|
|
$units *= 60;
|
|
// Fall through
|
|
case 'periods':
|
|
/** @noinspection PhpMissingBreakStatementInspection */
|
|
case 'minutes':
|
|
$units *= 60;
|
|
// Fall through
|
|
case 'seconds':
|
|
break;
|
|
default:
|
|
throw new \Exception("Unexpected value '$dur_units'");
|
|
}
|
|
$units = (int) $units;
|
|
}
|
|
|
|
|
|
// Gets the interval in periods for a booking with $start_time and $end_time
|
|
// Takes account of DST
|
|
function get_period_interval(int $start_time, int $end_time, int $area_id) : int
|
|
{
|
|
static $area_details = [];
|
|
|
|
if (!isset($area_details[$area_id]))
|
|
{
|
|
$area_details[$area_id] = get_area_details($area_id);
|
|
}
|
|
|
|
$periods_per_day = count(json_decode($area_details[$area_id]['periods'], true));
|
|
$date_time_zone = new DateTimeZone($area_details[$area_id]['timezone']);
|
|
|
|
// Create start and end dates and set both dates to noon so that we can compare them
|
|
// and get an integral number of days difference. Noon also happens to be when
|
|
// periods start, so will be useful in a moment.
|
|
$startDate = new DateTime();
|
|
$startDate
|
|
->setTimezone($date_time_zone)
|
|
->setTimestamp($start_time)
|
|
->modify('12:00');
|
|
|
|
$endDate = new DateTime();
|
|
$endDate
|
|
->setTimezone($date_time_zone)
|
|
->setTimestamp($end_time)
|
|
->modify('12:00');
|
|
|
|
// Calculate the difference in days
|
|
$interval = $startDate->diff($endDate);
|
|
$interval_days = intval($interval->format('%a'));
|
|
|
|
if ($interval_days === 0)
|
|
{
|
|
// If the interval starts and ends on the same day, then we just calculate the number
|
|
// of periods by calculating the number of minutes between the start and end times.
|
|
$result = ($end_time - $start_time)/60;
|
|
}
|
|
else
|
|
{
|
|
// Otherwise we calculate the number of periods on the first day
|
|
$startDate->add(new DateInterval('PT' . $periods_per_day . 'M'));
|
|
$result = get_period_interval($start_time, $startDate->getTimestamp(), $area_id);
|
|
// Add in the number of whole days worth of periods in between
|
|
$result += ($interval_days - 1) * $periods_per_day;
|
|
// And add in the number of periods on the last day
|
|
$result += get_period_interval($endDate->getTimestamp(), $end_time, $area_id);
|
|
}
|
|
|
|
return (int) $result;
|
|
}
|
|
|
|
|
|
function toPeriodString($start_period, &$dur, &$units, $translate=true)
|
|
{
|
|
global $periods;
|
|
|
|
$max_periods = count($periods);
|
|
$dur /= 60; // duration now in minutes
|
|
$days = $dur / MINUTES_PER_DAY;
|
|
$remainder = $dur % MINUTES_PER_DAY;
|
|
// strip out any gap between the end of the last period on one day
|
|
// and the beginning of the first on the next
|
|
if ($remainder > $max_periods)
|
|
{
|
|
$remainder += $max_periods - MINUTES_PER_DAY;
|
|
}
|
|
|
|
// We'll express the duration as an integer, in days if possible, otherwise periods
|
|
if (($remainder == 0) || (($start_period == 0) && ($remainder == $max_periods)))
|
|
{
|
|
$dur = (int) $days;
|
|
if ($remainder == $max_periods)
|
|
{
|
|
$dur++;
|
|
}
|
|
$units = $translate ? get_vocab("days") : "days";
|
|
}
|
|
else
|
|
{
|
|
$dur = (intval($days) * $max_periods) + $remainder;
|
|
$units = $translate ? get_vocab("periods") : "periods";
|
|
}
|
|
}
|
|
|
|
// Converts a period of $units starting at $start_period into seconds, when it is
|
|
// originally expressed in $dur_units (periods or days). (Almost the inverse of
|
|
// toPeriodString(), but note that toPeriodString() can do language translation)
|
|
function fromPeriodString($start_period, &$units, $dur_units)
|
|
{
|
|
global $periods;
|
|
|
|
if (!isset($units) || !isset($dur_units))
|
|
{
|
|
return;
|
|
}
|
|
|
|
// First get the duration in minutes
|
|
$max_periods = count($periods);
|
|
if ($dur_units == "periods")
|
|
{
|
|
$end_period = $start_period + $units;
|
|
if ($end_period > $max_periods)
|
|
{
|
|
$units = (MINUTES_PER_DAY * floor($end_period/$max_periods)) + ($end_period%$max_periods) - $start_period;
|
|
}
|
|
}
|
|
if ($dur_units == "days")
|
|
{
|
|
if ($start_period == 0)
|
|
{
|
|
$units = $max_periods + ($units-1)*MINUTES_PER_DAY;
|
|
}
|
|
else
|
|
{
|
|
$units = $units * MINUTES_PER_DAY;
|
|
}
|
|
}
|
|
|
|
// Then convert into seconds
|
|
$units = (int) $units;
|
|
$units = 60 * $units;
|
|
}
|
|
|
|
|
|
// Returns the BYDAY value for a given timestamp, eg 4SU for fourth Sunday in
|
|
// the month, or -1MO for the last Monday.
|
|
function date_byday(int $timestamp) : string
|
|
{
|
|
$dow = RFC5545::DAYS[date('w', $timestamp)];
|
|
$dom = date('j', $timestamp);
|
|
$ord = intval(($dom - 1)/DAYS_PER_WEEK) + 1;
|
|
if ($ord == 5)
|
|
{
|
|
$ord = -1;
|
|
}
|
|
return $ord . $dow;
|
|
}
|
|
|
|
|
|
// Returns TRUE if the time $hm1 is before $hm2
|
|
// $hm1 and $hm2 are associative arrays indexed by 'hours' and 'minutes'.
|
|
// The indices are chosen to allow the result of the PHP getdate() function
|
|
// to be passed as parameters
|
|
function hm_before(array $hm1, array $hm2) : bool
|
|
{
|
|
return ($hm1['hours'] < $hm2['hours']) ||
|
|
(($hm1['hours'] == $hm2['hours']) && ($hm1['minutes'] < $hm2['minutes']));
|
|
}
|
|
|
|
|
|
// Returns TRUE if the end of the last slot is on the day after the beginning
|
|
// of the first slot
|
|
function day_past_midnight() : bool
|
|
{
|
|
global $morningstarts, $morningstarts_minutes, $eveningends, $eveningends_minutes, $resolution;
|
|
|
|
$start_first_slot = (($morningstarts * 60) + $morningstarts_minutes) * 60;
|
|
$end_last_slot = ((($eveningends * 60) + $eveningends_minutes) * 60) + $resolution;
|
|
$end_last_slot = $end_last_slot % SECONDS_PER_DAY;
|
|
|
|
return ($end_last_slot <= $start_first_slot);
|
|
}
|
|
|
|
|
|
// Gets the UNIX timestamp for the start of the first slot on the given day
|
|
function get_start_first_slot(int $month, int $day, int $year) : int
|
|
{
|
|
global $morningstarts, $morningstarts_minutes, $enable_periods;
|
|
|
|
if ($enable_periods)
|
|
{
|
|
$result = mktime(12, 0, 0, $month, $day, $year);
|
|
}
|
|
else
|
|
{
|
|
$result = mktime($morningstarts, $morningstarts_minutes, 0,
|
|
$month, $day, $year);
|
|
}
|
|
|
|
if ($result === false)
|
|
{
|
|
throw new \Exception("Invalid arguments to mktime()");
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
// Gets the UNIX timestamp for the start of the last slot on the given day
|
|
function get_start_last_slot(int $month, int $day, int $year) : int
|
|
{
|
|
global $morningstarts, $morningstarts_minutes, $eveningends, $eveningends_minutes, $enable_periods, $periods;
|
|
|
|
if ($enable_periods)
|
|
{
|
|
$result = mktime(12, count($periods) -1, 0, $month, $day, $year);
|
|
}
|
|
else
|
|
{
|
|
// Work out if $evening_ends is really on the next day
|
|
if (hm_before(array('hours' => $eveningends, 'minutes' => $eveningends_minutes),
|
|
array('hours' => $morningstarts, 'minutes' => $morningstarts_minutes)))
|
|
{
|
|
$day++;
|
|
}
|
|
|
|
$result = mktime($eveningends, $eveningends_minutes, 0,
|
|
$month, $day, $year);
|
|
}
|
|
|
|
if ($result === false)
|
|
{
|
|
throw new \Exception("Invalid arguments to mktime()");
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
function get_end_last_slot(int $month, int $day, int $year) : int
|
|
{
|
|
global $resolution;
|
|
|
|
return intval(get_start_last_slot($month, $day, $year) + $resolution);
|
|
}
|
|
|
|
|
|
// Determines with a given timestamp is within a booking day, ie between the start of
|
|
// the first slot and end of the last slot. Returns a boolean.
|
|
function is_in_booking_day(int $t) : bool
|
|
{
|
|
global $morningstarts, $morningstarts_minutes,
|
|
$eveningends, $eveningends_minutes,
|
|
$resolution, $enable_periods;
|
|
|
|
if ($enable_periods)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
$start_day_secs = (($morningstarts * 60) + $morningstarts_minutes) * 60;
|
|
$end_day_secs = (((($eveningends * 60) + $eveningends_minutes) * 60) + $resolution) % SECONDS_PER_DAY;
|
|
|
|
$date = getdate($t);
|
|
$t_secs = (($date['hours'] * 60) + $date['minutes']) * 60;
|
|
|
|
if ($start_day_secs == $end_day_secs)
|
|
{
|
|
return true;
|
|
}
|
|
elseif (day_past_midnight())
|
|
{
|
|
return (($t_secs >= $start_day_secs) || ($t_secs <= $end_day_secs));
|
|
}
|
|
else
|
|
{
|
|
return (($t_secs >= $start_day_secs) && ($t_secs <= $end_day_secs));
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Takes a Unix timestamp and sets the time, while keeping the day the same.
|
|
*
|
|
* @param int $timestamp
|
|
* @param string $hhmm The time in the format HH:MM (24-hour format is used)
|
|
* @param string|null $tzid The timezone identifier. If NULL, the current timezone is used.
|
|
*/
|
|
function timestamp_set_time(int $timestamp, string $hhmm, ?string $tzid=null) : int
|
|
{
|
|
$date = new DateTime('now', (isset($tzid)) ? new DateTimeZone($tzid) : null);
|
|
$date->setTimestamp($timestamp);
|
|
$date->setHourMinute($hhmm);
|
|
return $date->getTimestamp();
|
|
}
|
|
|
|
|
|
// Force a timestamp $t to be on a booking day, either by moving it back to the end
|
|
// of the previous booking day, or forward to the start of the next.
|
|
function fit_to_booking_day(int $t, bool $back=true) : int
|
|
{
|
|
if (is_in_booking_day($t))
|
|
{
|
|
return $t;
|
|
}
|
|
|
|
$date = getdate($t);
|
|
// Remember that we need to cater for days that stretch beyond midnight.
|
|
if ($back)
|
|
{
|
|
$new_t = get_end_last_slot($date['mon'], $date['mday'], $date['year']);
|
|
if ($new_t > $t)
|
|
{
|
|
$new_t = get_end_last_slot($date['mon'], $date['mday'] - 1, $date['year']);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$new_t = get_start_first_slot($date['mon'], $date['mday'], $date['year']);
|
|
if ($new_t < $t)
|
|
{
|
|
$new_t = get_start_first_slot($date['mon'], $date['mday'] + 1, $date['year']);
|
|
}
|
|
}
|
|
|
|
return $new_t;
|
|
}
|
|
|
|
|
|
/**
|
|
* Get the duration of an interval given a start time and end time.
|
|
*
|
|
* Corrects for DST changes so that the duration is what the user would expect to see.
|
|
* For example, 12 noon to 12 noon crossing a DST boundary is 24 hours.
|
|
*
|
|
* @param int $start_time Unix timestamp
|
|
* @param int $end_time Unix timestamp
|
|
* @param bool $enable_periods Whether periods are being used
|
|
* @param bool $translate Whether to translate into the browser language
|
|
* @return array{duration: int, dur_units: string}
|
|
*/
|
|
function get_duration(int $start_time, int $end_time, bool $enable_periods, int $area_id, bool $translate=true) : array
|
|
{
|
|
$result = array();
|
|
|
|
$period_names = get_period_names();
|
|
|
|
if ($enable_periods)
|
|
{
|
|
$periods_per_day = count($period_names[$area_id]);
|
|
$n_periods = get_period_interval($start_time, $end_time, $area_id); // this handles DST
|
|
if (($n_periods % $periods_per_day) == 0)
|
|
{
|
|
$result['duration'] = intval($n_periods/$periods_per_day);
|
|
$result['dur_units'] = ($translate) ? get_vocab('days') : 'days';
|
|
}
|
|
else
|
|
{
|
|
$result['duration'] = $n_periods;
|
|
$result['dur_units'] = ($translate) ? get_vocab('periods') : 'periods';
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$result['duration'] = $end_time - $start_time;
|
|
// Need to make DST correct in opposite direction to entry creation
|
|
// so that user see what he expects to see
|
|
$result['duration'] -= cross_dst($start_time, $end_time);
|
|
toTimeString($result['duration'], $result['dur_units'], $translate);
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
|
|
// Escape a PHP variable for use in HTML
|
|
function escape_html($value)
|
|
{
|
|
switch ($type = gettype($value))
|
|
{
|
|
case 'double':
|
|
case 'integer':
|
|
return $value;
|
|
break;
|
|
case 'string':
|
|
return htmlspecialchars($value);
|
|
break;
|
|
default:
|
|
throw new TypeError("Type '$type' not supported as an argument.");
|
|
break;
|
|
}
|
|
}
|
|
|
|
|
|
// Escape a PHP string for use in JavaScript
|
|
//
|
|
// Based on a function contributed by kongaspar at gmail dot com at
|
|
// http://www.php.net/manual/function.addcslashes.php
|
|
function escape_js(string $str) : string
|
|
{
|
|
return addcslashes($str, "\\\'\"&\n\r<>/");
|
|
}
|
|
|
|
|
|
/**
|
|
* Get the default area id. The following order of preference is used to find an area:
|
|
* - the area that contains the default room (if it is set, visible, and enabled)
|
|
* - the first enabled area that contains an enabled, visible room
|
|
* - the first enabled area, regardless of the state of its rooms
|
|
*
|
|
* The area must be enabled for it to be considered.
|
|
*/
|
|
function get_default_area() : int
|
|
{
|
|
// This function could be changed to implement something like per-user defaults.
|
|
global $default_room;
|
|
|
|
// It's possible that this function is being called during an upgrade
|
|
// process before the disabled column existed.
|
|
$disabled_field_exists = db()->field_exists(_tbl('room'), 'disabled');
|
|
|
|
// If the $default_room is set and exists and is enabled and is visible to
|
|
// the current user, then return the corresponding area
|
|
if (isset($default_room) && is_visible($default_room))
|
|
{
|
|
$sql = "SELECT area_id
|
|
FROM " . _tbl('room') . " R, " . _tbl('area') . " A
|
|
WHERE R.id=:id
|
|
AND R.area_id = A.id";
|
|
|
|
if ($disabled_field_exists)
|
|
{
|
|
$sql .= " AND R.disabled = 0 AND A.disabled = 0";
|
|
}
|
|
|
|
$sql .= " LIMIT 1";
|
|
$area = db()->query1($sql, array(':id' => $default_room));
|
|
|
|
if ($area >= 0)
|
|
{
|
|
return (int) $area;
|
|
}
|
|
}
|
|
|
|
// Otherwise return the first enabled area in the database that has an
|
|
// enabled and visible room
|
|
$sql = "SELECT R.id, R.area_id
|
|
FROM " . _tbl('room') . " R, " . _tbl('area') . " A
|
|
WHERE R.area_id = A.id";
|
|
|
|
if ($disabled_field_exists)
|
|
{
|
|
$sql .= " AND R.disabled = 0 AND A.disabled = 0";
|
|
}
|
|
|
|
$order_by_column = (db()->field_exists(_tbl('area'), 'sort_key')) ? 'sort_key' : 'area_name';
|
|
$sql .= " ORDER BY A.$order_by_column";
|
|
|
|
$res = db()->query($sql);
|
|
|
|
while (false !== ($row = $res->next_row_keyed()))
|
|
{
|
|
if (is_visible($row['id']))
|
|
{
|
|
return (int) $row['area_id'];
|
|
}
|
|
}
|
|
|
|
// Otherwise return the first enabled area, regardless of the state of its rooms.
|
|
$sql = "SELECT id FROM " . _tbl('area');
|
|
if ($disabled_field_exists)
|
|
{
|
|
$sql .= " WHERE disabled = 0";
|
|
}
|
|
$sql .= " ORDER BY $order_by_column LIMIT 1";
|
|
$area = db()->query1($sql);
|
|
|
|
if ($area > 0)
|
|
{
|
|
return $area;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
// Return a default room given a valid area; used if no room is already known.
|
|
// If the area contains $default_room and $default_room is visible to the current
|
|
// user, then it returns $default_room. Otherwise it returns the first visible room
|
|
// in sort_key order in the database.
|
|
// This could be changed to implement something like per-user defaults.
|
|
function get_default_room(int $area) : int
|
|
{
|
|
global $default_room;
|
|
|
|
// It's possible that this function is being called during
|
|
// an upgrade process before the disabled columns existed.
|
|
$disabled_field_exists = db()->field_exists(_tbl('room'), 'disabled');
|
|
|
|
// Check to see whether this area contains $default_room
|
|
if (isset($default_room))
|
|
{
|
|
$sql = "SELECT id
|
|
FROM " . _tbl('room') . "
|
|
WHERE id=:id AND area_id=:area_id";
|
|
|
|
if ($disabled_field_exists)
|
|
{
|
|
$sql .= " AND disabled=0";
|
|
}
|
|
|
|
$sql .= " LIMIT 1";
|
|
$sql_params = array(':id' => $default_room, ':area_id' => $area);
|
|
$room = db()->query1($sql, $sql_params);
|
|
|
|
if (($room >= 0) && is_visible($room))
|
|
{
|
|
return (int) $room;
|
|
}
|
|
}
|
|
|
|
// Otherwise just return the first visible room in the area
|
|
$rooms = get_rooms($area, !$disabled_field_exists);
|
|
|
|
return (count($rooms) > 0) ? (int) $rooms[0]['id'] : 0;
|
|
}
|
|
|
|
|
|
/**
|
|
* Get the area id for a given room id
|
|
*
|
|
* @return int The area_id, or 0 if the room doesn't exist.
|
|
*/
|
|
function get_area(int $room) : int
|
|
{
|
|
$sql = "SELECT area_id
|
|
FROM " . _tbl('room') . "
|
|
WHERE id=?
|
|
LIMIT 1";
|
|
|
|
$area = db()->query1($sql, array($room));
|
|
|
|
return max($area, 0);
|
|
}
|
|
|
|
|
|
/**
|
|
* Get the timezone for an area
|
|
*
|
|
* @return false|string The timezone string, or false if there isn't one.
|
|
*/
|
|
function get_area_timezone(int $area_id)
|
|
{
|
|
$sql = "SELECT timezone
|
|
FROM " . _tbl('area') . "
|
|
WHERE id=:id
|
|
LIMIT 1";
|
|
return db()->query_scalar_non_bool($sql, [':id' => $area_id]);
|
|
}
|
|
|
|
|
|
// Clean up a row from the area table, making sure there are no nulls, casting
|
|
// boolean fields into bools and doing some sanity checking
|
|
function clean_area_row(array $row) : array
|
|
{
|
|
global $force_resolution, $area_defaults, $private_override_options;
|
|
|
|
// This code can get called during the upgrade process and so must
|
|
// not make any assumptions about the existence of extra columns in
|
|
// the area table.
|
|
|
|
// Cast the columns to their intended types (the pseudo-booleans will
|
|
// be picked up later)
|
|
row_cast_columns($row, 'area');
|
|
|
|
// Get the default settings if necessary
|
|
$columns = Columns::getInstance(_tbl('area'));
|
|
|
|
foreach ($columns as $column)
|
|
{
|
|
// Cast the pseudo-booleans to bools
|
|
if (isset($row[$column->name]) && ($column->getNature() == Column::NATURE_INTEGER) && ($column->getLength() <=2))
|
|
{
|
|
$row[$column->name] = (bool)$row[$column->name];
|
|
}
|
|
|
|
if (array_key_exists($column->name, $area_defaults))
|
|
{
|
|
// If the "per area" setting is in the database, then use that. Otherwise
|
|
// just stick with the default setting from the config file.
|
|
// (don't use the database setting if $force_resolution is TRUE
|
|
// and we're looking at the resolution field)
|
|
if (!isset($row[$column->name]) ||
|
|
(($column->name == 'resolution') && !empty($force_resolution) && empty($row['enable_periods'])) )
|
|
{
|
|
$row[$column->name] = $area_defaults[$column->name];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Do some sanity checking in case the area table is somehow messed up
|
|
// (1) 'private_override' must be a valid value
|
|
if (array_key_exists('private_override', $row) &&
|
|
!in_array($row['private_override'], $private_override_options))
|
|
{
|
|
$row['private_override'] = 'private'; // the safest default
|
|
$message = "Invalid value for 'private_override' in the area table. Using '{$row['private_override']}'.";
|
|
trigger_error($message, E_USER_WARNING);
|
|
}
|
|
// (2) 'resolution' must be positive
|
|
if (array_key_exists('resolution', $row) &&
|
|
(empty($row['resolution']) || ($row['resolution'] < 0)))
|
|
{
|
|
$row['resolution'] = 30*60; // 30 minutes, a reasonable fallback
|
|
$message = "Invalid value for 'resolution' in the area table. Using {$row['resolution']} seconds.";
|
|
trigger_error($message, E_USER_WARNING);
|
|
}
|
|
|
|
return $row;
|
|
}
|
|
|
|
|
|
// Update the default area settings with the ones specific to this area.
|
|
// If no value is set in the database, use the value from the config file.
|
|
// If $area is empty, use the default area
|
|
function get_area_settings(int $area) : void
|
|
{
|
|
global $auth;
|
|
global $resolution, $default_duration, $default_duration_all_day;
|
|
global $morningstarts, $morningstarts_minutes, $eveningends, $eveningends_minutes;
|
|
global $private_enabled, $private_default, $private_mandatory, $private_override;
|
|
global $min_create_ahead_enabled, $max_create_ahead_enabled, $min_create_ahead_secs, $max_create_ahead_secs;
|
|
global $min_delete_ahead_enabled, $max_delete_ahead_enabled, $min_delete_ahead_secs, $max_delete_ahead_secs;
|
|
global $max_duration_enabled, $max_duration_secs, $max_duration_periods;
|
|
global $approval_enabled, $reminders_enabled, $enable_periods, $periods;
|
|
global $confirmation_enabled, $confirmed_default, $timezone;
|
|
global $max_per_interval_area_enabled, $max_per_interval_area;
|
|
global $max_secs_per_interval_area_enabled, $max_secs_per_interval_area;
|
|
global $interval_types;
|
|
global $times_along_top, $default_type;
|
|
|
|
// This code can get called during the upgrade process and so must
|
|
// not make any assumptions about the existence of extra columns in
|
|
// the area table.
|
|
if (empty($area))
|
|
{
|
|
$area = get_default_area();
|
|
}
|
|
|
|
// Get all the "per area" config settings
|
|
$columns = array('timezone', 'resolution', 'default_duration', 'default_duration_all_day',
|
|
'morningstarts', 'morningstarts_minutes',
|
|
'eveningends', 'eveningends_minutes',
|
|
'private_enabled', 'private_default', 'private_mandatory', 'private_override',
|
|
'min_create_ahead_enabled', 'max_create_ahead_enabled',
|
|
'min_create_ahead_secs', 'max_create_ahead_secs',
|
|
'min_delete_ahead_enabled', 'max_delete_ahead_enabled',
|
|
'min_delete_ahead_secs', 'max_delete_ahead_secs',
|
|
'max_duration_enabled', 'max_duration_secs', 'max_duration_periods',
|
|
'max_per_day_enabled', 'max_per_day',
|
|
'max_per_week_enabled', 'max_per_week',
|
|
'max_per_month_enabled', 'max_per_month',
|
|
'max_per_year_enabled', 'max_per_year',
|
|
'max_per_future_enabled', 'max_per_future',
|
|
'max_secs_per_day_enabled', 'max_secs_per_day',
|
|
'max_secs_per_week_enabled', 'max_secs_per_week',
|
|
'max_secs_per_month_enabled', 'max_secs_per_month',
|
|
'max_secs_per_year_enabled', 'max_secs_per_year',
|
|
'max_secs_per_future_enabled', 'max_secs_per_future',
|
|
'approval_enabled', 'reminders_enabled', 'enable_periods', 'periods',
|
|
'confirmation_enabled', 'confirmed_default',
|
|
'times_along_top', 'default_type');
|
|
|
|
$sql = "SELECT *
|
|
FROM " . _tbl('area') . "
|
|
WHERE id=?
|
|
LIMIT 1";
|
|
|
|
$res = db()->query($sql, array($area));
|
|
if ($res->count() == 0)
|
|
{
|
|
// We still need to set the timezone even if the query didn't
|
|
// return any results
|
|
mrbs_default_timezone_set($timezone);
|
|
return;
|
|
}
|
|
else
|
|
{
|
|
$row = $res->next_row_keyed();
|
|
|
|
// Periods are stored as a JSON encoded string in the database
|
|
if (isset($row['periods']))
|
|
{
|
|
$row['periods'] = json_decode($row['periods'], true);
|
|
}
|
|
|
|
$row = clean_area_row($row);
|
|
foreach ($columns as $column)
|
|
{
|
|
if (array_key_exists($column, $row))
|
|
{
|
|
$$column = $row[$column];
|
|
}
|
|
}
|
|
}
|
|
// Set the timezone
|
|
mrbs_default_timezone_set($timezone);
|
|
|
|
// Set the $max_per_interval_area_enabled, $max_per_interval_area,
|
|
// $max_secs_per_interval_area_enabled and $max_secs_per_interval_area
|
|
// arrays, which are handled slightly differently
|
|
foreach ($interval_types as $interval_type)
|
|
{
|
|
$var = "max_per_{$interval_type}_enabled";
|
|
if (isset($$var))
|
|
{
|
|
$max_per_interval_area_enabled[$interval_type] = $$var;
|
|
}
|
|
|
|
$var = "max_per_$interval_type";
|
|
if (isset($$var))
|
|
{
|
|
$max_per_interval_area[$interval_type] = $$var;
|
|
}
|
|
|
|
$var = "max_secs_per_{$interval_type}_enabled";
|
|
if (isset($$var))
|
|
{
|
|
$max_secs_per_interval_area_enabled[$interval_type] = $$var;
|
|
}
|
|
|
|
$var = "max_secs_per_$interval_type";
|
|
if (isset($$var))
|
|
{
|
|
$max_secs_per_interval_area[$interval_type] = $$var;
|
|
}
|
|
}
|
|
|
|
// Sanitise the settings
|
|
if ($enable_periods)
|
|
{
|
|
$resolution = 60;
|
|
$morningstarts = 12;
|
|
$morningstarts_minutes = 0;
|
|
$eveningends = 12;
|
|
$eveningends_minutes = count($periods) - 1;
|
|
}
|
|
elseif (!isset($morningstarts_minutes))
|
|
{
|
|
// ensure that $morningstarts_minutes defaults to zero if not set
|
|
$morningstarts_minutes = 0;
|
|
}
|
|
|
|
// If it's been set in the config settings then force all bookings to be shown
|
|
// as private for users who haven't logged in.
|
|
if ($auth['force_private_for_guests'] && (null === session()->getCurrentUser()))
|
|
{
|
|
$private_override = 'private';
|
|
}
|
|
}
|
|
|
|
|
|
// Generate the predicate for use in an SQL query to test whether
|
|
// an area has $field set
|
|
function some_area_predicate(string $field) : string
|
|
{
|
|
global $area_defaults;
|
|
|
|
$predicate = "(($field IS NOT NULL) AND ($field > 0))";
|
|
if ($area_defaults[$field])
|
|
{
|
|
$predicate = "(" . $predicate . " OR ($field IS NULL))";
|
|
}
|
|
return $predicate;
|
|
}
|
|
|
|
|
|
// Generate the predicate for use in an SQL query to test whether
|
|
// an area does not have $field set
|
|
function some_area_predicate_not(string $field) : string
|
|
{
|
|
global $area_defaults;
|
|
|
|
$predicate = "(($field IS NOT NULL) AND ($field = 0))";
|
|
if (empty($area_defaults[$field]))
|
|
{
|
|
$predicate = "(" . $predicate . " OR ($field IS NULL))";
|
|
}
|
|
return $predicate;
|
|
}
|
|
|
|
|
|
// Determines whether there is at least one area with the relevant $field
|
|
// set (eg 'approval_enabled' or 'confirmation_enabled'). If $only_enabled
|
|
// is TRUE then the search is limited to enabled areas
|
|
//
|
|
// Returns: boolean
|
|
function some_area(string $field, bool $only_enabled=false, bool $not=false) : bool
|
|
{
|
|
$predicate = ($not) ? some_area_predicate_not($field) : some_area_predicate($field);
|
|
$sql = "SELECT COUNT(*) FROM " . _tbl('area') . " WHERE $predicate";
|
|
$sql .= ($only_enabled) ? " AND disabled=0" : "";
|
|
$sql .= " LIMIT 1";
|
|
|
|
return (db()->query1($sql) > 0);
|
|
}
|
|
|
|
|
|
// Determines whether there is at least one area with periods enabled.
|
|
// If $only_enabled is TRUE then the search is limited to enabled areas
|
|
function periods_somewhere(bool $only_enabled=false) : bool
|
|
{
|
|
return some_area('enable_periods', $only_enabled);
|
|
}
|
|
|
|
|
|
// Determines whether there is at least one area with times enabled.
|
|
// If $only_enabled is TRUE then the search is limited to enabled areas
|
|
function times_somewhere(bool $only_enabled=false) : bool
|
|
{
|
|
return some_area('enable_periods', $only_enabled, true);
|
|
}
|
|
|
|
|
|
// Returns a date in ISO 8601 format ('yyyy-mm-dd'), having adjusted the date if necessary
|
|
// to make sure it's a valid date
|
|
function format_iso_date(int $year, int $month, int $day) : string
|
|
{
|
|
$date = new DateTime();
|
|
return $date->setDate($year, $month, $day)->format(DateTime::ISO8601_DATE);
|
|
}
|
|
|
|
|
|
// Splits a date in ISO 8601 format ('yyyy-mm-dd') and returns an
|
|
// array of year, month, day with leading zeros removed, or FALSE
|
|
// on failure.
|
|
function split_iso_date(string $iso_date)
|
|
{
|
|
if (false === ($date = DateTime::createFromFormat(DateTime::ISO8601_DATE, $iso_date)))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return array(
|
|
(int) $date->format('Y'),
|
|
(int) $date->format('n'),
|
|
(int) $date->format('j')
|
|
);
|
|
}
|
|
|
|
|
|
// Validates that a string is a valid ISO date in yyyy-mm-dd format
|
|
function validate_iso_date(string $date) : bool
|
|
{
|
|
// Try creating a DateTime object. If that is successful and
|
|
// the inverse operation gives the original string then it is
|
|
// valid. (This is to check that, for example, 2021-09-31 hasn't
|
|
// been converted into 2021-10-01).
|
|
$format = DateTime::ISO8601_DATE;
|
|
$datetime = DateTime::createFromFormat($format, $date);
|
|
|
|
return ($datetime && ($datetime->format($format) === $date));
|
|
}
|
|
|
|
|
|
/**
|
|
* Format a timestamp.
|
|
*
|
|
* @param array<string, int|string> $format The format to use, specified by keys 'date_type', 'time_type',
|
|
* 'pattern' or 'skeleton'.
|
|
* @param int|null $timestamp The timestamp to format. If null, use the current time.
|
|
* @param string|null $locale The locale to use. If null, use the default web locale.
|
|
* @param string|null $tzid The timezone to use. If null, use the current default timezone.
|
|
*/
|
|
function datetime_format(array $format, ?int $timestamp=null, ?string $locale=null, ?string $tzid=null) : string
|
|
{
|
|
global $icu_override, $timezone;
|
|
|
|
if (!isset($timestamp))
|
|
{
|
|
$timestamp = time();
|
|
}
|
|
|
|
if (!isset($locale))
|
|
{
|
|
$locale = Language::getInstance()->getWebLocale();
|
|
}
|
|
|
|
if (!isset($tzid))
|
|
{
|
|
$tzid = $timezone;
|
|
}
|
|
|
|
if (isset($format['skeleton']))
|
|
{
|
|
$pattern_generator = new IntlDatePatternGenerator($locale);
|
|
$pattern = $pattern_generator->getBestPattern($format['skeleton']);
|
|
}
|
|
|
|
if (!isset($pattern) || ($pattern === false) || ($pattern === ''))
|
|
{
|
|
$pattern = $format['pattern'] ?? null;
|
|
}
|
|
|
|
$formatter = IntlDateFormatterFactory::create(
|
|
$locale,
|
|
$format['date_type'] ?? IntlDateFormatter::FULL,
|
|
$format['time_type'] ?? IntlDateFormatter::FULL,
|
|
$tzid, null, $pattern
|
|
);
|
|
|
|
// If we're overriding the ICU value(s) then use those, provided that we're able to make use
|
|
// of them with the IntlCalendar and (standard, not emulated - hence the check for the loaded
|
|
// extension, rather than whether the class exists, which it always will) IntlDateFormatter classes.
|
|
if (isset($icu_override[$locale]) && extension_loaded('intl') && class_exists('IntlCalendar'))
|
|
{
|
|
$cal = \IntlCalendar::createInstance($tzid, $locale);
|
|
{
|
|
if (isset($icu_override[$locale]['first_day_of_week']))
|
|
{
|
|
$cal->setFirstDayOfWeek($icu_override[$locale]['first_day_of_week']);
|
|
}
|
|
if (isset($icu_override[$locale]['minimal_days_in_first_week']))
|
|
{
|
|
$cal->setMinimalDaysInFirstWeek($icu_override[$locale]['minimal_days_in_first_week']);
|
|
}
|
|
}
|
|
$cal->setTime($timestamp * 1000);
|
|
return $formatter->format($cal);
|
|
}
|
|
|
|
return $formatter->format($timestamp);
|
|
}
|
|
|
|
|
|
// Determines whether a locale (default the current locale) uses a 12-hour clock.
|
|
// Returns NULL if it can't be determined.
|
|
function is_ampm(?string $locale=null) : ?bool
|
|
{
|
|
global $datetime_formats;
|
|
|
|
if (!isset($locale))
|
|
{
|
|
$locale = Language::getInstance()->getWebLocale();
|
|
}
|
|
|
|
// Work out what pattern is being used for times.
|
|
// If an explicit pattern has been set in the config file then use that.
|
|
if (isset($datetime_formats['time']['pattern']))
|
|
{
|
|
$pattern = $datetime_formats['time']['pattern'];
|
|
}
|
|
// Otherwise get the pattern that would be used by default.
|
|
else
|
|
{
|
|
$formatter = IntlDateFormatterFactory::create(
|
|
$locale,
|
|
$format['date_type'] ?? IntlDateFormatter::NONE,
|
|
$format['time_type'] ?? IntlDateFormatter::SHORT
|
|
);
|
|
$pattern = $formatter->getPattern();
|
|
if ($pattern === false)
|
|
{
|
|
trigger_error("Could not determine whether '$locale' uses a 12-hour clock");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// And see if it contains an 'a'
|
|
return str_contains($pattern, 'a');
|
|
}
|
|
|
|
|
|
// Get the local day name based on language.
|
|
function day_name(int $day_number, ?array $format=null, ?string $locale=null) : string
|
|
{
|
|
global $datetime_formats;
|
|
|
|
if (!isset($format))
|
|
{
|
|
$format = $datetime_formats['day_name'];
|
|
}
|
|
|
|
// Note 2000-01-02 is a Sunday.
|
|
return datetime_format($format, mktime(0,0,0,1,2+$day_number,2000), $locale);
|
|
}
|
|
|
|
|
|
// Gets the monthly repeat day for an entry, for example "20" or "second Wednesday"
|
|
function get_monthly_repeat_day(array $data) : string
|
|
{
|
|
// Fix up the keys while we're still transitioning to the RepeatRule
|
|
if (isset($data['repeat_rule']))
|
|
{
|
|
$repeat_rule = $data['repeat_rule'];
|
|
if ($repeat_rule->getMonthlyType() == RepeatRule::MONTHLY_ABSOLUTE)
|
|
{
|
|
$data['month_absolute'] = $repeat_rule->getMonthlyAbsolute();
|
|
}
|
|
else
|
|
{
|
|
$data['month_relative'] = $repeat_rule->getMonthlyRelative();
|
|
}
|
|
}
|
|
|
|
if (isset($data['month_absolute']))
|
|
{
|
|
$result = (string) $data['month_absolute'];
|
|
}
|
|
elseif (isset($data['month_relative']))
|
|
{
|
|
// Note: this does not internationalise very well and could do with revisiting.
|
|
// It follows the select box order in edit_entry, which is the more difficult one
|
|
// to sort out. It assumes all languages have the same order as English
|
|
// eg "the second Wednesday" which is probably not true.
|
|
list('ordinal' => $ord, 'day' => $dow) = RFC5545::parseByday($data['month_relative']);
|
|
$result = get_vocab("ord_" . $ord) . " " . day_name(RFC5545::convertDayToOrd($dow));
|
|
}
|
|
else
|
|
{
|
|
throw new Exception("Unknown monthly repeat type");
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
function hour_min_format() : array
|
|
{
|
|
global $datetime_formats;
|
|
|
|
return $datetime_formats['time'];
|
|
}
|
|
|
|
|
|
// Returns a string representing the hour and minute for the nominal
|
|
// seconds since the start of the day, $s
|
|
function hour_min(int $s) : string
|
|
{
|
|
$following_day = ($s >= SECONDS_PER_DAY);
|
|
$s = $s % SECONDS_PER_DAY; // in case $s is on the next day
|
|
// Choose a day that doesn't have any DST transitions in any timezone
|
|
$t = mktime(0, 0, $s, 1, 1, 2000);
|
|
$result = datetime_format(hour_min_format(), $t);
|
|
if ($following_day)
|
|
{
|
|
$result = "* " . $result;
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
|
|
/**
|
|
* Convert a timestamp into a date string, when using periods.
|
|
*
|
|
* @param bool $previous_period Whether the date string for the previous period should be used (useful for the end of a period)
|
|
*/
|
|
function period_date_string(int $t, int $area_id, bool $previous_period=false, ?string $locale=null) : string
|
|
{
|
|
global $datetime_formats;
|
|
|
|
$tzid = get_area_timezone($area_id);
|
|
|
|
// The separator is a ',' as a '-' leads to an ambiguous display in report.php when showing end times.
|
|
return period_time_string($t, $area_id, $previous_period) . ', ' . datetime_format($datetime_formats['date'], $t, $locale, $tzid);
|
|
}
|
|
|
|
|
|
/**
|
|
* Returns the name of the period in a given area for a given timestamp.
|
|
*
|
|
* @param bool $previous_period Whether the name of the previous period should be used (useful for the end of a period)
|
|
*/
|
|
function period_time_string(int $t, int $area_id, bool $previous_period=false) : string
|
|
{
|
|
$periods = Periods::getForArea($area_id);
|
|
return $periods->offsetGetByTimestamp($t, $previous_period)->name;
|
|
}
|
|
|
|
|
|
function time_date_string(int $t, ?string $locale=null, ?string $tzid=null) : string
|
|
{
|
|
global $datetime_formats;
|
|
|
|
return datetime_format($datetime_formats['date_and_time'], $t, $locale, $tzid);
|
|
}
|
|
|
|
|
|
/**
|
|
* Convert a timestamp into a date string
|
|
*
|
|
* @param bool $previous_period Whether the date string for the previous period should be used (useful for the end of a period)
|
|
*/
|
|
function date_string(bool $use_periods, int $t, int $area_id, bool $previous_period=false) : string
|
|
{
|
|
$tzid = get_area_timezone($area_id);
|
|
return ($use_periods) ? period_date_string($t, $area_id, $previous_period) : time_date_string($t, null, $tzid);
|
|
}
|
|
|
|
|
|
// Wrapper for the standard PHP function nl2br() that sets $use_xhtml to false
|
|
function mrbs_nl2br(string $string) : string
|
|
{
|
|
return nl2br($string, false);
|
|
}
|
|
|
|
|
|
// Take a string of email addresses separated by commas
|
|
// and return a comma separated list with duplicates removed.
|
|
function clean_address_list(string $string) : string
|
|
{
|
|
$array = explode(',', $string);
|
|
$array = array_map('trim', $array);
|
|
return implode(', ', array_unique($array));
|
|
}
|
|
|
|
|
|
function validate_email(string $email) : bool
|
|
{
|
|
return PHPMailer::validateAddress($email);
|
|
}
|
|
|
|
|
|
// Validates a comma separated list of email addresses. (The individual email
|
|
// addresses are 'trimmed' before validation, so spaces are allowed after the commas).
|
|
// Returns FALSE if any one of them is invalid, otherwise TRUE
|
|
function validate_email_list(string $list) : bool
|
|
{
|
|
if ($list !== '')
|
|
{
|
|
$emails = explode(',', $list);
|
|
foreach ($emails as $email)
|
|
{
|
|
if (!validate_email(trim($email)))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
|
|
// Parses an email address and returns an array indexed by 'local' and 'domain',
|
|
// or FALSE if the email address is not valid.
|
|
function parse_email(string $email)
|
|
{
|
|
$parser = new Parse(new Logger());
|
|
$parsed_address = $parser->parse($email, false);
|
|
|
|
if ($parsed_address['invalid'])
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return [
|
|
'local' => $parsed_address['local_part_parsed'],
|
|
'domain' => $parsed_address['domain_part']
|
|
];
|
|
}
|
|
|
|
|
|
function parse_addresses(string $address_string) : array
|
|
{
|
|
$result = [];
|
|
|
|
$parser = new Parse(new Logger());
|
|
$parsed_addresses = $parser->parse($address_string);
|
|
|
|
foreach ($parsed_addresses['email_addresses'] as $parsed_address)
|
|
{
|
|
if (!$parsed_address['invalid'])
|
|
{
|
|
$result[] = [
|
|
'name' => $parsed_address['name_parsed'],
|
|
'address' => $parsed_address['local_part_parsed'] . '@' . $parsed_address['domain_part']
|
|
];
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
// Round time down to the nearest resolution
|
|
function round_t_down(int $t, int $resolution, int $start_first_slot) : int
|
|
{
|
|
// The simple case when no DST transition is involved
|
|
if (cross_dst($start_first_slot, $t) == 0)
|
|
{
|
|
return $t - (int)abs(($t - $start_first_slot) % $resolution);
|
|
}
|
|
// Otherwise we have to pay attention to DST transitions
|
|
$date = new DateTime();
|
|
$date->setTimestamp($start_first_slot);
|
|
$nominal_seconds = nominal_seconds($start_first_slot);
|
|
// Keep advancing the slot until we find the one that is closest to $t
|
|
do {
|
|
$candidate_slot = $date->getTimestamp();
|
|
$nominal_seconds += $resolution;
|
|
$date->setNominalSeconds($nominal_seconds);
|
|
} while ($date->getTimestamp() <= $t);
|
|
|
|
return $candidate_slot;
|
|
}
|
|
|
|
|
|
// Round time up to the nearest resolution
|
|
function round_t_up(int $t, int $resolution, int $start_first_slot) : int
|
|
{
|
|
// The simple case when no DST transition is involved
|
|
if (cross_dst($start_first_slot, $t) == 0)
|
|
{
|
|
if (($t - $start_first_slot) % $resolution != 0) {
|
|
return $t + $resolution - abs(($t - $start_first_slot) % $resolution);
|
|
}
|
|
else {
|
|
return $t;
|
|
}
|
|
}
|
|
// Otherwise we have to pay attention to DST transitions
|
|
$date = new DateTime();
|
|
$date->setTimestamp($start_first_slot);
|
|
$nominal_seconds = nominal_seconds($start_first_slot);
|
|
// Keep advancing the slot until we find the one that is >= $t
|
|
do {
|
|
$candidate_slot = $date->getTimestamp();
|
|
$nominal_seconds += $resolution;
|
|
$date->setNominalSeconds($nominal_seconds);
|
|
} while ($candidate_slot < $t);
|
|
|
|
return $candidate_slot;
|
|
}
|
|
|
|
|
|
// Returns the nominal (ie ignoring DST transitions) seconds since the start of
|
|
// the calendar day on the start of the booking day
|
|
function nominal_seconds(int $t) : int
|
|
{
|
|
global $morningstarts, $morningstarts_minutes;
|
|
|
|
$date = getdate($t);
|
|
// check to see if the time is really on the next day
|
|
if (hm_before($date,
|
|
array('hours' => $morningstarts, 'minutes' => $morningstarts_minutes)))
|
|
{
|
|
$date['hours'] += 24;
|
|
}
|
|
return (($date['hours'] * 60) + $date['minutes']) * 60;
|
|
}
|
|
|
|
|
|
// Returns the index of the period represented by the timestamp $timestamp
|
|
function period_index_timestamp(int $timestamp) : int
|
|
{
|
|
// Periods are counted as minutes from noon, ie 1200 is $period[0],
|
|
// 1201 $period[1], etc.
|
|
$date = getdate($timestamp);
|
|
|
|
return intval((($date['hours'] - 12) * 60) + $date['minutes']);
|
|
}
|
|
|
|
|
|
// Returns the index of the period represented by $s nominal seconds
|
|
function period_index_nominal(int $s) : int
|
|
{
|
|
// Periods are counted as minutes from noon, ie 1200 is $period[0],
|
|
// 1201 $period[1], etc.
|
|
return intval($s/60) - (12*60);
|
|
}
|
|
|
|
|
|
// Returns the name of the period represented by the timestamp $timestamp
|
|
function period_name_timestamp(int $timestamp) : string
|
|
{
|
|
global $periods;
|
|
|
|
$index = period_index_timestamp($timestamp);
|
|
|
|
return $periods[$index] ?? unknown_period_name($index);
|
|
}
|
|
|
|
|
|
// Returns the name of a period that doesn't exist
|
|
function unknown_period_name(int $index) : string
|
|
{
|
|
return get_vocab('unknown_period', $index);
|
|
}
|
|
|
|
|
|
// returns the numeric day of the week (0-6) in terms of the MRBS week as defined by
|
|
// $weekstarts. For example if $weekstarts is set to 2 (Tuesday) and a $time for
|
|
// a Wednesday is given, then 1 is returned.
|
|
function day_of_MRBS_week(int $time) : int
|
|
{
|
|
global $weekstarts;
|
|
|
|
return (date('w', $time) - $weekstarts + DAYS_PER_WEEK) % DAYS_PER_WEEK;
|
|
}
|
|
|
|
|
|
// Compares two nominal dates which are indexed by 'hours', 'minutes', 'seconds',
|
|
// 'mon', 'mday' and 'year', ie as in the output of getdate(). Returns -1 if the
|
|
// first date is before the second, 0 if they are equal and +1 if the first date
|
|
// is after the second. NULL if a comparison can't be done.
|
|
//
|
|
// (Note that internally the function uses gmmktime() so the parameters do not
|
|
// have to represent valid values. For example you could pass '32' for day and
|
|
// that would be interpreted as 4 days after '28'.)
|
|
function nominal_date_compare(array $d1, array $d2) : int
|
|
{
|
|
// We compare the dates using gmmktime() because we are trying to compare nominal
|
|
// dates and so do not want DST transitions
|
|
$t1 = gmmktime($d1['hours'], $d1['minutes'], $d1['seconds'],
|
|
$d1['mon'], $d1['mday'], $d1['year']);
|
|
$t2 = gmmktime($d2['hours'], $d2['minutes'], $d2['seconds'],
|
|
$d2['mon'], $d2['mday'], $d2['year']);
|
|
if ($t1 < $t2)
|
|
{
|
|
return -1;
|
|
}
|
|
elseif ($t1 == $t2)
|
|
{
|
|
return 0;
|
|
}
|
|
else
|
|
{
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
|
|
// Determines whether there's a possibility that the interval between the two Unix
|
|
// timestamps could contain nominal times that don't exist, for example from 0100 to
|
|
// 0159 in Europe/London when entering DST.
|
|
function is_possibly_invalid(int $start, int $end) : bool
|
|
{
|
|
// We err on the side of caution by widening the interval by a day at each end. This
|
|
// allows for the possibility that the start or end times have been calculated by using
|
|
// mktime() on an invalid time!
|
|
return (cross_dst($start - 86400, $end + 86400) < 0);
|
|
}
|
|
|
|
|
|
// Checks whether the nominal time given is an invalid date and time with respect to
|
|
// DST transitions. When entering DST there is a set of times that don't exist, for
|
|
// example from 0100 to 0159 in Europe/London.
|
|
// Returns NULL if MRBS is unable to determine an answer, otherwise TRUE or FALSE (so
|
|
// a simple equality test will default to a valid time if MRBS can't determine an answer)
|
|
function is_invalid_datetime(int $hour, int $minute, int $second, int $month, int $day, int $year, ?string $tz=null) : ?bool
|
|
{
|
|
global $timezone;
|
|
|
|
// Do a quick check to see if there's a possibility of an invalid time by checking
|
|
// whether there's a transition into DST from the day before to the day after
|
|
if (empty($tz))
|
|
{
|
|
$tz = $timezone; // default to the current timezone
|
|
}
|
|
|
|
$old_tz = date_default_timezone_get(); // save the current timezone
|
|
date_default_timezone_set($tz);
|
|
|
|
// If the day before is in DST then the datetime must be valid, because
|
|
// you only get the gap when entering DST.
|
|
$dayBefore = mktime($hour, $minute, $second, $month, $day-1, $year);
|
|
if (date('I', $dayBefore))
|
|
{
|
|
$result = false;
|
|
}
|
|
else
|
|
{
|
|
// The day before is not in DST. If the day after is also not in DST,
|
|
// then there can have been no transition, so again the datetime must be valid.
|
|
$dayAfter = mktime($hour, $minute, $second, $month, $day+1, $year);
|
|
if (!date('I', $dayAfter))
|
|
{
|
|
$result = false;
|
|
}
|
|
else
|
|
{
|
|
$thisDateTimeZone = new DateTimeZone($tz);
|
|
// Get the transition data (we assume there is one and only one transition),
|
|
// in particular the time at which the transition happens and the new offset
|
|
$transitions = $thisDateTimeZone->getTransitions($dayBefore, $dayAfter);
|
|
// According to my reading of the PHP manual, getTransitions() should return
|
|
// all transitions between the start and end date. However what it seems to do
|
|
// is return an array consisting of the time data for the start date followed by
|
|
// the transition data. So as a precaution we take the last element of the array
|
|
// (we were only expecting one element, but seem to get two).
|
|
$transition = array_pop($transitions);
|
|
// If we failed for some reason to get any transition data, return NULL
|
|
if (!isset($transition))
|
|
{
|
|
$result = null;
|
|
}
|
|
else
|
|
{
|
|
// Get the old offset and work out how many seconds the clocks change by
|
|
$beforeDateTime = new DateTime(date('c', $dayBefore), $thisDateTimeZone);
|
|
$change = $transition['offset'] - $beforeDateTime->getOffset();
|
|
|
|
// See if the nominal date falls outside the gap
|
|
$lastValidSecond = getdate($transition['ts'] - 1);
|
|
$lastInvalidSecond = $lastValidSecond;
|
|
$lastInvalidSecond['seconds'] += $change;
|
|
$thisDate = array('hours' => $hour, 'minutes' => $minute, 'seconds' => $second,
|
|
'mon' => $month, 'mday' => $day, 'year' => $year);
|
|
|
|
$result = ((nominal_date_compare($thisDate, $lastValidSecond) > 0) &&
|
|
(nominal_date_compare($thisDate, $lastInvalidSecond) <= 0));
|
|
}
|
|
}
|
|
}
|
|
|
|
date_default_timezone_set($old_tz); // restore the old timezone
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
/**
|
|
* Calculate the modification (in seconds) necessary on account of any DST
|
|
* transitions when going from `$start` to `$end`.
|
|
*
|
|
* For example, if the timezone is Europe/London and `$start` is in February
|
|
* and `$end` is in April, then the modification is -3600 seconds.
|
|
*
|
|
* @param int $start A Unix timestamp
|
|
* @param int $end A Unix timestamp
|
|
* @return int The modification in seconds.
|
|
*/
|
|
function cross_dst(int $start, int $end) : int
|
|
{
|
|
global $timezone;
|
|
|
|
// Calculate the modification using the DateTimeZone information rather than date('I', $time),
|
|
// because not all DST transitions are 1 hour. For example, Lord Howe Island in Australia
|
|
// has a 30-minute transition.
|
|
$thisDateTimeZone = new DateTimeZone($timezone);
|
|
$startDateTime = (new DateTime('now', $thisDateTimeZone))->setTimestamp($start);
|
|
$endDateTime = (new DateTime('now', $thisDateTimeZone))->setTimestamp($end);
|
|
return $startDateTime->getOffset() - $endDateTime->getOffset();
|
|
}
|
|
|
|
|
|
// If $time falls on a non-working day, shift it back to the end of the last
|
|
// working day before that
|
|
function shift_to_workday(int $time) : int
|
|
{
|
|
global $working_days;
|
|
|
|
$dow = date('w', $time); // get the day of the week
|
|
$skip_back = 0; // number of days to skip back
|
|
// work out how many days to skip back to get to a working day
|
|
while (!in_array($dow, $working_days))
|
|
{
|
|
if ($skip_back == DAYS_PER_WEEK)
|
|
{
|
|
break;
|
|
}
|
|
$skip_back++;
|
|
$dow = ($dow + 6) % DAYS_PER_WEEK; // equivalent to skipping back a day
|
|
}
|
|
if ($skip_back != 0)
|
|
{
|
|
// set the time to the end of the working day
|
|
$d = intval(date('j', $time)) - $skip_back;
|
|
$m = intval(date('n', $time));
|
|
$y = intval(date('Y', $time));
|
|
$time = mktime(23, 59, 59, $m, $d, $y);
|
|
}
|
|
return $time;
|
|
}
|
|
|
|
// Returns the difference in seconds between two timestamps, $now and $then
|
|
// It gives $now - $then, less any seconds that were part of a non-working day
|
|
function working_time_diff(int $now, int $then) : int
|
|
{
|
|
global $working_days;
|
|
|
|
// Deal with the easy case
|
|
if ($now == $then)
|
|
{
|
|
return 0;
|
|
}
|
|
// Sanitise the $working_days array in case it was malformed
|
|
$working_week = array_unique(array_intersect(array(0,1,2,3,4,5,6), $working_days));
|
|
$n_working_days = count($working_week);
|
|
// Deal with the special case where there are no working days
|
|
if ($n_working_days == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
// and the special case where there are no holidays
|
|
if ($n_working_days == DAYS_PER_WEEK)
|
|
{
|
|
return ($now - $then);
|
|
}
|
|
|
|
// For the rest we're going to assume that $last comes after $first
|
|
$last = max($now, $then);
|
|
$first = min($now, $then);
|
|
|
|
// first of all, if $last or $first fall on a non-working day, shift
|
|
// them back to the end of the last working day
|
|
$last = shift_to_workday($last);
|
|
$first = shift_to_workday($first);
|
|
// So calculate the difference
|
|
$diff = $last - $first;
|
|
// Then we have to deduct all the non-working days in between. This will be
|
|
// (a) the number of non-working days in the whole weeks between them +
|
|
// (b) the number of non-working days in the part week
|
|
|
|
// First let's calculate (a)
|
|
$last = mktime(12, 0, 0, (int)date('n', $last), (int)date('j', $last), (int)date('Y', $last));
|
|
$first = mktime(12, 0, 0, (int)date('n', $first), (int)date('j', $first), (int)date('Y', $first));
|
|
$days_diff = (int) round(($last - $first)/SECONDS_PER_DAY); // the difference in days
|
|
$whole_weeks = (int) floor($days_diff/DAYS_PER_WEEK); // the number of whole weeks between the two
|
|
$non_working_days = $whole_weeks * (DAYS_PER_WEEK - $n_working_days);
|
|
// Now (b), ie we just have to calculate how many non-working days there are between the two
|
|
// days of the week that are left
|
|
$last_dow = date('w', $last);
|
|
$first_dow = date('w', $first);
|
|
|
|
while ($first_dow != $last_dow)
|
|
{
|
|
$first_dow = ($first_dow + 1) % DAYS_PER_WEEK;
|
|
if (!in_array($first_dow, $working_week))
|
|
{
|
|
$non_working_days++;
|
|
}
|
|
}
|
|
|
|
// So now subtract the number of weekend seconds
|
|
$diff = $diff - ($non_working_days * SECONDS_PER_DAY);
|
|
|
|
// Finally reverse the difference if $now was in fact before $then
|
|
if ($now < $then)
|
|
{
|
|
$diff = -$diff;
|
|
}
|
|
|
|
return (int) $diff;
|
|
}
|
|
|
|
|
|
// checks whether a given day of the week is supposed to be hidden in the display
|
|
function is_hidden_day(int $dow) : bool
|
|
{
|
|
global $hidden_days;
|
|
|
|
return (isset($hidden_days) && in_array($dow, $hidden_days));
|
|
}
|
|
|
|
|
|
// Checks whether a given day of the week is a weekend day.
|
|
// $dow is in the range [0..6].
|
|
function is_weekend(int $dow) : bool
|
|
{
|
|
global $weekdays;
|
|
|
|
return !in_array($dow, $weekdays);
|
|
}
|
|
|
|
|
|
// returns true if event should be considered private based on
|
|
// config settings and event's privacy status (passed to function)
|
|
function is_private_event(bool $privacy_status) : bool
|
|
{
|
|
global $private_override;
|
|
if ($private_override == "private" )
|
|
{
|
|
$privacy_status = true;
|
|
}
|
|
elseif ($private_override == "public" )
|
|
{
|
|
$privacy_status = false;
|
|
}
|
|
|
|
return $privacy_status;
|
|
}
|
|
|
|
|
|
/**
|
|
* Check whether a field is mandatory for a given area.
|
|
*/
|
|
function is_mandatory_field(string $field, int $area_id) : bool
|
|
{
|
|
global $is_mandatory_field;
|
|
|
|
if (!isset($is_mandatory_field[$field]))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// $is_mandatory_field can either be a boolean or an array of area ids.
|
|
if (is_bool($is_mandatory_field[$field]))
|
|
{
|
|
return $is_mandatory_field[$field];
|
|
}
|
|
|
|
return in_array($area_id, $is_mandatory_field[$field]);
|
|
}
|
|
|
|
|
|
// Generate a globally unique id
|
|
//
|
|
// We will generate a uid of the form "MRBS-uniqid-MD5hash@domain_name"
|
|
// where uniqid is time based and is generated by uniqid() and the
|
|
// MD5hash is the first 8 characters of the MD5 hash of $str concatenated
|
|
// with a random number.
|
|
function generate_global_uid(string $str) : string
|
|
{
|
|
global $server;
|
|
|
|
$uid = uniqid('MRBS-');
|
|
$uid .= "-" . substr(md5($str . rand(0,10000)), 0, 8);
|
|
$uid .= "@";
|
|
// Add on the domain name if possible, if not the server name,
|
|
// otherwise 'MRBS'
|
|
if (empty($server['SERVER_NAME']))
|
|
{
|
|
$uid .= 'MRBS';
|
|
}
|
|
elseif (mb_strpos($server['SERVER_NAME'], 'www.') === 0)
|
|
{
|
|
$uid .= mb_substr($server['SERVER_NAME'], 4);
|
|
}
|
|
else
|
|
{
|
|
$uid .= $server['SERVER_NAME'];
|
|
}
|
|
|
|
return $uid;
|
|
}
|
|
|
|
|
|
/**
|
|
* Tests whether an array is associative.
|
|
*/
|
|
function is_assoc(array $arr) : bool
|
|
{
|
|
// Thanks to magentix at gmail dot com at http://php.net/manual/function.is-array.php
|
|
return (is_array($arr) && count(array_filter(array_keys($arr),'is_string')) == count($arr));
|
|
}
|
|
|
|
|
|
/**
|
|
* Checks whether the application is running as a CGI module.
|
|
*/
|
|
function is_cgi() : bool
|
|
{
|
|
// Based on code from mniewerth at ultimediaos dot com at
|
|
// http://php.net/manual/features.commandline.php
|
|
return (substr(PHP_SAPI, 0, 3) == 'cgi');
|
|
}
|
|
|
|
|
|
/**
|
|
* Checks whether the application is being run from the CLI.
|
|
*/
|
|
function is_cli() : bool
|
|
{
|
|
// Based on code from mniewerth at ultimediaos dot com
|
|
// at http://php.net/manual/features.commandline.php
|
|
|
|
global $allow_cli;
|
|
|
|
if (!$allow_cli)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (defined('STDIN'))
|
|
{
|
|
return true;
|
|
}
|
|
elseif (is_cgi() && getenv('TERM'))
|
|
{
|
|
return true;
|
|
}
|
|
else
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Determines if the current request method is a GET request.
|
|
*/
|
|
function is_get_request() : bool
|
|
{
|
|
global $server;
|
|
|
|
return ($server['REQUEST_METHOD'] === 'GET');
|
|
}
|
|
|
|
|
|
/**
|
|
* Checks whether the application is in kiosk mode.
|
|
*/
|
|
function is_kiosk_mode() : bool
|
|
{
|
|
return session()->isset('kiosk_password_hash');
|
|
}
|
|
|
|
|
|
// Set and restore ignore_user_abort. The function is designed to be used to
|
|
// ensure a critical piece of code can't be aborted, and used in pairs of set and
|
|
// restore calls. The function keeps track of outstanding set requests so that
|
|
// the original state isn't restored if there are other requests still outstanding.
|
|
//
|
|
// $set TRUE set ignore_user_abort
|
|
// FALSE restore to the original state, if no other requests outstanding
|
|
function mrbs_ignore_user_abort(bool $set) : void
|
|
{
|
|
static $original_state;
|
|
static $outstanding_requests = 0;
|
|
|
|
if (!isset($original_state))
|
|
{
|
|
$original_state = boolval(ignore_user_abort());
|
|
}
|
|
|
|
// Set ignore_user_abort
|
|
if ($set)
|
|
{
|
|
if ($outstanding_requests == 0)
|
|
{
|
|
ignore_user_abort(true);
|
|
}
|
|
$outstanding_requests++;
|
|
}
|
|
else
|
|
// Restore the original state, provided no other requests are outstanding
|
|
{
|
|
$outstanding_requests--;
|
|
if ($outstanding_requests == 0)
|
|
{
|
|
ignore_user_abort($original_state);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Gets the web server software type and version, if it can.
|
|
function get_server_software() : string
|
|
{
|
|
global $server;
|
|
|
|
if (function_exists('apache_get_version'))
|
|
{
|
|
$result = apache_get_version();
|
|
if ($result !== false)
|
|
{
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
return $server['SERVER_SOFTWARE'] ?? '';
|
|
}
|
|
|
|
|
|
|
|
function get_field_entry_input(array $params) : Field
|
|
{
|
|
global $select_options, $datalist_options, $text_input_max;
|
|
|
|
if (isset($params['field']))
|
|
{
|
|
if (!empty($select_options[$params['field']]))
|
|
{
|
|
$class = 'FieldSelect';
|
|
}
|
|
elseif (!empty($datalist_options[$params['field']]))
|
|
{
|
|
$class = 'FieldInputDatalist';
|
|
}
|
|
else
|
|
{
|
|
$columns = Columns::getInstance(_tbl('entry'));
|
|
$column_name = preg_replace('/^entry./', '', $params['field']);
|
|
$column = $columns->getColumnByName($column_name);
|
|
if (!in_array($column_name, ['name', 'create_by']) && // special cases
|
|
($column->getNature() === Column::NATURE_CHARACTER) &&
|
|
($column->getLength() > $text_input_max))
|
|
{
|
|
$class = 'FieldTextarea';
|
|
}
|
|
else
|
|
{
|
|
$class = 'FieldInputText';
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
$class = 'FieldInputText';
|
|
}
|
|
|
|
$full_class = __NAMESPACE__ . "\\Form\\$class";
|
|
$field = new $full_class();
|
|
$field->setLabel($params['label'])
|
|
->setControlAttribute('name', $params['name']);
|
|
|
|
if (!empty($params['required']))
|
|
{
|
|
$field->setControlAttribute('required', true);
|
|
}
|
|
if (!empty($params['disabled']))
|
|
{
|
|
$field->setControlAttribute('disabled', true);
|
|
$field->addHiddenInput($params['name'], $params['value']);
|
|
}
|
|
|
|
switch ($class)
|
|
{
|
|
case 'FieldSelect':
|
|
$options = $select_options[$params['field']];
|
|
$field->addSelectOptions($options, $params['value']);
|
|
break;
|
|
|
|
/** @noinspection PhpMissingBreakStatementInspection */
|
|
case 'FieldInputDatalist':
|
|
$options = $datalist_options[$params['field']];
|
|
$field->addDatalistOptions($options);
|
|
// Drop through
|
|
|
|
/** @noinspection PhpMissingBreakStatementInspection */
|
|
case 'FieldInputText':
|
|
if (!empty($params['required']))
|
|
{
|
|
// Set a pattern as well as required to prevent a string of whitespace
|
|
$field->setControlAttribute('pattern', REGEX_TEXT_POS);
|
|
}
|
|
// Drop through
|
|
|
|
case 'FieldTextarea':
|
|
if ($class == 'FieldTextarea')
|
|
{
|
|
$field->setControlText($params['value'] ?? '');
|
|
}
|
|
else
|
|
{
|
|
$field->setControlAttribute('value', $params['value']);
|
|
}
|
|
if (isset($params['field']) &&
|
|
(null !== ($maxlength = maxlength($params['field']))))
|
|
{
|
|
$field->setControlAttribute('maxlength', $maxlength);
|
|
}
|
|
// Add a placeholder if necessary
|
|
$tag = $params['field'] . '.placeholder';
|
|
$placeholder = get_vocab($tag);
|
|
if (isset($placeholder) && ($placeholder !== $tag))
|
|
{
|
|
$field->setControlAttribute('placeholder', $placeholder);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
throw new \Exception("Unknown class '$class'");
|
|
break;
|
|
}
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
// Returns a Field object for selecting as user from the list of available users.
|
|
// If $datalist is true a datalist is returned, otherwise a select element.
|
|
// $params is an associative array with the following keys
|
|
// value the value of the current selection
|
|
// disabled boolean, whether the field should be disabled
|
|
// required boolean
|
|
// field the field name, eg 'entry.create_by'
|
|
// label the field label
|
|
// name the field name
|
|
function get_user_field(array $params, bool $datalist=false) : Field
|
|
{
|
|
if (method_exists(auth(), 'getUsernames'))
|
|
{
|
|
// We can get a list of all users, so present a <select> or <datalist> element.
|
|
// The options will actually be provided later via Ajax, so all we
|
|
// do here is present one option, ie the create_by user.
|
|
$options = array();
|
|
$selected_user = auth()->getUser($params['value']);
|
|
// It's possible that $selected_user no longer exists - may have left the
|
|
// organisation and been deleted from the user list - so in that case
|
|
// use their username for the displayname.
|
|
if (isset($selected_user))
|
|
{
|
|
$options[$selected_user->username] = $selected_user->display_name;
|
|
}
|
|
else
|
|
{
|
|
$options[$params['value']] = $params['value'];
|
|
}
|
|
|
|
$field = new FieldSelect();
|
|
$class = 'ajax_usernames';
|
|
if ($datalist)
|
|
{
|
|
$class .= ' datalist';
|
|
}
|
|
$field->setLabel($params['label'])
|
|
->setControlAttributes(array('name' => $params['name'],
|
|
'class' => $class,
|
|
'disabled' => $params['disabled']))
|
|
->addSelectOptions($options, $params['value'], true);
|
|
}
|
|
else
|
|
{
|
|
// We don't know all the available users, so we'll just present
|
|
// a text input field and rely on the user to enter a valid username.
|
|
$field = get_field_entry_input($params);
|
|
}
|
|
|
|
return $field;
|
|
}
|
|
|
|
|
|
/**
|
|
* Check if an IP address belongs to a specified CIDR range.
|
|
*
|
|
* Only works for IPv4 addresses.
|
|
*/
|
|
function ip_cidr_check (string $ip, string $cidr) : bool
|
|
{
|
|
// Based on code by lukav at lukav dot com from https://www.php.net/manual/en/ref.network.php
|
|
|
|
// Validate the arguments
|
|
if (!str_contains($cidr, '/'))
|
|
{
|
|
throw new \InvalidArgumentException("Invalid CIDR range '$cidr'");
|
|
}
|
|
if (false === ($ip_ip = ip2long ($ip)))
|
|
{
|
|
throw new \InvalidArgumentException("Invalid IP address '$ip'");
|
|
}
|
|
|
|
list($net, $mask) = explode('/', $cidr);
|
|
|
|
if (false === ($ip_net = ip2long ($net)))
|
|
{
|
|
throw new \InvalidArgumentException("Invalid CIDR range '$cidr'");
|
|
}
|
|
|
|
$ip_mask = ~((1 << (32 - $mask)) - 1);
|
|
|
|
return (($ip_ip & $ip_mask) == ($ip_net & $ip_mask));
|
|
}
|
|
|
|
|
|
/**
|
|
* Validate that a given URL is *not* a local URL. Useful for preventing Server Side Request Forgery attacks.
|
|
*
|
|
* @return false|string FALSE if the URL is local, otherwise the URL
|
|
*/
|
|
function url_validate_not_local(string $url)
|
|
{
|
|
// Check the URL is valid
|
|
if (false === ($url = filter_var($url, FILTER_VALIDATE_URL))) {
|
|
return false;
|
|
}
|
|
// Check that we can parse the URL
|
|
if (false === ($url_components = parse_url($url))) {
|
|
return false;
|
|
}
|
|
// Disallow some schemes for security reasons (possible SSRF attack)
|
|
if (isset($url_components['scheme']) && in_arrayi($url_components['scheme'], ['file'])) {
|
|
return false;
|
|
}
|
|
// Check the hostname is well-formed
|
|
if (false === ($hostname = filter_var($url_components['host'], FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME))) {
|
|
return false;
|
|
}
|
|
// Check the hostname is not 'localhost' (possible SSRF attack)
|
|
if (mb_strtolower($hostname) == 'localhost') {
|
|
return false;
|
|
}
|
|
// If the hostname is an IP address, check that it is not a local one (possible SSRF attack)
|
|
if (ip2long($hostname) !== false) {
|
|
foreach (['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '169.254.0.0/16'] as $cidr) {
|
|
// This won't work with IPv6 addresses, but IPv6 addresses will have been disallowed by the filter_var() call above
|
|
if (!ip_cidr_check($hostname, $cidr)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $url;
|
|
}
|