Plugin.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. <?php
  2. /**
  3. * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl>
  4. * @copyright Copyright (c) 2016, Joas Schilling <coding@schilljs.com>
  5. *
  6. * @author Christoph Wurst <christoph@winzerhof-wurst.at>
  7. * @author Daniel Kesselberg <mail@danielkesselberg.de>
  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. *
  13. * @license GNU AGPL version 3 or any later version
  14. *
  15. * This program is free software: you can redistribute it and/or modify
  16. * it under the terms of the GNU Affero General Public License as
  17. * published by the Free Software Foundation, either version 3 of the
  18. * License, or (at your option) any later version.
  19. *
  20. * This program is distributed in the hope that it will be useful,
  21. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  22. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  23. * GNU Affero General Public License for more details.
  24. *
  25. * You should have received a copy of the GNU Affero General Public License
  26. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  27. *
  28. */
  29. namespace OCA\DAV\CalDAV\Schedule;
  30. use DateTimeZone;
  31. use OCA\DAV\CalDAV\CalDavBackend;
  32. use OCA\DAV\CalDAV\Calendar;
  33. use OCA\DAV\CalDAV\CalendarHome;
  34. use OCP\IConfig;
  35. use Sabre\CalDAV\ICalendar;
  36. use Sabre\DAV\INode;
  37. use Sabre\DAV\IProperties;
  38. use Sabre\DAV\PropFind;
  39. use Sabre\DAV\Server;
  40. use Sabre\DAV\Xml\Property\LocalHref;
  41. use Sabre\DAVACL\IPrincipal;
  42. use Sabre\HTTP\RequestInterface;
  43. use Sabre\HTTP\ResponseInterface;
  44. use Sabre\VObject\Component;
  45. use Sabre\VObject\Component\VCalendar;
  46. use Sabre\VObject\Component\VEvent;
  47. use Sabre\VObject\DateTimeParser;
  48. use Sabre\VObject\FreeBusyGenerator;
  49. use Sabre\VObject\ITip;
  50. use Sabre\VObject\Parameter;
  51. use Sabre\VObject\Property;
  52. use Sabre\VObject\Reader;
  53. use function \Sabre\Uri\split;
  54. class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
  55. /**
  56. * @var IConfig
  57. */
  58. private $config;
  59. /** @var ITip\Message[] */
  60. private $schedulingResponses = [];
  61. /** @var string|null */
  62. private $pathOfCalendarObjectChange = null;
  63. public const CALENDAR_USER_TYPE = '{' . self::NS_CALDAV . '}calendar-user-type';
  64. public const SCHEDULE_DEFAULT_CALENDAR_URL = '{' . Plugin::NS_CALDAV . '}schedule-default-calendar-URL';
  65. /**
  66. * @param IConfig $config
  67. */
  68. public function __construct(IConfig $config) {
  69. $this->config = $config;
  70. }
  71. /**
  72. * Initializes the plugin
  73. *
  74. * @param Server $server
  75. * @return void
  76. */
  77. public function initialize(Server $server) {
  78. parent::initialize($server);
  79. $server->on('propFind', [$this, 'propFindDefaultCalendarUrl'], 90);
  80. $server->on('afterWriteContent', [$this, 'dispatchSchedulingResponses']);
  81. $server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']);
  82. }
  83. /**
  84. * Allow manual setting of the object change URL
  85. * to support public write
  86. *
  87. * @param string $path
  88. */
  89. public function setPathOfCalendarObjectChange(string $path): void {
  90. $this->pathOfCalendarObjectChange = $path;
  91. }
  92. /**
  93. * This method handler is invoked during fetching of properties.
  94. *
  95. * We use this event to add calendar-auto-schedule-specific properties.
  96. *
  97. * @param PropFind $propFind
  98. * @param INode $node
  99. * @return void
  100. */
  101. public function propFind(PropFind $propFind, INode $node) {
  102. if ($node instanceof IPrincipal) {
  103. // overwrite Sabre/Dav's implementation
  104. $propFind->handle(self::CALENDAR_USER_TYPE, function () use ($node) {
  105. if ($node instanceof IProperties) {
  106. $props = $node->getProperties([self::CALENDAR_USER_TYPE]);
  107. if (isset($props[self::CALENDAR_USER_TYPE])) {
  108. return $props[self::CALENDAR_USER_TYPE];
  109. }
  110. }
  111. return 'INDIVIDUAL';
  112. });
  113. }
  114. parent::propFind($propFind, $node);
  115. }
  116. /**
  117. * Returns a list of addresses that are associated with a principal.
  118. *
  119. * @param string $principal
  120. * @return array
  121. */
  122. protected function getAddressesForPrincipal($principal) {
  123. $result = parent::getAddressesForPrincipal($principal);
  124. if ($result === null) {
  125. $result = [];
  126. }
  127. return $result;
  128. }
  129. /**
  130. * @param RequestInterface $request
  131. * @param ResponseInterface $response
  132. * @param VCalendar $vCal
  133. * @param mixed $calendarPath
  134. * @param mixed $modified
  135. * @param mixed $isNew
  136. */
  137. public function calendarObjectChange(RequestInterface $request, ResponseInterface $response, VCalendar $vCal, $calendarPath, &$modified, $isNew) {
  138. // Save the first path we get as a calendar-object-change request
  139. if (!$this->pathOfCalendarObjectChange) {
  140. $this->pathOfCalendarObjectChange = $request->getPath();
  141. }
  142. parent::calendarObjectChange($request, $response, $vCal, $calendarPath, $modified, $isNew);
  143. }
  144. /**
  145. * @inheritDoc
  146. */
  147. public function scheduleLocalDelivery(ITip\Message $iTipMessage):void {
  148. parent::scheduleLocalDelivery($iTipMessage);
  149. // We only care when the message was successfully delivered locally
  150. if ($iTipMessage->scheduleStatus !== '1.2;Message delivered locally') {
  151. return;
  152. }
  153. // We only care about request. reply and cancel are properly handled
  154. // by parent::scheduleLocalDelivery already
  155. if (strcasecmp($iTipMessage->method, 'REQUEST') !== 0) {
  156. return;
  157. }
  158. // If parent::scheduleLocalDelivery set scheduleStatus to 1.2,
  159. // it means that it was successfully delivered locally.
  160. // Meaning that the ACL plugin is loaded and that a principial
  161. // exists for the given recipient id, no need to double check
  162. /** @var \Sabre\DAVACL\Plugin $aclPlugin */
  163. $aclPlugin = $this->server->getPlugin('acl');
  164. $principalUri = $aclPlugin->getPrincipalByUri($iTipMessage->recipient);
  165. $calendarUserType = $this->getCalendarUserTypeForPrincipal($principalUri);
  166. if (strcasecmp($calendarUserType, 'ROOM') !== 0 && strcasecmp($calendarUserType, 'RESOURCE') !== 0) {
  167. return;
  168. }
  169. $attendee = $this->getCurrentAttendee($iTipMessage);
  170. if (!$attendee) {
  171. return;
  172. }
  173. // We only respond when a response was actually requested
  174. $rsvp = $this->getAttendeeRSVP($attendee);
  175. if (!$rsvp) {
  176. return;
  177. }
  178. if (!isset($iTipMessage->message)) {
  179. return;
  180. }
  181. $vcalendar = $iTipMessage->message;
  182. if (!isset($vcalendar->VEVENT)) {
  183. return;
  184. }
  185. /** @var Component $vevent */
  186. $vevent = $vcalendar->VEVENT;
  187. // We don't support autoresponses for recurrencing events for now
  188. if (isset($vevent->RRULE) || isset($vevent->RDATE)) {
  189. return;
  190. }
  191. $dtstart = $vevent->DTSTART;
  192. $dtend = $this->getDTEndFromVEvent($vevent);
  193. $uid = $vevent->UID->getValue();
  194. $sequence = isset($vevent->SEQUENCE) ? $vevent->SEQUENCE->getValue() : 0;
  195. $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->serialize() : '';
  196. $message = <<<EOF
  197. BEGIN:VCALENDAR
  198. PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN
  199. METHOD:REPLY
  200. VERSION:2.0
  201. BEGIN:VEVENT
  202. ATTENDEE;PARTSTAT=%s:%s
  203. ORGANIZER:%s
  204. UID:%s
  205. SEQUENCE:%s
  206. REQUEST-STATUS:2.0;Success
  207. %sEND:VEVENT
  208. END:VCALENDAR
  209. EOF;
  210. if ($this->isAvailableAtTime($attendee->getValue(), $dtstart->getDateTime(), $dtend->getDateTime(), $uid)) {
  211. $partStat = 'ACCEPTED';
  212. } else {
  213. $partStat = 'DECLINED';
  214. }
  215. $vObject = Reader::read(vsprintf($message, [
  216. $partStat,
  217. $iTipMessage->recipient,
  218. $iTipMessage->sender,
  219. $uid,
  220. $sequence,
  221. $recurrenceId
  222. ]));
  223. $responseITipMessage = new ITip\Message();
  224. $responseITipMessage->uid = $uid;
  225. $responseITipMessage->component = 'VEVENT';
  226. $responseITipMessage->method = 'REPLY';
  227. $responseITipMessage->sequence = $sequence;
  228. $responseITipMessage->sender = $iTipMessage->recipient;
  229. $responseITipMessage->recipient = $iTipMessage->sender;
  230. $responseITipMessage->message = $vObject;
  231. // We can't dispatch them now already, because the organizers calendar-object
  232. // was not yet created. Hence Sabre/DAV won't find a calendar-object, when we
  233. // send our reply.
  234. $this->schedulingResponses[] = $responseITipMessage;
  235. }
  236. /**
  237. * @param string $uri
  238. */
  239. public function dispatchSchedulingResponses(string $uri):void {
  240. if ($uri !== $this->pathOfCalendarObjectChange) {
  241. return;
  242. }
  243. foreach ($this->schedulingResponses as $schedulingResponse) {
  244. $this->scheduleLocalDelivery($schedulingResponse);
  245. }
  246. }
  247. /**
  248. * Always use the personal calendar as target for scheduled events
  249. *
  250. * @param PropFind $propFind
  251. * @param INode $node
  252. * @return void
  253. */
  254. public function propFindDefaultCalendarUrl(PropFind $propFind, INode $node) {
  255. if ($node instanceof IPrincipal) {
  256. $propFind->handle(self::SCHEDULE_DEFAULT_CALENDAR_URL, function () use ($node) {
  257. /** @var \OCA\DAV\CalDAV\Plugin $caldavPlugin */
  258. $caldavPlugin = $this->server->getPlugin('caldav');
  259. $principalUrl = $node->getPrincipalUrl();
  260. $calendarHomePath = $caldavPlugin->getCalendarHomeForPrincipal($principalUrl);
  261. if (!$calendarHomePath) {
  262. return null;
  263. }
  264. $isResourceOrRoom = strpos($principalUrl, 'principals/calendar-resources') === 0 ||
  265. strpos($principalUrl, 'principals/calendar-rooms') === 0;
  266. if (strpos($principalUrl, 'principals/users') === 0) {
  267. [, $userId] = split($principalUrl);
  268. $uri = $this->config->getUserValue($userId, 'dav', 'defaultCalendar', CalDavBackend::PERSONAL_CALENDAR_URI);
  269. $displayName = CalDavBackend::PERSONAL_CALENDAR_NAME;
  270. } elseif ($isResourceOrRoom) {
  271. $uri = CalDavBackend::RESOURCE_BOOKING_CALENDAR_URI;
  272. $displayName = CalDavBackend::RESOURCE_BOOKING_CALENDAR_NAME;
  273. } else {
  274. // How did we end up here?
  275. // TODO - throw exception or just ignore?
  276. return null;
  277. }
  278. /** @var CalendarHome $calendarHome */
  279. $calendarHome = $this->server->tree->getNodeForPath($calendarHomePath);
  280. if (!$calendarHome->childExists($uri)) {
  281. // If the default calendar doesn't exist
  282. if ($isResourceOrRoom) {
  283. $calendarHome->getCalDAVBackend()->createCalendar($principalUrl, $uri, [
  284. '{DAV:}displayname' => $displayName,
  285. ]);
  286. } else {
  287. // And we're not handling scheduling on resource/room booking
  288. $userCalendars = [];
  289. /**
  290. * If the default calendar of the user isn't set and the
  291. * fallback doesn't match any of the user's calendar
  292. * try to find the first "personal" calendar we can write to
  293. * instead of creating a new one.
  294. * A appropriate personal calendar to receive invites:
  295. * - isn't a calendar subscription
  296. * - user can write to it (no virtual/3rd-party calendars)
  297. * - calendar isn't a share
  298. */
  299. foreach ($calendarHome->getChildren() as $node) {
  300. if ($node instanceof Calendar && !$node->isSubscription() && $node->canWrite() && !$node->isShared() && !$node->isDeleted()) {
  301. $userCalendars[] = $node;
  302. }
  303. }
  304. if (count($userCalendars) > 0) {
  305. // Calendar backend returns calendar by calendarorder property
  306. $uri = $userCalendars[0]->getName();
  307. } else {
  308. // Otherwise if we have really nothing, create a new calendar
  309. $calendarHome->getCalDAVBackend()->createCalendar($principalUrl, $uri, [
  310. '{DAV:}displayname' => $displayName,
  311. ]);
  312. }
  313. }
  314. }
  315. $result = $this->server->getPropertiesForPath($calendarHomePath . '/' . $uri, [], 1);
  316. if (empty($result)) {
  317. return null;
  318. }
  319. return new LocalHref($result[0]['href']);
  320. });
  321. }
  322. }
  323. /**
  324. * Returns a list of addresses that are associated with a principal.
  325. *
  326. * @param string $principal
  327. * @return string|null
  328. */
  329. protected function getCalendarUserTypeForPrincipal($principal):?string {
  330. $calendarUserType = '{' . self::NS_CALDAV . '}calendar-user-type';
  331. $properties = $this->server->getProperties(
  332. $principal,
  333. [$calendarUserType]
  334. );
  335. // If we can't find this information, we'll stop processing
  336. if (!isset($properties[$calendarUserType])) {
  337. return null;
  338. }
  339. return $properties[$calendarUserType];
  340. }
  341. /**
  342. * @param ITip\Message $iTipMessage
  343. * @return null|Property
  344. */
  345. private function getCurrentAttendee(ITip\Message $iTipMessage):?Property {
  346. /** @var VEvent $vevent */
  347. $vevent = $iTipMessage->message->VEVENT;
  348. $attendees = $vevent->select('ATTENDEE');
  349. foreach ($attendees as $attendee) {
  350. /** @var Property $attendee */
  351. if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) {
  352. return $attendee;
  353. }
  354. }
  355. return null;
  356. }
  357. /**
  358. * @param Property|null $attendee
  359. * @return bool
  360. */
  361. private function getAttendeeRSVP(Property $attendee = null):bool {
  362. if ($attendee !== null) {
  363. $rsvp = $attendee->offsetGet('RSVP');
  364. if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) {
  365. return true;
  366. }
  367. }
  368. // RFC 5545 3.2.17: default RSVP is false
  369. return false;
  370. }
  371. /**
  372. * @param VEvent $vevent
  373. * @return Property\ICalendar\DateTime
  374. */
  375. private function getDTEndFromVEvent(VEvent $vevent):Property\ICalendar\DateTime {
  376. if (isset($vevent->DTEND)) {
  377. return $vevent->DTEND;
  378. }
  379. if (isset($vevent->DURATION)) {
  380. $isFloating = $vevent->DTSTART->isFloating();
  381. /** @var Property\ICalendar\DateTime $end */
  382. $end = clone $vevent->DTSTART;
  383. $endDateTime = $end->getDateTime();
  384. $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue()));
  385. $end->setDateTime($endDateTime, $isFloating);
  386. return $end;
  387. }
  388. if (!$vevent->DTSTART->hasTime()) {
  389. $isFloating = $vevent->DTSTART->isFloating();
  390. /** @var Property\ICalendar\DateTime $end */
  391. $end = clone $vevent->DTSTART;
  392. $endDateTime = $end->getDateTime();
  393. $endDateTime = $endDateTime->modify('+1 day');
  394. $end->setDateTime($endDateTime, $isFloating);
  395. return $end;
  396. }
  397. return clone $vevent->DTSTART;
  398. }
  399. /**
  400. * @param string $email
  401. * @param \DateTimeInterface $start
  402. * @param \DateTimeInterface $end
  403. * @param string $ignoreUID
  404. * @return bool
  405. */
  406. private function isAvailableAtTime(string $email, \DateTimeInterface $start, \DateTimeInterface $end, string $ignoreUID):bool {
  407. // This method is heavily inspired by Sabre\CalDAV\Schedule\Plugin::scheduleLocalDelivery
  408. // and Sabre\CalDAV\Schedule\Plugin::getFreeBusyForEmail
  409. $aclPlugin = $this->server->getPlugin('acl');
  410. $this->server->removeListener('propFind', [$aclPlugin, 'propFind']);
  411. $result = $aclPlugin->principalSearch(
  412. ['{http://sabredav.org/ns}email-address' => $this->stripOffMailTo($email)],
  413. [
  414. '{DAV:}principal-URL',
  415. '{' . self::NS_CALDAV . '}calendar-home-set',
  416. '{' . self::NS_CALDAV . '}schedule-inbox-URL',
  417. '{http://sabredav.org/ns}email-address',
  418. ]
  419. );
  420. $this->server->on('propFind', [$aclPlugin, 'propFind'], 20);
  421. // Grabbing the calendar list
  422. $objects = [];
  423. $calendarTimeZone = new DateTimeZone('UTC');
  424. $homePath = $result[0][200]['{' . self::NS_CALDAV . '}calendar-home-set']->getHref();
  425. foreach ($this->server->tree->getNodeForPath($homePath)->getChildren() as $node) {
  426. if (!$node instanceof ICalendar) {
  427. continue;
  428. }
  429. // Getting the list of object uris within the time-range
  430. $urls = $node->calendarQuery([
  431. 'name' => 'VCALENDAR',
  432. 'comp-filters' => [
  433. [
  434. 'name' => 'VEVENT',
  435. 'is-not-defined' => false,
  436. 'time-range' => [
  437. 'start' => $start,
  438. 'end' => $end,
  439. ],
  440. 'comp-filters' => [],
  441. 'prop-filters' => [],
  442. ],
  443. [
  444. 'name' => 'VEVENT',
  445. 'is-not-defined' => false,
  446. 'time-range' => null,
  447. 'comp-filters' => [],
  448. 'prop-filters' => [
  449. [
  450. 'name' => 'UID',
  451. 'is-not-defined' => false,
  452. 'time-range' => null,
  453. 'text-match' => [
  454. 'value' => $ignoreUID,
  455. 'negate-condition' => true,
  456. 'collation' => 'i;octet',
  457. ],
  458. 'param-filters' => [],
  459. ],
  460. ]
  461. ],
  462. ],
  463. 'prop-filters' => [],
  464. 'is-not-defined' => false,
  465. 'time-range' => null,
  466. ]);
  467. foreach ($urls as $url) {
  468. $objects[] = $node->getChild($url)->get();
  469. }
  470. }
  471. $inboxProps = $this->server->getProperties(
  472. $result[0][200]['{' . self::NS_CALDAV . '}schedule-inbox-URL']->getHref(),
  473. ['{' . self::NS_CALDAV . '}calendar-availability']
  474. );
  475. $vcalendar = new VCalendar();
  476. $vcalendar->METHOD = 'REPLY';
  477. $generator = new FreeBusyGenerator();
  478. $generator->setObjects($objects);
  479. $generator->setTimeRange($start, $end);
  480. $generator->setBaseObject($vcalendar);
  481. $generator->setTimeZone($calendarTimeZone);
  482. if (isset($inboxProps['{' . self::NS_CALDAV . '}calendar-availability'])) {
  483. $generator->setVAvailability(
  484. Reader::read(
  485. $inboxProps['{' . self::NS_CALDAV . '}calendar-availability']
  486. )
  487. );
  488. }
  489. $result = $generator->getResult();
  490. if (!isset($result->VFREEBUSY)) {
  491. return false;
  492. }
  493. /** @var Component $freeBusyComponent */
  494. $freeBusyComponent = $result->VFREEBUSY;
  495. $freeBusyProperties = $freeBusyComponent->select('FREEBUSY');
  496. // If there is no Free-busy property at all, the time-range is empty and available
  497. if (count($freeBusyProperties) === 0) {
  498. return true;
  499. }
  500. // If more than one Free-Busy property was returned, it means that an event
  501. // starts or ends inside this time-range, so it's not availabe and we return false
  502. if (count($freeBusyProperties) > 1) {
  503. return false;
  504. }
  505. /** @var Property $freeBusyProperty */
  506. $freeBusyProperty = $freeBusyProperties[0];
  507. if (!$freeBusyProperty->offsetExists('FBTYPE')) {
  508. // If there is no FBTYPE, it means it's busy
  509. return false;
  510. }
  511. $fbTypeParameter = $freeBusyProperty->offsetGet('FBTYPE');
  512. if (!($fbTypeParameter instanceof Parameter)) {
  513. return false;
  514. }
  515. return (strcasecmp($fbTypeParameter->getValue(), 'FREE') === 0);
  516. }
  517. /**
  518. * @param string $email
  519. * @return string
  520. */
  521. private function stripOffMailTo(string $email): string {
  522. if (stripos($email, 'mailto:') === 0) {
  523. return substr($email, 7);
  524. }
  525. return $email;
  526. }
  527. }