ReminderService.php 24 KB

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