diff --git a/lib/kolab_sync_data_calendar.php b/lib/kolab_sync_data_calendar.php index a2a6fd9..1ae2bf9 100644 --- a/lib/kolab_sync_data_calendar.php +++ b/lib/kolab_sync_data_calendar.php @@ -1,1346 +1,1350 @@ | | | | This program is free software: you can redistribute it and/or modify | | it under the terms of the GNU Affero General Public License as published | | by the Free Software Foundation, either version 3 of the License, or | | (at your option) any later version. | | | | This program is distributed in the hope that it will be useful, | | but WITHOUT ANY WARRANTY; without even the implied warranty of | | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | | GNU Affero General Public License for more details. | | | | You should have received a copy of the GNU Affero General Public License | | along with this program. If not, see | +--------------------------------------------------------------------------+ | Author: Aleksander Machniak | +--------------------------------------------------------------------------+ */ /** * Calendar (Events) data class for Syncroton */ class kolab_sync_data_calendar extends kolab_sync_data implements Syncroton_Data_IDataCalendar { /** * Mapping from ActiveSync Calendar namespace fields */ protected $mapping = [ 'allDayEvent' => 'allday', 'startTime' => 'start', // keep it before endTime here //'attendees' => 'attendees', 'body' => 'description', //'bodyTruncated' => 'bodytruncated', 'busyStatus' => 'free_busy', //'categories' => 'categories', 'dtStamp' => 'changed', 'endTime' => 'end', //'exceptions' => 'exceptions', 'location' => 'location', //'meetingStatus' => 'meetingstatus', //'organizerEmail' => 'organizeremail', //'organizerName' => 'organizername', //'recurrence' => 'recurrence', //'reminder' => 'reminder', //'responseRequested' => 'responserequested', //'responseType' => 'responsetype', 'sensitivity' => 'sensitivity', 'subject' => 'title', //'timezone' => 'timezone', 'uID' => 'uid', ]; /** * Kolab object type * * @var string */ protected $modelName = 'event'; /** * Type of the default folder * * @var int */ protected $defaultFolderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR; /** * Default container for new entries * * @var string */ protected $defaultFolder = 'Calendar'; /** * Type of user created folders * * @var int */ protected $folderType = Syncroton_Command_FolderSync::FOLDERTYPE_CALENDAR_USER_CREATED; /** * attendee status */ public const ATTENDEE_STATUS_UNKNOWN = 0; public const ATTENDEE_STATUS_TENTATIVE = 2; public const ATTENDEE_STATUS_ACCEPTED = 3; public const ATTENDEE_STATUS_DECLINED = 4; public const ATTENDEE_STATUS_NOTRESPONDED = 5; /** * attendee types */ public const ATTENDEE_TYPE_REQUIRED = 1; public const ATTENDEE_TYPE_OPTIONAL = 2; public const ATTENDEE_TYPE_RESOURCE = 3; /** * busy status constants */ public const BUSY_STATUS_FREE = 0; public const BUSY_STATUS_TENTATIVE = 1; public const BUSY_STATUS_BUSY = 2; public const BUSY_STATUS_OUTOFOFFICE = 3; /** * Sensitivity values */ public const SENSITIVITY_NORMAL = 0; public const SENSITIVITY_PERSONAL = 1; public const SENSITIVITY_PRIVATE = 2; public const SENSITIVITY_CONFIDENTIAL = 3; /** * Internal iTip states */ public const ITIP_ACCEPTED = 'ACCEPTED'; public const ITIP_DECLINED = 'DECLINED'; public const ITIP_TENTATIVE = 'TENTATIVE'; public const ITIP_CANCELLED = 'CANCELLED'; public const KEY_DTSTAMP = 'x-custom.X-ACTIVESYNC-DTSTAMP'; public const KEY_REPLYTIME = 'x-custom.X-ACTIVESYNC-REPLYTIME'; /** * Mapping of attendee status * * @var array */ protected $attendeeStatusMap = [ 'UNKNOWN' => self::ATTENDEE_STATUS_UNKNOWN, 'TENTATIVE' => self::ATTENDEE_STATUS_TENTATIVE, 'ACCEPTED' => self::ATTENDEE_STATUS_ACCEPTED, 'DECLINED' => self::ATTENDEE_STATUS_DECLINED, 'DELEGATED' => self::ATTENDEE_STATUS_UNKNOWN, 'NEEDS-ACTION' => self::ATTENDEE_STATUS_NOTRESPONDED, ]; /** * Mapping of attendee type * * NOTE: recurrences need extra handling! * @var array */ protected $attendeeTypeMap = [ 'REQ-PARTICIPANT' => self::ATTENDEE_TYPE_REQUIRED, 'OPT-PARTICIPANT' => self::ATTENDEE_TYPE_OPTIONAL, // 'NON-PARTICIPANT' => self::ATTENDEE_TYPE_RESOURCE, // 'CHAIR' => self::ATTENDEE_TYPE_RESOURCE, ]; /** * Mapping of busy status * * @var array */ protected $busyStatusMap = [ 'free' => self::BUSY_STATUS_FREE, 'tentative' => self::BUSY_STATUS_TENTATIVE, 'busy' => self::BUSY_STATUS_BUSY, 'outofoffice' => self::BUSY_STATUS_OUTOFOFFICE, ]; /** * mapping of sensitivity * * @var array */ protected $sensitivityMap = [ 'public' => self::SENSITIVITY_PERSONAL, 'private' => self::SENSITIVITY_PRIVATE, 'confidential' => self::SENSITIVITY_CONFIDENTIAL, ]; /** * Appends contact data to xml element * * @param Syncroton_Model_SyncCollection $collection Collection data * @param string $serverId Local entry identifier * @param bool $as_array Return entry as array * * @return array|Syncroton_Model_Event Event object */ public function getEntry(Syncroton_Model_SyncCollection $collection, $serverId, $as_array = false) { $event = is_array($serverId) ? $serverId : $this->getObject($collection->collectionId, $serverId); $config = !empty($event['folderId']) ? $this->getFolderConfig($event['folderId']) : []; $result = []; $is_outlook = stripos($this->device->devicetype, 'outlook') !== false; $is_android = stripos($this->device->devicetype, 'android') !== false; // Kolab Format 3.0 and xCal does support timezone per-date, but ActiveSync allows // only one timezone per-event. We'll use timezone of the start date $result['timezone'] = kolab_sync_timezone_converter::encodeTimezoneFromDate($event['start']); // Calendar namespace fields foreach ($this->mapping as $key => $name) { $value = $this->getKolabDataItem($event, $name); switch ($name) { case 'changed': case 'end': case 'start': // For all-day events Kolab uses different times // At least Android doesn't display such event as all-day event if ($value && is_a($value, 'DateTime')) { $date = clone $value; if (!empty($event['allday'])) { // need this for self::date_from_kolab() $date->_dateonly = false; // @phpstan-ignore-line if ($name == 'start') { $date->setTime(0, 0, 0); } elseif ($name == 'end') { $date->setTime(0, 0, 0); $date->modify('+1 day'); } } // set this date for use in recurrence exceptions handling if ($name == 'start') { $event['_start'] = $date; } $value = self::date_from_kolab($date); } break; case 'sensitivity': if (!empty($value)) { $value = intval($this->sensitivityMap[$value]); } break; case 'free_busy': if (!empty($value)) { $value = $this->busyStatusMap[$value]; } break; case 'description': $value = $this->body_from_kolab($value, $collection); break; } // Ignore empty values (but not integer 0) if ((empty($value) || is_array($value)) && $value !== 0) { continue; } $result[$key] = $value; } // Event reminder time if (!empty($config['ALARMS'])) { $result['reminder'] = $this->from_kolab_alarm($event); } $result['categories'] = []; $result['attendees'] = []; // Categories, Roundcube Calendar plugin supports only one category at a time if (!empty($event['categories'])) { $result['categories'] = (array) $event['categories']; } // Organizer if (!empty($event['attendees'])) { foreach ($event['attendees'] as $idx => $attendee) { if ($attendee['role'] == 'ORGANIZER') { if (!empty($attendee['name'])) { $result['organizerName'] = $attendee['name']; } if (!empty($attendee['email'])) { $result['organizerEmail'] = $attendee['email']; } unset($event['attendees'][$idx]); break; } } } $resp_type = self::ATTENDEE_STATUS_UNKNOWN; $user_rsvp = false; // Attendees if (!empty($event['attendees'])) { $user_emails = $this->user_emails(); foreach ($event['attendees'] as $idx => $attendee) { if (empty($attendee['email'])) { // In Activesync email is required continue; } $email = $attendee['email']; $att = [ 'email' => $email, 'name' => !empty($attendee['name']) ? $attendee['name'] : $email, ]; $type = isset($attendee['role']) ? $this->attendeeTypeMap[$attendee['role']] : null; $status = isset($attendee['status']) ? $this->attendeeStatusMap[$attendee['status']] : null; if ($this->asversion >= 12) { if (isset($attendee['cutype']) && strtolower($attendee['cutype']) == 'resource') { $att['attendeeType'] = self::ATTENDEE_TYPE_RESOURCE; } else { $att['attendeeType'] = $type ?: self::ATTENDEE_TYPE_REQUIRED; } $att['attendeeStatus'] = $status ?: self::ATTENDEE_STATUS_UNKNOWN; } if (in_array_nocase($email, $user_emails)) { $user_rsvp = !empty($attendee['rsvp']); $resp_type = $status ?: self::ATTENDEE_STATUS_UNKNOWN; // Synchronize the attendee status to the event status to get the same behaviour as outlook. if (($is_outlook || $is_android) && isset($attendee['status'])) { if ($attendee['status'] == 'ACCEPTED') { $result['busyStatus'] = self::BUSY_STATUS_BUSY; } if ($attendee['status'] == 'TENTATIVE') { $result['busyStatus'] = self::BUSY_STATUS_TENTATIVE; } } } $result['attendees'][] = new Syncroton_Model_EventAttendee($att); } } // Event meeting status $this->meeting_status_from_kolab($event, $result); // Recurrence (and exceptions) $this->recurrence_from_kolab($collection, $event, $result); // RSVP status $result['responseRequested'] = $result['meetingStatus'] == 3 && $user_rsvp ? 1 : 0; $result['responseType'] = $result['meetingStatus'] == 3 ? $resp_type : null; // Appointment Reply Time (without it Outlook displays e.g. "Accepted on None") if ($resp_type != self::ATTENDEE_STATUS_UNKNOWN) { if ($reply_time = $this->getKolabDataItem($event, self::KEY_REPLYTIME)) { $result['appointmentReplyTime'] = new DateTime($reply_time, new DateTimeZone('UTC')); } elseif (!empty($event['changed'])) { $reply_time = clone $event['changed']; $reply_time->setTimezone(new DateTimeZone('UTC')); $result['appointmentReplyTime'] = $reply_time; } } return $as_array ? $result : new Syncroton_Model_Event($result); } /** * Convert an event from xml to libkolab array * * @param Syncroton_Model_Event|Syncroton_Model_EventException $data Event or event exception to convert * @param string $folderid Folder identifier * @param array $entry Existing entry * @param DateTimeZone $timezone Timezone of the event * * @return array */ public function toKolab($data, $folderid, $entry = null, $timezone = null) { if (empty($entry) && !empty($data->uID)) { // If we don't have an existing event (not a modification) we nevertheless check for conflicts. // This is necessary so we don't overwrite the server-side copy in case the client did not have it available // when generating an Add command. try { $entry = $this->getObject($folderid, $data->uID); if ($entry) { $this->logger->debug('Found and existing event for UID: ' . $data->uID); } } catch (Exception $e) { // uID is not available on exceptions, so we guard for that and silently ignore. } } $config = $this->getFolderConfig($entry ? $entry['folderId'] : $folderid); $event = !empty($entry) ? $entry : []; $is_exception = $data instanceof Syncroton_Model_EventException; $dummy_tz = str_repeat('A', 230) . '=='; $is_outlook = stripos($this->device->devicetype, 'outlook') !== false; $is_android = stripos($this->device->devicetype, 'android') !== false; // check data validity (of a new event) if (empty($event)) { $this->check_event($data); } if (!empty($event['start']) && ($event['start'] instanceof DateTime)) { $old_timezone = $event['start']->getTimezone(); } // Timezone if (!$timezone && isset($data->timezone) && $data->timezone != $dummy_tz) { $tzc = kolab_sync_timezone_converter::getInstance(); $expected = !empty($old_timezone) ? $old_timezone : kolab_format::$timezone; try { $timezone = $tzc->getTimezone($data->timezone, $expected->getName()); $timezone = new DateTimeZone($timezone); } catch (Exception $e) { $this->logger->warn('Failed to convert the timezone information. UID: ' . $event['uid'] . 'Timezone: ' . $data->timezone); $timezone = null; } } if (empty($timezone)) { $timezone = !empty($old_timezone) ? $old_timezone : new DateTimeZone('UTC'); } $event['allday'] = 0; // Calendar namespace fields foreach ($this->mapping as $key => $name) { // skip UID field, unsupported in event exceptions // we need to do this here, because the next line (data getter) will throw an exception if ($is_exception && $key == 'uID') { continue; } $value = $data->$key; // Skip ghosted (unset) properties, (but make sure 'changed' timestamp is reset) if ($value === null && $name != 'changed') { continue; } switch ($name) { case 'changed': $value = null; break; case 'end': case 'start': if ($timezone && $value) { $value->setTimezone($timezone); } if ($value && $data->allDayEvent) { $value->_dateonly = true; // In ActiveSync all-day event ends on 00:00:00 next day // In Kolab we just ignore the time spec. if ($name == 'end') { $diff = date_diff($event['start'], $value); $value = clone $event['start']; if ($diff->days > 1) { $value->add(new DateInterval('P' . ($diff->days - 1) . 'D')); } } } break; case 'sensitivity': $map = array_flip($this->sensitivityMap); $value = $map[$value] ?? null; break; case 'free_busy': // Outlook sets the busy state to the attendance state, and we don't want to change the event state based on that. // Outlook doesn't have the concept of an event state, so we just ignore this. if ($is_outlook || $is_android) { continue 2; } $map = array_flip($this->busyStatusMap); $value = $map[$value] ?? null; break; case 'description': $value = $this->getBody($value, Syncroton_Model_EmailBody::TYPE_PLAINTEXT); // If description isn't specified keep old description if ($value === null) { continue 2; } break; } $this->setKolabDataItem($event, $name, $value); } // Try to fix allday events from Android // It doesn't set all-day flag but the period is a whole day if (empty($event['allday']) && !empty($event['end']) && !empty($event['start'])) { $interval = @date_diff($event['start'], $event['end']); if ($interval->format('%y%m%d%h%i%s') === '001000') { $event['allday'] = 1; $event['end'] = clone $event['start']; } } // Reminder // @TODO: should alarms be used when importing event from phone? if (!empty($config['ALARMS'])) { $event['valarms'] = $this->to_kolab_alarm($data->reminder, $event); } $attendees = []; $categories = []; // Categories if (isset($data->categories)) { foreach ($data->categories as $category) { $categories[] = $category; } } // Organizer if (!$is_exception) { // Organizer specified if ($organizer_email = $data->organizerEmail) { $attendees[] = [ 'role' => 'ORGANIZER', 'name' => $data->organizerName, 'email' => $organizer_email, ]; } elseif (!empty($event['attendees'])) { // Organizer not specified, use one from the original event if that's an update foreach ($event['attendees'] as $idx => $attendee) { if (!empty($attendee['email']) && !empty($attendee['role']) && $attendee['role'] == 'ORGANIZER') { $organizer_email = $attendee['email']; $attendees[] = [ 'role' => 'ORGANIZER', 'name' => $attendee['name'] ?? '', 'email' => $organizer_email, ]; } } } } // Attendees // Whenever Outlook sends dummy timezone it is an event where the user is an attendee. // In these cases Attendees element is bogus: contains invalid status and does not // contain all attendees. We have to ignore it. if ($is_outlook && !$is_exception && $data->timezone === $dummy_tz) { $this->logger->debug('Dummy outlook update detected, ignoring attendee changes.'); $attendees = $entry['attendees']; } elseif (isset($data->attendees)) { foreach ($data->attendees as $attendee) { if (!empty($organizer_email) && $attendee->email && !strcasecmp($attendee->email, $organizer_email)) { // skip the organizer continue; } $role = false; if (isset($attendee->attendeeType)) { $role = array_search($attendee->attendeeType, $this->attendeeTypeMap); } if ($role === false) { $role = array_search(self::ATTENDEE_TYPE_REQUIRED, $this->attendeeTypeMap); } $_attendee = [ 'role' => $role, 'name' => $attendee->name != $attendee->email ? $attendee->name : '', 'email' => $attendee->email, ]; if (isset($attendee->attendeeType) && $attendee->attendeeType == self::ATTENDEE_TYPE_RESOURCE) { $_attendee['cutype'] = 'RESOURCE'; } if (isset($attendee->attendeeStatus)) { $_attendee['status'] = $attendee->attendeeStatus ? array_search($attendee->attendeeStatus, $this->attendeeStatusMap) : null; if (!$_attendee['status']) { $_attendee['status'] = 'NEEDS-ACTION'; $_attendee['rsvp'] = true; } } elseif (!empty($event['attendees']) && !empty($attendee->email)) { // copy the old attendee status foreach ($event['attendees'] as $old_attendee) { if ($old_attendee['email'] == $_attendee['email'] && isset($old_attendee['status'])) { $_attendee['status'] = $old_attendee['status']; $_attendee['rsvp'] = $old_attendee['rsvp']; break; } } } $attendees[] = $_attendee; } } // Outlook does not send the correct attendee status when changing between accepted and tentative, but it toggles the busyStatus. if ($is_outlook || $is_android) { $status = null; if ($data->busyStatus == self::BUSY_STATUS_BUSY) { $status = "ACCEPTED"; } elseif ($data->busyStatus == self::BUSY_STATUS_TENTATIVE) { $status = "TENTATIVE"; } if ($status) { $this->logger->debug("Updating our attendee status based on the busy status to {$status}."); $emails = $this->user_emails(); $this->find_and_update_attendee_status($attendees, $status, $emails); } } if (!$is_exception) { // Make sure the event has the organizer set if (!$organizer_email && ($identity = kolab_sync::get_instance()->user->get_identity())) { $attendees[] = [ 'role' => 'ORGANIZER', 'name' => $identity['name'], 'email' => $identity['email'], ]; } // recurrence (and exceptions) $event['recurrence'] = $this->recurrence_to_kolab($data, $folderid, $timezone); } $event['attendees'] = $attendees; $event['categories'] = $categories; $event['exceptions'] = $event['recurrence']['EXCEPTIONS'] ?? []; // Bump SEQUENCE number on update (Outlook only). // It's been confirmed that any change of the event that has attendees specified // bumps SEQUENCE number of the event (we can see this in sent iTips). // Unfortunately Outlook also sends an update when no SEQUENCE bump // is needed, e.g. when updating attendee status. // We try our best to bump the SEQUENCE only when expected // @phpstan-ignore-next-line if (!empty($entry) && !$is_exception && !empty($data->attendees) && $data->timezone != $dummy_tz) { if ($last_update = $this->getKolabDataItem($event, self::KEY_DTSTAMP)) { $last_update = new DateTime($last_update); } if (!empty($data->dtStamp) && $data->dtStamp != $last_update) { if ($this->has_significant_changes($event, $entry)) { $event['sequence']++; $this->logger->debug('Found significant changes in the updated event. Bumping SEQUENCE to ' . $event['sequence']); } } } // Because we use last event modification time above, we make sure // the event modification time is not (re)set by the server, // we use the original Outlook's timestamp. if ($is_outlook && !empty($data->dtStamp)) { $this->setKolabDataItem($event, self::KEY_DTSTAMP, $data->dtStamp->format(DateTime::ATOM)); } // This prevents kolab_format code to bump the sequence when not needed if (!isset($event['sequence'])) { $event['sequence'] = 0; } return $event; } /** * Set attendee status for meeting * * @param Syncroton_Model_MeetingResponse $request The meeting response * * @return string ID of new calendar entry */ public function setAttendeeStatus(Syncroton_Model_MeetingResponse $request) { $status_map = [ 1 => 'ACCEPTED', 2 => 'TENTATIVE', 3 => 'DECLINED', ]; $status = $status_map[$request->userResponse] ?? null; if (empty($status)) { throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); } // extract event from the invitation - [$event, $existing] = $this->get_event_from_invitation($request); + try { + [$event, $existing] = $this->get_event_from_invitation($request); + } catch (Exception $e) { + throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); + } /* switch ($status) { case 'ACCEPTED': $event['free_busy'] = 'busy'; break; case 'TENTATIVE': $event['free_busy'] = 'tentative'; break; case 'DECLINED': $event['free_busy'] = 'free'; break; } */ // Store response timestamp for further use $reply_time = new DateTime('now', new DateTimeZone('UTC')); $this->setKolabDataItem($event, self::KEY_REPLYTIME, $reply_time->format('Ymd\THis\Z')); // Update/Save the event if (empty($existing)) { $folderId = $this->save_event($event, $status); // Create SyncState for the new event, so it is not synced twice if ($folderId) { try { $syncBackend = Syncroton_Registry::getSyncStateBackend(); $folderBackend = Syncroton_Registry::getFolderBackend(); $contentBackend = Syncroton_Registry::getContentStateBackend(); $syncFolder = $folderBackend->getFolder($this->device->id, $folderId); $syncState = $syncBackend->getSyncState($this->device->id, $syncFolder->id); $contentBackend->create(new Syncroton_Model_Content([ 'device_id' => $this->device->id, 'folder_id' => $syncFolder->id, 'contentid' => $this->serverId($event['uid'], $folderId), 'creation_time' => $syncState->lastsync, 'creation_synckey' => $syncState->counter, ])); } catch (Exception $e) { // ignore } } } else { $folderId = $this->update_event($event, $existing, $status, $request->instanceId); } if (!$folderId) { throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); } // TODO: ActiveSync version >= 16, send the iTip response. if (isset($request->sendResponse)) { // SendResponse can contain Body to use as email body (can be empty) // TODO: Activesync >= 16.1 proposedStartTime and proposedEndTime. } // FIXME: We should not return an UID when status=DECLINED // as it's expected by the specification. Server // should delete an event in such a case, but we // keep the event copy with appropriate attendee status instead. return $this->serverId($event['uid'], $folderId); } /** * Process an event from an iTip message - update the event in the recipient's calendar * * @param array $event Event data from the iTip * * @return string|null Attendee status from the iTip (self::ITIP_* constant value) */ public function processItipReply($event) { // FIXME: This does not prevent from spoofing, i.e. an iTip message // could be sent by anyone impersonating an organizer or attendee // FIXME: This will not work with Kolab delegation, as we do look // for the event instance in personal folders only (for now) // We also do not use SENT-BY,DELEGATED-TO,DELEGATED-FROM here at all. // FIXME: This is potential performance problem - we update an event // whenever we sync an email message. User can have multiple AC clients // or many iTip messages in INBOX. Should we remember which email was // already processed? // FIXME: Should we check SEQUENCE or something else to prevent // overwriting the attendee status with outdated status (on REPLY)? // Here we're handling CANCEL message, find the event (or occurrence) and remove it if ($event['_method'] == 'CANCEL') { // TODO: Performance: When we're going to delete the event we don't have to fetch it, // we just need to find that it exists and in which folder. if ($existing = $this->find_event_by_uid($event['uid'])) { // Note: Normally we'd just set the event status to canceled, but // ActiveSync clients do not understand that, we have to delete it if (!empty($event['recurrence_date'])) { // A single recurring event occurrence $rec_day = $event['recurrence_date']->format('Ymd'); // Remove the matching RDATE entry if (!empty($existing['recurrence']['RDATE'])) { foreach ($existing['recurrence']['RDATE'] as $j => $rdate) { if ($rdate->format('Ymd') == $rec_day) { unset($existing['recurrence']['RDATE'][$j]); break; } } } // Check EXDATE list, maybe already cancelled if (!empty($existing['recurrence']['EXDATE'])) { foreach ($existing['recurrence']['EXDATE'] as $j => $exdate) { if ($exdate->format('Ymd') == $rec_day) { return self::ITIP_CANCELLED; // skip update } } } else { $existing['recurrence']['EXDATE'] = []; } if (!isset($existing['exceptions'])) { $existing['exceptions'] = []; } if (!empty($existing['exceptions'])) { foreach ($existing['exceptions'] as $i => $exception) { if (libcalendaring::is_recurrence_exception($event, $exception)) { unset($existing['exceptions'][$i]); } } } // Add an exception to the master event $existing['recurrence']['EXDATE'][] = $event['recurrence_date']; // TODO: Handle errors $this->save_event($existing, null); } else { $folder = $this->backend->getFolder($existing['folderId'], $this->device->deviceid, $this->modelName); if ($folder && $folder->valid) { // TODO: Handle errors $folder->delete($event['uid']); } } } return self::ITIP_CANCELLED; } // Here we're handling REPLY message if (empty($event['attendees']) || $event['_method'] != 'REPLY') { return null; } $attendeeStatus = null; $attendeeEmail = null; // Get the attendee/status foreach ($event['attendees'] as $attendee) { if (empty($attendee['role']) || $attendee['role'] != 'ORGANIZER') { if (!empty($attendee['email']) && !empty($attendee['status'])) { // Per iTip spec. there should be only one (non-organizer) attendee here // FIXME: Verify is it realy the case with e.g. Kolab webmail, If not, we should // probably use the message sender from the From: header $attendeeStatus = strtoupper($attendee['status']); $attendeeEmail = $attendee['email']; break; } } } // Find the event (or occurrence) and update it if ($attendeeStatus && ($existing = $this->find_event_by_uid($event['uid']))) { // TODO: We should probably check the SEQUENCE to not reset status to an outdated value if (!empty($event['recurrence_date'])) { // A single recurring event occurrence // Find the exception entry, it should exist, if not ignore if (!empty($existing['exceptions'])) { foreach ($existing['exceptions'] as $i => $exception) { if (!empty($exception['attendees']) && libcalendaring::is_recurrence_exception($event, $exception)) { $attendees = &$existing['exceptions'][$i]['attendees']; break; } } } } elseif (!empty($existing['attendees'])) { $attendees = &$existing['attendees']; } if (isset($attendees)) { $found = $this->find_and_update_attendee_status($attendees, $attendeeStatus, [$attendeeEmail], $changed); if ($found && $changed) { // TODO: error handling $this->save_event($existing, null); } } } return $attendeeStatus; } /** * Get an event from the invitation email or calendar folder */ protected function get_event_from_invitation(Syncroton_Model_MeetingResponse $request) { // Limitation: LongId might be used instead of RequestId, this is not supported if ($request->requestId) { $mail_class = new kolab_sync_data_email($this->device, $this->syncTimeStamp); // Event from an invitation email if ($event = $mail_class->get_invitation_event($request->requestId)) { // find the event in calendar $existing = $this->find_event_by_uid($event['uid']); return [$event, $existing]; } // Event from calendar folder if ($event = $this->getObject($request->collectionId, $request->requestId)) { return [$event, $event]; } throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::INVALID_REQUEST); } throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::MEETING_ERROR); } /** * Find the Kolab event in any (of subscribed personal calendars) folder */ protected function find_event_by_uid($uid) { if (empty($uid)) { return; } // TODO: should we check every existing event folder even if not subscribed for sync? if ($folders = $this->listFolders()) { foreach ($folders as $_folder) { $folder = $this->backend->getFolder($_folder['serverId'], $this->device->deviceid, $this->modelName); if ($folder && $folder->get_namespace() == 'personal' && ($result = $this->backend->getItem($_folder['serverId'], $this->device->deviceid, $this->modelName, $uid)) ) { $result['folderId'] = $_folder['serverId']; return $result; } } } } /** * Wrapper to update an event object */ protected function update_event($event, $old, $status, $instanceId = null) { // TODO: instanceId - DateTime - of the exception to be processed, if not set process all occurrences if ($instanceId) { throw new Syncroton_Exception_Status_MeetingResponse(Syncroton_Exception_Status_MeetingResponse::INVALID_REQUEST); } // A single recurring event occurrence if (!empty($event['recurrence_date'])) { $event['recurrence'] = []; if ($status) { $this->update_attendee_status($event, $status); $status = null; } if (!isset($old['exceptions'])) { $old['exceptions'] = []; } $existing = false; foreach ($old['exceptions'] as $i => $exception) { if (libcalendaring::is_recurrence_exception($event, $exception)) { $old['exceptions'][$i] = $event; $existing = true; } } // TODO: In case organizer first cancelled an occurrence and then invited // an attendee to the same date, and attendee accepts, we should remove EXDATE entry. // FIXME: We have to check with ActiveSync clients whether it is better // to have an exception with DECLINED attendee status, or an EXDATE entry if (!$existing) { $old['exceptions'][] = $event; } } // A main event update elseif (isset($event['sequence']) && $event['sequence'] > $old['sequence']) { // FIXME: Can we be smarter here? Should we update everything? What about e.g. new attendees? // And do we need to check the sequence? $props = ['start', 'end', 'title', 'description', 'location', 'free_busy']; foreach ($props as $prop) { if (isset($event[$prop])) { $old[$prop] = $event[$prop]; } } // Copy new custom properties if (!empty($event['x-custom'])) { foreach ($event['x-custom'] as $key => $val) { $old['x-custom'][$key] = $val; } } } // Updating an existing event is most-likely a response // to an iTip request with bumped SEQUENCE $old['sequence'] = ($old['sequence'] ?? 0) + 1; // Update the event return $this->save_event($old, $status); } /** * Save the Kolab event (create if not exist) * If an event does not exist it will be created in the default folder */ protected function save_event(&$event, $status = null) { $first = null; $default = null; if (!isset($event['folderId'])) { // Find the folder to which we'll save the event if ($folders = $this->listFolders()) { foreach ($folders as $_folder) { $folder = $this->backend->getFolder($_folder['serverId'], $this->device->deviceid, $this->modelName); if ($folder && $folder->get_namespace() == 'personal') { if ($_folder['type'] == 8) { $default = $_folder['serverId']; break; } if (!$first) { $first = $_folder['serverId']; } } } } // TODO: what if the user has no subscribed event folders for this device // should we use any existing event folder even if not subscribed for sync? } if ($status) { $this->update_attendee_status($event, $status); } // TODO: Free/busy trigger? $old_uid = isset($event['folderId']) ? $event['uid'] : null; $folder_id = $event['folderId'] ?? ($default ?? $first); $folder = $this->backend->getFolder($folder_id, $this->device->deviceid, $this->modelName); if (!empty($folder) && $folder->valid && $folder->save($event, $this->modelName, $old_uid)) { return $folder_id; } return false; } /** * Update the attendee status of the user matching $emails */ protected function find_and_update_attendee_status(&$attendees, $status, $emails, &$changed = false) { $found = false; foreach ((array) $attendees as $i => $attendee) { if (!empty($attendee['email']) && (empty($attendee['role']) || $attendee['role'] != 'ORGANIZER') && in_array_nocase($attendee['email'], $emails) ) { $changed = $changed || ($status != ($attendee['status'] ?? '')); $attendees[$i]['status'] = $status; $attendees[$i]['rsvp'] = false; $this->logger->debug('Updating existing attendee: ' . $attendee['email'] . ' status: ' . $status); $found = true; } } return $found; } /** * Update the attendee status of the user */ protected function update_attendee_status(&$event, $status) { $emails = $this->user_emails(); if (!$this->find_and_update_attendee_status($event['attendees'], $status, $emails)) { $this->logger->debug('Adding new attendee ' . $emails[0] . ' status: ' . $status); // Add the user to the attendees list $event['attendees'][] = [ 'role' => 'OPT-PARTICIPANT', 'name' => '', 'email' => $emails[0], 'status' => $status, 'rsvp' => false, ]; } } /** * Returns filter query array according to specified ActiveSync FilterType * * @param int $filter_type Filter type * * @return array Filter query */ protected function filter($filter_type = 0) { $filter = [['type', '=', $this->modelName]]; switch ($filter_type) { case Syncroton_Command_Sync::FILTER_2_WEEKS_BACK: $mod = '-2 weeks'; break; case Syncroton_Command_Sync::FILTER_1_MONTH_BACK: $mod = '-1 month'; break; case Syncroton_Command_Sync::FILTER_3_MONTHS_BACK: $mod = '-3 months'; break; case Syncroton_Command_Sync::FILTER_6_MONTHS_BACK: $mod = '-6 months'; break; } if (!empty($mod)) { $dt = new DateTime('now', new DateTimeZone('UTC')); $dt->modify($mod); $filter[] = ['dtend', '>', $dt]; } return $filter; } /** * Set MeetingStatus according to event data */ protected function meeting_status_from_kolab($event, &$result) { // 0 - The event is an appointment, which has no attendees. // 1 - The event is a meeting and the user is the meeting organizer. // 3 - This event is a meeting, and the user is not the meeting organizer. // 5 - The meeting has been canceled and the user was the meeting organizer. // 7 - The meeting has been canceled. The user was not the meeting organizer. $status = 0; if (!empty($event['attendees'])) { // Find out if the user is an organizer // TODO: Delegation/aliases support $user_emails = $this->user_emails(); $is_organizer = false; if ($event['organizer'] && $event['organizer']['email']) { $is_organizer = in_array_nocase($event['organizer']['email'], $user_emails); } if ($event['status'] == 'CANCELLED') { $status = $is_organizer ? 5 : 7; } else { $status = $is_organizer ? 1 : 3; } } $result['meetingStatus'] = $status; } /** * Converts libkolab alarms spec. into a number of minutes */ protected function from_kolab_alarm($event) { if (isset($event['valarms'])) { foreach ($event['valarms'] as $alarm) { if (in_array($alarm['action'], ['DISPLAY', 'AUDIO'])) { $value = $alarm['trigger']; break; } } } if (!empty($value) && $value instanceof DateTime) { if (!empty($event['start']) && ($interval = $event['start']->diff($value))) { if ($interval->invert && !$interval->m && !$interval->y) { return intval(round($interval->s / 60) + $interval->i + $interval->h * 60 + $interval->d * 60 * 24); } } } elseif (!empty($value) && preg_match('/^([-+]*)[PT]*([0-9]+)([WDHMS])$/', $value, $matches)) { $value = intval($matches[2]); if ($value && $matches[1] != '-') { return null; } switch ($matches[3]) { case 'S': $value = intval(round($value / 60)); break; case 'H': $value *= 60; break; case 'D': $value *= 24 * 60; break; case 'W': $value *= 7 * 24 * 60; break; } return $value; } } /** * Converts ActiveSync reminder into libkolab alarms spec. */ protected function to_kolab_alarm($value, $event) { if ($value === null || $value === '') { return isset($event['valarms']) ? (array) $event['valarms'] : []; } $valarms = []; $unsupported = []; if (!empty($event['valarms'])) { foreach ($event['valarms'] as $alarm) { if (empty($current) && in_array($alarm['action'], ['DISPLAY', 'AUDIO'])) { $current = $alarm; } else { $unsupported[] = $alarm; } } } $valarms[] = [ 'action' => !empty($current['action']) ? $current['action'] : 'DISPLAY', 'description' => !empty($current['description']) ? $current['description'] : '', 'trigger' => sprintf('-PT%dM', $value), ]; if (!empty($unsupported)) { $valarms = array_merge($valarms, $unsupported); } return $valarms; } /** * Sanity checks on event input * * @param Syncroton_Model_Event|Syncroton_Model_EventException &$entry Entry object * * @throws Syncroton_Exception_Status_Sync */ protected function check_event(Syncroton_Model_IEntry &$entry) { // https://msdn.microsoft.com/en-us/library/jj194434(v=exchg.80).aspx $now = new DateTime('now'); $rounded = new DateTime('now'); $min = (int) $rounded->format('i'); $add = $min > 30 ? (60 - $min) : (30 - $min); $rounded->add(new DateInterval('PT' . $add . 'M')); if (empty($entry->startTime) && empty($entry->endTime)) { // use current time rounded to 30 minutes $end = clone $rounded; $end->add(new DateInterval($entry->allDayEvent ? 'P1D' : 'PT30M')); $entry->startTime = $rounded; $entry->endTime = $end; } elseif (empty($entry->startTime)) { if ($entry->endTime < $now || $entry->endTime < $rounded) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM); } $entry->startTime = $rounded; } elseif (empty($entry->endTime)) { if ($entry->startTime < $now) { throw new Syncroton_Exception_Status_Sync(Syncroton_Exception_Status_Sync::INVALID_ITEM); } $rounded->add(new DateInterval($entry->allDayEvent ? 'P1D' : 'PT30M')); $entry->endTime = $rounded; } } /** * Check if the new event version has any significant changes */ protected function has_significant_changes($event, $old) { // Calendar namespace fields foreach (['allday', 'start', 'end', 'location', 'recurrence'] as $key) { if (($event[$key] ?? null) != ($old[$key] ?? null)) { // Comparing recurrence is tricky as there can be differences in default // value handling. Let's try to handle most common cases if ($key == 'recurrence' && $this->fixed_recurrence($event) == $this->fixed_recurrence($old)) { continue; } return true; } } if (count($event['attendees']) != count($old['attendees'])) { return true; } foreach ($event['attendees'] as $idx => $attendee) { $old_attendee = $old['attendees'][$idx]; if ($old_attendee['email'] != $attendee['email'] || ($attendee['role'] != 'ORGANIZER' && $attendee['status'] != $old_attendee['status'] && $attendee['status'] == 'NEEDS-ACTION') ) { return true; } } return false; } /** * Unify recurrence spec. for comparison */ protected function fixed_recurrence($event) { $rec = (array) $event['recurrence']; // Add BYDAY if not exists if (($rec['FREQ'] ?? '') == 'WEEKLY' && empty($rec['BYDAY'])) { $days = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']; $day = $event['start']->format('w'); $rec['BYDAY'] = $days[$day]; } if (empty($rec['INTERVAL'])) { $rec['INTERVAL'] = 1; } ksort($rec); return $rec; } }