Plugin.php 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997
  1. <?php
  2. declare(strict_types=1);
  3. namespace Sabre\CalDAV\Schedule;
  4. use DateTimeZone;
  5. use Sabre\CalDAV\ICalendar;
  6. use Sabre\CalDAV\ICalendarObject;
  7. use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp;
  8. use Sabre\DAV\Exception\BadRequest;
  9. use Sabre\DAV\Exception\Forbidden;
  10. use Sabre\DAV\Exception\NotFound;
  11. use Sabre\DAV\Exception\NotImplemented;
  12. use Sabre\DAV\INode;
  13. use Sabre\DAV\PropFind;
  14. use Sabre\DAV\PropPatch;
  15. use Sabre\DAV\Server;
  16. use Sabre\DAV\ServerPlugin;
  17. use Sabre\DAV\Sharing;
  18. use Sabre\DAV\Xml\Property\LocalHref;
  19. use Sabre\DAVACL;
  20. use Sabre\HTTP\RequestInterface;
  21. use Sabre\HTTP\ResponseInterface;
  22. use Sabre\VObject;
  23. use Sabre\VObject\Component\VCalendar;
  24. use Sabre\VObject\ITip;
  25. use Sabre\VObject\ITip\Message;
  26. use Sabre\VObject\Reader;
  27. /**
  28. * CalDAV scheduling plugin.
  29. * =========================.
  30. *
  31. * This plugin provides the functionality added by the "Scheduling Extensions
  32. * to CalDAV" standard, as defined in RFC6638.
  33. *
  34. * calendar-auto-schedule largely works by intercepting a users request to
  35. * update their local calendar. If a user creates a new event with attendees,
  36. * this plugin is supposed to grab the information from that event, and notify
  37. * the attendees of this.
  38. *
  39. * There's 3 possible transports for this:
  40. * * local delivery
  41. * * delivery through email (iMip)
  42. * * server-to-server delivery (iSchedule)
  43. *
  44. * iMip is simply, because we just need to add the iTip message as an email
  45. * attachment. Local delivery is harder, because we both need to add this same
  46. * message to a local DAV inbox, as well as live-update the relevant events.
  47. *
  48. * iSchedule is something for later.
  49. *
  50. * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
  51. * @author Evert Pot (http://evertpot.com/)
  52. * @license http://sabre.io/license/ Modified BSD License
  53. */
  54. class Plugin extends ServerPlugin
  55. {
  56. /**
  57. * This is the official CalDAV namespace.
  58. */
  59. const NS_CALDAV = 'urn:ietf:params:xml:ns:caldav';
  60. /**
  61. * Reference to main Server object.
  62. *
  63. * @var Server
  64. */
  65. protected $server;
  66. /**
  67. * Returns a list of features for the DAV: HTTP header.
  68. *
  69. * @return array
  70. */
  71. public function getFeatures()
  72. {
  73. return ['calendar-auto-schedule', 'calendar-availability'];
  74. }
  75. /**
  76. * Returns the name of the plugin.
  77. *
  78. * Using this name other plugins will be able to access other plugins
  79. * using Server::getPlugin
  80. *
  81. * @return string
  82. */
  83. public function getPluginName()
  84. {
  85. return 'caldav-schedule';
  86. }
  87. /**
  88. * Initializes the plugin.
  89. */
  90. public function initialize(Server $server)
  91. {
  92. $this->server = $server;
  93. $server->on('method:POST', [$this, 'httpPost']);
  94. $server->on('propFind', [$this, 'propFind']);
  95. $server->on('propPatch', [$this, 'propPatch']);
  96. $server->on('calendarObjectChange', [$this, 'calendarObjectChange']);
  97. $server->on('beforeUnbind', [$this, 'beforeUnbind']);
  98. $server->on('schedule', [$this, 'scheduleLocalDelivery']);
  99. $server->on('getSupportedPrivilegeSet', [$this, 'getSupportedPrivilegeSet']);
  100. $ns = '{'.self::NS_CALDAV.'}';
  101. /*
  102. * This information ensures that the {DAV:}resourcetype property has
  103. * the correct values.
  104. */
  105. $server->resourceTypeMapping['\\Sabre\\CalDAV\\Schedule\\IOutbox'] = $ns.'schedule-outbox';
  106. $server->resourceTypeMapping['\\Sabre\\CalDAV\\Schedule\\IInbox'] = $ns.'schedule-inbox';
  107. /*
  108. * Properties we protect are made read-only by the server.
  109. */
  110. array_push($server->protectedProperties,
  111. $ns.'schedule-inbox-URL',
  112. $ns.'schedule-outbox-URL',
  113. $ns.'calendar-user-address-set',
  114. $ns.'calendar-user-type',
  115. $ns.'schedule-default-calendar-URL'
  116. );
  117. }
  118. /**
  119. * Use this method to tell the server this plugin defines additional
  120. * HTTP methods.
  121. *
  122. * This method is passed a uri. It should only return HTTP methods that are
  123. * available for the specified uri.
  124. *
  125. * @param string $uri
  126. *
  127. * @return array
  128. */
  129. public function getHTTPMethods($uri)
  130. {
  131. try {
  132. $node = $this->server->tree->getNodeForPath($uri);
  133. } catch (NotFound $e) {
  134. return [];
  135. }
  136. if ($node instanceof IOutbox) {
  137. return ['POST'];
  138. }
  139. return [];
  140. }
  141. /**
  142. * This method handles POST request for the outbox.
  143. *
  144. * @return bool
  145. */
  146. public function httpPost(RequestInterface $request, ResponseInterface $response)
  147. {
  148. // Checking if this is a text/calendar content type
  149. $contentType = $request->getHeader('Content-Type');
  150. if (!$contentType || 0 !== strpos($contentType, 'text/calendar')) {
  151. return;
  152. }
  153. $path = $request->getPath();
  154. // Checking if we're talking to an outbox
  155. try {
  156. $node = $this->server->tree->getNodeForPath($path);
  157. } catch (NotFound $e) {
  158. return;
  159. }
  160. if (!$node instanceof IOutbox) {
  161. return;
  162. }
  163. $this->server->transactionType = 'post-caldav-outbox';
  164. $this->outboxRequest($node, $request, $response);
  165. // Returning false breaks the event chain and tells the server we've
  166. // handled the request.
  167. return false;
  168. }
  169. /**
  170. * This method handler is invoked during fetching of properties.
  171. *
  172. * We use this event to add calendar-auto-schedule-specific properties.
  173. */
  174. public function propFind(PropFind $propFind, INode $node)
  175. {
  176. if ($node instanceof DAVACL\IPrincipal) {
  177. $caldavPlugin = $this->server->getPlugin('caldav');
  178. $principalUrl = $node->getPrincipalUrl();
  179. // schedule-outbox-URL property
  180. $propFind->handle('{'.self::NS_CALDAV.'}schedule-outbox-URL', function () use ($principalUrl, $caldavPlugin) {
  181. $calendarHomePath = $caldavPlugin->getCalendarHomeForPrincipal($principalUrl);
  182. if (!$calendarHomePath) {
  183. return null;
  184. }
  185. $outboxPath = $calendarHomePath.'/outbox/';
  186. return new LocalHref($outboxPath);
  187. });
  188. // schedule-inbox-URL property
  189. $propFind->handle('{'.self::NS_CALDAV.'}schedule-inbox-URL', function () use ($principalUrl, $caldavPlugin) {
  190. $calendarHomePath = $caldavPlugin->getCalendarHomeForPrincipal($principalUrl);
  191. if (!$calendarHomePath) {
  192. return null;
  193. }
  194. $inboxPath = $calendarHomePath.'/inbox/';
  195. return new LocalHref($inboxPath);
  196. });
  197. $propFind->handle('{'.self::NS_CALDAV.'}schedule-default-calendar-URL', function () use ($principalUrl, $caldavPlugin) {
  198. // We don't support customizing this property yet, so in the
  199. // meantime we just grab the first calendar in the home-set.
  200. $calendarHomePath = $caldavPlugin->getCalendarHomeForPrincipal($principalUrl);
  201. if (!$calendarHomePath) {
  202. return null;
  203. }
  204. $sccs = '{'.self::NS_CALDAV.'}supported-calendar-component-set';
  205. $result = $this->server->getPropertiesForPath($calendarHomePath, [
  206. '{DAV:}resourcetype',
  207. '{DAV:}share-access',
  208. $sccs,
  209. ], 1);
  210. foreach ($result as $child) {
  211. if (!isset($child[200]['{DAV:}resourcetype']) || !$child[200]['{DAV:}resourcetype']->is('{'.self::NS_CALDAV.'}calendar')) {
  212. // Node is either not a calendar
  213. continue;
  214. }
  215. if (isset($child[200]['{DAV:}share-access'])) {
  216. $shareAccess = $child[200]['{DAV:}share-access']->getValue();
  217. if (Sharing\Plugin::ACCESS_NOTSHARED !== $shareAccess && Sharing\Plugin::ACCESS_SHAREDOWNER !== $shareAccess) {
  218. // Node is a shared node, not owned by the relevant
  219. // user.
  220. continue;
  221. }
  222. }
  223. if (!isset($child[200][$sccs]) || in_array('VEVENT', $child[200][$sccs]->getValue())) {
  224. // Either there is no supported-calendar-component-set
  225. // (which is fine) or we found one that supports VEVENT.
  226. return new LocalHref($child['href']);
  227. }
  228. }
  229. });
  230. // The server currently reports every principal to be of type
  231. // 'INDIVIDUAL'
  232. $propFind->handle('{'.self::NS_CALDAV.'}calendar-user-type', function () {
  233. return 'INDIVIDUAL';
  234. });
  235. }
  236. // Mapping the old property to the new property.
  237. $propFind->handle('{http://calendarserver.org/ns/}calendar-availability', function () use ($propFind, $node) {
  238. // In case it wasn't clear, the only difference is that we map the
  239. // old property to a different namespace.
  240. $availProp = '{'.self::NS_CALDAV.'}calendar-availability';
  241. $subPropFind = new PropFind(
  242. $propFind->getPath(),
  243. [$availProp]
  244. );
  245. $this->server->getPropertiesByNode(
  246. $subPropFind,
  247. $node
  248. );
  249. $propFind->set(
  250. '{http://calendarserver.org/ns/}calendar-availability',
  251. $subPropFind->get($availProp),
  252. $subPropFind->getStatus($availProp)
  253. );
  254. });
  255. }
  256. /**
  257. * This method is called during property updates.
  258. *
  259. * @param string $path
  260. */
  261. public function propPatch($path, PropPatch $propPatch)
  262. {
  263. // Mapping the old property to the new property.
  264. $propPatch->handle('{http://calendarserver.org/ns/}calendar-availability', function ($value) use ($path) {
  265. $availProp = '{'.self::NS_CALDAV.'}calendar-availability';
  266. $subPropPatch = new PropPatch([$availProp => $value]);
  267. $this->server->emit('propPatch', [$path, $subPropPatch]);
  268. $subPropPatch->commit();
  269. return $subPropPatch->getResult()[$availProp];
  270. });
  271. }
  272. /**
  273. * This method is triggered whenever there was a calendar object gets
  274. * created or updated.
  275. *
  276. * @param RequestInterface $request HTTP request
  277. * @param ResponseInterface $response HTTP Response
  278. * @param VCalendar $vCal Parsed iCalendar object
  279. * @param mixed $calendarPath Path to calendar collection
  280. * @param mixed $modified the iCalendar object has been touched
  281. * @param mixed $isNew Whether this was a new item or we're updating one
  282. */
  283. public function calendarObjectChange(RequestInterface $request, ResponseInterface $response, VCalendar $vCal, $calendarPath, &$modified, $isNew)
  284. {
  285. if (!$this->scheduleReply($this->server->httpRequest)) {
  286. return;
  287. }
  288. $calendarNode = $this->server->tree->getNodeForPath($calendarPath);
  289. $addresses = $this->getAddressesForPrincipal(
  290. $calendarNode->getOwner()
  291. );
  292. if (!$isNew) {
  293. $node = $this->server->tree->getNodeForPath($request->getPath());
  294. $oldObj = Reader::read($node->get());
  295. } else {
  296. $oldObj = null;
  297. }
  298. $this->processICalendarChange($oldObj, $vCal, $addresses, [], $modified);
  299. if ($oldObj) {
  300. // Destroy circular references so PHP will GC the object.
  301. $oldObj->destroy();
  302. }
  303. }
  304. /**
  305. * This method is responsible for delivering the ITip message.
  306. */
  307. public function deliver(ITip\Message $iTipMessage)
  308. {
  309. $this->server->emit('schedule', [$iTipMessage]);
  310. if (!$iTipMessage->scheduleStatus) {
  311. $iTipMessage->scheduleStatus = '5.2;There was no system capable of delivering the scheduling message';
  312. }
  313. // In case the change was considered 'insignificant', we are going to
  314. // remove any error statuses, if any. See ticket #525.
  315. list($baseCode) = explode('.', $iTipMessage->scheduleStatus);
  316. if (!$iTipMessage->significantChange && in_array($baseCode, ['3', '5'])) {
  317. $iTipMessage->scheduleStatus = null;
  318. }
  319. }
  320. /**
  321. * This method is triggered before a file gets deleted.
  322. *
  323. * We use this event to make sure that when this happens, attendees get
  324. * cancellations, and organizers get 'DECLINED' statuses.
  325. *
  326. * @param string $path
  327. */
  328. public function beforeUnbind($path)
  329. {
  330. // FIXME: We shouldn't trigger this functionality when we're issuing a
  331. // MOVE. This is a hack.
  332. if ('MOVE' === $this->server->httpRequest->getMethod()) {
  333. return;
  334. }
  335. $node = $this->server->tree->getNodeForPath($path);
  336. if (!$node instanceof ICalendarObject || $node instanceof ISchedulingObject) {
  337. return;
  338. }
  339. if (!$this->scheduleReply($this->server->httpRequest)) {
  340. return;
  341. }
  342. $addresses = $this->getAddressesForPrincipal(
  343. $node->getOwner()
  344. );
  345. $broker = new ITip\Broker();
  346. $messages = $broker->parseEvent(null, $addresses, $node->get());
  347. foreach ($messages as $message) {
  348. $this->deliver($message);
  349. }
  350. }
  351. /**
  352. * Event handler for the 'schedule' event.
  353. *
  354. * This handler attempts to look at local accounts to deliver the
  355. * scheduling object.
  356. */
  357. public function scheduleLocalDelivery(ITip\Message $iTipMessage)
  358. {
  359. $aclPlugin = $this->server->getPlugin('acl');
  360. // Local delivery is not available if the ACL plugin is not loaded.
  361. if (!$aclPlugin) {
  362. return;
  363. }
  364. $caldavNS = '{'.self::NS_CALDAV.'}';
  365. $principalUri = $aclPlugin->getPrincipalByUri($iTipMessage->recipient);
  366. if (!$principalUri) {
  367. $iTipMessage->scheduleStatus = '3.7;Could not find principal.';
  368. return;
  369. }
  370. // We found a principal URL, now we need to find its inbox.
  371. // Unfortunately we may not have sufficient privileges to find this, so
  372. // we are temporarily turning off ACL to let this come through.
  373. //
  374. // Once we support PHP 5.5, this should be wrapped in a try..finally
  375. // block so we can ensure that this privilege gets added again after.
  376. $this->server->removeListener('propFind', [$aclPlugin, 'propFind']);
  377. $result = $this->server->getProperties(
  378. $principalUri,
  379. [
  380. '{DAV:}principal-URL',
  381. $caldavNS.'calendar-home-set',
  382. $caldavNS.'schedule-inbox-URL',
  383. $caldavNS.'schedule-default-calendar-URL',
  384. '{http://sabredav.org/ns}email-address',
  385. ]
  386. );
  387. // Re-registering the ACL event
  388. $this->server->on('propFind', [$aclPlugin, 'propFind'], 20);
  389. if (!isset($result[$caldavNS.'schedule-inbox-URL'])) {
  390. $iTipMessage->scheduleStatus = '5.2;Could not find local inbox';
  391. return;
  392. }
  393. if (!isset($result[$caldavNS.'calendar-home-set'])) {
  394. $iTipMessage->scheduleStatus = '5.2;Could not locate a calendar-home-set';
  395. return;
  396. }
  397. if (!isset($result[$caldavNS.'schedule-default-calendar-URL'])) {
  398. $iTipMessage->scheduleStatus = '5.2;Could not find a schedule-default-calendar-URL property';
  399. return;
  400. }
  401. $calendarPath = $result[$caldavNS.'schedule-default-calendar-URL']->getHref();
  402. $homePath = $result[$caldavNS.'calendar-home-set']->getHref();
  403. $inboxPath = $result[$caldavNS.'schedule-inbox-URL']->getHref();
  404. if ('REPLY' === $iTipMessage->method) {
  405. $privilege = 'schedule-deliver-reply';
  406. } else {
  407. $privilege = 'schedule-deliver-invite';
  408. }
  409. if (!$aclPlugin->checkPrivileges($inboxPath, $caldavNS.$privilege, DAVACL\Plugin::R_PARENT, false)) {
  410. $iTipMessage->scheduleStatus = '3.8;insufficient privileges: '.$privilege.' is required on the recipient schedule inbox.';
  411. return;
  412. }
  413. // Next, we're going to find out if the item already exits in one of
  414. // the users' calendars.
  415. $uid = $iTipMessage->uid;
  416. $newFileName = 'sabredav-'.\Sabre\DAV\UUIDUtil::getUUID().'.ics';
  417. $home = $this->server->tree->getNodeForPath($homePath);
  418. $inbox = $this->server->tree->getNodeForPath($inboxPath);
  419. $currentObject = null;
  420. $objectNode = null;
  421. $oldICalendarData = null;
  422. $isNewNode = false;
  423. $result = $home->getCalendarObjectByUID($uid);
  424. if ($result) {
  425. // There was an existing object, we need to update probably.
  426. $objectPath = $homePath.'/'.$result;
  427. $objectNode = $this->server->tree->getNodeForPath($objectPath);
  428. $oldICalendarData = $objectNode->get();
  429. $currentObject = Reader::read($oldICalendarData);
  430. } else {
  431. $isNewNode = true;
  432. }
  433. $broker = new ITip\Broker();
  434. $newObject = $broker->processMessage($iTipMessage, $currentObject);
  435. $inbox->createFile($newFileName, $iTipMessage->message->serialize());
  436. if (!$newObject) {
  437. // We received an iTip message referring to a UID that we don't
  438. // have in any calendars yet, and processMessage did not give us a
  439. // calendarobject back.
  440. //
  441. // The implication is that processMessage did not understand the
  442. // iTip message.
  443. $iTipMessage->scheduleStatus = '5.0;iTip message was not processed by the server, likely because we didn\'t understand it.';
  444. return;
  445. }
  446. // Note that we are bypassing ACL on purpose by calling this directly.
  447. // We may need to look a bit deeper into this later. Supporting ACL
  448. // here would be nice.
  449. if ($isNewNode) {
  450. $calendar = $this->server->tree->getNodeForPath($calendarPath);
  451. $calendar->createFile($newFileName, $newObject->serialize());
  452. } else {
  453. // If the message was a reply, we may have to inform other
  454. // attendees of this attendees status. Therefore we're shooting off
  455. // another itipMessage.
  456. if ('REPLY' === $iTipMessage->method) {
  457. $this->processICalendarChange(
  458. $oldICalendarData,
  459. $newObject,
  460. [$iTipMessage->recipient],
  461. [$iTipMessage->sender]
  462. );
  463. }
  464. $objectNode->put($newObject->serialize());
  465. }
  466. $iTipMessage->scheduleStatus = '1.2;Message delivered locally';
  467. }
  468. /**
  469. * This method is triggered whenever a subsystem requests the privileges
  470. * that are supported on a particular node.
  471. *
  472. * We need to add a number of privileges for scheduling purposes.
  473. */
  474. public function getSupportedPrivilegeSet(INode $node, array &$supportedPrivilegeSet)
  475. {
  476. $ns = '{'.self::NS_CALDAV.'}';
  477. if ($node instanceof IOutbox) {
  478. $supportedPrivilegeSet[$ns.'schedule-send'] = [
  479. 'abstract' => false,
  480. 'aggregates' => [
  481. $ns.'schedule-send-invite' => [
  482. 'abstract' => false,
  483. 'aggregates' => [],
  484. ],
  485. $ns.'schedule-send-reply' => [
  486. 'abstract' => false,
  487. 'aggregates' => [],
  488. ],
  489. $ns.'schedule-send-freebusy' => [
  490. 'abstract' => false,
  491. 'aggregates' => [],
  492. ],
  493. // Privilege from an earlier scheduling draft, but still
  494. // used by some clients.
  495. $ns.'schedule-post-vevent' => [
  496. 'abstract' => false,
  497. 'aggregates' => [],
  498. ],
  499. ],
  500. ];
  501. }
  502. if ($node instanceof IInbox) {
  503. $supportedPrivilegeSet[$ns.'schedule-deliver'] = [
  504. 'abstract' => false,
  505. 'aggregates' => [
  506. $ns.'schedule-deliver-invite' => [
  507. 'abstract' => false,
  508. 'aggregates' => [],
  509. ],
  510. $ns.'schedule-deliver-reply' => [
  511. 'abstract' => false,
  512. 'aggregates' => [],
  513. ],
  514. $ns.'schedule-query-freebusy' => [
  515. 'abstract' => false,
  516. 'aggregates' => [],
  517. ],
  518. ],
  519. ];
  520. }
  521. }
  522. /**
  523. * This method looks at an old iCalendar object, a new iCalendar object and
  524. * starts sending scheduling messages based on the changes.
  525. *
  526. * A list of addresses needs to be specified, so the system knows who made
  527. * the update, because the behavior may be different based on if it's an
  528. * attendee or an organizer.
  529. *
  530. * This method may update $newObject to add any status changes.
  531. *
  532. * @param VCalendar|string|null $oldObject
  533. * @param array $ignore any addresses to not send messages to
  534. * @param bool $modified a marker to indicate that the original object modified by this process
  535. */
  536. protected function processICalendarChange($oldObject, VCalendar $newObject, array $addresses, array $ignore = [], &$modified = false)
  537. {
  538. $broker = new ITip\Broker();
  539. $messages = $broker->parseEvent($newObject, $addresses, $oldObject);
  540. if ($messages) {
  541. $modified = true;
  542. }
  543. foreach ($messages as $message) {
  544. if (in_array($message->recipient, $ignore)) {
  545. continue;
  546. }
  547. $this->deliver($message);
  548. if (isset($newObject->VEVENT->ORGANIZER) && ($newObject->VEVENT->ORGANIZER->getNormalizedValue() === $message->recipient)) {
  549. if ($message->scheduleStatus) {
  550. $newObject->VEVENT->ORGANIZER['SCHEDULE-STATUS'] = $message->getScheduleStatus();
  551. }
  552. unset($newObject->VEVENT->ORGANIZER['SCHEDULE-FORCE-SEND']);
  553. } else {
  554. if (isset($newObject->VEVENT->ATTENDEE)) {
  555. foreach ($newObject->VEVENT->ATTENDEE as $attendee) {
  556. if ($attendee->getNormalizedValue() === $message->recipient) {
  557. if ($message->scheduleStatus) {
  558. $attendee['SCHEDULE-STATUS'] = $message->getScheduleStatus();
  559. }
  560. unset($attendee['SCHEDULE-FORCE-SEND']);
  561. break;
  562. }
  563. }
  564. }
  565. }
  566. }
  567. }
  568. /**
  569. * Returns a list of addresses that are associated with a principal.
  570. *
  571. * @param string $principal
  572. *
  573. * @return array
  574. */
  575. protected function getAddressesForPrincipal($principal)
  576. {
  577. $CUAS = '{'.self::NS_CALDAV.'}calendar-user-address-set';
  578. $properties = $this->server->getProperties(
  579. $principal,
  580. [$CUAS]
  581. );
  582. // If we can't find this information, we'll stop processing
  583. if (!isset($properties[$CUAS])) {
  584. return [];
  585. }
  586. $addresses = $properties[$CUAS]->getHrefs();
  587. return $addresses;
  588. }
  589. /**
  590. * This method handles POST requests to the schedule-outbox.
  591. *
  592. * Currently, two types of requests are supported:
  593. * * FREEBUSY requests from RFC 6638
  594. * * Simple iTIP messages from draft-desruisseaux-caldav-sched-04
  595. *
  596. * The latter is from an expired early draft of the CalDAV scheduling
  597. * extensions, but iCal depends on a feature from that spec, so we
  598. * implement it.
  599. */
  600. public function outboxRequest(IOutbox $outboxNode, RequestInterface $request, ResponseInterface $response)
  601. {
  602. $outboxPath = $request->getPath();
  603. // Parsing the request body
  604. try {
  605. $vObject = VObject\Reader::read($request->getBody());
  606. } catch (VObject\ParseException $e) {
  607. throw new BadRequest('The request body must be a valid iCalendar object. Parse error: '.$e->getMessage());
  608. }
  609. // The incoming iCalendar object must have a METHOD property, and a
  610. // component. The combination of both determines what type of request
  611. // this is.
  612. $componentType = null;
  613. foreach ($vObject->getComponents() as $component) {
  614. if ('VTIMEZONE' !== $component->name) {
  615. $componentType = $component->name;
  616. break;
  617. }
  618. }
  619. if (is_null($componentType)) {
  620. throw new BadRequest('We expected at least one VTODO, VJOURNAL, VFREEBUSY or VEVENT component');
  621. }
  622. // Validating the METHOD
  623. $method = strtoupper((string) $vObject->METHOD);
  624. if (!$method) {
  625. throw new BadRequest('A METHOD property must be specified in iTIP messages');
  626. }
  627. // So we support one type of request:
  628. //
  629. // REQUEST with a VFREEBUSY component
  630. $acl = $this->server->getPlugin('acl');
  631. if ('VFREEBUSY' === $componentType && 'REQUEST' === $method) {
  632. $acl && $acl->checkPrivileges($outboxPath, '{'.self::NS_CALDAV.'}schedule-send-freebusy');
  633. $this->handleFreeBusyRequest($outboxNode, $vObject, $request, $response);
  634. // Destroy circular references so PHP can GC the object.
  635. $vObject->destroy();
  636. unset($vObject);
  637. } else {
  638. throw new NotImplemented('We only support VFREEBUSY (REQUEST) on this endpoint');
  639. }
  640. }
  641. /**
  642. * This method is responsible for parsing a free-busy query request and
  643. * returning its result in $response.
  644. */
  645. protected function handleFreeBusyRequest(IOutbox $outbox, VObject\Component $vObject, RequestInterface $request, ResponseInterface $response)
  646. {
  647. $vFreeBusy = $vObject->VFREEBUSY;
  648. $organizer = $vFreeBusy->ORGANIZER;
  649. $organizer = (string) $organizer;
  650. // Validating if the organizer matches the owner of the inbox.
  651. $owner = $outbox->getOwner();
  652. $caldavNS = '{'.self::NS_CALDAV.'}';
  653. $uas = $caldavNS.'calendar-user-address-set';
  654. $props = $this->server->getProperties($owner, [$uas]);
  655. if (empty($props[$uas]) || !in_array($organizer, $props[$uas]->getHrefs())) {
  656. throw new Forbidden('The organizer in the request did not match any of the addresses for the owner of this inbox');
  657. }
  658. if (!isset($vFreeBusy->ATTENDEE)) {
  659. throw new BadRequest('You must at least specify 1 attendee');
  660. }
  661. $attendees = [];
  662. foreach ($vFreeBusy->ATTENDEE as $attendee) {
  663. $attendees[] = (string) $attendee;
  664. }
  665. if (!isset($vFreeBusy->DTSTART) || !isset($vFreeBusy->DTEND)) {
  666. throw new BadRequest('DTSTART and DTEND must both be specified');
  667. }
  668. $startRange = $vFreeBusy->DTSTART->getDateTime();
  669. $endRange = $vFreeBusy->DTEND->getDateTime();
  670. $results = [];
  671. foreach ($attendees as $attendee) {
  672. $results[] = $this->getFreeBusyForEmail($attendee, $startRange, $endRange, $vObject);
  673. }
  674. $dom = new \DOMDocument('1.0', 'utf-8');
  675. $dom->formatOutput = true;
  676. $scheduleResponse = $dom->createElement('cal:schedule-response');
  677. foreach ($this->server->xml->namespaceMap as $namespace => $prefix) {
  678. $scheduleResponse->setAttribute('xmlns:'.$prefix, $namespace);
  679. }
  680. $dom->appendChild($scheduleResponse);
  681. foreach ($results as $result) {
  682. $xresponse = $dom->createElement('cal:response');
  683. $recipient = $dom->createElement('cal:recipient');
  684. $recipientHref = $dom->createElement('d:href');
  685. $recipientHref->appendChild($dom->createTextNode($result['href']));
  686. $recipient->appendChild($recipientHref);
  687. $xresponse->appendChild($recipient);
  688. $reqStatus = $dom->createElement('cal:request-status');
  689. $reqStatus->appendChild($dom->createTextNode($result['request-status']));
  690. $xresponse->appendChild($reqStatus);
  691. if (isset($result['calendar-data'])) {
  692. $calendardata = $dom->createElement('cal:calendar-data');
  693. $calendardata->appendChild($dom->createTextNode(str_replace("\r\n", "\n", $result['calendar-data']->serialize())));
  694. $xresponse->appendChild($calendardata);
  695. }
  696. $scheduleResponse->appendChild($xresponse);
  697. }
  698. $response->setStatus(200);
  699. $response->setHeader('Content-Type', 'application/xml');
  700. $response->setBody($dom->saveXML());
  701. }
  702. /**
  703. * Returns free-busy information for a specific address. The returned
  704. * data is an array containing the following properties:.
  705. *
  706. * calendar-data : A VFREEBUSY VObject
  707. * request-status : an iTip status code.
  708. * href: The principal's email address, as requested
  709. *
  710. * The following request status codes may be returned:
  711. * * 2.0;description
  712. * * 3.7;description
  713. *
  714. * @param string $email address
  715. *
  716. * @return array
  717. */
  718. protected function getFreeBusyForEmail($email, \DateTimeInterface $start, \DateTimeInterface $end, VObject\Component $request)
  719. {
  720. $caldavNS = '{'.self::NS_CALDAV.'}';
  721. $aclPlugin = $this->server->getPlugin('acl');
  722. if ('mailto:' === substr($email, 0, 7)) {
  723. $email = substr($email, 7);
  724. }
  725. $result = $aclPlugin->principalSearch(
  726. ['{http://sabredav.org/ns}email-address' => $email],
  727. [
  728. '{DAV:}principal-URL',
  729. $caldavNS.'calendar-home-set',
  730. $caldavNS.'schedule-inbox-URL',
  731. '{http://sabredav.org/ns}email-address',
  732. ]
  733. );
  734. if (!count($result)) {
  735. return [
  736. 'request-status' => '3.7;Could not find principal',
  737. 'href' => 'mailto:'.$email,
  738. ];
  739. }
  740. if (!isset($result[0][200][$caldavNS.'calendar-home-set'])) {
  741. return [
  742. 'request-status' => '3.7;No calendar-home-set property found',
  743. 'href' => 'mailto:'.$email,
  744. ];
  745. }
  746. if (!isset($result[0][200][$caldavNS.'schedule-inbox-URL'])) {
  747. return [
  748. 'request-status' => '3.7;No schedule-inbox-URL property found',
  749. 'href' => 'mailto:'.$email,
  750. ];
  751. }
  752. $homeSet = $result[0][200][$caldavNS.'calendar-home-set']->getHref();
  753. $inboxUrl = $result[0][200][$caldavNS.'schedule-inbox-URL']->getHref();
  754. // Do we have permission?
  755. $aclPlugin->checkPrivileges($inboxUrl, $caldavNS.'schedule-query-freebusy');
  756. // Grabbing the calendar list
  757. $objects = [];
  758. $calendarTimeZone = new DateTimeZone('UTC');
  759. foreach ($this->server->tree->getNodeForPath($homeSet)->getChildren() as $node) {
  760. if (!$node instanceof ICalendar) {
  761. continue;
  762. }
  763. $sct = $caldavNS.'schedule-calendar-transp';
  764. $ctz = $caldavNS.'calendar-timezone';
  765. $props = $node->getProperties([$sct, $ctz]);
  766. if (isset($props[$sct]) && ScheduleCalendarTransp::TRANSPARENT == $props[$sct]->getValue()) {
  767. // If a calendar is marked as 'transparent', it means we must
  768. // ignore it for free-busy purposes.
  769. continue;
  770. }
  771. if (isset($props[$ctz])) {
  772. $vtimezoneObj = VObject\Reader::read($props[$ctz]);
  773. $calendarTimeZone = $vtimezoneObj->VTIMEZONE->getTimeZone();
  774. // Destroy circular references so PHP can garbage collect the object.
  775. $vtimezoneObj->destroy();
  776. }
  777. // Getting the list of object uris within the time-range
  778. $urls = $node->calendarQuery([
  779. 'name' => 'VCALENDAR',
  780. 'comp-filters' => [
  781. [
  782. 'name' => 'VEVENT',
  783. 'comp-filters' => [],
  784. 'prop-filters' => [],
  785. 'is-not-defined' => false,
  786. 'time-range' => [
  787. 'start' => $start,
  788. 'end' => $end,
  789. ],
  790. ],
  791. ],
  792. 'prop-filters' => [],
  793. 'is-not-defined' => false,
  794. 'time-range' => null,
  795. ]);
  796. $calObjects = array_map(function ($url) use ($node) {
  797. $obj = $node->getChild($url)->get();
  798. return $obj;
  799. }, $urls);
  800. $objects = array_merge($objects, $calObjects);
  801. }
  802. $inboxProps = $this->server->getProperties(
  803. $inboxUrl,
  804. $caldavNS.'calendar-availability'
  805. );
  806. $vcalendar = new VObject\Component\VCalendar();
  807. $vcalendar->METHOD = 'REPLY';
  808. $generator = new VObject\FreeBusyGenerator();
  809. $generator->setObjects($objects);
  810. $generator->setTimeRange($start, $end);
  811. $generator->setBaseObject($vcalendar);
  812. $generator->setTimeZone($calendarTimeZone);
  813. if ($inboxProps) {
  814. $generator->setVAvailability(
  815. VObject\Reader::read(
  816. $inboxProps[$caldavNS.'calendar-availability']
  817. )
  818. );
  819. }
  820. $result = $generator->getResult();
  821. $vcalendar->VFREEBUSY->ATTENDEE = 'mailto:'.$email;
  822. $vcalendar->VFREEBUSY->UID = (string) $request->VFREEBUSY->UID;
  823. $vcalendar->VFREEBUSY->ORGANIZER = clone $request->VFREEBUSY->ORGANIZER;
  824. return [
  825. 'calendar-data' => $result,
  826. 'request-status' => '2.0;Success',
  827. 'href' => 'mailto:'.$email,
  828. ];
  829. }
  830. /**
  831. * This method checks the 'Schedule-Reply' header
  832. * and returns false if it's 'F', otherwise true.
  833. *
  834. * @return bool
  835. */
  836. protected function scheduleReply(RequestInterface $request)
  837. {
  838. $scheduleReply = $request->getHeader('Schedule-Reply');
  839. return 'F' !== $scheduleReply;
  840. }
  841. /**
  842. * Returns a bunch of meta-data about the plugin.
  843. *
  844. * Providing this information is optional, and is mainly displayed by the
  845. * Browser plugin.
  846. *
  847. * The description key in the returned array may contain html and will not
  848. * be sanitized.
  849. *
  850. * @return array
  851. */
  852. public function getPluginInfo()
  853. {
  854. return [
  855. 'name' => $this->getPluginName(),
  856. 'description' => 'Adds calendar-auto-schedule, as defined in rfc6638',
  857. 'link' => 'http://sabre.io/dav/scheduling/',
  858. ];
  859. }
  860. }