Browse Source

Make rooms / resources automatically reply to invites

Signed-off-by: Georg Ehrke <developer@georgehrke.com>
Georg Ehrke 4 years ago
parent
commit
cc37c39ede

+ 395 - 2
apps/dav/lib/CalDAV/Schedule/Plugin.php

@@ -24,17 +24,36 @@
  */
 namespace OCA\DAV\CalDAV\Schedule;
 
+use DateTimeZone;
 use OCA\DAV\CalDAV\CalDavBackend;
 use OCA\DAV\CalDAV\CalendarHome;
+use Sabre\CalDAV\ICalendar;
 use Sabre\DAV\INode;
 use Sabre\DAV\IProperties;
 use Sabre\DAV\PropFind;
 use Sabre\DAV\Server;
 use Sabre\DAV\Xml\Property\LocalHref;
 use Sabre\DAVACL\IPrincipal;
+use Sabre\HTTP\RequestInterface;
+use Sabre\HTTP\ResponseInterface;
+use Sabre\VObject\Component;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\DateTimeParser;
+use Sabre\VObject\ITip;
+use Sabre\VObject\Parameter;
+use Sabre\VObject\Property;
+use Sabre\VObject\Reader;
+use Sabre\VObject\FreeBusyGenerator;
 
 class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
 
+	/** @var ITip\Message[] */
+	private $schedulingResponses = [];
+
+	/** @var string|null */
+	private $pathOfCalendarObjectChange = null;
+
 	/**
 	 * Initializes the plugin
 	 *
@@ -44,6 +63,8 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
 	function initialize(Server $server) {
 		parent::initialize($server);
 		$server->on('propFind', [$this, 'propFindDefaultCalendarUrl'], 90);
+		$server->on('afterWriteContent', [$this, 'dispatchSchedulingResponses']);
+		$server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']);
 	}
 
 	/**
@@ -56,8 +77,6 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
 	 * @return void
 	 */
 	function propFind(PropFind $propFind, INode $node) {
-		parent::propFind($propFind, $node);
-
 		if ($node instanceof IPrincipal) {
 			// overwrite Sabre/Dav's implementation
 			$propFind->handle('{' . self::NS_CALDAV . '}calendar-user-type', function () use ($node) {
@@ -73,6 +92,8 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
 				return 'INDIVIDUAL';
 			});
 		}
+
+		parent::propFind($propFind, $node);
 	}
 
 	/**
@@ -91,6 +112,144 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
 		return $result;
 	}
 
+	/**
+	 * @param RequestInterface $request
+	 * @param ResponseInterface $response
+	 * @param VCalendar $vCal
+	 * @param mixed $calendarPath
+	 * @param mixed $modified
+	 * @param mixed $isNew
+	 */
+	public function calendarObjectChange(RequestInterface $request, ResponseInterface $response, VCalendar $vCal, $calendarPath, &$modified, $isNew) {
+		// Save the first path we get as a calendar-object-change request
+		if (!$this->pathOfCalendarObjectChange) {
+			$this->pathOfCalendarObjectChange = $request->getPath();
+		}
+
+		parent::calendarObjectChange($request, $response, $vCal, $calendarPath, $modified, $isNew);
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	public function scheduleLocalDelivery(ITip\Message $iTipMessage):void {
+		parent::scheduleLocalDelivery($iTipMessage);
+
+		// We only care when the message was successfully delivered locally
+		if ($iTipMessage->scheduleStatus !== '1.2;Message delivered locally') {
+			return;
+		}
+
+		// We only care about request. reply and cancel are properly handled
+		// by parent::scheduleLocalDelivery already
+		if (strcasecmp($iTipMessage->method, 'REQUEST') !== 0) {
+			return;
+		}
+
+		// If parent::scheduleLocalDelivery set scheduleStatus to 1.2,
+		// it means that it was successfully delivered locally.
+		// Meaning that the ACL plugin is loaded and that a principial
+		// exists for the given recipient id, no need to double check
+		/** @var \Sabre\DAVACL\Plugin $aclPlugin */
+		$aclPlugin = $this->server->getPlugin('acl');
+		$principalUri = $aclPlugin->getPrincipalByUri($iTipMessage->recipient);
+		$calendarUserType = $this->getCalendarUserTypeForPrincipal($principalUri);
+		if (strcasecmp($calendarUserType, 'ROOM') !== 0 && strcasecmp($calendarUserType, 'RESOURCE') !== 0) {
+			return;
+		}
+
+		$attendee = $this->getCurrentAttendee($iTipMessage);
+		if (!$attendee) {
+			return;
+		}
+
+		// We only respond when a response was actually requested
+		$rsvp = $this->getAttendeeRSVP($attendee);
+		if (!$rsvp) {
+			return;
+		}
+
+		if (!isset($iTipMessage->message)) {
+			return;
+		}
+
+		$vcalendar = $iTipMessage->message;
+		if (!isset($vcalendar->VEVENT)) {
+			return;
+		}
+
+		/** @var Component $vevent */
+		$vevent = $vcalendar->VEVENT;
+
+		// We don't support autoresponses for recurrencing events for now
+		if (isset($vevent->RRULE) || isset($vevent->RDATE)) {
+			return;
+		}
+
+		$dtstart = $vevent->DTSTART;
+		$dtend =  $this->getDTEndFromVEvent($vevent);
+		$uid = $vevent->UID->getValue();
+		$sequence = isset($vevent->SEQUENCE) ? $vevent->SEQUENCE->getValue() : 0;
+		$recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->serialize() : '';
+
+		$message = <<<EOF
+BEGIN:VCALENDAR
+PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN
+METHOD:REPLY
+VERSION:2.0
+BEGIN:VEVENT
+ATTENDEE;PARTSTAT=%s:%s
+ORGANIZER:%s
+UID:%s
+SEQUENCE:%s
+REQUEST-STATUS:2.0;Success
+%sEND:VEVENT
+END:VCALENDAR
+EOF;
+
+		if ($this->isAvailableAtTime($attendee->getValue(), $dtstart->getDateTime(), $dtend->getDateTime(), $uid)) {
+			$partStat = 'ACCEPTED';
+		} else {
+			$partStat = 'DECLINED';
+		}
+
+		$vObject = Reader::read(vsprintf($message, [
+			$partStat,
+			$iTipMessage->recipient,
+			$iTipMessage->sender,
+			$uid,
+			$sequence,
+			$recurrenceId
+		]));
+
+		$responseITipMessage = new ITip\Message();
+		$responseITipMessage->uid = $uid;
+		$responseITipMessage->component = 'VEVENT';
+		$responseITipMessage->method = 'REPLY';
+		$responseITipMessage->sequence = $sequence;
+		$responseITipMessage->sender = $iTipMessage->recipient;
+		$responseITipMessage->recipient = $iTipMessage->sender;
+		$responseITipMessage->message = $vObject;
+
+		// We can't dispatch them now already, because the organizers calendar-object
+		// was not yet created. Hence Sabre/DAV won't find a calendar-object, when we
+		// send our reply.
+		$this->schedulingResponses[] = $responseITipMessage;
+	}
+
+	/**
+	 * @param string $uri
+	 */
+	public function dispatchSchedulingResponses(string $uri):void {
+		if ($uri !== $this->pathOfCalendarObjectChange) {
+			return;
+		}
+
+		foreach ($this->schedulingResponses as $schedulingResponse) {
+			$this->scheduleLocalDelivery($schedulingResponse);
+		}
+	}
+
 	/**
 	 * Always use the personal calendar as target for scheduled events
 	 *
@@ -140,4 +299,238 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
 			});
 		}
 	}
+
+	/**
+	 * Returns a list of addresses that are associated with a principal.
+	 *
+	 * @param string $principal
+	 * @return string?
+	 */
+	protected function getCalendarUserTypeForPrincipal($principal):?string {
+		$calendarUserType = '{' . self::NS_CALDAV . '}calendar-user-type';
+		$properties = $this->server->getProperties(
+			$principal,
+			[$calendarUserType]
+		);
+
+		// If we can't find this information, we'll stop processing
+		if (!isset($properties[$calendarUserType])) {
+			return null;
+		}
+
+		return $properties[$calendarUserType];
+	}
+
+	/**
+	 * @param ITip\Message $iTipMessage
+	 * @return null|Property
+	 */
+	private function getCurrentAttendee(ITip\Message $iTipMessage):?Property {
+		/** @var VEvent $vevent */
+		$vevent = $iTipMessage->message->VEVENT;
+		$attendees = $vevent->select('ATTENDEE');
+		foreach ($attendees as $attendee) {
+			/** @var Property $attendee */
+			if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) {
+				return $attendee;
+			}
+		}
+		return null;
+	}
+
+	/**
+	 * @param Property|null $attendee
+	 * @return bool
+	 */
+	private function getAttendeeRSVP(Property $attendee = null):bool {
+		if ($attendee !== null) {
+			$rsvp = $attendee->offsetGet('RSVP');
+			if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) {
+				return true;
+			}
+		}
+		// RFC 5545 3.2.17: default RSVP is false
+		return false;
+	}
+
+	/**
+	 * @param VEvent $vevent
+	 * @return Property\ICalendar\DateTime
+	 */
+	private function getDTEndFromVEvent(VEvent $vevent):Property\ICalendar\DateTime {
+		if (isset($vevent->DTEND)) {
+			return $vevent->DTEND;
+		}
+
+		if (isset($vevent->DURATION)) {
+			$isFloating = $vevent->DTSTART->isFloating();
+			/** @var Property\ICalendar\DateTime $end */
+			$end = clone $vevent->DTSTART;
+			$endDateTime = $end->getDateTime();
+			$endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue()));
+			$end->setDateTime($endDateTime, $isFloating);
+			return $end;
+		}
+
+		if (!$vevent->DTSTART->hasTime()) {
+			$isFloating = $vevent->DTSTART->isFloating();
+			/** @var Property\ICalendar\DateTime $end */
+			$end = clone $vevent->DTSTART;
+			$endDateTime = $end->getDateTime();
+			$endDateTime = $endDateTime->modify('+1 day');
+			$end->setDateTime($endDateTime, $isFloating);
+			return $end;
+		}
+
+		return clone $vevent->DTSTART;
+	}
+
+	/**
+	 * @param string $email
+	 * @param \DateTimeInterface $start
+	 * @param \DateTimeInterface $end
+	 * @param string $ignoreUID
+	 * @return bool
+	 */
+	private function isAvailableAtTime(string $email, \DateTimeInterface $start, \DateTimeInterface $end, string $ignoreUID):bool {
+		// This method is heavily inspired by Sabre\CalDAV\Schedule\Plugin::scheduleLocalDelivery
+		// and Sabre\CalDAV\Schedule\Plugin::getFreeBusyForEmail
+
+		$aclPlugin = $this->server->getPlugin('acl');
+		$this->server->removeListener('propFind', [$aclPlugin, 'propFind']);
+
+		$result = $aclPlugin->principalSearch(
+			['{http://sabredav.org/ns}email-address' => $this->stripOffMailTo($email)],
+			[
+				'{DAV:}principal-URL',
+				'{' . self::NS_CALDAV . '}calendar-home-set',
+				'{' . self::NS_CALDAV . '}schedule-inbox-URL',
+				'{http://sabredav.org/ns}email-address',
+
+			]
+		);
+		$this->server->on('propFind', [$aclPlugin, 'propFind'], 20);
+
+
+		// Grabbing the calendar list
+		$objects = [];
+		$calendarTimeZone = new DateTimeZone('UTC');
+
+		$homePath = $result[0][200]['{' . self::NS_CALDAV . '}calendar-home-set']->getHref();
+		foreach ($this->server->tree->getNodeForPath($homePath)->getChildren() as $node) {
+			if (!$node instanceof ICalendar) {
+				continue;
+			}
+
+			// Getting the list of object uris within the time-range
+			$urls = $node->calendarQuery([
+				'name' => 'VCALENDAR',
+				'comp-filters' => [
+					[
+						'name' => 'VEVENT',
+						'is-not-defined' => false,
+						'time-range' => [
+							'start' => $start,
+							'end' => $end,
+						],
+						'comp-filters'   => [],
+						'prop-filters'   => [],
+					],
+					[
+						'name' => 'VEVENT',
+						'is-not-defined' => false,
+						'time-range' => null,
+						'comp-filters'   => [],
+						'prop-filters'   => [
+							[
+								'name'           => 'UID',
+								'is-not-defined' => false,
+								'time-range'     => null,
+								'text-match'     => [
+									'value' => $ignoreUID,
+									'negate-condition' => true,
+									'collation' => 'i;octet',
+								],
+								'param-filters' => [],
+							],
+						]
+					],
+				],
+				'prop-filters' => [],
+				'is-not-defined' => false,
+				'time-range' => null,
+			]);
+
+			foreach ($urls as $url) {
+				$objects[] = $node->getChild($url)->get();
+			}
+		}
+
+		$inboxProps = $this->server->getProperties(
+			$result[0][200]['{' . self::NS_CALDAV . '}schedule-inbox-URL']->getHref(),
+			['{' . self::NS_CALDAV . '}calendar-availability']
+		);
+
+		$vcalendar = new VCalendar();
+		$vcalendar->METHOD = 'REPLY';
+
+		$generator = new FreeBusyGenerator();
+		$generator->setObjects($objects);
+		$generator->setTimeRange($start, $end);
+		$generator->setBaseObject($vcalendar);
+		$generator->setTimeZone($calendarTimeZone);
+
+		if (isset($inboxProps['{' . self::NS_CALDAV . '}calendar-availability'])) {
+			$generator->setVAvailability(
+				Reader::read(
+					$inboxProps['{' . self::NS_CALDAV . '}calendar-availability']
+				)
+			);
+		}
+
+		$result = $generator->getResult();
+		if (!isset($result->VFREEBUSY)) {
+			return false;
+		}
+
+		/** @var Component $freeBusyComponent */
+		$freeBusyComponent = $result->VFREEBUSY;
+		$freeBusyProperties = $freeBusyComponent->select('FREEBUSY');
+		// If there is no Free-busy property at all, the time-range is empty and available
+		if (count($freeBusyProperties) === 0) {
+			return true;
+		}
+
+		// If more than one Free-Busy property was returned, it means that an event
+		// starts or ends inside this time-range, so it's not availabe and we return false
+		if (count($freeBusyProperties) > 1) {
+			return false;
+		}
+
+		/** @var Property $freeBusyProperty */
+		$freeBusyProperty = $freeBusyProperties[0];
+		if (!$freeBusyProperty->offsetExists('FBTYPE')) {
+			// If there is no FBTYPE, it means it's busy
+			return false;
+		}
+
+		$fbTypeParameter = $freeBusyProperty->offsetGet('FBTYPE');
+		if (!($fbTypeParameter instanceof Parameter)) {
+			return false;
+		}
+
+		return (strcasecmp($fbTypeParameter->getValue(), 'FREE') === 0);
+	}
+
+	/**
+	 * @param string $email
+	 * @return string
+	 */
+	private function stripOffMailTo(string $email): string {
+		if (stripos($email, 'mailto:')  === 0) {
+			return substr($email, 7);
+		}
+
+		return $email;
+	}
 }

+ 41 - 0
apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php

@@ -26,6 +26,8 @@ namespace OCA\DAV\Tests\unit\CalDAV\Schedule;
 use OCA\DAV\CalDAV\Schedule\Plugin;
 use Sabre\DAV\Server;
 use Sabre\DAV\Xml\Property\Href;
+use Sabre\VObject\Parameter;
+use Sabre\VObject\Property\ICalendar\CalAddress;
 use Test\TestCase;
 
 class PluginTest extends TestCase  {
@@ -82,4 +84,43 @@ class PluginTest extends TestCase  {
 		$result = $this->invokePrivate($this->plugin, 'getAddressesForPrincipal', ['MyPrincipal']);
 		$this->assertSame([], $result);
 	}
+
+	public function testStripOffMailTo() {
+		$this->assertEquals('test@example.com', $this->invokePrivate($this->plugin, 'stripOffMailTo', ['test@example.com']));
+		$this->assertEquals('test@example.com', $this->invokePrivate($this->plugin, 'stripOffMailTo', ['mailto:test@example.com']));
+	}
+
+	public function testGetAttendeeRSVP() {
+		$property1 = $this->createMock(CalAddress::class);
+		$parameter1 = $this->createMock(Parameter::class);
+		$property1->expects($this->once())
+			->method('offsetGet')
+			->with('RSVP')
+			->willReturn($parameter1);
+		$parameter1->expects($this->once())
+			->method('getValue')
+			->with()
+			->willReturn('TRUE');
+
+		$property2 = $this->createMock(CalAddress::class);
+		$parameter2 = $this->createMock(Parameter::class);
+		$property2->expects($this->once())
+			->method('offsetGet')
+			->with('RSVP')
+			->willReturn($parameter2);
+		$parameter2->expects($this->once())
+			->method('getValue')
+			->with()
+			->willReturn('FALSE');
+
+		$property3 = $this->createMock(CalAddress::class);
+		$property3->expects($this->once())
+			->method('offsetGet')
+			->with('RSVP')
+			->willReturn(null);
+
+		$this->assertTrue($this->invokePrivate($this->plugin, 'getAttendeeRSVP', [$property1]));
+		$this->assertFalse($this->invokePrivate($this->plugin, 'getAttendeeRSVP', [$property2]));
+		$this->assertFalse($this->invokePrivate($this->plugin, 'getAttendeeRSVP', [$property3]));
+	}
 }