* @author Georg Ehrke * @author Joas Schilling * @author Roeland Jago Douma * @author Thomas Citharel * @author Richard Steinmetz * * @license GNU AGPL version 3 or any later version * * 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 . * */ namespace OCA\DAV\CalDAV\Reminder; use DateTimeImmutable; use DateTimeZone; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\Connector\Sabre\Principal; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; use OCP\IUserManager; use Psr\Log\LoggerInterface; use Sabre\VObject; use Sabre\VObject\Component\VAlarm; use Sabre\VObject\Component\VEvent; use Sabre\VObject\InvalidDataException; use Sabre\VObject\ParseException; use Sabre\VObject\Recur\EventIterator; use Sabre\VObject\Recur\MaxInstancesExceededException; use Sabre\VObject\Recur\NoInstancesException; use function count; use function strcasecmp; 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; /** @var IConfig */ private $config; /** @var LoggerInterface */ private $logger; /** @var Principal */ private $principalConnector; 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 ]; public function __construct(Backend $backend, NotificationProviderManager $notificationProviderManager, IUserManager $userManager, IGroupManager $groupManager, CalDavBackend $caldavBackend, ITimeFactory $timeFactory, IConfig $config, LoggerInterface $logger, Principal $principalConnector) { $this->backend = $backend; $this->notificationProviderManager = $notificationProviderManager; $this->userManager = $userManager; $this->groupManager = $groupManager; $this->caldavBackend = $caldavBackend; $this->timeFactory = $timeFactory; $this->config = $config; $this->logger = $logger; $this->principalConnector = $principalConnector; } /** * Process reminders to activate * * @throws NotificationProvider\ProviderNotAvailableException * @throws NotificationTypeDoesNotExistException */ public function processReminders() :void { $reminders = $this->backend->getRemindersToProcess(); $this->logger->debug('{numReminders} reminders to process', [ 'numReminders' => count($reminders), ]); foreach ($reminders as $reminder) { $calendarData = is_resource($reminder['calendardata']) ? stream_get_contents($reminder['calendardata']) : $reminder['calendardata']; if (!$calendarData) { continue; } $vcalendar = $this->parseCalendarData($calendarData); if (!$vcalendar) { $this->logger->debug('Reminder {id} does not belong to a valid calendar', [ 'id' => $reminder['id'], ]); $this->backend->removeReminder($reminder['id']); continue; } $vevent = $this->getVEventByRecurrenceId($vcalendar, $reminder['recurrence_id'], $reminder['is_recurrence_exception']); if (!$vevent) { $this->logger->debug('Reminder {id} does not belong to a valid event', [ 'id' => $reminder['id'], ]); $this->backend->removeReminder($reminder['id']); continue; } if ($this->wasEventCancelled($vevent)) { $this->logger->debug('Reminder {id} belongs to a cancelled event', [ 'id' => $reminder['id'], ]); $this->deleteOrProcessNext($reminder, $vevent); continue; } if (!$this->notificationProviderManager->hasProvider($reminder['type'])) { $this->logger->debug('Reminder {id} does not belong to a valid notification provider', [ 'id' => $reminder['id'], ]); $this->deleteOrProcessNext($reminder, $vevent); continue; } if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedGroupMembers', 'yes') === 'no') { $users = $this->getAllUsersWithWriteAccessToCalendar($reminder['calendar_id']); } else { $users = []; } $user = $this->getUserFromPrincipalURI($reminder['principaluri']); if ($user) { $users[] = $user; } $userPrincipalEmailAddresses = []; $userPrincipal = $this->principalConnector->getPrincipalByPath($reminder['principaluri']); if ($userPrincipal) { $userPrincipalEmailAddresses = $this->principalConnector->getEmailAddressesOfPrincipal($userPrincipal); } $this->logger->debug('Reminder {id} will be sent to {numUsers} users', [ 'id' => $reminder['id'], 'numUsers' => count($users), ]); $notificationProvider = $this->notificationProviderManager->getProvider($reminder['type']); $notificationProvider->send($vevent, $reminder['displayname'], $userPrincipalEmailAddresses, $users); $this->deleteOrProcessNext($reminder, $vevent); } } /** * @param array $objectData * @throws VObject\InvalidDataException */ public function onCalendarObjectCreate(array $objectData):void { // We only support VEvents for now if (strcasecmp($objectData['component'], 'vevent') !== 0) { return; } $calendarData = is_resource($objectData['calendardata']) ? stream_get_contents($objectData['calendardata']) : $objectData['calendardata']; if (!$calendarData) { return; } $vcalendar = $this->parseCalendarData($calendarData); if (!$vcalendar) { return; } $calendarTimeZone = $this->getCalendarTimeZone((int) $objectData['calendarid']); $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); if (!isset($recurrenceException->VALARM)) { continue; } 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, $calendarTimeZone, $eventHash, $alarmHash, true, true); $this->writeRemindersToDatabase($alarms); } } if ($masterItem) { $processedAlarms = []; $masterAlarms = []; $masterHash = $this->getEventHash($masterItem); if (!isset($masterItem->VALARM)) { return; } 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; } catch (MaxInstancesExceededException $e) { // The event has more than 3500 recurring-instances // so we can ignore it 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; } try { $triggerTime = $valarm->getEffectiveTriggerTime(); /** * @psalm-suppress DocblockTypeContradiction * https://github.com/vimeo/psalm/issues/9244 */ if ($triggerTime->getTimezone() === false || $triggerTime->getTimezone()->getName() === 'UTC') { $triggerTime = new DateTimeImmutable( $triggerTime->format('Y-m-d H:i:s'), $calendarTimeZone ); } } catch (InvalidDataException $e) { continue; } // 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, $calendarTimeZone, $masterHash, $alarmHash, $isRecurring, false); $this->writeRemindersToDatabase($alarms); $processedAlarms[] = $alarmHash; } $iterator->next(); } } } /** * @param array $objectData * @throws VObject\InvalidDataException */ public function onCalendarObjectEdit(array $objectData):void { // TODO - this can be vastly improved // - get cached reminders // - ... $this->onCalendarObjectDelete($objectData); $this->onCalendarObjectCreate($objectData); } /** * @param array $objectData * @throws VObject\InvalidDataException */ public function onCalendarObjectDelete(array $objectData):void { // We only support VEvents for now if (strcasecmp($objectData['component'], 'vevent') !== 0) { return; } $this->backend->cleanRemindersForEvent((int) $objectData['id']); } /** * @param VAlarm $valarm * @param array $objectData * @param DateTimeZone $calendarTimeZone * @param string|null $eventHash * @param string|null $alarmHash * @param bool $isRecurring * @param bool $isRecurrenceException * @return array */ private function getRemindersForVAlarm(VAlarm $valarm, array $objectData, DateTimeZone $calendarTimeZone, 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(); /** * @psalm-suppress DocblockTypeContradiction * https://github.com/vimeo/psalm/issues/9244 */ if ($notificationDate->getTimezone() === false || $notificationDate->getTimezone()->getName() === 'UTC') { $notificationDate = new DateTimeImmutable( $notificationDate->format('Y-m-d H:i:s'), $calendarTimeZone ); } $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(); $calendarTimeZone = $this->getCalendarTimeZone((int) $reminder['calendar_id']); 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; } try { 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'], ], $calendarTimeZone, $reminder['event_hash'], $alarmHash, true, false); $this->writeRemindersToDatabase($alarms); // Abort generating reminders after creating one successfully return; } $iterator->next(); } } catch (MaxInstancesExceededException $e) { // Using debug logger as this isn't really an error $this->logger->debug('Recurrence with too many instances detected, skipping VEVENT', ['exception' => $e]); } $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]; } } elseif ($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; } // Ignore invalid events with no DTSTART if ($child->DTSTART === null) { 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); } /** * @param int $calendarid * * @return DateTimeZone */ private function getCalendarTimeZone(int $calendarid): DateTimeZone { $calendarInfo = $this->caldavBackend->getCalendarById($calendarid); $tzProp = '{urn:ietf:params:xml:ns:caldav}calendar-timezone'; if (!isset($calendarInfo[$tzProp])) { // Defaulting to UTC return new DateTimeZone('UTC'); } // This property contains a VCALENDAR with a single VTIMEZONE /** @var string $timezoneProp */ $timezoneProp = $calendarInfo[$tzProp]; /** @var VObject\Component\VCalendar $vtimezoneObj */ $vtimezoneObj = VObject\Reader::read($timezoneProp); /** @var VObject\Component\VTimeZone $vtimezone */ $vtimezone = $vtimezoneObj->VTIMEZONE; return $vtimezone->getTimeZone(); } }