ReminderService.php 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\DAV\CalDAV\Reminder;
  8. use DateTimeImmutable;
  9. use DateTimeZone;
  10. use OCA\DAV\CalDAV\CalDavBackend;
  11. use OCA\DAV\Connector\Sabre\Principal;
  12. use OCP\AppFramework\Utility\ITimeFactory;
  13. use OCP\IConfig;
  14. use OCP\IGroup;
  15. use OCP\IGroupManager;
  16. use OCP\IUser;
  17. use OCP\IUserManager;
  18. use Psr\Log\LoggerInterface;
  19. use Sabre\VObject;
  20. use Sabre\VObject\Component\VAlarm;
  21. use Sabre\VObject\Component\VEvent;
  22. use Sabre\VObject\InvalidDataException;
  23. use Sabre\VObject\ParseException;
  24. use Sabre\VObject\Recur\EventIterator;
  25. use Sabre\VObject\Recur\MaxInstancesExceededException;
  26. use Sabre\VObject\Recur\NoInstancesException;
  27. use function count;
  28. use function strcasecmp;
  29. class ReminderService {
  30. /** @var Backend */
  31. private $backend;
  32. /** @var NotificationProviderManager */
  33. private $notificationProviderManager;
  34. /** @var IUserManager */
  35. private $userManager;
  36. /** @var IGroupManager */
  37. private $groupManager;
  38. /** @var CalDavBackend */
  39. private $caldavBackend;
  40. /** @var ITimeFactory */
  41. private $timeFactory;
  42. /** @var IConfig */
  43. private $config;
  44. /** @var LoggerInterface */
  45. private $logger;
  46. /** @var Principal */
  47. private $principalConnector;
  48. public const REMINDER_TYPE_EMAIL = 'EMAIL';
  49. public const REMINDER_TYPE_DISPLAY = 'DISPLAY';
  50. public const REMINDER_TYPE_AUDIO = 'AUDIO';
  51. /**
  52. * @var String[]
  53. *
  54. * Official RFC5545 reminder types
  55. */
  56. public const REMINDER_TYPES = [
  57. self::REMINDER_TYPE_EMAIL,
  58. self::REMINDER_TYPE_DISPLAY,
  59. self::REMINDER_TYPE_AUDIO
  60. ];
  61. public function __construct(Backend $backend,
  62. NotificationProviderManager $notificationProviderManager,
  63. IUserManager $userManager,
  64. IGroupManager $groupManager,
  65. CalDavBackend $caldavBackend,
  66. ITimeFactory $timeFactory,
  67. IConfig $config,
  68. LoggerInterface $logger,
  69. Principal $principalConnector) {
  70. $this->backend = $backend;
  71. $this->notificationProviderManager = $notificationProviderManager;
  72. $this->userManager = $userManager;
  73. $this->groupManager = $groupManager;
  74. $this->caldavBackend = $caldavBackend;
  75. $this->timeFactory = $timeFactory;
  76. $this->config = $config;
  77. $this->logger = $logger;
  78. $this->principalConnector = $principalConnector;
  79. }
  80. /**
  81. * Process reminders to activate
  82. *
  83. * @throws NotificationProvider\ProviderNotAvailableException
  84. * @throws NotificationTypeDoesNotExistException
  85. */
  86. public function processReminders() :void {
  87. $reminders = $this->backend->getRemindersToProcess();
  88. $this->logger->debug('{numReminders} reminders to process', [
  89. 'numReminders' => count($reminders),
  90. ]);
  91. foreach ($reminders as $reminder) {
  92. $calendarData = is_resource($reminder['calendardata'])
  93. ? stream_get_contents($reminder['calendardata'])
  94. : $reminder['calendardata'];
  95. if (!$calendarData) {
  96. continue;
  97. }
  98. $vcalendar = $this->parseCalendarData($calendarData);
  99. if (!$vcalendar) {
  100. $this->logger->debug('Reminder {id} does not belong to a valid calendar', [
  101. 'id' => $reminder['id'],
  102. ]);
  103. $this->backend->removeReminder($reminder['id']);
  104. continue;
  105. }
  106. try {
  107. $vevent = $this->getVEventByRecurrenceId($vcalendar, $reminder['recurrence_id'], $reminder['is_recurrence_exception']);
  108. } catch (MaxInstancesExceededException $e) {
  109. $this->logger->debug('Recurrence with too many instances detected, skipping VEVENT', ['exception' => $e]);
  110. $this->backend->removeReminder($reminder['id']);
  111. continue;
  112. }
  113. if (!$vevent) {
  114. $this->logger->debug('Reminder {id} does not belong to a valid event', [
  115. 'id' => $reminder['id'],
  116. ]);
  117. $this->backend->removeReminder($reminder['id']);
  118. continue;
  119. }
  120. if ($this->wasEventCancelled($vevent)) {
  121. $this->logger->debug('Reminder {id} belongs to a cancelled event', [
  122. 'id' => $reminder['id'],
  123. ]);
  124. $this->deleteOrProcessNext($reminder, $vevent);
  125. continue;
  126. }
  127. if (!$this->notificationProviderManager->hasProvider($reminder['type'])) {
  128. $this->logger->debug('Reminder {id} does not belong to a valid notification provider', [
  129. 'id' => $reminder['id'],
  130. ]);
  131. $this->deleteOrProcessNext($reminder, $vevent);
  132. continue;
  133. }
  134. if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedUsers', 'yes') === 'no') {
  135. $users = $this->getAllUsersWithWriteAccessToCalendar($reminder['calendar_id']);
  136. } else {
  137. $users = [];
  138. }
  139. $user = $this->getUserFromPrincipalURI($reminder['principaluri']);
  140. if ($user) {
  141. $users[] = $user;
  142. }
  143. $userPrincipalEmailAddresses = [];
  144. $userPrincipal = $this->principalConnector->getPrincipalByPath($reminder['principaluri']);
  145. if ($userPrincipal) {
  146. $userPrincipalEmailAddresses = $this->principalConnector->getEmailAddressesOfPrincipal($userPrincipal);
  147. }
  148. $this->logger->debug('Reminder {id} will be sent to {numUsers} users', [
  149. 'id' => $reminder['id'],
  150. 'numUsers' => count($users),
  151. ]);
  152. $notificationProvider = $this->notificationProviderManager->getProvider($reminder['type']);
  153. $notificationProvider->send($vevent, $reminder['displayname'], $userPrincipalEmailAddresses, $users);
  154. $this->deleteOrProcessNext($reminder, $vevent);
  155. }
  156. }
  157. /**
  158. * @param array $objectData
  159. * @throws VObject\InvalidDataException
  160. */
  161. public function onCalendarObjectCreate(array $objectData):void {
  162. // We only support VEvents for now
  163. if (strcasecmp($objectData['component'], 'vevent') !== 0) {
  164. return;
  165. }
  166. $calendarData = is_resource($objectData['calendardata'])
  167. ? stream_get_contents($objectData['calendardata'])
  168. : $objectData['calendardata'];
  169. if (!$calendarData) {
  170. return;
  171. }
  172. $vcalendar = $this->parseCalendarData($calendarData);
  173. if (!$vcalendar) {
  174. return;
  175. }
  176. $calendarTimeZone = $this->getCalendarTimeZone((int) $objectData['calendarid']);
  177. $vevents = $this->getAllVEventsFromVCalendar($vcalendar);
  178. if (count($vevents) === 0) {
  179. return;
  180. }
  181. $uid = (string) $vevents[0]->UID;
  182. $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents);
  183. $masterItem = $this->getMasterItemFromListOfVEvents($vevents);
  184. $now = $this->timeFactory->getDateTime();
  185. $isRecurring = $masterItem ? $this->isRecurring($masterItem) : false;
  186. foreach ($recurrenceExceptions as $recurrenceException) {
  187. $eventHash = $this->getEventHash($recurrenceException);
  188. if (!isset($recurrenceException->VALARM)) {
  189. continue;
  190. }
  191. foreach ($recurrenceException->VALARM as $valarm) {
  192. /** @var VAlarm $valarm */
  193. $alarmHash = $this->getAlarmHash($valarm);
  194. $triggerTime = $valarm->getEffectiveTriggerTime();
  195. $diff = $now->diff($triggerTime);
  196. if ($diff->invert === 1) {
  197. continue;
  198. }
  199. $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone,
  200. $eventHash, $alarmHash, true, true);
  201. $this->writeRemindersToDatabase($alarms);
  202. }
  203. }
  204. if ($masterItem) {
  205. $processedAlarms = [];
  206. $masterAlarms = [];
  207. $masterHash = $this->getEventHash($masterItem);
  208. if (!isset($masterItem->VALARM)) {
  209. return;
  210. }
  211. foreach ($masterItem->VALARM as $valarm) {
  212. $masterAlarms[] = $this->getAlarmHash($valarm);
  213. }
  214. try {
  215. $iterator = new EventIterator($vevents, $uid);
  216. } catch (NoInstancesException $e) {
  217. // This event is recurring, but it doesn't have a single
  218. // instance. We are skipping this event from the output
  219. // entirely.
  220. return;
  221. } catch (MaxInstancesExceededException $e) {
  222. // The event has more than 3500 recurring-instances
  223. // so we can ignore it
  224. return;
  225. }
  226. while ($iterator->valid() && count($processedAlarms) < count($masterAlarms)) {
  227. $event = $iterator->getEventObject();
  228. // Recurrence-exceptions are handled separately, so just ignore them here
  229. if (\in_array($event, $recurrenceExceptions, true)) {
  230. $iterator->next();
  231. continue;
  232. }
  233. foreach ($event->VALARM as $valarm) {
  234. /** @var VAlarm $valarm */
  235. $alarmHash = $this->getAlarmHash($valarm);
  236. if (\in_array($alarmHash, $processedAlarms, true)) {
  237. continue;
  238. }
  239. if (!\in_array((string) $valarm->ACTION, self::REMINDER_TYPES, true)) {
  240. // Action allows x-name, we don't insert reminders
  241. // into the database if they are not standard
  242. $processedAlarms[] = $alarmHash;
  243. continue;
  244. }
  245. try {
  246. $triggerTime = $valarm->getEffectiveTriggerTime();
  247. /**
  248. * @psalm-suppress DocblockTypeContradiction
  249. * https://github.com/vimeo/psalm/issues/9244
  250. */
  251. if ($triggerTime->getTimezone() === false || $triggerTime->getTimezone()->getName() === 'UTC') {
  252. $triggerTime = new DateTimeImmutable(
  253. $triggerTime->format('Y-m-d H:i:s'),
  254. $calendarTimeZone
  255. );
  256. }
  257. } catch (InvalidDataException $e) {
  258. continue;
  259. }
  260. // If effective trigger time is in the past
  261. // just skip and generate for next event
  262. $diff = $now->diff($triggerTime);
  263. if ($diff->invert === 1) {
  264. // If an absolute alarm is in the past,
  265. // just add it to processedAlarms, so
  266. // we don't extend till eternity
  267. if (!$this->isAlarmRelative($valarm)) {
  268. $processedAlarms[] = $alarmHash;
  269. }
  270. continue;
  271. }
  272. $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone, $masterHash, $alarmHash, $isRecurring, false);
  273. $this->writeRemindersToDatabase($alarms);
  274. $processedAlarms[] = $alarmHash;
  275. }
  276. $iterator->next();
  277. }
  278. }
  279. }
  280. /**
  281. * @param array $objectData
  282. * @throws VObject\InvalidDataException
  283. */
  284. public function onCalendarObjectEdit(array $objectData):void {
  285. // TODO - this can be vastly improved
  286. // - get cached reminders
  287. // - ...
  288. $this->onCalendarObjectDelete($objectData);
  289. $this->onCalendarObjectCreate($objectData);
  290. }
  291. /**
  292. * @param array $objectData
  293. * @throws VObject\InvalidDataException
  294. */
  295. public function onCalendarObjectDelete(array $objectData):void {
  296. // We only support VEvents for now
  297. if (strcasecmp($objectData['component'], 'vevent') !== 0) {
  298. return;
  299. }
  300. $this->backend->cleanRemindersForEvent((int) $objectData['id']);
  301. }
  302. /**
  303. * @param VAlarm $valarm
  304. * @param array $objectData
  305. * @param DateTimeZone $calendarTimeZone
  306. * @param string|null $eventHash
  307. * @param string|null $alarmHash
  308. * @param bool $isRecurring
  309. * @param bool $isRecurrenceException
  310. * @return array
  311. */
  312. private function getRemindersForVAlarm(VAlarm $valarm,
  313. array $objectData,
  314. DateTimeZone $calendarTimeZone,
  315. ?string $eventHash = null,
  316. ?string $alarmHash = null,
  317. bool $isRecurring = false,
  318. bool $isRecurrenceException = false):array {
  319. if ($eventHash === null) {
  320. $eventHash = $this->getEventHash($valarm->parent);
  321. }
  322. if ($alarmHash === null) {
  323. $alarmHash = $this->getAlarmHash($valarm);
  324. }
  325. $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($valarm->parent);
  326. $isRelative = $this->isAlarmRelative($valarm);
  327. /** @var DateTimeImmutable $notificationDate */
  328. $notificationDate = $valarm->getEffectiveTriggerTime();
  329. /**
  330. * @psalm-suppress DocblockTypeContradiction
  331. * https://github.com/vimeo/psalm/issues/9244
  332. */
  333. if ($notificationDate->getTimezone() === false || $notificationDate->getTimezone()->getName() === 'UTC') {
  334. $notificationDate = new DateTimeImmutable(
  335. $notificationDate->format('Y-m-d H:i:s'),
  336. $calendarTimeZone
  337. );
  338. }
  339. $clonedNotificationDate = new \DateTime('now', $notificationDate->getTimezone());
  340. $clonedNotificationDate->setTimestamp($notificationDate->getTimestamp());
  341. $alarms = [];
  342. $alarms[] = [
  343. 'calendar_id' => $objectData['calendarid'],
  344. 'object_id' => $objectData['id'],
  345. 'uid' => (string) $valarm->parent->UID,
  346. 'is_recurring' => $isRecurring,
  347. 'recurrence_id' => $recurrenceId,
  348. 'is_recurrence_exception' => $isRecurrenceException,
  349. 'event_hash' => $eventHash,
  350. 'alarm_hash' => $alarmHash,
  351. 'type' => (string) $valarm->ACTION,
  352. 'is_relative' => $isRelative,
  353. 'notification_date' => $notificationDate->getTimestamp(),
  354. 'is_repeat_based' => false,
  355. ];
  356. $repeat = isset($valarm->REPEAT) ? (int) $valarm->REPEAT->getValue() : 0;
  357. for ($i = 0; $i < $repeat; $i++) {
  358. if ($valarm->DURATION === null) {
  359. continue;
  360. }
  361. $clonedNotificationDate->add($valarm->DURATION->getDateInterval());
  362. $alarms[] = [
  363. 'calendar_id' => $objectData['calendarid'],
  364. 'object_id' => $objectData['id'],
  365. 'uid' => (string) $valarm->parent->UID,
  366. 'is_recurring' => $isRecurring,
  367. 'recurrence_id' => $recurrenceId,
  368. 'is_recurrence_exception' => $isRecurrenceException,
  369. 'event_hash' => $eventHash,
  370. 'alarm_hash' => $alarmHash,
  371. 'type' => (string) $valarm->ACTION,
  372. 'is_relative' => $isRelative,
  373. 'notification_date' => $clonedNotificationDate->getTimestamp(),
  374. 'is_repeat_based' => true,
  375. ];
  376. }
  377. return $alarms;
  378. }
  379. /**
  380. * @param array $reminders
  381. */
  382. private function writeRemindersToDatabase(array $reminders): void {
  383. $uniqueReminders = [];
  384. foreach ($reminders as $reminder) {
  385. $key = $reminder['notification_date']. $reminder['event_hash'].$reminder['type'];
  386. if(!isset($uniqueReminders[$key])) {
  387. $uniqueReminders[$key] = $reminder;
  388. }
  389. }
  390. foreach (array_values($uniqueReminders) as $reminder) {
  391. $this->backend->insertReminder(
  392. (int) $reminder['calendar_id'],
  393. (int) $reminder['object_id'],
  394. $reminder['uid'],
  395. $reminder['is_recurring'],
  396. (int) $reminder['recurrence_id'],
  397. $reminder['is_recurrence_exception'],
  398. $reminder['event_hash'],
  399. $reminder['alarm_hash'],
  400. $reminder['type'],
  401. $reminder['is_relative'],
  402. (int) $reminder['notification_date'],
  403. $reminder['is_repeat_based']
  404. );
  405. }
  406. }
  407. /**
  408. * @param array $reminder
  409. * @param VEvent $vevent
  410. */
  411. private function deleteOrProcessNext(array $reminder,
  412. VObject\Component\VEvent $vevent):void {
  413. if ($reminder['is_repeat_based'] ||
  414. !$reminder['is_recurring'] ||
  415. !$reminder['is_relative'] ||
  416. $reminder['is_recurrence_exception']) {
  417. $this->backend->removeReminder($reminder['id']);
  418. return;
  419. }
  420. $vevents = $this->getAllVEventsFromVCalendar($vevent->parent);
  421. $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents);
  422. $now = $this->timeFactory->getDateTime();
  423. $calendarTimeZone = $this->getCalendarTimeZone((int) $reminder['calendar_id']);
  424. try {
  425. $iterator = new EventIterator($vevents, $reminder['uid']);
  426. } catch (NoInstancesException $e) {
  427. // This event is recurring, but it doesn't have a single
  428. // instance. We are skipping this event from the output
  429. // entirely.
  430. return;
  431. }
  432. try {
  433. while ($iterator->valid()) {
  434. $event = $iterator->getEventObject();
  435. // Recurrence-exceptions are handled separately, so just ignore them here
  436. if (\in_array($event, $recurrenceExceptions, true)) {
  437. $iterator->next();
  438. continue;
  439. }
  440. $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($event);
  441. if ($reminder['recurrence_id'] >= $recurrenceId) {
  442. $iterator->next();
  443. continue;
  444. }
  445. foreach ($event->VALARM as $valarm) {
  446. /** @var VAlarm $valarm */
  447. $alarmHash = $this->getAlarmHash($valarm);
  448. if ($alarmHash !== $reminder['alarm_hash']) {
  449. continue;
  450. }
  451. $triggerTime = $valarm->getEffectiveTriggerTime();
  452. // If effective trigger time is in the past
  453. // just skip and generate for next event
  454. $diff = $now->diff($triggerTime);
  455. if ($diff->invert === 1) {
  456. continue;
  457. }
  458. $this->backend->removeReminder($reminder['id']);
  459. $alarms = $this->getRemindersForVAlarm($valarm, [
  460. 'calendarid' => $reminder['calendar_id'],
  461. 'id' => $reminder['object_id'],
  462. ], $calendarTimeZone, $reminder['event_hash'], $alarmHash, true, false);
  463. $this->writeRemindersToDatabase($alarms);
  464. // Abort generating reminders after creating one successfully
  465. return;
  466. }
  467. $iterator->next();
  468. }
  469. } catch (MaxInstancesExceededException $e) {
  470. // Using debug logger as this isn't really an error
  471. $this->logger->debug('Recurrence with too many instances detected, skipping VEVENT', ['exception' => $e]);
  472. }
  473. $this->backend->removeReminder($reminder['id']);
  474. }
  475. /**
  476. * @param int $calendarId
  477. * @return IUser[]
  478. */
  479. private function getAllUsersWithWriteAccessToCalendar(int $calendarId):array {
  480. $shares = $this->caldavBackend->getShares($calendarId);
  481. $users = [];
  482. $userIds = [];
  483. $groups = [];
  484. foreach ($shares as $share) {
  485. // Only consider writable shares
  486. if ($share['readOnly']) {
  487. continue;
  488. }
  489. $principal = explode('/', $share['{http://owncloud.org/ns}principal']);
  490. if ($principal[1] === 'users') {
  491. $user = $this->userManager->get($principal[2]);
  492. if ($user) {
  493. $users[] = $user;
  494. $userIds[] = $principal[2];
  495. }
  496. } elseif ($principal[1] === 'groups') {
  497. $groups[] = $principal[2];
  498. }
  499. }
  500. foreach ($groups as $gid) {
  501. $group = $this->groupManager->get($gid);
  502. if ($group instanceof IGroup) {
  503. foreach ($group->getUsers() as $user) {
  504. if (!\in_array($user->getUID(), $userIds, true)) {
  505. $users[] = $user;
  506. $userIds[] = $user->getUID();
  507. }
  508. }
  509. }
  510. }
  511. return $users;
  512. }
  513. /**
  514. * Gets a hash of the event.
  515. * If the hash changes, we have to update all relative alarms.
  516. *
  517. * @param VEvent $vevent
  518. * @return string
  519. */
  520. private function getEventHash(VEvent $vevent):string {
  521. $properties = [
  522. (string) $vevent->DTSTART->serialize(),
  523. ];
  524. if ($vevent->DTEND) {
  525. $properties[] = (string) $vevent->DTEND->serialize();
  526. }
  527. if ($vevent->DURATION) {
  528. $properties[] = (string) $vevent->DURATION->serialize();
  529. }
  530. if ($vevent->{'RECURRENCE-ID'}) {
  531. $properties[] = (string) $vevent->{'RECURRENCE-ID'}->serialize();
  532. }
  533. if ($vevent->RRULE) {
  534. $properties[] = (string) $vevent->RRULE->serialize();
  535. }
  536. if ($vevent->EXDATE) {
  537. $properties[] = (string) $vevent->EXDATE->serialize();
  538. }
  539. if ($vevent->RDATE) {
  540. $properties[] = (string) $vevent->RDATE->serialize();
  541. }
  542. return md5(implode('::', $properties));
  543. }
  544. /**
  545. * Gets a hash of the alarm.
  546. * If the hash changes, we have to update oc_dav_reminders.
  547. *
  548. * @param VAlarm $valarm
  549. * @return string
  550. */
  551. private function getAlarmHash(VAlarm $valarm):string {
  552. $properties = [
  553. (string) $valarm->ACTION->serialize(),
  554. (string) $valarm->TRIGGER->serialize(),
  555. ];
  556. if ($valarm->DURATION) {
  557. $properties[] = (string) $valarm->DURATION->serialize();
  558. }
  559. if ($valarm->REPEAT) {
  560. $properties[] = (string) $valarm->REPEAT->serialize();
  561. }
  562. return md5(implode('::', $properties));
  563. }
  564. /**
  565. * @param VObject\Component\VCalendar $vcalendar
  566. * @param int $recurrenceId
  567. * @param bool $isRecurrenceException
  568. * @return VEvent|null
  569. */
  570. private function getVEventByRecurrenceId(VObject\Component\VCalendar $vcalendar,
  571. int $recurrenceId,
  572. bool $isRecurrenceException):?VEvent {
  573. $vevents = $this->getAllVEventsFromVCalendar($vcalendar);
  574. if (count($vevents) === 0) {
  575. return null;
  576. }
  577. $uid = (string) $vevents[0]->UID;
  578. $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents);
  579. $masterItem = $this->getMasterItemFromListOfVEvents($vevents);
  580. // Handle recurrence-exceptions first, because recurrence-expansion is expensive
  581. if ($isRecurrenceException) {
  582. foreach ($recurrenceExceptions as $recurrenceException) {
  583. if ($this->getEffectiveRecurrenceIdOfVEvent($recurrenceException) === $recurrenceId) {
  584. return $recurrenceException;
  585. }
  586. }
  587. return null;
  588. }
  589. if ($masterItem) {
  590. try {
  591. $iterator = new EventIterator($vevents, $uid);
  592. } catch (NoInstancesException $e) {
  593. // This event is recurring, but it doesn't have a single
  594. // instance. We are skipping this event from the output
  595. // entirely.
  596. return null;
  597. }
  598. while ($iterator->valid()) {
  599. $event = $iterator->getEventObject();
  600. // Recurrence-exceptions are handled separately, so just ignore them here
  601. if (\in_array($event, $recurrenceExceptions, true)) {
  602. $iterator->next();
  603. continue;
  604. }
  605. if ($this->getEffectiveRecurrenceIdOfVEvent($event) === $recurrenceId) {
  606. return $event;
  607. }
  608. $iterator->next();
  609. }
  610. }
  611. return null;
  612. }
  613. /**
  614. * @param VEvent $vevent
  615. * @return string
  616. */
  617. private function getStatusOfEvent(VEvent $vevent):string {
  618. if ($vevent->STATUS) {
  619. return (string) $vevent->STATUS;
  620. }
  621. // Doesn't say so in the standard,
  622. // but we consider events without a status
  623. // to be confirmed
  624. return 'CONFIRMED';
  625. }
  626. /**
  627. * @param VObject\Component\VEvent $vevent
  628. * @return bool
  629. */
  630. private function wasEventCancelled(VObject\Component\VEvent $vevent):bool {
  631. return $this->getStatusOfEvent($vevent) === 'CANCELLED';
  632. }
  633. /**
  634. * @param string $calendarData
  635. * @return VObject\Component\VCalendar|null
  636. */
  637. private function parseCalendarData(string $calendarData):?VObject\Component\VCalendar {
  638. try {
  639. return VObject\Reader::read($calendarData,
  640. VObject\Reader::OPTION_FORGIVING);
  641. } catch (ParseException $ex) {
  642. return null;
  643. }
  644. }
  645. /**
  646. * @param string $principalUri
  647. * @return IUser|null
  648. */
  649. private function getUserFromPrincipalURI(string $principalUri):?IUser {
  650. if (!$principalUri) {
  651. return null;
  652. }
  653. if (stripos($principalUri, 'principals/users/') !== 0) {
  654. return null;
  655. }
  656. $userId = substr($principalUri, 17);
  657. return $this->userManager->get($userId);
  658. }
  659. /**
  660. * @param VObject\Component\VCalendar $vcalendar
  661. * @return VObject\Component\VEvent[]
  662. */
  663. private function getAllVEventsFromVCalendar(VObject\Component\VCalendar $vcalendar):array {
  664. $vevents = [];
  665. foreach ($vcalendar->children() as $child) {
  666. if (!($child instanceof VObject\Component)) {
  667. continue;
  668. }
  669. if ($child->name !== 'VEVENT') {
  670. continue;
  671. }
  672. // Ignore invalid events with no DTSTART
  673. if ($child->DTSTART === null) {
  674. continue;
  675. }
  676. $vevents[] = $child;
  677. }
  678. return $vevents;
  679. }
  680. /**
  681. * @param array $vevents
  682. * @return VObject\Component\VEvent[]
  683. */
  684. private function getRecurrenceExceptionFromListOfVEvents(array $vevents):array {
  685. return array_values(array_filter($vevents, function (VEvent $vevent) {
  686. return $vevent->{'RECURRENCE-ID'} !== null;
  687. }));
  688. }
  689. /**
  690. * @param array $vevents
  691. * @return VEvent|null
  692. */
  693. private function getMasterItemFromListOfVEvents(array $vevents):?VEvent {
  694. $elements = array_values(array_filter($vevents, function (VEvent $vevent) {
  695. return $vevent->{'RECURRENCE-ID'} === null;
  696. }));
  697. if (count($elements) === 0) {
  698. return null;
  699. }
  700. if (count($elements) > 1) {
  701. throw new \TypeError('Multiple master objects');
  702. }
  703. return $elements[0];
  704. }
  705. /**
  706. * @param VAlarm $valarm
  707. * @return bool
  708. */
  709. private function isAlarmRelative(VAlarm $valarm):bool {
  710. $trigger = $valarm->TRIGGER;
  711. return $trigger instanceof VObject\Property\ICalendar\Duration;
  712. }
  713. /**
  714. * @param VEvent $vevent
  715. * @return int
  716. */
  717. private function getEffectiveRecurrenceIdOfVEvent(VEvent $vevent):int {
  718. if (isset($vevent->{'RECURRENCE-ID'})) {
  719. return $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp();
  720. }
  721. return $vevent->DTSTART->getDateTime()->getTimestamp();
  722. }
  723. /**
  724. * @param VEvent $vevent
  725. * @return bool
  726. */
  727. private function isRecurring(VEvent $vevent):bool {
  728. return isset($vevent->RRULE) || isset($vevent->RDATE);
  729. }
  730. /**
  731. * @param int $calendarid
  732. *
  733. * @return DateTimeZone
  734. */
  735. private function getCalendarTimeZone(int $calendarid): DateTimeZone {
  736. $calendarInfo = $this->caldavBackend->getCalendarById($calendarid);
  737. $tzProp = '{urn:ietf:params:xml:ns:caldav}calendar-timezone';
  738. if (empty($calendarInfo[$tzProp])) {
  739. // Defaulting to UTC
  740. return new DateTimeZone('UTC');
  741. }
  742. // This property contains a VCALENDAR with a single VTIMEZONE
  743. /** @var string $timezoneProp */
  744. $timezoneProp = $calendarInfo[$tzProp];
  745. /** @var VObject\Component\VCalendar $vtimezoneObj */
  746. $vtimezoneObj = VObject\Reader::read($timezoneProp);
  747. /** @var VObject\Component\VTimeZone $vtimezone */
  748. $vtimezone = $vtimezoneObj->VTIMEZONE;
  749. return $vtimezone->getTimeZone();
  750. }
  751. }