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