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
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
class Alarm extends Component
{
public const NAME = 'VALARM';
protected function validateProperty(Property $property): void
{
// TODO: Implement validateProperty() method.
}
}
+308
View File
@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
use MRBS\DB\DBStatement;
use MRBS\Exception;
use MRBS\Language;
use MRBS\RepeatRule;
use MRBS\Utf8\Utf8String;
use function MRBS\get_mrbs_version;
use function MRBS\row_cast_columns;
use function MRBS\unpack_status;
require_once MRBS_ROOT . '/version.inc';
/**
* The Calendar class is used to construct and manipulate calendar data
* in compliance with the iCalendar (RFC 5545) specification.
*/
class Calendar
{
private const NAME = 'VCALENDAR';
private const CR = "\r";
private const LF = "\n";
private const MAX_OCTETS_IN_LINE =75; // The maximum line length allowed
private const LINE_FOLD = self::EOL . ' '; // The RFC also allows a horizontal tab instead of a space
private const LINE_FOLD_OCTETS = 1;
public const EOL = self::CR . self::LF;
private $components = [];
private $properties = [];
public function __construct(?string $method=null)
{
$this->properties[] = new Property('PRODID', '-//MRBS//NONSGML ' . get_mrbs_version() . '//EN');
$this->properties[] = new Property('VERSION', '2.0');
$this->properties[] = new Property('CALSCALE', 'GREGORIAN');
if (isset($method))
{
$this->properties[] = new Property('METHOD', $method);
}
}
public function addComponent(Component $component) : self
{
$this->components[] = $component;
return $this;
}
public function addComponents(array $components) : self
{
foreach ($components as $component)
{
$this->addComponent($component);
}
return $this;
}
public function toString(): string
{
$result = 'BEGIN:' . self::NAME . self::EOL;
foreach ($this->properties as $property)
{
$result .= $property->toString();
}
foreach ($this->components as $component)
{
$result .= $component->toString();
}
$result .= 'END:' . self::NAME . self::EOL;
return self::fold($result);
}
/**
* "Fold" lines longer than 75 octets. Multibyte safe.
*/
public static function fold(string $str) : string
{
// "Lines of text SHOULD NOT be longer than 75 octets, excluding the line
// break. Long content lines SHOULD be split into a multiple line
// representations using a line "folding" technique. That is, a long
// line can be split between any two characters by inserting a CRLF
// immediately followed by a single linear white-space character (i.e.,
// SPACE or HTAB). Any sequence of CRLF followed immediately by a
// single linear white-space character is ignored (i.e., removed) when
// processing the content type." (RFC 5545)
// Deal with the trivial case
if ($str === '')
{
return $str;
}
// We assume that we are using UTF-8 and therefore that a space character
// is one octet long. If we ever switched for some reason to using, for
// example, UTF-16, this assumption would be invalid.
if ((Language::MRBS_CHARSET != 'utf-8') || (Language::MAIL_CHARSET != 'utf-8'))
{
throw new Exception("MRBS: internal error - using unsupported character set");
}
$utf8_string = new Utf8String($str);
// Simple case: no folding necessary
if ($utf8_string->byteCount() <= self::MAX_OCTETS_IN_LINE)
{
return $str;
}
// Iterate through the characters working out when to insert a fold
$result = '';
$n_chars = count($utf8_string->toArray());
$octets = 0;
$previous = [];
foreach ($utf8_string as $i => $char)
{
// Store the character
$previous[] = $char;
// If it's a CR and there's at least one more character to come, then get that one.
if (($char == self::CR) && ($i < $n_chars - 1))
{
continue;
}
// If it's a LF and the previous character was a CR, then we've reached the end of a line
if (($char == self::LF) && (count($previous) == 2) && ($previous[0] == self::CR))
{
// Output the EOL, clear the previous characters and reset the octet count
$result .= self::EOL;
$previous = [];
$octets = 0;
continue;
}
// Otherwise output the previous characters, inserting a fold if necessary
while (null !== ($previous_char = array_shift($previous)))
{
$previous_char_octets = (new Utf8String($previous_char))->byteCount();
// If this character would take us over the line length limit, then output a line fold
if ($octets + $previous_char_octets > self::MAX_OCTETS_IN_LINE)
{
$result .= self::LINE_FOLD;
// Reset the octet count to account for the whitespace introduced during folding.
// [Note: It's not entirely clear from the RFC whether the octet that is introduced
// when folding counts towards the 75 octets. Some implementations (eg Google
// Calendar as of Jan 2011) do not count it. However, it can do no harm to err on
// the safe side and include the initial whitespace in the count.]
$octets = self::LINE_FOLD_OCTETS;
}
// Now output the character and add on the octets just output.
$result .= $previous_char;
$octets += $previous_char_octets;
}
}
return $result;
}
/**
* Creates and returns an iCalendar object from a database query result.
*
* @param DBStatement $res The result set from an SQL query on the entry table, which
* has been sorted by repeat_id, start_time (both ascending).
* As well as all the fields in the entry table, the rows will
* also contain the room_id, area name, room name, timezone and
* repeat details (rep_type, end_date, rep_opt, rep_interval).
* @param bool $keep_private Whether to mark events as private.
* @param int $export_end Optional parameter specifying the end timestamp for exporting events. Defaults to PHP_INT_MAX.
*
* @return self The constructed iCalendar object.
*/
public static function createFromStatement(DBStatement $res, bool $keep_private, int $export_end=PHP_INT_MAX) : self
{
// We construct an iCalendar by going through the rows from the SQL query. Because
// it was sorted by repeat_id we will
// - get all the individual entries (which will not have a repeat_id)
// - then get the series. For each series we have to:
// - identify the series information.
// - identify any events that have been changed from the standard, ie events
// with entry_type == ENTRY_RPT_CHANGED
// - identify any events from the original series that have been cancelled. We
// can do this because we know from the repeat information the events that
// should be there, and we can tell from the start times the events that are
// actually there.
// We use PUBLISH rather than REQUEST because we're not inviting people to these meetings,
// we're just exporting the calendar. Furthermore, if we don't use PUBLISH then some
// calendar apps (eg Outlook, at least 2010 and 2013) won't open the full calendar.
$method = "PUBLISH";
$calendar = new self($method);
// We need to find all the timezones used in the result set before we can build the calendar.
$timezones = [];
$events = [];
$n_rows = $res->count();
for ($i=0; (false !== ($row = $res->next_row_keyed())); $i++)
{
row_cast_columns($row, 'entry');
// 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']);
unpack_status($row);
// Generate a timezone component for this row, if we haven't already done so.
if (!isset($timezones[$row['timezone']]))
{
$timezones[$row['timezone']] = Timezone::createFromTimezoneName($row['timezone']);
}
$tzid = ($row['timezone'] === false) ? null : $row['timezone'];
// If this is an individual entry, then construct an event
if (!isset($row['rep_type']) || ($row['rep_type'] == RepeatRule::NONE))
{
try
{
$events = array_merge($events, Event::createFromData($method, $row, $tzid));
}
catch (CalendarException $e)
{
// Don't do anything. We're not able to create Events, probably because we are using
// periods and the times for periods haven't been defined.
}
}
// Otherwise it's a series
else
{
// If we haven't started a series, then start one
if (!isset($series))
{
$series = new Series($row, $tzid, $export_end);
}
// Otherwise, if this row is a member of the current series, add the row to the series.
elseif ($row['repeat_id'] == $series->repeat_id)
{
$series->addRow($row);
}
// If it's a series that we haven't seen yet, or we've got no more
// rows, then process the series
if (($row['repeat_id'] != $series->repeat_id) || ($i == $n_rows - 1))
{
try
{
$events = array_merge($events, $series->toEvents($method));
}
catch (CalendarException $e)
{
// Don't do anything. We're not able to create Events, probably because we are using
// periods and the times for periods haven't been defined.
}
// If we're at the start of a new series then create a new series
if ($row['repeat_id'] != $series->repeat_id)
{
$series = new Series($row, $tzid, $export_end);
// And if this is the last row, ie the only member of the new series
// then process the new series
if ($i == $n_rows - 1)
{
try
{
$events = array_merge($events, $series->toEvents($method));
}
catch (CalendarException $e)
{
// Don't do anything. We're not able to create Events, probably because we are using
// periods and the times for periods haven't been defined.
}
}
}
}
}
}
// Now we've got all the timezones and events, add them to the calendar.
foreach ($timezones as $timezone)
{
if ($timezone !== false)
{
$calendar->addComponent($timezone);
}
}
// Use array_shift rather than foreach to save memory, by reducing the size
// of the $events array while building the calendar.
while (null !== ($event = array_shift($events)))
{
$calendar->addComponent($event);
}
return $calendar;
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
class CalendarException extends \Exception
{
}
+137
View File
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
/**
* Represents an abstract calendar component that can contain subcomponents and properties.
* This class serves as a base for specific calendar components such as VEVENT and VTIMEZONE.
*/
abstract class Component
{
// Self-referential 'abstract' declaration
public const NAME = self::NAME;
/**
* Components can contain other components, eg a VEVENT can contain a VALARM, or a VTIMEZONE can contain
* STANDARD and DAYLIGHT components.
*
* @var Component[]
*/
protected $components = [];
/**
* @var Property[]
*/
protected $properties = [];
/**
* Validate a property by checking that the name is valid for the component and
* that it hasn't yet been added to the component if only one instance of a
* property is allowed.
*/
protected function validateProperty(Property $property) : void
{
}
/**
* Add a subcomponent to the component.
*/
public function addComponent(Component $component) : self
{
$this->components[] = $component;
return $this;
}
/**
* Add a property to the component.
*/
public function addProperty(Property $property) : self
{
$this->validateProperty($property);
$this->properties[] = $property;
return $this;
}
/**
* Get the values of a property.
*/
public function getPropertyValues(string $name) : array
{
// TODO: Make this more efficient, so that it doesn't have to loop through all the properties
// TODO: if we know that there is only one of a particular property. Either add a limit
// TODO: parameter? Or check the Component class for once-only properties?
$result = [];
foreach ($this->properties as $property)
{
if ($property->getName() == $name)
{
// There could be more than one property with the same name
$result = array_merge($result, $property->getValues());
}
}
return $result;
}
/**
* Get properties in the component, optionally filtered by name. Note that there could be more than one
* property with the same name, so the result is an array of Property objects.
*
* @param string|null $name The name of the property to return. If null, return all properties.
* @param int|null $limit The maximum number of properties to return. If null, return all properties.
* @return Property[]
*/
public function getProperties(?string $name=null, ?int $limit=null) : array
{
if (!isset($name) && !isset($limit))
{
return $this->properties;
}
$result = [];
foreach ($this->properties as $property)
{
if (($name === null) || ($property->getName() === $name))
{
$result[] = $property;
}
if (isset($limit) && count($result) >= $limit)
{
break;
}
}
return $result;
}
/**
* Convert the component to a string.
*/
public function toString(): string
{
$result = 'BEGIN:' . static::NAME . Calendar::EOL;
foreach ($this->properties as $property)
{
$result .= $property->toString();
}
foreach ($this->components as $component)
{
$result .= $component->toString();
}
$result .= 'END:' . static::NAME . Calendar::EOL;
return $result;
}
}
+176
View File
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
class ComponentFactory
{
/**
* Create a component from a string containing the component content.
*
* @return Component|false The component object, or false if the string is not a valid component.
*/
public static function createFromString(string $content)
{
// Trim and unfold the content, then split it into lines,
$lines = explode(Calendar::EOL, self::unfold(trim($content)));
// It should have at least two lines: the first BEGIN: line and the last END: line.
if (count($lines) < 2)
{
trigger_error("Component has fewer than two lines: '$content'", E_USER_WARNING);
return false;
}
// Work out what kind of component this is from the first line.
$first_line = array_shift($lines);
if (!str_starts_with($first_line, 'BEGIN:'))
{
trigger_error("First line of component is not BEGIN: line: '$first_line'", E_USER_WARNING);
return false;
}
$component_name = mb_substr($first_line, mb_strlen('BEGIN:'));
// Check that the last line is a matching END: line.
if ("END:$component_name" !== ($last_line = array_pop($lines)))
{
trigger_error("Last line of component is not 'END:$component_name' line: '$last_line'", E_USER_WARNING);
return false;
}
// Create the component object.
switch ($component_name)
{
case Alarm::NAME:
$component = new Alarm();
break;
case Daylight::NAME:
$component = new Daylight();
break;
case Event::NAME:
$component = new Event();
break;
case Freebusy::NAME:
$component = new Freebusy();
break;
case Journal::NAME:
$component = new Journal();
break;
case Standard::NAME:
$component = new Standard();
break;
case Timezone::NAME:
$component = new Timezone();
break;
case Todo::NAME:
$component = new Todo();
break;
default:
trigger_error("Unknown component type: '$component_name'", E_USER_WARNING);
return false;
break;
}
// Go through the lines and add the properties to the component.
while (null !== ($line = array_shift($lines)))
{
// Check for a nested component
if (!str_starts_with($line, 'BEGIN:'))
{
// Not a nested component, so add the line as a property.
$component->addProperty(Property::createFromString($line));
}
else
{
// We've got a nested component.
$nested_component_name = mb_substr($line, mb_strlen('BEGIN:'));
// Save the lines until we reach the END: line
$nested_lines = [];
do {
$nested_lines[] = $line;
} while (null !== ($line = array_shift($lines)) && ("END:$nested_component_name" !== $line));
if (null === $line)
{
trigger_error("Nested $nested_component_name component does not have an END: line", E_USER_WARNING);
return false;
}
// Add the END: line to the nested lines.
$nested_lines[] = $line;
// Get the nested component and add it to this component.
// This code allows for an unlimited depth of nested components, though in practice only one level should be needed.
if (false === ($nested_component = self::createFromString(implode(Calendar::EOL, $nested_lines))))
{
return false;
}
$component->addComponent($nested_component);
}
}
return $component;
}
/**
* Reads the next component from a stream and creates a corresponding component object.
*
* @param resource $stream The input stream to read the component from.
* @param string|null $component_name The specific component name to look for, or null to read any component.
*
* @return Component|false The component object if a valid component is found, or false if there are no more components.
*/
public static function getNextFromStream($stream, ?string $component_name=null)
{
$lines = [];
// Theoretically the line should be folded if it's longer than 75 octets,
// but, just in case the file has been created without using folding, we
// will read a large number (4096) of bytes to make sure that we get as
// far as the end of the line.
while (false !== ($line = stream_get_line($stream, 4096, Calendar::EOL)))
{
if (empty($lines))
{
if (str_starts_with($line, 'BEGIN:'))
{
// Work out what kind of component this is from the first line and see if it's the
// one we're looking for. If it is, or if we're not looking for a specific component,
// then start saving the content lines.
$this_component_name = mb_substr($line, mb_strlen('BEGIN:'));
if (!isset($component_name))
{
$component_name = $this_component_name;
}
elseif ($component_name !== $this_component_name)
{
continue;
}
// We've reached the start of a new component, so start saving the lines.
$lines[] = $line;
}
}
else
{
$lines[] = $line;
if ($line == "END:$this_component_name")
{
// We've reached the end of the component, so return the Component object.
$content = implode(Calendar::EOL, $lines);
return ComponentFactory::createFromString($content);
}
}
}
return false;
}
/**
* Reverse the RFC 5545 folding process, which splits lines into groups
* of max 75 octets separated by 'CRLFspace' or 'CRLFtab'.
*/
private static function unfold(string $str) : string
{
return preg_replace('/\r\n[ \t]/u', '', $str);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
class Daylight extends Timezone
{
public const NAME = 'DAYLIGHT';
protected function validateProperty(Property $property): void
{
// TODO: Implement validateProperty() method.
}
}
+607
View File
@@ -0,0 +1,607 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
use DateInterval;
use DateTimeZone;
use MRBS\DateTime;
use MRBS\Exception;
use MRBS\Periods;
use MRBS\User;
use function MRBS\auth;
use function MRBS\get_mail_vocab;
use function MRBS\get_period_data;
use function MRBS\get_registrants;
use function MRBS\get_type_vocab;
use function MRBS\parse_addresses;
class Event extends Component
{
public const NAME = 'VEVENT';
/**
* The following are REQUIRED, but MUST NOT occur more than once.
*/
private const REQUIRED_PROPERTIES_ONCE_ONLY = ['DTSTAMP', 'UID'];
/**
* The following is REQUIRED if the component appears in an iCalendar object that doesn't specify the
* "METHOD" property; otherwise, it is OPTIONAL; in any case, it MUST NOT occur more than once.
*/
private const SPECIAL_PROPERTIES_ONCE_ONLY = ['DTSTART'];
/**
* The following are OPTIONAL, but MUST NOT occur more than once.
*/
private const OPTIONAL_PROPERTIES_ONCE_ONLY = [
'CLASS',
'CREATED',
'DESCRIPTION',
'GEO',
'LAST-MODIFIED',
'LOCATION',
'ORGANIZER',
'PRIORITY',
'SEQUENCE',
'STATUS',
'SUMMARY',
'TRANSP',
'URL',
'RECURRENCE-ID'
];
/**
* The following is OPTIONAL, but SHOULD NOT occur more than once.
*/
private const OPTIONAL_PROPERTIES_ONCE_ONLY_SHOULD = [
'RRULE'
];
/**
* Either 'dtend' or 'duration' MAY appear in a 'eventprop', but 'dtend' and 'duration' MUST NOT occur in
* the same 'eventprop'.
*/
private const OPTIONAL_PROPERTIES_MUTUALLY_EXCLUSIVE = [
'DTEND',
'DURATION'
];
private const OPTIONAL_PROPERTIES = [
'ATTACH',
'ATTENDEE',
'CATEGORIES',
'COMMENT',
'CONTACT',
'EXDATE',
'REQUEST-STATUS',
'RELATED-TO',
'RESOURCES',
'RDATE'
];
private $property_names = [];
/**
* Create an array of Event components given the booking data.
*
* @param string $method Specifies the calendar method, such as 'CANCEL', which determines the event status.
* @param array $data The event data, which must include keys for 'room_id', 'room_name' and 'area_name'.
* @param string|null $tzid The timezone identifier. If null, DATE-TIME values will be written in UTC format,
* otherwise they will be written in the local timezone format.
* @param array<string, string>|null $addresses An associative array of attendee addresses indexed by 'to' and 'cc'.
* @param bool $series Indicates whether the event is part of a recurring series (true) or a standalone event (false).
*
* @return Event[]
* @throws CalendarException
*/
public static function createFromData(string $method, array $data, ?string $tzid=null, ?array $addresses=null, bool $series=false, bool $for_mail=false) : array
{
global $ignore_gaps_between_periods;
// Get the period data for the room so that we know how to handle the start and end times. We also need it
// so that we can include it in the VEVENT.
list('enable_periods' => $data['enable_periods'], 'periods' => $data['periods']) = get_period_data($data['room_id']);
// If it's in "times" mode then it's easy.
if (!$data['enable_periods'])
{
return [self::createSingleEventFromData($method, $data, $tzid, $addresses, $series, null, $for_mail)];
}
// Otherwise we need to create a series of sub-events, treating each period as a separate sub-event unless
// they are consecutive, or we have been told to ignore gaps between periods.
// But we can't do this if we don't have a timezone identifier.
if (!isset($tzid))
{
throw new CalendarException("Cannot create events in periods mode without a timezone identifier");
}
// And we can't do it if the period times haven't been defined.
if (!$data['periods']->hasTimes())
{
throw new CalendarException("Cannot create events in periods mode because the period times have not been defined");
}
// We can produce events from periods.
$sub_events = [];
$start_date = (new DateTime('now', new DateTimeZone($tzid)))->setTimestamp($data['start_time'])->setTime(0, 0);
$date = clone $start_date;
$end_date = (new DateTime('now', new DateTimeZone($tzid)))->setTimestamp($data['end_time'])->setTime(0, 0);
$days_diff = $start_date->diff($end_date)->days;
// Cycle through the days in the interval
for ($d = 0; $d <= $days_diff; $d++)
{
// Cycle through the periods in the day
for ($i = 0; $i < $data['periods']->count(); $i++)
{
$start_timestamp = $data['periods']->getStartTimestamp($i, $date);
// If this period starts before the start of the booking, then skip it.
if ($start_timestamp < $data['start_time'])
{
continue;
}
// Get the real start and end times for this period
if (false === ($this_start = $data['periods']->timestampToRealStart($start_timestamp)))
{
throw new CalendarException("Cannot convert start time for period with offset $i to a real start time");
}
if (false === ($this_end = $data['periods']->timestampToRealEnd($start_timestamp)))
{
throw new CalendarException("Cannot convert end time for period with offset $i to a real end time");
}
// If we haven't started a sub-event yet, then start one.
if (!isset($sub_event))
{
$sub_event = [$i, $this_start, $this_end];
}
// Otherwise check if we have reached the end of the booking, in which case store the sub-event and finish.
elseif ($start_timestamp >= $data['end_time'])
{
$sub_events[] = $sub_event;
break 2; // Exit both the period and day loops.
}
// Otherwise check if there's a gap between this period and the previous one, and we're not ignoring gaps.
// If so, then store the sub-event and start a new one.
elseif (($this_start > $sub_event[2]) && !$ignore_gaps_between_periods)
{
$sub_events[] = $sub_event;
$sub_event = [$i, $this_start, $this_end];
}
// Otherwise extend the end time of the sub-event.
else
{
$sub_event[2] = $this_end;
}
}
// We've reached the end of the day, so store a new sub-event for the periods so far, if any.
if (isset($sub_event))
{
$sub_events[] = $sub_event;
unset($sub_event);
}
// Move to the next day
$date->modify('+1 day');
}
// Now we've got an array of sub-events, each of which has a start and end time, turn each one into an Event component.
$result = [];
foreach ($sub_events as $i => $sub_event)
{
list($offset, $data['start_time'], $data['end_time']) = $sub_event;
// However, if it's a series, we first have to convert the repeat end date to a real end time, as
// well as adjusting the time to match the time of the starting period (because the original repeat
// rule would have had the start time of the first period of the booking, but now we potentially
// have multiple bookings).
if ($series)
{
$repeat_rule = $data['repeat_rule'];
$end_date = $repeat_rule->getEndDate();
// Get the starting hour and minute of the first period of this sub-event and make the end date match it.
$end_date->setTime(Periods::getHourByOffset($offset), Periods::getMinuteByOffset($offset));
// Then convert the end date to a real end time.
$end_date->setTimestamp($data['periods']->timestampToRealStart($end_date->getTimestamp()));
$repeat_rule->setEndDate($end_date);
$data['repeat_rule'] = $repeat_rule;
}
// We need to give each sub-event that we create a different UID so that calendar programs will treat them as
// separate events. But only do this if there is more than one sub-event, as this has the advantage of keeping,
// if possible, the UID in the iCalendar the same as the UID in the database. Most of the time people will
// probably just be booking for one period.
$uid_part = (count($sub_events) > 1) ? $i : null;
$result[] = self::createSingleEventFromData($method, $data, $tzid, $addresses, $series, $uid_part, $for_mail);
}
return $result;
}
/**
* Create a single instance of an Event component given the booking data.
*
* @param string $method Specifies the calendar method, such as 'CANCEL', which determines the event status.
* @param array $data The event data.
* @param string|null $tzid The timezone identifier. If null, DATE-TIME values will be written in UTC format,
* otherwise they will be written in the local timezone format.
* @param array<string, string>|null $addresses An associative array of attendee addresses indexed by 'to' and 'cc'.
* @param bool $series Indicates whether the event is part of a recurring series (true) or a standalone event (false).
* @param int|null $uid_part If set, append this value to the UID to create a unique UID for the event.
*/
private static function createSingleEventFromData(
string $method,
array $data,
?string $tzid=null,
?array $addresses=null,
bool $series=false,
?int $uid_part=null,
bool $for_mail = false
) : self
{
global $mail_settings, $default_area_room_delimiter, $standard_fields;
global $partstat_accepted;
$event = new Event();
// REQUIRED properties, but MUST NOT occur more than once
// UID. Create a unique UID for the event by appending the uid_part to the original UID, if required.
$uid = $data['ical_uid'];
if (isset($uid_part))
{
$parts = explode('@', $uid, 2);
$uid = $parts[0] . '-' . $uid_part;
if (isset($parts[1]))
{
$uid .= '@' . $parts[1];
}
}
$event->addProperty(new Property('UID', $uid));
// DTSTAMP.
$event->addProperty(Property::createFromTimestamps('DTSTAMP', time()));
// Optional properties
$last_modified = empty($data['last_updated']) ? time() : $data['last_updated'];
$event->addProperty(Property::createFromTimestamps('LAST-MODIFIED', $last_modified));
// Note: we try and write the event times in the format of a local time with
// a timezone reference (ie RFC 5545 Form #3). Only if we can't do that do we
// fall back to a UTC time (ie RFC 5545 Form #2).
//
// The reason for this is that although this is not required by RFC 5545 (see
// Appendix A.2), its predecessor, RFC 2445, did require it for recurring
// events and is the standard against which older applications, notably Exchange
// 2007, are written. Note also that when using a local timezone format the
// VTIMEZONE component must be provided in the calendar. Some
// applications will work without the VTIMEZONE component, but many follow the
// standard and do require it. Here is an extract from RFC 2445:
// 'When used with a recurrence rule, the "DTSTART" and "DTEND" properties MUST be
// specified in local time and the appropriate set of "VTIMEZONE" calendar components
// MUST be included.'
$event->addProperty(Property::createFromTimestamps('DTSTART', $data['start_time'], $tzid));
$event->addProperty(Property::createFromTimestamps('DTEND', $data['end_time'], $tzid));
if ($series)
{
$event->addProperty(new Property('RRULE', $data['repeat_rule']->toRFC5545Rule()));
if (!empty($data['skip_list']))
{
$event->addProperty(Property::createFromTimestamps('EXDATE', $data['skip_list'], $tzid));
}
}
$event->addProperty(new Property('SUMMARY', $data['name']));
if (isset($data['description']))
{
$event->addProperty(new Property('DESCRIPTION', $data['description']));
}
$event->addProperty(new Property('LOCATION', $data['area_name'] . $default_area_room_delimiter . $data['room_name']));
$event->addProperty(new Property('SEQUENCE', $data['ical_sequence']));
// If this is an individual member of a series, then set the recurrence id.
if (!$series && ($data['entry_type'] != ENTRY_SINGLE))
{
$event->addProperty(new Property('RECURRENCE-ID', $data['ical_recur_id']));
}
// STATUS: As we can have confirmed and tentative bookings, we will send that information
// in the Status property, as some calendar apps will use it. For example, Outlook 2007 will
// distinguish between tentative and confirmed bookings. However, having sent it, we need to
// send a STATUS:CANCELLED on cancellation. It's not clear from the spec whether this is
// strictly necessary, but it can do no harm, and there are some apps that seem to need it -
// for example, Outlook 2003 (but not 2007).
if ($method === 'CANCEL')
{
$status = 'CANCELLED';
}
else
{
$status = (empty($data['tentative'])) ? 'CONFIRMED' : 'TENTATIVE';
}
$event->addProperty(new Property('STATUS', $status));
/*
Class is commented out for the moment. To be useful it probably needs to go
hand in hand with an ORGANIZER, otherwise people won't be able to see their own
bookings
$event->addProperty(new Property('CLASS', ($data['private']) ? 'PRIVATE' : 'PUBLIC'));
*/
// ORGANIZER
// TODO: Review whether the ORGANIZER property is required.
// RFC 5545 states:
// "This property MUST be specified in an iCalendar object
// that specifies a group-scheduled calendar entity. This property
// MUST be specified in an iCalendar object that specifies the
// publication of a calendar user's busy time. This property MUST
// NOT be specified in an iCalendar object that specifies only a time
// zone definition or that defines calendar components that are not
// group-scheduled components, but are components only on a single
// user's calendar."
// Does MRBS count as a user? If so, does this mean that as long as
// there is at least one ATTENDEE the property MUST be specified?
if ($for_mail)
{
// The organizer is MRBS. We don't make the create_by user the organizer because there
// are some mail systems such as IBM Domino that silently discard the email notification
// if the organizer's email address is the same as the recipient's - presumably because
// they assume that the recipient already knows about the event.
$organizer = self::getMrbsOrganizer();
}
else
{
// The file is not being used for email notifications, so we need to make the booking
// creator the organizer.
$organizer = auth()->getUser($data['create_by']);
// If the user doesn't exist, probably because they've been deleted, then create one.
if (!isset($organizer))
{
$organizer = new User($data['create_by']);
}
// The ORGANIZER property has to have a cal-address, so if the user doesn't have an email address,
// then use the MRBS organizer's. This is not strictly correct, but allows us to generate an
// event for export that can be re-imported into MRBS, when the important value is the username,
// not the email address.
if (!isset($organizer->email) || ($organizer->email === ''))
{
$mrbs_organizer = self::getMrbsOrganizer();
$organizer->email = $mrbs_organizer->email;
}
}
$property = new Property('ORGANIZER', 'mailto:' . $organizer->email);
$parameters = [
'CN' => 'display_name',
'X-MRBS-USERNAME' => 'username'
];
foreach ($parameters as $name => $property_name)
{
if (isset($organizer->$property_name) && ($organizer->$property_name !== ''))
{
$property->addParameter($name, $organizer->$property_name);
}
}
$event->addProperty($property);
// Put the people on the "to" list as required participants and those on the cc
// list as non-participants. In theory the email client can then decide whether
// to enter the booking automatically on the user's calendar - although at the
// time of writing (Dec 2010) there don't seem to be any that do so!
if (!empty($addresses))
{
$attendees = $addresses; // take a copy of $addresses as we're going to alter it
$keys = array('to', 'cc'); // We won't do 'bcc' as they need to stay blind
foreach ($keys as $key)
{
$attendees[$key] = parse_addresses($attendees[$key]); // convert the list into an array
}
foreach ($keys as $key)
{
foreach ($attendees[$key] as $attendee)
{
if (!empty($attendee))
{
switch ($key)
{
case 'to':
$role = "REQ-PARTICIPANT";
break;
default:
if (in_array($attendee, $attendees['to']))
{
// It's possible that an address could appear on more than one
// line, in which case we only want to have one ATTENDEE property
// for that address and we'll choose the REQ-PARTICIPANT. (Apart
// from two conflicting ATTENDEES not making sense, it also breaks
// some applications, eg Apple Mail/iCal)
continue 2; // Move on to the next attendeee
}
$role = "NON-PARTICIPANT";
break;
}
$property = new Property('ATTENDEE', 'mailto:' . $attendee['address']);
// Use the common name if there is one
if (isset($attendee['name']) && ($attendee['name'] !== ''))
{
$property->addParameter('CN', $attendee['name']);
}
$property->addParameter('ROLE', $role);
$property->addParameter('PARTSTAT', ($partstat_accepted) ? 'ACCEPTED' : 'NEEDS-ACTION');
$event->addProperty($property);
}
}
}
}
// MRBS specific properties
// Type
$event->addProperty(new Property('X-MRBS-TYPE', get_type_vocab($data['type'])));
// Periods
if ($data['enable_periods'])
{
$event->addProperty(new Property('X-MRBS-PERIODS', $data['periods']->toDbValue()));
}
// Registration properties
if (isset($data['allow_registration']))
{
$properties = [
'allow_registration',
'registrant_limit',
'registrant_limit_enabled',
'registration_opens',
'registration_opens_enabled',
'registration_closes',
'registration_closes_enabled'
];
foreach ($properties as $property)
{
$event->addProperty(new Property('X-MRBS-' . strtoupper(str_replace('_', '-', $property)), strval($data[$property])));
}
// Registrants (but only for individual entries)
if (!$series)
{
// Get the registrants if they're not already in the data.
$registrants = $data['registrants'] ?? get_registrants($data['id'], false);
foreach ($registrants as $registrant)
{
// We can't use the ATTENDEE property because its value has to be a URI.
$property = new Property('X-MRBS-REGISTRANT', $registrant['username']);
$property->addParameter('X-MRBS-REGISTERED', strval($registrant['registered']));
$property->addParameter('X-MRBS-CREATE-BY', $registrant['create_by']);
$event->addProperty($property);
}
}
}
// Custom fields
// These fields have already been handled above.
$already_handled = [
'last_updated',
'tentative',
'area_name',
'room_name',
'registrants',
'enable_periods',
'periods'
];
// These fields are in the area table and can be ignored.
$area_table_fields = [
'approval_enabled',
'confirmation_enabled',
'area_id',
'area_sort_key',
'timezone'
];
// These are derived fields and can be ignored for the moment. However we need to do something
// in the future about 'awaiting_approval' and 'private'.
// TODO
$special_fields = [
'awaiting_approval',
'duration',
'dur_units',
'private',
'repeat_rule',
'skip_list',
'room_sort_key'
];
$ignore_fields = array_merge($standard_fields['entry'], $already_handled, $area_table_fields, $special_fields);
foreach ($data as $key => $value)
{
if (!in_array($key, $ignore_fields) && isset($value))
{
// Column names are case-insensitive in MySQL, so we can safely convert them to upper-case to comply with
// the RFC 5545 standard that they are case-insensitive but by convention written in upper-case. In PostgreSQL
// column names are also case-insensitive, unless they are quoted, in which case they are case-sensitive. For
// PostgreSQL it is therefore recommended to use unquoted column names.
// Property names can only consist of ALPHA, DIGIT and "-" characters, so we convert "_" to "-".
$property = new Property('X-MRBS-' . mb_strtoupper(str_replace('_', '-', $key)), $value);
$event->addProperty($property);
}
}
return $event;
}
protected function validateProperty(Property $property) : void
{
$name = $property->getName();
$once_only_properties = array_merge(
self::REQUIRED_PROPERTIES_ONCE_ONLY,
self::OPTIONAL_PROPERTIES_ONCE_ONLY,
self::SPECIAL_PROPERTIES_ONCE_ONLY,
self::OPTIONAL_PROPERTIES_MUTUALLY_EXCLUSIVE
);
$valid_properties = array_merge(
$once_only_properties,
self::OPTIONAL_PROPERTIES_ONCE_ONLY_SHOULD,
self::OPTIONAL_PROPERTIES
);
// Check that the property is valid for an event
if (!in_array($name, $valid_properties, true) && !str_starts_with($name, 'X-'))
{
throw new RFC5545Exception("Property '$name' is not valid for an event");
}
// Check that the property is not set more than once if it's in the set that can only be set once.
if (in_array($name, $once_only_properties, true) && in_array($name, $this->property_names, true))
{
throw new RFC5545Exception("Property '$name' can only be set once");
}
// Check that we'll only have one of the mutually exclusive properties.
if (in_array($name, self::OPTIONAL_PROPERTIES_MUTUALLY_EXCLUSIVE, true) &&
in_array($name, array_intersect($this->property_names, self::OPTIONAL_PROPERTIES_MUTUALLY_EXCLUSIVE), true))
{
throw new RFC5545Exception("Only one of the following properties may be set: " . implode(', ', self::OPTIONAL_PROPERTIES_MUTUALLY_EXCLUSIVE));
}
// Check that we're not using a property twice that is not recommended to be used twice.
if (in_array($name, self::OPTIONAL_PROPERTIES_ONCE_ONLY_SHOULD, true) &&
in_array($name, $this->property_names, true))
{
trigger_error("Property '$name' is recommended to be set only once", E_USER_WARNING);
}
}
private static function getMrbsOrganizer() : User
{
global $mail_settings;
$organizer_addresses = parse_addresses($mail_settings['organizer']);
if (empty($organizer_addresses))
{
$message = "The value '" . $mail_settings['organizer'] . "' supplied for " . '$mail_settings["organizer"]' .
" is not a valid RFC822-style email address. Please check your MRBS config file.";
throw new Exception($message);
}
$organizer = $organizer_addresses[0];
$result = new User();
$result->email = $organizer['address'];
if (isset($organizer['name']) && ($organizer['name'] !== ''))
{
$result->display_name = $organizer['name'];
}
else
{
$result->display_name = get_mail_vocab('mrbs');
}
return $result;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
class Freebusy extends Component
{
public const NAME = 'VFREEBUSY';
protected function validateProperty(Property $property): void
{
// TODO: Implement validateProperty() method.
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
class Journal extends Component
{
public const NAME = 'VJOURNAL';
protected function validateProperty(Property $property): void
{
// TODO: Implement validateProperty() method.
}
}
+489
View File
@@ -0,0 +1,489 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
use DateTimeZone;
use MRBS\DateTime;
use MRBS\Utf8\Utf8String;
class Property
{
private const DATETIME_FORMAT = 'Ymd\THis'; // Format for expressing iCalendar dates
private const VALUE_TYPE_BINARY = 'BINARY';
private const VALUE_TYPE_BOOLEAN = 'BOOLEAN';
private const VALUE_TYPE_CAL_ADDRESS = 'CAL-ADDRESS';
private const VALUE_TYPE_DATE = 'DATE';
private const VALUE_TYPE_DATE_TIME = 'DATE-TIME';
private const VALUE_TYPE_DURATION = 'DURATION';
private const VALUE_TYPE_FLOAT = 'FLOAT';
private const VALUE_TYPE_INTEGER = 'INTEGER';
private const VALUE_TYPE_PERIOD = 'PERIOD';
private const VALUE_TYPE_RECUR = 'RECUR';
private const VALUE_TYPE_TEXT = 'TEXT';
private const VALUE_TYPE_TIME = 'TIME';
private const VALUE_TYPE_URI = 'URI';
private const VALUE_TYPE_UTC_OFFSET = 'UTC-OFFSET';
private $name;
private $params = [];
private $values = [];
private $value_type;
// TODO: Rewrite import.php to use these classes.
/**
* @param string|string[] $values
*/
public function __construct(string $name, $values)
{
// Property names are case-insensitive, but by convention we use uppercase.
$this->name = mb_strtoupper($name);
$this->values = (array) $values;
$this->setImplicitValueType();
}
/**
* Create a property instance given a string.
*
* @param string $string An unfolded property line
*/
public static function createFromString(string $string) : self
{
$parsed_string = self::parseLine($string);
$property = new self($parsed_string['name'], $parsed_string['values']);
foreach ($parsed_string['params'] as $name => $values)
{
$property->addParameter($name, $values);
}
return $property;
}
/**
* Create a property instance of type DATE-TIME from UNIX timestamps.
*
* @param int|int[] $timestamps
*/
public static function createFromTimestamps(string $name, $timestamps, ?string $tzid=null) : self
{
$result = new self($name, self::convertTimestamps($timestamps, $tzid));
if (isset($tzid))
{
$result->addParameter('TZID', $tzid);
}
return $result;
}
/**
* Convert a DATE-TIME value to a UNIX timestamp.
*/
public static function convertDatetimeValue(string $value, ?string $tzid=null) : int
{
if (!isset($tzid))
{
// FORM #1: DATE WITH LOCAL TIME
if (!str_ends_with($value, 'Z'))
{
throw new \Exception("Floating times not supported");
}
// FORM #2: DATE WITH UTC TIME
$value = rtrim($value, 'Z');
$tzid = 'UTC';
}
else
{
if (str_ends_with($value, 'Z'))
{
throw new \Exception("Both a TZID parameter and a Z suffix are not supported (see RFC 5545 section 3.3.5");
}
// FORM #3: DATE WITH LOCAL TIME AND TIME ZONE REFERENCE
}
$datetime = DateTime::createFromFormat('Ymd\THis', $value, new DateTimeZone($tzid));
return $datetime->getTimestamp();
}
/**
* Convert an array of UNIX timestamps to DATE-TIME values.
*
* @param int|int[] $timestamps
* @return string[]
*/
public static function convertTimestamps($timestamps, ?string $tzid=null) : array
{
$values = [];
$timestamps = (array) $timestamps;
$format = self::DATETIME_FORMAT;
if (!isset($tzid))
{
$tzid = 'UTC';
$format .= '\Z';
}
foreach ($timestamps as $timestamp)
{
$date = new DateTime('now', new DateTimeZone($tzid));
$date->setTimestamp($timestamp);
$values[] = $date->format($format);
}
return $values;
}
/**
* Adds a parameter/parameters to the property.
*
* @param string|string[] $values
*/
public function addParameter(string $name, $values) : void
{
// Parameter names are case-insensitive, but by convention we use uppercase.
// Parameters can have multiple values [param = param-name "=" param-value *("," param-value)].
// See, for example, DELEGATED-FROM and DELEGATED-TO in RFC 5545.
$uc_name = mb_strtoupper($name);
$this->params[$uc_name] = array_merge($this->params[$uc_name] ?? [], (array) $values);
// If the value type has been set explicitly using a VALUE parameter then update the value type.
if ($uc_name == 'VALUE')
{
// The VALUE parameter can only have one value.
$this->value_type = mb_strtoupper($values[0]);
}
}
/*
* Get the property name.
*/
public function getName() : string
{
return $this->name;
}
/**
* Get the property values.
*/
public function getValues() : array
{
return $this->values;
}
public function getParamValues(string $name) : array
{
return $this->params[$name] ?? [];
}
/**
* Convert the property to an unfolded string.
*/
public function toString() : string
{
$result = $this->name;
foreach ($this->params as $name => $values)
{
$result .= ';' . $name . '=' . implode(',', array_map([self::class, 'escapeParamValue'], $values));
}
if ($this->value_type == self::VALUE_TYPE_TEXT)
{
$value_string = implode(',', array_map([self::class, 'escapeText'], $this->values));
}
else
{
$value_string = implode(',', $this->values);
}
return "$result:$value_string" . Calendar::EOL;
}
/**
* Converts a property of value type DATE-TIME to UNIX timestamps.
*
* @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.5
*
* @return int[]
*/
public function toTimestamps() : array
{
if ($this->value_type !== self::VALUE_TYPE_DATE_TIME)
{
throw new \Exception("Property '$this->name' is not of type DATE-TIME");
}
$result = [];
$tzid = (isset($this->params['TZID'])) ? $this->params['TZID'][0] : null;
foreach ($this->values as $value)
{
$result[] = self::convertDatetimeValue($value, $tzid);
}
return $result;
}
/**
* Parse a property content line
*
* @param string $line An unfolded property line
* @return array{'name': string, 'params': array<string, string[]>, 'values': string[]}
*/
private static function parseLine(string $line) : array
{
$result = [];
$params = [];
// Get the property name, which will be the part before the first colon or semicolon.
$split = preg_split('/([:;])/', $line, 2, PREG_SPLIT_DELIM_CAPTURE);
$result['name'] = $split[0];
// Get any parameters, which come after a semicolon that isn't in a double-quoted string.
while ($split[1] == ';')
{
$split = preg_split('/([:;](?![^"]*"{1}[:;]))/', $split[2], 2, PREG_SPLIT_DELIM_CAPTURE);
$param = self::parseParam($split[0]);
$params[$param['name']] = $param['values'];
}
$result['params'] = $params;
// Finally, get the property values, which come after a colon that isn't in a double-quoted string.
$result['values'] = self::parsePropertyValues($split[2]);
return $result;
}
private static function parseParam(string $param) : array
{
$result = [];
$split = preg_split('/(=)/', $param, 2, PREG_SPLIT_DELIM_CAPTURE);
$result['name'] = $split[0];
$result['values'] = self::parseParamValues($split[2]);
return $result;
}
/**
* Parse a parameter value string.
*/
private static function parseParamValues(string $value_string) : array
{
// Property parameters can have multiple values (see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.4).
// Split the sting by unescaped commas.
$result = preg_split('/(,(?![^"]*"{1},))/', $value_string);
// Unescape the values
return array_map([self::class, 'unescapeParamValue'], $result);
}
/**
* Parse a property value string.
*/
private static function parsePropertyValues(string $value_string) : array
{
// Properties can have multiple values, separated by commas. These are difficult
// to parse using a regex as look-behinds need to be fixed width, so we can't look
// for an odd or even number of backslashes. Instead, we just iterate through the
// characters in the string.
$result = [];
$value = '';
$in_escape = false;
$iterator = new Utf8String($value_string);
while (null !== ($current_char = $iterator->current()))
{
if ($in_escape)
{
if (!in_array($current_char, ["\\", ";", ",", "\n", "\N"]))
{
$message = "Invalid escape sequence '\\$current_char' in value string '$value_string'.";
trigger_error($message, E_USER_WARNING);
}
$value .= $current_char;
$in_escape = false;
}
elseif ($current_char == "\\")
{
$in_escape = true;
}
elseif ($current_char == ",")
{
$result[] = $value;
$value = '';
}
else
{
$value .= $current_char;
}
$iterator->next();
}
$result[] = $value;
return $result;
}
/**
* Escapes a parameter value, if necessary, so that it can be used in an iCalendar property.
*/
private static function escapeParamValue(string $value) : string
{
// From RFC 5545:
// quoted-string = DQUOTE *QSAFE-CHAR DQUOTE
// QSAFE-CHAR = WSP / %x21 / %x23-7E / NON-US-ASCII
// ; Any character except CONTROL and DQUOTE
// "Property parameter values MUST NOT contain the DQUOTE character. The
// DQUOTE character is used as a delimiter for parameter values that
// contain restricted characters or URI text."
if (str_contains($value, '"'))
{
$value = str_replace('"', "'", $value);
$message = "Parameter value '$value' contains double quotes. This is not allowed. They have been replaced with single quotes.";
trigger_error($message, E_USER_WARNING);
}
// "Property parameter values that contain the COLON, SEMICOLON, or COMMA
// character separators MUST be specified as quoted-string text values."
if (preg_match('/[:;,]/', $value))
{
$value = '"' . $value . '"';
}
return $value;
}
/**
* Unescape a parameter value.
*/
private static function unescapeParamValue(string $str) : string
{
return trim($str, '"');
}
/**
* Escape text for use in a property TEXT value.
*/
private static function escapeText(string $text) : string
{
// Escape '\'
$text = str_replace("\\", "\\\\", $text);
// Escape ';'
$text = str_replace(";", "\;", $text);
// Escape ','
$text = str_replace(",", "\,", $text);
// EOL can only be \n
$text = str_replace("\r\n", "\n", $text);
// Escape '\n'
$text = str_replace("\n", "\\n", $text);
// Escape '\N'
$text = str_replace("\N", "\\N", $text);
return $text;
}
/**
* Reverses RFC 5545 escaping of text.
*
* Only suitable for a single value, not a list of values.
*/
private static function unescapeText(string $str) : string
{
// Unescape '\N'
$str = str_replace("\\N", "\N", $str);
// Unescape '\n'
$str = str_replace("\\n", "\n", $str);
// Unescape ','
$str = str_replace("\,", ",", $str);
// Unescape ';'
$str = str_replace("\;", ";", $str);
// Unescape '\'
$str = str_replace("\\\\", "\\", $str);
return $str;
}
private function setImplicitValueType() : void
{
// See RFC 5545 for the default value types for each property.
switch ($this->name)
{
case 'ATTENDEE':
case 'ORGANIZER':
$this->value_type = self::VALUE_TYPE_CAL_ADDRESS;
break;
case 'COMPLETED':
case 'CREATED':
case 'DTEND':
case 'DTSTAMP':
case 'DTSTART':
case 'DUE':
case 'EXDATE':
case 'LAST-MODIFIED':
case 'RDATE':
case 'RECURRENCE-ID':
$this->value_type = self::VALUE_TYPE_DATE_TIME;
break;
case 'DURATION':
case 'TRIGGER':
$this->value_type = self::VALUE_TYPE_DURATION;
break;
case 'GEO':
$this->value_type = self::VALUE_TYPE_FLOAT;
break;
case 'PERCENT-COMPLETE':
case 'PRIORITY':
case 'REPEAT':
case 'SEQUENCE':
$this->value_type = self::VALUE_TYPE_INTEGER;
break;
case 'FREEBUSY':
$this->value_type = self::VALUE_TYPE_PERIOD;
break;
case 'RRULE':
$this->value_type = self::VALUE_TYPE_RECUR;
break;
case 'ATTACH':
case 'TZURL':
case 'URL':
$this->value_type = self::VALUE_TYPE_URI;
break;
case 'TZOFFSETFROM':
case 'TZOFFSETTO':
$this->value_type = self::VALUE_TYPE_UTC_OFFSET;
break;
default:
$this->value_type = self::VALUE_TYPE_TEXT;
break;
}
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
use DateTimeZone;
use MRBS\DateTime;
use function MRBS\get_vocab;
class RFC5545
{
// An array which can be used to map day of the week numbers (0..6)
// onto days of the week as defined in RFC 5545
public const DAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
// Convert an RFC 5545 day to an ordinal number representing the day of the week,
// eg "MO" returns "1"
public static function convertDayToOrd($day) : int
{
$tmp = array_keys(self::DAYS, $day);
if (count($tmp) === 0)
{
throw new RFC5545Exception(
get_vocab('invalid_RFC5545_day', $day),
RFC5545Exception::INVALID_DAY
);
}
return $tmp[0];
}
// Splits a BYDAY string into its ordinal and day parts, returned as a simple array.
// For example "-1SU" is returned an array indexed by 'ordinal' and 'day' keys, eg
// array('ordinal' => -1, 'day' => 'SU');
public static function parseByday(string $byday) : array
{
$result = array();
$split_pos = mb_strlen($byday) -2;
$result['ordinal'] = (int) mb_substr($byday, 0, $split_pos);
$result['day'] = mb_substr($byday, $split_pos, 2);
return $result;
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace MRBS\ICalendar;
class RFC5545Exception extends \Exception
{
const INVALID_DAY = 1;
}
+193
View File
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
use MRBS\DateTime;
use MRBS\Exception;
use MRBS\RepeatRule;
use function MRBS\entry_has_registrants;
use function MRBS\get_repeat;
require_once MRBS_ROOT . '/mrbs_sql.inc';
class Series
{
public $repeat_id;
private $data;
private $repeat;
private $expected_start_times;
private $actual_start_times;
private $tzid;
// Constructs a new Series object and adds $row to it.
// $limit is the limiting UNIX timestamp for the series. This may be before the actual end
// of the series. Defaults to null, ie no limit. This enables the series extract to be truncated.
public function __construct(array $row, ?string $tzid=null, ?int $limit=null)
{
$this->tzid = $tzid;
$row = self::fixUpRow($row);
$this->data = array();
$this->repeat_id = $row['repeat_id'];
// Get the repeat data and save it, so that we can construct the repeat event later
$this->repeat = get_repeat($this->repeat_id);
if (!isset($this->repeat))
{
throw new Exception("Repeat data not available");
}
// Add in the area and room names, which we can get from this row
$this->repeat['area_name'] = $row['area_name'];
$this->repeat['room_name'] = $row['room_name'];
// Copy the registration settings from this row (they won't necessarily be correct if
// this is a changed entry, but we'll look out for an original row later)
// TODO: the registration settings should really be in the repeat table to begin with
$this->repeat = self::copyRegistrationSettings($this->repeat, $row);
$this->repeat['entry_type'] = $row['entry_type'];
// Limit the series (before we create the repeat rule)
if (isset($limit))
{
$this->repeat['end_date'] = min($limit, $this->repeat['end_date']);
}
// Create the repeat rule
$this->repeat = self::addRepeatRule($this->repeat);
// Construct an array of the start times we'd expect to see in this series so that
// we can check whether any are missing.
$this->expected_start_times = $this->repeat['repeat_rule']->getRepeatStartTimes($this->repeat['start_time']);
// And keep an array of all the start times we actually see
$this->actual_start_times = array();
// And finally add the row to the series
$this->addRow($row);
}
// Add a row to the series
public function addRow(array $row)
{
$row = self::fixUpRow($row);
// Add the row to the data array
$this->data[] = $row;
// If this is an original entry, add its start time to the array of ones we've seen
if ($row['entry_type'] == ENTRY_RPT_ORIGINAL)
{
$this->actual_start_times[] = $row['start_time'];
}
// And if we haven't yet seen an original row, and this is one, then grab
// the registration settings. (If we never see an original row it doesn't matter,
// because all the rows will be changed rows and have their own registration settings.)
if (($this->repeat['entry_type'] == ENTRY_RPT_CHANGED) &&
($row['entry_type'] == ENTRY_RPT_ORIGINAL))
{
$this->repeat = self::copyRegistrationSettings($this->repeat, $row);
$this->repeat['entry_type'] = ENTRY_RPT_ORIGINAL;
}
}
/**
* Convert the series to an array of iCalendar events
*
* @param string $method the METHOD, eg 'PUBLISH'
* @return Event[]
*/
public function toEvents(string $method) : array
{
$events = array();
$this->repeat['skip_list'] = array_diff($this->expected_start_times, $this->actual_start_times);
$events = array_merge($events, Event::createFromData($method, $this->repeat, $this->tzid, null, true));
// Then iterate through the series looking for changed entries
foreach($this->data as $entry)
{
if ($entry['entry_type'] == ENTRY_RPT_CHANGED)
{
$events = array_merge($events, Event::createFromData($method, $entry, $this->tzid));
}
}
return $events;
}
// Temporary fix-up to add a repeat_rule key to the $row array
// TODO: fix this properly
private static function addRepeatRule(array $row) : array
{
// Construct the repeat rule and add it to the row
$repeat_rule = new RepeatRule();
$repeat_rule->setType((int)$row['rep_type']);
$repeat_rule->setInterval((int)$row['rep_interval']);
$repeat_end_date = new DateTime();
$repeat_end_date->setTimestamp((int)$row['end_date']);
$repeat_rule->setEndDate($repeat_end_date);
$repeat_rule->setDaysFromOpt($row['rep_opt']);
if ($repeat_rule->getType() == RepeatRule::MONTHLY)
{
if (isset($row['month_absolute'])) {
$repeat_rule->setMonthlyAbsolute($row['month_absolute']);
$repeat_rule->setMonthlyType(RepeatRule::MONTHLY_ABSOLUTE);
}
elseif (isset($row['month_relative'])) {
$repeat_rule->setMonthlyRelative($row['month_relative']);
$repeat_rule->setMonthlyType(RepeatRule::MONTHLY_RELATIVE);
}
else {
throw new Exception("The repeat type is monthly but both the absolute and relative days are null.");
}
}
$row['repeat_rule'] = $repeat_rule;
return $row;
}
private static function copyRegistrationSettings(array $to, array $from) : array
{
$keys = array(
'allow_registration',
'registrant_limit',
'registrant_limit_enabled',
'registration_opens',
'registration_opens_enabled',
'registration_closes',
'registration_closes_enabled'
);
foreach ($keys as $key)
{
$to[$key] = $from[$key];
}
return $to;
}
private static function fixUpRow(array $row) : array
{
// Temporary fix-up
$row = self::addRepeatRule($row);
// Another fix-up: if the entry has registrants then treat it like
// a changed entry so that it appears as individual event in the calendar
// and can therefore have registrants associated with it.
// TODO: fix this properly. (Maybe entries with registrants should be
// TODO: ENTRY_RPT_CHANGED in the database in the first place? That would
// TODO: also solve the problem of not being able to edit series with registrants.)
if (entry_has_registrants($row['id']))
{
$row['entry_type'] = ENTRY_RPT_CHANGED;
}
return $row;
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
class Standard extends Timezone
{
public const NAME = 'STANDARD';
protected function validateProperty(Property $property): void
{
// TODO: Implement validateProperty() method.
}
}
+238
View File
@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
use GuzzleHttp\Client;
use function MRBS\_tbl;
use function MRBS\db;
use function MRBS\row_cast_columns;
class Timezone extends Component
{
public const NAME = 'VTIMEZONE';
protected function validateProperty(Property $property): void
{
// TODO: Implement validateProperty() method.
}
/**
* Create an instance of the class based on a given timezone name.
*
* This method caches the latest VTIMEZONE component in the database. If it has expired,
* it goes to the web for the latest version, or, if there's nothing in the database in the
* first place, it tries to populate it from the VTIMEZONE definitions in the filesystem.
*
* @param string $tz The name of the timezone for which the instance should be created.
* @return self|false Returns an instance of the class representing the timezone,
* or false if the timezone could not be determined or is invalid.
*/
public static function createFromTimezoneName (string $tz)
{
global $zoneinfo_update, $zoneinfo_expiry, $zoneinfo_outlook_compatible;
static $vtimezones = array(); // Cache the components for performance
if (!isset($vtimezones[$tz]))
{
// Look for a timezone definition in the database
$vtimezone_db = self::getFromDb($tz, $zoneinfo_outlook_compatible);
if (isset($vtimezone_db['vtimezone']))
{
$vtimezones[$tz] = ComponentFactory::createFromString($vtimezone_db['vtimezone']);
// If the definition has expired, and we're updating it, then get a fresh definition from the URL
if ($zoneinfo_update && ((time() - $vtimezone_db['last_updated']) >= $zoneinfo_expiry))
{
$vtimezone = self::getFromUrl($vtimezone_db['vtimezone']);
if (isset($vtimezone))
{
// We've got a valid VTIMEZONE, so we can update the database and the static variable
self::upsertDb($tz, $zoneinfo_outlook_compatible, $vtimezone);
$vtimezones[$tz] = ComponentFactory::createFromString($vtimezone);
}
else
{
// If we didn't manage to get a new VTIMEZONE, update the last_updated field
// so that MRBS will not try again until after the expiry interval has passed.
// This will mean that we don't keep encountering a timeout delay. (The most
// likely reason that we couldn't get a new VTIMEZONE is that the site doesn't
// have external internet access, so there's no point in retrying for a while).
self::touchDb($tz, $zoneinfo_outlook_compatible);
}
}
}
else
{
// If there's nothing in the database, get one from the filesystem
$vtimezone = self::getFromFile($tz, $zoneinfo_outlook_compatible);
if (isset($vtimezone))
{
// And put it in the database if it's valid
self::upsertDb($tz, $zoneinfo_outlook_compatible, $vtimezone);
$vtimezones[$tz] = ComponentFactory::createFromString($vtimezone);
}
else
{
// Everything has failed
$vtimezones[$tz] = false;
}
}
}
return $vtimezones[$tz];
}
/**
* Fetch timezone information from the database based on the given timezone identifier.
*
* @param string $tz The timezone identifier to retrieve data for.
* @return array{'vtimezone': string, 'last_updated': int} Returns an associative array containing the
* timezone information and last updated timestamp, or null if no matching record is found.
*/
private static function getFromDb(string $tz, bool $zoneinfo_outlook_compatible) : ?array
{
$sql = "SELECT vtimezone, last_updated
FROM " . _tbl('zoneinfo') . "
WHERE timezone=:timezone
AND outlook_compatible=:outlook_compatible
LIMIT 1";
$sql_params = array(
':timezone' => $tz,
':outlook_compatible' => ($zoneinfo_outlook_compatible) ? 1 : 0
);
$res = db()->query($sql, $sql_params);
if (false === ($row = $res->next_row_keyed()))
{
return null;
}
row_cast_columns($row, 'zoneinfo');
return $row;
}
/**
* Fetch the VTIMEZONE component from a file for a given timezone identifier.
*/
private static function getFromFile(string $tz, bool $zoneinfo_outlook_compatible) : ?string
{
$tz_dir = ($zoneinfo_outlook_compatible) ? TZDIR_OUTLOOK : TZDIR;
$tz_file = "$tz_dir/$tz.ics";
if (!is_readable($tz_file))
{
return null;
}
$vcalendar = file_get_contents($tz_file);
if (empty($vcalendar))
{
return null;
}
$vtimezone = self::extractVtimezone($vcalendar);
return (empty($vtimezone)) ? null : $vtimezone;
}
/**
* Gets a VTIMEZONE definition from the TZURL defined in the $vtimezone component string.
*/
private static function getFromUrl(string $vtimezone_string) : ?string
{
// (Note that a VTIMEZONE component can contain a TZURL property which
// gives the URL of the most up-to-date version. Calendar applications
// should be able to check this themselves, but we might as well give them
// the most up-to-date version in the first place).
if (false === ($vtimezone = ComponentFactory::createFromString($vtimezone_string)))
{
return null;
}
$tz_url_values = $vtimezone->getPropertyValues('TZURL');
if (empty($tz_url_values))
{
trigger_error("The VTIMEZONE component didn't contain a TZURL property.", E_USER_NOTICE);
return null;
}
$tz_url = $tz_url_values[0];
try {
$vcalendar = (new Client())->get($tz_url)->getBody()->getContents();
}
catch (\Exception $e) {
trigger_error(get_class($e) . ': ' . $e->getMessage(), E_USER_WARNING);
trigger_error("MRBS: failed to download a new timezone definition from $tz_url", E_USER_WARNING);
return null;
}
$new_vtimezone = self::extractVtimezone($vcalendar);
if (empty($new_vtimezone))
{
trigger_error("MRBS: $tz_url did not contain a valid VTIMEZONE", E_USER_WARNING);
return null;
}
return $new_vtimezone;
}
/**
* Extract a VTIMEZONE component from a VCALENDAR.
*/
private static function extractVtimezone(string $content) : string
{
// The VTIMEZONE components are enclosed in a VCALENDAR, so we want to
// extract the VTIMEZONE component.
preg_match('/BEGIN:VTIMEZONE[\s\S]*?END:VTIMEZONE/', $content, $matches);
return $matches[0] ?? '';
}
// Update the last_updated time for a timezone in the database
private static function touchDb(string $tz, bool $zoneinfo_outlook_compatible) : void
{
$sql = "UPDATE " . _tbl('zoneinfo') . "
SET last_updated=:last_updated
WHERE timezone=:timezone
AND outlook_compatible=:outlook_compatible";
$sql_params = array(
':last_updated' => time(),
':timezone' => $tz,
':outlook_compatible' => ($zoneinfo_outlook_compatible) ? 1 : 0
);
db()->command($sql, $sql_params);
}
/**
* Inserts or updates a database record in the `zoneinfo` table with the VTIMEZONE data
* for a timezone.
*/
private static function upsertDb(string $tz, bool $zoneinfo_outlook_compatible, string $vtimezone) : void
{
$sql_params = [];
$data = [
'vtimezone' => $vtimezone,
'last_updated' => time(),
'timezone' => $tz,
'outlook_compatible' => ($zoneinfo_outlook_compatible) ? 1 : 0
];
$sql = db()->syntax_upsert($data, _tbl('zoneinfo'), $sql_params, ['timezone', 'outlook_compatible'], ['id'], true);
db()->command($sql, $sql_params);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace MRBS\ICalendar;
class Todo extends Component
{
public const NAME = 'VTODO';
protected function validateProperty(Property $property): void
{
// TODO: Implement validateProperty() method.
}
}