Broker.php 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986
  1. <?php
  2. namespace Sabre\VObject\ITip;
  3. use Sabre\VObject\Component\VCalendar;
  4. use Sabre\VObject\DateTimeParser;
  5. use Sabre\VObject\Reader;
  6. use Sabre\VObject\Recur\EventIterator;
  7. /**
  8. * The ITip\Broker class is a utility class that helps with processing
  9. * so-called iTip messages.
  10. *
  11. * iTip is defined in rfc5546, stands for iCalendar Transport-Independent
  12. * Interoperability Protocol, and describes the underlying mechanism for
  13. * using iCalendar for scheduling for for example through email (also known as
  14. * IMip) and CalDAV Scheduling.
  15. *
  16. * This class helps by:
  17. *
  18. * 1. Creating individual invites based on an iCalendar event for each
  19. * attendee.
  20. * 2. Generating invite updates based on an iCalendar update. This may result
  21. * in new invites, updates and cancellations for attendees, if that list
  22. * changed.
  23. * 3. On the receiving end, it can create a local iCalendar event based on
  24. * a received invite.
  25. * 4. It can also process an invite update on a local event, ensuring that any
  26. * overridden properties from attendees are retained.
  27. * 5. It can create a accepted or declined iTip reply based on an invite.
  28. * 6. It can process a reply from an invite and update an events attendee
  29. * status based on a reply.
  30. *
  31. * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
  32. * @author Evert Pot (http://evertpot.com/)
  33. * @license http://sabre.io/license/ Modified BSD License
  34. */
  35. class Broker
  36. {
  37. /**
  38. * This setting determines whether the rules for the SCHEDULE-AGENT
  39. * parameter should be followed.
  40. *
  41. * This is a parameter defined on ATTENDEE properties, introduced by RFC
  42. * 6638. This parameter allows a caldav client to tell the server 'Don't do
  43. * any scheduling operations'.
  44. *
  45. * If this setting is turned on, any attendees with SCHEDULE-AGENT set to
  46. * CLIENT will be ignored. This is the desired behavior for a CalDAV
  47. * server, but if you're writing an iTip application that doesn't deal with
  48. * CalDAV, you may want to ignore this parameter.
  49. *
  50. * @var bool
  51. */
  52. public $scheduleAgentServerRules = true;
  53. /**
  54. * The broker will try during 'parseEvent' figure out whether the change
  55. * was significant.
  56. *
  57. * It uses a few different ways to do this. One of these ways is seeing if
  58. * certain properties changed values. This list of specified here.
  59. *
  60. * This list is taken from:
  61. * * http://tools.ietf.org/html/rfc5546#section-2.1.4
  62. *
  63. * @var string[]
  64. */
  65. public $significantChangeProperties = [
  66. 'DTSTART',
  67. 'DTEND',
  68. 'DURATION',
  69. 'DUE',
  70. 'RRULE',
  71. 'RDATE',
  72. 'EXDATE',
  73. 'STATUS',
  74. ];
  75. /**
  76. * This method is used to process an incoming itip message.
  77. *
  78. * Examples:
  79. *
  80. * 1. A user is an attendee to an event. The organizer sends an updated
  81. * meeting using a new iTip message with METHOD:REQUEST. This function
  82. * will process the message and update the attendee's event accordingly.
  83. *
  84. * 2. The organizer cancelled the event using METHOD:CANCEL. We will update
  85. * the users event to state STATUS:CANCELLED.
  86. *
  87. * 3. An attendee sent a reply to an invite using METHOD:REPLY. We can
  88. * update the organizers event to update the ATTENDEE with its correct
  89. * PARTSTAT.
  90. *
  91. * The $existingObject is updated in-place. If there is no existing object
  92. * (because it's a new invite for example) a new object will be created.
  93. *
  94. * If an existing object does not exist, and the method was CANCEL or
  95. * REPLY, the message effectively gets ignored, and no 'existingObject'
  96. * will be created.
  97. *
  98. * The updated $existingObject is also returned from this function.
  99. *
  100. * If the iTip message was not supported, we will always return false.
  101. *
  102. * @param VCalendar $existingObject
  103. *
  104. * @return VCalendar|null
  105. */
  106. public function processMessage(Message $itipMessage, ?VCalendar $existingObject = null)
  107. {
  108. // We only support events at the moment.
  109. if ('VEVENT' !== $itipMessage->component) {
  110. return false;
  111. }
  112. switch ($itipMessage->method) {
  113. case 'REQUEST':
  114. return $this->processMessageRequest($itipMessage, $existingObject);
  115. case 'CANCEL':
  116. return $this->processMessageCancel($itipMessage, $existingObject);
  117. case 'REPLY':
  118. return $this->processMessageReply($itipMessage, $existingObject);
  119. default:
  120. // Unsupported iTip message
  121. return;
  122. }
  123. return $existingObject;
  124. }
  125. /**
  126. * This function parses a VCALENDAR object and figure out if any messages
  127. * need to be sent.
  128. *
  129. * A VCALENDAR object will be created from the perspective of either an
  130. * attendee, or an organizer. You must pass a string identifying the
  131. * current user, so we can figure out who in the list of attendees or the
  132. * organizer we are sending this message on behalf of.
  133. *
  134. * It's possible to specify the current user as an array, in case the user
  135. * has more than one identifying href (such as multiple emails).
  136. *
  137. * It $oldCalendar is specified, it is assumed that the operation is
  138. * updating an existing event, which means that we need to look at the
  139. * differences between events, and potentially send old attendees
  140. * cancellations, and current attendees updates.
  141. *
  142. * If $calendar is null, but $oldCalendar is specified, we treat the
  143. * operation as if the user has deleted an event. If the user was an
  144. * organizer, this means that we need to send cancellation notices to
  145. * people. If the user was an attendee, we need to make sure that the
  146. * organizer gets the 'declined' message.
  147. *
  148. * @param VCalendar|string $calendar
  149. * @param string|array $userHref
  150. * @param VCalendar|string|null $oldCalendar
  151. *
  152. * @return array
  153. */
  154. public function parseEvent($calendar, $userHref, $oldCalendar = null)
  155. {
  156. if ($oldCalendar) {
  157. if (is_string($oldCalendar)) {
  158. $oldCalendar = Reader::read($oldCalendar);
  159. }
  160. if (!isset($oldCalendar->VEVENT)) {
  161. // We only support events at the moment
  162. return [];
  163. }
  164. $oldEventInfo = $this->parseEventInfo($oldCalendar);
  165. } else {
  166. $oldEventInfo = [
  167. 'organizer' => null,
  168. 'significantChangeHash' => '',
  169. 'attendees' => [],
  170. ];
  171. }
  172. $userHref = (array) $userHref;
  173. if (!is_null($calendar)) {
  174. if (is_string($calendar)) {
  175. $calendar = Reader::read($calendar);
  176. }
  177. if (!isset($calendar->VEVENT)) {
  178. // We only support events at the moment
  179. return [];
  180. }
  181. $eventInfo = $this->parseEventInfo($calendar);
  182. if (!$eventInfo['attendees'] && !$oldEventInfo['attendees']) {
  183. // If there were no attendees on either side of the equation,
  184. // we don't need to do anything.
  185. return [];
  186. }
  187. if (!$eventInfo['organizer'] && !$oldEventInfo['organizer']) {
  188. // There was no organizer before or after the change.
  189. return [];
  190. }
  191. $baseCalendar = $calendar;
  192. // If the new object didn't have an organizer, the organizer
  193. // changed the object from a scheduling object to a non-scheduling
  194. // object. We just copy the info from the old object.
  195. if (!$eventInfo['organizer'] && $oldEventInfo['organizer']) {
  196. $eventInfo['organizer'] = $oldEventInfo['organizer'];
  197. $eventInfo['organizerName'] = $oldEventInfo['organizerName'];
  198. }
  199. } else {
  200. // The calendar object got deleted, we need to process this as a
  201. // cancellation / decline.
  202. if (!$oldCalendar) {
  203. // No old and no new calendar, there's no thing to do.
  204. return [];
  205. }
  206. $eventInfo = $oldEventInfo;
  207. if (in_array($eventInfo['organizer'], $userHref)) {
  208. // This is an organizer deleting the event.
  209. $eventInfo['attendees'] = [];
  210. // Increasing the sequence, but only if the organizer deleted
  211. // the event.
  212. ++$eventInfo['sequence'];
  213. } else {
  214. // This is an attendee deleting the event.
  215. foreach ($eventInfo['attendees'] as $key => $attendee) {
  216. if (in_array($attendee['href'], $userHref)) {
  217. $eventInfo['attendees'][$key]['instances'] = ['master' => ['id' => 'master', 'partstat' => 'DECLINED'],
  218. ];
  219. }
  220. }
  221. }
  222. $baseCalendar = $oldCalendar;
  223. }
  224. if (in_array($eventInfo['organizer'], $userHref)) {
  225. return $this->parseEventForOrganizer($baseCalendar, $eventInfo, $oldEventInfo);
  226. } elseif ($oldCalendar) {
  227. // We need to figure out if the user is an attendee, but we're only
  228. // doing so if there's an oldCalendar, because we only want to
  229. // process updates, not creation of new events.
  230. foreach ($eventInfo['attendees'] as $attendee) {
  231. if (in_array($attendee['href'], $userHref)) {
  232. return $this->parseEventForAttendee($baseCalendar, $eventInfo, $oldEventInfo, $attendee['href']);
  233. }
  234. }
  235. }
  236. return [];
  237. }
  238. /**
  239. * Processes incoming REQUEST messages.
  240. *
  241. * This is message from an organizer, and is either a new event
  242. * invite, or an update to an existing one.
  243. *
  244. * @param VCalendar $existingObject
  245. *
  246. * @return VCalendar|null
  247. */
  248. protected function processMessageRequest(Message $itipMessage, ?VCalendar $existingObject = null)
  249. {
  250. if (!$existingObject) {
  251. // This is a new invite, and we're just going to copy over
  252. // all the components from the invite.
  253. $existingObject = new VCalendar();
  254. foreach ($itipMessage->message->getComponents() as $component) {
  255. $existingObject->add(clone $component);
  256. }
  257. } else {
  258. // We need to update an existing object with all the new
  259. // information. We can just remove all existing components
  260. // and create new ones.
  261. foreach ($existingObject->getComponents() as $component) {
  262. $existingObject->remove($component);
  263. }
  264. foreach ($itipMessage->message->getComponents() as $component) {
  265. $existingObject->add(clone $component);
  266. }
  267. }
  268. return $existingObject;
  269. }
  270. /**
  271. * Processes incoming CANCEL messages.
  272. *
  273. * This is a message from an organizer, and means that either an
  274. * attendee got removed from an event, or an event got cancelled
  275. * altogether.
  276. *
  277. * @param VCalendar $existingObject
  278. *
  279. * @return VCalendar|null
  280. */
  281. protected function processMessageCancel(Message $itipMessage, ?VCalendar $existingObject = null)
  282. {
  283. if (!$existingObject) {
  284. // The event didn't exist in the first place, so we're just
  285. // ignoring this message.
  286. } else {
  287. foreach ($existingObject->VEVENT as $vevent) {
  288. $vevent->STATUS = 'CANCELLED';
  289. $vevent->SEQUENCE = $itipMessage->sequence;
  290. }
  291. }
  292. return $existingObject;
  293. }
  294. /**
  295. * Processes incoming REPLY messages.
  296. *
  297. * The message is a reply. This is for example an attendee telling
  298. * an organizer he accepted the invite, or declined it.
  299. *
  300. * @param VCalendar $existingObject
  301. *
  302. * @return VCalendar|null
  303. */
  304. protected function processMessageReply(Message $itipMessage, ?VCalendar $existingObject = null)
  305. {
  306. // A reply can only be processed based on an existing object.
  307. // If the object is not available, the reply is ignored.
  308. if (!$existingObject) {
  309. return;
  310. }
  311. $instances = [];
  312. $requestStatus = '2.0';
  313. // Finding all the instances the attendee replied to.
  314. foreach ($itipMessage->message->VEVENT as $vevent) {
  315. // Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
  316. // The Unix timestamp will be the same for an event, even if the reply from the attendee
  317. // used a different format/timezone to express the event date-time.
  318. $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
  319. $attendee = $vevent->ATTENDEE;
  320. $instances[$recurId] = $attendee['PARTSTAT']->getValue();
  321. if (isset($vevent->{'REQUEST-STATUS'})) {
  322. $requestStatus = $vevent->{'REQUEST-STATUS'}->getValue();
  323. list($requestStatus) = explode(';', $requestStatus);
  324. }
  325. }
  326. // Now we need to loop through the original organizer event, to find
  327. // all the instances where we have a reply for.
  328. $masterObject = null;
  329. foreach ($existingObject->VEVENT as $vevent) {
  330. // Use the Unix timestamp returned by getTimestamp as a unique identifier for the recurrence.
  331. $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() : 'master';
  332. if ('master' === $recurId) {
  333. $masterObject = $vevent;
  334. }
  335. if (isset($instances[$recurId])) {
  336. $attendeeFound = false;
  337. if (isset($vevent->ATTENDEE)) {
  338. foreach ($vevent->ATTENDEE as $attendee) {
  339. if ($attendee->getValue() === $itipMessage->sender) {
  340. $attendeeFound = true;
  341. $attendee['PARTSTAT'] = $instances[$recurId];
  342. $attendee['SCHEDULE-STATUS'] = $requestStatus;
  343. // Un-setting the RSVP status, because we now know
  344. // that the attendee already replied.
  345. unset($attendee['RSVP']);
  346. break;
  347. }
  348. }
  349. }
  350. if (!$attendeeFound) {
  351. // Adding a new attendee. The iTip documentation calls this
  352. // a party crasher.
  353. $attendee = $vevent->add('ATTENDEE', $itipMessage->sender, [
  354. 'PARTSTAT' => $instances[$recurId],
  355. ]);
  356. if ($itipMessage->senderName) {
  357. $attendee['CN'] = $itipMessage->senderName;
  358. }
  359. }
  360. unset($instances[$recurId]);
  361. }
  362. }
  363. if (!$masterObject) {
  364. // No master object, we can't add new instances.
  365. return;
  366. }
  367. // If we got replies to instances that did not exist in the
  368. // original list, it means that new exceptions must be created.
  369. foreach ($instances as $recurId => $partstat) {
  370. $recurrenceIterator = new EventIterator($existingObject, $itipMessage->uid);
  371. $found = false;
  372. $iterations = 1000;
  373. do {
  374. $newObject = $recurrenceIterator->getEventObject();
  375. $recurrenceIterator->next();
  376. // Compare the Unix timestamp returned by getTimestamp with the previously calculated timestamp.
  377. // If they are the same, then this is a matching recurrence, even though its date-time may have
  378. // been expressed in a different format/timezone.
  379. if (isset($newObject->{'RECURRENCE-ID'}) && $newObject->{'RECURRENCE-ID'}->getDateTime()->getTimestamp() === $recurId) {
  380. $found = true;
  381. }
  382. --$iterations;
  383. } while ($recurrenceIterator->valid() && !$found && $iterations);
  384. // Invalid recurrence id. Skipping this object.
  385. if (!$found) {
  386. continue;
  387. }
  388. unset(
  389. $newObject->RRULE,
  390. $newObject->EXDATE,
  391. $newObject->RDATE
  392. );
  393. $attendeeFound = false;
  394. if (isset($newObject->ATTENDEE)) {
  395. foreach ($newObject->ATTENDEE as $attendee) {
  396. if ($attendee->getValue() === $itipMessage->sender) {
  397. $attendeeFound = true;
  398. $attendee['PARTSTAT'] = $partstat;
  399. break;
  400. }
  401. }
  402. }
  403. if (!$attendeeFound) {
  404. // Adding a new attendee
  405. $attendee = $newObject->add('ATTENDEE', $itipMessage->sender, [
  406. 'PARTSTAT' => $partstat,
  407. ]);
  408. if ($itipMessage->senderName) {
  409. $attendee['CN'] = $itipMessage->senderName;
  410. }
  411. }
  412. $existingObject->add($newObject);
  413. }
  414. return $existingObject;
  415. }
  416. /**
  417. * This method is used in cases where an event got updated, and we
  418. * potentially need to send emails to attendees to let them know of updates
  419. * in the events.
  420. *
  421. * We will detect which attendees got added, which got removed and create
  422. * specific messages for these situations.
  423. *
  424. * @return array
  425. */
  426. protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo)
  427. {
  428. // Merging attendee lists.
  429. $attendees = [];
  430. foreach ($oldEventInfo['attendees'] as $attendee) {
  431. $attendees[$attendee['href']] = [
  432. 'href' => $attendee['href'],
  433. 'oldInstances' => $attendee['instances'],
  434. 'newInstances' => [],
  435. 'name' => $attendee['name'],
  436. 'forceSend' => null,
  437. ];
  438. }
  439. foreach ($eventInfo['attendees'] as $attendee) {
  440. if (isset($attendees[$attendee['href']])) {
  441. $attendees[$attendee['href']]['name'] = $attendee['name'];
  442. $attendees[$attendee['href']]['newInstances'] = $attendee['instances'];
  443. $attendees[$attendee['href']]['forceSend'] = $attendee['forceSend'];
  444. } else {
  445. $attendees[$attendee['href']] = [
  446. 'href' => $attendee['href'],
  447. 'oldInstances' => [],
  448. 'newInstances' => $attendee['instances'],
  449. 'name' => $attendee['name'],
  450. 'forceSend' => $attendee['forceSend'],
  451. ];
  452. }
  453. }
  454. $messages = [];
  455. foreach ($attendees as $attendee) {
  456. // An organizer can also be an attendee. We should not generate any
  457. // messages for those.
  458. if ($attendee['href'] === $eventInfo['organizer']) {
  459. continue;
  460. }
  461. $message = new Message();
  462. $message->uid = $eventInfo['uid'];
  463. $message->component = 'VEVENT';
  464. $message->sequence = $eventInfo['sequence'];
  465. $message->sender = $eventInfo['organizer'];
  466. $message->senderName = $eventInfo['organizerName'];
  467. $message->recipient = $attendee['href'];
  468. $message->recipientName = $attendee['name'];
  469. // Creating the new iCalendar body.
  470. $icalMsg = new VCalendar();
  471. foreach ($calendar->select('VTIMEZONE') as $timezone) {
  472. $icalMsg->add(clone $timezone);
  473. }
  474. if (!$attendee['newInstances'] || 'CANCELLED' === $eventInfo['status']) {
  475. // If there are no instances the attendee is a part of, it means
  476. // the attendee was removed and we need to send them a CANCEL message.
  477. // Also If the meeting STATUS property was changed to CANCELLED
  478. // we need to send the attendee a CANCEL message.
  479. $message->method = 'CANCEL';
  480. $icalMsg->METHOD = $message->method;
  481. $event = $icalMsg->add('VEVENT', [
  482. 'UID' => $message->uid,
  483. 'SEQUENCE' => $message->sequence,
  484. 'DTSTAMP' => gmdate('Ymd\\THis\\Z'),
  485. ]);
  486. if (isset($calendar->VEVENT->SUMMARY)) {
  487. $event->add('SUMMARY', $calendar->VEVENT->SUMMARY->getValue());
  488. }
  489. $event->add(clone $calendar->VEVENT->DTSTART);
  490. if (isset($calendar->VEVENT->DTEND)) {
  491. $event->add(clone $calendar->VEVENT->DTEND);
  492. } elseif (isset($calendar->VEVENT->DURATION)) {
  493. $event->add(clone $calendar->VEVENT->DURATION);
  494. }
  495. $org = $event->add('ORGANIZER', $eventInfo['organizer']);
  496. if ($eventInfo['organizerName']) {
  497. $org['CN'] = $eventInfo['organizerName'];
  498. }
  499. $event->add('ATTENDEE', $attendee['href'], [
  500. 'CN' => $attendee['name'],
  501. ]);
  502. $message->significantChange = true;
  503. } else {
  504. // The attendee gets the updated event body
  505. $message->method = 'REQUEST';
  506. $icalMsg->METHOD = $message->method;
  507. // We need to find out that this change is significant. If it's
  508. // not, systems may opt to not send messages.
  509. //
  510. // We do this based on the 'significantChangeHash' which is
  511. // some value that changes if there's a certain set of
  512. // properties changed in the event, or simply if there's a
  513. // difference in instances that the attendee is invited to.
  514. $oldAttendeeInstances = array_keys($attendee['oldInstances']);
  515. $newAttendeeInstances = array_keys($attendee['newInstances']);
  516. $message->significantChange =
  517. 'REQUEST' === $attendee['forceSend'] ||
  518. count($oldAttendeeInstances) != count($newAttendeeInstances) ||
  519. count(array_diff($oldAttendeeInstances, $newAttendeeInstances)) > 0 ||
  520. $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash'];
  521. foreach ($attendee['newInstances'] as $instanceId => $instanceInfo) {
  522. $currentEvent = clone $eventInfo['instances'][$instanceId];
  523. if ('master' === $instanceId) {
  524. // We need to find a list of events that the attendee
  525. // is not a part of to add to the list of exceptions.
  526. $exceptions = [];
  527. foreach ($eventInfo['instances'] as $instanceId => $vevent) {
  528. if (!isset($attendee['newInstances'][$instanceId])) {
  529. $exceptions[] = $instanceId;
  530. }
  531. }
  532. // If there were exceptions, we need to add it to an
  533. // existing EXDATE property, if it exists.
  534. if ($exceptions) {
  535. if (isset($currentEvent->EXDATE)) {
  536. $currentEvent->EXDATE->setParts(array_merge(
  537. $currentEvent->EXDATE->getParts(),
  538. $exceptions
  539. ));
  540. } else {
  541. $currentEvent->EXDATE = $exceptions;
  542. }
  543. }
  544. // Cleaning up any scheduling information that
  545. // shouldn't be sent along.
  546. unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']);
  547. unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']);
  548. foreach ($currentEvent->ATTENDEE as $attendee) {
  549. unset($attendee['SCHEDULE-FORCE-SEND']);
  550. unset($attendee['SCHEDULE-STATUS']);
  551. // We're adding PARTSTAT=NEEDS-ACTION to ensure that
  552. // iOS shows an "Inbox Item"
  553. if (!isset($attendee['PARTSTAT'])) {
  554. $attendee['PARTSTAT'] = 'NEEDS-ACTION';
  555. }
  556. }
  557. }
  558. $currentEvent->DTSTAMP = gmdate('Ymd\\THis\\Z');
  559. $icalMsg->add($currentEvent);
  560. }
  561. }
  562. $message->message = $icalMsg;
  563. $messages[] = $message;
  564. }
  565. return $messages;
  566. }
  567. /**
  568. * Parse an event update for an attendee.
  569. *
  570. * This function figures out if we need to send a reply to an organizer.
  571. *
  572. * @param string $attendee
  573. *
  574. * @return Message[]
  575. */
  576. protected function parseEventForAttendee(VCalendar $calendar, array $eventInfo, array $oldEventInfo, $attendee)
  577. {
  578. if ($this->scheduleAgentServerRules && 'CLIENT' === $eventInfo['organizerScheduleAgent']) {
  579. return [];
  580. }
  581. // Don't bother generating messages for events that have already been
  582. // cancelled.
  583. if ('CANCELLED' === $eventInfo['status']) {
  584. return [];
  585. }
  586. $oldInstances = !empty($oldEventInfo['attendees'][$attendee]['instances']) ?
  587. $oldEventInfo['attendees'][$attendee]['instances'] :
  588. [];
  589. $instances = [];
  590. foreach ($oldInstances as $instance) {
  591. $instances[$instance['id']] = [
  592. 'id' => $instance['id'],
  593. 'oldstatus' => $instance['partstat'],
  594. 'newstatus' => null,
  595. ];
  596. }
  597. foreach ($eventInfo['attendees'][$attendee]['instances'] as $instance) {
  598. if (isset($instances[$instance['id']])) {
  599. $instances[$instance['id']]['newstatus'] = $instance['partstat'];
  600. } else {
  601. $instances[$instance['id']] = [
  602. 'id' => $instance['id'],
  603. 'oldstatus' => null,
  604. 'newstatus' => $instance['partstat'],
  605. ];
  606. }
  607. }
  608. // We need to also look for differences in EXDATE. If there are new
  609. // items in EXDATE, it means that an attendee deleted instances of an
  610. // event, which means we need to send DECLINED specifically for those
  611. // instances.
  612. // We only need to do that though, if the master event is not declined.
  613. if (isset($instances['master']) && 'DECLINED' !== $instances['master']['newstatus']) {
  614. foreach ($eventInfo['exdate'] as $exDate) {
  615. if (!in_array($exDate, $oldEventInfo['exdate'])) {
  616. if (isset($instances[$exDate])) {
  617. $instances[$exDate]['newstatus'] = 'DECLINED';
  618. } else {
  619. $instances[$exDate] = [
  620. 'id' => $exDate,
  621. 'oldstatus' => null,
  622. 'newstatus' => 'DECLINED',
  623. ];
  624. }
  625. }
  626. }
  627. }
  628. // Gathering a few extra properties for each instance.
  629. foreach ($instances as $recurId => $instanceInfo) {
  630. if (isset($eventInfo['instances'][$recurId])) {
  631. $instances[$recurId]['dtstart'] = clone $eventInfo['instances'][$recurId]->DTSTART;
  632. } else {
  633. $instances[$recurId]['dtstart'] = $recurId;
  634. }
  635. }
  636. $message = new Message();
  637. $message->uid = $eventInfo['uid'];
  638. $message->method = 'REPLY';
  639. $message->component = 'VEVENT';
  640. $message->sequence = $eventInfo['sequence'];
  641. $message->sender = $attendee;
  642. $message->senderName = $eventInfo['attendees'][$attendee]['name'];
  643. $message->recipient = $eventInfo['organizer'];
  644. $message->recipientName = $eventInfo['organizerName'];
  645. $icalMsg = new VCalendar();
  646. $icalMsg->METHOD = 'REPLY';
  647. foreach ($calendar->select('VTIMEZONE') as $timezone) {
  648. $icalMsg->add(clone $timezone);
  649. }
  650. $hasReply = false;
  651. foreach ($instances as $instance) {
  652. if ($instance['oldstatus'] == $instance['newstatus'] && 'REPLY' !== $eventInfo['organizerForceSend']) {
  653. // Skip
  654. continue;
  655. }
  656. $event = $icalMsg->add('VEVENT', [
  657. 'UID' => $message->uid,
  658. 'SEQUENCE' => $message->sequence,
  659. ]);
  660. $summary = isset($calendar->VEVENT->SUMMARY) ? $calendar->VEVENT->SUMMARY->getValue() : '';
  661. // Adding properties from the correct source instance
  662. if (isset($eventInfo['instances'][$instance['id']])) {
  663. $instanceObj = $eventInfo['instances'][$instance['id']];
  664. $event->add(clone $instanceObj->DTSTART);
  665. if (isset($instanceObj->DTEND)) {
  666. $event->add(clone $instanceObj->DTEND);
  667. } elseif (isset($instanceObj->DURATION)) {
  668. $event->add(clone $instanceObj->DURATION);
  669. }
  670. if (isset($instanceObj->SUMMARY)) {
  671. $event->add('SUMMARY', $instanceObj->SUMMARY->getValue());
  672. } elseif ($summary) {
  673. $event->add('SUMMARY', $summary);
  674. }
  675. } else {
  676. // This branch of the code is reached, when a reply is
  677. // generated for an instance of a recurring event, through the
  678. // fact that the instance has disappeared by showing up in
  679. // EXDATE
  680. $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']);
  681. // Treat is as a DATE field
  682. if (strlen($instance['id']) <= 8) {
  683. $event->add('DTSTART', $dt, ['VALUE' => 'DATE']);
  684. } else {
  685. $event->add('DTSTART', $dt);
  686. }
  687. if ($summary) {
  688. $event->add('SUMMARY', $summary);
  689. }
  690. }
  691. if ('master' !== $instance['id']) {
  692. $dt = DateTimeParser::parse($instance['id'], $eventInfo['timezone']);
  693. // Treat is as a DATE field
  694. if (strlen($instance['id']) <= 8) {
  695. $event->add('RECURRENCE-ID', $dt, ['VALUE' => 'DATE']);
  696. } else {
  697. $event->add('RECURRENCE-ID', $dt);
  698. }
  699. }
  700. $organizer = $event->add('ORGANIZER', $message->recipient);
  701. if ($message->recipientName) {
  702. $organizer['CN'] = $message->recipientName;
  703. }
  704. $attendee = $event->add('ATTENDEE', $message->sender, [
  705. 'PARTSTAT' => $instance['newstatus'],
  706. ]);
  707. if ($message->senderName) {
  708. $attendee['CN'] = $message->senderName;
  709. }
  710. $hasReply = true;
  711. }
  712. if ($hasReply) {
  713. $message->message = $icalMsg;
  714. return [$message];
  715. } else {
  716. return [];
  717. }
  718. }
  719. /**
  720. * Returns attendee information and information about instances of an
  721. * event.
  722. *
  723. * Returns an array with the following keys:
  724. *
  725. * 1. uid
  726. * 2. organizer
  727. * 3. organizerName
  728. * 4. organizerScheduleAgent
  729. * 5. organizerForceSend
  730. * 6. instances
  731. * 7. attendees
  732. * 8. sequence
  733. * 9. exdate
  734. * 10. timezone - strictly the timezone on which the recurrence rule is
  735. * based on.
  736. * 11. significantChangeHash
  737. * 12. status
  738. *
  739. * @param VCalendar $calendar
  740. *
  741. * @return array
  742. */
  743. protected function parseEventInfo(?VCalendar $calendar = null)
  744. {
  745. $uid = null;
  746. $organizer = null;
  747. $organizerName = null;
  748. $organizerForceSend = null;
  749. $sequence = null;
  750. $timezone = null;
  751. $status = null;
  752. $organizerScheduleAgent = 'SERVER';
  753. $significantChangeHash = '';
  754. // Now we need to collect a list of attendees, and which instances they
  755. // are a part of.
  756. $attendees = [];
  757. $instances = [];
  758. $exdate = [];
  759. $significantChangeEventProperties = [];
  760. foreach ($calendar->VEVENT as $vevent) {
  761. $eventSignificantChangeHash = '';
  762. $rrule = [];
  763. if (is_null($uid)) {
  764. $uid = $vevent->UID->getValue();
  765. } else {
  766. if ($uid !== $vevent->UID->getValue()) {
  767. throw new ITipException('If a calendar contained more than one event, they must have the same UID.');
  768. }
  769. }
  770. if (!isset($vevent->DTSTART)) {
  771. throw new ITipException('An event MUST have a DTSTART property.');
  772. }
  773. if (isset($vevent->ORGANIZER)) {
  774. if (is_null($organizer)) {
  775. $organizer = $vevent->ORGANIZER->getNormalizedValue();
  776. $organizerName = isset($vevent->ORGANIZER['CN']) ? $vevent->ORGANIZER['CN'] : null;
  777. } else {
  778. if (strtoupper($organizer) !== strtoupper($vevent->ORGANIZER->getNormalizedValue())) {
  779. throw new SameOrganizerForAllComponentsException('Every instance of the event must have the same organizer.');
  780. }
  781. }
  782. $organizerForceSend =
  783. isset($vevent->ORGANIZER['SCHEDULE-FORCE-SEND']) ?
  784. strtoupper($vevent->ORGANIZER['SCHEDULE-FORCE-SEND']) :
  785. null;
  786. $organizerScheduleAgent =
  787. isset($vevent->ORGANIZER['SCHEDULE-AGENT']) ?
  788. strtoupper((string) $vevent->ORGANIZER['SCHEDULE-AGENT']) :
  789. 'SERVER';
  790. }
  791. if (is_null($sequence) && isset($vevent->SEQUENCE)) {
  792. $sequence = $vevent->SEQUENCE->getValue();
  793. }
  794. if (isset($vevent->EXDATE)) {
  795. foreach ($vevent->select('EXDATE') as $val) {
  796. $exdate = array_merge($exdate, $val->getParts());
  797. }
  798. sort($exdate);
  799. }
  800. if (isset($vevent->RRULE)) {
  801. foreach ($vevent->select('RRULE') as $rr) {
  802. foreach ($rr->getParts() as $key => $val) {
  803. // ignore default values (https://github.com/sabre-io/vobject/issues/126)
  804. if ('INTERVAL' === $key && 1 == $val) {
  805. continue;
  806. }
  807. if (is_array($val)) {
  808. $val = implode(',', $val);
  809. }
  810. $rrule[] = "$key=$val";
  811. }
  812. }
  813. sort($rrule);
  814. }
  815. if (isset($vevent->STATUS)) {
  816. $status = strtoupper($vevent->STATUS->getValue());
  817. }
  818. $recurId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->getValue() : 'master';
  819. if (is_null($timezone)) {
  820. if ('master' === $recurId) {
  821. $timezone = $vevent->DTSTART->getDateTime()->getTimeZone();
  822. } else {
  823. $timezone = $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimeZone();
  824. }
  825. }
  826. if (isset($vevent->ATTENDEE)) {
  827. foreach ($vevent->ATTENDEE as $attendee) {
  828. if ($this->scheduleAgentServerRules &&
  829. isset($attendee['SCHEDULE-AGENT']) &&
  830. 'CLIENT' === strtoupper($attendee['SCHEDULE-AGENT']->getValue())
  831. ) {
  832. continue;
  833. }
  834. $partStat =
  835. isset($attendee['PARTSTAT']) ?
  836. strtoupper($attendee['PARTSTAT']) :
  837. 'NEEDS-ACTION';
  838. $forceSend =
  839. isset($attendee['SCHEDULE-FORCE-SEND']) ?
  840. strtoupper($attendee['SCHEDULE-FORCE-SEND']) :
  841. null;
  842. if (isset($attendees[$attendee->getNormalizedValue()])) {
  843. $attendees[$attendee->getNormalizedValue()]['instances'][$recurId] = [
  844. 'id' => $recurId,
  845. 'partstat' => $partStat,
  846. 'forceSend' => $forceSend,
  847. ];
  848. } else {
  849. $attendees[$attendee->getNormalizedValue()] = [
  850. 'href' => $attendee->getNormalizedValue(),
  851. 'instances' => [
  852. $recurId => [
  853. 'id' => $recurId,
  854. 'partstat' => $partStat,
  855. ],
  856. ],
  857. 'name' => isset($attendee['CN']) ? (string) $attendee['CN'] : null,
  858. 'forceSend' => $forceSend,
  859. ];
  860. }
  861. }
  862. $instances[$recurId] = $vevent;
  863. }
  864. foreach ($this->significantChangeProperties as $prop) {
  865. if (isset($vevent->$prop)) {
  866. $propertyValues = $vevent->select($prop);
  867. $eventSignificantChangeHash .= $prop.':';
  868. if ('EXDATE' === $prop) {
  869. $eventSignificantChangeHash .= implode(',', $exdate).';';
  870. } elseif ('RRULE' === $prop) {
  871. $eventSignificantChangeHash .= implode(',', $rrule).';';
  872. } else {
  873. foreach ($propertyValues as $val) {
  874. $eventSignificantChangeHash .= $val->getValue().';';
  875. }
  876. }
  877. }
  878. }
  879. $significantChangeEventProperties[] = $eventSignificantChangeHash;
  880. }
  881. asort($significantChangeEventProperties);
  882. foreach ($significantChangeEventProperties as $eventSignificantChangeHash) {
  883. $significantChangeHash .= $eventSignificantChangeHash;
  884. }
  885. $significantChangeHash = md5($significantChangeHash);
  886. return compact(
  887. 'uid',
  888. 'organizer',
  889. 'organizerName',
  890. 'organizerScheduleAgent',
  891. 'organizerForceSend',
  892. 'instances',
  893. 'attendees',
  894. 'sequence',
  895. 'exdate',
  896. 'timezone',
  897. 'significantChangeHash',
  898. 'status'
  899. );
  900. }
  901. }