* @author Georg Ehrke * * @license AGPL-3.0 * * This code is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, version 3, * as published by the Free Software Foundation. * * 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, version 3, * along with this program. If not, see * */ namespace OCA\DAV\CalDAV\Reminder; use \DateTimeImmutable; use OCA\DAV\CalDAV\CalDavBackend; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; use OCP\IUserManager; use Sabre\VObject; use Sabre\VObject\Component\VAlarm; use Sabre\VObject\Component\VEvent; use Sabre\VObject\ParseException; use Sabre\VObject\Recur\EventIterator; use Sabre\VObject\Recur\NoInstancesException; class ReminderService { /** @var Backend */ private $backend; /** @var NotificationProviderManager */ private $notificationProviderManager; /** @var IUserManager */ private $userManager; /** @var IGroupManager */ private $groupManager; /** @var CalDavBackend */ private $caldavBackend; /** @var ITimeFactory */ private $timeFactory; public const REMINDER_TYPE_EMAIL = 'EMAIL'; public const REMINDER_TYPE_DISPLAY = 'DISPLAY'; public const REMINDER_TYPE_AUDIO = 'AUDIO'; /** * @var String[] * * Official RFC5545 reminder types */ public const REMINDER_TYPES = [ self::REMINDER_TYPE_EMAIL, self::REMINDER_TYPE_DISPLAY, self::REMINDER_TYPE_AUDIO ]; /** * ReminderService constructor. * * @param Backend $backend * @param NotificationProviderManager $notificationProviderManager * @param IUserManager $userManager * @param IGroupManager $groupManager * @param CalDavBackend $caldavBackend * @param ITimeFactory $timeFactory */ public function __construct(Backend $backend, NotificationProviderManager $notificationProviderManager, IUserManager $userManager, IGroupManager $groupManager, CalDavBackend $caldavBackend, ITimeFactory $timeFactory) { $this->backend = $backend; $this->notificationProviderManager = $notificationProviderManager; $this->userManager = $userManager; $this->groupManager = $groupManager; $this->caldavBackend = $caldavBackend; $this->timeFactory = $timeFactory; } /** * Process reminders to activate * * @throws NotificationProvider\ProviderNotAvailableException * @throws NotificationTypeDoesNotExistException */ public function processReminders():void { $reminders = $this->backend->getRemindersToProcess(); foreach($reminders as $reminder) { $vcalendar = $this->parseCalendarData($reminder['calendardata']); if (!$vcalendar) { $this->backend->removeReminder($reminder['id']); continue; } $vevent = $this->getVEventByRecurrenceId($vcalendar, $reminder['recurrence_id'], $reminder['is_recurrence_exception']); if (!$vevent) { $this->backend->removeReminder($reminder['id']); continue; } if ($this->wasEventCancelled($vevent)) { $this->deleteOrProcessNext($reminder, $vevent); continue; } if (!$this->notificationProviderManager->hasProvider($reminder['type'])) { $this->deleteOrProcessNext($reminder, $vevent); continue; } $users = $this->getAllUsersWithWriteAccessToCalendar($reminder['calendar_id']); $user = $this->getUserFromPrincipalURI($reminder['principaluri']); if ($user) { $users[] = $user; } $notificationProvider = $this->notificationProviderManager->getProvider($reminder['type']); $notificationProvider->send($vevent, $reminder['displayname'], $users); $this->deleteOrProcessNext($reminder, $vevent); } } /** * @param string $action * @param array $objectData * @throws VObject\InvalidDataException */ public function onTouchCalendarObject(string $action, array $objectData):void { // We only support VEvents for now if (strcasecmp($objectData['component'], 'vevent') !== 0) { return; } switch($action) { case '\OCA\DAV\CalDAV\CalDavBackend::createCalendarObject': $this->onCalendarObjectCreate($objectData); break; case '\OCA\DAV\CalDAV\CalDavBackend::updateCalendarObject': $this->onCalendarObjectEdit($objectData); break; case '\OCA\DAV\CalDAV\CalDavBackend::deleteCalendarObject': $this->onCalendarObjectDelete($objectData); break; default: break; } } /** * @param array $objectData */ private function onCalendarObjectCreate(array $objectData):void { /** @var VObject\Component\VCalendar $vcalendar */ $vcalendar = $this->parseCalendarData($objectData['calendardata']); if (!$vcalendar) { return; } $vevents = $this->getAllVEventsFromVCalendar($vcalendar); if (count($vevents) === 0) { return; } $uid = (string) $vevents[0]->UID; $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents); $masterItem = $this->getMasterItemFromListOfVEvents($vevents); $now = $this->timeFactory->getDateTime(); $isRecurring = $masterItem ? $this->isRecurring($masterItem) : false; foreach($recurrenceExceptions as $recurrenceException) { $eventHash = $this->getEventHash($recurrenceException); foreach($recurrenceException->VALARM as $valarm) { /** @var VAlarm $valarm */ $alarmHash = $this->getAlarmHash($valarm); $triggerTime = $valarm->getEffectiveTriggerTime(); $diff = $now->diff($triggerTime); if ($diff->invert === 1) { continue; } $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $eventHash, $alarmHash, true, true); $this->writeRemindersToDatabase($alarms); } } if ($masterItem) { $processedAlarms = []; $masterAlarms = []; $masterHash = $this->getEventHash($masterItem); foreach($masterItem->VALARM as $valarm) { $masterAlarms[] = $this->getAlarmHash($valarm); } try { $iterator = new EventIterator($vevents, $uid); } catch (NoInstancesException $e) { // This event is recurring, but it doesn't have a single // instance. We are skipping this event from the output // entirely. return; } while($iterator->valid() && count($processedAlarms) < count($masterAlarms)) { $event = $iterator->getEventObject(); // Recurrence-exceptions are handled separately, so just ignore them here if (\in_array($event, $recurrenceExceptions, true)) { $iterator->next(); continue; } foreach($event->VALARM as $valarm) { /** @var VAlarm $valarm */ $alarmHash = $this->getAlarmHash($valarm); if (\in_array($alarmHash, $processedAlarms, true)) { continue; } if (!\in_array((string) $valarm->ACTION, self::REMINDER_TYPES, true)) { // Action allows x-name, we don't insert reminders // into the database if they are not standard $processedAlarms[] = $alarmHash; continue; } $triggerTime = $valarm->getEffectiveTriggerTime(); // If effective trigger time is in the past // just skip and generate for next event $diff = $now->diff($triggerTime); if ($diff->invert === 1) { // If an absolute alarm is in the past, // just add it to processedAlarms, so // we don't extend till eternity if (!$this->isAlarmRelative($valarm)) { $processedAlarms[] = $alarmHash; } continue; } $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $masterHash, $alarmHash, $isRecurring, false); $this->writeRemindersToDatabase($alarms); $processedAlarms[] = $alarmHash; } $iterator->next(); } } } /** * @param array $objectData */ private function onCalendarObjectEdit(array $objectData):void { // TODO - this can be vastly improved // - get cached reminders // - ... $this->onCalendarObjectDelete($objectData); $this->onCalendarObjectCreate($objectData); } /** * @param array $objectData */ private function onCalendarObjectDelete(array $objectData):void { $this->backend->cleanRemindersForEvent((int) $objectData['id']); } /** * @param VAlarm $valarm * @param array $objectData * @param string|null $eventHash * @param string|null $alarmHash * @param bool $isRecurring * @param bool $isRecurrenceException * @return array */ private function getRemindersForVAlarm(VAlarm $valarm, array $objectData, string $eventHash=null, string $alarmHash=null, bool $isRecurring=false, bool $isRecurrenceException=false):array { if ($eventHash === null) { $eventHash = $this->getEventHash($valarm->parent); } if ($alarmHash === null) { $alarmHash = $this->getAlarmHash($valarm); } $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($valarm->parent); $isRelative = $this->isAlarmRelative($valarm); /** @var DateTimeImmutable $notificationDate */ $notificationDate = $valarm->getEffectiveTriggerTime(); $clonedNotificationDate = new \DateTime('now', $notificationDate->getTimezone()); $clonedNotificationDate->setTimestamp($notificationDate->getTimestamp()); $alarms = []; $alarms[] = [ 'calendar_id' => $objectData['calendarid'], 'object_id' => $objectData['id'], 'uid' => (string) $valarm->parent->UID, 'is_recurring' => $isRecurring, 'recurrence_id' => $recurrenceId, 'is_recurrence_exception' => $isRecurrenceException, 'event_hash' => $eventHash, 'alarm_hash' => $alarmHash, 'type' => (string) $valarm->ACTION, 'is_relative' => $isRelative, 'notification_date' => $notificationDate->getTimestamp(), 'is_repeat_based' => false, ]; $repeat = isset($valarm->REPEAT) ? (int) $valarm->REPEAT->getValue() : 0; for($i = 0; $i < $repeat; $i++) { if ($valarm->DURATION === null) { continue; } $clonedNotificationDate->add($valarm->DURATION->getDateInterval()); $alarms[] = [ 'calendar_id' => $objectData['calendarid'], 'object_id' => $objectData['id'], 'uid' => (string) $valarm->parent->UID, 'is_recurring' => $isRecurring, 'recurrence_id' => $recurrenceId, 'is_recurrence_exception' => $isRecurrenceException, 'event_hash' => $eventHash, 'alarm_hash' => $alarmHash, 'type' => (string) $valarm->ACTION, 'is_relative' => $isRelative, 'notification_date' => $clonedNotificationDate->getTimestamp(), 'is_repeat_based' => true, ]; } return $alarms; } /** * @param array $reminders */ private function writeRemindersToDatabase(array $reminders): void { foreach($reminders as $reminder) { $this->backend->insertReminder( (int) $reminder['calendar_id'], (int) $reminder['object_id'], $reminder['uid'], $reminder['is_recurring'], (int) $reminder['recurrence_id'], $reminder['is_recurrence_exception'], $reminder['event_hash'], $reminder['alarm_hash'], $reminder['type'], $reminder['is_relative'], (int) $reminder['notification_date'], $reminder['is_repeat_based'] ); } } /** * @param array $reminder * @param VEvent $vevent */ private function deleteOrProcessNext(array $reminder, VObject\Component\VEvent $vevent):void { if ($reminder['is_repeat_based'] || !$reminder['is_recurring'] || !$reminder['is_relative'] || $reminder['is_recurrence_exception']) { $this->backend->removeReminder($reminder['id']); return; } $vevents = $this->getAllVEventsFromVCalendar($vevent->parent); $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents); $now = $this->timeFactory->getDateTime(); try { $iterator = new EventIterator($vevents, $reminder['uid']); } catch (NoInstancesException $e) { // This event is recurring, but it doesn't have a single // instance. We are skipping this event from the output // entirely. return; } while($iterator->valid()) { $event = $iterator->getEventObject(); // Recurrence-exceptions are handled separately, so just ignore them here if (\in_array($event, $recurrenceExceptions, true)) { $iterator->next(); continue; } $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($event); if ($reminder['recurrence_id'] >= $recurrenceId) { $iterator->next(); continue; } foreach($event->VALARM as $valarm) { /** @var VAlarm $valarm */ $alarmHash = $this->getAlarmHash($valarm); if ($alarmHash !== $reminder['alarm_hash']) { continue; } $triggerTime = $valarm->getEffectiveTriggerTime(); // If effective trigger time is in the past // just skip and generate for next event $diff = $now->diff($triggerTime); if ($diff->invert === 1) { continue; } $this->backend->removeReminder($reminder['id']); $alarms = $this->getRemindersForVAlarm($valarm, [ 'calendarid' => $reminder['calendar_id'], 'id' => $reminder['object_id'], ], $reminder['event_hash'], $alarmHash, true, false); $this->writeRemindersToDatabase($alarms); // Abort generating reminders after creating one successfully return; } $iterator->next(); } $this->backend->removeReminder($reminder['id']); } /** * @param int $calendarId * @return IUser[] */ private function getAllUsersWithWriteAccessToCalendar(int $calendarId):array { $shares = $this->caldavBackend->getShares($calendarId); $users = []; $userIds = []; $groups = []; foreach ($shares as $share) { // Only consider writable shares if ($share['readOnly']) { continue; } $principal = explode('/', $share['{http://owncloud.org/ns}principal']); if ($principal[1] === 'users') { $user = $this->userManager->get($principal[2]); if ($user) { $users[] = $user; $userIds[] = $principal[2]; } } else if ($principal[1] === 'groups') { $groups[] = $principal[2]; } } foreach ($groups as $gid) { $group = $this->groupManager->get($gid); if ($group instanceof IGroup) { foreach ($group->getUsers() as $user) { if (!\in_array($user->getUID(), $userIds, true)) { $users[] = $user; $userIds[] = $user->getUID(); } } } } return $users; } /** * Gets a hash of the event. * If the hash changes, we have to update all relative alarms. * * @param VEvent $vevent * @return string */ private function getEventHash(VEvent $vevent):string { $properties = [ (string) $vevent->DTSTART->serialize(), ]; if ($vevent->DTEND) { $properties[] = (string) $vevent->DTEND->serialize(); } if ($vevent->DURATION) { $properties[] = (string) $vevent->DURATION->serialize(); } if ($vevent->{'RECURRENCE-ID'}) { $properties[] = (string) $vevent->{'RECURRENCE-ID'}->serialize(); } if ($vevent->RRULE) { $properties[] = (string) $vevent->RRULE->serialize(); } if ($vevent->EXDATE) { $properties[] = (string) $vevent->EXDATE->serialize(); } if ($vevent->RDATE) { $properties[] = (string) $vevent->RDATE->serialize(); } return md5(implode('::', $properties)); } /** * Gets a hash of the alarm. * If the hash changes, we have to update oc_dav_reminders. * * @param VAlarm $valarm * @return string */ private function getAlarmHash(VAlarm $valarm):string { $properties = [ (string) $valarm->ACTION->serialize(), (string) $valarm->TRIGGER->serialize(), ]; if ($valarm->DURATION) { $properties[] = (string) $valarm->DURATION->serialize(); } if ($valarm->REPEAT) { $properties[] = (string) $valarm->REPEAT->serialize(); } return md5(implode('::', $properties)); } /** * @param VObject\Component\VCalendar $vcalendar * @param int $recurrenceId * @param bool $isRecurrenceException * @return VEvent|null */ private function getVEventByRecurrenceId(VObject\Component\VCalendar $vcalendar, int $recurrenceId, bool $isRecurrenceException):?VEvent { $vevents = $this->getAllVEventsFromVCalendar($vcalendar); if (count($vevents) === 0) { return null; } $uid = (string) $vevents[0]->UID; $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents); $masterItem = $this->getMasterItemFromListOfVEvents($vevents); // Handle recurrence-exceptions first, because recurrence-expansion is expensive if ($isRecurrenceException) { foreach($recurrenceExceptions as $recurrenceException) { if ($this->getEffectiveRecurrenceIdOfVEvent($recurrenceException) === $recurrenceId) { return $recurrenceException; } } return null; } if ($masterItem) { try { $iterator = new EventIterator($vevents, $uid); } catch (NoInstancesException $e) { // This event is recurring, but it doesn't have a single // instance. We are skipping this event from the output // entirely. return null; } while ($iterator->valid()) { $event = $iterator->getEventObject(); // Recurrence-exceptions are handled separately, so just ignore them here if (\in_array($event, $recurrenceExceptions, true)) { $iterator->next(); continue; } if ($this->getEffectiveRecurrenceIdOfVEvent($event) === $recurrenceId) { return $event; } $iterator->next(); } } return null; } /** * @param VEvent $vevent * @return string */ private function getStatusOfEvent(VEvent $vevent):string { if ($vevent->STATUS) { return (string) $vevent->STATUS; } // Doesn't say so in the standard, // but we consider events without a status // to be confirmed return 'CONFIRMED'; } /** * @param VObject\Component\VEvent $vevent * @return bool */ private function wasEventCancelled(VObject\Component\VEvent $vevent):bool { return $this->getStatusOfEvent($vevent) === 'CANCELLED'; } /** * @param string $calendarData * @return VObject\Component\VCalendar|null */ private function parseCalendarData(string $calendarData):?VObject\Component\VCalendar { try { return VObject\Reader::read($calendarData, VObject\Reader::OPTION_FORGIVING); } catch(ParseException $ex) { return null; } } /** * @param string $principalUri * @return IUser|null */ private function getUserFromPrincipalURI(string $principalUri):?IUser { if (!$principalUri) { return null; } if (stripos($principalUri, 'principals/users/') !== 0) { return null; } $userId = substr($principalUri, 17); return $this->userManager->get($userId); } /** * @param VObject\Component\VCalendar $vcalendar * @return VObject\Component\VEvent[] */ private function getAllVEventsFromVCalendar(VObject\Component\VCalendar $vcalendar):array { $vevents = []; foreach($vcalendar->children() as $child) { if (!($child instanceof VObject\Component)) { continue; } if ($child->name !== 'VEVENT') { continue; } $vevents[] = $child; } return $vevents; } /** * @param array $vevents * @return VObject\Component\VEvent[] */ private function getRecurrenceExceptionFromListOfVEvents(array $vevents):array { return array_values(array_filter($vevents, function(VEvent $vevent) { return $vevent->{'RECURRENCE-ID'} !== null; })); } /** * @param array $vevents * @return VEvent|null */ private function getMasterItemFromListOfVEvents(array $vevents):?VEvent { $elements = array_values(array_filter($vevents, function(VEvent $vevent) { return $vevent->{'RECURRENCE-ID'} === null; })); if (count($elements) === 0) { return null; } if (count($elements) > 1) { throw new \TypeError('Multiple master objects'); } return $elements[0]; } /** * @param VAlarm $valarm * @return bool */ private function isAlarmRelative(VAlarm $valarm):bool { $trigger = $valarm->TRIGGER; return $trigger instanceof VObject\Property\ICalendar\Duration; } /** * @param VEvent $vevent * @return int */ private function getEffectiveRecurrenceIdOfVEvent(VEvent $vevent):int { if (isset($vevent->{'RECURRENCE-ID'})) { return $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp(); } return $vevent->DTSTART->getDateTime()->getTimestamp(); } /** * @param VEvent $vevent * @return bool */ private function isRecurring(VEvent $vevent):bool { return isset($vevent->RRULE) || isset($vevent->RDATE); } }