1<?php 2 3namespace SabreForRainLoop\CalDAV; 4 5use SabreForRainLoop\DAV; 6use SabreForRainLoop\DAVACL; 7use SabreForRainLoop\VObject; 8 9/** 10 * CalDAV plugin 11 * 12 * This plugin provides functionality added by CalDAV (RFC 4791) 13 * It implements new reports, and the MKCALENDAR method. 14 * 15 * @copyright Copyright (C) 2007-2013 fruux GmbH (https://fruux.com/). 16 * @author Evert Pot (http://evertpot.com/) 17 * @license http://code.google.com/p/sabredav/wiki/License Modified BSD License 18 */ 19class Plugin extends DAV\ServerPlugin { 20 21 /** 22 * This is the official CalDAV namespace 23 */ 24 const NS_CALDAV = 'urn:ietf:params:xml:ns:caldav'; 25 26 /** 27 * This is the namespace for the proprietary calendarserver extensions 28 */ 29 const NS_CALENDARSERVER = 'http://calendarserver.org/ns/'; 30 31 /** 32 * The hardcoded root for calendar objects. It is unfortunate 33 * that we're stuck with it, but it will have to do for now 34 */ 35 const CALENDAR_ROOT = 'calendars'; 36 37 /** 38 * Reference to server object 39 * 40 * @var DAV\Server 41 */ 42 protected $server; 43 44 /** 45 * The email handler for invites and other scheduling messages. 46 * 47 * @var Schedule\IMip 48 */ 49 protected $imipHandler; 50 51 /** 52 * Sets the iMIP handler. 53 * 54 * iMIP = The email transport of iCalendar scheduling messages. Setting 55 * this is optional, but if you want the server to allow invites to be sent 56 * out, you must set a handler. 57 * 58 * Specifically iCal will plain assume that the server supports this. If 59 * the server doesn't, iCal will display errors when inviting people to 60 * events. 61 * 62 * @param Schedule\IMip $imipHandler 63 * @return void 64 */ 65 public function setIMipHandler(Schedule\IMip $imipHandler) { 66 67 $this->imipHandler = $imipHandler; 68 69 } 70 71 /** 72 * Use this method to tell the server this plugin defines additional 73 * HTTP methods. 74 * 75 * This method is passed a uri. It should only return HTTP methods that are 76 * available for the specified uri. 77 * 78 * @param string $uri 79 * @return array 80 */ 81 public function getHTTPMethods($uri) { 82 83 // The MKCALENDAR is only available on unmapped uri's, whose 84 // parents extend IExtendedCollection 85 list($parent, $name) = DAV\URLUtil::splitPath($uri); 86 87 $node = $this->server->tree->getNodeForPath($parent); 88 89 if ($node instanceof DAV\IExtendedCollection) { 90 try { 91 $node->getChild($name); 92 } catch (DAV\Exception\NotFound $e) { 93 return array('MKCALENDAR'); 94 } 95 } 96 return array(); 97 98 } 99 100 /** 101 * Returns a list of features for the DAV: HTTP header. 102 * 103 * @return array 104 */ 105 public function getFeatures() { 106 107 return array('calendar-access', 'calendar-proxy'); 108 109 } 110 111 /** 112 * Returns a plugin name. 113 * 114 * Using this name other plugins will be able to access other plugins 115 * using DAV\Server::getPlugin 116 * 117 * @return string 118 */ 119 public function getPluginName() { 120 121 return 'caldav'; 122 123 } 124 125 /** 126 * Returns a list of reports this plugin supports. 127 * 128 * This will be used in the {DAV:}supported-report-set property. 129 * Note that you still need to subscribe to the 'report' event to actually 130 * implement them 131 * 132 * @param string $uri 133 * @return array 134 */ 135 public function getSupportedReportSet($uri) { 136 137 $node = $this->server->tree->getNodeForPath($uri); 138 139 $reports = array(); 140 if ($node instanceof ICalendar || $node instanceof ICalendarObject) { 141 $reports[] = '{' . self::NS_CALDAV . '}calendar-multiget'; 142 $reports[] = '{' . self::NS_CALDAV . '}calendar-query'; 143 } 144 if ($node instanceof ICalendar) { 145 $reports[] = '{' . self::NS_CALDAV . '}free-busy-query'; 146 } 147 return $reports; 148 149 } 150 151 /** 152 * Initializes the plugin 153 * 154 * @param DAV\Server $server 155 * @return void 156 */ 157 public function initialize(DAV\Server $server) { 158 159 $this->server = $server; 160 161 $server->subscribeEvent('unknownMethod',array($this,'unknownMethod')); 162 //$server->subscribeEvent('unknownMethod',array($this,'unknownMethod2'),1000); 163 $server->subscribeEvent('report',array($this,'report')); 164 $server->subscribeEvent('beforeGetProperties',array($this,'beforeGetProperties')); 165 $server->subscribeEvent('onHTMLActionsPanel', array($this,'htmlActionsPanel')); 166 $server->subscribeEvent('onBrowserPostAction', array($this,'browserPostAction')); 167 $server->subscribeEvent('beforeWriteContent', array($this, 'beforeWriteContent')); 168 $server->subscribeEvent('beforeCreateFile', array($this, 'beforeCreateFile')); 169 $server->subscribeEvent('beforeMethod', array($this,'beforeMethod')); 170 171 $server->xmlNamespaces[self::NS_CALDAV] = 'cal'; 172 $server->xmlNamespaces[self::NS_CALENDARSERVER] = 'cs'; 173 174 $server->propertyMap['{' . self::NS_CALDAV . '}supported-calendar-component-set'] = 'SabreForRainLoop\\CalDAV\\Property\\SupportedCalendarComponentSet'; 175 $server->propertyMap['{' . self::NS_CALDAV . '}schedule-calendar-transp'] = 'SabreForRainLoop\\CalDAV\\Property\\ScheduleCalendarTransp'; 176 177 $server->resourceTypeMapping['\\SabreForRainLoop\\CalDAV\\ICalendar'] = '{urn:ietf:params:xml:ns:caldav}calendar'; 178 $server->resourceTypeMapping['\\SabreForRainLoop\\CalDAV\\Schedule\\IOutbox'] = '{urn:ietf:params:xml:ns:caldav}schedule-outbox'; 179 $server->resourceTypeMapping['\\SabreForRainLoop\\CalDAV\\Principal\\IProxyRead'] = '{http://calendarserver.org/ns/}calendar-proxy-read'; 180 $server->resourceTypeMapping['\\SabreForRainLoop\\CalDAV\\Principal\\IProxyWrite'] = '{http://calendarserver.org/ns/}calendar-proxy-write'; 181 $server->resourceTypeMapping['\\SabreForRainLoop\\CalDAV\\Notifications\\ICollection'] = '{' . self::NS_CALENDARSERVER . '}notification'; 182 183 array_push($server->protectedProperties, 184 185 '{' . self::NS_CALDAV . '}supported-calendar-component-set', 186 '{' . self::NS_CALDAV . '}supported-calendar-data', 187 '{' . self::NS_CALDAV . '}max-resource-size', 188 '{' . self::NS_CALDAV . '}min-date-time', 189 '{' . self::NS_CALDAV . '}max-date-time', 190 '{' . self::NS_CALDAV . '}max-instances', 191 '{' . self::NS_CALDAV . '}max-attendees-per-instance', 192 '{' . self::NS_CALDAV . '}calendar-home-set', 193 '{' . self::NS_CALDAV . '}supported-collation-set', 194 '{' . self::NS_CALDAV . '}calendar-data', 195 196 // scheduling extension 197 '{' . self::NS_CALDAV . '}schedule-inbox-URL', 198 '{' . self::NS_CALDAV . '}schedule-outbox-URL', 199 '{' . self::NS_CALDAV . '}calendar-user-address-set', 200 '{' . self::NS_CALDAV . '}calendar-user-type', 201 202 // CalendarServer extensions 203 '{' . self::NS_CALENDARSERVER . '}getctag', 204 '{' . self::NS_CALENDARSERVER . '}calendar-proxy-read-for', 205 '{' . self::NS_CALENDARSERVER . '}calendar-proxy-write-for', 206 '{' . self::NS_CALENDARSERVER . '}notification-URL', 207 '{' . self::NS_CALENDARSERVER . '}notificationtype' 208 209 ); 210 } 211 212 /** 213 * This function handles support for the MKCALENDAR method 214 * 215 * @param string $method 216 * @param string $uri 217 * @return bool 218 */ 219 public function unknownMethod($method, $uri) { 220 221 switch ($method) { 222 case 'MKCALENDAR' : 223 $this->httpMkCalendar($uri); 224 // false is returned to stop the propagation of the 225 // unknownMethod event. 226 return false; 227 case 'POST' : 228 229 // Checking if this is a text/calendar content type 230 $contentType = $this->server->httpRequest->getHeader('Content-Type'); 231 if (strpos($contentType, 'text/calendar')!==0) { 232 return; 233 } 234 235 // Checking if we're talking to an outbox 236 try { 237 $node = $this->server->tree->getNodeForPath($uri); 238 } catch (DAV\Exception\NotFound $e) { 239 return; 240 } 241 if (!$node instanceof Schedule\IOutbox) 242 return; 243 244 $this->outboxRequest($node, $uri); 245 return false; 246 247 } 248 249 } 250 251 /** 252 * This functions handles REPORT requests specific to CalDAV 253 * 254 * @param string $reportName 255 * @param \DOMNode $dom 256 * @return bool 257 */ 258 public function report($reportName,$dom) { 259 260 switch($reportName) { 261 case '{'.self::NS_CALDAV.'}calendar-multiget' : 262 $this->calendarMultiGetReport($dom); 263 return false; 264 case '{'.self::NS_CALDAV.'}calendar-query' : 265 $this->calendarQueryReport($dom); 266 return false; 267 case '{'.self::NS_CALDAV.'}free-busy-query' : 268 $this->freeBusyQueryReport($dom); 269 return false; 270 271 } 272 273 274 } 275 276 /** 277 * This function handles the MKCALENDAR HTTP method, which creates 278 * a new calendar. 279 * 280 * @param string $uri 281 * @return void 282 */ 283 public function httpMkCalendar($uri) { 284 285 // Due to unforgivable bugs in iCal, we're completely disabling MKCALENDAR support 286 // for clients matching iCal in the user agent 287 //$ua = $this->server->httpRequest->getHeader('User-Agent'); 288 //if (strpos($ua,'iCal/')!==false) { 289 // throw new \SabreForRainLoop\DAV\Exception\Forbidden('iCal has major bugs in it\'s RFC3744 support. Therefore we are left with no other choice but disabling this feature.'); 290 //} 291 292 $body = $this->server->httpRequest->getBody(true); 293 $properties = array(); 294 295 if ($body) { 296 297 $dom = DAV\XMLUtil::loadDOMDocument($body); 298 299 foreach($dom->firstChild->childNodes as $child) { 300 301 if (DAV\XMLUtil::toClarkNotation($child)!=='{DAV:}set') continue; 302 foreach(DAV\XMLUtil::parseProperties($child,$this->server->propertyMap) as $k=>$prop) { 303 $properties[$k] = $prop; 304 } 305 306 } 307 } 308 309 $resourceType = array('{DAV:}collection','{urn:ietf:params:xml:ns:caldav}calendar'); 310 311 $this->server->createCollection($uri,$resourceType,$properties); 312 313 $this->server->httpResponse->sendStatus(201); 314 $this->server->httpResponse->setHeader('Content-Length',0); 315 } 316 317 /** 318 * beforeGetProperties 319 * 320 * This method handler is invoked before any after properties for a 321 * resource are fetched. This allows us to add in any CalDAV specific 322 * properties. 323 * 324 * @param string $path 325 * @param DAV\INode $node 326 * @param array $requestedProperties 327 * @param array $returnedProperties 328 * @return void 329 */ 330 public function beforeGetProperties($path, DAV\INode $node, &$requestedProperties, &$returnedProperties) { 331 332 if ($node instanceof DAVACL\IPrincipal) { 333 334 // calendar-home-set property 335 $calHome = '{' . self::NS_CALDAV . '}calendar-home-set'; 336 if (in_array($calHome,$requestedProperties)) { 337 $principalId = $node->getName(); 338 $calendarHomePath = self::CALENDAR_ROOT . '/' . $principalId . '/'; 339 340 unset($requestedProperties[array_search($calHome, $requestedProperties)]); 341 $returnedProperties[200][$calHome] = new DAV\Property\Href($calendarHomePath); 342 343 } 344 345 // schedule-outbox-URL property 346 $scheduleProp = '{' . self::NS_CALDAV . '}schedule-outbox-URL'; 347 if (in_array($scheduleProp,$requestedProperties)) { 348 $principalId = $node->getName(); 349 $outboxPath = self::CALENDAR_ROOT . '/' . $principalId . '/outbox'; 350 351 unset($requestedProperties[array_search($scheduleProp, $requestedProperties)]); 352 $returnedProperties[200][$scheduleProp] = new DAV\Property\Href($outboxPath); 353 354 } 355 356 // calendar-user-address-set property 357 $calProp = '{' . self::NS_CALDAV . '}calendar-user-address-set'; 358 if (in_array($calProp,$requestedProperties)) { 359 360 $addresses = $node->getAlternateUriSet(); 361 $addresses[] = $this->server->getBaseUri() . DAV\URLUtil::encodePath($node->getPrincipalUrl() . '/'); 362 unset($requestedProperties[array_search($calProp, $requestedProperties)]); 363 $returnedProperties[200][$calProp] = new DAV\Property\HrefList($addresses, false); 364 365 } 366 367 // These two properties are shortcuts for ical to easily find 368 // other principals this principal has access to. 369 $propRead = '{' . self::NS_CALENDARSERVER . '}calendar-proxy-read-for'; 370 $propWrite = '{' . self::NS_CALENDARSERVER . '}calendar-proxy-write-for'; 371 if (in_array($propRead,$requestedProperties) || in_array($propWrite,$requestedProperties)) { 372 373 $aclPlugin = $this->server->getPlugin('acl'); 374 $membership = $aclPlugin->getPrincipalMembership($path); 375 $readList = array(); 376 $writeList = array(); 377 378 foreach($membership as $group) { 379 380 $groupNode = $this->server->tree->getNodeForPath($group); 381 382 // If the node is either ap proxy-read or proxy-write 383 // group, we grab the parent principal and add it to the 384 // list. 385 if ($groupNode instanceof Principal\IProxyRead) { 386 list($readList[]) = DAV\URLUtil::splitPath($group); 387 } 388 if ($groupNode instanceof Principal\IProxyWrite) { 389 list($writeList[]) = DAV\URLUtil::splitPath($group); 390 } 391 392 } 393 if (in_array($propRead,$requestedProperties)) { 394 unset($requestedProperties[$propRead]); 395 $returnedProperties[200][$propRead] = new DAV\Property\HrefList($readList); 396 } 397 if (in_array($propWrite,$requestedProperties)) { 398 unset($requestedProperties[$propWrite]); 399 $returnedProperties[200][$propWrite] = new DAV\Property\HrefList($writeList); 400 } 401 402 } 403 404 // notification-URL property 405 $notificationUrl = '{' . self::NS_CALENDARSERVER . '}notification-URL'; 406 if (($index = array_search($notificationUrl, $requestedProperties)) !== false) { 407 $principalId = $node->getName(); 408 $calendarHomePath = 'calendars/' . $principalId . '/notifications/'; 409 unset($requestedProperties[$index]); 410 $returnedProperties[200][$notificationUrl] = new DAV\Property\Href($calendarHomePath); 411 } 412 413 } // instanceof IPrincipal 414 415 if ($node instanceof Notifications\INode) { 416 417 $propertyName = '{' . self::NS_CALENDARSERVER . '}notificationtype'; 418 if (($index = array_search($propertyName, $requestedProperties)) !== false) { 419 420 $returnedProperties[200][$propertyName] = 421 $node->getNotificationType(); 422 423 unset($requestedProperties[$index]); 424 425 } 426 427 } // instanceof Notifications_INode 428 429 430 if ($node instanceof ICalendarObject) { 431 // The calendar-data property is not supposed to be a 'real' 432 // property, but in large chunks of the spec it does act as such. 433 // Therefore we simply expose it as a property. 434 $calDataProp = '{' . Plugin::NS_CALDAV . '}calendar-data'; 435 if (in_array($calDataProp, $requestedProperties)) { 436 unset($requestedProperties[$calDataProp]); 437 $val = $node->get(); 438 if (is_resource($val)) 439 $val = stream_get_contents($val); 440 441 // Taking out \r to not screw up the xml output 442 $returnedProperties[200][$calDataProp] = str_replace("\r","", $val); 443 444 } 445 } 446 447 } 448 449 /** 450 * This function handles the calendar-multiget REPORT. 451 * 452 * This report is used by the client to fetch the content of a series 453 * of urls. Effectively avoiding a lot of redundant requests. 454 * 455 * @param \DOMNode $dom 456 * @return void 457 */ 458 public function calendarMultiGetReport($dom) { 459 460 $properties = array_keys(DAV\XMLUtil::parseProperties($dom->firstChild)); 461 $hrefElems = $dom->getElementsByTagNameNS('urn:DAV','href'); 462 463 $xpath = new \DOMXPath($dom); 464 $xpath->registerNameSpace('cal',Plugin::NS_CALDAV); 465 $xpath->registerNameSpace('dav','urn:DAV'); 466 467 $expand = $xpath->query('/cal:calendar-multiget/dav:prop/cal:calendar-data/cal:expand'); 468 if ($expand->length>0) { 469 $expandElem = $expand->item(0); 470 $start = $expandElem->getAttribute('start'); 471 $end = $expandElem->getAttribute('end'); 472 if(!$start || !$end) { 473 throw new DAV\Exception\BadRequest('The "start" and "end" attributes are required for the CALDAV:expand element'); 474 } 475 $start = VObject\DateTimeParser::parseDateTime($start); 476 $end = VObject\DateTimeParser::parseDateTime($end); 477 478 if ($end <= $start) { 479 throw new DAV\Exception\BadRequest('The end-date must be larger than the start-date in the expand element.'); 480 } 481 482 $expand = true; 483 484 } else { 485 486 $expand = false; 487 488 } 489 490 foreach($hrefElems as $elem) { 491 $uri = $this->server->calculateUri($elem->nodeValue); 492 list($objProps) = $this->server->getPropertiesForPath($uri,$properties); 493 494 if ($expand && isset($objProps[200]['{' . self::NS_CALDAV . '}calendar-data'])) { 495 $vObject = VObject\Reader::read($objProps[200]['{' . self::NS_CALDAV . '}calendar-data']); 496 $vObject->expand($start, $end); 497 $objProps[200]['{' . self::NS_CALDAV . '}calendar-data'] = $vObject->serialize(); 498 } 499 500 $propertyList[]=$objProps; 501 502 } 503 504 $prefer = $this->server->getHTTPPRefer(); 505 506 $this->server->httpResponse->sendStatus(207); 507 $this->server->httpResponse->setHeader('Content-Type','application/xml; charset=utf-8'); 508 $this->server->httpResponse->setHeader('Vary','Brief,Prefer'); 509 $this->server->httpResponse->sendBody($this->server->generateMultiStatus($propertyList, $prefer['return-minimal'])); 510 511 } 512 513 /** 514 * This function handles the calendar-query REPORT 515 * 516 * This report is used by clients to request calendar objects based on 517 * complex conditions. 518 * 519 * @param \DOMNode $dom 520 * @return void 521 */ 522 public function calendarQueryReport($dom) { 523 524 $parser = new CalendarQueryParser($dom); 525 $parser->parse(); 526 527 $node = $this->server->tree->getNodeForPath($this->server->getRequestUri()); 528 $depth = $this->server->getHTTPDepth(0); 529 530 // The default result is an empty array 531 $result = array(); 532 533 // The calendarobject was requested directly. In this case we handle 534 // this locally. 535 if ($depth == 0 && $node instanceof ICalendarObject) { 536 537 $requestedCalendarData = true; 538 $requestedProperties = $parser->requestedProperties; 539 540 if (!in_array('{urn:ietf:params:xml:ns:caldav}calendar-data', $requestedProperties)) { 541 542 // We always retrieve calendar-data, as we need it for filtering. 543 $requestedProperties[] = '{urn:ietf:params:xml:ns:caldav}calendar-data'; 544 545 // If calendar-data wasn't explicitly requested, we need to remove 546 // it after processing. 547 $requestedCalendarData = false; 548 } 549 550 $properties = $this->server->getPropertiesForPath( 551 $this->server->getRequestUri(), 552 $requestedProperties, 553 0 554 ); 555 556 // This array should have only 1 element, the first calendar 557 // object. 558 $properties = current($properties); 559 560 // If there wasn't any calendar-data returned somehow, we ignore 561 // this. 562 if (isset($properties[200]['{urn:ietf:params:xml:ns:caldav}calendar-data'])) { 563 564 $validator = new CalendarQueryValidator(); 565 566 $vObject = VObject\Reader::read($properties[200]['{urn:ietf:params:xml:ns:caldav}calendar-data']); 567 if ($validator->validate($vObject,$parser->filters)) { 568 569 // If the client didn't require the calendar-data property, 570 // we won't give it back. 571 if (!$requestedCalendarData) { 572 unset($properties[200]['{urn:ietf:params:xml:ns:caldav}calendar-data']); 573 } else { 574 if ($parser->expand) { 575 $vObject->expand($parser->expand['start'], $parser->expand['end']); 576 $properties[200]['{' . self::NS_CALDAV . '}calendar-data'] = $vObject->serialize(); 577 } 578 } 579 580 $result = array($properties); 581 582 } 583 584 } 585 586 } 587 // If we're dealing with a calendar, the calendar itself is responsible 588 // for the calendar-query. 589 if ($node instanceof ICalendar && $depth = 1) { 590 591 $nodePaths = $node->calendarQuery($parser->filters); 592 593 foreach($nodePaths as $path) { 594 595 list($properties) = 596 $this->server->getPropertiesForPath($this->server->getRequestUri() . '/' . $path, $parser->requestedProperties); 597 598 if ($parser->expand) { 599 // We need to do some post-processing 600 $vObject = VObject\Reader::read($properties[200]['{urn:ietf:params:xml:ns:caldav}calendar-data']); 601 $vObject->expand($parser->expand['start'], $parser->expand['end']); 602 $properties[200]['{' . self::NS_CALDAV . '}calendar-data'] = $vObject->serialize(); 603 } 604 605 $result[] = $properties; 606 607 } 608 609 } 610 611 $prefer = $this->server->getHTTPPRefer(); 612 613 $this->server->httpResponse->sendStatus(207); 614 $this->server->httpResponse->setHeader('Content-Type','application/xml; charset=utf-8'); 615 $this->server->httpResponse->setHeader('Vary','Brief,Prefer'); 616 $this->server->httpResponse->sendBody($this->server->generateMultiStatus($result, $prefer['return-minimal'])); 617 618 } 619 620 /** 621 * This method is responsible for parsing the request and generating the 622 * response for the CALDAV:free-busy-query REPORT. 623 * 624 * @param \DOMNode $dom 625 * @return void 626 */ 627 protected function freeBusyQueryReport(\DOMNode $dom) { 628 629 $start = null; 630 $end = null; 631 632 foreach($dom->firstChild->childNodes as $childNode) { 633 634 $clark = DAV\XMLUtil::toClarkNotation($childNode); 635 if ($clark == '{' . self::NS_CALDAV . '}time-range') { 636 $start = $childNode->getAttribute('start'); 637 $end = $childNode->getAttribute('end'); 638 break; 639 } 640 641 } 642 if ($start) { 643 $start = VObject\DateTimeParser::parseDateTime($start); 644 } 645 if ($end) { 646 $end = VObject\DateTimeParser::parseDateTime($end); 647 } 648 649 if (!$start && !$end) { 650 throw new DAV\Exception\BadRequest('The freebusy report must have a time-range filter'); 651 } 652 $acl = $this->server->getPlugin('acl'); 653 654 if (!$acl) { 655 throw new DAV\Exception('The ACL plugin must be loaded for free-busy queries to work'); 656 } 657 $uri = $this->server->getRequestUri(); 658 $acl->checkPrivileges($uri,'{' . self::NS_CALDAV . '}read-free-busy'); 659 660 $calendar = $this->server->tree->getNodeForPath($uri); 661 if (!$calendar instanceof ICalendar) { 662 throw new DAV\Exception\NotImplemented('The free-busy-query REPORT is only implemented on calendars'); 663 } 664 665 // Doing a calendar-query first, to make sure we get the most 666 // performance. 667 $urls = $calendar->calendarQuery(array( 668 'name' => 'VCALENDAR', 669 'comp-filters' => array( 670 array( 671 'name' => 'VEVENT', 672 'comp-filters' => array(), 673 'prop-filters' => array(), 674 'is-not-defined' => false, 675 'time-range' => array( 676 'start' => $start, 677 'end' => $end, 678 ), 679 ), 680 ), 681 'prop-filters' => array(), 682 'is-not-defined' => false, 683 'time-range' => null, 684 )); 685 686 $objects = array_map(function($url) use ($calendar) { 687 $obj = $calendar->getChild($url)->get(); 688 return $obj; 689 }, $urls); 690 691 $generator = new VObject\FreeBusyGenerator(); 692 $generator->setObjects($objects); 693 $generator->setTimeRange($start, $end); 694 $result = $generator->getResult(); 695 $result = $result->serialize(); 696 697 $this->server->httpResponse->sendStatus(200); 698 $this->server->httpResponse->setHeader('Content-Type', 'text/calendar'); 699 $this->server->httpResponse->setHeader('Content-Length', strlen($result)); 700 $this->server->httpResponse->sendBody($result); 701 702 } 703 704 /** 705 * This method is triggered before a file gets updated with new content. 706 * 707 * This plugin uses this method to ensure that CalDAV objects receive 708 * valid calendar data. 709 * 710 * @param string $path 711 * @param DAV\IFile $node 712 * @param resource $data 713 * @return void 714 */ 715 public function beforeWriteContent($path, DAV\IFile $node, &$data) { 716 717 if (!$node instanceof ICalendarObject) 718 return; 719 720 $this->validateICalendar($data, $path); 721 722 } 723 724 /** 725 * This method is triggered before a new file is created. 726 * 727 * This plugin uses this method to ensure that newly created calendar 728 * objects contain valid calendar data. 729 * 730 * @param string $path 731 * @param resource $data 732 * @param DAV\ICollection $parentNode 733 * @return void 734 */ 735 public function beforeCreateFile($path, &$data, DAV\ICollection $parentNode) { 736 737 if (!$parentNode instanceof Calendar) 738 return; 739 740 $this->validateICalendar($data, $path); 741 742 } 743 744 /** 745 * This event is triggered before any HTTP request is handled. 746 * 747 * We use this to intercept GET calls to notification nodes, and return the 748 * proper response. 749 * 750 * @param string $method 751 * @param string $path 752 * @return void 753 */ 754 public function beforeMethod($method, $path) { 755 756 if ($method!=='GET') return; 757 758 try { 759 $node = $this->server->tree->getNodeForPath($path); 760 } catch (DAV\Exception\NotFound $e) { 761 return; 762 } 763 764 if (!$node instanceof Notifications\INode) 765 return; 766 767 if (!$this->server->checkPreconditions(true)) return false; 768 $dom = new \DOMDocument('1.0', 'UTF-8'); 769 770 $dom->formatOutput = true; 771 772 $root = $dom->createElement('cs:notification'); 773 foreach($this->server->xmlNamespaces as $namespace => $prefix) { 774 $root->setAttribute('xmlns:' . $prefix, $namespace); 775 } 776 777 $dom->appendChild($root); 778 $node->getNotificationType()->serializeBody($this->server, $root); 779 780 $this->server->httpResponse->setHeader('Content-Type','application/xml'); 781 $this->server->httpResponse->setHeader('ETag',$node->getETag()); 782 $this->server->httpResponse->sendStatus(200); 783 $this->server->httpResponse->sendBody($dom->saveXML()); 784 785 return false; 786 787 } 788 789 /** 790 * Checks if the submitted iCalendar data is in fact, valid. 791 * 792 * An exception is thrown if it's not. 793 * 794 * @param resource|string $data 795 * @param string $path 796 * @return void 797 */ 798 protected function validateICalendar(&$data, $path) { 799 800 // If it's a stream, we convert it to a string first. 801 if (is_resource($data)) { 802 $data = stream_get_contents($data); 803 } 804 805 // Converting the data to unicode, if needed. 806 $data = DAV\StringUtil::ensureUTF8($data); 807 808 try { 809 810 $vobj = VObject\Reader::read($data); 811 812 } catch (VObject\ParseException $e) { 813 814 throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid iCalendar 2.0 data. Parse error: ' . $e->getMessage()); 815 816 } 817 818 if ($vobj->name !== 'VCALENDAR') { 819 throw new DAV\Exception\UnsupportedMediaType('This collection can only support iCalendar objects.'); 820 } 821 822 // Get the Supported Components for the target calendar 823 list($parentPath,$object) = DAV\URLUtil::splitPath($path); 824 $calendarProperties = $this->server->getProperties($parentPath,array('{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set')); 825 $supportedComponents = $calendarProperties['{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set']->getValue(); 826 827 $foundType = null; 828 $foundUID = null; 829 foreach($vobj->getComponents() as $component) { 830 switch($component->name) { 831 case 'VTIMEZONE' : 832 continue 2; 833 case 'VEVENT' : 834 case 'VTODO' : 835 case 'VJOURNAL' : 836 if (is_null($foundType)) { 837 $foundType = $component->name; 838 if (!in_array($foundType, $supportedComponents)) { 839 throw new Exception\InvalidComponentType('This calendar only supports ' . implode(', ', $supportedComponents) . '. We found a ' . $foundType); 840 } 841 if (!isset($component->UID)) { 842 throw new DAV\Exception\BadRequest('Every ' . $component->name . ' component must have an UID'); 843 } 844 $foundUID = (string)$component->UID; 845 } else { 846 if ($foundType !== $component->name) { 847 throw new DAV\Exception\BadRequest('A calendar object must only contain 1 component. We found a ' . $component->name . ' as well as a ' . $foundType); 848 } 849 if ($foundUID !== (string)$component->UID) { 850 throw new DAV\Exception\BadRequest('Every ' . $component->name . ' in this object must have identical UIDs'); 851 } 852 } 853 break; 854 default : 855 throw new DAV\Exception\BadRequest('You are not allowed to create components of type: ' . $component->name . ' here'); 856 857 } 858 } 859 if (!$foundType) 860 throw new DAV\Exception\BadRequest('iCalendar object must contain at least 1 of VEVENT, VTODO or VJOURNAL'); 861 862 } 863 864 /** 865 * This method handles POST requests to the schedule-outbox. 866 * 867 * Currently, two types of requests are support: 868 * * FREEBUSY requests from RFC 6638 869 * * Simple iTIP messages from draft-desruisseaux-caldav-sched-04 870 * 871 * The latter is from an expired early draft of the CalDAV scheduling 872 * extensions, but iCal depends on a feature from that spec, so we 873 * implement it. 874 * 875 * @param Schedule\IOutbox $outboxNode 876 * @param string $outboxUri 877 * @return void 878 */ 879 public function outboxRequest(Schedule\IOutbox $outboxNode, $outboxUri) { 880 881 // Parsing the request body 882 try { 883 $vObject = VObject\Reader::read($this->server->httpRequest->getBody(true)); 884 } catch (VObject\ParseException $e) { 885 throw new DAV\Exception\BadRequest('The request body must be a valid iCalendar object. Parse error: ' . $e->getMessage()); 886 } 887 888 // The incoming iCalendar object must have a METHOD property, and a 889 // component. The combination of both determines what type of request 890 // this is. 891 $componentType = null; 892 foreach($vObject->getComponents() as $component) { 893 if ($component->name !== 'VTIMEZONE') { 894 $componentType = $component->name; 895 break; 896 } 897 } 898 if (is_null($componentType)) { 899 throw new DAV\Exception\BadRequest('We expected at least one VTODO, VJOURNAL, VFREEBUSY or VEVENT component'); 900 } 901 902 // Validating the METHOD 903 $method = strtoupper((string)$vObject->METHOD); 904 if (!$method) { 905 throw new DAV\Exception\BadRequest('A METHOD property must be specified in iTIP messages'); 906 } 907 908 // So we support two types of requests: 909 // 910 // REQUEST with a VFREEBUSY component 911 // REQUEST, REPLY, ADD, CANCEL on VEVENT components 912 913 $acl = $this->server->getPlugin('acl'); 914 915 if ($componentType === 'VFREEBUSY' && $method === 'REQUEST') { 916 917 $acl && $acl->checkPrivileges($outboxUri,'{' . Plugin::NS_CALDAV . '}schedule-query-freebusy'); 918 $this->handleFreeBusyRequest($outboxNode, $vObject); 919 920 } elseif ($componentType === 'VEVENT' && in_array($method, array('REQUEST','REPLY','ADD','CANCEL'))) { 921 922 $acl && $acl->checkPrivileges($outboxUri,'{' . Plugin::NS_CALDAV . '}schedule-post-vevent'); 923 $this->handleEventNotification($outboxNode, $vObject); 924 925 } else { 926 927 throw new DAV\Exception\NotImplemented('SabreDAV supports only VFREEBUSY (REQUEST) and VEVENT (REQUEST, REPLY, ADD, CANCEL)'); 928 929 } 930 931 } 932 933 /** 934 * This method handles the REQUEST, REPLY, ADD and CANCEL methods for 935 * VEVENT iTip messages. 936 * 937 * @return void 938 */ 939 protected function handleEventNotification(Schedule\IOutbox $outboxNode, VObject\Component $vObject) { 940 941 $originator = $this->server->httpRequest->getHeader('Originator'); 942 $recipients = $this->server->httpRequest->getHeader('Recipient'); 943 944 if (!$originator) { 945 throw new DAV\Exception\BadRequest('The Originator: header must be specified when making POST requests'); 946 } 947 if (!$recipients) { 948 throw new DAV\Exception\BadRequest('The Recipient: header must be specified when making POST requests'); 949 } 950 951 $recipients = explode(',',$recipients); 952 foreach($recipients as $k=>$recipient) { 953 954 $recipient = trim($recipient); 955 if (!preg_match('/^mailto:(.*)@(.*)$/i', $recipient)) { 956 throw new DAV\Exception\BadRequest('Recipients must start with mailto: and must be valid email address'); 957 } 958 $recipient = substr($recipient, 7); 959 $recipients[$k] = $recipient; 960 } 961 962 // We need to make sure that 'originator' matches one of the email 963 // addresses of the selected principal. 964 $principal = $outboxNode->getOwner(); 965 $props = $this->server->getProperties($principal,array( 966 '{' . self::NS_CALDAV . '}calendar-user-address-set', 967 )); 968 969 $addresses = array(); 970 if (isset($props['{' . self::NS_CALDAV . '}calendar-user-address-set'])) { 971 $addresses = $props['{' . self::NS_CALDAV . '}calendar-user-address-set']->getHrefs(); 972 } 973 974 $found = false; 975 foreach($addresses as $address) { 976 977 // Trimming the / on both sides, just in case.. 978 if (rtrim(strtolower($originator),'/') === rtrim(strtolower($address),'/')) { 979 $found = true; 980 break; 981 } 982 983 } 984 985 if (!$found) { 986 throw new DAV\Exception\Forbidden('The addresses specified in the Originator header did not match any addresses in the owners calendar-user-address-set header'); 987 } 988 989 // If the Originator header was a url, and not a mailto: address.. 990 // we're going to try to pull the mailto: from the vobject body. 991 if (strtolower(substr($originator,0,7)) !== 'mailto:') { 992 $originator = (string)$vObject->VEVENT->ORGANIZER; 993 994 } 995 if (strtolower(substr($originator,0,7)) !== 'mailto:') { 996 throw new DAV\Exception\Forbidden('Could not find mailto: address in both the Orignator header, and the ORGANIZER property in the VEVENT'); 997 } 998 $originator = substr($originator,7); 999 1000 $result = $this->iMIPMessage($originator, $recipients, $vObject, $principal); 1001 $this->server->httpResponse->sendStatus(200); 1002 $this->server->httpResponse->setHeader('Content-Type','application/xml'); 1003 $this->server->httpResponse->sendBody($this->generateScheduleResponse($result)); 1004 1005 } 1006 1007 /** 1008 * Sends an iMIP message by email. 1009 * 1010 * This method must return an array with status codes per recipient. 1011 * This should look something like: 1012 * 1013 * array( 1014 * 'user1@example.org' => '2.0;Success' 1015 * ) 1016 * 1017 * Formatting for this status code can be found at: 1018 * https://tools.ietf.org/html/rfc5545#section-3.8.8.3 1019 * 1020 * A list of valid status codes can be found at: 1021 * https://tools.ietf.org/html/rfc5546#section-3.6 1022 * 1023 * @param string $originator 1024 * @param array $recipients 1025 * @param VObject\Component $vObject 1026 * @param string $principal Principal url 1027 * @return array 1028 */ 1029 protected function iMIPMessage($originator, array $recipients, VObject\Component $vObject, $principal) { 1030 1031 if (!$this->imipHandler) { 1032 $resultStatus = '5.2;This server does not support this operation'; 1033 } else { 1034 $this->imipHandler->sendMessage($originator, $recipients, $vObject, $principal); 1035 $resultStatus = '2.0;Success'; 1036 } 1037 1038 $result = array(); 1039 foreach($recipients as $recipient) { 1040 $result[$recipient] = $resultStatus; 1041 } 1042 1043 return $result; 1044 1045 } 1046 1047 /** 1048 * Generates a schedule-response XML body 1049 * 1050 * The recipients array is a key->value list, containing email addresses 1051 * and iTip status codes. See the iMIPMessage method for a description of 1052 * the value. 1053 * 1054 * @param array $recipients 1055 * @return string 1056 */ 1057 public function generateScheduleResponse(array $recipients) { 1058 1059 $dom = new \DOMDocument('1.0','utf-8'); 1060 $dom->formatOutput = true; 1061 $xscheduleResponse = $dom->createElement('cal:schedule-response'); 1062 $dom->appendChild($xscheduleResponse); 1063 1064 foreach($this->server->xmlNamespaces as $namespace=>$prefix) { 1065 1066 $xscheduleResponse->setAttribute('xmlns:' . $prefix, $namespace); 1067 1068 } 1069 1070 foreach($recipients as $recipient=>$status) { 1071 $xresponse = $dom->createElement('cal:response'); 1072 1073 $xrecipient = $dom->createElement('cal:recipient'); 1074 $xrecipient->appendChild($dom->createTextNode($recipient)); 1075 $xresponse->appendChild($xrecipient); 1076 1077 $xrequestStatus = $dom->createElement('cal:request-status'); 1078 $xrequestStatus->appendChild($dom->createTextNode($status)); 1079 $xresponse->appendChild($xrequestStatus); 1080 1081 $xscheduleResponse->appendChild($xresponse); 1082 1083 } 1084 1085 return $dom->saveXML(); 1086 1087 } 1088 1089 /** 1090 * This method is responsible for parsing a free-busy query request and 1091 * returning it's result. 1092 * 1093 * @param Schedule\IOutbox $outbox 1094 * @param string $request 1095 * @return string 1096 */ 1097 protected function handleFreeBusyRequest(Schedule\IOutbox $outbox, VObject\Component $vObject) { 1098 1099 $vFreeBusy = $vObject->VFREEBUSY; 1100 $organizer = $vFreeBusy->organizer; 1101 1102 $organizer = (string)$organizer; 1103 1104 // Validating if the organizer matches the owner of the inbox. 1105 $owner = $outbox->getOwner(); 1106 1107 $caldavNS = '{' . Plugin::NS_CALDAV . '}'; 1108 1109 $uas = $caldavNS . 'calendar-user-address-set'; 1110 $props = $this->server->getProperties($owner,array($uas)); 1111 1112 if (empty($props[$uas]) || !in_array($organizer, $props[$uas]->getHrefs())) { 1113 throw new DAV\Exception\Forbidden('The organizer in the request did not match any of the addresses for the owner of this inbox'); 1114 } 1115 1116 if (!isset($vFreeBusy->ATTENDEE)) { 1117 throw new DAV\Exception\BadRequest('You must at least specify 1 attendee'); 1118 } 1119 1120 $attendees = array(); 1121 foreach($vFreeBusy->ATTENDEE as $attendee) { 1122 $attendees[]= (string)$attendee; 1123 } 1124 1125 1126 if (!isset($vFreeBusy->DTSTART) || !isset($vFreeBusy->DTEND)) { 1127 throw new DAV\Exception\BadRequest('DTSTART and DTEND must both be specified'); 1128 } 1129 1130 $startRange = $vFreeBusy->DTSTART->getDateTime(); 1131 $endRange = $vFreeBusy->DTEND->getDateTime(); 1132 1133 $results = array(); 1134 foreach($attendees as $attendee) { 1135 $results[] = $this->getFreeBusyForEmail($attendee, $startRange, $endRange, $vObject); 1136 } 1137 1138 $dom = new \DOMDocument('1.0','utf-8'); 1139 $dom->formatOutput = true; 1140 $scheduleResponse = $dom->createElement('cal:schedule-response'); 1141 foreach($this->server->xmlNamespaces as $namespace=>$prefix) { 1142 1143 $scheduleResponse->setAttribute('xmlns:' . $prefix,$namespace); 1144 1145 } 1146 $dom->appendChild($scheduleResponse); 1147 1148 foreach($results as $result) { 1149 $response = $dom->createElement('cal:response'); 1150 1151 $recipient = $dom->createElement('cal:recipient'); 1152 $recipientHref = $dom->createElement('d:href'); 1153 1154 $recipientHref->appendChild($dom->createTextNode($result['href'])); 1155 $recipient->appendChild($recipientHref); 1156 $response->appendChild($recipient); 1157 1158 $reqStatus = $dom->createElement('cal:request-status'); 1159 $reqStatus->appendChild($dom->createTextNode($result['request-status'])); 1160 $response->appendChild($reqStatus); 1161 1162 if (isset($result['calendar-data'])) { 1163 1164 $calendardata = $dom->createElement('cal:calendar-data'); 1165 $calendardata->appendChild($dom->createTextNode(str_replace("\r\n","\n",$result['calendar-data']->serialize()))); 1166 $response->appendChild($calendardata); 1167 1168 } 1169 $scheduleResponse->appendChild($response); 1170 } 1171 1172 $this->server->httpResponse->sendStatus(200); 1173 $this->server->httpResponse->setHeader('Content-Type','application/xml'); 1174 $this->server->httpResponse->sendBody($dom->saveXML()); 1175 1176 } 1177 1178 /** 1179 * Returns free-busy information for a specific address. The returned 1180 * data is an array containing the following properties: 1181 * 1182 * calendar-data : A VFREEBUSY VObject 1183 * request-status : an iTip status code. 1184 * href: The principal's email address, as requested 1185 * 1186 * The following request status codes may be returned: 1187 * * 2.0;description 1188 * * 3.7;description 1189 * 1190 * @param string $email address 1191 * @param \DateTime $start 1192 * @param \DateTime $end 1193 * @param VObject\Component $request 1194 * @return array 1195 */ 1196 protected function getFreeBusyForEmail($email, \DateTime $start, \DateTime $end, VObject\Component $request) { 1197 1198 $caldavNS = '{' . Plugin::NS_CALDAV . '}'; 1199 1200 $aclPlugin = $this->server->getPlugin('acl'); 1201 if (substr($email,0,7)==='mailto:') $email = substr($email,7); 1202 1203 $result = $aclPlugin->principalSearch( 1204 array('{http://sabredav.org/ns}email-address' => $email), 1205 array( 1206 '{DAV:}principal-URL', $caldavNS . 'calendar-home-set', 1207 '{http://sabredav.org/ns}email-address', 1208 ) 1209 ); 1210 1211 if (!count($result)) { 1212 return array( 1213 'request-status' => '3.7;Could not find principal', 1214 'href' => 'mailto:' . $email, 1215 ); 1216 } 1217 1218 if (!isset($result[0][200][$caldavNS . 'calendar-home-set'])) { 1219 return array( 1220 'request-status' => '3.7;No calendar-home-set property found', 1221 'href' => 'mailto:' . $email, 1222 ); 1223 } 1224 $homeSet = $result[0][200][$caldavNS . 'calendar-home-set']->getHref(); 1225 1226 // Grabbing the calendar list 1227 $objects = array(); 1228 foreach($this->server->tree->getNodeForPath($homeSet)->getChildren() as $node) { 1229 if (!$node instanceof ICalendar) { 1230 continue; 1231 } 1232 $aclPlugin->checkPrivileges($homeSet . $node->getName() ,$caldavNS . 'read-free-busy'); 1233 1234 // Getting the list of object uris within the time-range 1235 $urls = $node->calendarQuery(array( 1236 'name' => 'VCALENDAR', 1237 'comp-filters' => array( 1238 array( 1239 'name' => 'VEVENT', 1240 'comp-filters' => array(), 1241 'prop-filters' => array(), 1242 'is-not-defined' => false, 1243 'time-range' => array( 1244 'start' => $start, 1245 'end' => $end, 1246 ), 1247 ), 1248 ), 1249 'prop-filters' => array(), 1250 'is-not-defined' => false, 1251 'time-range' => null, 1252 )); 1253 1254 $calObjects = array_map(function($url) use ($node) { 1255 $obj = $node->getChild($url)->get(); 1256 return $obj; 1257 }, $urls); 1258 1259 $objects = array_merge($objects,$calObjects); 1260 1261 } 1262 1263 $vcalendar = new VObject\Component\VCalendar(); 1264 $vcalendar->VERSION = '2.0'; 1265 $vcalendar->METHOD = 'REPLY'; 1266 $vcalendar->CALSCALE = 'GREGORIAN'; 1267 $vcalendar->PRODID = '-//SabreDAV//SabreDAV ' . DAV\Version::VERSION . '//EN'; 1268 1269 $generator = new VObject\FreeBusyGenerator(); 1270 $generator->setObjects($objects); 1271 $generator->setTimeRange($start, $end); 1272 $generator->setBaseObject($vcalendar); 1273 1274 $result = $generator->getResult(); 1275 1276 $vcalendar->VFREEBUSY->ATTENDEE = 'mailto:' . $email; 1277 $vcalendar->VFREEBUSY->UID = (string)$request->VFREEBUSY->UID; 1278 $vcalendar->VFREEBUSY->ORGANIZER = clone $request->VFREEBUSY->ORGANIZER; 1279 1280 return array( 1281 'calendar-data' => $result, 1282 'request-status' => '2.0;Success', 1283 'href' => 'mailto:' . $email, 1284 ); 1285 } 1286 1287 /** 1288 * This method is used to generate HTML output for the 1289 * DAV\Browser\Plugin. This allows us to generate an interface users 1290 * can use to create new calendars. 1291 * 1292 * @param DAV\INode $node 1293 * @param string $output 1294 * @return bool 1295 */ 1296 public function htmlActionsPanel(DAV\INode $node, &$output) { 1297 1298 if (!$node instanceof UserCalendars) 1299 return; 1300 1301 $output.= '<tr><td colspan="2"><form method="post" action=""> 1302 <h3>Create new calendar</h3> 1303 <input type="hidden" name="sabreAction" value="mkcalendar" /> 1304 <label>Name (uri):</label> <input type="text" name="name" /><br /> 1305 <label>Display name:</label> <input type="text" name="{DAV:}displayname" /><br /> 1306 <input type="submit" value="create" /> 1307 </form> 1308 </td></tr>'; 1309 1310 return false; 1311 1312 } 1313 1314 /** 1315 * This method allows us to intercept the 'mkcalendar' sabreAction. This 1316 * action enables the user to create new calendars from the browser plugin. 1317 * 1318 * @param string $uri 1319 * @param string $action 1320 * @param array $postVars 1321 * @return bool 1322 */ 1323 public function browserPostAction($uri, $action, array $postVars) { 1324 1325 if ($action!=='mkcalendar') 1326 return; 1327 1328 $resourceType = array('{DAV:}collection','{urn:ietf:params:xml:ns:caldav}calendar'); 1329 $properties = array(); 1330 if (isset($postVars['{DAV:}displayname'])) { 1331 $properties['{DAV:}displayname'] = $postVars['{DAV:}displayname']; 1332 } 1333 $this->server->createCollection($uri . '/' . $postVars['name'],$resourceType,$properties); 1334 return false; 1335 1336 } 1337 1338} 1339