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, , // 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 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 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 $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 $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