1<?php 2 3namespace Sabre\CalDAV; 4 5use DateTimeZone; 6use Sabre\DAV; 7use Sabre\DAV\Exception\BadRequest; 8use Sabre\DAV\INode; 9use Sabre\DAV\MkCol; 10use Sabre\DAV\Xml\Property\LocalHref; 11use Sabre\DAVACL; 12use Sabre\HTTP; 13use Sabre\HTTP\RequestInterface; 14use Sabre\HTTP\ResponseInterface; 15use Sabre\Uri; 16use Sabre\VObject; 17 18/** 19 * CalDAV plugin 20 * 21 * This plugin provides functionality added by CalDAV (RFC 4791) 22 * It implements new reports, and the MKCALENDAR method. 23 * 24 * @copyright Copyright (C) fruux GmbH (https://fruux.com/) 25 * @author Evert Pot (http://evertpot.com/) 26 * @license http://sabre.io/license/ Modified BSD License 27 */ 28class Plugin extends DAV\ServerPlugin { 29 30 /** 31 * This is the official CalDAV namespace 32 */ 33 const NS_CALDAV = 'urn:ietf:params:xml:ns:caldav'; 34 35 /** 36 * This is the namespace for the proprietary calendarserver extensions 37 */ 38 const NS_CALENDARSERVER = 'http://calendarserver.org/ns/'; 39 40 /** 41 * The hardcoded root for calendar objects. It is unfortunate 42 * that we're stuck with it, but it will have to do for now 43 */ 44 const CALENDAR_ROOT = 'calendars'; 45 46 /** 47 * Reference to server object 48 * 49 * @var DAV\Server 50 */ 51 protected $server; 52 53 /** 54 * The default PDO storage uses a MySQL MEDIUMBLOB for iCalendar data, 55 * which can hold up to 2^24 = 16777216 bytes. This is plenty. We're 56 * capping it to 10M here. 57 */ 58 protected $maxResourceSize = 10000000; 59 60 /** 61 * Use this method to tell the server this plugin defines additional 62 * HTTP methods. 63 * 64 * This method is passed a uri. It should only return HTTP methods that are 65 * available for the specified uri. 66 * 67 * @param string $uri 68 * @return array 69 */ 70 function getHTTPMethods($uri) { 71 72 // The MKCALENDAR is only available on unmapped uri's, whose 73 // parents extend IExtendedCollection 74 list($parent, $name) = Uri\split($uri); 75 76 $node = $this->server->tree->getNodeForPath($parent); 77 78 if ($node instanceof DAV\IExtendedCollection) { 79 try { 80 $node->getChild($name); 81 } catch (DAV\Exception\NotFound $e) { 82 return ['MKCALENDAR']; 83 } 84 } 85 return []; 86 87 } 88 89 /** 90 * Returns the path to a principal's calendar home. 91 * 92 * The return url must not end with a slash. 93 * This function should return null in case a principal did not have 94 * a calendar home. 95 * 96 * @param string $principalUrl 97 * @return string 98 */ 99 function getCalendarHomeForPrincipal($principalUrl) { 100 101 // The default behavior for most sabre/dav servers is that there is a 102 // principals root node, which contains users directly under it. 103 // 104 // This function assumes that there are two components in a principal 105 // path. If there's more, we don't return a calendar home. This 106 // excludes things like the calendar-proxy-read principal (which it 107 // should). 108 $parts = explode('/', trim($principalUrl, '/')); 109 if (count($parts) !== 2) return; 110 if ($parts[0] !== 'principals') return; 111 112 return self::CALENDAR_ROOT . '/' . $parts[1]; 113 114 } 115 116 /** 117 * Returns a list of features for the DAV: HTTP header. 118 * 119 * @return array 120 */ 121 function getFeatures() { 122 123 return ['calendar-access', 'calendar-proxy']; 124 125 } 126 127 /** 128 * Returns a plugin name. 129 * 130 * Using this name other plugins will be able to access other plugins 131 * using DAV\Server::getPlugin 132 * 133 * @return string 134 */ 135 function getPluginName() { 136 137 return 'caldav'; 138 139 } 140 141 /** 142 * Returns a list of reports this plugin supports. 143 * 144 * This will be used in the {DAV:}supported-report-set property. 145 * Note that you still need to subscribe to the 'report' event to actually 146 * implement them 147 * 148 * @param string $uri 149 * @return array 150 */ 151 function getSupportedReportSet($uri) { 152 153 $node = $this->server->tree->getNodeForPath($uri); 154 155 $reports = []; 156 if ($node instanceof ICalendarObjectContainer || $node instanceof ICalendarObject) { 157 $reports[] = '{' . self::NS_CALDAV . '}calendar-multiget'; 158 $reports[] = '{' . self::NS_CALDAV . '}calendar-query'; 159 } 160 if ($node instanceof ICalendar) { 161 $reports[] = '{' . self::NS_CALDAV . '}free-busy-query'; 162 } 163 // iCal has a bug where it assumes that sync support is enabled, only 164 // if we say we support it on the calendar-home, even though this is 165 // not actually the case. 166 if ($node instanceof CalendarHome && $this->server->getPlugin('sync')) { 167 $reports[] = '{DAV:}sync-collection'; 168 } 169 return $reports; 170 171 } 172 173 /** 174 * Initializes the plugin 175 * 176 * @param DAV\Server $server 177 * @return void 178 */ 179 function initialize(DAV\Server $server) { 180 181 $this->server = $server; 182 183 $server->on('method:MKCALENDAR', [$this, 'httpMkCalendar']); 184 $server->on('report', [$this, 'report']); 185 $server->on('propFind', [$this, 'propFind']); 186 $server->on('onHTMLActionsPanel', [$this, 'htmlActionsPanel']); 187 $server->on('beforeCreateFile', [$this, 'beforeCreateFile']); 188 $server->on('beforeWriteContent', [$this, 'beforeWriteContent']); 189 $server->on('afterMethod:GET', [$this, 'httpAfterGET']); 190 $server->on('getSupportedPrivilegeSet', [$this, 'getSupportedPrivilegeSet']); 191 192 $server->xml->namespaceMap[self::NS_CALDAV] = 'cal'; 193 $server->xml->namespaceMap[self::NS_CALENDARSERVER] = 'cs'; 194 195 $server->xml->elementMap['{' . self::NS_CALDAV . '}supported-calendar-component-set'] = 'Sabre\\CalDAV\\Xml\\Property\\SupportedCalendarComponentSet'; 196 $server->xml->elementMap['{' . self::NS_CALDAV . '}calendar-query'] = 'Sabre\\CalDAV\\Xml\\Request\\CalendarQueryReport'; 197 $server->xml->elementMap['{' . self::NS_CALDAV . '}calendar-multiget'] = 'Sabre\\CalDAV\\Xml\\Request\\CalendarMultiGetReport'; 198 $server->xml->elementMap['{' . self::NS_CALDAV . '}free-busy-query'] = 'Sabre\\CalDAV\\Xml\\Request\\FreeBusyQueryReport'; 199 $server->xml->elementMap['{' . self::NS_CALDAV . '}mkcalendar'] = 'Sabre\\CalDAV\\Xml\\Request\\MkCalendar'; 200 $server->xml->elementMap['{' . self::NS_CALDAV . '}schedule-calendar-transp'] = 'Sabre\\CalDAV\\Xml\\Property\\ScheduleCalendarTransp'; 201 $server->xml->elementMap['{' . self::NS_CALDAV . '}supported-calendar-component-set'] = 'Sabre\\CalDAV\\Xml\\Property\\SupportedCalendarComponentSet'; 202 203 $server->resourceTypeMapping['\\Sabre\\CalDAV\\ICalendar'] = '{urn:ietf:params:xml:ns:caldav}calendar'; 204 205 $server->resourceTypeMapping['\\Sabre\\CalDAV\\Principal\\IProxyRead'] = '{http://calendarserver.org/ns/}calendar-proxy-read'; 206 $server->resourceTypeMapping['\\Sabre\\CalDAV\\Principal\\IProxyWrite'] = '{http://calendarserver.org/ns/}calendar-proxy-write'; 207 208 array_push($server->protectedProperties, 209 210 '{' . self::NS_CALDAV . '}supported-calendar-component-set', 211 '{' . self::NS_CALDAV . '}supported-calendar-data', 212 '{' . self::NS_CALDAV . '}max-resource-size', 213 '{' . self::NS_CALDAV . '}min-date-time', 214 '{' . self::NS_CALDAV . '}max-date-time', 215 '{' . self::NS_CALDAV . '}max-instances', 216 '{' . self::NS_CALDAV . '}max-attendees-per-instance', 217 '{' . self::NS_CALDAV . '}calendar-home-set', 218 '{' . self::NS_CALDAV . '}supported-collation-set', 219 '{' . self::NS_CALDAV . '}calendar-data', 220 221 // CalendarServer extensions 222 '{' . self::NS_CALENDARSERVER . '}getctag', 223 '{' . self::NS_CALENDARSERVER . '}calendar-proxy-read-for', 224 '{' . self::NS_CALENDARSERVER . '}calendar-proxy-write-for' 225 226 ); 227 228 if ($aclPlugin = $server->getPlugin('acl')) { 229 $aclPlugin->principalSearchPropertySet['{' . self::NS_CALDAV . '}calendar-user-address-set'] = 'Calendar address'; 230 } 231 } 232 233 /** 234 * This functions handles REPORT requests specific to CalDAV 235 * 236 * @param string $reportName 237 * @param mixed $report 238 * @param mixed $path 239 * @return bool 240 */ 241 function report($reportName, $report, $path) { 242 243 switch ($reportName) { 244 case '{' . self::NS_CALDAV . '}calendar-multiget' : 245 $this->server->transactionType = 'report-calendar-multiget'; 246 $this->calendarMultiGetReport($report); 247 return false; 248 case '{' . self::NS_CALDAV . '}calendar-query' : 249 $this->server->transactionType = 'report-calendar-query'; 250 $this->calendarQueryReport($report); 251 return false; 252 case '{' . self::NS_CALDAV . '}free-busy-query' : 253 $this->server->transactionType = 'report-free-busy-query'; 254 $this->freeBusyQueryReport($report); 255 return false; 256 257 } 258 259 260 } 261 262 /** 263 * This function handles the MKCALENDAR HTTP method, which creates 264 * a new calendar. 265 * 266 * @param RequestInterface $request 267 * @param ResponseInterface $response 268 * @return bool 269 */ 270 function httpMkCalendar(RequestInterface $request, ResponseInterface $response) { 271 272 $body = $request->getBodyAsString(); 273 $path = $request->getPath(); 274 275 $properties = []; 276 277 if ($body) { 278 279 try { 280 $mkcalendar = $this->server->xml->expect( 281 '{urn:ietf:params:xml:ns:caldav}mkcalendar', 282 $body 283 ); 284 } catch (\Sabre\Xml\ParseException $e) { 285 throw new BadRequest($e->getMessage(), null, $e); 286 } 287 $properties = $mkcalendar->getProperties(); 288 289 } 290 291 // iCal abuses MKCALENDAR since iCal 10.9.2 to create server-stored 292 // subscriptions. Before that it used MKCOL which was the correct way 293 // to do this. 294 // 295 // If the body had a {DAV:}resourcetype, it means we stumbled upon this 296 // request, and we simply use it instead of the pre-defined list. 297 if (isset($properties['{DAV:}resourcetype'])) { 298 $resourceType = $properties['{DAV:}resourcetype']->getValue(); 299 } else { 300 $resourceType = ['{DAV:}collection','{urn:ietf:params:xml:ns:caldav}calendar']; 301 } 302 303 $this->server->createCollection($path, new MkCol($resourceType, $properties)); 304 305 $response->setStatus(201); 306 $response->setHeader('Content-Length', 0); 307 308 // This breaks the method chain. 309 return false; 310 } 311 312 /** 313 * PropFind 314 * 315 * This method handler is invoked before any after properties for a 316 * resource are fetched. This allows us to add in any CalDAV specific 317 * properties. 318 * 319 * @param DAV\PropFind $propFind 320 * @param DAV\INode $node 321 * @return void 322 */ 323 function propFind(DAV\PropFind $propFind, DAV\INode $node) { 324 325 $ns = '{' . self::NS_CALDAV . '}'; 326 327 if ($node instanceof ICalendarObjectContainer) { 328 329 $propFind->handle($ns . 'max-resource-size', $this->maxResourceSize); 330 $propFind->handle($ns . 'supported-calendar-data', function() { 331 return new Xml\Property\SupportedCalendarData(); 332 }); 333 $propFind->handle($ns . 'supported-collation-set', function() { 334 return new Xml\Property\SupportedCollationSet(); 335 }); 336 337 } 338 339 if ($node instanceof DAVACL\IPrincipal) { 340 341 $principalUrl = $node->getPrincipalUrl(); 342 343 $propFind->handle('{' . self::NS_CALDAV . '}calendar-home-set', function() use ($principalUrl) { 344 345 $calendarHomePath = $this->getCalendarHomeForPrincipal($principalUrl); 346 if (is_null($calendarHomePath)) return null; 347 return new LocalHref($calendarHomePath . '/'); 348 349 }); 350 // The calendar-user-address-set property is basically mapped to 351 // the {DAV:}alternate-URI-set property. 352 $propFind->handle('{' . self::NS_CALDAV . '}calendar-user-address-set', function() use ($node) { 353 $addresses = $node->getAlternateUriSet(); 354 $addresses[] = $this->server->getBaseUri() . $node->getPrincipalUrl() . '/'; 355 return new LocalHref($addresses); 356 }); 357 // For some reason somebody thought it was a good idea to add 358 // another one of these properties. We're supporting it too. 359 $propFind->handle('{' . self::NS_CALENDARSERVER . '}email-address-set', function() use ($node) { 360 $addresses = $node->getAlternateUriSet(); 361 $emails = []; 362 foreach ($addresses as $address) { 363 if (substr($address, 0, 7) === 'mailto:') { 364 $emails[] = substr($address, 7); 365 } 366 } 367 return new Xml\Property\EmailAddressSet($emails); 368 }); 369 370 // These two properties are shortcuts for ical to easily find 371 // other principals this principal has access to. 372 $propRead = '{' . self::NS_CALENDARSERVER . '}calendar-proxy-read-for'; 373 $propWrite = '{' . self::NS_CALENDARSERVER . '}calendar-proxy-write-for'; 374 375 if ($propFind->getStatus($propRead) === 404 || $propFind->getStatus($propWrite) === 404) { 376 377 $aclPlugin = $this->server->getPlugin('acl'); 378 $membership = $aclPlugin->getPrincipalMembership($propFind->getPath()); 379 $readList = []; 380 $writeList = []; 381 382 foreach ($membership as $group) { 383 384 $groupNode = $this->server->tree->getNodeForPath($group); 385 386 $listItem = Uri\split($group)[0] . '/'; 387 388 // If the node is either ap proxy-read or proxy-write 389 // group, we grab the parent principal and add it to the 390 // list. 391 if ($groupNode instanceof Principal\IProxyRead) { 392 $readList[] = $listItem; 393 } 394 if ($groupNode instanceof Principal\IProxyWrite) { 395 $writeList[] = $listItem; 396 } 397 398 } 399 400 $propFind->set($propRead, new LocalHref($readList)); 401 $propFind->set($propWrite, new LocalHref($writeList)); 402 403 } 404 405 } // instanceof IPrincipal 406 407 if ($node instanceof ICalendarObject) { 408 409 // The calendar-data property is not supposed to be a 'real' 410 // property, but in large chunks of the spec it does act as such. 411 // Therefore we simply expose it as a property. 412 $propFind->handle('{' . self::NS_CALDAV . '}calendar-data', function() use ($node) { 413 $val = $node->get(); 414 if (is_resource($val)) 415 $val = stream_get_contents($val); 416 417 // Taking out \r to not screw up the xml output 418 return str_replace("\r", "", $val); 419 420 }); 421 422 } 423 424 } 425 426 /** 427 * This function handles the calendar-multiget REPORT. 428 * 429 * This report is used by the client to fetch the content of a series 430 * of urls. Effectively avoiding a lot of redundant requests. 431 * 432 * @param CalendarMultiGetReport $report 433 * @return void 434 */ 435 function calendarMultiGetReport($report) { 436 437 $needsJson = $report->contentType === 'application/calendar+json'; 438 439 $timeZones = []; 440 $propertyList = []; 441 442 $paths = array_map( 443 [$this->server, 'calculateUri'], 444 $report->hrefs 445 ); 446 447 foreach ($this->server->getPropertiesForMultiplePaths($paths, $report->properties) as $uri => $objProps) { 448 449 if (($needsJson || $report->expand) && isset($objProps[200]['{' . self::NS_CALDAV . '}calendar-data'])) { 450 $vObject = VObject\Reader::read($objProps[200]['{' . self::NS_CALDAV . '}calendar-data']); 451 452 if ($report->expand) { 453 // We're expanding, and for that we need to figure out the 454 // calendar's timezone. 455 list($calendarPath) = Uri\split($uri); 456 if (!isset($timeZones[$calendarPath])) { 457 // Checking the calendar-timezone property. 458 $tzProp = '{' . self::NS_CALDAV . '}calendar-timezone'; 459 $tzResult = $this->server->getProperties($calendarPath, [$tzProp]); 460 if (isset($tzResult[$tzProp])) { 461 // This property contains a VCALENDAR with a single 462 // VTIMEZONE. 463 $vtimezoneObj = VObject\Reader::read($tzResult[$tzProp]); 464 $timeZone = $vtimezoneObj->VTIMEZONE->getTimeZone(); 465 } else { 466 // Defaulting to UTC. 467 $timeZone = new DateTimeZone('UTC'); 468 } 469 $timeZones[$calendarPath] = $timeZone; 470 } 471 472 $vObject = $vObject->expand($report->expand['start'], $report->expand['end'], $timeZones[$calendarPath]); 473 } 474 if ($needsJson) { 475 $objProps[200]['{' . self::NS_CALDAV . '}calendar-data'] = json_encode($vObject->jsonSerialize()); 476 } else { 477 $objProps[200]['{' . self::NS_CALDAV . '}calendar-data'] = $vObject->serialize(); 478 } 479 // Destroy circular references so PHP will garbage collect the 480 // object. 481 $vObject->destroy(); 482 } 483 484 $propertyList[] = $objProps; 485 486 } 487 488 $prefer = $this->server->getHTTPPrefer(); 489 490 $this->server->httpResponse->setStatus(207); 491 $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); 492 $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer'); 493 $this->server->httpResponse->setBody($this->server->generateMultiStatus($propertyList, $prefer['return'] === 'minimal')); 494 495 } 496 497 /** 498 * This function handles the calendar-query REPORT 499 * 500 * This report is used by clients to request calendar objects based on 501 * complex conditions. 502 * 503 * @param Xml\Request\CalendarQueryReport $report 504 * @return void 505 */ 506 function calendarQueryReport($report) { 507 508 $path = $this->server->getRequestUri(); 509 510 $needsJson = $report->contentType === 'application/calendar+json'; 511 512 $node = $this->server->tree->getNodeForPath($this->server->getRequestUri()); 513 $depth = $this->server->getHTTPDepth(0); 514 515 // The default result is an empty array 516 $result = []; 517 518 $calendarTimeZone = null; 519 if ($report->expand) { 520 // We're expanding, and for that we need to figure out the 521 // calendar's timezone. 522 $tzProp = '{' . self::NS_CALDAV . '}calendar-timezone'; 523 $tzResult = $this->server->getProperties($path, [$tzProp]); 524 if (isset($tzResult[$tzProp])) { 525 // This property contains a VCALENDAR with a single 526 // VTIMEZONE. 527 $vtimezoneObj = VObject\Reader::read($tzResult[$tzProp]); 528 $calendarTimeZone = $vtimezoneObj->VTIMEZONE->getTimeZone(); 529 530 // Destroy circular references so PHP will garbage collect the 531 // object. 532 $vtimezoneObj->destroy(); 533 } else { 534 // Defaulting to UTC. 535 $calendarTimeZone = new DateTimeZone('UTC'); 536 } 537 } 538 539 // The calendarobject was requested directly. In this case we handle 540 // this locally. 541 if ($depth == 0 && $node instanceof ICalendarObject) { 542 543 $requestedCalendarData = true; 544 $requestedProperties = $report->properties; 545 546 if (!in_array('{urn:ietf:params:xml:ns:caldav}calendar-data', $requestedProperties)) { 547 548 // We always retrieve calendar-data, as we need it for filtering. 549 $requestedProperties[] = '{urn:ietf:params:xml:ns:caldav}calendar-data'; 550 551 // If calendar-data wasn't explicitly requested, we need to remove 552 // it after processing. 553 $requestedCalendarData = false; 554 } 555 556 $properties = $this->server->getPropertiesForPath( 557 $path, 558 $requestedProperties, 559 0 560 ); 561 562 // This array should have only 1 element, the first calendar 563 // object. 564 $properties = current($properties); 565 566 // If there wasn't any calendar-data returned somehow, we ignore 567 // this. 568 if (isset($properties[200]['{urn:ietf:params:xml:ns:caldav}calendar-data'])) { 569 570 $validator = new CalendarQueryValidator(); 571 572 $vObject = VObject\Reader::read($properties[200]['{urn:ietf:params:xml:ns:caldav}calendar-data']); 573 if ($validator->validate($vObject, $report->filters)) { 574 575 // If the client didn't require the calendar-data property, 576 // we won't give it back. 577 if (!$requestedCalendarData) { 578 unset($properties[200]['{urn:ietf:params:xml:ns:caldav}calendar-data']); 579 } else { 580 581 582 if ($report->expand) { 583 $vObject = $vObject->expand($report->expand['start'], $report->expand['end'], $calendarTimeZone); 584 } 585 if ($needsJson) { 586 $properties[200]['{' . self::NS_CALDAV . '}calendar-data'] = json_encode($vObject->jsonSerialize()); 587 } elseif ($report->expand) { 588 $properties[200]['{' . self::NS_CALDAV . '}calendar-data'] = $vObject->serialize(); 589 } 590 } 591 592 $result = [$properties]; 593 594 } 595 // Destroy circular references so PHP will garbage collect the 596 // object. 597 $vObject->destroy(); 598 599 } 600 601 } 602 603 if ($node instanceof ICalendarObjectContainer && $depth === 0) { 604 605 if (strpos($this->server->httpRequest->getHeader('User-Agent'), 'MSFT-') === 0) { 606 // Microsoft clients incorrectly supplied depth as 0, when it actually 607 // should have set depth to 1. We're implementing a workaround here 608 // to deal with this. 609 // 610 // This targets at least the following clients: 611 // Windows 10 612 // Windows Phone 8, 10 613 $depth = 1; 614 } else { 615 throw new BadRequest('A calendar-query REPORT on a calendar with a Depth: 0 is undefined. Set Depth to 1'); 616 } 617 618 } 619 620 // If we're dealing with a calendar, the calendar itself is responsible 621 // for the calendar-query. 622 if ($node instanceof ICalendarObjectContainer && $depth == 1) { 623 624 $nodePaths = $node->calendarQuery($report->filters); 625 626 foreach ($nodePaths as $path) { 627 628 list($properties) = 629 $this->server->getPropertiesForPath($this->server->getRequestUri() . '/' . $path, $report->properties); 630 631 if (($needsJson || $report->expand)) { 632 $vObject = VObject\Reader::read($properties[200]['{' . self::NS_CALDAV . '}calendar-data']); 633 634 if ($report->expand) { 635 $vObject = $vObject->expand($report->expand['start'], $report->expand['end'], $calendarTimeZone); 636 } 637 638 if ($needsJson) { 639 $properties[200]['{' . self::NS_CALDAV . '}calendar-data'] = json_encode($vObject->jsonSerialize()); 640 } else { 641 $properties[200]['{' . self::NS_CALDAV . '}calendar-data'] = $vObject->serialize(); 642 } 643 644 // Destroy circular references so PHP will garbage collect the 645 // object. 646 $vObject->destroy(); 647 } 648 $result[] = $properties; 649 650 } 651 652 } 653 654 $prefer = $this->server->getHTTPPrefer(); 655 656 $this->server->httpResponse->setStatus(207); 657 $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); 658 $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer'); 659 $this->server->httpResponse->setBody($this->server->generateMultiStatus($result, $prefer['return'] === 'minimal')); 660 661 } 662 663 /** 664 * This method is responsible for parsing the request and generating the 665 * response for the CALDAV:free-busy-query REPORT. 666 * 667 * @param Xml\Request\FreeBusyQueryReport $report 668 * @return void 669 */ 670 protected function freeBusyQueryReport(Xml\Request\FreeBusyQueryReport $report) { 671 672 $uri = $this->server->getRequestUri(); 673 674 $acl = $this->server->getPlugin('acl'); 675 if ($acl) { 676 $acl->checkPrivileges($uri, '{' . self::NS_CALDAV . '}read-free-busy'); 677 } 678 679 $calendar = $this->server->tree->getNodeForPath($uri); 680 if (!$calendar instanceof ICalendar) { 681 throw new DAV\Exception\NotImplemented('The free-busy-query REPORT is only implemented on calendars'); 682 } 683 684 $tzProp = '{' . self::NS_CALDAV . '}calendar-timezone'; 685 686 // Figuring out the default timezone for the calendar, for floating 687 // times. 688 $calendarProps = $this->server->getProperties($uri, [$tzProp]); 689 690 if (isset($calendarProps[$tzProp])) { 691 $vtimezoneObj = VObject\Reader::read($calendarProps[$tzProp]); 692 $calendarTimeZone = $vtimezoneObj->VTIMEZONE->getTimeZone(); 693 // Destroy circular references so PHP will garbage collect the object. 694 $vtimezoneObj->destroy(); 695 } else { 696 $calendarTimeZone = new DateTimeZone('UTC'); 697 } 698 699 // Doing a calendar-query first, to make sure we get the most 700 // performance. 701 $urls = $calendar->calendarQuery([ 702 'name' => 'VCALENDAR', 703 'comp-filters' => [ 704 [ 705 'name' => 'VEVENT', 706 'comp-filters' => [], 707 'prop-filters' => [], 708 'is-not-defined' => false, 709 'time-range' => [ 710 'start' => $report->start, 711 'end' => $report->end, 712 ], 713 ], 714 ], 715 'prop-filters' => [], 716 'is-not-defined' => false, 717 'time-range' => null, 718 ]); 719 720 $objects = array_map(function($url) use ($calendar) { 721 $obj = $calendar->getChild($url)->get(); 722 return $obj; 723 }, $urls); 724 725 $generator = new VObject\FreeBusyGenerator(); 726 $generator->setObjects($objects); 727 $generator->setTimeRange($report->start, $report->end); 728 $generator->setTimeZone($calendarTimeZone); 729 $result = $generator->getResult(); 730 $result = $result->serialize(); 731 732 $this->server->httpResponse->setStatus(200); 733 $this->server->httpResponse->setHeader('Content-Type', 'text/calendar'); 734 $this->server->httpResponse->setHeader('Content-Length', strlen($result)); 735 $this->server->httpResponse->setBody($result); 736 737 } 738 739 /** 740 * This method is triggered before a file gets updated with new content. 741 * 742 * This plugin uses this method to ensure that CalDAV objects receive 743 * valid calendar data. 744 * 745 * @param string $path 746 * @param DAV\IFile $node 747 * @param resource $data 748 * @param bool $modified Should be set to true, if this event handler 749 * changed &$data. 750 * @return void 751 */ 752 function beforeWriteContent($path, DAV\IFile $node, &$data, &$modified) { 753 754 if (!$node instanceof ICalendarObject) 755 return; 756 757 // We're onyl interested in ICalendarObject nodes that are inside of a 758 // real calendar. This is to avoid triggering validation and scheduling 759 // for non-calendars (such as an inbox). 760 list($parent) = Uri\split($path); 761 $parentNode = $this->server->tree->getNodeForPath($parent); 762 763 if (!$parentNode instanceof ICalendar) 764 return; 765 766 $this->validateICalendar( 767 $data, 768 $path, 769 $modified, 770 $this->server->httpRequest, 771 $this->server->httpResponse, 772 false 773 ); 774 775 } 776 777 /** 778 * This method is triggered before a new file is created. 779 * 780 * This plugin uses this method to ensure that newly created calendar 781 * objects contain valid calendar data. 782 * 783 * @param string $path 784 * @param resource $data 785 * @param DAV\ICollection $parentNode 786 * @param bool $modified Should be set to true, if this event handler 787 * changed &$data. 788 * @return void 789 */ 790 function beforeCreateFile($path, &$data, DAV\ICollection $parentNode, &$modified) { 791 792 if (!$parentNode instanceof ICalendar) 793 return; 794 795 $this->validateICalendar( 796 $data, 797 $path, 798 $modified, 799 $this->server->httpRequest, 800 $this->server->httpResponse, 801 true 802 ); 803 804 } 805 806 /** 807 * Checks if the submitted iCalendar data is in fact, valid. 808 * 809 * An exception is thrown if it's not. 810 * 811 * @param resource|string $data 812 * @param string $path 813 * @param bool $modified Should be set to true, if this event handler 814 * changed &$data. 815 * @param RequestInterface $request The http request. 816 * @param ResponseInterface $response The http response. 817 * @param bool $isNew Is the item a new one, or an update. 818 * @return void 819 */ 820 protected function validateICalendar(&$data, $path, &$modified, RequestInterface $request, ResponseInterface $response, $isNew) { 821 822 // If it's a stream, we convert it to a string first. 823 if (is_resource($data)) { 824 $data = stream_get_contents($data); 825 } 826 827 $before = $data; 828 829 try { 830 831 // If the data starts with a [, we can reasonably assume we're dealing 832 // with a jCal object. 833 if (substr($data, 0, 1) === '[') { 834 $vobj = VObject\Reader::readJson($data); 835 836 // Converting $data back to iCalendar, as that's what we 837 // technically support everywhere. 838 $data = $vobj->serialize(); 839 $modified = true; 840 } else { 841 $vobj = VObject\Reader::read($data); 842 } 843 844 } catch (VObject\ParseException $e) { 845 846 throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid iCalendar 2.0 data. Parse error: ' . $e->getMessage()); 847 848 } 849 850 if ($vobj->name !== 'VCALENDAR') { 851 throw new DAV\Exception\UnsupportedMediaType('This collection can only support iCalendar objects.'); 852 } 853 854 $sCCS = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'; 855 856 // Get the Supported Components for the target calendar 857 list($parentPath) = Uri\split($path); 858 $calendarProperties = $this->server->getProperties($parentPath, [$sCCS]); 859 860 if (isset($calendarProperties[$sCCS])) { 861 $supportedComponents = $calendarProperties[$sCCS]->getValue(); 862 } else { 863 $supportedComponents = ['VJOURNAL', 'VTODO', 'VEVENT']; 864 } 865 866 $foundType = null; 867 868 foreach ($vobj->getComponents() as $component) { 869 switch ($component->name) { 870 case 'VTIMEZONE' : 871 continue 2; 872 case 'VEVENT' : 873 case 'VTODO' : 874 case 'VJOURNAL' : 875 $foundType = $component->name; 876 break; 877 } 878 879 } 880 881 if (!$foundType || !in_array($foundType, $supportedComponents)) { 882 throw new Exception\InvalidComponentType('iCalendar objects must at least have a component of type ' . implode(', ', $supportedComponents)); 883 } 884 885 $options = VObject\Node::PROFILE_CALDAV; 886 $prefer = $this->server->getHTTPPrefer(); 887 888 if ($prefer['handling'] !== 'strict') { 889 $options |= VObject\Node::REPAIR; 890 } 891 892 $messages = $vobj->validate($options); 893 894 $highestLevel = 0; 895 $warningMessage = null; 896 897 // $messages contains a list of problems with the vcard, along with 898 // their severity. 899 foreach ($messages as $message) { 900 901 if ($message['level'] > $highestLevel) { 902 // Recording the highest reported error level. 903 $highestLevel = $message['level']; 904 $warningMessage = $message['message']; 905 } 906 switch ($message['level']) { 907 908 case 1 : 909 // Level 1 means that there was a problem, but it was repaired. 910 $modified = true; 911 break; 912 case 2 : 913 // Level 2 means a warning, but not critical 914 break; 915 case 3 : 916 // Level 3 means a critical error 917 throw new DAV\Exception\UnsupportedMediaType('Validation error in iCalendar: ' . $message['message']); 918 919 } 920 921 } 922 if ($warningMessage) { 923 $response->setHeader( 924 'X-Sabre-Ew-Gross', 925 'iCalendar validation warning: ' . $warningMessage 926 ); 927 } 928 929 // We use an extra variable to allow event handles to tell us whether 930 // the object was modified or not. 931 // 932 // This helps us determine if we need to re-serialize the object. 933 $subModified = false; 934 935 $this->server->emit( 936 'calendarObjectChange', 937 [ 938 $request, 939 $response, 940 $vobj, 941 $parentPath, 942 &$subModified, 943 $isNew 944 ] 945 ); 946 947 if ($modified || $subModified) { 948 // An event handler told us that it modified the object. 949 $data = $vobj->serialize(); 950 951 // Using md5 to figure out if there was an *actual* change. 952 if (!$modified && strcmp($data, $before) !== 0) { 953 $modified = true; 954 } 955 956 } 957 958 // Destroy circular references so PHP will garbage collect the object. 959 $vobj->destroy(); 960 961 } 962 963 /** 964 * This method is triggered whenever a subsystem reqeuests the privileges 965 * that are supported on a particular node. 966 * 967 * @param INode $node 968 * @param array $supportedPrivilegeSet 969 */ 970 function getSupportedPrivilegeSet(INode $node, array &$supportedPrivilegeSet) { 971 972 if ($node instanceof ICalendar) { 973 $supportedPrivilegeSet['{DAV:}read']['aggregates']['{' . self::NS_CALDAV . '}read-free-busy'] = [ 974 'abstract' => false, 975 'aggregates' => [], 976 ]; 977 } 978 } 979 980 /** 981 * This method is used to generate HTML output for the 982 * DAV\Browser\Plugin. This allows us to generate an interface users 983 * can use to create new calendars. 984 * 985 * @param DAV\INode $node 986 * @param string $output 987 * @return bool 988 */ 989 function htmlActionsPanel(DAV\INode $node, &$output) { 990 991 if (!$node instanceof CalendarHome) 992 return; 993 994 $output .= '<tr><td colspan="2"><form method="post" action=""> 995 <h3>Create new calendar</h3> 996 <input type="hidden" name="sabreAction" value="mkcol" /> 997 <input type="hidden" name="resourceType" value="{DAV:}collection,{' . self::NS_CALDAV . '}calendar" /> 998 <label>Name (uri):</label> <input type="text" name="name" /><br /> 999 <label>Display name:</label> <input type="text" name="{DAV:}displayname" /><br /> 1000 <input type="submit" value="create" /> 1001 </form> 1002 </td></tr>'; 1003 1004 return false; 1005 1006 } 1007 1008 /** 1009 * This event is triggered after GET requests. 1010 * 1011 * This is used to transform data into jCal, if this was requested. 1012 * 1013 * @param RequestInterface $request 1014 * @param ResponseInterface $response 1015 * @return void 1016 */ 1017 function httpAfterGet(RequestInterface $request, ResponseInterface $response) { 1018 1019 if (strpos($response->getHeader('Content-Type'), 'text/calendar') === false) { 1020 return; 1021 } 1022 1023 $result = HTTP\Util::negotiate( 1024 $request->getHeader('Accept'), 1025 ['text/calendar', 'application/calendar+json'] 1026 ); 1027 1028 if ($result !== 'application/calendar+json') { 1029 // Do nothing 1030 return; 1031 } 1032 1033 // Transforming. 1034 $vobj = VObject\Reader::read($response->getBody()); 1035 1036 $jsonBody = json_encode($vobj->jsonSerialize()); 1037 $response->setBody($jsonBody); 1038 1039 // Destroy circular references so PHP will garbage collect the object. 1040 $vobj->destroy(); 1041 1042 $response->setHeader('Content-Type', 'application/calendar+json'); 1043 $response->setHeader('Content-Length', strlen($jsonBody)); 1044 1045 } 1046 1047 /** 1048 * Returns a bunch of meta-data about the plugin. 1049 * 1050 * Providing this information is optional, and is mainly displayed by the 1051 * Browser plugin. 1052 * 1053 * The description key in the returned array may contain html and will not 1054 * be sanitized. 1055 * 1056 * @return array 1057 */ 1058 function getPluginInfo() { 1059 1060 return [ 1061 'name' => $this->getPluginName(), 1062 'description' => 'Adds support for CalDAV (rfc4791)', 1063 'link' => 'http://sabre.io/dav/caldav/', 1064 ]; 1065 1066 } 1067 1068} 1069