MRBS 1.12.2 等保2.0二级整改完整提交

包含:登录失败锁定、90天密码有效期、30分钟会话超时、
强制改密、登录审计日志、屏幕水印、企业背景图、
备案信息固定底部、favicon、JS空集合保护、
会话过期体验优化(403 JSON)、display_errors 关闭、
固定 key 根治 Integrity check failed 等全部改动

注意:config.inc.php/.htaccess/.user.ini 含敏感信息,
通过 .gitignore 排除,勿推送到公开仓库。
This commit is contained in:
人事系统开发
2026-09-09 16:55:02 +08:00
commit 1ba6efd8ed
2151 changed files with 528780 additions and 0 deletions
+495
View File
@@ -0,0 +1,495 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
// This is a limited implementation of the HTML5 DOM and is optimised for use by
// MRBS forms. It makes a number of simplifications and restrictions. In particular
// it assumes that:
// (a) an element can only contain one text node, and
// (b) that text node either comes before or after all the element nodes that it contains
//
// In the full DOM an element can contain multiple text nodes, for example
// <p>Some text<b>a bold bit</b>some more text</p>
//
// If structures like these are required ten they can usually be achieved by wrapping the raw
// text nodes in a <span>.
use function MRBS\escape_html;
use function MRBS\is_assoc;
class Element
{
private $tag;
private $self_closing;
private $attributes = array();
private $text = null;
private $raw = false;
private $text_at_start = false;
private $elements = [];
private $next = null;
private $prev = null;
public function __construct(string $tag, bool $self_closing=false)
{
$this->tag = $tag;
$this->self_closing = $self_closing;
}
// If $raw is true then the text will not be put through escape_html(). Only to
// be used for trusted text.
public function setText(string $text, bool $text_at_start=false, bool $raw=false) : Element
{
if ($this->self_closing)
{
throw new \Exception("A self closing element cannot contain text.");
}
$this->text = $text;
$this->text_at_start = $text_at_start;
$this->raw = $raw;
return $this;
}
public function getAttribute(string $name)
{
return (isset($this->attributes[$name])) ? $this->attributes[$name] : null;
}
// A value of true allows for the setting of boolean attributes such as
// 'required' and 'disabled'
public function setAttribute(string $name, $value=true) : Element
{
$this->attributes[$name] = $value;
return $this;
}
public function setAttributes(array $attributes) : Element
{
foreach ($attributes as $name => $value)
{
$this->setAttribute($name, $value);
}
return $this;
}
public function removeAttribute(string $name) : Element
{
unset($this->attributes[$name]);
return $this;
}
public function getElement(string $key) : Element
{
return $this->elements[$key];
}
public function setElement(string $key, Element $element) : Element
{
$this->elements[$key] = $element;
return $this;
}
public function getElements() : array
{
return $this->elements;
}
public function setElements(array $elements) : Element
{
$this->elements = $elements;
return $this;
}
public function addElement(?Element $element=null, ?string $key=null) : Element
{
if (isset($element))
{
if (isset($key))
{
$this->elements[$key] = $element;
}
else
{
$this->elements[] = $element;
}
}
return $this;
}
public function addElements(array $elements) : Element
{
foreach ($elements as $element)
{
$this->addElement($element);
}
return $this;
}
public function removeElement(string $key) : Element
{
unset($this->elements[$key]);
return $this;
}
public function next(?Element $element=null) : ?Element
{
if (isset($element))
{
$this->next = $element;
return $this;
}
elseif (isset($this->next))
{
return $this->next;
}
else
{
return null;
}
}
public function prev(?Element $element=null): ?Element
{
if (isset($element))
{
$this->prev = $element;
return $this;
}
elseif (isset($this->prev))
{
return $this->prev;
}
else
{
return null;
}
}
public function addClass(string $class) : Element
{
$classes = $this->getAttribute('class');
$classes = (isset($classes)) ? explode(' ', $classes) : array();
if (!in_array($class, $classes))
{
$classes[] = $class;
$this->setAttribute('class', implode(' ', $classes));
}
return $this;
}
// Add a set of select options to an element, eg to a <select> or <datalist> element.
// $options An array of options for the select element. Can be a one- or two-dimensional
// array. If it's two-dimensional then the keys of the outer level represent
// <optgroup> labels. The inner level can be a simple array or an associative
// array with value => text members for each <option> in the <select> element.
// $selected The value(s) of the option(s) that are selected. Can be a single value
// or an array of values.
// $associative Whether to treat the options as a simple or an associative array. (This
// parameter is necessary because if you index an array with strings that look
// like integers then PHP casts the keys to integers and the array becomes a
// simple array). Can take the following values:
// true treat as an associative array
// false treat as a simple array
// null auto-detect
// $for_datalist Whether the options are intended for use in a <datalist>.
public function addSelectOptions(array $options, $selected=null, ?bool $associative=null, bool $for_datalist=false) : Element
{
// Trivial case
if (empty($options))
{
return $this;
}
if (!isset($associative))
{
$associative = is_assoc($options);
}
// It's possible to have multiple options selected
if (!is_array($selected))
{
$selected = array($selected);
}
// Test whether $options is a one-dimensional or two-dimensional array.
// If two-dimensional then we need to use <optgroup>s.
if (is_array(reset($options))) // cannot use $options[0] because $options may be associative
{
if ($for_datalist)
{
throw new \InvalidArgumentException("Datalists cannot have <optgroup> elements.");
}
foreach ($options as $group => $group_options)
{
$optgroup = new ElementOptgroup();
$optgroup->setAttribute('label', $group)
->addSelectOptions($group_options, $selected, $associative);
$this->addElement($optgroup);
}
}
else
{
foreach ($options as $key => $text)
{
$option = new ElementOption();
// We need to be careful about strings with multiple consecutive spaces in them. If we have a <select> element
// with simple options of the form <option>Joe Smith</option>, then the text "Joe Smith" will be contracted
// to "Joe Smith". This means that (a) it will look wrong on the form and (b) the wrong value will be passed
// through to the form handler. One way of getting round this would be to substitute the extra spaces with
// non-breaking space characters. However, although this will now look correct on the form, the value that is
// passed through to the form handler will contain non-breaking spaces instead of ordinary spaces. A query
// using this value to find a row in the database will rely on the database engine treating ordinary and non-
// breaking spaces as equivalent. While this will be true for many collations, it won't always be so.
//
// The solution is to place the text as is in the value attribute, but to replace the spaces in the text node
// with non-breaking spaces, eg <option value="Joe Smith">Joe Smith</option>. Because extra spaces in
// attributes are preserved, the correct string will be passed through to the form handler. And because
// there are now non-breaking spaces in the text node, the string is displayed properly in the browser. If the
// options are in the form of an associative array with values and text, then the value will be used for the
// value attribute and the text can have its spaces replaced.
//
// This solution doesn't work though for <datalist> elements, at least not when using Chrome or Safari.
// According to https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/datalist "Each <option>
// element should have a value attribute, which represents a suggestion to be entered into the input. It can
// also have a label attribute, or, missing that, some text content, which may be displayed by the browser
// instead of value (Firefox), or in addition to value (Chrome and Safari, as supplemental text). The exact
// content of the drop-down menu depends on the browser, but when clicked, content entered into control field
// will always come from the value attribute." In other words, if the value and text are different - even in
// the type of space character used - then, when using Chrome and Safari, they are both displayed by the
// browser, which looks a little odd, when they are essentially the same string.
//
// So for <datalist> elements, which are never associative anyway, we just use the value attribute and have an
// empty text node, eg <option value="Joe Smith"></option>.
//
// See also https://github.com/meeting-room-booking-system/mrbs-code/issues/3871 and
// https://stackoverflow.com/questions/79629259/does-mysql-distinguish-betwen-ordinary-and-non-breaking-spaces-in-a-query
$value = ($associative) ? $key : $text;
$option->setAttribute('value', $value);
if (!$for_datalist || $associative)
{
// If it's a string replace the second and subsequent spaces with non-breaking spaces
$text = (is_string($text)) ? preg_replace('/(?<= ) /', "\xc2\xa0", $text) : strval($text);
$option->setText($text);
}
if (!$for_datalist && in_array($value, $selected))
{
$option->setAttribute('selected');
}
$this->addElement($option);
}
}
return $this;
}
// $checked is either a scalar or an array of keys that are checked
public function addCheckboxOptions(array $options, string $name, $checked=null, $associative=null, bool $disabled=false): Element
{
// Trivial case
if (empty($options))
{
return $this;
}
if (is_scalar($checked))
{
$checked = array($checked);
}
if (!isset($associative))
{
$associative = is_assoc($options);
}
foreach ($options as $key => $value)
{
if (!$associative)
{
$key = $value;
}
$checkbox = new ElementInputCheckbox();
$checkbox->setAttributes(array('name' => $name,
'value' => $key));
if (isset($checked) && (in_array($key, $checked)))
{
$checkbox->setChecked(true);
}
if ($disabled)
{
$checkbox->setAttribute('disabled', true);
}
$label = new ElementLabel();
$label->setText(strval($value))
->addElement($checkbox);
$this->addElement($label);
}
return $this;
}
public function addRadioOptions(array $options, string $name, $checked=null, $associative=null, bool $disabled=false): Element
{
// Trivial case
if (empty($options))
{
return $this;
}
if (!isset($associative))
{
$associative = is_assoc($options);
}
foreach ($options as $key => $value)
{
if (!$associative)
{
$key = $value;
}
$radio = new ElementInputRadio();
$radio->setAttributes(array('name' => $name,
'value' => $key,
'disabled' => $disabled));
if (isset($checked) && ($key == $checked))
{
$radio->setAttribute('checked');
}
$label = new ElementLabel();
$label->setText(strval($value))
->addElement($radio);
$this->addElement($label);
}
return $this;
}
public function render() : void
{
echo $this->toHTML();
}
// Turns the form into HTML. HTML escaping is done here.
// If $no_whitespace is true, then don't put any whitespace after opening or
// closing tags. This is useful for structures such as
// <label><input>text</label> where whitespace after the <input> tag would
// affect what the browser displays on the screen.
public function toHTML(bool $no_whitespace=false): string
{
$html = "";
$prev = $this->prev();
if (isset($prev))
{
$html .= $prev->toHTML();
}
$terminator = ($no_whitespace) ? '' : "\n";
$html .= "<" . $this->tag;
foreach ($this->attributes as $key => $value)
{
if (!isset($value) || ($value === false))
{
// a boolean attribute, or else an empty attribute, that should be omitted.
// We allow the empty string, '', because that can be used, for example, in
// 'value=""' as an attribute for the <option> element in a <select> element
// that has the 'required' attribute set.
continue;
}
$html .= " $key";
if ($value !== true)
{
// boolean attributes, eg 'required', don't need a value
$html .= '="' . escape_html($value) . '"';
}
}
$html .= ">";
if ($this->self_closing)
{
$html .= $terminator;
}
else
{
if (isset($this->text) && $this->text_at_start)
{
$html .= self::escapeText($this->text, $this->raw);
}
if (!empty($this->elements))
{
// If this element contains text, then don't use a terminator, otherwise
// unwanted whitespace will be introduced.
if (!isset($this->text))
{
$html .= $terminator;
}
foreach ($this->elements as $element)
{
$html .= $element->toHTML(isset($this->text));
}
}
if (isset($this->text) && !$this->text_at_start)
{
$html .= self::escapeText($this->text, $this->raw);
}
$html .= "</" . $this->tag . ">$terminator";
}
$next = $this->next();
if (isset($next))
{
$html .= $next->toHTML();
}
return $html;
}
private static function escapeText($text, bool $raw=false)
{
return ($raw) ? $text : escape_html($text);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementA extends Element
{
public function __construct()
{
parent::__construct('a');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementButton extends Element
{
public function __construct()
{
parent::__construct('button');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementDatalist extends Element
{
public function __construct()
{
parent::__construct('datalist');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementDiv extends Element
{
public function __construct()
{
parent::__construct('div');
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementFieldset extends Element
{
public function __construct()
{
parent::__construct('fieldset');
}
// $legend can be
// (a) an ElementLegend object, or
// (b) another element, or
// (c) a string
// If it is (b) or (c) then it is wrapped inside a Legend element.
public function addLegend($legend) : ElementFieldset
{
if (is_object($legend) &&
(__NAMESPACE__ . "\\ElementLegend" == get_class($legend)))
{
$element = $legend;
}
else
{
$element = new ElementLegend();
if (is_string($legend))
{
$element->setText($legend);
}
else
{
// Assumed to be an object of class 'Element' if it is not a string
$element->addElement($legend);
}
}
$this->addElement($element);
return $this;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementImg extends Element
{
public function __construct()
{
parent::__construct('img', true);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
abstract class ElementInput extends Element
{
public function __construct()
{
parent::__construct('input', true);
$this->setAttribute('type', 'text');
}
}
+29
View File
@@ -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;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputDatalist extends ElementInput
{
private static $list_prefix = "mrbs_";
private static $list_number = 1;
public function __construct()
{
// One problem with using a datalist with an input element is the way different browsers
// handle autocomplete. If you have autocomplete on, and also an id or name attribute, then some
// browsers, eg Edge, will bring the history up on top of the datalist options so that you can't
// see the first few options. But if you have autocomplete off, then other browsers, eg Chrome,
// will not present the datalist options at all. This can be fixed in JavaScript by having a second,
// hidden, input which holds the actual form value and mirrors the visible input. Because we can't
// rely on JavaScript being enabled we will create the basic HTML using autocomplete on, ie the default,
// which is the least bad alternative. One disadvantage of this method is that the label is no longer
// tied to the visible input, but this isn't as important for a text input as it is, say, for a checkbox
// or radio button.
parent::__construct();
// Provide a unique id to link the list with the input.
// Doesn't matter what it is as it won't be used elsewhere.
$list_id = self::$list_prefix . self::$list_number;
self::$list_number++;
$this->setAttributes(array('type' => 'text',
'list' => $list_id));
$datalist = new ElementDatalist();
$datalist->setAttribute('id', $list_id);
$this->next($datalist);
}
public function addDatalistOptions(array $options, ?bool $associative=null): ElementInputDatalist
{
// Put a <select> wrapper around the options so that browsers that don't
// support <datalist> will still have the options in their DOM and then
// the JavaScript polyfill can find them and do something with them
$select = new ElementSelect();
$select->addClass('none');
$select->addSelectOptions($options, null, $associative, true);
$this->next($this->next()->addElement($select));
return $this;
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputDate extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'date');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputEmail extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'email');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputFile extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'file');
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputHidden extends ElementInput
{
public function __construct(?string $name=null, $value=null)
{
parent::__construct();
$this->setAttribute('type', 'hidden');
if (isset($name))
{
$this->setAttribute('name', $name);
}
if (isset($value))
{
if (is_bool($value))
{
$value = ($value) ? 1 : 0;
}
$this->setAttribute('value', $value);
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputImage extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'image');
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputNumber extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttributes(array('type' => 'number',
'step' => '1'));
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputPassword extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'password');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputRadio extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'radio');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputSearch extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'search');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputSubmit extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'submit');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputText extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'text');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputTime extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'time');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementInputUrl extends ElementInput
{
public function __construct()
{
parent::__construct();
$this->setAttribute('type', 'url');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementLabel extends Element
{
public function __construct()
{
parent::__construct('label');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementLegend extends Element
{
public function __construct()
{
parent::__construct('legend');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementOptgroup extends Element
{
public function __construct()
{
parent::__construct('optgroup');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementOption extends Element
{
public function __construct()
{
parent::__construct('option');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementP extends Element
{
public function __construct()
{
parent::__construct('p');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementSelect extends Element
{
public function __construct()
{
parent::__construct('select');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementSpan extends Element
{
public function __construct()
{
parent::__construct('span');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class ElementTextarea extends Element
{
public function __construct()
{
parent::__construct('textarea');
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
// Fields consider of a 'label' element, ie a <label>, and a control
// element, eg an <input> or <select>, all wrapped in a <div>. For example
// <div>
// <label></label>
// <input>
// </div>
abstract class Field extends Element
{
// $is_group records whether the field consists of a group of controls
// (eg radio buttons) or just a single control, in which case a label
// can be associated with it.
protected $is_group = false;
public function __construct()
{
// Wrap all fields in a <div> ... </div>
parent::__construct('div');
$this->addElement(new ElementLabel(), 'label');
}
public function addControl(Element $element): Field
{
$this->addElement($element, 'control');
return $this;
}
public function getControl(): Element
{
return $this->getElement('control');
}
public function setControl(Element $element): Field
{
$this->setElement('control', $element);
return $this;
}
public function removeControl(): Field
{
$this->removeElement('control');
return $this;
}
// If $raw is true then the text will not be put through escape_html(). Only to
// be used for trusted text.
public function setLabel($text, bool $text_at_start=false, bool $raw=false): Field
{
$label = $this->getElement('label');
$label->setText($text, $text_at_start, $raw);
$this->setElement('label', $label);
return $this;
}
// Sets an attribute for the field control. Also takes care of the label
// by associating the label with the control using a 'for' attribute, by
// using the 'id' if one is given, or if not, by assuming that the 'id'
// is the same as the 'name' (unless $add_id is FALSE, in which case an id
// won't be added).
public function setControlAttribute(string $name, $value=true, bool $add_id=true): Field
{
$elements = $this->getElements();
// If this is the name attribute and we haven't yet got an id, then
// make the id the same as the name - unless $add_id is FALSE
if ($add_id && ($name == 'name') && (null === $elements['control']->getAttribute('id')))
{
$this->setControlAttribute('id', $value);
}
// If this is an id and it's not a group field, then associate the
// label with the id
if (!$this->is_group && ($name == 'id'))
{
$elements['label']->setAttribute('for', $value);
}
$elements['control']->setAttribute($name, $value);
$this->setElements($elements);
return $this;
}
// Sets the attributes for the field control.
public function setControlAttributes(array $attributes, bool $add_id=true): Field
{
foreach ($attributes as $key => $value)
{
$this->setControlAttribute($key, $value, $add_id);
}
return $this;
}
public function addControlClass(string $class): Field
{
$elements = $this->getElements();
$elements['control']->addClass($class);
$this->setElements($elements);
return $this;
}
public function addLabelClass(string $class): Field
{
$elements = $this->getElements();
$elements['label']->addClass($class);
$this->setElements($elements);
return $this;
}
public function setControlChecked($checked=true): Field
{
$elements = $this->getElements();
$elements['control']->setChecked($checked);
$this->setElements($elements);
return $this;
}
public function setControlText(string $text): Field
{
$elements = $this->getElements();
$elements['control']->setText($text);
$this->setElements($elements);
return $this;
}
public function addControlElement(Element $element): Field
{
$elements = $this->getElements();
$elements['control']->addElement($element);
$this->setElements($elements);
return $this;
}
public function addLabelElement(Element $element): Field
{
$elements = $this->getElements();
$elements['label']->addElement($element);
$this->setElements($elements);
return $this;
}
public function setLabelAttribute(string $name, $value=true): Field
{
$elements = $this->getElements();
$elements['label']->setAttribute($name, $value);
$this->setElements($elements);
return $this;
}
// Sets the attributes for the field label. No need to do
// the 'for' attribute, as that is done automatically when you
// set the 'id' in the control attributes.
public function setLabelAttributes(array $attributes): Field
{
$elements = $this->getElements();
foreach ($attributes as $key => $value)
{
$elements['label']->setAttribute($key, $value);
}
$this->setElements($elements);
return $this;
}
public function removeLabelAttribute(string $name): Field
{
$elements = $this->getElements();
$elements['label']->removeAttribute($name);
$this->setElements($elements);
return $this;
}
// Adds a hidden input to the form
public function addHiddenInput(string $name, $value) : Field
{
$element = new ElementInputHidden($name, $value);
$this->addElement($element);
return $this;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldButton extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementButton());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldDiv extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementDiv());
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputCheckbox extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputCheckbox());
}
public function setChecked($checked=true)
{
$control = $this->getControl();
$control->setChecked($checked);
$this->setControl($control);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputCheckboxGroup extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementDiv())
->setControlAttributes(array('class' => 'group'));
$this->is_group = true;
}
public function addCheckboxOptions(array $options, string $name, $checked=null, $associative=null, bool $disabled=false): Element
{
$element = $this->getControl();
$element->addCheckboxOptions($options, $name, $checked, $associative, $disabled);
$this->setControl($element);
return $this;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputDatalist extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputDatalist());
}
public function addDatalistOptions(array $options, ?bool $associative=null) : FieldInputDatalist
{
$datalist = $this->getControl();
$datalist->addDatalistOptions($options, $associative);
$this->setControl($datalist);
return $this;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputDate extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputDate());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputEmail extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputEmail());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputFile extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputFile());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
// Defaults to step="1"
class FieldInputNumber extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputNumber());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputPassword extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputPassword());
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputRadioGroup extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementDiv())
->setControlAttributes(array('class' => 'group'));
$this->is_group = true;
}
public function addRadioOptions(array $options, string $name, $checked=null, $associative=null, bool $disabled=false): Element
{
$element = $this->getControl();
$element->addRadioOptions($options, $name, $checked, $associative, $disabled);
$this->setControl($element);
return $this;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputSearch extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputSearch());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputSubmit extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputSubmit());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputText extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputText());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputTime extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputTime());
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldInputUrl extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementInputUrl());
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldSelect extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementSelect());
}
public function addSelectOptions(array $options, $selected=null, ?bool $associative=null, bool $for_datalist=false): Element
{
$select = $this->getControl();
$select->addSelectOptions($options, $selected, $associative);
$this->setControl($select);
return $this;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldSpan extends Field
{
public function __construct()
{
parent::__construct();
$this->addControl(new ElementSpan());
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
class FieldTextarea extends Field
{
public function __construct()
{
parent::__construct();
$this->setAttribute('class', 'field_text_area')
->addControl(new ElementTextarea());
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
use function MRBS\toTimeString;
class FieldTimeWithUnits extends FieldDiv
{
// Constructs a field that has an enabling checkbox and then inputs for
// the quantity and units of time.
//
// $param_names An array of the parameter names, indexed by
// 'enabler', 'quantity' and 'seconds'
// $enabled The current value of the enabling checkbox
// $seconds The current value of the field, in seconds
// $suffix Optional text that can appear after the units
// $input_attributes Optional array of additional attributes for the input
public function __construct(array $param_names, $enabled, $seconds, $suffix=null, ?array $input_attributes=null)
{
parent::__construct();
// Convert the raw seconds into as large a unit as possible
$duration = $seconds;
toTimeString($duration, $units);
// The checkbox, which enables or disables the field
$checkbox = new ElementInputCheckbox();
$checkbox->setAttributes(array('name' => $param_names['enabler'],
'class' => 'enabler'))
->setChecked($enabled);
$this->addControlElement($checkbox);
// The quantity element
$input = new ElementInputNumber();
$attributes = array('name' => $param_names['quantity'],
'value' => $duration);
if (isset($input_attributes))
{
$attributes = array_merge($attributes, $input_attributes);
}
$input->setAttributes($attributes);
$this->addControlElement($input);
// The select element for the units
$options = Form::getTimeUnitOptions();
$select = new ElementSelect();
$select->setAttribute('name', $param_names['units'])
->addSelectOptions($options, array_search($units, $options), true);
$this->addControlElement($select);
// The suffix
if (isset($suffix))
{
$span = new ElementSpan();
$span->setText($suffix);
$this->addControlElement($span);
}
}
}
+350
View File
@@ -0,0 +1,350 @@
<?php
declare(strict_types=1);
namespace MRBS\Form;
use MRBS\Errors\Errors;
use MRBS\Exception;
use function MRBS\generate_token;
use function MRBS\get_form_var;
use function MRBS\get_vocab;
use function MRBS\session;
class Form extends Element
{
public const METHOD_GET = 0;
public const METHOD_POST = 1;
private const TOKEN_NAME = 'csrf_token';
private static $token = null;
private static $cookie_set = false;
// Creates a new form, automatically adding a CSRF token and a MAX_FILE_SIZE value
// as hidden inputs if the method is POST. The GET method should only be used for
// navigating between pages and not for submitting form data which could change the
// database contents or reveal data to which the user is not entitled.
public function __construct(int $method=self::METHOD_GET)
{
parent::__construct('form');
$this->setMethod($method);
}
// Sets the form method. If the method is POST then a CSRF token and a MAX_FILE_SIZE
// value are also added as hidden inputs. If the method is GET then these hidden
// inputs are removed because, in the case of the CSRF token we don't want it to
// appear in the URL for security reasons, and in the case of MAX_FILE_SIZE it should
// only be needed for POST requests.
// TODO: if this method is called after the constructor then make sure the MAX_FILE_SIZE
// TODO: hidden input is at the beginning, before any possible file input elements.
private function setMethod(int $method) : Element
{
if ($method === self::METHOD_GET)
{
$this->removeHiddenInput('MAX_FILE_SIZE');
$this->removeHiddenInput(self::TOKEN_NAME);
}
elseif ($method === self::METHOD_POST)
{
// Add a MAX_FILE_SIZE hidden input for use by forms that have a file
// upload input. This hidden input must come before the file input
// element if it is to be used by PHP. Although at the time of writing
// it is not used by any browsers, we can add some JavaScript to check
// the file size when it is selected and thus save a failed upload attempt.
$max_file_size = ini_get('upload_max_filesize');
if ($max_file_size !== false)
{
$max_file_size = self::convertToBytes($max_file_size);
$this->addHiddenInput('MAX_FILE_SIZE', $max_file_size, 'MAX_FILE_SIZE');
}
// Add a CSRF token
$this->addCSRFToken();
}
return parent::setAttribute('method', self::methodToString($method));
}
// Converts a method string (eg 'get' or 'GET') to a method constant (eg self::METHOD_GET)
private static function methodToInt(string $string) : int
{
if (strcasecmp($string, 'get') === 0)
{
return self::METHOD_GET;
}
if (strcasecmp($string, 'post') === 0)
{
return self::METHOD_POST;
}
throw new Exception("Unsupported method $string");
}
// Converts a method constant (eg self::METHOD_GET) to a method string (eg 'get')
private static function methodToString(int $int) : string
{
if ($int === self::METHOD_GET)
{
return 'get';
}
if ($int === self::METHOD_POST)
{
return 'post';
}
throw new Exception("Unsupported method constant $int");
}
// Sets a form attribute, taking special action in the case of the
// method attribute to set/unset the CSRF token and MAX_FILE_SIZE
// hidden inputs. Can cope with either string or integer method values.
public function setAttribute(string $name, $value=true): Element
{
if (strcasecmp($name, 'method') === 0)
{
if (is_string($value))
{
$value = self::methodToInt($value);
}
if ($value === self::METHOD_POST)
{
$message = "Changing the form method after the form has been created may result " .
"in the MAX_FILE_SIZE hidden input coming after a file input, thus " .
"making it unusable by the server.";
trigger_error($message, E_USER_WARNING);
}
return $this->setMethod($value);
}
return parent::setAttribute($name, $value);
}
// Adds a hidden input to the form. Optionally give the element a key
// so that it can be removed later using the same key.
public function addHiddenInput(string $name, $value, ?string $key=null) : Form
{
$element = new ElementInputHidden($name, $value);
$this->addElement($element, $key);
return $this;
}
// Adds an array of hidden inputs to the form
public function addHiddenInputs(array $hidden_inputs) : Form
{
foreach ($hidden_inputs as $key => $value)
{
$this->addHiddenInput($key, $value);
}
return $this;
}
// Removes a hidden input from the form.
private function removeHiddenInput(string $key) : Form
{
$this->removeElement($key);
return $this;
}
// Returns the HTML for a hidden field containing a CSRF token
public static function getTokenHTML() : string
{
$element = new ElementInputHidden();
$element->setAttributes(array('name' => self::TOKEN_NAME,
'value' => self::getToken()));
return $element->toHTML();
}
// Checks the CSRF token against the stored value and dies with a fatal error
// if they do not match. Note that:
// (1) The CSRF token is always looked for in the POST data, never anywhere else.
// GET requests should only be used for operations that do not modify data or
// grant access.
// (2) Forms should normally use a POST method.
// (3) Actions should normally be taken by handler pages which are not designed to be
// accessed directly by the user and are only expecting POST requests. These pages
// will look for the CSRF token however they are requested. If they are requested via
// GET then they will still look for the token in the POST data and so fail.
// (4) There are some MRBS pages that can be accessed either via a URL with query string,
// or via a POST request. These pages should not take any action, but as a matter of
// good practice should check the token anyway if they have been requested by a POST.
// To cater for these pages the $post_only parameter should be set to TRUE.
public static function checkToken(bool $post_only=false) : void
{
global $server;
if ($post_only && ($server['REQUEST_METHOD'] != 'POST'))
{
return;
}
$token = get_form_var(self::TOKEN_NAME, 'string', null, INPUT_POST);
$stored_token = self::getStoredToken();
if (!self::compareTokens($stored_token, $token))
{
if (isset($stored_token))
{
// Only report a possible CSRF attack if the stored token exists. If it doesn't
// it's normally because the user session has expired in between the form being
// displayed and submitted.
trigger_error('Possible CSRF attack from IP address ' . $server['REMOTE_ADDR'], E_USER_NOTICE);
}
if (method_exists(session(), 'logoffUser'))
{
session()->logoffUser();
}
Errors::fatalError(get_vocab("session_expired"));
}
}
// $max_unit can be set to 'seconds', 'minutes', 'hours', etc. and
// can be used to specify the maximum unit to return.
public static function getTimeUnitOptions($max_unit=null) : array
{
$options = array();
$units = array('seconds', 'minutes', 'hours', 'days', 'weeks');
foreach ($units as $unit)
{
$options[$unit] = get_vocab($unit);
if (isset($max_unit) && ($max_unit == $unit))
{
break;
}
}
return $options;
}
private function addCSRFToken() : Form
{
$this->addHiddenInput(self::TOKEN_NAME, self::getToken(), self::TOKEN_NAME);
return $this;
}
// Get a CSRF token
public static function getToken() : string
{
$token_length = 32;
if (!isset(self::$token))
{
$stored_token = self::getStoredToken();
// The test below should really be isset() rather than !empty(). However occasionally MRBS has the
// value 0 stored in the session variable. It's not clear how or why this is happening. Until the
// root cause is found we test for empty() and if the token is set but empty we generate a new token.
// Update: it seems that when the token is 0, so are all the other session variables. So the problem
// is probably not in the form code, but elsewhere.
if (!empty($stored_token))
{
self::$token = $stored_token;
}
else
{
if (isset($stored_token))
{
// The token is set but empty
$message = "Stored token is '$stored_token'. This should not be possible. " .
"Generating a new token.";
trigger_error($message,E_USER_WARNING);
}
self::$token = generate_token($token_length);
self::storeToken(self::$token);
}
}
return self::$token;
}
// Compare two tokens in a timing attack safe manner.
// Returns true if they are equal, otherwise false.
// Note: it is important to provide the user-supplied string as the
// second parameter, rather than the first.
private static function compareTokens($known_token, $user_token) : bool
{
if (is_null($known_token) || is_null($user_token))
{
return false;
}
if (function_exists('hash_equals'))
{
return hash_equals($known_token, $user_token);
}
// Could do fancier things here to give a timing attack safe comparison,
// For example https://github.com/indigophp/hash-compat
return ($known_token === $user_token);
}
private static function storeToken($token) : void
{
session()->set(self::TOKEN_NAME, $token);
}
private static function getStoredToken() : ?string
{
$result = session()->get(self::TOKEN_NAME);
// For some unknown reason the integer value 0 is sometimes stored in the session
// variable. It's not clear how this can happen.
if (isset($result) && !is_string($result))
{
trigger_error("Stored token is of type " . gettype($result) . ", value $result", E_USER_WARNING);
$result = strval($result);
}
return $result;
}
// Convert a file size to bytes
// See https://www.php.net/manual/en/faq.using.php#faq.using.shorthandbytes
private static function convertToBytes(string $size) : int
{
// Split the size into value and units (if any)
$values = preg_split('/(?<=[0-9])(?=[^0-9]+)/i', $size);
if (count($values) == 2)
{
$result = intval($values[0]);
switch ($values[1])
{
case 'G':
$result = 1024 * $result;
// Fall through
case 'M':
$result = 1024 * $result;
// Fall through
case 'K':
$result = 1024 * $result;
return $result;
break;
default:
// Unrecognised suffix
break;
}
}
return intval($size);
}
}