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