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:
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
namespace MRBS\ICalendar;
|
||||
|
||||
class CalendarException extends \Exception
|
||||
{
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace MRBS\ICalendar;
|
||||
|
||||
|
||||
class RFC5545Exception extends \Exception
|
||||
{
|
||||
const INVALID_DAY = 1;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user