包含:登录失败锁定、90天密码有效期、30分钟会话超时、 强制改密、登录审计日志、屏幕水印、企业背景图、 备案信息固定底部、favicon、登录页JS修复等全部改动
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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\"> </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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\DB;
|
||||
|
||||
class DBExternalException extends DBException
|
||||
{
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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(' ', ' ', $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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
class ElementA extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('a');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
class ElementButton extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('button');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
class ElementDatalist extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('datalist');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
class ElementDiv extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('div');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
class ElementImg extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('img', true);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
class ElementLabel extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('label');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
class ElementLegend extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('legend');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
|
||||
class ElementOptgroup extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('optgroup');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
|
||||
class ElementOption extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('option');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
class ElementP extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('p');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
class ElementSelect extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('select');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
class ElementSpan extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('span');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\Form;
|
||||
|
||||
class ElementTextarea extends Element
|
||||
{
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct('textarea');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user