MRBS 1.12.2 等保2.0二级整改完整提交
Docker image / push (push) Canceled after 0s

包含:登录失败锁定、90天密码有效期、30分钟会话超时、
强制改密、登录审计日志、屏幕水印、企业背景图、
备案信息固定底部、favicon、登录页JS修复等全部改动
This commit is contained in:
人事系统开发
2026-09-08 21:19:47 +08:00
commit 48092cab42
2221 changed files with 659586 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace MRBS;
/**
* 等保2.0二级整改:安全审计日志(JSON Lines,文件追加)
*
* MRBS 自带 Logger 仅是 PSR-3 外壳(notice/info/debug 只触发 E_USER_NOTICE,
* 无法落盘),因此这里自行实现一个轻量文件审计器:
* - 配置项 $audit_log_file 指定日志路径(默认 <web>/audit/security_audit.log)
* - 超过 20MB 自动轮转一次(保留一份 .old)
* - 写失败静默(不影响业务),避免审计故障拖垮登录流程
*
* 事件类型(event):
* LOGIN_OK / LOGIN_FAIL / LOGIN_BLOCKED / PWD_CHANGE / PWD_CHANGE_FAIL
* PWD_RESET / PWD_ADMIN_SET / LOGOUT
*
* 日志格式(每行一个 JSON 对象,便于 grep / 导入 SIEM):
* {"ts":"...","ip":"...","user":"...","event":"...","detail":"..."}
*/
class Audit
{
public static function log(string $event, ?string $user = null, string $detail = '') : void
{
global $audit_log_file, $timezone;
$file = $audit_log_file;
if (empty($file))
{
$file = dirname(__DIR__, 2) . '/audit/security_audit.log';
}
// 确保日志目录存在(部署时可能尚未手工创建 audit/ 目录)
$dir = dirname($file);
if (!is_dir($dir))
{
@mkdir($dir, 0775, true);
}
// 防审计文件无限增长:超过 20MB 轮转一次(保留一份 .old)
if (is_file($file) && (filesize($file) > 20 * 1024 * 1024))
{
@rename($file, $file . '.old');
}
// 统一按 MRBS 配置时区记录时间戳(登录处理发生在 init_area() 之前,
// 彼时 PHP 默认时区可能仍是 UTC,会导致同一日志两种时区混写)
$old_tz = date_default_timezone_get();
if (!empty($timezone))
{
@date_default_timezone_set($timezone);
}
$ts = date('c');
if (!empty($old_tz))
{
@date_default_timezone_set($old_tz);
}
$record = array(
'ts' => $ts,
'ip' => $_SERVER['REMOTE_ADDR'] ?? '-',
'user' => $user ?? '-',
'event' => $event,
'detail' => $detail
);
@file_put_contents($file, json_encode($record, JSON_UNESCAPED_UNICODE) . "\n",
FILE_APPEND | LOCK_EX);
}
}
+473
View File
@@ -0,0 +1,473 @@
<?php
declare(strict_types=1);
namespace MRBS\Auth;
use MRBS\Language;
use MRBS\User;
use function MRBS\format_compound_name;
use function MRBS\get_registrants;
use function MRBS\get_sortable_name;
use function MRBS\get_vocab;
use function MRBS\in_arrayi;
use function MRBS\session;
abstract class Auth
{
protected $getDisplayNamesAtOnce = true;
/**
* Checks if the specified username/password pair are valid.
*
* @return false|string the validated username or false
*/
abstract public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass);
protected function getUserFresh(string $username) : ?User
{
$user = new User($username);
$user->display_name = $username;
$user->level = $this->getDefaultLevel($username);
$user->email = $this->getDefaultEmail($username);
return $user;
}
public function getUser(string $username) : ?User
{
// Cache results for performance as getting user details in
// most authentication types is expensive.
static $users = array();
// Use array_key_exists() rather than isset() in case the value is NULL
if (!array_key_exists($username, $users))
{
// Check to see if this is the current user. If it is, then we
// can save ourselves a potentially expensive operation.
// But we can only do this if we are not being called by getCurrentUser(), which
// some session schemes do, as otherwise we'll end up with an infinite recursion.
// TODO: is there a better way of handling this??
$backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
$backtrace_functions = array_column($backtrace, 'function');
if (!in_array('getCurrentUser', $backtrace_functions))
{
$mrbs_user = session()->getCurrentUser();
}
if (isset($mrbs_user) && ($mrbs_user->username === $username))
{
$user = $mrbs_user;
}
else
{
$user = $this->getUserFresh($username);
// Make sure we've got a sensible display name
if (isset($user) &&
(!isset($user->display_name) || ($user->display_name === '')))
{
$user->display_name = $user->username;
}
}
$users[$username] = $user;
}
return $users[$username];
}
/**
* Returns a username given an email address.
*
* @return string|null The username (or the first found if two or more users share the same email address), or
* NULL if no user is found.
*/
public function getUsernameByEmail(string $email) : ?string
{
global $mail_settings;
// Default: return the email address, unless we're constructing email
// addresses by adding a domain name onto the username. In which case,
// strip off the domain name and then add on any necessary suffix.
// This should be the inverse of getDefaultEmail().
$result = $email;
if (isset($mail_settings['domain']) && ($mail_settings['domain'] !== ''))
{
$at_domain = '@' . self::trimDomain($mail_settings['domain']);
if (str_ends_with($email, $at_domain))
{
// Strip the @domain
$result = str_replace($at_domain, '', $result);
// And add on the suffix if there is one
if (isset($mail_settings['username_suffix']))
{
$result .= $mail_settings['username_suffix'];
}
}
}
return $result;
}
public function getDisplayName(?string $username) : ?string
{
global $get_display_names_all_at_once;
static $display_names = null; // Cache for performance
// Easy case 1: $username is null
if (!isset($username))
{
return null;
}
// Easy case 2: it's the current user
$mrbs_user = session()->getCurrentUser();
if (isset($mrbs_user) && ($mrbs_user->username === $username))
{
return $mrbs_user->display_name;
}
// If we can (and want to) then get all the usernames at the same time. It's
// much faster than getting them one at a time when they are stored externally.
// Check to see if $display_names is set before getting the usernames, so that
// if getUsernames returns false we don't keep on trying it for every username
// (the else block will set $display_names).
if ($get_display_names_all_at_once &&
method_exists($this, 'getUsernames') &&
!isset($display_names) &&
(false !== ($usernames = $this->getUsernames())))
{
$display_names = array_column($usernames, 'display_name', 'username');
}
// Otherwise just get them one at a time
else
{
if (!isset($display_names[$username]))
{
$user = $this->getUser($username);
$display_names[$username] = (isset($user)) ? $user->display_name : $username;
}
}
if (isset($display_names[$username]) && ($display_names[$username] !== ''))
{
return $display_names[$username];
}
return $username;
}
/**
* Checks whether the authentication type allows the creation of new users.
*
* This will normally return false if users are managed elsewhere (e.g. on
* an external database, or on an LDAP server).
*/
public function canCreateUsers() : bool
{
return false;
}
/**
* Checks whether validation of a user by email address is possible and allowed.
*/
public function canValidateByEmail() : bool
{
return false;
}
/**
* Checks whether validation of a user by username is possible and allowed.
*/
public function canValidateByUsername() : bool
{
return true;
}
/**
* Checks whether the method has a password reset facility
*/
public function canResetPassword() : bool
{
return false;
}
/**
* Checks whether the password can be reset by supplying an email address
*/
public function canResetByEmail() : bool
{
return false;
}
/**
* Validates that the password conforms to the password policy.
*
* Ideally this function should also be matched by client-side
* validation, but unfortunately JavaScript's native support for Unicode
* pattern matching is very limited. Would need to be implemented using
* an add-in library.
*/
public function validatePassword(
#[\SensitiveParameter]
string $password) : bool
{
global $pwd_policy;
if (isset($pwd_policy))
{
// Set up regular expressions. Use p{Ll} instead of [a-z] etc.
// to make sure accented characters are included
$pattern = array('alpha' => '/\p{L}/',
'lower' => '/\p{Ll}/',
'upper' => '/\p{Lu}/',
'numeric' => '/\p{N}/',
'special' => '/[^\p{L}|\p{N}]/');
// Check for conformance to each rule
foreach($pwd_policy as $rule => $value)
{
switch($rule)
{
case 'length':
if (mb_strlen($password) < $value)
{
return false;
}
break;
default:
// turn on Unicode matching
$pattern[$rule] .= 'u';
$n = preg_match_all($pattern[$rule], $password, $matches);
if (($n === false) || ($n < $value))
{
return false;
}
break;
}
}
}
// Everything is OK
return true;
}
/**
* Returns an array of registrants' display names
*
* @return string[]
*/
public function getRegistrantsDisplayNames (array $entry, bool $with_registered_by=false, bool $with_registrant_username=false) : array
{
$display_names = array();
// Only bother getting the names if we don't already know how many there are,
// or if we know there are definitely some
if (!isset($entry['n_registered']) || ($entry['n_registered'] > 0))
{
$display_names = $this->getRegistrantsDisplayNamesUnsorted($entry['id'], $with_registered_by, $with_registrant_username);
usort($display_names, 'MRBS\compare_display_names');
}
return $display_names;
}
/**
* @return string[]
*/
protected function getRegistrantsDisplayNamesUnsorted(int $id, bool $with_registered_by, bool $with_registrant_username) : array
{
$display_names = array();
$registrants = get_registrants($id, false);
foreach ($registrants as $registrant)
{
$display_name = $this->getDisplayName($registrant['username']);
// Add in the name of the person who registered this user, if required and if different.
if ($with_registered_by &&
isset($registrant['create_by']) &&
($registrant['create_by'] !== $registrant['username']))
{
if ($with_registrant_username)
{
$display_names[] = get_vocab("registrant_username_and_registered_by",
$registrant['username'],
$display_name,
$this->getDisplayName($registrant['create_by']));
}
else
{
$display_names[] = get_vocab("registrant_registered_by",
$display_name,
$this->getDisplayName($registrant['create_by']));
}
}
else
{
$display_names[] = ($with_registrant_username) ? format_compound_name($registrant['username'], $display_name) : $display_name;
}
}
return $display_names;
}
/**
* Gets the level from the $auth['admin'] array in the config file
*/
protected function getDefaultLevel(?string $username) : int
{
global $auth, $max_level;
// User not logged in, user level '0'
if(!isset($username))
{
return 0;
}
// Check whether the user is an admin; if not they are level 1.
return (isset($auth['admin']) && in_arrayi($username, $auth['admin'])) ? $max_level : 1;
}
/**
* Gets the default email address using config file settings
*/
protected function getDefaultEmail(?string $username) : string
{
global $mail_settings;
if (!isset($username) || $username === '')
{
return '';
}
$email = $username;
// Remove the suffix, if there is one
if (isset($mail_settings['username_suffix']) && ($mail_settings['username_suffix'] !== ''))
{
$suffix = $mail_settings['username_suffix'];
if (substr($email, -strlen($suffix)) === $suffix)
{
$email = substr($email, 0, -strlen($suffix));
}
}
// Add on the domain, if there is one
if (isset($mail_settings['domain']) && ($mail_settings['domain'] !== ''))
{
$email .= '@' . self::trimDomain($mail_settings['domain']);
}
return $email;
}
/**
* Trim any leading '@' character.
*
* Older versions of MRBS required the '@' character to be included in $mail_settings['domain'],
* and we still allow this for backwards compatibility.
*/
private static function trimDomain(string $domain) : string
{
return ltrim($domain, '@');
}
/**
* Callback function for comparing two users.
*
* Compares first by 'display_name' and then by 'username'.
*
* @param array{username: string, display_name: string} $user1
* @param array{username: string, display_name: string} $user2
*/
private static function compareUsers(array $user1, array $user2) : int
{
$display_name1 = get_sortable_name($user1['display_name']);
$display_name2 = get_sortable_name($user2['display_name']);
// Provide fallbacks just in case the display names are NULL or empty
$display_name1 = (isset($display_name1) && ($display_name1 !== '')) ? $display_name1 : $user1['username'];
$display_name2 = (isset($display_name2) && ($display_name2 !== '')) ? $display_name2 : $user2['username'];
$collator = new \Collator(Language::getInstance()->getWebLocale());
$collator->setStrength(\Collator::SECONDARY); // Case-insensitive, but accent-sensitive
$display_name_comparison = $collator->compare($display_name1, $display_name2);
if ($display_name_comparison === 0)
{
return $collator->compare($user1['username'], $user2['username']);
}
return $display_name_comparison;
}
/**
* Sorts an array of users indexed by 'username' and 'display_name', eg the
* output of getUsernames().
*
* Sorts by display_name then username.
*
* @param array{array{username: string, display_name: string}} $users
* @return void
*/
protected static function sortUsers(array &$users) : void
{
usort($users, [__CLASS__, 'compareUsers']);
}
/**
* Check we've got the right session scheme for the type.
*
* To be called for those authentication types which require the same session scheme.
*
* @return void|never
*/
protected function checkSessionMatchesType()
{
global $auth;
if ($auth['session'] !== $auth['type'])
{
$class = get_called_class();
$message = "MRBS configuration error: $class needs \$auth['session'] set to '" . $auth['type'] . "'";
die($message);
}
}
/**
* Writes the debug message to the error log together with the calling method and line number.
* It assumes that it has been called by a debug method.
*/
protected static function logDebugMessage(string $message) : void
{
// Need to go three levels back to get the real calling method.
list( , $called, $caller) = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
error_log(
"[MRBS DEBUG] " .
$caller['class'] . $caller['type'] . $caller['function'] . '(' . $called['line'] . ')' .
": $message"
);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace MRBS\Auth;
/**
* Authentication scheme that uses an Apache "auth basic" password file for user authentication.
*
* To use this authentication scheme, set the following things in config.inc.php:
*
* $auth["type"] = "auth_basic";
* $auth["auth_basic"]["passwd_file] = "/etc/httpd/htpasswd"; // Example
* $auth["auth_basic"]["mode"] = "des"; // The mode of encryption used in
* // the file. Must be one of:
* // 'des', 'sha' or 'md5'.
*
* Then, you may configure admin users:
*
* $auth["admin"][] = "username1";
* $auth["admin"][] = "username2";
*/
class AuthAuthBasic extends Auth
{
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
global $auth;
// Check if we do not have a username/password
if(!isset($user) || !isset($pass))
{
return false;
}
if (!isset($auth["auth_basic"]["passwd_file"]))
{
error_log("auth_basic: passwd file not specified");
return false;
}
if (!isset($auth["auth_basic"]["mode"]))
{
error_log("auth_basic: mode not specified");
return false;
}
require_once "File/Passwd/Authbasic.php";
$f = &File_Passwd::factory('Authbasic');
$f->setFile($auth["auth_basic"]["passwd_file"]);
$f->setMode($auth["auth_basic"]["mode"]);
$f->load();
if ($f->verifyPasswd($user, $pass) === true)
{
return $user;
}
return false;
}
}
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace MRBS\Auth;
use MRBS\Intl\Locale;
use MRBS\Language;
use MRBS\User;
use phpCAS;
use function MRBS\is_https;
class AuthCas extends Auth
{
public function __construct()
{
$this->checkSessionMatchesType();
$this->init();
}
// Initialise CAS
public function init() : void
{
global $auth, $server;
static $init_complete = false;
if ($init_complete)
{
return;
}
// We still use a couple of deprecated features - the phpCAS autoloader instead of composer and
// phpCAS::setDebug() instead of phpCAS::setLogger() - so temporarily disable deprecation errors
// and restore them later.
// TODO: Fix this
$old_level = error_reporting();
error_reporting($old_level & ~E_USER_DEPRECATED);
if ($auth['cas']['debug'])
{
phpCAS::setDebug();
phpCAS::setVerbose(true);
}
// Form a client service name if we haven't been given one
if (isset($auth['cas']['client_service_name']))
{
$client_service_name = $auth['cas']['client_service_name'];
}
else
{
$client_service_name = ((is_https()) ? 'https' : 'http') . '://' . $server['HTTP_HOST'];
$client_service_name .= (isset($server['SERVER_PORT'])) ? ':' . $server['SERVER_PORT'] : '';
}
phpCAS::client(CAS_VERSION_2_0,
$auth['cas']['host'],
(int)$auth['cas']['port'],
$auth['cas']['context'],
$client_service_name
);
// Restore the original level of error reporting now that we've made the first
// call to phpCAS.
error_reporting($old_level);
if ($auth['cas']['no_server_validation'])
{
phpCAS::setNoCasServerValidation();
}
elseif (!empty($auth['cas']['ca_cert_path']))
{
phpCAS::setCasServerCACert($auth['cas']['ca_cert_path']);
}
// Handle incoming logout requests
if (empty($auth['cas']['real_hosts']))
{
phpCAS::handleLogoutRequests();
}
else
{
phpCAS::handleLogoutRequests(true, $auth['cas']['real_hosts']);
}
// Set the language
// (The language constants will only be defined after the first call to a phpCAS method)
$cas_lang_map = array(
'ca' => PHPCAS_LANG_CATALAN,
'de' => PHPCAS_LANG_GERMAN,
'el' => PHPCAS_LANG_GREEK,
'en' => PHPCAS_LANG_ENGLISH,
'es' => PHPCAS_LANG_SPANISH,
'fr' => PHPCAS_LANG_FRENCH,
'gl' => PHPCAS_LANG_GALEGO,
'ja' => PHPCAS_LANG_JAPANESE,
'pt' => PHPCAS_LANG_PORTUGUESE,
'zh' => PHPCAS_LANG_CHINESE_SIMPLIFIED
);
$locale = Locale::parseLocale(Language::getInstance()->getWebLang());
if (isset($cas_lang_map[$locale['language']]))
{
phpCAS::setLang($cas_lang_map[$locale['language']]);
}
// Use our own Guzzle request implementation in case curl is not available.
$client = phpCAS::getCasClient();
$client->setRequestImplementation(__NAMESPACE__ . '\AuthCasGuzzleRequest');
$init_complete = true;
}
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
return (phpCAS::isAuthenticated()) ? $user : false;
}
protected function getUserFresh(string $username) : ?User
{
$user = new User($username);
$user->level = $this->getLevel($username);
$user->email = $this->getDefaultEmail($username);
return $user;
}
protected function getLevel(string $username) : int
{
global $auth;
// User not logged in, user level '0'
if (!isset($username))
{
return 0;
}
// If the attribute filters are set, check to see whether the user has
// the required attributes
if (isset($auth['cas']['filter_attr_name']) &&
isset($auth['cas']['filter_attr_values']))
{
// getAttribute can return either a scalar or an array
$actual_values = phpCAS::getAttribute($auth['cas']['filter_attr_name']);
if (!is_array($actual_values))
{
$actual_values = array($actual_values);
}
// $auth['cas']['filter_attr_values'] can be either a scalar or an array
$required_values = $auth['cas']['filter_attr_values'];
if (!is_array($required_values))
{
$required_values = array($required_values);
}
// If the user doesn't have at least one of the required attributes they are level 0
if (count(array_intersect($actual_values, $required_values)) === 0)
{
return 0;
}
}
// Check the config file to see whether the user is an admin
return $this->getDefaultLevel($username);
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace MRBS\Auth;
use CAS_OutOfSequenceException;
use CAS_Request_AbstractRequest;
use CAS_Request_RequestInterface;
use GuzzleHttp\Client;
/**
* A CAS request class that uses Guzzle to make the request, rather than the default curl. Guzzle will
* use curl if possible but falls back to the native PHP functions if curl is not available.
*/
class AuthCasGuzzleRequest extends CAS_Request_AbstractRequest implements CAS_Request_RequestInterface
{
private $client;
private $options = [
'headers' => []
];
private $response_status_code;
private $sent = false; // $_sent is private in CAS_Request_AbstractRequest
public function __construct()
{
$this->client = new Client();
}
/**
* @see CAS_Request_RequestInterface::addCookie()
*/
public function addCookie($name, $value) : void
{
// TODO: Implement addCookie() method.
throw new \Exception('Not yet implemented');
}
/**
* @see CAS_Request_RequestInterface::addCookies()
*/
public function addCookies(array $cookies) : void
{
// TODO: Implement addCookies() method.
throw new \Exception('Not yet implemented');
}
/**
* @see CAS_Request_RequestInterface::addHeader()
*/
public function addHeader($header) : void
{
if ($this->sent)
{
throw new CAS_OutOfSequenceException('Request has already been sent cannot '.__METHOD__);
}
if (preg_match('/^([^:]+):\s*(.+)$/', $header, $matches))
{
$this->options['headers'][$matches[1]] = $matches[2];
}
}
/**
* @see CAS_Request_RequestInterface::addHeaders()
*/
public function addHeaders(array $headers) : void
{
if ($this->sent)
{
throw new CAS_OutOfSequenceException('Request has already been sent cannot '.__METHOD__);
}
foreach ($headers as $header)
{
$this->addHeader($header);
}
}
/**
* @see CAS_Request_RequestInterface::setPostBody()
*/
public function setPostBody($body) : void
{
parent::setPostBody($body);
parse_str($body, $this->options['form_params']);
}
/**
* @see CAS_Request_RequestInterface::setSslCaCert()
*/
public function setSslCaCert($caCertPath, $validate_cn = true) : void
{
parent::setSslCaCert($caCertPath, $validate_cn);
$this->options['verify'] = ($validate_cn) ? $caCertPath : false;
}
/**
* @see CAS_Request_RequestInterface::getResponseStatusCode()
*/
public function getResponseStatusCode() : int
{
if (!$this->sent)
{
throw new CAS_OutOfSequenceException('Request has not been sent yet. Cannot '.__METHOD__);
}
return $this->response_status_code;
}
/**
* @see CAS_Request_AbstractRequest::sendRequest()
*/
protected function sendRequest() : bool
{
try
{
$method = ($this->isPost) ? 'POST' : 'GET';
$this->sent = true;
$response = $this->client->request($method, $this->url, $this->options);
$this->response_status_code = $response->getStatusCode();
$this->storeResponseBody($response->getBody()->getContents());
foreach ($response->getHeaders() as $name => $values)
{
$this->storeResponseHeader(mb_strtolower($name) . ': ' . implode(', ', $values));
}
return true;
}
catch (\Exception $e)
{
$this->storeErrorMessage( $e->getMessage());
return false;
}
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace MRBS\Auth;
class AuthConfig extends Auth
{
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
global $auth;
// Check if we do not have a username/password
if(!isset($user) || !isset($pass) || strlen($pass)==0)
{
return false;
}
if ((isset($auth["user"][$user]) &&
($auth["user"][$user] == $pass)
) ||
(isset($auth["user"][mb_strtolower($user)]) &&
($auth["user"][mb_strtolower($user)] == $pass)
))
{
return $user; // User validated
}
return false; // User unknown or password invalid
}
// Return an array of users, indexed by 'username' and 'display_name'
public function getUsernames() : array
{
global $auth;
$result = array();
foreach ($auth['user'] as $user => $password)
{
$result[] = array('username' => $user,
'display_name' => $user);
}
// Need to sort the users
self::sortUsers($result);
return $result;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace MRBS\Auth;
/**
* Authentication scheme that uses a password hash file as the source for user authentication.
*
* This supports any password hash format that your installation of PHP supports.
*
* To use this authentication scheme, set the following things in config.inc.php:
*
* $auth["type"] = "crypt";
* $auth["crypt"]["passwd_file] = "/etc/httpd/mrbs_passwd";
*
* Then, you may configure admin users:
*
* $auth["admin"][] = "username1";
* $auth["admin"][] = "username2";
*/
class AuthCrypt extends Auth
{
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
global $auth;
// Check if we do not have a username/password
if(!isset($user) || !isset($pass))
{
return false;
}
if (!isset($auth["crypt"]["passwd_file"]))
{
error_log("auth_crypt: passwd file not specified");
return false;
}
$fh = fopen($auth["crypt"]["passwd_file"], "r");
if (!$fh)
{
error_log("auth_crypt: couldn't open passwd file\n");
return false;
}
$ret = false; // Default to failure
while ($line = fgets($fh))
{
if (preg_match("/^\Q$user\E:(.*)/", $line, $matches))
{
if (password_verify($pass, $matches[1]))
{
$ret = $user; // Success!
}
}
}
fclose($fh);
return $ret;
}
}
+981
View File
@@ -0,0 +1,981 @@
<?php
declare(strict_types=1);
namespace MRBS\Auth;
use MRBS\DB\DB;
use MRBS\Language;
use MRBS\MailQueue;
use MRBS\User;
use PHPMailer\PHPMailer\PHPMailer;
use function MRBS\_tbl;
use function MRBS\auth;
use function MRBS\db;
use function MRBS\format_compound_name;
use function MRBS\generate_token;
use function MRBS\get_vocab;
use function MRBS\multisite;
use function MRBS\parse_email;
use function MRBS\row_cast_columns;
use function MRBS\toTimeString;
use function MRBS\url_base;
class AuthDb extends AuthDbAbstract
{
// 等保整改:登录状态标记(供 SessionWithLogin 区分“锁定 / 密码错误 / 用户不存在”)
// @var bool
private $loginBlocked = false;
private $loginFailed = false;
public function __construct()
{
$this->db_table = _tbl('users');
$this->column_name_username = 'name';
$this->column_name_display_name = 'display_name';
}
/**
* @param string|null $user a username or email address
*/
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
// The string $user that the user logged on with could be either a username or
// an email address, or even possibly just the local part of an email address.
// So it's just possible that there is more than one user with this password and
// username | email address | local-part. If we get more than one, then we don't
// know which user it is, so we return false.
$valid_usernames = array();
if (($valid_username = $this->validateUsername($user, $pass)) !== false)
{
$valid_usernames[] = $valid_username;
}
$valid_usernames = array_merge($valid_usernames, $this->validateEmail($user, $pass));
$valid_usernames = array_unique($valid_usernames);
if (count($valid_usernames) == 1)
{
$result = $valid_usernames[0];
// 登录成功:复位状态标记(避免残留锁定标记影响后续判断)
$this->loginBlocked = false;
$this->loginFailed = false;
// Update the database with this login, but don't change the timestamp
$now = time();
$sql = "UPDATE " . _tbl('users') . "
SET last_login=?, timestamp=timestamp
WHERE name=?";
$sql_params = array($now, $result);
$this->connection()->command($sql, $sql_params);
return $result;
}
else
{
return false;
}
}
protected function connection() : ?DB
{
return db();
}
/* validateUsername($user, $pass)
*
* Checks if the specified username/password pair are valid
*
* $user - The user name
* $pass - The password
*
* Returns:
* false - The pair are invalid or do not exist
* string - The validated username
*/
private function validateUsername(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
$sql_params = array();
// We use syntax_casesensitive_equals() rather than just '=' because '=' in MySQL
// permits trailing spacings, eg 'john' = 'john '. We could use LIKE, but that then
// permits wildcards, so we could use a combination of LIKE and '=' but that's a bit
// messy. We could use STRCMP, but that's MySQL only.
// Usernames are unique in the users table, so we only look for one.
// 等保整改:额外取出 failed_logins / locked_until 用于登录失败锁定
$sql = "SELECT password_hash, name, failed_logins, locked_until
FROM " . _tbl('users') . "
WHERE " . $this->connection()->syntax_casesensitive_equals('name', mb_strtolower($user), $sql_params) . "
LIMIT 1";
$res = $this->connection()->query($sql, $sql_params);
$row = $res->next_row_keyed();
// 复位状态标记(一次登录尝试至多设置其一)
$this->loginBlocked = false;
$this->loginFailed = false;
if (!isset($row['password_hash']))
{
// No user found with that name. 故意不计数、不区分提示,避免用户名枚举。
return false;
}
// 等保整改:账号临时锁定检查(连续失败达阈值后 locked_until > now)
if ((int)$row['locked_until'] > time())
{
$this->loginBlocked = true;
return false;
}
if (!$this->checkPassword($pass, $row['password_hash'], 'name', $row['name']))
{
$this->loginFailed = true;
$this->registerLoginFailure($row['name'], (int)$row['failed_logins']);
return false;
}
// 密码正确:清零失败计数并解除锁定
$this->clearLoginFailures($row['name']);
return $row['name'];
}
/* authValidateEmail($email, $pass)
*
* Checks if the specified email/password pair are valid
*
* $email - The email address
* $pass - The password
*
* Returns:
* array - An array of valid usernames, empty if none found
*/
private function validateEmail(
#[\SensitiveParameter]
string $email,
#[\SensitiveParameter]
string $pass) : array
{
$valid_usernames = array();
// Email addresses are not unique in the users table, so we need to find all of them.
$users = self::getUsersByEmail($email);
// Check all the users that have this email address and password hash.
// 等保整改:同一邮箱多账号时逐个做锁定检查 / 失败计数(封堵 email 登录通道的暴力破解)
foreach($users as $user)
{
if (isset($user['password_hash']))
{
if ((int)$user['locked_until'] > time())
{
$this->loginBlocked = true;
continue;
}
if ($this->checkPassword($pass, $user['password_hash'], 'email', $email))
{
$valid_usernames[] = $user['name'];
$this->clearLoginFailures($user['name']);
}
else
{
$this->loginFailed = true;
$this->registerLoginFailure($user['name'], (int)$user['failed_logins']);
}
}
}
return $valid_usernames;
}
// ===== 等保整改:登录失败锁定 / 口令有效期 / 自助改密 =====
// 上次登录尝试是否因账号锁定被拒
public function getLoginBlocked() : bool
{
return $this->loginBlocked;
}
// 上次登录尝试是否因密码错误被拒(区别于“用户不存在”)
public function getLoginFailed() : bool
{
return $this->loginFailed;
}
// 登录失败计数:达到阈值则设置 locked_until。
// 若账号曾因达到阈值被锁(现已到期解锁),从 1 重新计数,避免“永远差一次就锁”。
private function registerLoginFailure(string $username, int $current_failures) : void
{
global $login_lock_threshold, $login_lock_duration;
$threshold = $login_lock_threshold ?? 5;
$duration = $login_lock_duration ?? (15 * 60);
if ($current_failures >= $threshold)
{
$current_failures = 0;
}
$new_failures = $current_failures + 1;
$locked_until = ($new_failures >= $threshold) ? (time() + $duration) : 0;
$sql = "UPDATE " . _tbl('users') . "
SET failed_logins=?, locked_until=?
WHERE name=?";
$this->connection()->command($sql, array($new_failures, $locked_until, $username));
}
// 登录成功:清零失败计数与锁定时间
private function clearLoginFailures(string $username) : void
{
$sql = "UPDATE " . _tbl('users') . "
SET failed_logins=0, locked_until=0
WHERE name=?";
$this->connection()->command($sql, array($username));
}
// 口令是否需要强制修改(初始口令 / 已超过 90 天有效期)
public function needsPasswordChange(string $username) : bool
{
global $pwd_max_age;
$max_age = $pwd_max_age ?? (90 * 24 * 60 * 60);
$sql = "SELECT password_changed_at
FROM " . _tbl('users') . "
WHERE name=?
LIMIT 1";
$res = $this->connection()->query($sql, array($username));
$row = $res->next_row_keyed();
if (!isset($row))
{
return false;
}
$changed_at = (int)$row['password_changed_at'];
return ($changed_at == 0) || ((time() - $changed_at) > $max_age);
}
// 自助改密:更新口令并记录修改时间、清零失败计数并解除锁定
public function updatePassword(string $username, string $password) : void
{
$sql = "UPDATE " . _tbl('users') . "
SET password_hash=:password_hash,
password_changed_at=:password_changed_at,
failed_logins=0,
locked_until=0
WHERE name=:name";
$sql_params = array(
':password_hash' => password_hash($password, PASSWORD_DEFAULT),
':password_changed_at' => time(),
':name' => $username
);
$this->connection()->command($sql, $sql_params);
}
protected function getUserFresh(string $username) : ?User
{
$row = $this->getUserByUsername($username);
// The username doesn't exist - return NULL
if (!isset($row))
{
return null;
}
// The username does exist - return a User object
$user = new User($username);
// $user->level and $user->display_name will be set as part of this
foreach ($row as $key => $value)
{
if ($key == 'name')
{
// This has already been set as the 'username' property;
continue;
}
$user->$key = $value;
}
return $user;
}
// Return an array of all users
public function getUsers() : array
{
// Add in an extra column, last_updated, which is the SQL timestamp converted to a UNIX
// timestamp. We do the conversion in the SQL query so that it is converted using the
// same timezone that it was stored with.
$sql = "SELECT *, ". $this->connection()->syntax_timestamp_to_unix("timestamp") . " AS last_updated
FROM " . _tbl('users') . "
ORDER BY name";
$res = $this->connection()->query($sql);
$result = $res->all_rows_keyed();
foreach ($result as &$row)
{
row_cast_columns($row, 'users');
// Turn the last_updated column into an int (some MySQL drivers will return a string,
// and it won't have been caught by row_cast_columns() as it's a derived result).
$row['last_updated'] = intval($row['last_updated']);
}
return $result;
}
/**
*/
public function canCreateUsers() : bool
{
return true;
}
/**
*/
public function canValidateByEmail() : bool
{
return true;
}
/**
*/
public function canResetPassword() : bool
{
return true;
}
/**
*/
public function canResetByEmail() : bool
{
// We allow resetting by email, even if there are multiple users with the
// same email address.
return $this->canValidateByEmail();
}
public function requestPassword(?string $login) : bool
{
if (!isset($login) || ($login === ''))
{
return false;
}
// Get the possible users given this login, which could be a username or email address.
// However all the possible users must have the same email address, so check the email
// addresses at the same time.
$possible_users = array();
$user = $this->getUserByUsername($login);
// Users must have an email address otherwise we won't be able to mail a reset link
if (isset($user) && isset($user['email']) && ($user['email'] !== ''))
{
$possible_users[] = $user;
}
if ($this->canValidateByEmail())
{
$users = $this->getUsersByEmail($login);
if (!empty($users))
{
// Check that the email addresses are the same
if (!empty($possible_users) &&
(mb_strtolower($possible_users[0]['email']) !== mb_strtolower($login)))
{
return false;
}
foreach ($users as $user)
{
$possible_users[] = $user;
}
}
}
if (!empty($possible_users))
{
// Generate a key
$key = generate_token(32);
// Update the database
if ($this->setResetKey($possible_users, $key))
{
// Email the user
return $this->notifyUser($possible_users, $key);
}
}
return false;
}
public function resetPassword(
#[\SensitiveParameter]
?string $username,
?string $key,
#[\SensitiveParameter]
?string $password) : bool
{
// Check that we've got a password and we're allowed to reset the password
if (!isset($password) || !auth()->isValidReset($username, $key))
{
return false;
}
// Set the new password and clear the reset key
// 等保整改:记录口令修改时间(password_changed_at),供 90 天有效期计算
$sql = "UPDATE " . _tbl('users') . "
SET password_hash=:password_hash,
password_changed_at=:password_changed_at,
reset_key_hash=NULL,
reset_key_expiry=0
WHERE name=:name"; // PostgreSQL does not support LIMIT with UPDATE
$sql_params = array(
':password_hash' => password_hash($password, PASSWORD_DEFAULT),
':password_changed_at' => time(),
':name' => $username
);
$this->connection()->command($sql, $sql_params);
return true;
}
public function isValidReset(?string $user, ?string $key) : bool
{
if (!isset($user) || !isset($key) || ($user === '') || ($key === ''))
{
return false;
}
$sql = "SELECT reset_key_hash, reset_key_expiry
FROM " . _tbl('users') . "
WHERE name=:name
LIMIT 1";
$sql_params = array(':name' => $user);
$res = $this->connection()->query($sql,$sql_params);
// Check we've found a row
if ($res->count() == 0)
{
return false;
}
$row = $res->next_row_keyed();
// Check that the reset hasn't expired
if (time() > $row['reset_key_expiry'])
{
return false;
}
// Check we've got the correct key
return password_verify($key, $row['reset_key_hash']);
}
// Returns an unsorted array of registrants display names
protected function getRegistrantsDisplayNamesUnsortedWithout(int $id, bool $with_registrant_username) : array
{
// For the 'db' auth type we can improve performance by doing a single query
// on the participants table joined with the users table. (Actually it's two
// queries in a UNION: one getting the rows where there isn't an entry in the
// users table and another the rows where there is.)
$sql = "SELECT P.username as username,
P.username as display_name
FROM " . _tbl('participants') . " P
LEFT JOIN " . _tbl('users') . " U
ON P.username=U.name
WHERE P.entry_id=:entry_id
AND (U.display_name IS NULL OR U.display_name='')
UNION
SELECT U.name as username,
U.display_name as display_name
FROM " . _tbl('participants') . " P
LEFT JOIN " . _tbl('users') . " U
ON P.username=U.name
WHERE P.entry_id=:entry_id
AND U.display_name IS NOT NULL AND U.display_name!=''";
$result = array();
$res = $this->connection()->query($sql, array(':entry_id' => $id));
while (false !== ($row = $res->next_row_keyed()))
{
$result[] = ($with_registrant_username) ? format_compound_name($row['username'], $row['display_name']) : $row['display_name'];
}
return $result;
}
// Returns an unsorted array of registrants display names, including, if
// different, the display name of the person that registered them.
protected function getRegistrantsDisplayNamesUnsortedWith(int $id, bool $with_registrant_username) : array
{
// For the 'db' auth type we can improve performance by doing a single query
// on the participants table joined with the users table. (Actually it's four
// queries in a UNION: one getting the rows where there isn't an entry in the
// users table and another the rows where there is, etc. for both the registrant
// and the person that registered them.)
$sql = "SELECT P.username as registrant_username,
P.username as registrant_display_name,
P.create_by as create_by_username,
P.create_by as create_by_display_name
FROM " . _tbl('participants') . " P
LEFT JOIN " . _tbl('users') . " U1
ON P.username=U1.name
LEFT JOIN " . _tbl('users') . " U2
ON P.create_by=U2.name
WHERE P.entry_id=:entry_id
AND (U1.display_name IS NULL OR U1.display_name='')
AND (U2.display_name IS NULL OR U2.display_name='')
UNION
SELECT P.username as registrant_username,
P.username as registrant_display_name,
P.create_by as create_by_username,
U2.display_name as registrant_display_name
FROM " . _tbl('participants') . " P
LEFT JOIN " . _tbl('users') . " U1
ON P.username=U1.name
LEFT JOIN " . _tbl('users') . " U2
ON P.create_by=U2.name
WHERE P.entry_id=:entry_id
AND (U1.display_name IS NULL OR U1.display_name='')
AND U2.display_name IS NOT NULL AND U2.display_name!=''
UNION
SELECT P.username as registrant_username,
U1.display_name as registrant_display_name,
P.create_by as create_by_username,
P.create_by as registrant_display_name
FROM " . _tbl('participants') . " P
LEFT JOIN " . _tbl('users') . " U1
ON P.username=U1.name
LEFT JOIN " . _tbl('users') . " U2
ON P.create_by=U2.name
WHERE P.entry_id=:entry_id
AND U1.display_name IS NOT NULL AND U1.display_name!=''
AND (U2.display_name IS NULL OR U2.display_name='')
UNION
SELECT P.username as registrant_username,
U1.display_name as registrant_display_name,
P.create_by as create_by_username,
U2.display_name as registrant_display_name
FROM " . _tbl('participants') . " P
LEFT JOIN " . _tbl('users') . " U1
ON P.username=U1.name
LEFT JOIN " . _tbl('users') . " U2
ON P.create_by=U2.name
WHERE P.entry_id=:entry_id
AND U1.display_name IS NOT NULL AND U1.display_name!=''
AND U2.display_name IS NOT NULL AND U2.display_name!=''";
$result = array();
$res = $this->connection()->query($sql, array(':entry_id' => $id));
while (false !== ($row = $res->next_row_keyed()))
{
if ($row['registrant_username'] === $row['create_by_username'])
{
if ($with_registrant_username)
{
$result[] = format_compound_name($row['registrant_username'], $row['registrant_display_name']);
}
else
{
$result[] = $row['registrant_display_name'];
}
}
else
{
if ($with_registrant_username && ($row['registrant_username'] !== $row['registrant_display_name']))
{
$result[] = get_vocab('registrant_username_and_registered_by',
$row['registrant_username'],
$row['registrant_display_name'],
$row['create_by_display_name']);
}
else
{
$result[] = get_vocab('registrant_registered_by',
$row['registrant_display_name'],
$row['create_by_display_name']);
}
}
}
return $result;
}
/**
* @return string[]
*/
protected function getRegistrantsDisplayNamesUnsorted(int $id, bool $with_registered_by, $with_registrant_username) : array
{
if ($with_registered_by)
{
return $this->getRegistrantsDisplayNamesUnsortedWith($id, $with_registrant_username);
}
else
{
return $this->getRegistrantsDisplayNamesUnsortedWithout($id, $with_registrant_username);
}
}
private function notifyUser(array $users, string $key) : bool
{
global $auth, $mail_settings;
if (empty($users) || !isset($users[0]['email']) || ($users[0]['email'] === ''))
{
return false;
}
$expiry_time = $auth['db']['reset_key_expiry'];
toTimeString($expiry_time, $expiry_units, true, 'hours');
$addresses = array(
'from' => $mail_settings['from']
);
// Add the To address, using the display name if possible (ie if it exists and there's
// only one user).
// Also get a name to use in the message body
if ((count($users) == 1) &&
isset($users[0]['display_name']) &&
($users[0]['display_name'] !== ''))
{
$mailer = new PHPMailer();
$mailer->CharSet = Language::MAIL_CHARSET;
// Note that addrFormat() returns a MIME-encoded address
$addresses['to'] = $mailer->addrFormat(array($users[0]['email'], $users[0]['display_name']));
$name = $users[0]['display_name'];
}
else
{
$addresses['to'] = $users[0]['email'];
// If there's only one user we can use the username, otherwise we have to use the
// email address which is the same for all users.
$name = (count($users) == 1) ? $users[0]['name'] : $users[0]['email'];
}
$subject = get_vocab('password_reset_subject');
$body = '<p>';
$body .= get_vocab('password_reset_body', intval($expiry_time), $expiry_units, $name);
$body .= "</p>\n";
// Construct and add in the link
$usernames = array();
foreach ($users as $user)
{
$usernames[] = $user['name'];
}
$usernames = array_unique($usernames);
$vars = array(
'action' => 'reset',
'usernames' => $usernames,
'key' => $key
);
$query = http_build_query($vars, '', '&');
$href = url_base() . multisite("reset_password.php?$query");
$body .= "<p><a href=\"$href\">" . get_vocab('reset_password') . "</a>.</p>";
MailQueue::add(
$addresses,
$subject,
strip_tags($body),
$body,
null,
Language::MAIL_CHARSET
);
return true;
}
private function setResetKey(array $users, string $key) : bool
{
global $auth;
if (empty($users))
{
return false;
}
$ids = array();
foreach($users as $user)
{
// Use intval to make sure the string is safe for the SQL query
$ids[] = intval($user['id']);
}
$sql = "UPDATE " . _tbl('users') . "
SET reset_key_hash=:reset_key_hash,
reset_key_expiry=:reset_key_expiry
WHERE id IN (" . implode(',', $ids) . ")";
$sql_params = array(
':reset_key_hash' => password_hash($key, PASSWORD_DEFAULT),
':reset_key_expiry' => time() + $auth['db']['reset_key_expiry']
);
$this->connection()->command($sql, $sql_params);
return true;
}
private function getUserByUsername(string $username) : ?array
{
$sql = "SELECT *
FROM " . _tbl('users') . "
WHERE name=:name
LIMIT 1";
$result = $this->connection()->query($sql, array(':name' => $username));
// The username doesn't exist - return NULL
if ($result->count() === 0)
{
return null;
}
return $result->next_row_keyed();
}
public function getUserByUserId(int $id) : ?User
{
$sql = "SELECT *
FROM " . _tbl('users') . "
WHERE id=:id
LIMIT 1";
$result = $this->connection()->query($sql, array(':id' => $id));
// The username doesn't exist - return NULL
if ($result->count() === 0)
{
return null;
}
// The username does exist - return a User object
$user = new User();
$row = $result->next_row_keyed();
// $user->level and $user->display_name will be set as part of this
foreach ($row as $key => $value)
{
if ($key == 'name')
{
$user->username = $value;
}
$user->$key = $value;
}
return $user;
}
public function getUsernameByEmail(string $email) : ?string
{
$sql = "SELECT name
FROM " . _tbl('users') . "
WHERE email=?";
$res = $this->connection()->query($sql, array($email));
if ($res->count() == 0)
{
return null;
}
if ($res->count() > 1)
{
// Could maybe do something better here
trigger_error("Email address not unique", E_USER_NOTICE);
}
$row = $res->next_row_keyed();
return $row['name'];
}
// Returns an array of rows for all users with the email address $email.
// Assumes that email addresses are case insensitive.
// Allows equivalent Gmail addresses, ie ignores dots in the local part and
// treats gmail.com and googlemail.com as equivalent domains.
private function getUsersByEmail(string $email) : array
{
global $auth;
$result = array();
// For the moment we will assume that email addresses are case-insensitive. Whilst it is true
// on most systems, it isn't always true. The domain is case-insensitive but the local-part can
// be case-sensitive. But before we can take account of this, the email addresses in the database
// need to be normalised so that all the domain names are stored in lower case. Then it will be
// possible to do a case-sensitive comparison.
if (mb_strpos($email, '@') === false)
{
if (!empty($auth['allow_local_part_email']))
{
// We're just checking the local-part of the email address
$sql_params = array($email);
$condition = "LOWER(?)=LOWER(" . $this->connection()->syntax_simple_split('email', '@', 1, $sql_params) .")";
}
else
{
return $result;
}
}
else
{
$address = parse_email($email);
// Invalid email address
if ($address === false)
{
return $result;
}
// Special case for Gmail addresses: ignore dots in the local part and treat gmail.com and
// googlemail.com as equivalent domains.
elseif (in_array(mb_strtolower($address['domain']), array('gmail.com', 'googlemail.com')))
{
$sql_params = array(str_replace('.', '', $address['local']));
$sql_params[] = $sql_params[0];
$condition = "(LOWER(?) = REPLACE(TRIM(TRAILING '@gmail.com' FROM LOWER(email)), '.', '')) OR " .
"(LOWER(?) = REPLACE(TRIM(TRAILING '@googlemail.com' FROM LOWER(email)), '.', ''))";
}
// Everything else: check the complete email address
else
{
$sql_params = array($email);
$condition = "LOWER(?)=LOWER(email)";
}
}
$sql = "SELECT *
FROM " . _tbl('users') . "
WHERE $condition";
$res = $this->connection()->query($sql, $sql_params);
while (false !== ($row = $res->next_row_keyed()))
{
$result[] = $row;
}
return $result;
}
private function rehash(
#[\SensitiveParameter]
string $password,
string $column_name,
string $column_value) : void
{
$sql_params = array(password_hash($password, PASSWORD_DEFAULT));
switch ($column_name)
{
case 'name':
$condition = $this->connection()->syntax_casesensitive_equals($column_name, mb_strtolower($column_value), $sql_params);
break;
case 'email':
// For the moment we will assume that email addresses are case insensitive. Whilst it is true
// on most systems, it isn't always true. The domain is case insensitive but the local-part can
// be case sensitive. But before we can take account of this, the email addresses in the database
// need to be normalised so that all the domain names are stored in lower case. Then it will be possible
// to do a case sensitive comparison.
$sql_params[] = $column_value;
$condition = "LOWER($column_name)=LOWER(?)";
break;
default:
trigger_error("Unsupported column name '$column_name'.", E_USER_NOTICE);
return;
break;
}
$sql = "UPDATE " . _tbl('users') . "
SET password_hash=?
WHERE $condition";
$this->connection()->command($sql, $sql_params);
}
// Checks $password against $password_hash for the row in the user table
// where $column_name=$column_value. Typically $column_name will be either
// 'name' or 'email'.
// Returns a boolean: true if they match, otherwise false.
private function checkPassword(
#[\SensitiveParameter]
string $password,
string $password_hash,
string $column_name,
string $column_value) : bool
{
$result = false;
$do_rehash = false;
/* If the hash starts '$' it's a PHP password hash */
if (substr($password_hash, 0, 1) == '$')
{
if (password_verify($password, $password_hash))
{
$result = true;
if (password_needs_rehash($password_hash, PASSWORD_DEFAULT))
{
$do_rehash = true;
}
}
}
/* Otherwise it's a legacy MD5 hash */
else
{
if (md5($password) == $password_hash)
{
$result = true;
$do_rehash = true;
}
}
if ($do_rehash)
{
$this->rehash($password, $column_name, $column_value);
}
return $result;
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace MRBS\Auth;
use MRBS\DB\DB;
abstract class AuthDbAbstract extends Auth
{
protected $db_table;
protected $column_name_username;
protected $column_name_display_name;
/**
* Returns a database connection.
*
* The connection isn't established in the constructor, but here only when it's really necessary, as it can be
* expensive establishing a connection when the server is remote. For example, `method_exists(auth(), 'method')`
* will end up calling the constructor, but a connection isn't needed just for method_exists().
*/
abstract protected function connection() : ?DB;
/**
* Return an array of users, indexed by 'username' and 'display_name'.
*/
public function getUsernames() : array
{
if (isset($this->column_name_display_name) && ($this->column_name_display_name !== ''))
{
$display_name_column = $this->column_name_display_name;
}
else
{
$display_name_column = $this->column_name_username;
}
$quoted_column_name_display_name = $this->connection()->quote($display_name_column);
$quoted_column_name_username = $this->connection()->quote($this->column_name_username);
$sql = "SELECT $quoted_column_name_username AS username,
CASE
WHEN $quoted_column_name_display_name IS NOT NULL AND $quoted_column_name_display_name!='' THEN $quoted_column_name_display_name
ELSE $quoted_column_name_username
END AS display_name
FROM " . $this->connection()->quote($this->db_table) . "
WHERE $quoted_column_name_username IS NOT NULL
ORDER BY display_name";
$res = $this->connection()->query($sql);
$users = $res->all_rows_keyed();
// Although the users may already be sorted, we sort them again because MRBS
// offers an option for sorting by first or last name.
self::sortUsers($users);
return $users;
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace MRBS\Auth;
use MRBS\DB\DB;
use MRBS\DB\DBExternalException;
use MRBS\DB\DBFactory;
use MRBS\User;
use ValueError;
class AuthDbExt extends AuthDbAbstract
{
protected $password_format;
protected $column_name_password;
protected $column_name_email;
protected $column_name_level;
public function __construct()
{
global $auth;
// Take our own copies of the settings
$vars = array(
'db_table',
'password_format',
'column_name_username',
'column_name_display_name',
'column_name_password',
'column_name_email',
'column_name_level'
);
foreach ($vars as $var)
{
$this->$var = $auth['db_ext'][$var] ?? null;
}
// Backwards compatibility setting
if (!isset($this->password_format) && !empty($auth['db_ext']['use_md5_passwords']))
{
$this->password_format = 'md5';
}
}
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
// syntax_casesensitive_equals() modifies our SQL params array for us. We need an exact match -
// MySQL allows trailing spaces when using an '=' comparison, eg 'john' = 'john '
$sql_params = array();
$query = "SELECT " . $this->connection()->quote($this->column_name_password) .
"FROM " . $this->connection()->quote($this->db_table) .
"WHERE " . $this->connection()->syntax_casesensitive_equals($this->column_name_username,
$user,
$sql_params);
$stmt = $this->connection()->query($query, $sql_params);
// Check whether (a) there's just one result and (b) that password matches
return (($stmt->count() === 1) && $this->password_check($pass, $stmt->next_row()[0])) ? $user : false;
}
// Checks that a password matches a hash
protected function password_check(string $password, string $hash) : bool
{
switch ($this->password_format)
{
case 'crypt':
case 'password_hash':
// Don't call password_needs_rehash() as (a) we may not have UPDATE rights on the external
// database and (b) whether the password needs to be updated will depend on the PHP version
// on the external system, not this one.
return (password_verify($password, $hash));
break;
case 'plaintext':
return hash_equals($hash, $password);
break;
default:
// Check we've got a valid hashing algorithm. From PHP 8.0.0 hash() will do this.
if ((version_compare(PHP_VERSION, '8.0.0') < 0) &&
!in_array($this->password_format, hash_algos()))
{
throw new ValueError("'$this->password_format' is not a valid hashing algorithm");
}
return hash_equals($hash, hash($this->password_format, $password));
break;
}
}
protected function getUserFresh(string $username) : ?User
{
$sql_params = array();
// Only retrieve the columns we need (a) to minimise the query and (b) to avoid
// sending unnecessary information unencrypted over the internet (Remote SQL is
// usually unencrypted).
$columns = array();
$properties = array(
'column_name_display_name',
'column_name_email',
'column_name_level'
);
foreach ($properties as $property)
{
if (isset($this->$property))
{
$columns[] = $this->$property;
}
}
$sql = "SELECT " . implode(', ', array_map(array($this->connection(), 'quote'), $columns)) . "
FROM " . $this->connection()->quote($this->db_table) . "
WHERE " . $this->connection()->syntax_casesensitive_equals($this->column_name_username,
$username,
$sql_params) . "
LIMIT 1";
$stmt = $this->connection()->query($sql, $sql_params);
// The username doesn't exist - return NULL
if ($stmt->count() === 0)
{
return null;
}
// The username does exist - return a User object
$data = $stmt->next_row_keyed();
$user = new User($username);
// Set the email address
if (isset($this->column_name_email) && isset($data[$this->column_name_email]))
{
$user->email = $data[$this->column_name_email];
}
// Set the display name
if (isset($this->column_name_display_name) && isset($data[$this->column_name_display_name]))
{
$user->display_name = $data[$this->column_name_display_name];
}
// Set the level
// First get the default level. Any admins defined in the config
// file override settings in the external database.
$user->level = $this->getDefaultLevel($username);
// Then if they are not an admin get their level from the external db
if ($user->level < 2)
{
// If there's can entry in the db, then use that
if (isset($this->column_name_level) &&
($this->column_name_level !== '') &&
isset($data[$this->column_name_level]))
{
$user->level = $data[$this->column_name_level];
}
}
// Then set the remaining properties. (We don't set all the properties from
// $data initially because we want to preserve the default values if we don't
// have data for the four important properties.)
// (Note that normally there won't be any extra properties because we have
// specified above the columns that we want, but this code is here so that extra
// columns can be added if required.)
foreach ($data as $key => $value)
{
if (!property_exists($user, $key))
{
$user->$key = $value;
}
}
return $user;
}
protected function connection(): ?DB
{
global $auth;
static $connection = null;
if (!isset($connection))
{
if (empty($auth['db_ext']['db_system']))
{
$auth['db_ext']['db_system'] = 'mysql';
}
// Establish a connection
$port = isset($auth['db_ext']['db_port']) ? (int) $auth['db_ext']['db_port'] : null;
try
{
$connection = DBFactory::create(
$auth['db_ext']['db_system'],
$auth['db_ext']['db_host'],
$auth['db_ext']['db_username'],
$auth['db_ext']['db_password'],
$auth['db_ext']['db_name'],
false,
$port
);
}
catch (\PDOException $e)
{
// Differentiate between an error in the MRBS database and the external database
throw new DBExternalException($e->getMessage(), $e->getCode());
}
}
return $connection;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace MRBS\Auth;
/**
* Authentication scheme that uses an external script as the source for user authentication.
*
* To use this authentication scheme, set the following things in config.inc.php:
*
* $auth["realm"] = "MRBS"; // Or any other string
* $auth["type"] = "ext";
* $auth["prog"] = "authenticationprogram"; // The full path to the external script
* $auth["params"] = "<...>"; // Parameters to pass to the script; #USERNAME# and #PASSWORD#
* // will be expanded to the values typed by the user, e.g.
* // "/etc/htpasswd #USERNAME# #PASSWORD#"
*
* Then, you may configure admin users:
*
* $auth["admin"][] = "username1";
* $auth["admin"][] = "username2";
*
*/
class AuthExt extends Auth
{
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
global $auth;
// Check if we do not have a username/password
if(!isset($user) || !isset($pass))
{
return false;
}
// Generate the command line
$cmd = $auth["prog"] . ' ' . $auth["params"];
$cmd = str_replace('#USERNAME#', escapeshellarg($user), $cmd);
$cmd = str_replace('#PASSWORD#', escapeshellarg($pass), $cmd);
// Run the program
exec($cmd, $output, $ret);
// If it succeeded, return success
if ($ret == 0)
{
return $user;
}
// return failure
return false;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace MRBS\Auth;
class AuthFactory
{
public static function create(string $type)
{
// Transform the authentication type from lowercase_separated to LowercaseSeparated
$parts = explode('_', $type);
$parts = array_map('ucfirst', $parts);
$class = __NAMESPACE__ . "\\Auth" . implode('', $parts);
return new $class;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace MRBS\Auth;
use MRBS\User;
/**
* For use with mod_idcheck (http://idcheck.sourceforge.net/).
*
* Must have `$auth['session']` set to 'remote_user'.
*/
class AuthIdcheck extends AuthNone
{
public function __construct()
{
global $auth;
if ($auth['session'] != 'remote_user')
{
$message = 'MRBS configuration error. If $auth["type"] is set to "idcheck"' .
' then $auth["session"] must be set to "remote_user"';
die($message);
}
}
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
// Method provided for completeness as it's an abstract method.
// However it's not used by the 'remote_user' session scheme.
return $user;
}
protected function getUserFresh(string $username) : ?User
{
global $server;
$user = new User($username);
$user->level = $this->getDefaultLevel($username);
// We only know the details of the currently logged in user
if (isset($username) && isset($server['REMOTE_USER']) && ($username == $server['REMOTE_USER']))
{
$user->display_name = $server['IDCHECK_NAME'];
$user->email = $server['IDCHECK_MAIL'];
}
else
{
$user->display_name = $username;
$user->email = '';
}
return $user;
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace MRBS\Auth;
/**
* Authentication scheme that uses IMAP as the source for user authentication.
*
* To use this authentication scheme, set the following things in config.inc.php:
*
* $auth["realm"] = "MRBS"; // Or any other string
* $auth["type"] = "imap";
*
* Then, you may configure admin users:
*
* $auth["admin"][] = "imapuser1";
* $auth["admin"][] = "imapuser2";
*/
class AuthImap extends Auth
{
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
global $imap_host, $imap_port;
$all_imap_ports = array();
// Check if we do not have a username/password
if (!isset($user) || !isset($pass) || strlen($pass)==0)
{
return false;
}
// Check that if there is an array of hosts and an array of ports
// then the number of each is the same
if (is_array( $imap_host ) &&
is_array( $imap_port ) &&
(count($imap_port) != count($imap_host)) )
{
return false;
}
// Transfer the list of imap hosts to a new value to ensure that
// an array is always used.
// If a single value is passed then turn it into an array
if (is_array( $imap_host ) )
{
$all_imap_hosts = $imap_host;
}
else
{
$all_imap_hosts = array($imap_host);
}
// create an array of the port numbers to match the number of
// hosts if a single port number has been passed.
if (is_array( $imap_port ) )
{
$all_imap_ports = $imap_port;
}
else
{
foreach($all_imap_hosts as $value)
{
$all_imap_ports[] = $imap_port;
}
}
// iterate over all hosts and return if you get a successful login
foreach( $all_imap_hosts as $idx => $host)
{
$error_number = "";
$error_string = "";
// Connect to IMAP-server
$stream = fsockopen( $host, $all_imap_ports[$idx], $error_number,
$error_string, 15 );
if ( $stream )
{
$response = fgets( $stream, 1024 );
$logon_str = "a001 LOGIN \"" . self::quote_imap( $user ) . "\" \"" . self::quote_imap( $pass ) . "\"\r\n";
fputs( $stream, $logon_str );
$response = fgets( $stream, 1024 );
if ( substr( $response, 5, 2 ) == 'OK' )
{
fputs( $stream, "a002 LOGOUT\r\n" );
$response = fgets( $stream, 1024 );
fclose( $stream );
return $user;
}
fputs( $stream, "a002 LOGOUT\r\n" );
fclose( $stream );
}
}
// return failure
return false;
}
/**
*/
public function canValidateByEmail() : bool
{
return true;
}
/**
*/
public function canValidateByUsername() : bool
{
return false;
}
/* quote_imap($str)
*
* quote char's into valid IMAP string
*
* $str - String to be quoted
*
* Returns:
* quoted string
*/
private static function quote_imap(string $str) : string
{
return preg_replace('/(["\\\\])/', '\\$1', $str);
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
namespace MRBS\Auth;
use MRBS\Exception;
use Webklex\PHPIMAP\ClientManager;
use Webklex\PHPIMAP\Exceptions\AuthFailedException;
use Webklex\PHPIMAP\Exceptions\ImapServerErrorException;
/**
* Authentication scheme that uses IMAP as the source for user authentication. If the PHP version is
* less than 8.0, it requires you to have the PHP 'imap' extension installed and enabled.
*
* To use this authentication scheme, set the following things in config.inc.php:
*
* $auth["realm"] = "MRBS"; // Or any other string
* $auth["type"] = "imap_php";
*
* You must also configure at least:
*
* $auth["imap_php"]["hostname"] = "mailserver.hostname";
*
* You can also specify any of the following options:
*
* $auth["imap_php"]["port"] = 993; // Specifies the port number to connect to
* $auth["imap_php"]["ssl"] = true; // Use SSL
* $auth["imap_php"]["tls"] = true; // Use TLS
* $auth["imap_php"]["novalidate-cert"] = true; // Turn off SSL/TLS certificate validation
*
* Then, you may configure admin users:
*
* $auth["admin"][] = "imapuser1";
* $auth["admin"][] = "imapuser2";
*/
class AuthImapPhp extends Auth
{
private $canUseWebklex;
public function __construct()
{
assert(
version_compare(MRBS_MIN_PHP_VERSION, '8.0') < 0,
'The code below is no longer required.'
);
// The imap extension was removed in PHP 8.4 and so we use the Webklex/PHPIMAP library
// instead. However this requires PHP 8.0 or greater.
$this->canUseWebklex = (version_compare(PHP_VERSION, '8.0') >= 0);
if (!$this->canUseWebklex && !function_exists('imap_open'))
{
throw new Exception("The imap extension is not installed on this server.");
}
}
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
global $auth;
// If required, check that the username is from the permitted domain
if (isset($auth['imap_php']['user_domain']))
{
if (!filter_var($user, FILTER_VALIDATE_EMAIL))
{
return false;
}
list(, $domain) = explode('@', $user);
if ($domain != $auth['imap_php']['user_domain'])
{
return false;
}
}
if (!$this->canUseWebklex)
{
return $this->validateUserLegacy($user, $pass);
}
$cm = new ClientManager();
$config = [
'host' => $auth['imap_php']['hostname'],
'username' => $user,
'password' => $pass,
'protocol' => 'imap'
];
// The defaults are chosen to be compatible with the legacy behaviour.
$config['port'] = $auth['imap_php']['port'] ?? 143;
$config['validate_cert'] = empty($auth['imap_php']['novalidate-cert']);
if (!empty($auth['imap_php']['ssl']))
{
$config['encryption'] = 'ssl';
}
elseif (!empty($auth['imap_php']['tls']))
{
$config['encryption'] = 'tls';
}
else
{
$config['encryption'] = '';
}
try {
$client = $cm->make($config);
$client->connect(); //Connect to the IMAP Server
return $user;
}
catch (ImapServerErrorException | AuthFailedException $e) {
// Don't do anything with these exceptions: they are normal when
// authentication fails.
}
catch (\Exception $e) {
// We weren't expecting any other exceptions so trigger an error.
$message = "Caught exception '" . get_class($e) . "'\n";
$message .= $e->getMessage() . "\n" . $e->getTraceAsString();
trigger_error($message, E_USER_WARNING);
}
return false;
}
private function validateUserLegacy(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
global $auth;
$location = '{' . $auth['imap_php']['hostname'];
if (isset($auth['imap_php']['port']))
{
$location .= ':' . $auth['imap_php']['port'];
}
$location .= '/imap';
if (!empty($auth['imap_php']['ssl']))
{
$location .= '/ssl';
}
if (!empty($auth['imap_php']['tls']))
{
$location .= '/tls';
}
if (!empty($auth['imap_php']['novalidate-cert']))
{
$location .= '/novalidate-cert';
}
$location .= '}INBOX';
$mbox = imap_open($location, $user, $pass);
if ($mbox !== false)
{
imap_close($mbox);
return $user;
}
return false;
}
/**
*/
public function canValidateByEmail() : bool
{
return true;
}
/**
*/
public function canValidateByUsername() : bool
{
return false;
}
}
+239
View File
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace MRBS\Auth;
use Joomla\CMS\Factory;
use MRBS\Joomla\JFactory;
use MRBS\User;
require_once MRBS_ROOT . '/auth/cms/joomla.inc';
class AuthJoomla extends Auth
{
public function __construct()
{
$this->checkSessionMatchesType();
}
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
if (version_compare(JVERSION, '5.0', '<'))
{
$mainframe = JFactory::getApplication('site');
}
else
{
$mainframe = Factory::getApplication('site');
}
return $mainframe->login(array('username' => $user, 'password' => $pass)) ? $user : false;
}
protected function getUserFresh(?string $username=null) : ?User
{
if ($username === '')
{
return null;
}
if (version_compare(JVERSION, '5.0', '<'))
{
$joomla_user = JFactory::getUser($username);
}
else
{
$joomla_user = Factory::getUser($username);
}
if ($joomla_user === false)
{
return new User($username);
}
if ($joomla_user->guest)
{
return null;
}
$user = new User($joomla_user->username);
$user->display_name = $joomla_user->name;
$user->email = $joomla_user->email;
$user->level = self::getUserLevel($joomla_user);
return $user;
}
// TODO: sort out where getCurrentUser belongs. We have it in both
// TODO: Auth and Session for Joomla!
public function getCurrentUser() : ?User
{
return $this->getUserFresh();
}
// Return an array of MRBS users, indexed by 'username' and 'display_name'
public function getUsernames() : array
{
$result = array();
// We only want MRBS users, not all the Joomla users
$groups = self::getMRBSGroups();
// Get the user ids associated with those groups
$user_ids = array();
foreach($groups as $group)
{
// Include child groups by doing it recursively
if (version_compare(JVERSION, '5.0', '<'))
{
$user_ids = array_merge($user_ids, \JAccess::getUsersByGroup($group, $recursive = true));
}
else
{
$user_ids = array_merge($user_ids, \Joomla\CMS\Access\Access::getUsersByGroup($group, $recursive = true));
}
}
$user_ids = array_unique($user_ids);
// No doubt it would be faster to do this with a single SQL query, but then we wouldn't
// be using the Joomla API abstraction.
foreach ($user_ids as $user_id)
{
if (version_compare(JVERSION, '5.0', '<'))
{
$user = JFactory::getUser((int)$user_id);
}
else
{
$user = Factory::getUser((int)$user_id);
}
// Check to see that the user has a username. The result of getUser() on a user_id that doesn't exist is,
// strangely, a user object with all properties set to null. In theory (?) all the user_ids returned by
// getUsersByGroup() should exist, but there has been a case where this is not so. See
// https://github.com/meeting-room-booking-system/mrbs-code/issues/3682 .
if (isset($user->username))
{
$result[] = array(
'username' => $user->username,
'display_name' => $user->name
);
}
else
{
trigger_error("The Joomla user with id $user_id appears in Joomla groups but not in Joomla users.", E_USER_WARNING);
}
}
// Need to sort the users
self::sortUsers($result);
return $result;
}
// Get an array of Joomla groups that have MRBS user or admin rights
private static function getMRBSGroups() : array
{
global $auth;
$result = array();
// Get all the Joomla access levels that have MRBS user or admin rights
$mrbs_access_levels = array_merge($auth['joomla']['admin_access_levels'],
$auth['joomla']['user_access_levels']);
$mrbs_access_levels = array_unique($mrbs_access_levels);
// There doesn't seem to be a Joomla API to do this, so we'll have to do
// it with direct access to the database.
// Get a db connection.
if (version_compare(JVERSION, '5.0', '<'))
{
$db = JFactory::getDbo();
}
else
{
$db = Factory::getDbo();
}
// Create a new query object.
$query = $db->getQuery(true);
// Execute the query
$query->select($db->quoteName(array('rules')));
$query->from($db->quoteName('#__viewlevels'));
$query->where($db->quoteName('id') . ' IN ('. implode(',', $mrbs_access_levels) . ')');
$db->setQuery($query);
$column = $db->loadColumn();
// Process the results into an array
foreach ($column as $rules)
{
$result = array_merge($result, (json_decode($rules)));
}
// Remove duplicates
$result = array_unique($result);
return $result;
}
private static function getUserLevel(object $joomla_user) : int
{
global $auth;
$required_class = (version_compare(JVERSION, '5.0', '<')) ? 'MRBS\Joomla\JUser' : 'Joomla\CMS\User\User';
$actual_class = get_class($joomla_user);
if ($actual_class !== $required_class)
{
$message = 'Argument #1 ($joomla_user) must be of type ' . "$required_class, $actual_class given";
throw new \TypeError($message);
}
// User not logged in, user level '0'
if ($joomla_user->guest)
{
return 0;
}
// Otherwise get the user's access levels
$authorised_levels = $joomla_user->getAuthorisedViewLevels();
// Check if they have admin access
if (isset($auth['joomla']['admin_access_levels']))
{
$admin_levels = (array)$auth['joomla']['admin_access_levels'];
if (count(array_intersect($authorised_levels, $admin_levels)) > 0)
{
return 2;
}
}
// Check if they have user access
if (isset($auth['joomla']['user_access_levels']))
{
$user_levels = (array)$auth['joomla']['user_access_levels'];
if (count(array_intersect($authorised_levels, $user_levels)) > 0)
{
return 1;
}
}
// Everybody else is access level '0'
return 0;
}
}
+997
View File
@@ -0,0 +1,997 @@
<?php
namespace MRBS\Auth;
use MRBS\Errors\Errors;
use MRBS\Exception;
use MRBS\User;
use function MRBS\get_microtime;
use function MRBS\in_arrayi;
use function MRBS\session;
class AuthLdap extends Auth
{
// LDAP ERROR CODES
const LDAP_SUCCESS = 0x00;
const LDAP_OPERATIONS_ERROR = 0x01;
const LDAP_PROTOCOL_ERROR = 0x02;
const LDAP_TIMELIMIT_EXCEEDED = 0x03;
const LDAP_SIZELIMIT_EXCEEDED = 0x04;
const LDAP_COMPARE_FALSE = 0x05;
const LDAP_COMPARE_TRUE = 0x06;
const LDAP_AUTH_METHOD_NOT_SUPPORTED = 0x07;
const LDAP_STRONG_AUTH_REQUIRED = 0x08;
// Not used in LDAPv3
const LDAP_PARTIAL_RESULTS = 0x09;
// Next 5 new in LDAPv3
const LDAP_REFERRAL = 0x0a;
const LDAP_ADMINLIMIT_EXCEEDED = 0x0b;
const LDAP_UNAVAILABLE_CRITICAL_EXTENSION = 0x0c;
const LDAP_CONFIDENTIALITY_REQUIRED = 0x0d;
const LDAP_SASL_BIND_INPROGRESS = 0x0e;
const LDAP_NO_SUCH_ATTRIBUTE = 0x10;
const LDAP_UNDEFINED_TYPE = 0x11;
const LDAP_INAPPROPRIATE_MATCHING = 0x12;
const LDAP_CONSTRAINT_VIOLATION = 0x13;
const LDAP_TYPE_OR_VALUE_EXISTS = 0x14;
const LDAP_INVALID_SYNTAX = 0x15;
const LDAP_NO_SUCH_OBJECT = 0x20;
const LDAP_ALIAS_PROBLEM = 0x21;
const LDAP_INVALID_DN_SYNTAX = 0x22;
// Next two not used in LDAPv3 =
const LDAP_IS_LEAF = 0x23;
const LDAP_ALIAS_DEREF_PROBLEM = 0x24;
const LDAP_INAPPROPRIATE_AUTH = 0x30;
const LDAP_INVALID_CREDENTIALS = 0x31;
const LDAP_INSUFFICIENT_ACCESS = 0x32;
const LDAP_BUSY = 0x33;
const LDAP_UNAVAILABLE = 0x34;
const LDAP_UNWILLING_TO_PERFORM = 0x35;
const LDAP_LOOP_DETECT = 0x36;
const LDAP_SORT_CONTROL_MISSING = 0x3C;
const LDAP_INDEX_RANGE_ERROR = 0x3D;
const LDAP_NAMING_VIOLATION = 0x40;
const LDAP_OBJECT_CLASS_VIOLATION = 0x41;
const LDAP_NOT_ALLOWED_ON_NONLEAF = 0x42;
const LDAP_NOT_ALLOWED_ON_RDN = 0x43;
const LDAP_ALREADY_EXISTS = 0x44;
const LDAP_NO_OBJECT_CLASS_MODS = 0x45;
const LDAP_RESULTS_TOO_LARGE = 0x46;
// Next two for LDAPv3
const LDAP_AFFECTS_MULTIPLE_DSAS = 0x47;
const LDAP_OTHER = 0x50;
// Used by some APIs
const LDAP_SERVER_DOWN = 0x51;
const LDAP_LOCAL_ERROR = 0x52;
const LDAP_ENCODING_ERROR = 0x53;
const LDAP_DECODING_ERROR = 0x54;
const LDAP_TIMEOUT = 0x55;
const LDAP_AUTH_UNKNOWN = 0x56;
const LDAP_FILTER_ERROR = 0x57;
const LDAP_USER_CANCELLED = 0x58;
const LDAP_PARAM_ERROR = 0x59;
const LDAP_NO_MEMORY = 0x5a;
// Preliminary LDAPv3 codes
const LDAP_CONNECT_ERROR = 0x5b;
const LDAP_NOT_SUPPORTED = 0x5c;
const LDAP_CONTROL_NOT_FOUND = 0x5d;
const LDAP_NO_RESULTS_RETURNED = 0x5e;
const LDAP_MORE_RESULTS_TO_RETURN = 0x5f;
const LDAP_CLIENT_LOOP = 0x60;
const LDAP_REFERRAL_LIMIT_EXCEEDED = 0x61;
// Default ports
const DEFAULT_PORT_LDAP = 389;
const DEFAULT_PORT_LDAPS = 636;
private static $all_ldap_opts;
private static $config_items;
private static $profile_clock;
public function __construct()
{
global $ldap_host;
global $ldap_port;
global $ldap_v3;
global $ldap_tls;
global $ldap_base_dn;
global $ldap_user_attrib;
global $ldap_dn_search_attrib;
global $ldap_dn_search_dn;
global $ldap_dn_search_password;
global $ldap_filter;
global $ldap_group_member_attrib;
global $ldap_admin_group_dn;
global $ldap_email_attrib;
global $ldap_name_attrib;
global $ldap_disable_referrals;
global $ldap_deref;
global $ldap_filter_base_dn;
global $ldap_filter_user_attr;
// Check that ldap is installed
if (!function_exists('ldap_connect'))
{
die("<hr><p><b>ERROR: PHP's 'ldap' extension is not installed/enabled. ".
"Please check your web server configuration.</b></p><hr>\n");
}
// Transfer the values from the config variables into a local
// associative array, turning them all into arrays
self::$config_items = array('ldap_host',
'ldap_port',
'ldap_base_dn',
'ldap_user_attrib',
'ldap_dn_search_attrib',
'ldap_dn_search_dn',
'ldap_dn_search_password',
'ldap_filter',
'ldap_group_member_attrib',
'ldap_admin_group_dn',
'ldap_v3',
'ldap_tls',
'ldap_email_attrib',
'ldap_name_attrib',
'ldap_disable_referrals',
'ldap_deref',
'ldap_filter_base_dn',
'ldap_filter_user_attr',
'ldap_client_cert',
'ldap_client_key'
);
self::$all_ldap_opts = array();
// Get the array items (we'll handle the non-array items in a moment) and check
// that they all have the same length
$count = null;
foreach (self::$config_items as $item)
{
if (isset($$item) && is_array($$item))
{
self::$all_ldap_opts[$item] = $$item;
if (isset($count))
{
if (count($$item) != $count)
{
Errors::fatalError("MRBS configuration error: Count of LDAP array config variables doesn't match, aborting!");
}
}
else
{
$count = count($$item);
}
}
}
// Turn any non-array config items into arrays
if (!isset($count))
{
$count = 1;
}
foreach (self::$config_items as $item)
{
if (isset($$item) && !is_array($$item))
{
self::$all_ldap_opts[$item] = array_fill(0, $count, $$item);
}
}
}
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
// Check if we do not have a username/password
// User can always bind to LDAP anonymously with empty password,
// therefore we need to block empty password here...
if (!isset($user) || !isset($pass) || strlen($pass)==0)
{
self::debug('empty username or password passed');
return false;
}
$object = array();
$object['pass'] = $pass;
return ($this->action('validateUserCallback', $user, $object)) ? $user : false;
}
/* validateUserCallback(&$ldap, $base_dn, $dn, $user_search,
$user, &$object)
*
* Checks if the specified username/password pair are valid
*
* &$ldap - Reference to the LDAP object
* $base_dn - The base DN
* $dn - The user's DN
* $user_search - The LDAP filter to find the user
* $user - The user name
* &$object - Reference to the generic object
*
* Returns:
* false - Didn't find a user
* true - Found a user
*/
private static function validateUserCallback(&$ldap, $base_dn, $dn, $user_search,
$user, &$object)
{
self::debug("base_dn '$base_dn' dn '$dn' user '$user'");
$pass = $object['pass'];
// try an authenticated bind
// use this to confirm that the user/password pair
if ($dn && self::ldapBind($ldap, $dn, $pass))
{
// however if there is a filter check that the
// user is part of the group defined by the filter
if (!isset($object['config']['ldap_filter']) || ($object['config']['ldap_filter'] === ''))
{
self::debug("successful authenticated bind with no \$ldap_filter");
return true;
}
else
{
// If we've got a search DN and password, then bind again using those credentials because
// it's possible that the user doesn't have read access in the directory, even for their own
// entry, in which case we'll get a "No such object" result.
if (isset($object['config']['ldap_dn_search_dn']) &&
isset($object['config']['ldap_dn_search_password']))
{
self::debug("rebinding as '" . $object['config']['ldap_dn_search_dn'] . "'");
if (!self::ldapBind($ldap, $object['config']['ldap_dn_search_dn'], $object['config']['ldap_dn_search_password']))
{
self::debug("rebinding failed: " . self::ldapError($ldap));
return false;
}
self::debug('rebinding successful');
}
$filter = $object['config']['ldap_filter'];
self::debug("successful authenticated bind checking '$filter'");
// If ldap_filter_base_dn is set, set the filter to search for the user
// in the given base_dn (OpenLDAP). If not, read from the user
// attribute (AD)
if (isset($object['config']['ldap_filter_base_dn']))
{
$f = "(&(".
$object['config']['ldap_filter_user_attr'].
"=$user)($filter))";
$filter_dn = $object['config']['ldap_filter_base_dn'];
$call = 'ldap_search';
}
else
{
$f = "($filter)";
$filter_dn = $dn;
$call = 'ldap_read';
}
self::debug("trying filter: $f: dn: $filter_dn: method: $call");
$res = $call(
$ldap,
$filter_dn,
$f,
array()
);
if (ldap_count_entries($ldap, $res) > 0)
{
self::debug('found entry with filter');
return true;
}
self::debug('no entry found with filter');
}
}
else
{
self::debug("bind to '$dn' failed: ". self::ldapError($ldap));
}
// return failure if no connection is established
return false;
}
protected function getUserFresh(string $username) : ?User
{
if (!isset($username) || ($username === ''))
{
return null;
}
$object = array();
$res = $this->action('getUserCallback', $username, $object);
if (!$res || !isset($object['user']))
{
return null;
}
// Use $object['user']['username'] rather than $username because they won't necessarily be
// the same. See https://sourceforge.net/p/mrbs/bugs/518/
$user = parent::getUserFresh($object['user']['username']);
$keys = array('display_name', 'email', 'level');
foreach ($keys as $key)
{
if (isset($object['user'][$key]))
{
$user->$key = $object['user'][$key];
}
}
return $user;
}
/* getUserCallback(&$ldap, $base_dn, $dn, $user_search,
$username, &$object)
*
* &$ldap - Reference to the LDAP object
* $base_dn - The base DN
* $dn - The user's DN
* $user_search - The LDAP filter to find the user
* $username - The user name
* &$object - Reference to the generic object
*
* Returns:
* false - Didn't find a user
* true - Found a user
*/
private static function getUserCallback(&$ldap, $base_dn, $dn, $user_search,
$user, &$object)
{
global $ldap_get_user_email, $ldap_debug_attributes, $max_level;
self::debug("base_dn '$base_dn' dn '$dn' user_search '$user_search' user '$user'");
if (!$ldap || !$base_dn || !$dn || !$user_search)
{
self::debug("invalid parameters, could not call ldap_read, returning false");
return false;
}
$attributes = self::getAttributes($object, $ldap_get_user_email, true);
self::resetProfileClock();
// We suppress the errors because it's possible to get a "No such object" error if
// the DN doesn't exist - which it won't if (a) we're searching an array of LDAP hosts
// or (b) the DN has been deleted since the booking was made. But check the error
// code afterwards and trigger an error if it was any other kind of error.
$res = @ldap_read(
$ldap,
$dn,
"(objectclass=*)",
array_values($attributes),
0,
1
);
$t = self::getProfileClock();
if ($res === false)
{
self::debug("ldap_read() failed: " . self::ldapError($ldap));
if (self::LDAP_NO_SUCH_OBJECT !== ($errno = ldap_errno($ldap)))
{
if ($errno === self::LDAP_SUCCESS)
{
// The errno is reporting success, but that's just the ldap errno. If ldap_read()
// doesn't get as far as interrogating the directory, it will return false,
// but ldap_errno() will return success. To get more details try temporarily
// removing the error suppression operator ('@').
$message = "ldap_read() failed, not due to an LDAP error but probably due " .
"to an initialization error such as 'Array initialization wrong'";
}
else
{
$message = ldap_err2str($errno);
}
trigger_error($message, E_USER_WARNING);
}
return false;
}
$n_entries = ldap_count_entries($ldap, $res);
if ($n_entries === false)
{
self::debug("No entries found - ldap_count_entries() error");
return false;
}
self::debug("$n_entries entries found");
if ($n_entries === 0)
{
return false;
}
self::debug("ldap_read() succeeded, taking $t seconds");
if ($ldap_debug_attributes)
{
// Repeat the read, this time fetching all the attributes and then write
// the attributes and their values to the debug log. Useful for discovering
// attribute names.
$res2 = @ldap_read($ldap, $dn, "(objectclass=*)", [], 0, 1);
$entry = ldap_first_entry($ldap, $res2);
$attribute = ldap_first_attribute($ldap, $entry);
while ($attribute)
{
$values = ldap_get_values($ldap, $entry, $attribute);
unset($values['count']); // We don't need this element
self::debug("Attribute: \"$attribute\"; Value(s): \"" . implode('", "', $values) . '"');
$attribute = ldap_next_attribute($ldap, $entry);
}
}
$entry = ldap_first_entry($ldap, $res);
$user = self::getResult($ldap, $entry, $attributes);
if (!isset($user['username']))
{
$message = 'No username found. Check the value of $ldap_user_attrib in the MRBS config file.';
$message .= " It is currently set to '" . $object['config']['ldap_user_attrib'] . "'.";
self::debug($message);
return false;
}
if (!isset($user['display_name']))
{
$user['display_name'] = $user['username'];
}
if (isset($user['groups']))
{
if (isset($object['config']['ldap_admin_group_dn']))
{
$user['level'] = in_arrayi($object['config']['ldap_admin_group_dn'], $user['groups']) ? $max_level : 1;
}
}
self::debug("User '" . $user['username'] . "' found");
$object['user'] = $user;
return true;
}
public function getUsernames()
{
$mrbs_user = session()->getCurrentUser();
if (!isset($mrbs_user))
{
return false;
}
$object = array();
$object['users'] = array();
$users = array();
$res = $this->action('getUsernamesCallback', $mrbs_user->username, $object, true);
if ($res === false)
{
trigger_error("MRBS: could not get LDAP usernames.", E_USER_WARNING);
return false;
}
if (isset($object['users']))
{
$users = $object['users'];
}
self::sortUsers($users);
return $users;
}
private static function getUsernamesCallback(&$ldap, $base_dn, $dn, $user_search,
$user, &$object)
{
self::debug("base_dn '$base_dn'");
if (!$ldap || !$base_dn || !isset($object['config']['ldap_user_attrib']))
{
self::debug("invalid parameters, could not call ldap_search, returning false");
return false;
}
if (isset($object['config']['ldap_filter']))
{
$filter = $object['config']['ldap_filter'];
}
else
{
$filter = 'objectclass=*';
}
$filter = "($filter)";
// Form the attributes
$username_attrib = mb_strtolower($object['config']['ldap_user_attrib']);
$attributes = array($username_attrib);
// The display name attribute might not have been set in the config file
if (isset($object['config']['ldap_name_attrib']))
{
$display_name_attrib = mb_strtolower($object['config']['ldap_name_attrib']);
$attributes[] = $display_name_attrib;
}
self::debug("searching with base_dn '$base_dn' and filter '$filter'");
self::resetProfileClock();
$res = ldap_search($ldap, $base_dn, $filter, $attributes);
$t = self::getProfileClock();
if ($res === false)
{
self::debug("ldap_search failed: " . self::ldapError($ldap));
return false;
}
self::debug(ldap_count_entries($ldap, $res) . " entries found in $t seconds");
$entry = ldap_first_entry($ldap, $res);
// Loop through the entries to get all the users
while ($entry)
{
// Initialise all keys in the user array to NULL, in case an attribute isn't present
$user = array('username' => null,
'display_name' => null);
$attribute = ldap_first_attribute($ldap, $entry);
// Loop through all the attributes for this user
while ($attribute)
{
$values = ldap_get_values($ldap, $entry, $attribute);
$attribute = mb_strtolower($attribute); // ready for the comparisons
if ($attribute == $username_attrib)
{
$user['username'] = $values[0];
}
elseif ($attribute == $display_name_attrib)
{
$user['display_name'] = $values[0];
}
$attribute = ldap_next_attribute($ldap, $entry);
}
if (isset($user['username']))
{
if (!isset($user['display_name']))
{
$user['display_name'] = $user['username'];
}
$object['users'][] = $user;
}
$entry = ldap_next_entry($ldap, $entry);
}
return true;
}
// Returns an array of attributes for use in an LDAP query
private static function getAttributes(array $object, bool $include_email=true, bool $include_groups=true) : array
{
$result = array();
// Username
$result['username'] = mb_strtolower($object['config']['ldap_user_attrib']);
// The display name attribute might not have been set in the config file
if (isset($object['config']['ldap_name_attrib']))
{
$result['display_name'] = mb_strtolower($object['config']['ldap_name_attrib']);
}
// The email address
if ($include_email && isset($object['config']['ldap_email_attrib']))
{
$result['email'] = mb_strtolower($object['config']['ldap_email_attrib']);
}
// The group name attribute might not have been set in the config file
if ($include_groups && isset($object['config']['ldap_group_member_attrib']))
{
$result['groups'] = mb_strtolower($object['config']['ldap_group_member_attrib']);
}
return $result;
}
// Returns an associative array from the result of an LDAP search
private static function getResult($ldap, $entry, array $attributes) : array
{
// Initialise all keys in the user array, in case an attribute isn't present
$attributes_keys = array_keys($attributes);
$user = array();
foreach ($attributes_keys as $key)
{
switch ($key)
{
case 'username':
case 'display_name':
case 'email':
$user[$key] = null;
break;
case 'groups':
$user[$key] = array();
break;
default:
throw new Exception("Unknown key '$key'");
}
}
$attribute = ldap_first_attribute($ldap, $entry);
// Loop through all the attributes for this user
while ($attribute)
{
$values = ldap_get_values($ldap, $entry, $attribute);
$attribute = mb_strtolower($attribute); // ready for the comparisons
if ($attribute == $attributes['username'])
{
$user['username'] = $values[0];
}
elseif (isset($attributes['display_name']) && ($attribute == $attributes['display_name']))
{
$user['display_name'] = $values[0];
}
elseif (isset($attributes['email']) && ($attribute == $attributes['email']))
{
$user['email'] = $values[0];
}
elseif (isset($attributes['groups']) && ($attribute == $attributes['groups']))
{
for ($i=0; $i<$values['count']; $i++)
{
$user['groups'][] = $values[$i];
}
}
$attribute = ldap_next_attribute($ldap, $entry);
}
return $user;
}
/* action($callback, $username, &$object)
*
* Connects/binds to all configured LDAP servers/base DNs and
* then performs a callback, passing the LDAP object, $base_dn,
* user DN (in $dn), $username and a generic object $object
*
* $callback - The callback function
* $username - The user name
* &$object - Reference to the generic object, type defined by caller
* $keep_going - Don't stop when a user has been found, but keep going through all the LDAP
* hosts. Useful, for example, when you want to get a list of all users.
*
* Returns:
* boolean - Whether the action was successful
*/
public function action(string $callback, string $username, &$object, bool $keep_going=false) : bool
{
global $ldap_unbind_between_attempts;
$result = false;
for ($idx=0; $idx < count(self::$all_ldap_opts['ldap_host']); $idx++)
{
// Establish LDAP connection
$uri = self::getUri($idx);
$ldap = ldap_connect($uri);
// Check that connection was established
if ($ldap)
{
self::debug("got LDAP connection using $uri");
// Set any applicable LDAP options
self::setOptions($ldap, $idx);
if (isset(self::$all_ldap_opts['ldap_dn_search_attrib'][$idx]))
{
if (isset(self::$all_ldap_opts['ldap_dn_search_dn'][$idx]) &&
isset(self::$all_ldap_opts['ldap_dn_search_password'][$idx]))
{
// Bind with DN and password
self::debug("binding with search_dn and search_password");
$res = self::ldapBind($ldap, self::$all_ldap_opts['ldap_dn_search_dn'][$idx],
self::$all_ldap_opts['ldap_dn_search_password'][$idx]);
}
else
{
// Anonymous bind
self::debug("binding anonymously");
$res = self::ldapBind($ldap);
}
if (!$res)
{
self::debug("initial bind failed: " . self::ldapError($ldap));
}
else
{
self::debug("initial bind was successful");
$base_dn = self::$all_ldap_opts['ldap_base_dn'][$idx];
$filter = "(" . self::$all_ldap_opts['ldap_dn_search_attrib'][$idx] . "=$username)";
self::debug("searching using base_dn '$base_dn' and filter '$filter'");
$res = ldap_search($ldap, $base_dn, $filter);
if ($res === false)
{
self::debug("ldap_search failed: ". self::ldapError($ldap));
}
else
{
if (ldap_count_entries($ldap, $res) == 1)
{
$entries = ldap_get_entries($ldap, $res);
$dn = $entries[0]["dn"];
$user_search = "distinguishedName=" . $dn;
self::debug("found one entry dn '$dn'");
}
else
{
self::debug(ldap_count_entries($ldap, $res) . " entries found, no unique dn");
}
}
}
}
else
{
// construct dn for user
$user_search = self::$all_ldap_opts['ldap_user_attrib'][$idx] . "=" . $username;
$dn = $user_search . "," . self::$all_ldap_opts['ldap_base_dn'][$idx];
self::debug("constructed dn '$dn' and " .
"user_search '$user_search' using '" .
self::$all_ldap_opts['ldap_user_attrib'][$idx] . "'");
}
foreach (self::$config_items as $item)
{
if (isset(self::$all_ldap_opts[$item][$idx]))
{
$object['config'][$item] = self::$all_ldap_opts[$item][$idx];
}
else
{
$object['config'][$item] = null;
}
}
if (empty($dn))
{
self::debug("no DN determined, not calling callback");
}
else
{
$res = self::$callback($ldap, self::$all_ldap_opts['ldap_base_dn'][$idx], $dn,
$user_search, $username, $object);
if ($res)
{
$result = true;
}
}
if ($ldap_unbind_between_attempts)
{
self::debug("unbinding from $uri");
ldap_unbind($ldap);
}
} // if ($ldap)
if ($result && !$keep_going)
{
return true;
}
} // for ()
return $result;
}
// A wrapper for ldap_bind() that optionally suppresses "invalid credentials" errors.
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
// The attribute is only recognised by PHP 8.2 and later.
private static function ldapBind (
$link_identifier,
?string $bind_rdn=null,
#[\SensitiveParameter]
?string $bind_password=null
) : bool
{
global $ldap_suppress_invalid_credentials;
// Suppress all errors and then look to see what the error was and then
// trigger the error again, depending on config settings.
$result = @ldap_bind($link_identifier, $bind_rdn, $bind_password);
if (!$result)
{
$errno = ldap_errno($link_identifier);
if (!$ldap_suppress_invalid_credentials || ($errno != self::LDAP_INVALID_CREDENTIALS))
{
trigger_error(ldap_err2str($errno), E_USER_WARNING);
}
}
return $result;
}
// Gets the full LDAP URI
private static function getUri(int $idx) : string
{
// First get the scheme and host
$host = self::$all_ldap_opts['ldap_host'][$idx];
$parsed_url = parse_url($host);
if (isset($parsed_url['scheme']))
{
$scheme = $parsed_url['scheme'];
$host = $parsed_url['host'];
}
// If we haven't got a scheme then make an educated guess based on the port.
if (!isset($scheme))
{
// And if there isn't a port defined either then use a sensible default
$port = self::$all_ldap_opts['ldap_port'][$idx] ?? self::DEFAULT_PORT_LDAP;
$scheme = ($port == self::DEFAULT_PORT_LDAPS) ? 'ldaps' : 'ldap';
}
// If we have got a scheme then get the port. If it has been defined explicitly in the
// config file, then use that. Otherwise make an educated guess based on the scheme.
else
{
if (isset(self::$all_ldap_opts['ldap_port'][$idx]))
{
$port = self::$all_ldap_opts['ldap_port'][$idx];
}
else
{
$port = ($scheme == 'ldaps') ? self::DEFAULT_PORT_LDAPS : self::DEFAULT_PORT_LDAP;
}
}
return "$scheme://$host:$port";
}
private static function setOptions($ldap, int $idx) : void
{
if (isset(self::$all_ldap_opts['ldap_deref'][$idx]))
{
ldap_set_option($ldap, LDAP_OPT_DEREF, self::$all_ldap_opts['ldap_deref'][$idx]);
}
if (isset(self::$all_ldap_opts['ldap_v3'][$idx]) &&
self::$all_ldap_opts['ldap_v3'][$idx])
{
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
}
if (isset(self::$all_ldap_opts['ldap_client_cert'][$idx]) &&
self::$all_ldap_opts['ldap_client_cert'][$idx])
{
// Requires PHP 7.1.0 or later
ldap_set_option($ldap, LDAP_OPT_X_TLS_CERTFILE, self::$all_ldap_opts['ldap_client_cert'][$idx]);
}
if (isset(self::$all_ldap_opts['ldap_client_key'][$idx]) &&
self::$all_ldap_opts['ldap_client_key'][$idx])
{
// Requires PHP 7.1.0 or later
ldap_set_option($ldap, LDAP_OPT_X_TLS_KEYFILE, self::$all_ldap_opts['ldap_client_key'][$idx]);
}
if (isset(self::$all_ldap_opts['ldap_tls'][$idx]) &&
self::$all_ldap_opts['ldap_tls'][$idx])
{
ldap_start_tls($ldap);
}
if (isset(self::$all_ldap_opts['ldap_disable_referrals'][$idx]) &&
self::$all_ldap_opts['ldap_disable_referrals'][$idx])
{
// Required to do a search on Active Directory for Win 2003+
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
}
}
// Adds extra diagnostic information to ldap_error()
private static function ldapError ($link_identifier) : string
{
$result = ldap_error($link_identifier);
// LDAP_OPT_DIAGNOSTIC_MESSAGE is not supported by all LDAP libraries
if (defined('LDAP_OPT_DIAGNOSTIC_MESSAGE') &&
ldap_get_option($link_identifier, LDAP_OPT_DIAGNOSTIC_MESSAGE, $err) &&
isset($err) && ($err !== ''))
{
$result .= " [$err]";
}
return $result;
}
/* debug($message)
*
* Output LDAP debugging, if either of the configuration variables
* $ldap_debug or $ldap_debug_attributes is true.
*
*/
private static function debug(string $message) : void
{
global $ldap_debug, $ldap_debug_attributes;
if ($ldap_debug || $ldap_debug_attributes)
{
self::logDebugMessage($message);
}
}
private static function getProfileClock()
{
global $ldap_debug;
if ($ldap_debug)
{
return (get_microtime() - self::$profile_clock);
}
else
{
return null;
}
}
private static function resetProfileClock() : void
{
global $ldap_debug;
if ($ldap_debug)
{
self::$profile_clock = get_microtime();
}
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace MRBS\Auth;
class AuthNone extends Auth
{
/**
* Checks if the specified username/password pair are valid.
*
* This authentication scheme always validates positively.
*
* @return string|null
*/
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
return $user;
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace MRBS\Auth;
class AuthNw extends Auth
{
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
global $auth;
// Check if we do not have a username/password
if (empty($user) || empty($pass))
{
return false;
}
// Generate the command line
$cmd = $auth["prog"] . " -S " . $auth["params"] . " -U '$user'";
// Run the program, sending the password to stdin.
$p = popen($cmd, "w");
if (!$p)
{
return false;
}
fputs($p, $pass);
if (pclose($p) == 0)
{
return $user;
}
// return failure
return false;
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace MRBS\Auth;
use MRBS\Exception;
/**
* Authentication scheme that uses POP3 as the source for user authentication.
*
* To use this authentication scheme, set the following things in config.inc.php:
*
* $auth["realm"] = "MRBS"; // Or any other string
* $auth["type"] = "pop3";
*
* Then, you may configure admin users:
*
* $auth["admin"][] = "pop3user1";
* $auth["admin"][] = "pop3user2";
*/
class AuthPop3 extends Auth
{
private const CONNECT_TIMEOUT = 15; // seconds
private const STREAM_TIMEOUT = 15; // seconds
private $hosts;
private $ports;
public function __construct()
{
global $pop3_host, $pop3_port;
// Build an array of hosts and ports from the config settings
$this->hosts = array();
$this->ports = array();
// Check that if there is an array of hosts and an array of ports
// then the number of each is the same
if (is_array($pop3_host) && is_array($pop3_port) &&
(count($pop3_port) != count($pop3_host)))
{
$message = "MRBS config error: number of POP3 hosts does not match number of POP3 ports.";
throw new Exception($message);
}
// Transfer the list of POP3 hosts to a new value to ensure that an array is always used.
// If a single value is passed then turn it into an array
$this->hosts = (is_array($pop3_host)) ? $pop3_host : array($pop3_host);
// Create an array of the port numbers to match the number of
// hosts if a single port number has been passed.
$this->ports = (is_array($pop3_port)) ? $pop3_port : array_pad($this->ports, count($this->hosts), $pop3_port);
}
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
// Check if we do not have a username/password
if (!isset($user) || !isset($pass) || strlen($pass)==0)
{
return false;
}
// iterate over all hosts and return if you get a successful login
foreach ($this->hosts as $i => $host)
{
$port = $this->ports[$i];
// Connect to POP3 server
$stream = fsockopen($host, $port, $error_number, $error_string, self::CONNECT_TIMEOUT);
if ($stream === false)
{
continue;
}
stream_set_timeout($stream, self::STREAM_TIMEOUT);
$response = fgets($stream, 1024);
if ($response === false)
{
trigger_error("fgets() failed using host '$host' and port '$port'", E_USER_WARNING);
continue;
}
// First we try to use APOP, and then if that fails we fall back to
// traditional stuff
// Get the shared secret ( something on the greeting line that looks like <XXXX> )
if (preg_match('/(<[^>]*>)/', $response, $match))
{
$shared_secret = $match[0];
}
// If we have a shared secret then try APOP
if (isset($shared_secret) && ($shared_secret !== ''))
{
$md5_token = md5("$shared_secret$pass");
$auth_string = "APOP $user $md5_token\r\n";
fputs($stream, $auth_string);
// Read the response. If it's an OK then we're authenticated
$response = fgets($stream, 1024);
if (str_starts_with($response, '+OK'))
{
fputs($stream, "QUIT\r\n");
return $user;
}
}
// If we've still not authenticated then try using traditional methods.
// Need to reconnect if we tried APOP
$stream = fsockopen($host, $port, $error_number, $error_string, self::CONNECT_TIMEOUT);
if ($stream === false)
{
continue;
}
stream_set_timeout($stream, self::STREAM_TIMEOUT);
// Send standard POP3 USER and PASS commands
fputs($stream, "USER $user\r\n");
$response = fgets($stream, 1024);
if (str_starts_with($response, '+OK'))
{
fputs($stream, "PASS $pass\r\n");
$response = fgets($stream, 1024);
if (str_starts_with($response, '+OK'))
{
return $user;
}
}
fputs($stream, "QUIT\r\n");
}
// Return failure
return false;
}
/**
*/
public function canValidateByEmail() : bool
{
return true;
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
namespace MRBS\Auth;
use MRBS\User;
/**
* Authentication management scheme that delegates everything to a ready
* configured SimpleSamlPhp instance. You should use this scheme, along with
* the session scheme with the same name, if you want your users to
* authenticate using SAML Single Sign-on.
*
* See the session management scheme with the same name for information on
* how to configure SAML authentication. This authentication module on its
* own doesn't work.
*/
class AuthSaml extends Auth
{
public function __construct()
{
$this->checkSessionMatchesType();
}
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
$current_username = \MRBS\session()->getUsername();
if (isset($current_username) && $current_username === $user)
{
return $user;
}
return false;
}
protected function getUserFresh(string $username) : ?User
{
$user = new User($username);
$user->level = $this->getLevel($username);
$user->email = $this->getEmail($username);
$user->display_name = $this->getUserDisplayName($username);
return $user;
}
/* getLevel($username)
*
* Determines the user's access level
*
* It does this by comparing SAML attributes with $auth['saml']['admin']
* If any attribute matches, the user is considered admin and 2 is returned.
*
* If the user is not logged in, or the provided username doesn't match our
* SAML session, 0 is returned.
*
* Otherwise, 1 is returned.
*
* $username - The user name
*
* Returns:
* The user's access level
*/
private function getLevel(string $username) : int
{
global $auth;
if (!isset($auth['saml']['admin']))
{
return $this->getDefaultLevel($username);
}
$userData = \MRBS\session()->ssp->getAttributes();
$current_username = \MRBS\session()->getUsername();
if (isset($current_username) && $current_username === $username)
{
foreach ($auth['saml']['admin'] as $attr => $values)
{
if (array_key_exists($attr, $userData))
{
foreach ($values as $value)
{
if (in_array($value, $userData[$attr]))
{
return 2;
}
}
}
}
if (isset($auth['saml']['user']))
{
foreach ($auth['saml']['user'] as $attr => $values)
{
if (array_key_exists($attr, $userData))
{
foreach ($values as $value)
{
if (in_array($value, $userData[$attr]))
{
return 1;
}
}
}
}
return 0;
}
return 1;
}
return 0;
}
// Gets the users e-mail from the SAML attributes.
// Returns an empty string if no e-mail address was found
private function getEmail(string $username) : string
{
global $auth;
$mailAttr = $auth['saml']['attr']['mail'];
$userData = \MRBS\session()->ssp->getAttributes();
$current_username = \MRBS\session()->getUsername();
if (isset($current_username) && $current_username === $username)
{
return array_key_exists($mailAttr, $userData) ? $userData[$mailAttr][0] : '';
}
return '';
}
// Gets the users displayname from the SAML attributes.
// Returns an empty string if no givenName and surname was found
private function getUserDisplayName(string $username) : string
{
global $auth;
$givenNameAttr = $auth['saml']['attr']['givenName'];
$surnameAttr = $auth['saml']['attr']['surname'];
$userData = \MRBS\session()->ssp->getAttributes();
$current_username = \MRBS\session()->getUsername();
if (isset($current_username) && $current_username === $username)
{
$givenName = array_key_exists($givenNameAttr, $userData) ? $userData[$givenNameAttr][0] : '';
$surname = array_key_exists($surnameAttr, $userData) ? $userData[$surnameAttr][0] : '';
return trim($givenName . ' ' . $surname);
}
return '';
}
}
+228
View File
@@ -0,0 +1,228 @@
<?php
namespace MRBS\Auth;
use MRBS\User;
/**
* A class for authenticating against a Wix system. It requires code to be installed in
* http-functions.js in the Wix backend. See wix/README for full details.
*
* It would be nice to be able to use Wix's OAuth2 server, but it seems to be limited to Wix apps.
*
* Another approach might be to use the Wix backend function getMember(), but this doesn't work inside
* http_functions as the URL endpoint for http_functions is not associated with a user session.
*/
class AuthWix extends Auth
{
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
if (!isset($user) || !isset($pass))
{
return false;
}
$params = array(
'email' => $user,
'password' => $pass
);
$result = $this->http_functions('validateMember', $params);
if ($result === false)
{
// curl_exec failure: we'll return false anyway
return $result;
}
return (json_decode($result)) ? $user : false;
}
/**
*/
public function canValidateByEmail() : bool
{
return true;
}
/**
*/
public function canValidateByUsername() : bool
{
return false;
}
protected function getUserFresh(string $username) : ?User
{
global $auth;
$params = array('email' => $username);
$result = $this->http_functions('getMemberByEmail', $params);
// The username doesn't exist - return NULL
if (($result === false) || ($result === '') || ($result === json_encode(null)))
{
return null;
}
$result = json_decode($result);
$user = new User($username);
// Set the email address
$user->email = $result->member->loginEmail;
// Set the display name
$property = $auth['wix']['display_name_property'] ?? 'name';
if (isset($result->member->$property) && ($result->member->$property !== ''))
{
$user->display_name = $result->member->$property;
}
else
{
$user->display_name = $result->member->loginEmail;
}
// Set the level
// First get the default level. Any admins defined in the config
// file override settings in the external database.
$user->level = $this->getDefaultLevel($username);
// Then if they are not an admin get their admin status from Wix
if (($user->level < 2) && isset($result->badges) && in_array($auth['wix']['admin_badge'], $result->badges))
{
$user->level = 2;
}
return $user;
}
// Return an array of users, indexed by 'username' and 'display_name'
public function getUsernames() : array
{
global $auth;
$params = array();
if (isset($auth['wix']['display_name_property']))
{
$params['display_name_property'] = $auth['wix']['display_name_property'];
}
$result = $this->http_functions('getMemberNames', $params);
if ($result === false)
{
return array();
}
$users = json_decode($result, true);
self::sortUsers($users);
return $users;
}
private function http_functions(string $function, array $params)
{
global $auth, $server;
// Add a trailing '/' if necessary
if (!str_ends_with($auth['wix']['site_url'], '/'))
{
$auth['wix']['site_url'] .= '/';
}
// Get a user agent to keep the other end happy
if (isset($server['HTTP_USER_AGENT']) && ($server['HTTP_USER_AGENT'] !== ''))
{
$user_agent = $server['HTTP_USER_AGENT'];
}
else
{
$user_agent = 'PHP';
}
// Add in the API key
$params['key'] = $auth['wix']['mrbs_api_key'];
// And the API key secret name in Wix
$params['secret_name'] = $auth['wix']['mrbs_api_key_secret_name'];
// And the limit, for internal use by the Wix backend code
if (isset($auth['wix']['limit']))
{
$params['limit'] = $auth['wix']['limit'];
}
$url = $auth['wix']['site_url'] . "_functions/$function";
self::debug("URL=\"$url\"");
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Necessary to prevent "HTTP/2 stream 0 was not closed cleanly: INTERNAL_ERROR (err 2)" error;
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
// Necessary to prevent "OpenSSL SSL_read: Connection reset by peer, errno 104" error;
curl_setopt($ch, CURLOPT_USERAGENT, $user_agent);
curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json'));
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($params));
// Use compression if we can
if (defined('CURLOPT_ENCODING'))
{
curl_setopt($ch, CURLOPT_ENCODING, '');
}
// Get some debug info in case there's an error
curl_setopt($ch, CURLOPT_VERBOSE, true);
$stream = fopen('php://temp', 'w+');
curl_setopt($ch, CURLOPT_STDERR, $stream);
$result = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($result === false)
{
rewind($stream);
$message = curl_error($ch);
$message .= "\n\nCurl verbose log:\n\n" . stream_get_contents($stream);
trigger_error($message, E_USER_WARNING);
}
elseif ($http_code != 200)
{
trigger_error("Curl received HTTP response code $http_code: $result", E_USER_WARNING);
$result = false;
}
fclose($stream);
// curl_close() doesn't do anything from PHP 8.0 onwards (because the curl
// handle is an object and not a resource) and is deprecated from PHP 8.5.
assert(version_compare(MRBS_MIN_PHP_VERSION, '8.0.0', '<'), "The code below is now redundant.");
if (version_compare(PHP_VERSION, '8.0.0') < 0)
{
curl_close($ch);
}
return $result;
}
private static function debug(string $message) : void
{
global $auth;
if ($auth['wix']['debug'])
{
self::logDebugMessage($message);
}
}
}
+179
View File
@@ -0,0 +1,179 @@
<?php
namespace MRBS\Auth;
use MRBS\User;
require_once MRBS_ROOT . '/auth/cms/wordpress.inc';
class AuthWordpress extends Auth
{
public function __construct()
{
$this->checkSessionMatchesType();
}
public function validateUser(
#[\SensitiveParameter]
?string $user,
#[\SensitiveParameter]
?string $pass)
{
return (is_wp_error(wp_authenticate($user, $pass))) ? false : $user;
}
protected function getUserFresh(string $username) : ?User
{
$wp_user = get_user_by('login', $username);
if ($wp_user === false)
{
return null;
}
$user = new User($username);
$user->display_name = $wp_user->display_name;
$user->email = $wp_user->user_email;
$user->level = self::getUserLevel($wp_user);
return $user;
}
/**
*/
public function canValidateByEmail() : bool
{
// In the case of WordPress, wp_authenticate() accepts either
// a username or email address and so this function always returns true.
return true;
}
// Return an array of MRBS users, indexed by 'username' and 'display_name'
public function getUsernames() : array
{
global $auth;
$result = array();
// We are only interested in MRBS users and admins
$mrbs_roles = array_merge((array)$auth['wordpress']['admin_roles'],
(array)$auth['wordpress']['user_roles']);
// The 'role__in' argument to get_users() is only supported in Wordpress >= 4.4.
// Before that we have to do it one role at a time with the 'role' argument.
$can_use_role__in = version_compare(get_bloginfo('version'), '4.4', '>=');
$args = array('fields' => array('user_login', 'display_name'),
'orderby' => 'display_name',
'order' => 'ASC');
if ($can_use_role__in)
{
$args['role__in'] = $mrbs_roles;
$users = get_users($args);
}
else
{
$users = array();
$mrbs_roles = array_unique($mrbs_roles);
foreach ($mrbs_roles as $mrbs_role)
{
$args['role'] = $mrbs_role;
$users = array_merge($users, get_users($args));
}
// Remove duplicate users
$users = array_map('unserialize', array_unique(array_map('serialize', $users)));
}
foreach ($users as $user)
{
$result[] = array('username' => $user->user_login,
'display_name' => $user->display_name);
}
// Although the users are probably already sorted, we sort them again because MRBS
// offers an option for sorting by first or last name.
self::sortUsers($result);
return $result;
}
private static function getUserLevel(\WP_User $wp_user) : int
{
global $auth;
// cache the user levels for performance
static $user_levels = array();
// User not logged in, user level '0'
// Shouldn't get here anyway because the type hint won't allow it,
// but we'll check anyway for completeness
if(!isset($wp_user) || ($wp_user === false))
{
return 0;
}
if (!isset($user_levels[$wp_user->login]))
{
// Check to see if one of the user's roles is an MRBS blacklisted role
if (isset($auth['wordpress']['blacklisted_roles']) &&
self::check_roles($wp_user, $auth['wordpress']['blacklisted_roles']))
{
$user_levels[$wp_user->login] = 0;
}
// Check to see if one of the user's roles is an MRBS admin role
elseif (isset($auth['wordpress']['admin_roles']) &&
self::check_roles($wp_user, $auth['wordpress']['admin_roles']))
{
$user_levels[$wp_user->login] = 2;
}
// Check to see if one of the user's roles is an MRBS user role
elseif (isset($auth['wordpress']['user_roles']) &&
self::check_roles($wp_user, $auth['wordpress']['user_roles']))
{
$user_levels[$wp_user->login] = 1;
}
// Everybody else is access level '0'
else
{
$user_levels[$wp_user->login] = 0;
}
}
return $user_levels[$wp_user->login];
}
// Checks to see whether any of the user's roles are contained in $mrbs_roles, which can be a
// string or an array of strings.
private static function check_roles(\WP_User $wp_user, $mrbs_roles) : bool
{
if (!isset($mrbs_roles))
{
return false;
}
// Turn $mrbs_roles into an array if it isn't already
$mrbs_roles = (array)$mrbs_roles;
// Put the roles into the standard WordPress format
$mrbs_roles = array_map(self::class . '::standardise_role_name', $mrbs_roles);
return (count(array_intersect($wp_user->roles, $mrbs_roles)) > 0);
}
// Convert a WordPress role name to lowercase and replace spaces by underscores.
// Example "MRBS Admin" -> "mrbs_admin"
private static function standardise_role_name(string $role) : string
{
return str_replace(' ', '_', mb_strtolower($role));
}
}
+411
View File
@@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
use MRBS\DateTime;
use function MRBS\datetime_format;
use function MRBS\day_past_midnight;
use function MRBS\escape_html;
use function MRBS\format_iso_date;
use function MRBS\get_vocab;
use function MRBS\is_hidden_day;
use function MRBS\multisite;
abstract class Calendar
{
protected $day;
protected $month;
protected $year;
protected $area_id;
protected $room_id;
protected $view;
protected $view_all;
protected $start_date;
protected $end_date;
protected $map;
public function __construct(string $view, int $view_all, int $year, int $month, int $day, int $area_id, int $room_id)
{
$this->view = $view;
$this->view_all = $view_all;
$this->year = $year;
$this->month = $month;
$this->day = $day;
$this->area_id = $area_id;
$this->room_id = $room_id;
}
abstract public function innerHTML() : string;
// Get a series of flex divs for a room in a day index interval for the map.
protected function flexDivsHTML(int $room_id, int $start_day_index, int $end_day_index) : string
{
global $resolution;
$html = '';
// Get the time slots
$n_time_slots = self::getNTimeSlots();
$morning_slot_seconds = self::morningSlotsSeconds();
$evening_slot_seconds = $morning_slot_seconds + (($n_time_slots - 1) * $resolution);
// Loop through the days in the interval
for ($i=$start_day_index; $i<=$end_day_index; $i++)
{
$s = $morning_slot_seconds;
// Loop through the slots in the day
while ($s <= $evening_slot_seconds)
{
// Get the entry for this slot
$this_slot = $this->map->slot($room_id, $i, $s);
// Start a FlexDiv if we haven't got one
if (!isset($flex_div))
{
$flex_div = new FlexDiv($this_slot[0]['id'] ?? null);
// If it's a booking, set the properties
if (!empty($this_slot))
{
$this_entry = $this_slot[0];
$flex_div->setClasses($this->getEntryClasses($this_entry));
$flex_div->setLength($this_entry['n_slots']);
$flex_div->setName($this_entry['name']);
}
// Work out how many slots to advance
$n = $flex_div->getLength();
}
// Otherwise, look to see whether this is a continuation of the stored entry,
// or else a change, in which case output the stored entry and reset.
else
{
// Another free slot
if (empty($this_slot) && !isset($flex_div->id))
{
$n = 1;
$flex_div->addLength($n);
}
// A continuation of an existing booking
elseif (!empty($this_slot) && isset($flex_div->id) && ($flex_div->id == $this_slot[0]['id']))
{
$n = $this_slot[0]['n_slots'];
$flex_div->addLength($n);
}
// There's been a change. Output the FlexDiv and reset
else
{
$html .= $flex_div->html();
unset($flex_div);
$n = 0;
}
}
$s = $s + ($n * $resolution); // Advance n slots
}
}
// Output the final FlexDiv
if (isset($flex_div))
{
$html .= $flex_div->html();
}
return $html;
}
protected function getDate(int $t) : string
{
global $datetime_formats;
if (in_array($this->view, ['month', 'year']))
{
return datetime_format(['pattern' => 'd'], $t);
}
else
{
return datetime_format($datetime_formats['view_week_day_month'], $t);
}
}
private function getDay(int $t) : string
{
// In the month view use a pattern which will tend to give a narrower result, to save space.
$pattern = ($this->view == 'month') ? 'cccccc' : 'ccc';
return datetime_format(['pattern' => $pattern], $t);
}
public static function morningSlotsSeconds() : int
{
global $morningstarts, $morningstarts_minutes;
return (($morningstarts * 60) + $morningstarts_minutes) * 60;
}
// Gets the number of time slots between the beginning and end of the booking
// day. (This is the normal number on a non-DST transition day)
public static function getNTimeSlots() : int
{
global $eveningends, $eveningends_minutes;
global $resolution;
$start_first = self::morningSlotsSeconds(); // seconds
$end_last = ((($eveningends * 60) + $eveningends_minutes) * 60) + $resolution; // seconds
$end_last = $end_last % SECONDS_PER_DAY;
if (day_past_midnight())
{
$end_last += SECONDS_PER_DAY;
}
// Force the result to be an int. It normally will be, but might not be if, say,
// $force_resolution is set.
return intval(($end_last - $start_first)/$resolution);
}
// If we're not using periods, construct an array describing the slots to pass to the JavaScript so that
// it can calculate where the timeline should be drawn. (If we are using periods then the timeline is
// meaningless because we don't know when periods begin and end.)
// $month, $day, $year the start of the interval
// $n_days the number of days in the interval
// $day_cells if the columns/rows represent a full day (as in the week/month all rooms views)
protected function getSlots(int $month, int $day, int $year, int $n_days=1, bool $day_cells=false) : ?array
{
global $enable_periods, $morningstarts, $morningstarts_minutes, $resolution;
if ($enable_periods)
{
return null;
}
$slots = array();
$n_time_slots = self::getNTimeSlots();
$morning_slot_seconds = self::morningSlotsSeconds();
$evening_slot_seconds = $morning_slot_seconds + (($n_time_slots - 1) * $resolution);
for ($j = 0; $j < $n_days; $j++)
{
$d = $day + $j;
// If there's more than one day in the interval then don't include the hidden days in the array, because
// they don't appear in the DOM. If there's only one day then we've managed to display the hidden day.
if (($n_days > 1) &&
is_hidden_day(intval(date('w', mktime($morningstarts, $morningstarts_minutes, 0, $month, $d, $year)))))
{
continue;
}
$this_day = array();
if ($day_cells)
{
$this_day[] = mktime(0, 0, $morning_slot_seconds, $month, $d, $year);
// Need to do mktime() again for the end of the slot as we can't assume that the end slot is $resolution
// seconds after the start of the slot because of the possibility of DST transitions
$this_day[] = mktime(0, 0, $evening_slot_seconds + $resolution, $month, $d, $year);
}
else
{
for ($s = $morning_slot_seconds;
$s <= $evening_slot_seconds;
$s += $resolution)
{
$this_slot = array();
$this_slot[] = mktime(0, 0, $s, $month, $d, $year);
// Need to do mktime() again for the end of the slot as we can't assume that the end slot is $resolution
// seconds after the start of the slot because of the possibility of DST transitions
$this_slot[] = mktime(0, 0, $s + $resolution, $month, $d, $year);
$this_day[] = $this_slot;
}
}
$slots[] = $this_day;
}
if ($day_cells)
{
$slots = array($slots);
}
return $slots;
}
// Get classes for weekends, holidays, etc.
protected function getDateClasses(DateTime $date) : array
{
$result = [];
if ($date->isWeekend())
{
$result[] = 'weekend';
}
if ($date->isHoliday())
{
$result[] = 'holiday';
}
if ($date->isToday())
{
$result[] = 'today';
}
return $result;
}
// Returns an array of classes to be used for the entry
protected function getEntryClasses(array $entry) : array
{
global $approval_enabled, $confirmation_enabled;
$classes = array($entry['type']);
if ($entry['private'])
{
$classes[] = 'private';
}
if ($approval_enabled && ($entry['awaiting_approval']))
{
$classes[] = 'awaiting_approval';
}
if ($confirmation_enabled && ($entry['tentative']))
{
$classes[] = 'tentative';
}
if (isset($entry['repeat_id']))
{
$classes[] = 'series';
}
if ($entry['allow_registration'])
{
if ($entry['registrant_limit_enabled'] &&
($entry['n_registered'] >= $entry['registrant_limit']))
{
$classes[] = 'full';
}
else
{
$classes[] = 'spaces';
}
}
return $classes;
}
protected function multidayHeaderRowsHTML(int $day_start_interval, int $n_days, int $start_dow, string $label='') : array
{
global $row_labels_both_sides;
$result = array();
$n_rows = 2;
// Loop through twice: one row for the days of the week, the next for the date.
for ($i = 0; $i < $n_rows; $i++)
{
$result[$i] = "<tr>\n";
// Could use a rowspan here, but we'd need to make sure the sticky cells work
// and change the JavaScript in refresh.js.php
$text = ($i == 0) ? '' : $label;
$first_last_html = '<th class="first_last">' . escape_html($text) . "</th>\n";
$result[$i] .= $first_last_html;
$vars = [
'view' => 'day',
'view_all' => $this->view_all,
'area' => $this->area_id,
'room' => $this->room_id
];
// the standard view, with days along the top and rooms down the side
for ($j = 0; $j < $n_days; $j++)
{
if (is_hidden_day(($j + $start_dow) % DAYS_PER_WEEK))
{
continue;
}
$vars['page_date'] = format_iso_date($this->year, $this->month, $day_start_interval + $j);
$link = "index.php?" . http_build_query($vars, '', '&');
$link = multisite($link);
$t = mktime(12, 0, 0, $this->month, $day_start_interval + $j, $this->year);
$text = ($i === 0) ? $this->getDay($t) : $this->getDate($t);
$date = new DateTime();
$date->setTimestamp($t);
$classes = $this->getDateClasses($date);
$result[$i] .= '<th' .
// Add the date for JavaScript. Only really necessary for the first row in
// the week view when not viewing all the rooms, but just add it always.
' data-date="' . escape_html($date->getISODate()) . '"' .
((!empty($classes)) ? ' class="' . implode(' ', $classes) . '"' : '') .
'><a href="' . escape_html($link) . '">' . escape_html($text) . "</a></th>\n";
}
// next line to display rooms on right side
if ($row_labels_both_sides)
{
$result[$i] .= $first_last_html;
}
$result[$i] .= "</tr>\n";
}
return $result;
}
// Draw a room cell to be used in the header rows/columns of the calendar views
// $room contains the room details
// $vars an associative array containing the variables to be used to build the link
protected function roomCellHTML(array $room, array $vars) : string
{
$link = 'index.php?' . http_build_query($vars, '', '&');
$link = multisite($link);
switch ($vars['view'])
{
case 'day':
$tag = 'viewday';
break;
case 'week':
$tag = 'viewweek';
break;
case 'month':
$tag = 'viewmonth';
break;
case 'year':
$tag = 'viewyear';
break;
default:
trigger_error("Unknown view '" . $vars['view'] . "'", E_USER_NOTICE);
$tag = 'viewweek';
break;
}
$title = get_vocab($tag) . "\n\n" . $room['description'];
$html = '';
$html .= '<th data-room="' . escape_html($room['id']) . '">';
$html .= '<a href="' . escape_html($link) . '"' .
' title = "' . escape_html($title) . '">';
$html .= escape_html($room['room_name']);
// Put the capacity in a span to give flexibility in styling
$html .= '<span class="capacity';
if ($room['capacity'] == 0)
{
$html .= ' zero';
}
$html .= '">' . escape_html($room['capacity']);
$html .= '</span>';
$html .= '</a>';
$html .= "</th>\n";
return $html;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
use InvalidArgumentException;
class CalendarFactory
{
public static function create(
string $view,
int $view_all,
int $year,
int $month,
int $day,
int $area_id,
int $room_id,
?int $timetohighlight=null,
?string $kiosk=null) : Calendar
{
switch ($view)
{
case 'day':
return new CalendarMultislotDay($view, $view_all, $year, $month, $day, $area_id, $room_id, $timetohighlight, $kiosk);
break;
case 'week':
if ($view_all)
{
return new CalendarMultidayMultiroom($view, $view_all, $year, $month, $day, $area_id, $room_id);
}
return new CalendarMultislotWeek($view, $view_all, $year, $month, $day, $area_id, $room_id, $timetohighlight);
break;
case 'month':
if ($view_all)
{
return new CalendarMultidayMultiroom($view, $view_all, $year, $month, $day, $area_id, $room_id);
}
return new CalendarMonthOneRoom($view, $view_all, $year, $month, $day, $area_id, $room_id);
break;
case 'year':
if ($view_all)
{
return new CalendarMultimonthMultiroom($view, $view_all, $year, $month, $day, $area_id, $room_id);
}
return new CalendarMultimonthOneRoom($view, $view_all, $year, $month, $day, $area_id, $room_id);
break;
default:
throw new InvalidArgumentException("Invalid view: $view");
break;
}
}
}
@@ -0,0 +1,386 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
use MRBS\DateTime;
use MRBS\Language;
use function MRBS\datetime_format;
use function MRBS\day_name;
use function MRBS\escape_html;
use function MRBS\get_end_last_slot;
use function MRBS\get_entries_by_room;
use function MRBS\get_room_name;
use function MRBS\get_start_first_slot;
use function MRBS\get_vocab;
use function MRBS\hour_min_format;
use function MRBS\is_book_admin;
use function MRBS\is_hidden_day;
use function MRBS\is_visible;
use function MRBS\is_weekend;
use function MRBS\multisite;
use function MRBS\period_time_string;
use function MRBS\session;
class CalendarMonthOneRoom extends Calendar
{
public function innerHTML(): string
{
global $weekstarts, $view_week_number, $show_plus_link, $monthly_view_entries_details;
global $enable_periods, $morningstarts, $morningstarts_minutes;
global $prevent_booking_on_holidays, $prevent_booking_on_weekends;
// Check that we've got a valid, enabled room
if (is_null(get_room_name($this->room_id)) || !is_visible($this->room_id))
{
// No rooms have been created yet, or else they are all disabled
// Add an 'empty' data flag so that the JavaScript knows whether this is a real table or not
return "<tbody data-empty=1><tr><td><h1>".get_vocab("no_rooms_for_area")."</h1></td></tr></tbody>";
}
$html = '';
// Month view start time. This ignores morningstarts/eveningends because it
// doesn't make sense to not show all entries for the day, and it messes
// things up when entries cross midnight.
$month_start = mktime(0, 0, 0, $this->month, 1, $this->year);
// What column the month starts in: 0 means $weekstarts weekday.
$weekday_start = (date("w", $month_start) - $weekstarts + DAYS_PER_WEEK) % DAYS_PER_WEEK;
$last_day_of_month = (int) date("t", $month_start);
$html .= $this->theadHTML();
// Main body
$html .= "<tbody>\n";
$html .= "<tr>\n";
// Skip days in week before the start of the month:
for ($weekcol = 0; $weekcol < $weekday_start; $weekcol++)
{
$html .= $this->tdBlankDayHTML($weekcol);
}
$start_date = (new DateTime())->setTimestamp(get_start_first_slot($this->month, 1, $this->year));
$end_date = (new DateTime())->setTimestamp(get_end_last_slot($this->month, $last_day_of_month, $this->year));
// Get the data. It's much quicker to do a single SQL query getting all the
// entries for the interval in one go, rather than doing a query for each day.
$entries = get_entries_by_room($this->room_id, $start_date, $end_date);
// Draw the days of the month:
for ($d = 1, $date = clone $start_date; $d <= $last_day_of_month; $d++, $date->modify('+1 day'))
{
// Get the slot times
$start_first_slot = get_start_first_slot($this->month, $d, $this->year);
$end_last_slot = get_end_last_slot($this->month, $d, $this->year);
// if we're at the start of the week (and it's not the first week), start a new row
if (($weekcol == 0) && ($d > 1))
{
$html .= "</tr><tr>\n";
}
// output the day cell
if ($date->isHiddenDay())
{
// These days are to be hidden in the display (as they are hidden, just give the
// day of the week in the header row)
$html .= "<td class=\"hidden_day\">\n";
$html .= "<div class=\"cell_container\">\n";
$html .= "<div class=\"cell_header\">\n";
// first put in the day of the month
$html .= "<span>$d</span>\n";
$html .= "</div>\n";
$html .= "</div>\n";
$html .= "</td>\n";
}
else
{
// Add classes for weekends and holidays
$classes = $this->getDateClasses($date);
$html .= '<td' . ((empty($classes)) ? '' : ' class="' . implode(' ', $classes) . '"') . ">\n";
$html .= "<div class=\"cell_container\">\n";
$html .= "<div class=\"cell_header\">\n";
$vars = [
'page_date' => $date->getISODate(),
'area' => $this->area_id,
'room' => $this->room_id
];
// If it's the first day of the week, show the week number
if ($view_week_number && $date->isFirstDayOfWeek(Language::getInstance()->getWebLocale()))
{
$vars['view'] = 'week';
$query = http_build_query($vars, '', '&');
$html .= '<a class="week_number" href="' . escape_html(multisite("index.php?$query")) . '">';
$html .= $date->format('W');
$html .= "</a>\n";
}
// then put in the day of the month
$vars['view'] = 'day';
$query = http_build_query($vars, '', '&');
$html .= '<a class="monthday" href="' . escape_html(multisite("index.php?$query")) . "\">$d</a>\n";
$html .= "</div>\n";
// Then the link to make a new booking.
// Don't provide a link if the slot doesn't really exist or if the user is logged in, but not a booking admin,
// and it's a holiday/weekend and bookings on holidays/weekends are not allowed. (We provide a link if they
// are not logged in because they might want to click and login as an admin).
if ((null !== session()->getCurrentUser()) && !is_book_admin($this->room_id) &&
(($prevent_booking_on_holidays && in_array('holiday', $classes)) ||
($prevent_booking_on_weekends && in_array('weekend', $classes))))
{
$html .= '<span class="not_allowed"></span>';
}
else
{
$vars['view'] = $this->view;
if ($enable_periods)
{
$vars['period'] = 0;
}
else
{
$vars['hour'] = $morningstarts;
$vars['minute'] = $morningstarts_minutes;
}
$query = http_build_query($vars, '', '&');
$html .= '<a class="new_booking" href="' . escape_html(multisite("edit_entry.php?$query")) . '"' .
' aria-label="' . escape_html(get_vocab('create_new_booking')) . "\">\n";
if ($show_plus_link)
{
$html .= "<img src=\"images/new.gif\" alt=\"New\" width=\"10\" height=\"10\">\n";
}
$html .= "</a>\n";
}
// then any bookings for the day
$html .= "<div class=\"booking_list\">\n";
// Show the start/stop times, 1 or 2 per line, linked to view_entry.
foreach ($entries as $entry)
{
// We are only interested in this day's entries
if (($entry['start_time'] >= $end_last_slot) ||
($entry['end_time'] <= $start_first_slot))
{
continue;
}
$entry = Map::prepareEntry($entry);
$classes = $this->getEntryClasses($entry);
$classes[] = $monthly_view_entries_details;
$html .= '<div class="' . implode(' ', $classes) . '">';
$vars = [
'id' => $entry['id'],
'year' => $this->year,
'month' => $this->month,
'day' => $d
];
$query = http_build_query($vars, '', '&');
$booking_link = multisite("view_entry.php?$query");
$slot_text = $this->bookingSummaryHTML(
$entry['start_time'],
$entry['end_time'],
$start_first_slot,
$end_last_slot
);
$description_text = mb_substr($entry['name'], 0, 255);
$full_text = $slot_text . " " . $description_text;
switch ($monthly_view_entries_details)
{
case "description":
{
$display_text = $description_text;
break;
}
case "slot":
{
$display_text = $slot_text;
break;
}
case "both":
{
$display_text = $full_text;
break;
}
default:
{
$html .= "error: unknown parameter";
}
}
$title_text = $full_text;
if (isset($entry['description']) && ($entry['description'] !== ''))
{
$title_text .= "\n\n" . $entry['description'];
}
$html .= '<a href="' . escape_html($booking_link) . '"' .
' title="' . escape_html($title_text) . '">';
$html .= escape_html($display_text) . '</a>';
$html .= "</div>\n";
}
$html .= "</div>\n";
$html .= "</div>\n";
$html .= "</td>\n";
}
// increment the day of the week counter
if (++$weekcol == DAYS_PER_WEEK)
{
$weekcol = 0;
}
} // end of for loop going through valid days of the month
// Skip from end of month to end of week:
if ($weekcol > 0)
{
for (; $weekcol < DAYS_PER_WEEK; $weekcol++)
{
$html .= $this->tdBlankDayHTML($weekcol);
}
}
$html .= "</tr>\n";
$html .= "</tbody>\n";
return $html;
}
// Describe the start and end time, accounting for "all day"
// and for entries starting before/ending after today.
// There are 9 cases, for start time < = or > midnight this morning,
// and end time < = or > midnight tonight.
private function bookingSummaryHTML(int $start, int $end, int $day_start, int $day_end) : string
{
global $enable_periods, $area;
// Use ~ (not -) to separate the start and stop times, because MSIE
// will incorrectly line break after a -.
$separator = '~';
$after_today = "==>";
$before_today = "<==";
$midnight = "24:00"; // need to fix this so it works with AM/PM configurations (and for that matter 24h)
$all_day = get_vocab('all_day');
if ($enable_periods)
{
$start_str = escape_html(period_time_string($start, $area));
$end_str = escape_html(period_time_string($end, $area, true));
}
else
{
$start_str = escape_html(datetime_format(hour_min_format(), $start));
$end_str = escape_html(datetime_format(hour_min_format(), $end));
}
switch (self::cmp3($start, $day_start) . self::cmp3($end, $day_end + 1))
{
case "> < ": // Starts after midnight, ends before midnight
case "= < ": // Starts at midnight, ends before midnight
$result = $start_str;
// Don't bother showing the end if it's the same as the start period
if ($end_str !== $start_str)
{
$result .= $separator . $end_str;
}
break;
case "> = ": // Starts after midnight, ends at midnight
$result = $start_str . $separator . $midnight;
break;
case "> > ": // Starts after midnight, continues tomorrow
$result = $start_str . $separator . $after_today;
break;
case "= = ": // Starts at midnight, ends at midnight
$result = $all_day;
break;
case "= > ": // Starts at midnight, continues tomorrow
$result = $all_day . $after_today;
break;
case "< < ": // Starts before today, ends before midnight
$result = $before_today . $separator . $end_str;
break;
case "< = ": // Starts before today, ends at midnight
$result = $before_today . $all_day;
break;
case "< > ": // Starts before today, continues tomorrow
$result = $before_today . $all_day . $after_today;
break;
}
return $result;
}
// 3-value compare: Returns result of compare as "< " "= " or "> ".
private static function cmp3(int $a, int $b) : string
{
if ($a < $b)
{
return "< ";
}
if ($a == $b)
{
return "= ";
}
return "> ";
}
private function tdBlankDayHTML(int $col) : string
{
global $weekstarts;
$td_class = (is_hidden_day(($col + $weekstarts) % DAYS_PER_WEEK)) ? 'hidden_day' : 'invalid';
return "<td class=\"$td_class\"><div class=\"cell_container\">&nbsp;</div></td>\n";
}
// Gets the table head for the single room month view
private function theadHTML() : string
{
global $weekstarts;
$html = '';
// Weekday name header row:
$html .= "<thead>\n";
$html .= "<tr>\n";
for ($i = 0; $i< DAYS_PER_WEEK; $i++)
{
$classes = [];
$dow = ($i + $weekstarts) % DAYS_PER_WEEK;
if (is_hidden_day($dow))
{
$classes[] = 'hidden_day';
}
if (is_weekend($dow))
{
$classes[] = 'weekend';
}
$html .= '<th';
if (!empty($classes))
{
$html .= ' class="' . implode(' ', $classes) . '"';
}
$html .= '>' . day_name(($i + $weekstarts)%DAYS_PER_WEEK) . '</th>';
}
$html .= "\n</tr>\n";
$html .= "</thead>\n";
return $html;
}
}
@@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
use MRBS\DateTime;
use function MRBS\day_of_MRBS_week;
use function MRBS\escape_html;
use function MRBS\format_iso_date;
use function MRBS\get_end_last_slot;
use function MRBS\get_entries_by_area;
use function MRBS\get_rooms;
use function MRBS\get_start_first_slot;
use function MRBS\get_vocab;
use function MRBS\multisite;
class CalendarMultidayMultiroom extends Calendar
{
private $day_start_interval;
private $n_days;
private $start_dow;
public function __construct(string $view, int $view_all, int $year, int $month, int $day, int $area_id, int $room_id)
{
global $weekstarts, $resolution;
parent::__construct($view, $view_all, $year, $month, $day, $area_id, $room_id);
// Calculate/get:
// the first day of the interval
// how many days there are in it
// the day of the week of the first day in the interval
$time = mktime(12, 0, 0, $this->month, $this->day, $this->year);
switch ($this->view)
{
case 'week':
$skipback = day_of_MRBS_week($time);
$this->day_start_interval = $this->day - $skipback;
$this->n_days = DAYS_PER_WEEK;
$this->start_dow = $weekstarts;
break;
case 'month':
$this->day_start_interval = 1;
$this->n_days = (int) date('t', $time);
$this->start_dow = (int) date('N', mktime(12, 0, 0, $this->month, 1, $this->year));
break;
default:
trigger_error("Unsupported view '$this->view'", E_USER_WARNING);
break;
}
$this->start_date = (new DateTime())->setTimestamp(get_start_first_slot($this->month, $this->day_start_interval, $this->year));
$this->end_date = (new DateTime())->setTimestamp(get_end_last_slot($this->month, $this->day_start_interval + $this->n_days-1, $this->year));
// Get the data. It's much quicker to do a single SQL query getting all the
// entries for the interval in one go, rather than doing a query for each day.
$entries = get_entries_by_area($this->area_id, $this->start_date, $this->end_date);
// We want to build an array containing all the data we want to show and then spit it out.
$this->map = new Map($this->start_date, $this->end_date, $resolution);
$this->map->addEntries($entries);
}
// TODO: Handle the case where there is more than one booking per slot
public function innerHTML(): string
{
global $row_labels_both_sides, $column_labels_both_ends;
global $view_all_always_go_to_day_view;
// It's theoretically possible to display a transposed table with rooms along the top and days
// down the side. However, it doesn't seem a very useful display and so hasn't yet been implemented.
// The problem is that the results don't look good whether you have the flex direction as 'row' or
// 'column'. If you set it to 'row' the bookings are read from left to right within a day, but from
// top to bottom within the interval (week/month), so you have to read the display by snaking down
// the columns, which is potentially confusing. If you set it to 'column' then the bookings are in
// order reading straight down the column, but the text within the bookings is usually clipped unless
// the booking lasts the whole day. When the days are along the top and the text is clipped you can
// at least see the first few characters which is useful, but when the days are down the side you only
// see the top of the line.
//
// As a result $days_along_top is always true, but is here so that there can be stubs in the code in
// case people want a transposed view in future.
$days_along_top = true;
$rooms = get_rooms($this->area_id);
$n_rooms = count($rooms);
// Check to see whether there are any rooms in the area
if ($n_rooms == 0)
{
// Add an 'empty' data flag so that the JavaScript knows whether this is a real table or not
return "<tbody data-empty=1><tr><td><h1>" . get_vocab("no_rooms_for_area") . "</h1></td></tr></tbody>";
}
// TABLE HEADER
$thead = '<thead';
$slots = $this->getSlots($this->month, $this->day_start_interval, $this->year, $this->n_days, true);
if (isset($slots))
{
// Add some data to enable the JavaScript to draw the timeline
$thead .= ' data-slots="' . escape_html(json_encode($slots)) . '"';
$thead .= ' data-timeline-vertical="' . (($days_along_top) ? 'true' : 'false') . '"';
$thead .= ' data-timeline-full="true"';
}
$thead .= ">\n";
if ($days_along_top)
{
$header_inner_rows = $this->multidayHeaderRowsHTML($this->day_start_interval, $this->n_days, $this->start_dow);
}
else
{
// See comment above
trigger_error("Not yet implemented", E_USER_WARNING);
}
$thead .= implode('', $header_inner_rows);
$thead .= "</thead>\n";
// Now repeat the header in a footer if required
$tfoot = ($column_labels_both_ends) ? "<tfoot>\n" . implode('',array_reverse($header_inner_rows)) . "</tfoot>\n" : '';
// TABLE BODY LISTING BOOKINGS
$tbody = "<tbody>\n";
$room_link_vars = [
'view' => $this->view,
'view_all' => 0,
'page_date' => format_iso_date($this->year, $this->month, $this->day),
'area' => $this->area_id
];
if ($days_along_top)
{
// the standard view, with days along the top and rooms down the side
foreach ($rooms as $room)
{
$room_id = $room['id'];
$room_link_vars['room'] = $room_id;
$tbody .= "<tr>\n";
$row_label = $this->roomCellHTML($room, $room_link_vars);
$tbody .= $row_label;
for ($j = 0, $date = clone $this->start_date; $j < $this->n_days; $j++, $date->modify('+1 day'))
{
if ($date->isHiddenDay())
{
continue;
}
// Add classes for weekends, holidays, etc.
$classes = $this->getDateClasses($date);
$tbody .= '<td';
if (!empty($classes))
{
$tbody .= ' class="' . implode(' ', $classes) . '"';
}
$tbody .= '>';
$vars = [
'view_all' => $this->view_all,
'page_date' => $date->getISODate(),
'area' => $this->area_id,
'room' => $room['id']
];
// If there is more than one slot per day, then it can be very difficult to pick
// out an individual one, which could be just one pixel wide, so we go to the
// day view first where it's easier to see what you are doing. Otherwise, we go
// direct to edit_entry.php if the slot is free, or view_entry.php if it is not.
// Note: the structure of the cell, with a single link and multiple flex divs,
// only allows us to direct to the booking if there's only one slot per day.
if ($view_all_always_go_to_day_view || (self::getNTimeSlots() > 1))
{
$page = 'index.php';
$vars['view'] = 'day';
}
else
{
$vars['view'] = $this->view;
$this_slot = $this->map->slot($room_id, $j, Calendar::morningSlotsSeconds());
if (empty($this_slot))
{
$page = 'edit_entry.php';
}
else
{
$page = 'view_entry.php';
$vars['id'] = $this_slot[0]['id'];
}
}
$link = "$page?" . http_build_query($vars, '', '&');
$link = multisite($link);
$tbody .= '<a href="' . escape_html($link) . "\">\n";
$tbody .= $this->flexDivsHTML($room_id, $j, $j);
$tbody .= "</a>\n";
$tbody .= "</td>\n";
}
if ($row_labels_both_sides)
{
$tbody .= $row_label;
}
$tbody .= "</tr>\n";
}
}
else
{
// See comment above
trigger_error("Not yet implemented", E_USER_WARNING);
}
$tbody .= "</tbody>\n";
return $thead . $tfoot . $tbody;
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
use MRBS\DateTime;
use MRBS\Exception;
abstract class CalendarMultimonth extends Calendar
{
protected $n_months;
public function __construct(string $view, int $view_all, int $year, int $month, int $day, int $area_id, int $room_id)
{
global $year_start;
parent::__construct($view, $view_all, $year, $month, $day, $area_id, $room_id);
if ($view === 'year')
{
$this->n_months = MONTHS_PER_YEAR;
}
else
{
throw new Exception("Invalid view: '$view'");
}
// Get the start and end dates
$this->start_date = (new DateTime())->setDate($this->year, $this->month, 1);
$this->start_date->setMonthYearStart($year_start);
$this->start_date->setStartFirstSlot();
$this->end_date = clone $this->start_date;
$this->end_date->modify('+' . $this->n_months . ' month');
$this->end_date->modify('-1 day');
$this->end_date->setEndLastSlot();
}
}
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
use MRBS\DateTime;
use function MRBS\datetime_format;
use function MRBS\escape_html;
use function MRBS\format_iso_date;
use function MRBS\get_entries_by_area;
use function MRBS\get_rooms;
use function MRBS\get_vocab;
use function MRBS\multisite;
class CalendarMultimonthMultiroom extends CalendarMultimonth
{
public function __construct(string $view, int $view_all, int $year, int $month, int $day, int $area_id, int $room_id)
{
global $resolution;
parent::__construct($view, $view_all, $year, $month, $day, $area_id, $room_id);
// Get the entries. It's much quicker to do a single SQL query getting all the
// entries for the interval in one go, rather than doing a query for each day.
$entries = get_entries_by_area($this->area_id, $this->start_date, $this->end_date);
// We want to build an array containing all the data we want to show and then spit it out.
$this->map = new Map($this->start_date, $this->end_date, $resolution);
$this->map->addEntries($entries);
}
public function innerHTML(): string
{
global $column_labels_both_ends;
// Check to see whether there are any rooms in the area
$rooms = get_rooms($this->area_id);
if (count($rooms) == 0)
{
// Add an 'empty' data flag so that the JavaScript knows whether this is a real table or not
return "<tbody data-empty=1><tr><td><h1>" . get_vocab("no_rooms_for_area") . "</h1></td></tr></tbody>";
}
// Table header
$thead = '<thead';
// TODO: get_slots() for JavaScript
$thead .= ">\n";
$header_row = $this->headerRowHTML();
$thead .= $header_row;
$thead .= "</thead>\n";
// Table body
$tbody = "<tbody>\n";
foreach ($rooms as $room)
{
$tbody .= $this->bodyRowHTML($room);
}
$tbody .= "<tbody>\n";
// Table footer
$tfoot = ($column_labels_both_ends) ? "<tfoot>\n$header_row</tfoot>\n" : '';
return $thead . $tfoot . $tbody;
}
private function bodyRowHTML(array $room): string
{
global $row_labels_both_sides, $year_start;
$room_link_vars = [
'view' => $this->view,
'view_all' => 0,
'page_date' => format_iso_date($this->year, $this->month, $this->day),
'area' => $this->area_id
];
$html = "<tr>\n";
$room_link_vars['room'] = $room['id'];
$row_label = $this->roomCellHTML($room, $room_link_vars);
$html .= $row_label;
$date = (new DateTime())->setDate($this->year, $this->month, $this->day);
$date->setMonthYearStart($year_start);
// The variables for the link query string
$vars = [
'view' => 'month',
'view_all' => 0,
'area' => $this->area_id,
'room' => $room['id']
];
$j = 0; // Need to keep track of the day in the Calendar interval (zero indexed)
// Loop through the months in the interval
for ($i=0; $i<$this->n_months; $i++)
{
$html .= "<td>\n";
$vars['page_date'] = $date->getISODate();
$link = 'index.php?' . http_build_query($vars, '', '&');
$link = multisite($link);
$html .= '<a href="' . escape_html($link) . '">';
$days_in_month = $date->getDaysInMonth();
$html .= $this->flexDivsHTML($room['id'], $j, $j + $days_in_month - 1);
$html .= '</a>';
$html .= "</td>\n";
$j += $days_in_month;
$date->modifyMonthsNoOverflow(1, true); // Advance 1 month
}
if ($row_labels_both_sides)
{
$html .= $row_label;
}
$html .= "</tr>\n";
return $html;
}
private function headerRowHTML(string $label='') : string
{
global $datetime_formats, $row_labels_both_sides, $year_start;
$html = "<tr>\n";
// The left-hand header column
$first_last_html = '<th class="first_last">' . escape_html($label) . "</th>\n";
$html .= $first_last_html;
// The main header cells
$date = (new DateTime())->setDate($this->year, $this->month, $this->day);
$date->setMonthYearStart($year_start);
// The variables for the link query string
$vars = [
'view' => 'month',
'view_all' => $this->view_all,
'area' => $this->area_id,
'room' => $this->room_id
];
for ($i=0; $i<$this->n_months; $i++)
{
$vars['page_date'] = $date->getISODate();
$link = 'index.php?' . http_build_query($vars, '', '&');
$link = multisite($link);
$month_name = datetime_format($datetime_formats['month_name_year_view'], $date->getTimestamp());
$html .= '<th><a href="' . escape_html($link) . '">' . escape_html($month_name) . "</a></th>\n";
$date->modifyMonthsNoOverflow(1, true);
}
// The right-hand header column, if required
if ($row_labels_both_sides)
{
$html .= $first_last_html;
}
$html .= "</tr>\n";
return $html;
}
}
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
use MRBS\DateTime;
use function MRBS\datetime_format;
use function MRBS\escape_html;
use function MRBS\get_entries_by_room;
use function MRBS\multisite;
class CalendarMultimonthOneRoom extends CalendarMultimonth
{
public function __construct($view, $view_all, $year, $month, $day, $area_id, $room_id)
{
global $resolution;
parent::__construct($view, $view_all, $year, $month, $day, $area_id, $room_id);
// Get the entries. It's much quicker to do a single SQL query getting all the
// entries for the interval in one go, rather than doing a query for each day.
$entries = get_entries_by_room($this->room_id, $this->start_date, $this->end_date);
// We want to build an array containing all the data we want to show and then spit it out.
$this->map = new Map($this->start_date, $this->end_date, $resolution);
$this->map->addEntries($entries);
}
public function innerHTML(): string
{
global $column_labels_both_ends;
// Table header
$thead = '<thead';
// TODO: get_slots() for JavaScript
$thead .= ">\n";
$header_row = $this->headerRowHTML();
$thead .= $header_row;
$thead .= "</thead>\n";
// Table body
$tbody = $this->bodyHTML();
// Table footer
$tfoot = ($column_labels_both_ends) ? "<tfoot>\n$header_row</tfoot>\n" : '';
return $thead . $tfoot . $tbody;
}
private function bodyHTML(): string
{
global $year_start, $datetime_formats, $row_labels_both_sides;
$html = "<tbody>\n";
// The variables for the link query string
$vars = [
'view_all' => $this->view_all,
'area' => $this->area_id,
'room' => $this->room_id
];
$date = (new DateTime())->setDate($this->year, $this->month, 1); // Set to first day of month
$date->setMonthYearStart($year_start);
$d = 0; // Need to keep track of the day in the Calendar interval (zero indexed)
for ($i=0; $i<$this->n_months; $i++)
{
$html .= "<tr>\n";
$vars['page_date'] = $date->getISODate();
$vars['view'] = 'month';
$link = 'index.php?' . http_build_query($vars, '', '&');
$link = multisite($link);
$month_name = datetime_format($datetime_formats['month_name_year_view'], $date->getTimestamp());
$first_last_html = '<th><a href="' . escape_html($link) . '">' . escape_html($month_name) . "</a></th>\n";
$html .= $first_last_html;
for ($j=1; $j<=$date->getDaysInMonth(); $j++)
{
$date->setDay($j);
// Although it's possible to add date classes (eg for weekends and holidays) the result
// doesn't look very good on the screen because the weekdays aren't all in the same column.
$html .= "<td>";
$vars['page_date'] = $date->getISODate();
$vars['view'] = 'day';
$link = 'index.php?' . http_build_query($vars, '', '&');
$link = multisite($link);
$html .= '<a href="' . escape_html($link) . '">';
$html .= $this->flexDivsHTML($this->room_id, $d, $d);
$html .= '</a>';
$html .= "</td>";
$d++;
}
// Fill in the remaining, invalid, days
while ($j <= 31)
{
$html .= '<td class="invalid"></td>';
$j++;
}
// The right-hand header column, if required
if ($row_labels_both_sides)
{
$html .= $first_last_html;
}
$html .= "</tr>\n";
$date->modify('+1 day'); // Advance to the next month
}
$html .= "</tbody>\n";
return $html;
}
private function headerRowHTML(string $label='') : string
{
global $row_labels_both_sides;
$html = "<tr>\n";
// The left-hand header column
$first_last_html = '<th class="first_last">' . escape_html($label) . "</th>\n";
$html .= $first_last_html;
// Choose a month (January) with 31 days and cycle through all the days.
// We can't add a link to the header cells because we don't know which month they refer to.
for ($d=1; $d <= 31; $d++)
{
$t = mktime(12, 0, 0, 1, $d, $this->year);
$html .= "<th>" . escape_html($this->getDate($t)) . "</th>\n";
}
// The right-hand header column, if required
if ($row_labels_both_sides)
{
$html .= $first_last_html;
}
$html .= "</tr>\n";
return $html;
}
}
+307
View File
@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
use MRBS\DateTime;
use MRBS\Period;
use MRBS\Periods;
use function MRBS\escape_html;
use function MRBS\get_vocab;
use function MRBS\getWritable;
use function MRBS\hm_before;
use function MRBS\hour_min;
use function MRBS\is_book_admin;
use function MRBS\multisite;
use function MRBS\period_index_nominal;
use function MRBS\session;
abstract class CalendarMultislot extends Calendar
{
protected $timetohighlight;
// $s is nominal seconds
protected function getQueryVars(int $room, int $month, int $day, int $year, int $s) : array
{
global $morningstarts, $morningstarts_minutes;
$result = [];
// check to see if the time is really on the next day
$date = getdate(mktime(0, 0, $s, $month, $day, $year));
if (hm_before($date, ['hours' => $morningstarts, 'minutes' => $morningstarts_minutes]))
{
$date['hours'] += 24;
}
$hour = $date['hours'];
$minute = $date['minutes'];
$period = period_index_nominal($s);
$vars = [
'view' => $this->view,
'year' => $year,
'month' => $month,
'day' => $day,
'area' => $this->area_id
];
$result['booking'] = $vars;
$result['new_periods'] = array_merge($vars, ['room' => $room, 'period' => $period]);
$result['new_times'] = array_merge($vars, ['room' => $room, 'hour' => $hour, 'minute' => $minute]);
return $result;
}
protected function tdHTML(array $cell, array $query_vars, bool $is_invalid = false) : string
{
// Draws a single cell in the main table of the day and week views
//
// $cell is an array of entries that occupy that cell. There can be none, one or many
// bookings in a cell. If there are no bookings, then a blank cell is drawn with a link
// to the edit entry form. If there is one booking, then the booking is shown in that
// cell. If there is more than one booking, then all the bookings are shown.
// $query_vars is an array containing the query vars to be used in the link for the cell.
// It is indexed as follows:
// ['new_periods'] the vars to be used for an empty cell if using periods
// ['new_times'] the vars to be used for an empty cell if using times
// ['booking'] the vars to be used for a full cell
//
// $is_invalid specifies whether the slot actually exists or is one of the non-existent
// slots in the transition to DST
global $enable_periods, $show_plus_link, $prevent_booking_on_holidays, $prevent_booking_on_weekends;
$html = '';
$classes = array();
// Don't put in a <td> cell if the slot contains a single booking whose n_slots is NULL.
// This would mean that it's the second or subsequent slot of a booking and so the
// <td> for the first slot would have had a rowspan that extended the cell down for
// the number of slots of the booking.
if (empty($cell) || !is_null($cell[0]['n_slots']))
{
if (!empty($cell))
{
$classes[] = 'booked';
if (count($cell) > 1)
{
$classes[] = 'multiply';
}
}
elseif ($is_invalid)
{
$classes[] = 'invalid';
}
else
{
$classes[] = 'new';
// Add classes for weekends and holidays
$date = new DateTime();
$date->setDate(
$query_vars['new_times']['year'],
$query_vars['new_times']['month'],
$query_vars['new_times']['day']
);
$classes = array_merge($classes, $this->getDateClasses($date));
}
// If there's no booking, or if there are multiple bookings, then make the slot one unit long
$slots = (count($cell) == 1) ? $cell[0]['n_slots'] : 1;
$html .= $this->tdOpeningTagHTML($classes, $slots);
// If the room isn't booked, then allow it to be booked
if (empty($cell))
{
// Don't provide a link if the slot doesn't really exist or if the user is logged in, but not a booking admin,
// and it's a holiday/weekend and bookings on holidays/weekends are not allowed. (We provide a link if they
// are not logged in because they might want to click and login as an admin).
if ($is_invalid ||
((null !== session()->getCurrentUser()) && !is_book_admin($query_vars['new_times']['room']) &&
(($prevent_booking_on_holidays && in_array('holiday', $classes)) ||
($prevent_booking_on_weekends && in_array('weekend', $classes)))))
{
$html .= '<span class="not_allowed"></span>';
}
else
{
$vars = ($enable_periods) ? $query_vars['new_periods'] : $query_vars['new_times'];
$query = http_build_query($vars, '', '&');
$html .= '<a href="' . escape_html(multisite("edit_entry.php?$query")) . '"' .
' aria-label="' . escape_html(get_vocab('create_new_booking')) . "\">";
if ($show_plus_link)
{
$html .= "<img src=\"images/new.gif\" alt=\"New\" width=\"10\" height=\"10\">";
}
$html .= "</a>";
}
}
else // if it is booked, then show the booking
{
foreach ($cell as $booking)
{
$vars = $query_vars['booking'];
$vars['id'] = $booking['id'];
$query = http_build_query($vars, '', '&');
// We have to wrap the booking in a <div> because we want the booking itself to be given
// an absolute position, and we can't use position relative on a <td> in IE11 and below.
// We also need the bookings in a container because jQuery UI resizable has problems
// with border-box (see https://stackoverflow.com/questions/18344272). And we need
// border-box for the bookings because we are using padding on the bookings and we want
// 'width: 100%' and 'height: 100%' to fill the table-cell with the entire booking
// including content.
$classes = $this->getEntryClasses($booking);
$classes[] = 'booking';
if ($booking['is_multiday_start'])
{
$classes[] = 'multiday_start';
}
if ($booking['is_multiday_end'])
{
$classes[] = 'multiday_end';
}
// Tell JavaScript to make bookings resizable
if ((count($cell) == 1) &&
getWritable($booking['create_by'], $booking['room_id']))
{
$classes[] = 'writable';
}
$html .= '<div class="' . implode(' ', $classes) . '">';
$html .= '<a href="' . escape_html(multisite("view_entry.php?$query")) . '"' .
' title="' . escape_html($booking['description'] ?? '') . '"' .
' class="' . $booking['type'] . '"' .
' data-id="' . $booking['id'] . '"' .
' data-type="' . $booking['type'] . '">';
$html .= escape_html($booking['name']) . '</a>';
$html .= "</div>";
}
}
$html .= "</td>\n";
}
return $html;
}
// Output a start table cell tag <td> with class of $classes.
// $classes can be either a string or an array of classes
// empty or row_highlight if highlighted.
// $slots is the number of time slots high that the cell should be
//
// $data is an optional third parameter which if set passes an
// associative array of name-value pairs to be used in data attributes
private function tdOpeningTagHTML(array $classes, int $slots, ?array $data=null) : string
{
global $times_along_top;
$html = '<td';
if (!empty($classes))
{
$html.= ' class="' . implode(' ', $classes) . '"';
}
if ($slots > 1)
// No need to output more HTML than necessary
{
$html .= (($times_along_top) ? ' colspan' : ' rowspan') . "=\"$slots\"";
}
if (isset($data))
{
foreach ($data as $name => $value)
{
$html .= " data-$name=\"$value\"";
}
}
$html .= ">";
return $html;
}
// Draw a time cell to be used in the first and last columns of the day and week views
// $s the number of seconds since the start of the day (nominal - not adjusted for DST)
// $url the url to form the basis of the link in the time cell
function tbodyThTimeCellHTML(int $s, string $url) : string
{
global $enable_periods, $resolution;
$html = '';
$html .= "<th data-seconds=\"$s\">";
$html .= '<a href="' . escape_html($url) . '" title="' . get_vocab("highlight_line") . "\">";
if ($enable_periods)
{
$periods = Periods::getForArea($this->area_id);
$html .= escape_html($periods->offsetGetByNominalSeconds($s)->name);
}
else
{
$html .= escape_html($this->timeslotText($s, $resolution));
}
$html .= "</a></th>\n";
return $html;
}
protected function theadThTimeCellsHTML(int $start, int $end, int $increment) : string
{
global $enable_periods;
$html = '';
for ($s = $start; $s <= $end; $s += $increment)
{
// Put the number of seconds since the start of the day (nominal, ignoring DST)
// in a data attribute so that JavaScript can pick it up
$html .= "<th data-seconds=\"$s\">";
// We need the span so that we can apply some padding. We can't apply it
// to the <th> because that is used by jQuery.offset() in resizable bookings
$html .= "<span>";
if ( $enable_periods )
{
$periods = Periods::getForArea($this->area_id);
$html .= escape_html($periods->offsetGetByNominalSeconds($s)->name);
}
else
{
$html .= escape_html($this->timeslotText($s, $increment));
}
$html .= "</span>";
$html .= "</th>\n";
}
return $html;
}
private function timeslotText(int $s, int $resolution) : string
{
global $show_slot_endtime;
$result = hour_min($s);
if ($show_slot_endtime)
{
$result .= '-' . hour_min($s + $resolution);
}
return $result;
}
}
@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
use MRBS\DateTime;
use function MRBS\escape_html;
use function MRBS\get_entries_by_area;
use function MRBS\get_room_details;
use function MRBS\get_rooms;
use function MRBS\get_start_first_slot;
use function MRBS\get_start_last_slot;
use function MRBS\get_vocab;
use function MRBS\is_invalid_datetime;
use function MRBS\is_possibly_invalid;
use function MRBS\multisite;
class CalendarMultislotDay extends CalendarMultislot
{
private $kiosk;
public function __construct(string $view, int $view_all, int $year, int $month, int $day, int $area_id, int $room_id, ?int $timetohighlight=null, ?string $kiosk=null)
{
parent::__construct($view, $view_all, $year, $month, $day, $area_id, $room_id);
$this->timetohighlight = $timetohighlight;
$this->kiosk = $kiosk;
}
public function innerHTML() : string
{
global $enable_periods;
global $times_along_top, $row_labels_both_sides, $column_labels_both_ends;
global $resolution, $morningstarts, $morningstarts_minutes;
if ($this->kiosk === 'room')
{
$rooms = array(get_room_details($this->room_id));
}
else
{
$rooms = get_rooms($this->area_id);
}
$n_rooms = count($rooms);
if ($n_rooms == 0)
{
// Add an 'empty' data flag so that the JavaScript knows whether this is a real table or not
return "<tbody data-empty=1><tr><td><h1>" . get_vocab("no_rooms_for_area") . "</h1></td></tr></tbody>";
}
$start_first_slot = get_start_first_slot($this->month, $this->day, $this->year);
$start_last_slot = get_start_last_slot($this->month, $this->day, $this->year);
// Keep a count of the number of slots at the start of the day that we're
// not showing (will only be relevant in kiosk mode).
$skipped_slots = 0;
// If we are in kiosk mode we are not interested in what has already happened.
// But if we are in periods mode we don't know when the periods occur, so show them all.
if (isset($kiosk) && !$enable_periods)
{
$now = time();
$start_next_slot = $start_first_slot + $resolution;
while (($now > $start_next_slot) && ($start_next_slot < $start_last_slot))
{
$start_first_slot = $start_next_slot;
$skipped_slots++;
$start_next_slot = $start_first_slot + $resolution;
}
}
// Work out whether there's a possibility that a time slot is invalid,
// in other words whether the booking day includes a transition into DST.
// If we know that there's a transition into DST then some of the slots are
// going to be invalid. Knowing whether or not there are possibly invalid slots
// saves us bothering to do the detailed calculations of which slots are invalid.
$is_possibly_invalid = !$enable_periods && is_possibly_invalid($start_first_slot, $start_last_slot);
$start_date = (new DateTime())->setTimestamp($start_first_slot);
$end_date = (new DateTime())->setTimestamp($start_last_slot + $resolution);
$entries = get_entries_by_area($this->area_id, $start_date, $end_date);
// We want to build a map containing all the data we want to show
// and then spit it out.
$map = new Map($start_date, $end_date, $resolution);
$map->addEntries($entries);
$n_time_slots = self::getNTimeSlots() - $skipped_slots;
$morning_slot_seconds = ((($morningstarts * 60) + $morningstarts_minutes) * 60) + ($skipped_slots * $resolution);
$evening_slot_seconds = $morning_slot_seconds + (($n_time_slots - 1) * $resolution);
// TABLE HEADER
$thead = '<thead';
$slots = $this->getSlots($this->month, $this->day, $this->year);
if (isset($slots))
{
// Remove the skipped slots from the start of the first day's array
for ($i=0; $i<$skipped_slots; $i++)
{
array_shift($slots[0]);
}
// Add some data to enable the JavaScript to draw the timeline
$thead .= ' data-slots="' . escape_html(json_encode($slots)) . '"';
$thead .= ' data-timeline-vertical="' . (($times_along_top) ? 'true' : 'false') . '"';
$thead .= ' data-timeline-full="true"';
}
$thead .= ">\n";
$header_inner = "<tr>\n";
if ($times_along_top)
{
$tag = 'room';
}
elseif ($enable_periods)
{
$tag = 'period';
}
else
{
$tag = 'time';
}
$first_last_html = '<th class="first_last">' . get_vocab($tag) . "</th>\n";
$header_inner .= $first_last_html;
// We can display the table in two ways
if ($times_along_top)
{
$header_inner .= $this->theadThTimeCellsHTML($morning_slot_seconds, $evening_slot_seconds, $resolution);
}
else
{
$vars = [
'view' => 'week',
'view_all' => 0,
'year' => $this->year,
'month' => $this->month,
'day' => $this->day,
'area' => $this->area_id
];
$header_inner .= $this->roomsHeaderCellsHTML($rooms, $vars);
} // end standard view (for the header)
// next: line to display times on right side
if ($row_labels_both_sides)
{
$header_inner .= $first_last_html;
}
$header_inner .= "</tr>\n";
$thead .= $header_inner;
$thead .= "</thead>\n";
// Now repeat the header in a footer if required
$tfoot = ($column_labels_both_ends) ? "<tfoot>\n$header_inner</tfoot>\n" : '';
// TABLE BODY LISTING BOOKINGS
$tbody = "<tbody>\n";
// This is the main bit of the display
// We loop through time and then the rooms we just got
// if the today is a day which includes a DST change then use
// the day after to generate timesteps through the day as this
// will ensure a constant time step
// We can display the table in two ways
if ($times_along_top)
{
// with times along the top and rooms down the side
foreach ($rooms as $room)
{
$tbody .= "<tr>\n";
$vars = array('view' => 'week',
'view_all' => 0,
'year' => $this->year,
'month' => $this->month,
'day' => $this->day,
'area' => $this->area_id,
'room' => $room['id']);
$row_label = $this->roomCellHTML($room, $vars);
$tbody .= $row_label;
$is_invalid = array();
for ($s = $morning_slot_seconds;
$s <= $evening_slot_seconds;
$s += $resolution)
{
// Work out whether this timeslot is invalid and save the result, so that we
// don't have to repeat the calculation for every room
if (!isset($is_invalid[$s]))
{
$is_invalid[$s] = $is_possibly_invalid && is_invalid_datetime(0, 0, $s, $this->month, $this->day, $this->year);
}
// set up the query vars to be used for the link in the cell
$query_vars = $this->getQueryVars($room['id'], $this->month, $this->day, $this->year, $s);
// and then draw the cell
$tbody .= $this->tdHTML($map->slot($room['id'], 0, $s), $query_vars, $is_invalid[$s]);
} // end for (looping through the times)
if ($row_labels_both_sides)
{
$tbody .= $row_label;
}
$tbody .= "</tr>\n";
} // end for (looping through the rooms)
} // end "times_along_top" view (for the body)
else
{
// the standard view, with rooms along the top and times down the side
for ($s = $morning_slot_seconds;
$s <= $evening_slot_seconds;
$s += $resolution)
{
// Show the time linked to the URL for highlighting that time
$classes = array();
$vars = array(
'view' => 'day',
'year' => $this->year,
'month' => $this->month,
'day' => $this->day,
'area' => $this->area_id
);
if (isset($this->room_id))
{
$vars['room'] = $this->room_id;
}
if (isset($this->timetohighlight) && ($s == $this->timetohighlight))
{
$classes[] = 'row_highlight';
}
else
{
$vars['timetohighlight'] = $s;
}
$url = "index.php?" . http_build_query($vars, '', '&');
$url = multisite($url);
$tbody .= '<tr';
if (!empty($classes))
{
$tbody .= ' class="' . implode(' ', $classes) . '"';
}
$tbody .= ">\n";
$tbody .= $this->tbodyThTimeCellHTML($s, $url);
$is_invalid = $is_possibly_invalid && is_invalid_datetime(0, 0, $s, $this->month, $this->day, $this->year);
// Loop through the list of rooms we have for this area
foreach ($rooms as $room)
{
// set up the query vars to be used for the link in the cell
$query_vars = $this->getQueryVars($room['id'], $this->month, $this->day, $this->year, $s);
$tbody .= $this->tdHTML($map->slot($room['id'], 0, $s), $query_vars, $is_invalid);
}
// next lines to display times on right side
if ($row_labels_both_sides)
{
$tbody .= $this->tbodyThTimeCellHTML($s, $url);
}
$tbody .= "</tr>\n";
}
} // end standard view (for the body)
$tbody .= "</tbody>\n";
return $thead . $tfoot . $tbody;
}
private function roomsHeaderCellsHTML(array $rooms, array $vars) : string
{
$html = '';
foreach($rooms as $room)
{
$vars['room'] = $room['id'];
$html .= $this->roomCellHTML($room, $vars);
}
return $html;
}
}
@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
use MRBS\DateTime;
use function MRBS\datetime_format;
use function MRBS\day_of_MRBS_week;
use function MRBS\escape_html;
use function MRBS\get_entries_by_room;
use function MRBS\get_room_name;
use function MRBS\get_start_first_slot;
use function MRBS\get_start_last_slot;
use function MRBS\get_vocab;
use function MRBS\is_invalid_datetime;
use function MRBS\is_possibly_invalid;
use function MRBS\is_visible;
use function MRBS\multisite;
class CalendarMultislotWeek extends CalendarMultislot
{
public function __construct(string $view, int $view_all, int $year, int $month, int $day, int $area_id, int $room_id, ?int $timetohighlight=null)
{
parent::__construct($view, $view_all, $year, $month, $day, $area_id, $room_id);
$this->timetohighlight = $timetohighlight;
}
public function innerHTML(): string
{
global $enable_periods;
global $times_along_top, $row_labels_both_sides, $column_labels_both_ends;
global $resolution, $morningstarts, $morningstarts_minutes;
global $weekstarts, $datetime_formats;
// Check that we've got a valid, enabled room
$room_name = get_room_name($this->room_id);
if (is_null($room_name) || (!is_visible($this->room_id)))
{
// No rooms have been created yet, or else they are all disabled
// Add an 'empty' data flag so that the JavaScript knows whether this is a real table or not
return "<tbody data-empty=1><tr><td><h1>".get_vocab("no_rooms_for_area")."</h1></td></tr></tbody>";
}
// We have a valid room
// Calculate how many days to skip back to get to the start of the week
$time = mktime(12, 0, 0, $this->month, $this->day, $this->year);
$skipback = day_of_MRBS_week($time);
$day_start_week = $this->day - $skipback;
// We will use $day for links and $day_start_week for anything to do with showing the bookings,
// because we want the booking display to start on the first day of the week (eg Sunday if $weekstarts is 0)
// but we want to preserve the notion of the current day (or 'sticky day') when switching between pages
// Define the start and end of each day of the week in a way which is not
// affected by daylight saving...
for ($j = 0; $j < DAYS_PER_WEEK; $j++)
{
$start_first_slot[$j] = get_start_first_slot($this->month, $day_start_week+$j, $this->year);
$start_last_slot[$j] = get_start_last_slot($this->month, $day_start_week+$j, $this->year);
// Work out whether there's a possibility that a time slot is invalid,
// in other words whether the booking day includes a transition into DST.
// If we know that there's a transition into DST then some of the slots are
// going to be invalid. Knowing whether or not there are possibly invalid slots
// saves us bothering to do the detailed calculations of which slots are invalid.
$is_possibly_invalid[$j] = !$enable_periods && is_possibly_invalid($start_first_slot[$j], $start_last_slot[$j]);
}
unset($j); // Just so that we pick up any accidental attempt to use it later
$start_date = (new DateTime())->setTimestamp($start_first_slot[0]);
$end_date = (new DateTime())->setTimestamp($start_last_slot[DAYS_PER_WEEK - 1] + $resolution);
// Get the data. It's much quicker to do a single SQL query getting all the
// entries for the interval in one go, rather than doing a query for each day.
$entries = get_entries_by_room($this->room_id, $start_date, $end_date);
$map = new Map($start_date, $end_date, $resolution);
$map->addEntries($entries);
// START DISPLAYING THE MAIN TABLE
$n_time_slots = self::getNTimeSlots();
$morning_slot_seconds = (($morningstarts * 60) + $morningstarts_minutes) * 60;
$evening_slot_seconds = $morning_slot_seconds + (($n_time_slots - 1) * $resolution);
// TABLE HEADER
$thead = '<thead';
$slots = $this->getSlots($this->month, $day_start_week, $this->year, DAYS_PER_WEEK);
if (isset($slots))
{
// Add some data to enable the JavaScript to draw the timeline
$thead .= ' data-slots="' . escape_html(json_encode($slots)) . '"';
$thead .= ' data-timeline-vertical="' . (($times_along_top) ? 'true' : 'false') . '"';
$thead .= ' data-timeline-full="false"';
}
$thead .= ">\n";
if ($times_along_top)
{
$tag = 'date';
}
elseif ($enable_periods)
{
$tag = 'period';
}
else
{
$tag = 'time';
}
$label = get_vocab($tag);
// We can display the table in two ways
if ($times_along_top)
{
$header_inner = "<tr>\n";
$first_last_html = '<th class="first_last">' . $label . "</th>\n";
$header_inner .= $first_last_html;
$header_inner .= $this->theadThTimeCellsHTML($morning_slot_seconds, $evening_slot_seconds, $resolution);
// next line to display times on right side
if ($row_labels_both_sides)
{
$header_inner .= $first_last_html;
}
$header_inner .= "</tr>\n";
$header_inner_rows = [$header_inner];
}
else
{
$header_inner_rows = $this->multidayHeaderRowsHTML($day_start_week, DAYS_PER_WEEK, $weekstarts, $label);
}
$thead .= implode('', $header_inner_rows);
$thead .= "</thead>\n";
// Now repeat the header in a footer if required
$tfoot = ($column_labels_both_ends) ? "<tfoot>\n" . implode('',array_reverse($header_inner_rows)) . "</tfoot>\n" : '';
// TABLE BODY LISTING BOOKINGS
$tbody = "<tbody>\n";
// We can display the table in two ways
if ($times_along_top)
{
$format = $datetime_formats['view_week_day_date_month'];
// with times along the top and days of the week down the side
// See note above: weekday==0 is day $weekstarts, not necessarily Sunday.
for ($j = 0, $date = clone $start_date; $j < DAYS_PER_WEEK; $j++, $date->modify('+1 day'))
{
if ($date->isHiddenDay())
{
// These days are to be hidden in the display: don't display a row
continue;
}
$tbody .= "<tr>\n";
$day_cell_text = datetime_format($format, $date->getTimestamp());
$vars = array('view' => 'day',
'view_all' => $this->view_all,
'page_date' => $date->getISODate(),
'area' => $this->area_id,
'room' => $this->room_id);
$day_cell_link = 'index.php?' . http_build_query($vars, '', '&');
$day_cell_link = multisite($day_cell_link);
$row_label = $this->dayCellHTML($day_cell_text, $day_cell_link, $date);
$tbody .= $row_label;
for ($s = $morning_slot_seconds;
$s <= $evening_slot_seconds;
$s += $resolution)
{
$is_invalid = $is_possibly_invalid[$j] && is_invalid_datetime(0, 0, $s, $date->getMonth(), $date->getDay(), $date->getYear());
// set up the query vars to be used for the link in the cell
$query_vars = $this->getQueryVars($this->room_id, $date->getMonth(), $date->getDay(), $date->getYear(), $s);
// and then draw the cell
$tbody .= $this->tdHTML($map->slot($this->room_id, $j, $s), $query_vars, $is_invalid);
} // end looping through the time slots
if ($row_labels_both_sides)
{
$tbody .= $row_label;
}
$tbody .= "</tr>\n";
} // end looping through the days of the week
} // end "times along top" view (for the body)
else
{
// the standard view, with days of the week along the top and times down the side
for ($s = $morning_slot_seconds;
$s <= $evening_slot_seconds;
$s += $resolution)
{
// Show the time linked to the URL for highlighting that time:
$classes = array();
$vars = array('view' => 'week',
'view_all' => $this->view_all,
'year' => $this->year,
'month' => $this->month,
'day' => $this->day,
'area' => $this->area_id,
'room' => $this->room_id);
if (isset($this->timetohighlight) && ($s == $this->timetohighlight))
{
$classes[] = 'row_highlight';
}
else
{
$vars['timetohighlight'] = $s;
}
$url = 'index.php?' . http_build_query($vars, '', '&');
$url = multisite($url);
$tbody.= '<tr';
if (!empty($classes))
{
$tbody .= ' class="' . implode(' ', $classes) . '"';
}
$tbody .= ">\n";
$tbody .= $this->tbodyThTimeCellHTML($s, $url);
// See note above: weekday==0 is day $weekstarts, not necessarily Sunday.
for ($j = 0, $date = clone $start_date; $j < DAYS_PER_WEEK; $j++, $date->modify('+1 day'))
{
if ($date->isHiddenDay())
{
// These days are to be hidden in the display
continue;
}
// Set up the query vars to be used for the link in the cell.
$cell_day = $date->getDay();
$cell_month = $date->getMonth();
$cell_year = $date->getYear();
$is_invalid = $is_possibly_invalid[$j] && is_invalid_datetime(0, 0, $s, $cell_month, $cell_day, $cell_year);
$query_vars = $this->getQueryVars($this->room_id, $cell_month, $cell_day, $cell_year, $s);
// and then draw the cell
$tbody .= $this->tdHTML($map->slot($this->room_id, $j, $s), $query_vars, $is_invalid);
}
// next lines to display times on right side
if ($row_labels_both_sides)
{
$tbody .= $this->tbodyThTimeCellHTML($s, $url);
}
$tbody .= "</tr>\n";
}
} // end standard view (for the body)
$tbody .= "</tbody>\n";
return $thead . $tfoot . $tbody;
}
// Draw a day cell to be used in the header rows/columns of the week view
// $text contains the date, formatted as a string (not escaped - allowed to contain HTML tags)
// $link the href to be used for the link
// $date the date
private function dayCellHTML(string $text, string $link, DateTime $date) : string
{
$html = '';
// Put the date into a data attribute so that it can be picked up by JavaScript
$html .= '<th data-date="' . escape_html($date->getISODate()) . '"';
// Add classes for weekends and holidays
$classes = $this->getDateClasses($date);
if (!empty($classes))
{
$html .= ' class="' . implode(' ', $classes) . '"';
}
$html .= '>';
$html .= '<a href="' . escape_html($link) . '"' .
' title="' . escape_html(get_vocab("viewday")) . '">';
$html .= $text; // allowed to contain HTML tags - do not escape
$html .= '</a>';
$html .= "</th>\n";
return $html;
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
use function MRBS\escape_html;
class FlexDiv
{
public $id;
private $classes = [];
private $length = 1; // slots
private $name;
// Create a new FlexDiv, which either represents a booking with id $id,
// or free slots
public function __construct(?int $id)
{
if (isset($id))
{
$this->id = $id;
}
else
{
$this->classes = ['free'];
}
}
public function addLength(int $increment) : void
{
$this->length += $increment;
}
public function getLength() : int
{
return $this->length;
}
public function setClasses(array $classes) : void
{
$this->classes = $classes;
}
public function setLength(int $length) : void
{
$this->length = $length;
}
public function setName(string $name) : void
{
$this->name = $name;
}
public function html(): string
{
// Fix the size of the div at one pixel per slot. Allow it to grow proportionately,
// but not shrink.
$html = '<div style="flex: ' . $this->getLength() . ' 0 ' . $this->getLength() . 'px"';
if (!empty($this->classes))
{
$html .= ' class="' . escape_html(implode(' ', $this->classes)) . '"';
}
if (isset($this->name) && ($this->name !== ''))
{
$html .= ' title="' . escape_html($this->name) . '"';
}
$html .= '>';
if (isset($this->name) && ($this->name !== ''))
{
$html .= escape_html($this->name);
}
$html .= '</div>';
return $html;
}
}
+302
View File
@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace MRBS\Calendar;
// A class for building a map of bookings which can be used for constructing the calendar display
use MRBS\DateTime;
use MRBS\Exception;
use function MRBS\auth;
use function MRBS\get_start_first_slot;
use function MRBS\get_start_last_slot;
use function MRBS\get_vocab;
use function MRBS\getWritable;
use function MRBS\is_private_event;
use function MRBS\nominal_seconds;
use function MRBS\round_t_down;
use function MRBS\round_t_up;
use function MRBS\session;
class Map
{
private $start_date;
private $end_date;
private $resolution;
private $data_has_been_coalesced = false;
// $data is a column of the map of the screen that will be displayed, and is an array indexed
// by the room_id, then day, then number of nominal seconds (ie ignoring DST changes) since the
// start of the calendar day which has the start of the booking day. Each element of the array
// consists of an array of entries that fall in that slot.
private $data = [];
// $entries is an array, indexed by entry id, storing the entries that we are using for this map.
// Instead of storing the entry itself in the $data array, which will be many times for entries
// spanning multiple slots, we just store the entry id to save memory. Normally this wouldn't
// help as PHP uses copy-on-write, but we want to modify the entries to store extra information
// relevant to the slot, thus triggering a copy-on-write. Instead, we store the extra information
// in an array together with the entry id.
private $entries = [];
// Keys for the entry data stored in $data.
private const ENTRY_ID = 0;
private const ENTRY_IS_MULTIDAY_START = 1;
private const ENTRY_IS_MULTIDAY_END = 2;
private const ENTRY_N_SLOTS = 3;
public function __construct(DateTime $start_date, DateTime $end_date, int $resolution)
{
$this->start_date = $start_date;
$this->end_date = $end_date;
$this->resolution = $resolution;
}
public function addEntries(array $entries) : void
{
$date = clone $this->start_date;
$d = 0;
while ($date <= $this->end_date)
{
$this_day = $date->getDay();
$this_month = $date->getMonth();
$this_year = $date->getYear();
$start_first_slot = get_start_first_slot($this_month, $this_day, $this_year);
$start_last_slot = get_start_last_slot($this_month, $this_day, $this_year);
foreach ($entries as $entry)
{
$this->addEntry($entry, $d, $start_first_slot, $start_last_slot);
}
$d++;
$date->modify('+1 day');
}
}
// Add an entry to the map of the bookings being prepared for display.
//
// $entry a booking from the database
// $day_index the index day of the booking, starting at zero
// $start_first_slot the start of the first slot of the booking day (Unix timestamp)
// $start_last_slot the start of the last slot of the booking day (Unix timestamp)
private function addEntry(array $entry, int $day_index, int $start_first_slot, int $start_last_slot) : void
{
// $entry is expected to have the following keys, when present:
// room_id
// start_time
// end_time
// name
// repeat_id
// id
// type
// description
// create_by
// awaiting_approval
// private
// tentative
// Normally of course there will only be one entry per slot, but it is possible to have
// multiple entries per slot if the resolution is increased, the day shifted since the
// original bookings were made, or if the bookings were made using an older version of MRBS
// that had faulty conflict detection. For example, if you previously had a resolution of
// 1800 seconds, you might have a booking (A) for 1000-1130 and another (B) for 1130-1230.
// If you then increase the resolution to 3600 seconds, these two bookings
// will both occupy the 1100-1200 time slot.
//
// We also store the following extra information:
// is_multiday_start a boolean indicating if the booking stretches beyond the day start
// is_multiday_end a boolean indicating if the booking stretches beyond the day end
// n_slots the number of slots the booking lasts (tentatively set to 1)
// s is the number of nominal seconds (ie ignoring DST changes) since the
// start of the calendar day which has the start of the booking day
if ($this->data_has_been_coalesced)
{
throw new Exception("Map: entries cannot be added after output has started");
}
// We're only interested in entries which occur on this day (it's possible
// for $entry to contain entries for other days)
if (($entry['start_time'] >= $start_last_slot + $this->resolution) ||
($entry['end_time'] <= $start_first_slot))
{
return;
}
// Fill in the map for this meeting. Start at the meeting start time,
// or the day start time, whichever is later. End one slot before the
// meeting end time (since the next slot is for meetings which start then),
// or at the last slot in the day, whichever is earlier.
// Time is of the format HHMM without leading zeros.
// Adjust the starting and ending times so that bookings which don't
// start or end at a recognised time still appear.
$start_t = max(round_t_down($entry['start_time'], $this->resolution, $start_first_slot), $start_first_slot);
$end_t = min(round_t_up($entry['end_time'], $this->resolution, $start_first_slot) - $this->resolution, $start_last_slot);
// Calculate the times used for indexing - we index by nominal seconds since the start
// of the calendar day which has the start of the booking day
$start_s = nominal_seconds($start_t);
$end_s = nominal_seconds($end_t);
// Get some additional information about the entry related to the way it displays on the page
$is_multiday_start = ($entry['start_time'] < $start_first_slot);
$is_multiday_end = ($entry['end_time'] > ($start_last_slot + $this->resolution));
// Tentatively assume that this booking occupies 1 slot. Call coalesce() later to fix it.
$n_slots = 1;
for ($s = $start_s; $s <= $end_s; $s += $this->resolution)
{
// Add the entry to the array of entries if it's not already there
if (!isset($this->entries[$entry['id']]))
{
$this->entries[$entry['id']] = self::prepareEntry($entry);
}
// Store a pointer to this entry, together with the additional data
$this->data[$entry['room_id']][$day_index][$s][] = [
self::ENTRY_ID => $entry['id'],
self::ENTRY_IS_MULTIDAY_START => $is_multiday_start,
self::ENTRY_IS_MULTIDAY_END => $is_multiday_end,
self::ENTRY_N_SLOTS => $n_slots
];
}
}
// Prepares an entry for display by (a) adding in registration level information
// and (b) replacing the text in private fields if necessary.
public static function prepareEntry(array $entry) : array
{
global $is_private_field, $show_registration_level, $auth, $kiosk;
// Add in the registration level details
if ($show_registration_level && $entry['allow_registration'])
{
// Check whether we should be showing the registrants' names
$show_names = ($auth['show_registrant_names_in_calendar'] && ($entry['n_registered'] > 0));
if ($show_names && !$auth['show_registrant_names_in_public_calendar'])
{
// If we're not allowed to show names in the public calendar, check that the user is logged in
// and has an access level of at least 1
$mrbs_user = session()->getCurrentUser();
$show_names = isset($mrbs_user) && ($mrbs_user->level > 0);
}
$names = ($show_names) ? implode(', ', auth()->getRegistrantsDisplayNames($entry)) : '';
if ($entry['registrant_limit_enabled'])
{
$tag = ($show_names) ? 'registration_level_limited_with_names' : 'registration_level_limited';
$entry['name'] .= get_vocab($tag, $entry['n_registered'], $entry['registrant_limit'], $names);
}
else
{
$tag = ($show_names) ? 'registration_level_unlimited_with_names' : 'registration_level_unlimited';
$entry['name'] .= get_vocab($tag, $entry['n_registered'], $names);
}
}
// Check whether the event is private
if (is_private_event($entry['private']) &&
($kiosk || !getWritable($entry['create_by'], $entry['room_id'])))
{
$entry['private'] = true;
foreach (array('name', 'description') as $key)
{
if ($is_private_field["entry.$key"])
{
$entry[$key] = get_vocab('unavailable');
}
}
if (!empty($is_private_field['entry.type']))
{
$entry['type'] = 'private_type';
}
}
else
{
$entry['private'] = false;
}
return $entry;
}
// Returns the entry or entries that should be displayed at slot $s on day $day for room $room_id.
// Returns an empty array if there is no entry.
// Should not be called until all the data has been added.
public function slot(int $room_id, int $day, int $slot) : array
{
$result = [];
if (!$this->data_has_been_coalesced)
{
$this->coalesce();
}
foreach ($this->data[$room_id][$day][$slot] ?? [] as $entry_data)
{
$entry = $this->entries[$entry_data[self::ENTRY_ID]];
$entry['is_multiday_start'] = $entry_data[self::ENTRY_IS_MULTIDAY_START];
$entry['is_multiday_end'] = $entry_data[self::ENTRY_IS_MULTIDAY_END];
$entry['n_slots'] = $entry_data[self::ENTRY_N_SLOTS];
$result[] = $entry;
}
return $result;
}
// Coalesces map entries that span consecutive time slots.
private function coalesce() : void
{
// The add() method set n_slots=1 for all map entries. For each booking in the
// room that spans multiple consecutive time slots, and that does not have
// conflicting bookings, the first entry will have its slot count adjusted, and
// the continuation entries will have their n_slots attribute set to NULL.
foreach ($this->data as &$room_data)
{
foreach ($room_data as &$day_data)
{
// Iterate through pairs of consecutive time slots in reverse chronological order
for (end($day_data); ($s = key($day_data)) !== null; prev($day_data))
{
$p = $s - $this->resolution; // The preceding time slot
if (isset($day_data[$p]))
{
if (count($day_data[$s]) == 1)
{
// Single booking for time slot $s. If this event is a continuation
// of a sole event from time slot $p (the previous slot), then
// increment the slot count of the same booking in slot $p, and clear
// out the redundant attributes in slot $s.
if (count($day_data[$p]) == 1 && $day_data[$p][0][self::ENTRY_ID] == $day_data[$s][0][self::ENTRY_ID])
{
$this_booking = &$day_data[$s][0];
$prev_booking = &$day_data[$p][0];
$prev_booking[self::ENTRY_N_SLOTS] = 1 + $this_booking[self::ENTRY_N_SLOTS];
$this_booking[self::ENTRY_N_SLOTS] = null;
}
}
else
{
// Multiple bookings for time slot $s. Mark all of them as 1 slot.
foreach ($day_data[$s] as &$booking)
{
$booking[self::ENTRY_N_SLOTS] = 1;
}
}
}
}
}
}
$this->data_has_been_coalesced = true;
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace MRBS;
use ReflectionClass;
class Column
{
const NATURE_BINARY = 0;
const NATURE_BOOLEAN = 1;
const NATURE_CHARACTER = 2;
const NATURE_DECIMAL = 3;
const NATURE_INTEGER = 4;
const NATURE_JSON = 5;
const NATURE_REAL = 6;
const NATURE_TIME = 7;
const NATURE_TIMESTAMP = 8;
public $table;
public $name;
private $default;
private $is_nullable;
private $length;
private $nature;
private $type;
public function __construct(string $table, string $name)
{
$this->table = $table;
$this->name = $name;
}
/**
* Gets the default value for this column.
*
* @return null|mixed
*/
public function getDefault()
{
return $this->default;
}
public function setDefault($default) : void
{
$this->default = $default;
}
public function getIsNullable() : bool
{
return $this->is_nullable;
}
public function setIsNullable(bool $is_nullable) : void
{
$this->is_nullable = $is_nullable;
}
// Returns the column length. Can be null|int|string
// For example a DECIMAL might return "5,2".
public function getLength()
{
return $this->length;
}
// $length can be null|int|string
public function setLength($length) : void
{
$this->length = $length;
}
public function getNature() : int
{
return $this->nature;
}
public function setNature(int $nature) : void
{
$reflectionClass = new ReflectionClass($this);
$constants = $reflectionClass->getConstants();
if (!in_array($nature, array_values($constants), true))
{
throw new \Exception("Invalid nature '$nature'");
}
$this->nature = $nature;
}
public function getType() : ?string
{
return $this->type;
}
public function setType(string $type) : void
{
$this->type = $type;
}
// Gets the type ('bool', 'decimal', 'float', 'int' or 'string') to be used with get_form_var().
// TODO: this method maybe doesn't belong here.
public function getFormVarType() : string
{
switch ($this->nature)
{
case self::NATURE_CHARACTER:
$var_type = 'string';
break;
case self::NATURE_DECIMAL:
$var_type = 'decimal';
break;
case self::NATURE_INTEGER:
$var_type = ($this->isBooleanLike()) ? 'bool' : 'int';
break;
case self::NATURE_REAL:
$var_type = 'float';
break;
// We can only really deal with the types above at the moment
default:
$var_type = 'string';
break;
}
return $var_type;
}
// Sanitize a value ready for insertion in the database
public function sanitizeValue($value)
{
// Turn the booleans into 0/1 values (necessary for PostgreSQL)
if (is_bool($value))
{
$value = ($value) ? 1 : 0;
}
// Trim the strings and truncate them to the maximum field length
// (necessary for PostgreSQL which doesn't truncate them itself
// but instead will throw an error)
elseif (is_string($value))
{
// Some variables, eg decimals, will also be PHP strings, so only
// trim columns with a database nature of 'character'.
if ($this->nature === Column::NATURE_CHARACTER)
{
$value = trim($value);
$value = $this->truncate($value);
}
}
return $value;
}
public function isBooleanLike() : bool
{
// Smallints and tinyints are considered to be booleans
return (($this->nature == self::NATURE_BOOLEAN) ||
(($this->nature == self::NATURE_INTEGER) &&
(isset($this->length) && ($this->length <= 2))));
}
// Truncate any fields that have a maximum length as a precaution.
// Although the MAXLENGTH attribute may be 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.
private function truncate($value)
{
$result = $value;
if (($this->nature == self::NATURE_CHARACTER) &&
isset($this->length) &&
($this->length < 256))
{
$result = mb_substr($value, 0, intval($this->length));
}
return $result;
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace MRBS;
use Countable;
use Iterator;
// Holds information about table columns
// Implemented as a singleton class for performance reasons: it is
// expensive getting the field info in the constructor.
class Columns implements Countable, Iterator
{
private static $instances = array();
private $data;
private $index = 0;
private $table_name;
private function __construct($table_name)
{
assert(version_compare(MRBS_MIN_PHP_VERSION, '7.4.0', '<'), "The __wakeup() method is now redundant.");
$this->table_name = $table_name;
// Get the column info
$this->data = db()->field_info($table_name);
}
private function __clone()
{
}
public function __unserialize(array $data) : void
{
// __unserialize() must have public visibility
throw new \Exception("Cannot unserialize a singleton.");
}
// __wakeup() is deprecated from PHP 8.5.
// "The __wakeup() serialization magic method has been deprecated. Implement __unserialize()
// instead (or in addition, if support for old PHP versions is necessary)".
// __unserialize() is only available from PHP 7.4.0
public function __wakeup()
{
// __wakeup() must have public visibility
throw new \Exception("Cannot unserialize a singleton.");
}
public static function getInstance(string $table_name) : Columns
{
if (!isset(self::$instances[$table_name]))
{
self::$instances[$table_name] = new self($table_name);
}
return self::$instances[$table_name];
}
public function getNames() : array
{
$result = array();
foreach ($this as $column)
{
$result[] = $column->name;
}
return $result;
}
public function hasIdColumn() : bool
{
$column = $this->getColumnByName('id');
return isset($column);
}
public function getColumnByName(string $name) : ?Column
{
foreach ($this as $column)
{
if ($column->name == $name)
{
return $column;
}
}
return null;
}
public function current() : Column
{
$info = $this->data[$this->index];
$column = new Column($this->table_name, $info['name']);
$column->setLength($info['length']);
$column->setDefault($info['default']);
$column->setIsNullable($info['is_nullable']);
$column->setType($info['type']);
switch ($info['nature'])
{
case 'binary':
$column->setNature(Column::NATURE_BINARY);
break;
case 'boolean':
$column->setNature(Column::NATURE_BOOLEAN);
break;
case 'character':
$column->setNature(Column::NATURE_CHARACTER);
break;
case 'decimal':
$column->setNature(Column::NATURE_DECIMAL);
break;
case 'integer':
$column->setNature(Column::NATURE_INTEGER);
break;
case 'json':
$column->setNature(Column::NATURE_JSON);
break;
case 'real':
$column->setNature(Column::NATURE_REAL);
break;
case 'timestamp':
$column->setNature(Column::NATURE_TIMESTAMP);
break;
default:
throw new \Exception("Unknown nature '" . $info['nature'] . "'");
break;
}
return $column;
}
public function next() : void
{
$this->index++;
}
public function key() : int
{
return $this->index;
}
public function valid() : bool
{
return isset($this->data[$this->key()]);
}
public function rewind() : void
{
$this->index = 0;
}
public function count() : int
{
return count($this->data);
}
}
+684
View File
@@ -0,0 +1,684 @@
<?php
declare(strict_types=1);
namespace MRBS\DB;
use MRBS\Column;
use MRBS\Columns;
use MRBS\Exception;
use PDO;
use PDOException;
use Throwable;
use function MRBS\mrbs_ignore_user_abort;
abstract class DB
{
const DB_SCHEMA_VERSION = 82;
const DB_SCHEMA_VERSION_LOCAL = 1;
const DB_DEFAULT_PORT = null;
const DB_DBO_DRIVER = null;
const DB_CHARSET = 'UTF8';
protected $dbh = null;
protected $mutex_locks = array();
protected $version_string = null;
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
// The attribute is only recognised by PHP 8.2 and later.
abstract public function __construct(
string $db_host,
#[\SensitiveParameter]
string $db_username,
#[\SensitiveParameter]
string $db_password,
#[\SensitiveParameter]
string $db_name,
bool $persist = false,
?int $db_port = null,
array $db_options = []
);
/**
* Destructor. Cleans up the connection if there is one.
*/
public function __destruct()
{
try {
// Release any forgotten locks
$this->mutex_unlock_all();
// Rollback any outstanding transactions
$this->rollback();
}
catch (Throwable $e) {
// Don't do anything, except raise an error. This is the destructor and if we get an
// exception or error it's probably because the connection has been lost or timed out,
// in which case the locks will have been released and the transaction rolled back anyway.
trigger_error($e->getMessage(), E_USER_NOTICE);
}
}
// Build a DSN.
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
// The attribute is only recognised by PHP 8.2 and later.
public static function dsn(
string $db_host,
#[\SensitiveParameter]
string $db_name,
?int $db_port = null
) : string
{
// Early error handling
if (is_null(static::DB_DBO_DRIVER) ||
is_null(static::DB_DEFAULT_PORT)) {
throw new Exception("Encountered a fatal bug in DB abstraction code!");
}
// Prefix
$result = static::DB_DBO_DRIVER . ':';
// Host
if ($db_host !== '') {
$result .= 'host=' . $db_host . ';';
}
// Port
if (empty($db_port)) {
$db_port = static::DB_DEFAULT_PORT;
}
$result .= 'port=' . $db_port . ';';
// Database name
$result .= 'dbname=' . $db_name;
return $result;
}
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
// The attribute is only recognised by PHP 8.2 and later.
// $driver_options is an optional array of options that supplements/overrides the
// default options.
protected function connect(
string $db_host,
#[\SensitiveParameter]
string $db_username,
#[\SensitiveParameter]
string $db_password,
#[\SensitiveParameter]
string $db_name,
bool $persist = false,
?int $db_port = null,
?array $driver_options = null
): void
{
// Establish a database connection.
$default_options = array(
PDO::ATTR_PERSISTENT => $persist,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
);
// The LHS of the array + operator overrides the RHS if the keys are the same
$options = (empty($driver_options)) ? $default_options : $driver_options + $default_options;
$this->dbh = new PDO(
static::dsn($db_host, $db_name, $db_port),
$db_username,
$db_password,
$options
);
$this->command("SET NAMES '" . static::DB_CHARSET . "'");
}
//
public function error(): string
{
$error = "No database connection!";
if ($this->dbh) {
$error_info = $this->dbh->errorInfo();
$error = $error_info[2];
}
return $error;
}
public function getAttribute(int $attribute)
{
return $this->dbh->getAttribute($attribute);
}
/**
* Execute a non-SELECT SQL command (insert/update/delete).
*
* @return int The number of tuples matched (whether affected or not) if OK (a number >= 0)
* @throws DBException
*/
public function command(string $sql, array $params = array()): int
{
try
{
$sth = $this->dbh->prepare($sql);
$sth->execute($params);
}
catch (PDOException $e)
{
throw new DBException($e->getMessage(), 0, $e, $sql, $params);
}
return $sth->rowCount();
}
// Execute an SQL query which should return a single non-negative integer value.
// This is a lightweight alternative to query(), good for use with count(*)
// and similar queries.
// It returns -1 if the query returns no result, or a single NULL value, such as from
// a MIN or MAX aggregate function applied over no rows.
// Throws a DBException on error.
public function query1(string $sql, array $params = array()) : int
{
$result = $this->query_scalar_non_bool($sql, $params);
if (is_null($result) || ($result === false))
{
return -1;
}
// Check that the result looks like an integer, even though it may be a string, and then cast
// it to an integer. For example "2" is OK, but "2.0" is not.
$result = filter_var($result, FILTER_VALIDATE_INT);
if ($result === false)
{
throw new \UnexpectedValueException("query1() should only be used for selecting integer values.");
}
return $result;
}
/**
* Execute an SQL query which should return a single scalar value that can be anything
* other than a boolean (because the function returns FALSE if there is no value).
*
* @return mixed The value returned by the query, or FALSE if there is none.
* @throws DBException
*/
public function query_scalar_non_bool(string $sql, array $params = [])
{
try
{
$sth = $this->dbh->prepare($sql);
$sth->execute($params);
return $sth->fetchColumn();
}
catch (PDOException $e)
{
throw new DBException($e->getMessage(), 0, $e, $sql, $params);
}
}
/**
* Run an SQL query that returns a simple one-dimensional array of results.
* The SQL query must select only one column.
*
* @return array The results, as an array of scalars, or an empty array if there are no results.
* @throws DBException
*/
public function query_array(string $sql, array $params = []): array
{
$stmt = $this->query($sql, $params);
$result = [];
while (false !== ($row = $stmt->next_row()))
{
$result[] = $row[0];
}
return $result;
}
/**
* Execute an SQL query.
*
* @throws DBException
*/
public function query(string $sql, array $params = []): DBStatement
{
try {
$sth = $this->dbh->prepare($sql);
$sth->execute($params);
} catch (PDOException $e) {
throw new DBException($e->getMessage(), 0, $e, $sql, $params);
}
return new DBStatement($this, $sth);
}
/**
* Begin a transaction. If already inside a transaction, this is a no-op.
*
* @see PDO::beginTransaction()
*/
public function begin(): void
{
// Turn off ignore_user_abort until the transaction has been committed or rolled back.
// See the warning at http://php.net/manual/en/features.persistent-connections.php
// (Only applies to persistent connections, but we'll do it for all cases to keep
// things simple)
mrbs_ignore_user_abort(true);
if (!$this->dbh->inTransaction()) {
$this->dbh->beginTransaction();
}
}
/**
* Commit a transaction. If not already inside a transaction, this is a no-op.
*
* @see PDO::commit()
*/
public function commit(): void
{
if ($this->dbh->inTransaction()) {
$this->dbh->commit();
}
mrbs_ignore_user_abort(false);
}
/**
* Roll back a transaction. If not already inside a transaction, this is a no-op.
*
* @see PDO::rollBack()
*/
public function rollback(): void
{
if ($this->dbh && $this->dbh->inTransaction()) {
$this->dbh->rollBack();
}
mrbs_ignore_user_abort(false);
}
// Checks if inside a transaction
public function inTransaction(): bool
{
return $this->dbh->inTransaction();
}
// Dies with a message that the database version is lower than the minimum required
protected function versionDie(string $database, string $this_version, string $min_version): void
{
$message = "MRBS requires $database version $min_version or higher. " .
"This server is running version $this_version.";
die($message);
}
// Returns the version string, eg "8.0.28",
// "10.3.36-MariaDB-log-cll-lve" or
// "PostgreSQL 14.2, compiled by Visual C++ build 1914, 64-bit".
protected function versionString(): string
{
if (!isset($this->version_string)) {
// Don't use getAttribute(PDO::ATTR_SERVER_VERSION) because that will
// sometimes also give you the version prefix (so-called "replication
// version hack") with MariaDB.
$result = $this->query_scalar_non_bool("SELECT VERSION()");
$this->version_string = ($result === false) ? '' : $result;
}
return $this->version_string;
}
// Replaces the keys in the array $array according to $key_map. Elements with
// value NULL are dropped.
protected static function replaceOptionKeys(array $array, array $key_map): array
{
$result = array();
foreach ($array as $key => $value) {
if (isset($value)) {
if (array_key_exists($key, $key_map)) {
$result[$key_map[$key]] = $value;
}
else {
trigger_error("Unsupported database driver option '$key'");
}
}
}
return $result;
}
// Return a boolean depending on whether $field exists in $table
public function field_exists(string $table, string $field): bool
{
$rows = $this->field_info($table);
foreach ($rows as $row) {
if ($row['name'] === $field) {
return true;
}
}
return false;
}
// Checks whether a table has duplicate values for a field
public function tableHasDuplicates(string $table, string $field): bool
{
$sql = "SELECT $field, COUNT(*)
FROM $table
GROUP BY $field
HAVING COUNT(*) > 1";
$res = $this->query($sql);
return ($res->count() > 0);
}
// Quote a table or column name (which could be a qualified identifier, eg 'table.column')
abstract public function quote(string $identifier): string;
// Return the value of an autoincrement field from the last insert.
// Must be called right after an insert on that table!
abstract public function insert_id(string $table, string $field) : int;
/**
* Acquire a mutual-exclusion lock.
*
* WARNING: The use of this method should be avoided as GET_LOCK (used in the MySQL implementation) is not supported
* by MariaDB Galera Cluster (and other cluster implementations?).
*
* @return bool Returns true if the lock is acquired successfully, otherwise false.
*/
abstract public function mutex_lock(string $name): bool;
/**
* Release a mutual-exclusion lock.
*
* WARNING: The use of this method should be avoided as RELEASE_LOCK (used in the MySQL implementation) is not
* supported by MariaDB Galera Cluster (and other cluster implementations?).
*
* @return bool Returns true if the lock is released successfully, otherwise false.
*/
abstract public function mutex_unlock(string $name): bool;
/**
* Release all mutual-exclusion locks.
*
* WARNING: The use of this method should be avoided as RELEASE_ALL_LOCKS (used in the MySQL implementation) is not
* supported by MariaDB Galera Cluster (and other cluster implementations?).
*/
abstract public function mutex_unlock_all(): void;
/**
* Return a string identifying the database version and type.
*/
abstract public function version(): string;
/**
* Check if a table exists.
*/
abstract public function table_exists(string $table): bool;
/**
* Get information about the columns in a table.
*
* NOTE: the type mapping is incomplete and just covers the types commonly used by MRBS.
*
* @return array An array with the following keys for each column:
* - **name** the column name
* - **type** the type as reported by MySQL
* - **nature** the type mapped onto one of a generic set of types
* (boolean, integer, real, character, binary). This enables
* the nature to be used by MRBS code when deciding how to
* display fields, without MRBS having to worry about the
* differences between MySQL and PostgreSQL type names.
* - **length** the maximum length of the field in bytes, octets or characters
* (Note: this could be NULL)
* - **is_nullable** whether the column can be set to NULL (boolean)
*/
abstract public function field_info(string $table): array;
// Syntax methods
/**
* Generate the SQL for LIMIT clauses.
*/
abstract public function syntax_limit(int $count, int $offset): string;
/**
* Generate the SQL for converting a TIMESTAMP to a Unix timestamp.
*/
abstract public function syntax_timestamp_to_unix(string $fieldname): string;
/**
* Generate the SQL for a case-sensitive string "equals" function.
*
* NB: This method is assumed to do a strict comparison, eg take account of trailing spaces.
*
* @param array &$params The SQL parameters, which will be modified by this function.
*/
abstract public function syntax_casesensitive_equals(string $fieldname, string $string, array &$params): string;
/**
* Generate the SQL for a case-insensitive string "contains" function.
*
* @param string $string The (unescaped) string to search for.
* @param array &$params The SQL parameters, which will be modified by this function.
*/
abstract public function syntax_caseless_contains(string $fieldname, string $string, array &$params): string;
/**
* Generate the SQL to add a table column after another specified column.
*/
abstract public function syntax_addcolumn_after(string $fieldname): string;
/**
* Generate the SQL to specify a column as an auto-incrementing integer while doing a CREATE TABLE.
*/
abstract public function syntax_createtable_autoincrementcolumn(): string;
/**
* Generate the SQL for a bitwise XOR operator.
*/
abstract public function syntax_bitwise_xor(): string;
/**
* Generate the syntax for a column being in a list of values.
*/
public function syntax_in_list(string $column_name, array $list, array &$params) : string
{
// Empty lists aren't allowed.
if (count($list) === 0)
{
return 'FALSE';
}
$params = array_merge($params, $list);
return $this->quote($column_name) . " IN (" . implode(',', array_fill(0, count($list), '?')) . ")";
}
/**
* Generate the SQL for a simple split of a column's value into two parts, separated by a delimiter. Note: this
* function assumes there is only one occurrence of the delimiter in the column's value.
*
* @param int $part The part to return, either 1 for the text to the left of the delimiter, or 2 for the text to the right.
* @param array $params The SQL parameters, which will be modified by this function.
*/
abstract public function syntax_simple_split(string $fieldname, string $delimiter, int $part, array &$params): string;
/**
* Generate the SQL for aggregating a number of rows as a delimited string.
*/
abstract public function syntax_group_array_as_string(string $fieldname, string $delimiter = ','): string;
// Returns the syntax for an "upsert" query. Unfortunately getting the id of the
// last row differs between MySQL and PostgreSQL. In PostgreSQL the query will
// return a row with the id in the 'id' column. However there isn't a corresponding
// way of doing this in MySQL, but db()->insert_id() will work, regardless of whether
// an insert or update was performed.
//
// $conflict_keys the key(s) which is/are unique; can be a scalar or an array
// $assignments an array of assignments for the UPDATE clause
// $has_id_column whether the table has an id column
abstract public function syntax_on_duplicate_key_update(
$conflict_keys,
array $assignments,
bool $has_id_column=false
) : string;
/**
* Determines whether the driver returns native types (eg a PHP int for an SQL INT).
*/
abstract public function returnsNativeTypes() : bool;
/**
* Determines whether the database supports multiple locks. Note that:
* - Use of this method should be avoided as RELEASE_ALL_LOCKS (used in the MySQL implementation) is not supported
* by MariaDB Galera Cluster.
* - This method should not be called for the first time while locks are in place, because it will release them.
*/
abstract public function supportsMultipleLocks(): bool;
/**
* Constructs an SQL upsert (insert or update) query based on the provided data and parameters.
*
* Unfortunately, getting the id of the last row differs between MySQL and PostgreSQL. In PostgreSQL the query will
* return a row with the id in the 'id' column. However, there isn't a corresponding way of doing this in MySQL, but
* db()->insert_id() will work, regardless of whether an insert or update was performed.
*
* @param array $data An associative array of data to be inserted or updated, indexed by column name.
* @param string $table The table name where the data should be inserted or updated.
* @param array &$params A reference to an array where the generated SQL parameters will be stored.
* @param array|string $conflict_keys A list of column names or a single column name that will be used to detect conflicts (e.g., unique constraints).
* @param array $ignore_columns A list of columns to be excluded from the query.
* @param bool $has_id_column Indicates whether the table includes an ID column that requires special handling.
* @return string The constructed SQL upsert query string.
*/
public function syntax_upsert(array $data, string $table, array &$params, $conflict_keys=[], array $ignore_columns=[], bool $has_id_column = false): string
{
if (is_scalar($conflict_keys))
{
$conflict_keys = array($conflict_keys);
}
list('columns' => $columns, 'values' => $values, 'sql_params' => $params) = $this->prepareData($data, $table, $ignore_columns);
$quoted_columns = array_map(array(\MRBS\db(), 'quote'), $columns);
$sql = "INSERT INTO " . $this->quote($table) . "
(" . implode(', ', $quoted_columns) . ")
VALUES (" . implode(', ', $values) . ") ";
// Go through the columns we've just found and turn them into assignments
// for the update part
$assignments = array();
for ($i=0; $i<count($columns); $i++)
{
$column = $columns[$i];
$value = $values[$i];
$assignments[] = $this->quote($column) . "=$value";
}
$sql .= \MRBS\db()->syntax_on_duplicate_key_update(
$conflict_keys,
$assignments,
$has_id_column
);
return $sql;
}
/**
* Prepare data for an SQL query. If `$table` is given, then it will also sanitize values, eg by trimming and
* truncating strings and converting booleans into 0/1.
*/
private function prepareData(array $data, ?string $table=null, array $ignore_columns=[]): array
{
$columns = array();
$values = array();
$sql_params = array();
$cols = (isset($table)) ? Columns::getInstance($table) : array_keys($data);
$i = 0;
foreach ($cols as $col)
{
// We are only interested in those elements of $data that have a corresponding
// column in the table - except for those that we have been told to ignore.
// Examples might be 'id' which normally auto-increments, and 'timestamp' which
// normally auto-updates.
if (is_object($col) && in_array($col->name, $ignore_columns))
{
continue;
}
$column_name = (is_object($col)) ? $col->name : $col;
$columns[] = $column_name;
if (!isset($data[$column_name]) && (!is_object($col) || $col->getIsNullable()))
{
$values[] = 'NULL';
}
else
{
// Need to make sure the placeholder only uses allowed characters which are
// [a-zA-Z0-9_]. We can't use the column name because the column name might
// contain characters which are not allowed. And we can't use '?' because
// we may want to use the placeholders twice, once for an insert and once for an
// update. Besides, debugging is easier with named parameters.
$named_parameter = ":p$i";
$values[] = $named_parameter;
if (isset($data[$column_name]))
{
$sql_param = $data[$column_name];
if (is_object($col))
{
// NB MariaDB doesn't support the JSON data type. It treats it as an
// alias of LONG TEXT.
if ($col->getNature() === Column::NATURE_JSON)
{
if (!is_string($sql_param) || !json_validate($sql_param))
{
throw new Exception('Invalid JSON string');
}
}
else
{
$sql_param = $col->sanitizeValue($sql_param);
}
}
}
else
{
// The column is not nullable and $col is an object if we got here
$sql_param = $col->getDefault();
}
$sql_params[$named_parameter] = $sql_param;
$i++;
}
}
return array(
'columns' => $columns,
'values' => $values,
'sql_params' => $sql_params
);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace MRBS\DB;
use PDOException;
class DBException extends PDOException
{
public function __construct(string $message, int $code=0, ?PDOException $previous=null, ?string $sql=null, ?array $params=null)
{
if (isset($sql))
{
$message .= "\n" .
'SQL: ' . str_replace("\n", '', $sql) . "\n" .
'Params: ' . print_r($params, true);
}
parent::__construct($message, $code, $previous);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace MRBS\DB;
class DBExternalException extends DBException
{
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace MRBS\DB;
// A helper class to build a DB object, dependent on the database type required
use Throwable;
class DBFactory
{
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
// The attribute is only recognised by PHP 8.2 and later.
public static function create(
string $db_system,
string $db_host,
#[\SensitiveParameter]
string $db_username,
#[\SensitiveParameter]
string $db_password,
#[\SensitiveParameter]
string $db_name,
bool $persist=false,
?int $db_port=null,
array $db_options=[]) : DB
{
self::checkExtensionEnabled($db_system);
$class_name = self::getClassName($db_system);
return new $class_name($db_host, $db_username, $db_password, $db_name, $persist, $db_port, $db_options);
}
public static function createDsn(
string $db_system,
string $db_host,
#[\SensitiveParameter]
string $db_name,
?int $db_port = null
) : string
{
$class_name = self::getClassName($db_system);
return $class_name::dsn($db_host, $db_name, $db_port);
}
// Check that the appropriate PDO extension is enabled. This can't always be
// done in the constructor of the class itself because the class can refer to a
// driver-specific constant.
private static function checkExtensionEnabled(string $db_system) : void
{
// Check for the existence of a driver-specific constant
switch ($db_system)
{
case 'mysql':
case 'mysqli':
$constant_name = 'Pdo\Mysql::ATTR_FOUND_ROWS';
$extension = 'pdo_mysql';
break;
case 'pgsql':
$constant_name = 'Pdo\Pgsql::ATTR_DISABLE_PREPARES';
$extension = 'pdo_pgsql';
break;
default:
return;
}
// We have to test for the constant in a try/catch block, because if we are using the Pdo\Mysql or
// Pdo\Pgsql emulations (ie we are not running PHP 8.4 or later) then the emulations will throw
// an error.
try
{
if (!defined($constant_name))
{
throw new DBException("Undefined constant $constant_name.");
}
}
catch (Throwable $e)
{
$message = "Undefined constant $constant_name. Check that the $extension extension is enabled " .
"in your php.ini file.";
throw new DBException($message);
}
}
private static function getClassName(string $db_system) : string
{
switch ($db_system)
{
case 'mysql':
case 'mysqli':
$class_name = 'DB_mysql';
break;
case 'pgsql':
$class_name = 'DB_pgsql';
break;
default:
throw new DBException("Unsupported database driver '$db_system'");
break;
}
return __NAMESPACE__ . '\\' . $class_name;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace MRBS\DB;
use PDO;
use PDOStatement;
class DBStatement
{
protected $db_object = null;
protected $statement = null;
public function __construct(DB $db_obj, PDOStatement $sth)
{
$this->db_object = $db_obj;
$this->statement = $sth;
}
/**
* Fetch the next row from a result set
*
* @return mixed[]|false An array indexed by column number as returned in the result set, starting at column 0,
* or FALSE if there are no more rows.
*/
public function next_row()
{
return $this->statement->fetch(PDO::FETCH_NUM);
}
/**
* Returns the next row from a statement as an associative array.
*
* @return array<string,mixed>|false The next row indexed by column name, or FALSE if there are no more rows.
*/
public function next_row_keyed()
{
return $this->statement->fetch(PDO::FETCH_ASSOC);
}
/**
* Return all the rows from a statement object, as an array of arrays keyed on the column name.
*/
public function all_rows_keyed() : array
{
$result = array();
while (false !== ($row = $this->next_row_keyed()))
{
$result[] = $row;
}
return $result;
}
/**
* Returns the number of rows affected by the last SQL statement.
*
* For DELETE, INSERT, or UPDATE statements the number of rows affected is returned, though note that this depends
* on the setting of Pdo\Mysql::ATTR_FOUND_ROWS for MySQL.
*
* For statements that produce result sets, such as SELECT, the behaviour is undefined and can be different for each driver.
*/
public function count() : int
{
return $this->statement->rowCount();
}
// Returns the number of fields in a statement.
public function num_fields() : int
{
return $this->statement->columnCount();
}
}
+753
View File
@@ -0,0 +1,753 @@
<?php
declare(strict_types=1);
namespace MRBS\DB;
use Error;
use MRBS\Errors\Errors;
use PDO;
use Pdo\Mysql;
use PDOException;
use function MRBS\get_vocab;
class DB_mysql extends DB
{
const DB_DEFAULT_PORT = 3306;
const DB_DBO_DRIVER = "mysql";
const DB_CHARSET = "utf8mb4";
const DB_MARIADB = 0;
const DB_MYSQL = 1;
const DB_PERCONA = 2;
const DB_OTHER = 3;
// For a full list of error codes see https://mariadb.com/kb/en/mariadb-error-codes/
// (That page doesn't list codes only used by MySQL)
const ER_CON_COUNT_ERROR = 1040; // Too many connections
const ER_TOO_MANY_USER_CONNECTIONS = 1203; // User %s already has more than 'max_user_connections' active connections
const ER_USER_LIMIT_REACHED = 1226; // User '%s' has exceeded the '%s' resource (current value: %ld)
private const OPTIONS = [
Mysql::ATTR_FOUND_ROWS => true // Return the number of found (matched) rows, not the number of changed rows.
];
private const MIN_VERSIONS = array(
self::DB_MARIADB => '5.5.3', // '10.0.2' recommended for multiple lock support
self::DB_MYSQL => '5.5.3', // '5.7.5' recommended for multiple lock support
self::DB_PERCONA => '5.5.3' // '5.7.5' recommended for multiple lock support
);
private const DB_NAMES = array(
self::DB_MARIADB => 'MariaDB',
self::DB_MYSQL => 'MySQL',
self::DB_PERCONA => 'Percona'
);
private $db_type = null;
private $returns_native_types = null;
private $supports_multiple_locks = null;
private $version_comment = null;
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
// The attribute is only recognised by PHP 8.2 and later.
public function __construct(
string $db_host,
#[\SensitiveParameter]
string $db_username,
#[\SensitiveParameter]
string $db_password,
#[\SensitiveParameter]
string $db_name,
bool $persist=false,
?int $db_port=null,
array $db_options=[])
{
global $db_retries, $db_delay;
$driver_options = self::siteOptions() + self::OPTIONS;
// We allow retries if the connection fails due to a resource constraint, possibly because
// this database user already has max_user_connections open (through other instances of users
// accessing MRBS) or other database users on the same server have reached the maximum number of
// connections for the database.
$attempts_left = max(1, $db_retries + 1);
while ($attempts_left > 0)
{
try
{
$this->connect(
$db_host,
$db_username,
$db_password,
$db_name,
$persist,
$db_port,
$driver_options
);
// Set $attempts_left to zero as we won't have got here if an exception has been thrown
$attempts_left = 0;
$this->checkVersion();
// Turn off ONLY_FULL_GROUP_BY mode (which is the default in MySQL 5.7.5 and later) to prevent SQL
// errors of the type "Syntax error or access violation: 1055 'mrbs.E.start_time' isn't in GROUP BY".
// TODO: However the proper solution is probably to rewrite the offending queries.
$this->command("SET sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''))");
// Set STRICT_TRANS_TABLES so that we can detect invalid values being inserted in the database
$this->command("SET SESSION sql_mode = 'STRICT_TRANS_TABLES'");
}
catch (PDOException $e)
{
$code = $e->getCode();
$message = $e->getMessage();
if (in_array($code, array(
self::ER_CON_COUNT_ERROR,
self::ER_TOO_MANY_USER_CONNECTIONS,
self::ER_USER_LIMIT_REACHED
)))
{
$attempts_left--;
}
else
{
// It's some kind of error other than a resource error, so retrying won't help
$attempts_left = 0;
if ($code == 2054) // The server requested authentication method unknown to the client [MySQL specific]
{
$message .= ".\n[MRBS note] It looks like you may have an old style MySQL password stored, which cannot be " .
"used with PDO (though it is possible that mysqli may have accepted it). Try " .
"deleting the MySQL user and recreating it with the same password.";
}
}
if ($attempts_left > 0)
{
trigger_error($message . ". Retrying ...", E_USER_NOTICE);
usleep($db_delay * 1000);
}
else
{
throw new DBException($message);
}
}
}
}
// Translates $db_options['mysql'] into an array of options indexed by their
// PDO constants.
// Note that we cannot declare a constant array to hold this mapping as not all
// systems support all the PDO constants.
private static function siteOptions() : array
{
global $db_options;
$result = array();
foreach ($db_options['mysql'] as $key => $value)
{
// Only try and set the option if we need to. Otherwise, we could trigger an
// 'undefined class constant' error unnecessarily.
if (isset($value))
{
try
{
switch ($key)
{
case 'ssl_ca':
$index = Mysql::ATTR_SSL_CA;
break;
case 'ssl_capath':
$index = Mysql::ATTR_SSL_CAPATH;
break;
case 'ssl_cert':
$index = Mysql::ATTR_SSL_CERT;
break;
case 'ssl_cipher':
$index = Mysql::ATTR_SSL_CIPHER;
break;
case 'ssl_key':
$index = Mysql::ATTR_SSL_KEY;
break;
case 'ssl_verify_server_cert':
$index = Mysql::ATTR_SSL_VERIFY_SERVER_CERT;
break;
default:
$index = null;
trigger_error("Unsupported option '$key'");
break;
}
if (isset($index))
{
$result[$index] = $value;
}
}
catch (Error $e)
{
$message = $e->getMessage() . ". Try using the 'nd_pdo_mysql' extension instead of 'pdo_mysql'.";
trigger_error($message, E_USER_WARNING);
Errors::fatalError(get_vocab("fatal_error"));
}
}
}
return $result;
}
// Quote a table or column name (which could be a qualified identifier, eg 'table.column')
public function quote(string $identifier) : string
{
$quote_char = '`';
$parts = explode('.', $identifier);
return $quote_char . implode($quote_char . '.' . $quote_char, $parts) . $quote_char;
}
// Return the value of an autoincrement field from the last insert.
// Must be called right after an insert on that table!
//
// For MySQL we don't need to refer to the passed $table or $field
public function insert_id(string $table, string $field): int
{
return (int)$this->dbh->lastInsertId();
}
// Checks the attribute PDO::ATTR_STRINGIFY_FETCHES
private function getStringifyFetches() : bool
{
// Not all drivers support PDO::ATTR_STRINGIFY_FETCHES
try {
return $this->getAttribute(PDO::ATTR_STRINGIFY_FETCHES);
}
catch (PDOException $e) {
return false;
}
}
public function returnsNativeTypes() : bool
{
if (!isset($this->returns_native_types))
{
// MySQL will return native types if PDO::ATTR_STRINGIFY_FETCHES is false
// and we're using a native driver and (the PHP version is at least 8.1 or
// PDO::ATTR_EMULATE_PREPARES is false).
// See https://stackoverflow.com/questions/1197005/how-to-get-numeric-types-from-mysql-using-pdo
// and https://stackoverflow.com/questions/20079320/how-do-i-return-integer-and-numeric-columns-from-mysql-as-integers-and-numerics
$this->returns_native_types =
!$this->getStringifyFetches()&&
str_contains($this->getAttribute(PDO::ATTR_CLIENT_VERSION), 'mysqlnd') &&
((version_compare(PHP_VERSION, '8.1.0') >= 0) || !$this->getAttribute(PDO::ATTR_EMULATE_PREPARES));
}
return $this->returns_native_types;
}
public function supportsMultipleLocks() : bool
{
// TODO: avoid the use of RELEASE_ALL_LOCKS for MariaDB Galera Cluster (and possibly other cluster implementations?).
if (!isset($this->supports_multiple_locks))
{
if (!empty($this->mutex_locks))
{
throw new Exception(__METHOD__ . " called when there are locks in place.");
}
try
{
// We could check version numbers, but then we have to test for different
// version numbers in MySQL and MariaDB, and possibly others. It's
// probably cleaner to check for the capability to RELEASE_ALL_LOCKS(), which
// was introduced at the same time as support for multiple locks.
$this->query("SELECT RELEASE_ALL_LOCKS()");
$this->supports_multiple_locks = true;
}
catch (DBException $e)
{
$this->supports_multiple_locks = false;
}
}
return $this->supports_multiple_locks;
}
private static function hash(string $name) : string
{
// Since MySQL 5.7.5 lock names have been restricted to 64 characters.
// Truncating them is probably sufficient to ensure uniqueness.
return substr($name, 0, 64);
}
public function mutex_lock(string $name) : bool
{
// TODO: avoid the use of GET_LOCK as it is not supported by MariaDB Galera Cluster (or else get rid of the need
// TODO: for this method).
$timeout = 20; // seconds
if (!$this->supportsMultipleLocks() && !empty($this->mutex_locks))
{
$message = "Trying to set lock '$name', but lock '" . $this->mutex_locks[0] .
"' already exists. Only one lock is allowed at any one time.";
trigger_error($message, E_USER_WARNING);
return false;
}
// GET_LOCK returns 1 if the lock was obtained successfully, 0 if the attempt
// timed out (for example, because another client has previously locked the name),
// or NULL if an error occurred (such as running out of memory or the thread was
// killed with mysqladmin kill)
try
{
$sql_params = array(':str' => self::hash($name),
':timeout' => $timeout);
$stmt = $this->query("SELECT GET_LOCK(:str, :timeout)", $sql_params);
}
catch (DBException $e)
{
trigger_error($e->getMessage(), E_USER_WARNING);
return false;
}
if (($stmt->count() != 1) ||
($stmt->num_fields() != 1))
{
trigger_error("Unexpected number of rows and columns in result", E_USER_WARNING);
return false;
}
$result = $stmt->next_row()[0];
if ($result == '1')
{
$this->mutex_locks[] = $name;
return true;
}
// Otherwise there's been some kind of failure to get a lock
switch ($result)
{
case '0':
$message = "GET_LOCK timed out after $timeout seconds";
break;
case null:
$message = "GET_LOCK: an error occurred (such as running out of memory " .
"or the thread was killed with mysqladmin kill)";
break;
default:
$message = "GET_LOCK: unexpected result '$result'";
break;
}
trigger_error($message, E_USER_WARNING);
return false;
}
public function mutex_unlock(string $name) : bool
{
// TODO: avoid the use of RELEASE_LOCK as it is not supported by MariaDB Galera Cluster (or else get rid of the need
// TODO: for this method).
// First do some sanity checking before executing the SQL query
if (!in_array($name, $this->mutex_locks))
{
trigger_error("Trying to release a lock ('$name') which hasn't been set", E_USER_WARNING);
return false;
}
// If this request looks OK, then execute the SQL query
try
{
$stmt = $this->query("SELECT RELEASE_LOCK(?)", array(self::hash($name)));
}
catch (DBException $e)
{
trigger_error($e->getMessage(), E_USER_WARNING);
return false;
}
if (($stmt->count() != 1) ||
($stmt->num_fields() != 1))
{
trigger_error("Unexpected number of rows and columns in result", E_USER_WARNING);
return false;
}
$result = $stmt->next_row()[0];
if ($result == '1')
{
if (($key = array_search($name, $this->mutex_locks)) !== false)
{
unset($this->mutex_locks[$key]);
}
return true;
}
// Otherwise there's been some kind of failure to release a lock. These should in theory
// have been caught by the sanity checking above, but just in case ...
switch ($result)
{
case '0':
$message = "RELEASE_LOCK: the lock '$name' was not established by this thread and so could not be released";
break;
case null:
$message = "RELEASE_LOCK: the lock '$name' does not exist";
break;
default:
$message = "RELEASE_LOCK: unexpected result '$result'";
break;
}
trigger_error($message, E_USER_WARNING);
return false;
}
public function mutex_unlock_all() : void
{
// TODO: avoid the use of RELEASE_ALL_LOCKS as it is not supported by MariaDB Galera Cluster (or else get rid of the need
// TODO: for this method).
if ($this->supportsMultipleLocks())
{
$this->query("SELECT RELEASE_ALL_LOCKS()");
}
else
{
foreach ($this->mutex_locks as $lock)
{
$this->mutex_unlock($lock);
}
}
}
private function dbType() : ?int
{
global $debug;
if (!isset($this->db_type))
{
if ((false !== mb_stripos($this->versionComment(), 'maria')) || (false !== mb_stripos($this->version(), 'maria')))
{
$this->db_type = self::DB_MARIADB;
}
elseif ((false !== mb_stripos($this->versionComment(), 'mysql')) || (false !== mb_stripos($this->version(), 'mysql')))
{
$this->db_type = self::DB_MYSQL;
}
// Most Ubuntu packages will identify the database type - see https://github.com/meeting-room-booking-system/mrbs-code/issues/72.
// But there are some packages that don't seem to include the database type in any of the version information, for example
// see SF Bugs #545 (https://sourceforge.net/p/mrbs/bugs/545/). Let's assume that they are MySQL databases, though this isn't
// necessarily true as it seems Ubuntu can be packaged with either MySQL or MariaDB - see for example https://launchpad.net/ubuntu.
// However, if we assume MySQL then the required MySQL version number will be less than or equal to the required MariaDB version
// number and the initial version check will pass, though the code may fail later on when it tries to use an unsupported feature.
// TODO: something better. Perhaps we could also look at version numbers and then make some assumptions about whether the database
// TODO: is MySQL or MariaDB, but that could become dangerous in the future. Or perhaps there's some other way.
elseif ((false !== mb_stripos($this->versionComment(), 'ubuntu')) || (false !== mb_stripos($this->version(), 'ubuntu')))
{
$this->db_type = self::DB_MYSQL;
}
elseif ((false !== mb_stripos($this->versionComment(), 'percona')) || (false !== mb_stripos($this->version(), 'percona')))
{
$this->db_type = self::DB_PERCONA;
}
// The Altervista.org hosting platform will give this version comment
elseif ($this->versionComment() == 'Source distribution')
{
$this->db_type = self::DB_MYSQL;
}
else
{
if ($debug)
{
trigger_error("Unknown database type '" . $this->versionComment() . "'");
}
$this->db_type = self::DB_OTHER;
}
}
return $this->db_type;
}
// Checks that the database version meets the minimum requirement and dies if not
private function checkVersion() : void
{
$db_version = $this->versionNumber();
$db_type = $this->dbType();
if (isset(self::MIN_VERSIONS[$db_type]) &&
(version_compare($db_version, self::MIN_VERSIONS[$db_type]) < 0))
{
$this->versionDie(self::DB_NAMES[$db_type], $db_version, self::MIN_VERSIONS[$db_type]);
}
// If it's another type of database we'll have to add some minimum version requirements fot it
}
// Returns the version_comment variable, eg "MySQL Community Server - GPL"
// or "MariaDB Server".
private function versionComment() : string
{
if (!isset($this->version_comment))
{
$sql = "SHOW variables LIKE 'version_comment'";
$res = $this->query($sql);
$row = $res->next_row_keyed();
$this->version_comment = ($row === false) ? '' : $row['Value'];
}
return $this->version_comment;
}
// Returns the database version number as a string
private function versionNumber() : string
{
$result = $this->versionString();
// Extract the version number
preg_match('/^\d+(\.\d+)+/', $result, $matches);
return $matches[0];
}
public function version() : string
{
return $this->versionComment() . ' DB_mysql.php' . $this->versionString();
}
public function table_exists(string $table) : bool
{
$res = $this->query("SHOW TABLES LIKE ?", array($table));
return ($res->count() > 0);
}
public function field_info(string $table) : array
{
// Map MySQL types on to a set of generic types
$nature_map = array(
'bigint' => 'integer',
'blob' => 'binary',
'char' => 'character',
'date' => 'timestamp',
'datetime' => 'timestamp',
'decimal' => 'decimal',
'double' => 'real',
'float' => 'real',
'int' => 'integer',
'longblob' => 'binary',
'longtext' => 'character',
'mediumblob' => 'binary',
'mediumint' => 'integer',
'mediumtext' => 'character',
'numeric' => 'decimal',
'smallint' => 'integer',
'text' => 'character',
'time' => 'timestamp',
'timestamp' => 'timestamp',
'tinyblob' => 'binary',
'tinyint' => 'integer',
'tinytext' => 'character',
'varchar' => 'character',
'year' => 'timestamp'
);
// Length in bytes of MySQL integer types
$int_bytes = array(
'bigint' => 8, // bytes
'int' => 4,
'mediumint' => 3,
'smallint' => 2,
'tinyint' => 1
);
$stmt = $this->query("SHOW COLUMNS FROM $table", array());
$fields = array();
while (false !== ($row = $stmt->next_row_keyed()))
{
$name = $row['Field'];
$type = $row['Type'];
$default = $row['Default'];
// Get the type and optionally length in parentheses, ignoring any attributes. Note that the
// length could be of the form (6,2) for a decimal. Examples that we have to cope with:
// tinyint
// tinyint unsigned
// decimal(6,2)
// varchar(255)
// mediumint(4) unsigned zerofill
// The type will be in the first group and the length in the optional second group
preg_match('/(\w+)[\s(]?([\d,]+)?/', $type, $matches);
$short_type = $matches[1];
// map the type onto one of the generic natures, if a mapping exists
$nature = (array_key_exists($short_type, $nature_map)) ? $nature_map[$short_type] : $short_type;
// now work out the length
if ($nature == 'integer')
{
// Convert the default to an int (unless it's NULL)
if (isset($default))
{
$default = (int) $default;
}
// if it's one of the ints, then look up the length in bytes
$length = (array_key_exists($short_type, $int_bytes)) ? $int_bytes[$short_type] : 0;
}
elseif (($nature == 'character') || ($nature == 'decimal'))
{
// if it's a character or decimal type then use the length that was in parentheses
// eg if it was a varchar(25), we want the 25 and if a decimal(6,2) we want the 6,2
if (isset($matches[2]))
{
$length = $matches[2];
}
// otherwise it could be any length (eg if it was a 'text')
else
{
$length = defined('PHP_INT_MAX') ? PHP_INT_MAX : 9999;
}
}
else // we're only dealing with a few simple cases at the moment
{
$length = null;
}
// Convert the is_nullable field to a boolean
$is_nullable = (mb_strtolower($row['Null']) == 'yes');
$fields[] = array(
'name' => $name,
'type' => $type,
'nature' => $nature,
'length' => $length,
'is_nullable' => $is_nullable,
'default' => $default
);
}
return $fields;
}
// Syntax methods
public function syntax_limit(int $count, int $offset) : string
{
return "LIMIT $offset,$count";
}
public function syntax_timestamp_to_unix(string $fieldname) : string
{
return "UNIX_TIMESTAMP($fieldname)";
}
public function syntax_casesensitive_equals(string $fieldname, string $string, array &$params) : string
{
$params[] = $string;
// The '=' comparison in MySQL allows trailing spaces, eg 'john' = 'john ', so we cannot just use that.
// Also, by default MySQL is case-insensitive, so we force a binary comparison.
// We cannot assume that the database column has utf8 collation. We may, for example, be
// authenticating a user against an external database. See the post at
// https://stackoverflow.com/questions/5629111/how-can-i-make-sql-case-sensitive-string-comparison-on-mysql#answer-56283818
// for an explanation of the query.
return $this->quote($fieldname) . "=CONVERT(? using utf8mb4) COLLATE utf8mb4_bin";
}
public function syntax_caseless_contains(string $fieldname, string $string, array &$params) : string
{
// In MySQL, REGEXP seems to be case-sensitive, so use LIKE instead. But this
// requires quoting of % and _ in addition to the usual.
$string = str_replace("\\", "\\\\", $string);
$string = str_replace("%", "\\%", $string);
$string = str_replace("_", "\\_", $string);
$params[] = "%$string%";
return "$fieldname LIKE ?";
}
public function syntax_addcolumn_after(string $fieldname) : string
{
return "AFTER $fieldname";
}
public function syntax_createtable_autoincrementcolumn() : string
{
return "int NOT NULL auto_increment";
}
public function syntax_bitwise_xor() : string
{
return "^";
}
public function syntax_simple_split(string $fieldname, string $delimiter, int $part, array &$params) : string
{
switch ($part)
{
case 1:
$count = 1;
break;
case 2:
$count = -1;
break;
default:
throw new Exception("Invalid value ($part) given for " . '$part.');
break;
}
$params[] = $delimiter;
return "SUBSTRING_INDEX($fieldname, ?, $count)";
}
public function syntax_group_array_as_string(string $fieldname, string $delimiter=',') : string
{
// Use DISTINCT to eliminate duplicates which can arise when the query
// has joins on two or more junction tables. Maybe a different query
// would eliminate the duplicates and the need for DISTINCT, and it may
// or may not be more efficient.
return "GROUP_CONCAT(DISTINCT $fieldname SEPARATOR '$delimiter')";
}
// Returns the syntax for an "upsert" query. Unfortunately getting the id of the
// last row differs between MySQL and PostgreSQL. In PostgreSQL the query will
// return a row with the id in the 'id' column. However there isn't a corresponding
// way of doing this in MySQL, but db()->insert_id() will work, regardless of whether
// an insert or update was performed.
//
// $conflict_keys the key(s) which is/are unique; can be a scalar or an array
// (ignored in MySQL)
// $assignments an array of assignments for the UPDATE clause
// $has_id_column whether the table has an id column
public function syntax_on_duplicate_key_update($conflict_keys, array $assignments, bool $has_id_column=false) : string
{
if ($has_id_column)
{
// In order to make lastInsertId() work even after an UPDATE
$assignments[] = "id=LAST_INSERT_ID(id)";
}
return "ON DUPLICATE KEY UPDATE " . implode(', ', $assignments);
}
}
+493
View File
@@ -0,0 +1,493 @@
<?php
declare(strict_types=1);
namespace MRBS\DB;
use PDOException;
class DB_pgsql extends DB
{
const DB_DEFAULT_PORT = 5432;
const DB_DBO_DRIVER = "pgsql";
private const MIN_VERSION = '8.2';
private const OPTIONS = array();
private const OPTIONS_MAP = array();
// The SensitiveParameter attribute needs to be on a separate line for PHP 7.
// The attribute is only recognised by PHP 8.2 and later.
public function __construct(
string $db_host,
#[\SensitiveParameter]
string $db_username,
#[\SensitiveParameter]
string $db_password,
#[\SensitiveParameter]
string $db_name,
bool $persist=false,
?int $db_port=null,
array $db_options=[])
{
$driver_options = self::OPTIONS;
// If user-defined driver options exist add them in to the standard driver options, having
// first replaced the keys with their PDO values.
if (!empty($db_options['pgsql']))
{
$driver_options = self::replaceOptionKeys($db_options['pgsql'], self::OPTIONS_MAP) + $driver_options;
}
try
{
$this->connect(
$db_host,
$db_username,
$db_password,
$db_name,
$persist,
$db_port,
$driver_options
);
$this->checkVersion();
}
catch (PDOException $e)
{
$message = $e->getMessage();
// This can be a problem when migrating to the PDO version of MRBS from an earlier version.
if (($e->getCode() == 7) && ($db_host === ''))
{
$message .= ".\n[MRBS note] Try setting " . '$db_host' . " to '127.0.0.1'.";
}
throw new DBException($message);
}
}
// A small utility function (not part of the DB abstraction API) to
// resolve a qualified table name into its schema and table components.
// Returns an array indexed by 'table_schema' and 'table_name'.
// 'table_schema' can be NULL
private static function resolve_table(string $table) : array
{
if (mb_strpos($table, '.') === false)
{
$table_schema = null;
$table_name = $table;
}
else
{
list($table_schema, $table_name) = explode('.', $table, 2);
}
return array('table_schema' => $table_schema,
'table_name' => $table_name);
}
// Quote a table or column name (which could be a qualified identifier, eg 'table.column')
// NOTE: We fold the identifier to lower case here even though it is quoted. Unlike MySQL,
// PostgreSQL folds identifiers to lower case, unless they are quoted. However in MRBS we
// normally want to quote an identifier in case it has characters such as spaces in it, as
// could be the case with user generated column names for custom fields. But if we were also
// to quote the table name, then queries such as 'SELECT * FROM mrbs_entry E WHERE "E"."id"=2'
// would fail because the alias 'E' is folded to 'e', but the WHERE clause gives 'E.id'.
// This means that we won't be able to distinguish in PostgreSQL between column names that just
// differ in case. But having column names differing in case would be confusing anyway and so
// should be discouraged. And a PostgreSQL user generating custom fields would expect them to
// be folded to lower case anyway, so presumably wouldn't try and create column names differing
// only in case.
public function quote(string $identifier) : string
{
$quote_char = '"';
$parts = explode('.', strtolower($identifier));
return $quote_char . implode($quote_char . '.' . $quote_char, $parts) . $quote_char;
}
// Return the value of an autoincrement field from the last insert.
// For PostgreSQL, this must be a SERIAL type field.
public function insert_id(string $table, string $field): int
{
$seq_name = $table . "_" . $field . "_seq";
return (int)$this->dbh->lastInsertId($seq_name);
}
// Hash a string into an int.
// In PostgreSQL advisory lock keys are BIGINTs.
private static function hash(string $name) : int
{
return crc32($name);
}
public function returnsNativeTypes() : bool
{
return true;
}
public function supportsMultipleLocks(): bool
{
return true;
}
public function mutex_lock(string $name) : bool
{
// pg_advisory_lock() will block indefinitely by default until a lock
// is obtained or a deadlock detected.
// TODO: should we set a lock timeout?
try
{
$this->query("SELECT pg_advisory_lock(" . self::hash($name) . ")");
}
catch (DBException $e)
{
trigger_error($e->getMessage());
return false;
}
$this->mutex_locks[] = $name;
return true;
}
public function mutex_unlock(string $name) : bool
{
$sql = "SELECT pg_advisory_unlock(" . self::hash($name) . ")";
$res = $this->query($sql);
$row = $res->next_row();
if ($row === false)
{
throw new DBException("Unexpected pg_advisory_unlock() error");
}
$result = $row[0];
if ($result)
{
if (($key = array_search($name, $this->mutex_locks)) !== false)
{
unset($this->mutex_locks[$key]);
}
}
return $result;
}
public function mutex_unlock_all() : void
{
$this->query("SELECT pg_advisory_unlock_all()");
}
// Checks that the database version meets the minimum requirement and dies if not
private function checkVersion() : void
{
$this_version = $this->versionNumber();
if (version_compare($this_version, self::MIN_VERSION) < 0)
{
$this->versionDie('PostgreSQL', $this_version, self::MIN_VERSION);
}
}
public function version() : string
{
return $this->versionString();
}
// Just returns a version number, eg "9.2.24"
private function versionNumber() : string
{
$result = $this->query_scalar_non_bool("SHOW SERVER_VERSION");
if ($result === false)
{
throw new Exception("Could not get PostgreSQL server version");
}
return $result;
}
public function table_exists(string $table) : bool
{
// $table can be a qualified name. We need to resolve it if necessary into its component
// parts, the schema and table names
$table_parts = self::resolve_table($table);
$sql_params = array();
$sql = "SELECT COUNT(*)
FROM information_schema.tables
WHERE table_name = ?";
$sql_params[] = $table_parts['table_name'];
if (isset($table_parts['table_schema']))
{
$sql .= " AND table_schema = ?";
$sql_params[] = $table_parts['table_schema'];
}
$res = $this->query1($sql, $sql_params);
if ($res == 0)
{
return false;
}
elseif ($res == 1)
{
return true;
}
elseif (($res > 1) && !isset($table_parts['table_schema']))
{
$message = "More than one table called '$table'. You need to set " . '$db_schema in the config file.';
throw new DBException($message);
}
else
{
$message = "Unexpected result from SELECT COUNT(*) query.";
throw new DBException($message);
}
}
public function field_info(string $table) : array
{
$fields = array();
// Map PostgreSQL types on to a set of generic types
$nature_map = array(
'bigint' => 'integer',
'boolean' => 'boolean',
'bytea' => 'binary',
'character' => 'character',
'character varying' => 'character',
'date' => 'timestamp',
'decimal' => 'decimal',
'double precision' => 'real',
'integer' => 'integer',
'numeric' => 'decimal',
'real' => 'real',
'smallint' => 'integer',
'text' => 'character',
'time with time zone' => 'timestamp',
'time without time zone' => 'timestamp',
'timestamp with time zone' => 'timestamp'
);
// $table can be a qualified name. We need to resolve it if necessary into its component
// parts, the schema and table names
$table_parts = self::resolve_table($table);
$sql_params = array();
// $table_name and $table_schema should be trusted but escape them anyway for good measure
$sql = "SELECT column_name, column_default, data_type, numeric_precision, numeric_scale,
character_maximum_length, character_octet_length, is_nullable
FROM information_schema.columns
WHERE table_name = ?";
$sql_params[] = $table_parts['table_name'];
if (isset($table_parts['table_schema']))
{
$sql .= " AND table_schema = ?";
$sql_params[] = $table_parts['table_schema'];
}
$sql .= " ORDER BY ordinal_position";
$stmt = $this->query($sql, $sql_params);
while (false !== ($row = $stmt->next_row_keyed()))
{
$name = $row['column_name'];
$type = $row['data_type'];
$parsed_default = $this->parseDefault($row['column_default']);
$default = $parsed_default['value'];
// map the type onto one of the generic natures, if a mapping exists
$nature = (array_key_exists($type, $nature_map)) ? $nature_map[$type] : $type;
// Convert the default to be of the correct type
if (isset($default) && ($nature == 'integer'))
{
$default = (int) $default;
}
// Get a length value; one of these values should be set
if (isset($row['numeric_precision']))
{
if ($nature == 'decimal')
{
$length = $row['numeric_precision'] . ',' . $row['numeric_scale'];
}
else
{
$length = (int) floor($row['numeric_precision'] / 8); // precision is in bits
}
}
elseif (isset($row['character_maximum_length']))
{
$length = $row['character_maximum_length'];
}
elseif (isset($row['character_octet_length']))
{
$length = $row['character_octet_length'];
}
// Convert the is_nullable field to a boolean
$is_nullable = (mb_strtolower($row['is_nullable']) == 'yes');
$fields[] = array(
'name' => $name,
'type' => $type,
'nature' => $nature,
'length' => $length,
'is_nullable' => $is_nullable,
'default' => $default
);
}
return $fields;
}
// Syntax methods
public function syntax_limit(int $count, int $offset) : string
{
return "LIMIT $count OFFSET $offset";
}
public function syntax_timestamp_to_unix(string $fieldname) : string
{
// A PostgreSQL timestamp can be a float. We need to round it
// to the nearest integer. Note that ROUND still returns a float type
// even though the value is an integer, so we need to cast it as well.
// (But the casting may round as well? If so the round is redundant.)
return "CAST(ROUND(DATE_PART('epoch', $fieldname)) AS integer)";
}
public function syntax_casesensitive_equals(string $fieldname, string $string, array &$params) : string
{
$params[] = $string;
return $this->quote($fieldname) . "=?";
}
public function syntax_caseless_contains(string $fieldname, string $string, array &$params) : string
{
// In PostgreSQL, we can do case-insensitive regexp with ~*, but not case-insensitive LIKE matching.
// Quotemeta escapes everything we need except for single quotes.
$params[] = quotemeta($string);
return "$fieldname ~* ?";
}
public function syntax_addcolumn_after(string $fieldname) : string
{
// Can't be done in PostgreSQL without dropping and re-creating the table.
return '';
}
public function syntax_createtable_autoincrementcolumn() : string
{
return "serial";
}
public function syntax_bitwise_xor() : string
{
return "#";
}
public function syntax_simple_split(string $fieldname, string $delimiter, int $part, array &$params) : string
{
switch ($part)
{
case 1:
case 2:
$count = $part;
break;
default:
throw new Exception("Invalid value ($part) given for " . '$part.');
break;
}
$params[] = $delimiter;
return "SPLIT_PART($fieldname, ?, $count)";
}
public function syntax_group_array_as_string(string $fieldname, string $delimiter=',') : string
{
// array_agg introduced in PostgreSQL version 8.4
//
// Use DISTINCT to eliminate duplicates which can arise when the query
// has joins on two or more junction tables. Maybe a different query
// would eliminate the duplicates and the need for DISTINCT, and it may
// or may not be more efficient.
return "array_to_string(array_agg(DISTINCT $fieldname), '$delimiter')";
}
// Returns the syntax for an "upsert" query. Unfortunately getting the id of the
// last row differs between MySQL and PostgreSQL. In PostgreSQL the query will
// return a row with the id in the 'id' column. However there isn't a corresponding
// way of doing this in MySQL, but db()->insert_id() will work, regardless of whether
// an insert or update was performed. In PostgreSQL insert_id() returns the sequence
// number and not the id of the row. Because the sequence number is updated on every
// INSERT in Postgres, regardless of whether a row was actually inserted, the value
// won't be the id of the row in the case of an update. Note that one side effect of
// this behaviour is that there will be gaps in the sequence numbers of the rows, but
// this doesn't matter.
//
// $conflict_keys the key(s) which is/are unique; can be a scalar or an array
// $assignments an array of assignments for the UPDATE clause
// $has_id_column whether the table has an id column
public function syntax_on_duplicate_key_update($conflict_keys, array $assignments, bool $has_id_column=false) : string
{
$conflict_keys = array_map(array($this, 'quote'), $conflict_keys);
$sql = "ON CONFLICT (" . implode(', ', $conflict_keys) . ")";
$sql .= " DO UPDATE SET " . implode(', ', $assignments);
if ($has_id_column)
{
$sql .= " RETURNING id";
}
return $sql;
}
// Parse the contents of column_default to get the default value.
// Examples of column_default are "nextval('mrbs_users_id_seq'::regclass)", "NULL",
// "0" and "'E'::bpchar"
// WARNING: this is a very rough and ready parser and only deals with simple cases.
// TODO: do something better
private function parseDefault($default)
{
if (is_null($default) || str_starts_with($default, 'NULL::'))
{
$value = null;
}
elseif (preg_match("/^'(.*)'::/", $default, $matches))
{
$value = $matches[1];
}
else
{
$value = $default;
}
return ['value' => $value];
}
}
+568
View File
@@ -0,0 +1,568 @@
<?php
declare(strict_types=1);
namespace MRBS;
use IntlCalendar;
use MRBS\ICalendar\RFC5545;
use UnexpectedValueException;
class DateTime extends \DateTime
{
private static $isHoliday = array();
private static $validHolidays = array();
public const ISO8601_DATE = 'Y-m-d';
private const HOLIDAY_RANGE_OPERATOR = '..';
// Before PHP 8 a child of DateTime::createFromFormat() returned an instance of the
// parent, rather than the child. So we have to force createFromFormat() to return
// an instance of the child. See https://bugs.php.net/bug.php?id=79975 and also
// https://stackoverflow.com/questions/5450197/make-datetimecreatefromformat-return-child-class-instead-of-parent
// This method will no longer be necessary when the minimum PHP version is > 7.
#[\ReturnTypeWillChange]
public static function createFromFormat($format, $datetime, ?\DateTimeZone $timezone = null)
{
assert(version_compare(MRBS_MIN_PHP_VERSION, '8.0.0', '<'), "This method is now redundant.");
$parent = parent::createFromFormat($format, $datetime, $timezone);
if ($parent === false)
{
return false;
}
return new static($parent->format('Y-m-d\TH:i:s.u'), $parent->getTimezone());
}
// Returns the first day of the week (0 = Sunday) for a given timezone and locale.
// If $timezone is null, the default timezone will be used.
// If $locale is null, the default locale will be used.
// The method relies on the IntlCalendar class. If it doesn't exist, or there's an error,
// the method assumes that the week starts on a Monday.
public static function firstDayOfWeek(?string $timezone = null, ?string $locale = null) : int
{
global $icu_override;
$default = 1; // Monday
if (!class_exists('\\IntlCalendar'))
{
return $default;
}
$calendar = IntlCalendar::createInstance($timezone, $locale);
if (!isset($calendar))
{
trigger_error("Could not create IntlCalendar for timezone '$timezone' and locale '$locale'", E_USER_WARNING);
return $default;
}
// If we're overriding the ICU library then use that value
if (isset($icu_override[$locale]['first_day_of_week']))
{
$first_day = $icu_override[$locale]['first_day_of_week'];
// Check that it's a valid day
if (!in_array($first_day, array(
IntlCalendar::DOW_SUNDAY,
IntlCalendar::DOW_MONDAY,
IntlCalendar::DOW_TUESDAY,
IntlCalendar::DOW_WEDNESDAY,
IntlCalendar::DOW_THURSDAY,
IntlCalendar::DOW_FRIDAY,
IntlCalendar::DOW_SATURDAY
)))
{
throw new Exception('$icu_override[' . $locale . "]['first_day_of_week'] must be in the range [1..7]");
}
}
// Otherwise just get the standard value from ICU
else
{
$first_day = $calendar->getFirstDayOfWeek();
if ($first_day === false) {
trigger_error($calendar->getErrorMessage(), E_USER_WARNING);
return $default;
}
}
return $first_day - 1; // IntlCalendar::DOW_SUNDAY = 1, so we need to subtract 1
}
// Tests whether this is the first day of the week in the locale.
// If $locale is null, the default locale will be used.
public function isFirstDayOfWeek(?string $locale = null) : bool
{
return ($this->getDayOfWeek() === self::firstDayOfWeek($this->getTimezone()->getName(), $locale));
}
// TODO: make $relative an object?
// Sets the day to $relative, where relative is an RFC5545 relative day,
// eg "-2SU". Returns FALSE if the relative day doesn't exist in this
// month, otherwise TRUE.
public function setRelativeDay(string $relative) : bool
{
$clone = clone $this;
// Get the ordinal number and the day of the week
list('ordinal' => $ord, 'day' => $dow) = RFC5545::parseByday($relative);
// Set the starting day of the month, either to the first or last day of
// the month, depending on whether we are counting forwards or backwards.
$clone->setDay(($ord > 0) ? 1 : (int) $clone->format('t'));
// Advance/go back to the first day of the week that is required
// TODO: this could be optimised slightly by calculating the exact number of days required
while ($clone->format('w') != RFC5545::convertDayToOrd($dow))
{
$modifier = ($ord > 0) ? '+1 day' : '-1 day';
$clone->modify($modifier);
}
// Advance/go back the required number of weeks
if (abs($ord) > 1)
{
$modifier = (($ord > 0) ? '+' : '-') . ($ord - 1) . 'weeks';
$clone->modify($modifier);
}
// See if we are still in the same month. If not, then the relative day doesn't
// exist in this month and return FALSE. If so, then set this date to be the
// clone's and return TRUE.
if ($clone->getMonth() !== $this->getMonth())
{
return false;
}
$this->setTimestamp($clone->getTimestamp());
return true;
}
// Sets the time to the start of the first slot of the day
public function setStartFirstSlot() : self
{
global $morningstarts, $morningstarts_minutes, $enable_periods;
if ($enable_periods)
{
return $this->setTime(12, 0);
}
return $this->setTime($morningstarts, $morningstarts_minutes);
}
// Sets the time to the start of the last slot of the day.
// (Note that if the booking day extends beyond midnight, then this will
// be on the next day.)
public function setStartLastSlot() : self
{
global $morningstarts, $morningstarts_minutes, $eveningends, $eveningends_minutes, $enable_periods, $periods;
if ($enable_periods)
{
return $this->setTime(12, count($periods) - 1);
}
// Work out if $evening_ends is really on the next day
if (hm_before(
['hours' => $eveningends, 'minutes' => $eveningends_minutes],
['hours' => $morningstarts, 'minutes' => $morningstarts_minutes])
)
{
$this->modify('+1 day');
}
return $this->setTime($eveningends, $eveningends_minutes);
}
// Sets the time to the end of the last slot of the day.
// (Note that if the booking day extends beyond midnight, then this will
// be on the next day.)
public function setEndLastSlot() : self
{
global $resolution;
$this->setStartLastSlot();
$this->modify("+$resolution seconds");
return $this;
}
/**
* Set the time, given as HH:MM.
*
* @param string $hhmm The time in the format HH:MM (24-hour format is used)
*/
public function setHourMinute(string $hhmm) : self
{
if (count($exploded = explode(':', $hhmm)) !== 2)
{
throw new \InvalidArgumentException("Invalid time '$hhmm'");
}
list($hour, $minute) = $exploded;
$this->setTime(intval($hour), intval($minute));
return $this;
}
// Adds $n (which can be negative) months to this date, without overflowing
// into the next month. For example modifying 2023-01-31 by +1 month gives
// 2023-02-28 rather than 2023-03-03.
public function modifyMonthsNoOverflow(int $n, bool $allow_hidden_days = false) : void
{
if ($n == 0)
{
return;
}
$modifier = "$n months";
$day = $this->format('j');
$this->modify('first day of this month');
$this->modify($modifier);
$this->modify('+' . (min($day, $this->format('t')) - 1) . ' days');
if (!$allow_hidden_days)
{
$this->findNearestUnhiddenDayInMonth();
}
}
// Adds $n (which can be negative) years to this date, without overflowing
// into the next month. For example modifying 2024-02-29 by +1 year gives
// 2025-02-28 rather than 2025-03-01.
public function modifyYearsNoOverflow(int $n, bool $allow_hidden_days = false) : void
{
$this->modifyMonthsNoOverflow(12 * $n, $allow_hidden_days);
}
/**
* Get the hour in 24-hour format without leading zeros
*/
public function getHour() : int
{
return intval($this->format('G'));
}
/**
* Get the minute without leading zeros
*/
public function getMinute() : int
{
return intval($this->format('i'));
}
/**
* Get the day of the month without leading zeros
*/
public function getDay() : int
{
return intval($this->format('j'));
}
public function getDayOfWeek() : int
{
return intval($this->format('w'));
}
public function getDaysInMonth() : int
{
return intval($this->format('t'));
}
/**
* Get the numeric representation of a month, without leading zeros
*/
public function getMonth() : int
{
return intval($this->format('n'));
}
/**
* Get the full numeric representation of a year, at least 4 digits, with - for years BCE.
*/
public function getYear() : int
{
return intval($this->format('Y'));
}
// Returns a date in ISO 8601 format ('yyyy-mm-dd')
public function getISODate() : string
{
return $this->format(self::ISO8601_DATE);
}
// Set the day to $day
public function setDay(int $day) : self
{
$date = getdate($this->getTimestamp());
$this->setDate($date['year'], $date['mon'], $day);
return $this;
}
// Sets the day to $day, but not past the end of the month
public function setDayNoOverflow(int $day) : self
{
$this->setDay(min($day, (int) $this->format('t')));
return $this;
}
// Winds the date back to the first $month that is at or before
// this date. Useful for aligning with financial or academic years.
// If $month is 0, then no change is made.
public function setMonthYearStart(int $month) : self
{
if ($month !== 0)
{
$this_month = $this->getMonth();
$modification = $month - $this_month;
if ($modification > 0)
{
$modification -= MONTHS_PER_YEAR;
}
$this->modifyMonthsNoOverflow($modification, true);
}
return $this;
}
// Checks whether the config setting of holidays for $year consists
// of a valid set of dates.
private static function validateHolidays(string $year) : bool
{
global $holidays;
// Only need to validate a year once, so store the answer in a static property
if (!isset(self::$validHolidays[$year]))
{
try
{
foreach ($holidays[$year] as $holiday)
{
$limits = explode(self::HOLIDAY_RANGE_OPERATOR, $holiday);
foreach ($limits as $limit)
{
// Check that the dates are valid
if (!validate_iso_date($limit))
{
throw new UnexpectedValueException("invalid holiday date '$limit'.");
}
// Check that the year is correct
if ($year != split_iso_date($limit)[0])
{
throw new UnexpectedValueException("the holiday '$limit' does not occur in the year '$year'.");
}
}
// Check that we haven't got more than two limits
if (count($limits) > 2)
{
throw new UnexpectedValueException("invalid range '$holiday'; there is more than one " .
"range operator (" . self::HOLIDAY_RANGE_OPERATOR . ").");
}
// Check that the end of the range isn't before the beginning
elseif ((count($limits) == 2) && ($limits[1] < $limits[0]))
{
throw new UnexpectedValueException("invalid range '$holiday'; the end is before the beginning.");
}
}
self::$validHolidays[$year] = true;
}
catch (UnexpectedValueException $e)
{
self::$validHolidays[$year] = false;
trigger_error('Check the config setting of $holidays: ' . $e->getMessage(), E_USER_WARNING);
}
}
return self::$validHolidays[$year];
}
// Move the date to the nearest unhidden day in this month.
private function findNearestUnhiddenDayInMonth() : void
{
// Trivial case: it's already unhidden
if (!$this->isHiddenDay())
{
return;
}
// Keep track of whether we've already tried looking beyond the ends
// of the month, to avoid doing it again unnecessarily.
$end_of_month_reached = false;
$start_of_month_reached =false;
// Create a series of modifiers going progressively +1, -1, +2, -2 ..
// +6, -6 days away from this day and test each one to check that the
// modified day is both in the same month as the original date and is
// not hidden.
for ($i=1; $i<DAYS_PER_WEEK; $i++)
{
$unsigned_modifier = "$i days";
foreach (['+', '-'] as $sign)
{
// Check whether we've already been past the end/start of the
// month, and if so try the next modifier.
if ((($sign == '+') && $end_of_month_reached) ||
(($sign == '-') && $start_of_month_reached))
{
continue;
}
// Otherwise, create a clone and test it with this modifier
$clone = clone $this;
$modifier = $sign . $unsigned_modifier;
$clone->modify($modifier);
if ($clone->getMonth() == $this->getMonth())
{
if (!$clone->isHiddenDay())
{
// Success! The clone is in the same month and not hidden,
// so apply the same modifier to the original.
$this->modify($modifier);
return;
}
}
else
{
if ($sign == '+')
{
$end_of_month_reached = true;
}
else
{
$start_of_month_reached = true;
}
}
unset($clone);
}
}
}
// Determines whether the date is a holiday, as defined
// in the config variable $holidays.
private function isHolidayConfig() : bool
{
global $holidays;
$year = $this->format('Y');
$iso_date = $this->getISODate();
// Only need to check if a date is a holiday once, so store the answer in a
// static property
if (!isset(self::$isHoliday[$iso_date]))
{
self::$isHoliday[$iso_date] = false;
if (!empty($holidays[$year]) && self::validateHolidays($year))
{
foreach ($holidays[$year] as $holiday)
{
$limits = explode('..', $holiday);
if (count($limits) == 1)
{
// It's a single date of the form '2022-01-01'
if ($iso_date == $limits[0])
{
self::$isHoliday[$iso_date] = true;
break;
}
}
elseif (count($limits) == 2)
{
// It's a range of the form '2022-07-01..2022-07-31'
if (($iso_date >= $limits[0]) && ($iso_date <= $limits[1]))
{
self::$isHoliday[$iso_date] = true;
break;
}
}
else
{
trigger_error("Invalid holiday element '$holiday'");
}
}
}
}
return self::$isHoliday[$iso_date];
}
// Determines whether a given date is supposed to be hidden in the display
public function isHiddenDay() : bool
{
global $hidden_days;
return (isset($hidden_days) && in_array($this->format('w'), $hidden_days));
}
// Determines whether the date is a holiday.
public function isHoliday() : bool
{
if ($this->isHolidayConfig())
{
return true;
}
// This method can be extended to check other sources, eg a .ics
// file or a database table.
return false;
}
public function isToday() : bool
{
$today = new DateTime();
return ($this->getISODate() == $today->getISODate());
}
public function isWeekend() : bool
{
return is_weekend(intval($this->format('w')));
}
// Set the time to $s, where $s is the nominal number of
// seconds after midnight, ignoring DST changes.
public function setNominalSeconds(int $s) : self
{
$second = $s % 60;
$s -= $second;
$m = $s/60;
$minute = $m % 60;
$m -= $minute;
$hour = $m/60;
while ($hour > 24)
{
$this->modify('+1 day');
$hour -= 24;
}
return $this->setTime($hour, $minute, $second);
}
}
+183
View File
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace MRBS;
use DateInterval;
use IntlDateFormatter;
use OpenPsa\Ranger\Ranger;
class EntryInterval
{
private $start_date;
private $end_date;
private $is_periods_mode;
private const DEFAULT_DATE_TYPE = IntlDateFormatter::MEDIUM;
private const DEFAULT_TIME_TYPE = IntlDateFormatter::SHORT;
// $start_time and $end_time are Unix timestamps
public function __construct(int $start_timestamp, int $end_timestamp, bool $is_periods_mode)
{
$this->start_date = new DateTime();
$this->start_date->setTimestamp($start_timestamp);
$this->end_date = new DateTime();
$this->end_date->setTimestamp($end_timestamp);
$this->is_periods_mode = $is_periods_mode;
}
public function __toString()
{
global $datetime_formats;
$start_time = $this->start_date->getTimestamp();
$end_time = $this->end_date->getTimestamp();
// If we're in periods mode, the last period is actually the one before the
// period that starts at the end time
if ($this->is_periods_mode)
{
$end_time = $end_time - 60;
}
// Just in case they have been unset in the config file
$date_type = $datetime_formats['range_datetime']['date_type'] ?? self::DEFAULT_DATE_TYPE;
$time_type = $datetime_formats['range_datetime']['time_type'] ?? self::DEFAULT_TIME_TYPE;
$ranger = new Ranger(Language::getInstance()->getWebLocale());
$ranger
->setRangeSeparator(get_vocab('range_separator'))
->setDateTimeSeparator(get_vocab('date_time_separator'))
->setDateType($date_type)
->setTimeType($time_type);
$range = $ranger->format($start_time, $end_time);
// If we're in periods mode, substitute the period names for times
if ($this->is_periods_mode)
{
// First of all substitute the start time
// Note that we are using the global IntlDateFormatter, rather than using the
// IntlDateFormatterFactory, because that's what Ranger will have used, so we
// want the same result, regardless of whether it's correct or not.
$formatter = new IntlDateFormatter(Language::getInstance()->getWebLocale(), IntlDateFormatter::NONE, $time_type);
$start_time_string = $formatter->format($start_time);
$start_period_name = period_name_timestamp($start_time);
$range = str_replace($start_time_string, $start_period_name, $range);
if ($start_time !== $end_time)
{
// Then do the end time if there is one
$end_time_string = $formatter->format($end_time);
$end_period_name = period_name_timestamp($end_time);
$range = str_replace($end_time_string, $end_period_name, $range);
// Then because the Ranger will have missed out the AM/PM information for one of
// the times if it's the same for both times (eg 12:01 - 12:02pm, or 下午12:03 - 12:04),
// we need to work out what the strings would be without the AM/PM information is and
// substitute those. So get the pattern, strip the AMPM symbols and modify the formatter.
$pattern = $formatter->getPattern();
$pattern_short = self::trimAMPM($pattern);
$formatter->setPattern($pattern_short);
// Now do the start time again, looking for the short string
$start_time_string_short = $formatter->format($start_time);
$range = str_replace($start_time_string_short, $start_period_name, $range);
// And then the end time again
$end_time_string_short = $formatter->format($end_time);
$range = str_replace($end_time_string_short, $end_period_name, $range);
}
}
return $range;
}
// Checks whether the interval overlaps any of an array of days (0=Sunday, etc).
// Returns FALSE if it doesn't, or the first overlapped day as an MRBS\DateTime object if it does.
public function overlapsDays(array $days)
{
// Zero the $date and $end times so that the while condition works.
$date = clone $this->start_date;
$date->setTime(0,0);
$end = clone $this->end_date;
$end->setTime(0, 0);
$i = 0;
while (($date <= $end) && ($i < DAYS_PER_WEEK))
{
if (in_array($date->format('w'), $days))
{
return $date;
}
$date->add(new DateInterval('P1D'));
$i++;
}
return false;
}
// Checks whether the interval overlaps a holiday. Returns FALSE if it doesn't,
// or the first overlapped holiday as an MRBS\DateTime object if it does.
public function overlapsHoliday()
{
// Zero the $date and $end times so that the while condition works.
$date = clone $this->start_date;
$date->setTime(0,0);
$end = clone $this->end_date;
$end->setTime(0, 0);
while ($date <= $end)
{
if ($date->isHoliday())
{
return $date;
}
$date->add(new DateInterval('P1D'));
}
return false;
}
// Checks whether the interval overlaps a weekend. Returns FALSE if it doesn't,
// or the first overlapped weekend day as an MRBS\DateTime object if it does.
public function overlapsWeekend()
{
// Zero the $date and $end times so that the while condition works.
$date = clone $this->start_date;
$date->setTime(0,0);
$end = clone $this->end_date;
$end->setTime(0, 0);
$i = 0;
// Don't check more than a week's worth of days in case no weekend days have been defined
while (($date <= $end) && ($i<DAYS_PER_WEEK))
{
if ($date->isWeekend())
{
return $date;
}
$date->add(new DateInterval('P1D'));
$i++;
}
return false;
}
// Checks whether an entry spans more than one calendar (not booking) day
public function spansMultipleDays() : bool
{
return ($this->start_date->getISODate() !== $this->end_date->getISODate());
}
// Trim whitespace, including NBSP, and any AMPM symbols ('a', 'b' and 'B') from the pattern
private static function trimAMPM($pattern) : string
{
$result = trim($pattern, "abB \n\r\t\v\x00");
// And trim any non-breaking spaces which can occur between the time and AM/PM information
$result = preg_replace("/^\s+|\s+$/u", '', $result);
return $result;
}
}
+524
View File
@@ -0,0 +1,524 @@
<?php
declare(strict_types=1);
namespace MRBS\Errors;
use InvalidArgumentException;
use Monolog\Handler\BrowserConsoleHandler;
use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Monolog\Processor\IntrospectionProcessor;
use Monolog\Registry;
use MRBS\Errors\Formatter\BrowserFormatter;
use MRBS\Errors\Formatter\ErrorLogFormatter;
use MRBS\Errors\Handler\PHPMailerHandler;
use MRBS\Mailer;
use PHPMailer\PHPMailer\Exception;
use PHPMailer\PHPMailer\PHPMailer;
use Psr\Log\LogLevel;
use RuntimeException;
use Throwable;
use function MRBS\escape_html;
use function MRBS\get_vocab;
use function MRBS\mrbs_default_timezone_set;
use function MRBS\print_footer;
use function MRBS\print_simple_header;
/**
* A class for dealing with errors.
*/
class Errors // (Don't call the class Error, to avoid confusion with the PHP class \Error.)
{
private const LOG_LEVELS = [
LogLevel::EMERGENCY,
LogLevel::ALERT,
LogLevel::CRITICAL,
LogLevel::ERROR,
LogLevel::WARNING,
LogLevel::NOTICE,
LogLevel::INFO,
LogLevel::DEBUG
];
private const MAJOR_LEVELS = [
LogLevel::EMERGENCY,
LogLevel::ALERT,
LogLevel::CRITICAL,
LogLevel::ERROR,
LogLevel::WARNING
];
private static $errno_levels = [
E_ERROR => LogLevel::CRITICAL,
E_WARNING => LogLevel::WARNING,
E_NOTICE => LogLevel::NOTICE,
E_CORE_ERROR => LogLevel::CRITICAL,
E_CORE_WARNING => LogLevel::WARNING,
E_COMPILE_ERROR => LogLevel::CRITICAL,
E_COMPILE_WARNING => LogLevel::WARNING,
E_DEPRECATED => LogLevel::WARNING,
E_USER_ERROR => LogLevel::CRITICAL,
E_USER_WARNING => LogLevel::WARNING,
E_USER_NOTICE => LogLevel::NOTICE,
E_USER_DEPRECATED => LogLevel::WARNING,
E_RECOVERABLE_ERROR => LogLevel::CRITICAL
];
/**
* Initialise the class
*
* @throws Exception
*/
public static function init(): void
{
global $debug;
// Enable/disable asertions, depending on whether we are in debug mode.
// Note that if 'zend.assertions' is set to -1 in the php.ini file, we can't
// make any changes because no code has been produced (and trying to make a
// change will produce a warning).
if (intval(ini_get('zend.assertions')) !== -1)
{
// We can't set zend.assertions to -1, because the code has already been produced.
ini_set('zend.assertions', ($debug) ? '1' : '0');
}
if ($debug && function_exists('opcache_reset'))
{
// Useful for making compile-time errors more obvious
opcache_reset();
}
// Add in the E_STRICT level for old versions of PHP
assert(version_compare(MRBS_MIN_PHP_VERSION, '8.0.0', '<'), "The if block below can be removed.");
if (version_compare(phpversion(), '8.0.0', '<'))
{
self::$errno_levels[E_STRICT] = LogLevel::WARNING;
}
self::setDisplayErrors();
self::setErrorLog();
$error_level = self::getErrorLevel();
error_reporting($error_level);
self::initLogger();
set_error_handler([__CLASS__, 'errorHandler'], $error_level);
set_exception_handler([__CLASS__, 'exceptionHandler']);
register_shutdown_function([__CLASS__, 'shutdownFunction']);
}
public static function errorHandler(int $errno, string $errstr, string $errfile, int $errline): bool
{
// "If the function returns false then the normal error handler continues."
// (https://www.php.net/manual/en/function.set-error-handler.php)
// Check to see whether error reporting has been disabled by
// the error suppression operator (@), because the custom error
// handler is still called even if errors are suppressed.
if (!(error_reporting() & $errno))
{
return true;
}
$details = self::get_error_name($errno) . " in $errfile at line $errline";
if (!array_key_exists($errno, self::$errno_levels))
{
throw new RuntimeException("Cannot find mapping for ERRNO level $errno");
}
self::output_error(self::$errno_levels[$errno], $errstr, $details);
return true;
}
/**
* Custom exception handler. Logs the error and then outputs a fatal error message.
*
* @return never
*/
public static function exceptionHandler(Throwable $exception): void
{
// Log the exception
$class = get_class($exception);
$details = "Uncaught exception '$class' in " . $exception->getFile() . " at line " . $exception->getLine();
$message = $exception->getMessage();
self::output_error(LogLevel::CRITICAL, $message, $details, $exception);
// Then output a fatal error
$namespace_root = strtok(__NAMESPACE__, '\\');
switch (get_class($exception))
{
case $namespace_root . '\DB\DBExternalException':
$fatal_message = get_vocab("fatal_db_ext_error");
break;
case $namespace_root . '\DB\DBException':
case 'PDOException':
$fatal_message = get_vocab("fatal_db_error");
break;
default:
$fatal_message = get_vocab("fatal_error");
break;
}
self::fatalError($fatal_message);
}
/**
* Converts an error into an exception
*
* @return never
* @throws \Exception
*/
public static function exceptionThrower(int $errno, string $errstr) : void
{
throw new \Exception($errstr, $errno);
}
/**
* Error handler - this is used to display serious errors such as database
* errors without sending incomplete HTML pages. This is only used for
* errors which "should never happen", not those caused by bad inputs.
* Always outputs the bottom of the page and exits.
*
* @return never
*/
public static function fatalError(string $message): void
{
print_simple_header();
echo "<p>\n". escape_html($message) . "</p>\n";
print_footer();
exit;
}
public static function shutdownFunction() : void
{
$error = error_get_last();
if (isset($error) &&
(mb_strpos($error['message'], 'iconv()') !== false) &&
!function_exists('iconv'))
{
// Help new admins understand what to do in case the iconv error occurs...
$message = "MRBS - iconv module not installed. ";
$details = "The iconv module, which provides PHP support for Unicode, is not " .
"installed on your system." .
"Unicode gives MRBS the ability to easily support languages other " .
"than English. Without Unicode, support for non-English-speaking " .
"users will be crippled." .
"To fix this error, you need to install and enable the iconv module." .
"On a Windows server, enable php_iconv.dll in %windir%\\php.ini, and " .
"make sure both %phpdir%\\dlls\\iconv.dll and %phpdir%\\extensions\\php_iconv.dll " .
"are in the path. One way to do this is to copy these two files to %windir%." .
"On a Unix server, recompile your PHP module with the appropriate option for " .
"enabling the iconv extension. Consult your PHP server documentation for " .
"more information about enabling iconv support.\n";
self::output_error(LogLevel::NOTICE, $message, $details);
}
}
private static function setDisplayErrors(): void
{
global $debug;
if ($debug)
{
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1'); // ini_set() only accepts non-string values from PHP 8.1.0
}
}
private static function setErrorLog() : void
{
// If the error log file is a relative path then turn it into an absolute one in
// order to avoid problems in shutdown when the working directory can change.
// (See the notes in https://www.php.net/manual/en/function.register-shutdown-function.php).
// Check for both Windows and Unix style separators because Unix separators can be used
// on Windows.
$error_log = ini_get('error_log');
if (($error_log !== '') &&
(mb_strpos($error_log, '/') === false) &&
(mb_strpos($error_log, '\\') === false))
{
ini_set('error_log', getcwd() . '/' . $error_log);
}
}
private static function getErrorLevel() : int
{
global $debug;
if ($debug)
{
return -1;
}
// Make sure notice errors are not reported; they can break mrbs code.
$error_level = E_ALL & ~E_NOTICE & ~E_USER_NOTICE;
if (defined("E_DEPRECATED"))
{
$error_level = $error_level & ~E_DEPRECATED;
}
// The Mail and Net libraries generate E_STRICT errors, so disable E_STRICT (which became
// part of E_ALL in PHP 5.4). E_STRICT is deprecated from PHP 8.4 (and not used since PHP 7).
assert(version_compare(MRBS_MIN_PHP_VERSION, '8.0.0', '<'), "The if block below can be removed.");
if (defined("E_STRICT") && (version_compare(PHP_VERSION, '8.4') < 0))
{
$error_level = $error_level & ~E_STRICT;
}
return $error_level;
}
/**
* @throws Exception
*/
private static function initLogger() : void
{
global $mail_settings, $sendmail_settings, $smtp_settings, $logger_settings;
$logger = new Logger($logger_settings['channel_name']);
$logger->pushProcessor(new IntrospectionProcessor());
if (ini_get('display_errors'))
{
$handler = new StreamHandler('php://output');
$handler->setFormatter(new BrowserFormatter());
if ($logger_settings['stream']['browser'])
{
$logger->pushHandler($handler);
}
if ($logger_settings['stream']['console'])
{
$logger->pushHandler(new BrowserConsoleHandler());
}
}
if (ini_get('log_errors'))
{
$handler = new ErrorLogHandler();
$handler->setFormatter(new ErrorLogFormatter());
$logger->pushHandler($handler);
}
if ($logger_settings['mail']['enabled'])
{
$mailer = new Mailer($mail_settings, $sendmail_settings, $smtp_settings, true);
$mailer->CharSet = PHPMailer::CHARSET_UTF8;
$mailer->setFromRFC822($logger_settings['mail']['from']);
$mailer->addAddressesRFC822($logger_settings['mail']['to']);
$handler = new PHPMailerHandler($mailer, $logger_settings['mail']['level']);
$logger->pushHandler($handler);
}
Registry::addLogger($logger);
}
/**
* Logs an exception
*/
private static function output_exception_error(Throwable $exception) : void
{
$class = get_class($exception);
$details = "Uncaught exception '$class' in " . $exception->getFile() . " at line " . $exception->getLine();
$message = $exception->getMessage();
self::output_error(LogLevel::CRITICAL, $message, $details, $exception);
}
private static function output_error(string $level, string $message, string $details, ?Throwable $e = null) : void
{
global $debug, $auth, $get, $post;
static $default_timezone_set = false;
// We can't start outputting any error messages unless the default timezone has been set,
// so if we are not sure that it has been set, then set it.
if (!$default_timezone_set)
{
mrbs_default_timezone_set();
$default_timezone_set = true;
}
if (!in_array($level, self::LOG_LEVELS))
{
throw new InvalidArgumentException("Invalid log level '$level'.");
}
$context = [];
if (in_array($level, self::MAJOR_LEVELS))
{
if (isset($get))
{
$context['get'] = $get;
}
if (isset($post))
{
$context['post'] = $post;
if (!$auth['log_credentials'])
{
// Overwrite the username and password to stop them appearing
// in error logs.
foreach (array('username', 'password') as $var)
{
if (isset($context['post'][$var]) && ($context['post'][$var] !== ''))
{
$context['post'][$var] = '****';
}
}
}
}
}
if ($debug || in_array($level, self::MAJOR_LEVELS))
{
$backtrace = self::generateBacktrace($e);
$context['backtrace'] = $backtrace;
}
$context['details'] = $details;
Registry::MRBS()->log($level, $message, $context);
}
/**
* Generate a backtrace. This function allows us to format the output slightly better
* than debug_print_backtrace().
*
* @return string[]
*/
private static function generateBacktrace(?Throwable $e = null) : array
{
global $debug;
$result = [];
// Get the backtrace. If we've been given a throwable then use that to get the
// trace as it goes further back in the stack.
if (isset($e))
{
$calls = $e->getTrace();
}
else
{
$options = DEBUG_BACKTRACE_PROVIDE_OBJECT;
// Unless we are debugging ignore arguments as these can give away
// database credentials
if (!$debug)
{
$options = $options | DEBUG_BACKTRACE_IGNORE_ARGS;
}
$calls = debug_backtrace($options);
}
// Get rid of calls on the stack which are just concerned with error handling and logging.
while (!empty($calls) && isset($calls[0]['class']) && ($calls[0]['class'] === __CLASS__))
{
array_shift($calls);
}
// Turn each call into a string
foreach ($calls as $i => $call)
{
$trace = "#$i " . self::callToString($call);
$result[] = $trace;
}
return $result;
}
private static function callToString(array $call) : string
{
$result = '';
if (isset($call['class']) && isset($call['type']))
{
$result .= $call['class'] . $call['type'];
}
if (isset($call['function']))
{
$result .= $call['function'];
$result .= '(';
// Add in the args if required, unless it was trigger_error() that was called
// because that will just repeat the error message.
if (isset($call['args']) && ($call['function'] !== 'trigger_error'))
{
$result .= self::getArgString($call['args']);
}
$result .= ')';
}
if (isset($call['file']) && isset($call['line']))
{
$result .= ' called at [' . $call['file'] . ':' . $call['line'] . ']';
}
return $result;
}
private static function getArgString(array $args) : string
{
$result = array();
foreach ($args as $arg)
{
$type = gettype($arg);
switch ($type)
{
case 'boolean':
$result[] = ($arg) ? 'true' : 'false';
break;
case 'integer':
case 'double':
case 'string':
$result[] = $arg;
break;
case 'object':
$class = get_class($arg);
$result[] = ($class == 'SensitiveParameterValue') ? "[$class]" : $type;
break;
default:
$result[] = $type;
break;
}
}
return implode(', ', $result);
}
/**
* Translate an error constant value into the name of the constant
*/
private static function get_error_name(int $errno) : string
{
$constants = get_defined_constants(true);
$keys = array_keys($constants['Core'], $errno);
$keys = array_filter($keys, function($value) {
return (mb_strpos($value, 'E_') === 0);
});
return implode('|', $keys); // There should only be one member of the array, all being well.
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Errors\Formatter;
class BrowserFormatter extends GeneralFormatter
{
public function __construct()
{
parent::__construct(null, true);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Errors\Formatter;
class ErrorLogFormatter extends GeneralFormatter
{
public function __construct()
{
parent::__construct(null, false);
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace MRBS\Errors\Formatter;
use Monolog\Formatter\NormalizerFormatter;
use function MRBS\escape_html;
abstract class GeneralFormatter extends NormalizerFormatter
{
private $useHTML;
public function __construct(?string $dateFormat = null, $useHTML=false)
{
$this->useHTML = $useHTML;
parent::__construct($dateFormat);
}
public function format(array $record): string
{
$lines = [];
if (!isset($record['context']['details']))
{
// This will be when the logger is called directly from the MRBS code, rather than the Errors class.
$record['context']['details'] = $record['channel'] . '.' .$record['level_name'] . ' in ' . $record['extra']['file'] . ' at line ' . $record['extra']['line'];
}
$lines[] = $this->escape($record['context']['details']);
$lines[] = $this->escape($record['message']);
// Add in any stacktrace
if (!empty($record['context']['backtrace']))
{
foreach ($record['context']['backtrace'] as $call)
{
$lines[] = $this->escape($call);
}
}
// Add in the GET and POST variables
foreach(['$_GET' => 'get', '$_POST' => 'post'] as $name => $var)
{
if (isset($record['context'][$var]))
{
$line = $this->escape(print_r($record['context'][$var], true));
if ($this->useHTML)
{
// Replace spaces with non-breaking spaces
$line = str_replace(' ', '&nbsp;', $line);
}
// Remove the final new line
$line = rtrim($line);
if ($this->useHTML)
{
$line = str_replace("\n", "<br>\n", $line);
}
$lines[] = "$name: $line";
}
}
if ($this->useHTML)
{
// Make the first line bold.
$lines[0] = '<b>' . $lines[0] . '</b>';
}
$result = implode(($this->useHTML) ? "<br>\n" : "\n", $lines);
if ($this->useHTML)
{
// Wrap it in a paragraph
$result = "<p>\n" . $result . "\n</p>\n";
}
return $result;
}
private function escape(string $value): string
{
return ($this->useHTML) ? escape_html($value) : $value;
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace MRBS\Errors\Handler;
use Monolog\Formatter\FormatterInterface;
use Monolog\Handler\MailHandler;
use Monolog\Logger;
use MRBS\Errors\Formatter\BrowserFormatter;
use MRBS\Errors\Formatter\ErrorLogFormatter;
use MRBS\Errors\Formatter\MailFormatter;
use PHPMailer\PHPMailer\PHPMailer;
class PHPMailerHandler extends MailHandler
{
private $mailer;
public function __construct(PHPMailer $mailer, $level = Logger::ERROR, bool $bubble = true)
{
parent::__construct($level, $bubble);
$this->mailer = $mailer;
}
protected function send(string $content, array $records): void
{
$mailer = $this->buildMessage($content, $records);
$mailer->send();
}
private function buildMessage(string $content, array $records): PHPMailer
{
$mailer = clone $this->mailer;
$record = $records[0];
if (isset($record['context']['details']))
{
$mailer->Subject = $record['context']['details'];
}
else
{
$mailer->Subject = $record['channel'] . '.' . $record['level_name'];
if (isset($record['extra']['file']))
{
$mailer->Subject .= ' in ' . $record['extra']['file'];
}
if (isset($record['extra']['line']))
{
$mailer->Subject .= ' at line ' . $record['extra']['line'];
}
}
$mailer->Body = $content;
return $mailer;
}
protected function getDefaultFormatter(): FormatterInterface
{
return new ErrorLogFormatter();
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace MRBS;
// At the moment MRBS\Exception is identical to \Exception and only exists to catch
// code throwing a new Exception within the MRBS namespace. However it does allow
// us to do MRBS specific exception handling in the future.
class Exception extends \Exception
{
}
+495
View File
@@ -0,0 +1,495 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
// This is a limited implementation of the HTML5 DOM and is optimised for use by
// MRBS forms. It makes a number of simplifications and restrictions. In particular
// it assumes that:
// (a) an element can only contain one text node, and
// (b) that text node either comes before or after all the element nodes that it contains
//
// In the full DOM an element can contain multiple text nodes, for example
// <p>Some text<b>a bold bit</b>some more text</p>
//
// If structures like these are required ten they can usually be achieved by wrapping the raw
// text nodes in a <span>.
use function MRBS\escape_html;
use function MRBS\is_assoc;
class Element
{
private $tag;
private $self_closing;
private $attributes = array();
private $text = null;
private $raw = false;
private $text_at_start = false;
private $elements = [];
private $next = null;
private $prev = null;
public function __construct(string $tag, bool $self_closing=false)
{
$this->tag = $tag;
$this->self_closing = $self_closing;
}
// If $raw is true then the text will not be put through escape_html(). Only to
// be used for trusted text.
public function setText(string $text, bool $text_at_start=false, bool $raw=false) : Element
{
if ($this->self_closing)
{
throw new \Exception("A self closing element cannot contain text.");
}
$this->text = $text;
$this->text_at_start = $text_at_start;
$this->raw = $raw;
return $this;
}
public function getAttribute(string $name)
{
return (isset($this->attributes[$name])) ? $this->attributes[$name] : null;
}
// A value of true allows for the setting of boolean attributes such as
// 'required' and 'disabled'
public function setAttribute(string $name, $value=true) : Element
{
$this->attributes[$name] = $value;
return $this;
}
public function setAttributes(array $attributes) : Element
{
foreach ($attributes as $name => $value)
{
$this->setAttribute($name, $value);
}
return $this;
}
public function removeAttribute(string $name) : Element
{
unset($this->attributes[$name]);
return $this;
}
public function getElement(string $key) : Element
{
return $this->elements[$key];
}
public function setElement(string $key, Element $element) : Element
{
$this->elements[$key] = $element;
return $this;
}
public function getElements() : array
{
return $this->elements;
}
public function setElements(array $elements) : Element
{
$this->elements = $elements;
return $this;
}
public function addElement(?Element $element=null, ?string $key=null) : Element
{
if (isset($element))
{
if (isset($key))
{
$this->elements[$key] = $element;
}
else
{
$this->elements[] = $element;
}
}
return $this;
}
public function addElements(array $elements) : Element
{
foreach ($elements as $element)
{
$this->addElement($element);
}
return $this;
}
public function removeElement(string $key) : Element
{
unset($this->elements[$key]);
return $this;
}
public function next(?Element $element=null) : ?Element
{
if (isset($element))
{
$this->next = $element;
return $this;
}
elseif (isset($this->next))
{
return $this->next;
}
else
{
return null;
}
}
public function prev(?Element $element=null): ?Element
{
if (isset($element))
{
$this->prev = $element;
return $this;
}
elseif (isset($this->prev))
{
return $this->prev;
}
else
{
return null;
}
}
public function addClass(string $class) : Element
{
$classes = $this->getAttribute('class');
$classes = (isset($classes)) ? explode(' ', $classes) : array();
if (!in_array($class, $classes))
{
$classes[] = $class;
$this->setAttribute('class', implode(' ', $classes));
}
return $this;
}
// Add a set of select options to an element, eg to a <select> or <datalist> element.
// $options An array of options for the select element. Can be a one- or two-dimensional
// array. If it's two-dimensional then the keys of the outer level represent
// <optgroup> labels. The inner level can be a simple array or an associative
// array with value => text members for each <option> in the <select> element.
// $selected The value(s) of the option(s) that are selected. Can be a single value
// or an array of values.
// $associative Whether to treat the options as a simple or an associative array. (This
// parameter is necessary because if you index an array with strings that look
// like integers then PHP casts the keys to integers and the array becomes a
// simple array). Can take the following values:
// true treat as an associative array
// false treat as a simple array
// null auto-detect
// $for_datalist Whether the options are intended for use in a <datalist>.
public function addSelectOptions(array $options, $selected=null, ?bool $associative=null, bool $for_datalist=false) : Element
{
// Trivial case
if (empty($options))
{
return $this;
}
if (!isset($associative))
{
$associative = is_assoc($options);
}
// It's possible to have multiple options selected
if (!is_array($selected))
{
$selected = array($selected);
}
// Test whether $options is a one-dimensional or two-dimensional array.
// If two-dimensional then we need to use <optgroup>s.
if (is_array(reset($options))) // cannot use $options[0] because $options may be associative
{
if ($for_datalist)
{
throw new \InvalidArgumentException("Datalists cannot have <optgroup> elements.");
}
foreach ($options as $group => $group_options)
{
$optgroup = new ElementOptgroup();
$optgroup->setAttribute('label', $group)
->addSelectOptions($group_options, $selected, $associative);
$this->addElement($optgroup);
}
}
else
{
foreach ($options as $key => $text)
{
$option = new ElementOption();
// We need to be careful about strings with multiple consecutive spaces in them. If we have a <select> element
// with simple options of the form <option>Joe Smith</option>, then the text "Joe Smith" will be contracted
// to "Joe Smith". This means that (a) it will look wrong on the form and (b) the wrong value will be passed
// through to the form handler. One way of getting round this would be to substitute the extra spaces with
// non-breaking space characters. However, although this will now look correct on the form, the value that is
// passed through to the form handler will contain non-breaking spaces instead of ordinary spaces. A query
// using this value to find a row in the database will rely on the database engine treating ordinary and non-
// breaking spaces as equivalent. While this will be true for many collations, it won't always be so.
//
// The solution is to place the text as is in the value attribute, but to replace the spaces in the text node
// with non-breaking spaces, eg <option value="Joe Smith">Joe Smith</option>. Because extra spaces in
// attributes are preserved, the correct string will be passed through to the form handler. And because
// there are now non-breaking spaces in the text node, the string is displayed properly in the browser. If the
// options are in the form of an associative array with values and text, then the value will be used for the
// value attribute and the text can have its spaces replaced.
//
// This solution doesn't work though for <datalist> elements, at least not when using Chrome or Safari.
// According to https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/datalist "Each <option>
// element should have a value attribute, which represents a suggestion to be entered into the input. It can
// also have a label attribute, or, missing that, some text content, which may be displayed by the browser
// instead of value (Firefox), or in addition to value (Chrome and Safari, as supplemental text). The exact
// content of the drop-down menu depends on the browser, but when clicked, content entered into control field
// will always come from the value attribute." In other words, if the value and text are different - even in
// the type of space character used - then, when using Chrome and Safari, they are both displayed by the
// browser, which looks a little odd, when they are essentially the same string.
//
// So for <datalist> elements, which are never associative anyway, we just use the value attribute and have an
// empty text node, eg <option value="Joe Smith"></option>.
//
// See also https://github.com/meeting-room-booking-system/mrbs-code/issues/3871 and
// https://stackoverflow.com/questions/79629259/does-mysql-distinguish-betwen-ordinary-and-non-breaking-spaces-in-a-query
$value = ($associative) ? $key : $text;
$option->setAttribute('value', $value);
if (!$for_datalist || $associative)
{
// If it's a string replace the second and subsequent spaces with non-breaking spaces
$text = (is_string($text)) ? preg_replace('/(?<= ) /', "\xc2\xa0", $text) : strval($text);
$option->setText($text);
}
if (!$for_datalist && in_array($value, $selected))
{
$option->setAttribute('selected');
}
$this->addElement($option);
}
}
return $this;
}
// $checked is either a scalar or an array of keys that are checked
public function addCheckboxOptions(array $options, string $name, $checked=null, $associative=null, bool $disabled=false): Element
{
// Trivial case
if (empty($options))
{
return $this;
}
if (is_scalar($checked))
{
$checked = array($checked);
}
if (!isset($associative))
{
$associative = is_assoc($options);
}
foreach ($options as $key => $value)
{
if (!$associative)
{
$key = $value;
}
$checkbox = new ElementInputCheckbox();
$checkbox->setAttributes(array('name' => $name,
'value' => $key));
if (isset($checked) && (in_array($key, $checked)))
{
$checkbox->setChecked(true);
}
if ($disabled)
{
$checkbox->setAttribute('disabled', true);
}
$label = new ElementLabel();
$label->setText(strval($value))
->addElement($checkbox);
$this->addElement($label);
}
return $this;
}
public function addRadioOptions(array $options, string $name, $checked=null, $associative=null, bool $disabled=false): Element
{
// Trivial case
if (empty($options))
{
return $this;
}
if (!isset($associative))
{
$associative = is_assoc($options);
}
foreach ($options as $key => $value)
{
if (!$associative)
{
$key = $value;
}
$radio = new ElementInputRadio();
$radio->setAttributes(array('name' => $name,
'value' => $key,
'disabled' => $disabled));
if (isset($checked) && ($key == $checked))
{
$radio->setAttribute('checked');
}
$label = new ElementLabel();
$label->setText(strval($value))
->addElement($radio);
$this->addElement($label);
}
return $this;
}
public function render() : void
{
echo $this->toHTML();
}
// Turns the form into HTML. HTML escaping is done here.
// If $no_whitespace is true, then don't put any whitespace after opening or
// closing tags. This is useful for structures such as
// <label><input>text</label> where whitespace after the <input> tag would
// affect what the browser displays on the screen.
public function toHTML(bool $no_whitespace=false): string
{
$html = "";
$prev = $this->prev();
if (isset($prev))
{
$html .= $prev->toHTML();
}
$terminator = ($no_whitespace) ? '' : "\n";
$html .= "<" . $this->tag;
foreach ($this->attributes as $key => $value)
{
if (!isset($value) || ($value === false))
{
// a boolean attribute, or else an empty attribute, that should be omitted.
// We allow the empty string, '', because that can be used, for example, in
// 'value=""' as an attribute for the <option> element in a <select> element
// that has the 'required' attribute set.
continue;
}
$html .= " $key";
if ($value !== true)
{
// boolean attributes, eg 'required', don't need a value
$html .= '="' . escape_html($value) . '"';
}
}
$html .= ">";
if ($this->self_closing)
{
$html .= $terminator;
}
else
{
if (isset($this->text) && $this->text_at_start)
{
$html .= self::escapeText($this->text, $this->raw);
}
if (!empty($this->elements))
{
// If this element contains text, then don't use a terminator, otherwise
// unwanted whitespace will be introduced.
if (!isset($this->text))
{
$html .= $terminator;
}
foreach ($this->elements as $element)
{
$html .= $element->toHTML(isset($this->text));
}
}
if (isset($this->text) && !$this->text_at_start)
{
$html .= self::escapeText($this->text, $this->raw);
}
$html .= "</" . $this->tag . ">$terminator";
}
$next = $this->next();
if (isset($next))
{
$html .= $next->toHTML();
}
return $html;
}
private static function escapeText($text, bool $raw=false)
{
return ($raw) ? $text : escape_html($text);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementA extends Element
{
public function __construct()
{
parent::__construct('a');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementButton extends Element
{
public function __construct()
{
parent::__construct('button');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementDatalist extends Element
{
public function __construct()
{
parent::__construct('datalist');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementDiv extends Element
{
public function __construct()
{
parent::__construct('div');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementFieldset extends Element
{
public function __construct()
{
parent::__construct('fieldset');
}
// $legend can be
// (a) an ElementLegend object, or
// (b) another element, or
// (c) a string
// If it is (b) or (c) then it is wrapped inside a Legend element.
public function addLegend($legend) : ElementFieldset
{
if (is_object($legend) &&
(__NAMESPACE__ . "\\ElementLegend" == get_class($legend)))
{
$element = $legend;
}
else
{
$element = new ElementLegend();
if (is_string($legend))
{
$element->setText($legend);
}
else
{
// Assumed to be an object of class 'Element' if it is not a string
$element->addElement($legend);
}
}
$this->addElement($element);
return $this;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementImg extends Element
{
public function __construct()
{
parent::__construct('img', true);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
abstract class ElementInput extends Element
{
public function __construct()
{
parent::__construct('input', true);
$this->setAttribute('type', 'text');
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputCheckbox extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'checkbox');
}
public function setChecked($checked=true): ElementInputCheckbox
{
if ($checked)
{
$this->setAttribute('checked');
}
else
{
$this->removeAttribute('checked');
}
return $this;
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputDatalist extends ElementInput
{
private static $list_prefix = "mrbs_";
private static $list_number = 1;
public function __construct()
{
// One problem with using a datalist with an input element is the way different browsers
// handle autocomplete. If you have autocomplete on, and also an id or name attribute, then some
// browsers, eg Edge, will bring the history up on top of the datalist options so that you can't
// see the first few options. But if you have autocomplete off, then other browsers, eg Chrome,
// will not present the datalist options at all. This can be fixed in JavaScript by having a second,
// hidden, input which holds the actual form value and mirrors the visible input. Because we can't
// rely on JavaScript being enabled we will create the basic HTML using autocomplete on, ie the default,
// which is the least bad alternative. One disadvantage of this method is that the label is no longer
// tied to the visible input, but this isn't as important for a text input as it is, say, for a checkbox
// or radio button.
parent::__construct();
// Provide a unique id to link the list with the input.
// Doesn't matter what it is as it won't be used elsewhere.
$list_id = self::$list_prefix . self::$list_number;
self::$list_number++;
$this->setAttributes(array('type' => 'text',
'list' => $list_id));
$datalist = new ElementDatalist();
$datalist->setAttribute('id', $list_id);
$this->next($datalist);
}
public function addDatalistOptions(array $options, ?bool $associative=null): ElementInputDatalist
{
// Put a <select> wrapper around the options so that browsers that don't
// support <datalist> will still have the options in their DOM and then
// the JavaScript polyfill can find them and do something with them
$select = new ElementSelect();
$select->addClass('none');
$select->addSelectOptions($options, null, $associative, true);
$this->next($this->next()->addElement($select));
return $this;
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputDate extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'date');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputEmail extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'email');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputFile extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'file');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputHidden extends ElementInput
{
public function __construct(?string $name=null, $value=null)
{
parent::__construct();
$this->setAttribute('type', 'hidden');
if (isset($name))
{
$this->setAttribute('name', $name);
}
if (isset($value))
{
if (is_bool($value))
{
$value = ($value) ? 1 : 0;
}
$this->setAttribute('value', $value);
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputImage extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'image');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputNumber extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttributes(array('type' => 'number',
'step' => '1'));
}
}
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputPassword extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'password');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputRadio extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'radio');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputSearch extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'search');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputSubmit extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'submit');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputText extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'text');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputTime extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'time');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputUrl extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'url');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementLabel extends Element
{
public function __construct()
{
parent::__construct('label');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementLegend extends Element
{
public function __construct()
{
parent::__construct('legend');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementOptgroup extends Element
{
public function __construct()
{
parent::__construct('optgroup');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementOption extends Element
{
public function __construct()
{
parent::__construct('option');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementP extends Element
{
public function __construct()
{
parent::__construct('p');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementSelect extends Element
{
public function __construct()
{
parent::__construct('select');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementSpan extends Element
{
public function __construct()
{
parent::__construct('span');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementTextarea extends Element
{
public function __construct()
{
parent::__construct('textarea');
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
// Fields consider of a 'label' element, ie a <label>, and a control
// element, eg an <input> or <select>, all wrapped in a <div>. For example
// <div>
// <label></label>
// <input>
// </div>
abstract class Field extends Element
{
// $is_group records whether the field consists of a group of controls
// (eg radio buttons) or just a single control, in which case a label
// can be associated with it.
protected $is_group = false;
public function __construct()
{
// Wrap all fields in a <div> ... </div>
parent::__construct('div');
$this->addElement(new ElementLabel(), 'label');
}
public function addControl(Element $element): Field
{
$this->addElement($element, 'control');
return $this;
}
public function getControl(): Element
{
return $this->getElement('control');
}
public function setControl(Element $element): Field
{
$this->setElement('control', $element);
return $this;
}
public function removeControl(): Field
{
$this->removeElement('control');
return $this;
}
// If $raw is true then the text will not be put through escape_html(). Only to
// be used for trusted text.
public function setLabel($text, bool $text_at_start=false, bool $raw=false): Field
{
$label = $this->getElement('label');
$label->setText($text, $text_at_start, $raw);
$this->setElement('label', $label);
return $this;
}
// Sets an attribute for the field control. Also takes care of the label
// by associating the label with the control using a 'for' attribute, by
// using the 'id' if one is given, or if not, by assuming that the 'id'
// is the same as the 'name' (unless $add_id is FALSE, in which case an id
// won't be added).
public function setControlAttribute(string $name, $value=true, bool $add_id=true): Field
{
$elements = $this->getElements();
// If this is the name attribute and we haven't yet got an id, then
// make the id the same as the name - unless $add_id is FALSE
if ($add_id && ($name == 'name') && (null === $elements['control']->getAttribute('id')))
{
$this->setControlAttribute('id', $value);
}
// If this is an id and it's not a group field, then associate the
// label with the id
if (!$this->is_group && ($name == 'id'))
{
$elements['label']->setAttribute('for', $value);
}
$elements['control']->setAttribute($name, $value);
$this->setElements($elements);
return $this;
}
// Sets the attributes for the field control.
public function setControlAttributes(array $attributes, bool $add_id=true): Field
{
foreach ($attributes as $key => $value)
{
$this->setControlAttribute($key, $value, $add_id);
}
return $this;
}
public function addControlClass(string $class): Field
{
$elements = $this->getElements();
$elements['control']->addClass($class);
$this->setElements($elements);
return $this;
}
public function addLabelClass(string $class): Field
{
$elements = $this->getElements();
$elements['label']->addClass($class);
$this->setElements($elements);
return $this;
}
public function setControlChecked($checked=true): Field
{
$elements = $this->getElements();
$elements['control']->setChecked($checked);
$this->setElements($elements);
return $this;
}
public function setControlText(string $text): Field
{
$elements = $this->getElements();
$elements['control']->setText($text);
$this->setElements($elements);
return $this;
}
public function addControlElement(Element $element): Field
{
$elements = $this->getElements();
$elements['control']->addElement($element);
$this->setElements($elements);
return $this;
}
public function addLabelElement(Element $element): Field
{
$elements = $this->getElements();
$elements['label']->addElement($element);
$this->setElements($elements);
return $this;
}
public function setLabelAttribute(string $name, $value=true): Field
{
$elements = $this->getElements();
$elements['label']->setAttribute($name, $value);
$this->setElements($elements);
return $this;
}
// Sets the attributes for the field label. No need to do
// the 'for' attribute, as that is done automatically when you
// set the 'id' in the control attributes.
public function setLabelAttributes(array $attributes): Field
{
$elements = $this->getElements();
foreach ($attributes as $key => $value)
{
$elements['label']->setAttribute($key, $value);
}
$this->setElements($elements);
return $this;
}
public function removeLabelAttribute(string $name): Field
{
$elements = $this->getElements();
$elements['label']->removeAttribute($name);
$this->setElements($elements);
return $this;
}
// Adds a hidden input to the form
public function addHiddenInput(string $name, $value) : Field
{
$element = new ElementInputHidden($name, $value);
$this->addElement($element);
return $this;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldButton extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementButton());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldDiv extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementDiv());
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputCheckbox extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputCheckbox());
}
public function setChecked($checked=true)
{
$control = $this->getControl();
$control->setChecked($checked);
$this->setControl($control);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputCheckboxGroup extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementDiv())
->setControlAttributes(array('class' => 'group'));
$this->is_group = true;
}
public function addCheckboxOptions(array $options, string $name, $checked=null, $associative=null, bool $disabled=false): Element
{
$element = $this->getControl();
$element->addCheckboxOptions($options, $name, $checked, $associative, $disabled);
$this->setControl($element);
return $this;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputDatalist extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputDatalist());
}
public function addDatalistOptions(array $options, ?bool $associative=null) : FieldInputDatalist
{
$datalist = $this->getControl();
$datalist->addDatalistOptions($options, $associative);
$this->setControl($datalist);
return $this;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputDate extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputDate());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputEmail extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputEmail());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputFile extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputFile());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
// Defaults to step="1"
class FieldInputNumber extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputNumber());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputPassword extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputPassword());
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputRadioGroup extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementDiv())
->setControlAttributes(array('class' => 'group'));
$this->is_group = true;
}
public function addRadioOptions(array $options, string $name, $checked=null, $associative=null, bool $disabled=false): Element
{
$element = $this->getControl();
$element->addRadioOptions($options, $name, $checked, $associative, $disabled);
$this->setControl($element);
return $this;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputSearch extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputSearch());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputSubmit extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputSubmit());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputText extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputText());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputTime extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputTime());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputUrl extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputUrl());
}
}

Some files were not shown because too many files have changed in this diff Show More