Files
mrbs-equbao-2026/web/lib/MRBS/ICalendar/Event.php
T
人事系统开发 48092cab42
Docker image / push (push) Canceled after 0s
MRBS 1.12.2 等保2.0二级整改完整提交
包含:登录失败锁定、90天密码有效期、30分钟会话超时、
强制改密、登录审计日志、屏幕水印、企业背景图、
备案信息固定底部、favicon、登录页JS修复等全部改动
2026-09-08 21:19:47 +08:00

608 lines
23 KiB
PHP

<?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;
}
}