/** * Intercepts GET requests on calendar urls ending with ?export. * * @param RequestInterface $request * @param ResponseInterface $response * @return bool */ function httpGet(RequestInterface $request, ResponseInterface $response) { $queryParams = $request->getQueryParameters(); if (!array_key_exists('export', $queryParams)) { return; } $path = $request->getPath(); $node = $this->server->getProperties($path, ['{DAV:}resourcetype', '{DAV:}displayname', '{http://sabredav.org/ns}sync-token', '{http://apple.com/ns/ical/}calendar-color']); if (!isset($node['{DAV:}resourcetype']) || !$node['{DAV:}resourcetype']->is('{' . Plugin::NS_CALDAV . '}calendar')) { return; } // Marking the transactionType, for logging purposes. $this->server->transactionType = 'get-calendar-export'; $properties = $node; $start = null; $end = null; $expand = false; if (isset($queryParams['start'])) { if (!ctype_digit($queryParams['start'])) { throw new BadRequest('The start= parameter must contain a unix timestamp'); } $start = DateTime::createFromFormat('U', $queryParams['start']); } if (isset($queryParams['end'])) { if (!ctype_digit($queryParams['end'])) { throw new BadRequest('The end= parameter must contain a unix timestamp'); } $end = DateTime::createFromFormat('U', $queryParams['end']); } if (isset($queryParams['expand']) && !!$queryParams['expand']) { if (!$start || !$end) { throw new BadRequest('If you\'d like to expand recurrences, you must specify both a start= and end= parameter.'); } $expand = true; } $format = \Sabre\HTTP\Util::Negotiate($request->getHeader('Accept'), ['text/calendar', 'application/calendar+json']); if (isset($queryParams['accept'])) { if ($queryParams['accept'] === 'application/calendar+json' || $queryParams['accept'] === 'jcal') { $format = 'application/calendar+json'; } } if (!$format) { $format = 'text/calendar'; } $this->generateResponse($path, $start, $end, $expand, $format, $properties, $response); // Returning false to break the event chain return false; }
/** * This method is called when properties are retrieved. * * This specific handler is called very late in the process, because we * want other systems to first have a chance to handle the properties. * * @param PropFind $propFind * @param INode $node * @return void */ function propFindLate(PropFind $propFind, INode $node) { $propFind->handle('{http://calendarserver.org/ns/}getctag', function () use($propFind) { // If we already have a sync-token from the current propFind // request, we can re-use that. $val = $propFind->get('{http://sabredav.org/ns}sync-token'); if ($val) { return $val; } $val = $propFind->get('{DAV:}sync-token'); if ($val && is_scalar($val)) { return $val; } if ($val && $val instanceof Property\IHref) { return substr($val->getHref(), strlen(Sync\Plugin::SYNCTOKEN_PREFIX)); } // If we got here, the earlier two properties may simply not have // been part of the earlier request. We're going to fetch them. $result = $this->server->getProperties($propFind->getPath(), ['{http://sabredav.org/ns}sync-token', '{DAV:}sync-token']); if (isset($result['{http://sabredav.org/ns}sync-token'])) { return $result['{http://sabredav.org/ns}sync-token']; } if (isset($result['{DAV:}sync-token'])) { $val = $result['{DAV:}sync-token']; if (is_scalar($val)) { return $val; } elseif ($val instanceof Property\IHref) { return substr($val->getHref(), strlen(Sync\Plugin::SYNCTOKEN_PREFIX)); } } }); }
/** * This method is responsible for generating the actual, full response. * * @param string $path * @param DateTime|null $start * @param DateTime|null $end * @param bool $expand * @param string $componentType * @param string $format * @param array $properties * @param ResponseInterface $response */ protected function generateResponse($path, $start, $end, $expand, $componentType, $format, $properties, ResponseInterface $response) { $calDataProp = '{' . Plugin::NS_CALDAV . '}calendar-data'; $blobs = []; if ($start || $end || $componentType) { // If there was a start or end filter, we need to enlist // calendarQuery for speed. $calendarNode = $this->server->tree->getNodeForPath($path); $queryResult = $calendarNode->calendarQuery(['name' => 'VCALENDAR', 'comp-filters' => [['name' => $componentType, 'comp-filters' => [], 'prop-filters' => [], 'is-not-defined' => false, 'time-range' => ['start' => $start, 'end' => $end]]], 'prop-filters' => [], 'is-not-defined' => false, 'time-range' => null]); // queryResult is just a list of base urls. We need to prefix the // calendar path. $queryResult = array_map(function ($item) use($path) { return $path . '/' . $item; }, $queryResult); $nodes = $this->server->getPropertiesForMultiplePaths($queryResult, [$calDataProp]); unset($queryResult); } else { $nodes = $this->server->getPropertiesForPath($path, [$calDataProp], 1); } // Flattening the arrays foreach ($nodes as $node) { if (isset($node[200][$calDataProp])) { $blobs[$node['href']] = $node[200][$calDataProp]; } } unset($nodes); $mergedCalendar = $this->mergeObjects($properties, $blobs); if ($expand) { $calendarTimeZone = null; // We're expanding, and for that we need to figure out the // calendar's timezone. $tzProp = '{' . Plugin::NS_CALDAV . '}calendar-timezone'; $tzResult = $this->server->getProperties($path, [$tzProp]); if (isset($tzResult[$tzProp])) { // This property contains a VCALENDAR with a single // VTIMEZONE. $vtimezoneObj = VObject\Reader::read($tzResult[$tzProp]); $calendarTimeZone = $vtimezoneObj->VTIMEZONE->getTimeZone(); // Destroy circular references to PHP will GC the object. $vtimezoneObj->destroy(); unset($vtimezoneObj); } else { // Defaulting to UTC. $calendarTimeZone = new DateTimeZone('UTC'); } $mergedCalendar->expand($start, $end, $calendarTimeZone); } $response->setHeader('Content-Type', $format); switch ($format) { case 'text/calendar': $mergedCalendar = $mergedCalendar->serialize(); break; case 'application/calendar+json': $mergedCalendar = json_encode($mergedCalendar->jsonSerialize()); break; } $response->setStatus(200); $response->setBody($mergedCalendar); }
/** * This method is responsible for parsing a free-busy query request and * returning it's result. * * @param IOutbox $outbox * @param VObject\Component $vObject * @param RequestInterface $request * @param ResponseInterface $response * @return string */ protected function handleFreeBusyRequest(IOutbox $outbox, VObject\Component $vObject, RequestInterface $request, ResponseInterface $response) { $vFreeBusy = $vObject->VFREEBUSY; $organizer = $vFreeBusy->organizer; $organizer = (string) $organizer; // Validating if the organizer matches the owner of the inbox. $owner = $outbox->getOwner(); $caldavNS = '{' . self::NS_CALDAV . '}'; $uas = $caldavNS . 'calendar-user-address-set'; $props = $this->server->getProperties($owner, [$uas]); if (empty($props[$uas]) || !in_array($organizer, $props[$uas]->getHrefs())) { throw new Forbidden('The organizer in the request did not match any of the addresses for the owner of this inbox'); } if (!isset($vFreeBusy->ATTENDEE)) { throw new BadRequest('You must at least specify 1 attendee'); } $attendees = []; foreach ($vFreeBusy->ATTENDEE as $attendee) { $attendees[] = (string) $attendee; } if (!isset($vFreeBusy->DTSTART) || !isset($vFreeBusy->DTEND)) { throw new BadRequest('DTSTART and DTEND must both be specified'); } $startRange = $vFreeBusy->DTSTART->getDateTime(); $endRange = $vFreeBusy->DTEND->getDateTime(); $results = []; foreach ($attendees as $attendee) { $results[] = $this->getFreeBusyForEmail($attendee, $startRange, $endRange, $vObject); } $dom = new \DOMDocument('1.0', 'utf-8'); $dom->formatOutput = true; $scheduleResponse = $dom->createElement('cal:schedule-response'); foreach ($this->server->xmlNamespaces as $namespace => $prefix) { $scheduleResponse->setAttribute('xmlns:' . $prefix, $namespace); } $dom->appendChild($scheduleResponse); foreach ($results as $result) { $xresponse = $dom->createElement('cal:response'); $recipient = $dom->createElement('cal:recipient'); $recipientHref = $dom->createElement('d:href'); $recipientHref->appendChild($dom->createTextNode($result['href'])); $recipient->appendChild($recipientHref); $xresponse->appendChild($recipient); $reqStatus = $dom->createElement('cal:request-status'); $reqStatus->appendChild($dom->createTextNode($result['request-status'])); $xresponse->appendChild($reqStatus); if (isset($result['calendar-data'])) { $calendardata = $dom->createElement('cal:calendar-data'); $calendardata->appendChild($dom->createTextNode(str_replace("\r\n", "\n", $result['calendar-data']->serialize()))); $xresponse->appendChild($calendardata); } $scheduleResponse->appendChild($xresponse); } $response->setStatus(200); $response->setHeader('Content-Type', 'application/xml'); $response->setBody($dom->saveXML()); }
function testResourceType() { $tree = array(new DirectoryMock('directory')); $server = new DAV\Server($tree); $plugin = new Plugin(); $server->addPlugin($plugin); $props = $server->getProperties('directory', array('{DAV:}resourcetype')); $this->assertTrue($props['{DAV:}resourcetype']->is('{' . Plugin::NS_CARDDAV . '}directory')); }
/** * Checks if the submitted iCalendar data is in fact, valid. * * An exception is thrown if it's not. * * @param resource|string $data * @param string $path * @param bool $modified Should be set to true, if this event handler * changed &$data. * @param RequestInterface $request The http request. * @param ResponseInterface $response The http response. * @param bool $isNew Is the item a new one, or an update. * @return void */ protected function validateICalendar(&$data, $path, &$modified, RequestInterface $request, ResponseInterface $response, $isNew) { // If it's a stream, we convert it to a string first. if (is_resource($data)) { $data = stream_get_contents($data); } $before = md5($data); // Converting the data to unicode, if needed. $data = DAV\StringUtil::ensureUTF8($data); if ($before !== md5($data)) { $modified = true; } try { // If the data starts with a [, we can reasonably assume we're dealing // with a jCal object. if (substr($data, 0, 1) === '[') { $vobj = VObject\Reader::readJson($data); // Converting $data back to iCalendar, as that's what we // technically support everywhere. $data = $vobj->serialize(); $modified = true; } else { $vobj = VObject\Reader::read($data); } } catch (VObject\ParseException $e) { throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid iCalendar 2.0 data. Parse error: ' . $e->getMessage()); } if ($vobj->name !== 'VCALENDAR') { throw new DAV\Exception\UnsupportedMediaType('This collection can only support iCalendar objects.'); } $sCCS = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'; // Get the Supported Components for the target calendar list($parentPath) = Uri\split($path); $calendarProperties = $this->server->getProperties($parentPath, [$sCCS]); if (isset($calendarProperties[$sCCS])) { $supportedComponents = $calendarProperties[$sCCS]->getValue(); } else { $supportedComponents = ['VJOURNAL', 'VTODO', 'VEVENT']; } $foundType = null; $foundUID = null; foreach ($vobj->getComponents() as $component) { switch ($component->name) { case 'VTIMEZONE': continue 2; case 'VEVENT': case 'VTODO': case 'VJOURNAL': if (is_null($foundType)) { $foundType = $component->name; if (!in_array($foundType, $supportedComponents)) { throw new Exception\InvalidComponentType('This calendar only supports ' . implode(', ', $supportedComponents) . '. We found a ' . $foundType); } if (!isset($component->UID)) { throw new DAV\Exception\BadRequest('Every ' . $component->name . ' component must have an UID'); } $foundUID = (string) $component->UID; } else { if ($foundType !== $component->name) { throw new DAV\Exception\BadRequest('A calendar object must only contain 1 component. We found a ' . $component->name . ' as well as a ' . $foundType); } if ($foundUID !== (string) $component->UID) { throw new DAV\Exception\BadRequest('Every ' . $component->name . ' in this object must have identical UIDs'); } } break; default: throw new DAV\Exception\BadRequest('You are not allowed to create components of type: ' . $component->name . ' here'); } } if (!$foundType) { throw new DAV\Exception\BadRequest('iCalendar object must contain at least 1 of VEVENT, VTODO or VJOURNAL'); } // We use an extra variable to allow event handles to tell us wether // the object was modified or not. // // This helps us determine if we need to re-serialize the object. $subModified = false; $this->server->emit('calendarObjectChange', [$request, $response, $vobj, $parentPath, &$subModified, $isNew]); if ($subModified) { // An event handler told us that it modified the object. $data = $vobj->serialize(); // Using md5 to figure out if there was an *actual* change. if (!$modified && $before !== md5($data)) { $modified = true; } } }
/** * Returns free-busy information for a specific address. The returned * data is an array containing the following properties: * * calendar-data : A VFREEBUSY VObject * request-status : an iTip status code. * href: The principal's email address, as requested * * The following request status codes may be returned: * * 2.0;description * * 3.7;description * * @param string $email address * @param DateTimeInterface $start * @param DateTimeInterface $end * @param VObject\Component $request * @return array */ protected function getFreeBusyForEmail($email, \DateTimeInterface $start, \DateTimeInterface $end, VObject\Component $request) { $caldavNS = '{' . self::NS_CALDAV . '}'; $aclPlugin = $this->server->getPlugin('acl'); if (substr($email, 0, 7) === 'mailto:') { $email = substr($email, 7); } $result = $aclPlugin->principalSearch(['{http://sabredav.org/ns}email-address' => $email], ['{DAV:}principal-URL', $caldavNS . 'calendar-home-set', $caldavNS . 'schedule-inbox-URL', '{http://sabredav.org/ns}email-address']); if (!count($result)) { return ['request-status' => '3.7;Could not find principal', 'href' => 'mailto:' . $email]; } if (!isset($result[0][200][$caldavNS . 'calendar-home-set'])) { return ['request-status' => '3.7;No calendar-home-set property found', 'href' => 'mailto:' . $email]; } if (!isset($result[0][200][$caldavNS . 'schedule-inbox-URL'])) { return ['request-status' => '3.7;No schedule-inbox-URL property found', 'href' => 'mailto:' . $email]; } $homeSet = $result[0][200][$caldavNS . 'calendar-home-set']->getHref(); $inboxUrl = $result[0][200][$caldavNS . 'schedule-inbox-URL']->getHref(); // Grabbing the calendar list $objects = []; $calendarTimeZone = new DateTimeZone('UTC'); foreach ($this->server->tree->getNodeForPath($homeSet)->getChildren() as $node) { if (!$node instanceof ICalendar) { continue; } $sct = $caldavNS . 'schedule-calendar-transp'; $ctz = $caldavNS . 'calendar-timezone'; $props = $node->getProperties([$sct, $ctz]); if (isset($props[$sct]) && $props[$sct]->getValue() == ScheduleCalendarTransp::TRANSPARENT) { // If a calendar is marked as 'transparent', it means we must // ignore it for free-busy purposes. continue; } $aclPlugin->checkPrivileges($homeSet . $node->getName(), $caldavNS . 'read-free-busy'); if (isset($props[$ctz])) { $vtimezoneObj = VObject\Reader::read($props[$ctz]); $calendarTimeZone = $vtimezoneObj->VTIMEZONE->getTimeZone(); // Destroy circular references so PHP can garbage collect the object. $vtimezoneObj->destroy(); } // Getting the list of object uris within the time-range $urls = $node->calendarQuery(['name' => 'VCALENDAR', 'comp-filters' => [['name' => 'VEVENT', 'comp-filters' => [], 'prop-filters' => [], 'is-not-defined' => false, 'time-range' => ['start' => $start, 'end' => $end]]], 'prop-filters' => [], 'is-not-defined' => false, 'time-range' => null]); $calObjects = array_map(function ($url) use($node) { $obj = $node->getChild($url)->get(); return $obj; }, $urls); $objects = array_merge($objects, $calObjects); } $inboxProps = $this->server->getProperties($inboxUrl, $caldavNS . 'calendar-availability'); $vcalendar = new VObject\Component\VCalendar(); $vcalendar->METHOD = 'REPLY'; $generator = new VObject\FreeBusyGenerator(); $generator->setObjects($objects); $generator->setTimeRange($start, $end); $generator->setBaseObject($vcalendar); $generator->setTimeZone($calendarTimeZone); if ($inboxProps) { $generator->setVAvailability(VObject\Reader::read($inboxProps[$caldavNS . 'calendar-availability'])); } $result = $generator->getResult(); $vcalendar->VFREEBUSY->ATTENDEE = 'mailto:' . $email; $vcalendar->VFREEBUSY->UID = (string) $request->VFREEBUSY->UID; $vcalendar->VFREEBUSY->ORGANIZER = clone $request->VFREEBUSY->ORGANIZER; return ['calendar-data' => $result, 'request-status' => '2.0;Success', 'href' => 'mailto:' . $email]; }
/** * @depends testSupportedReportSetPropertyNonCalendar */ function testCalendarProperties() { $ns = '{urn:ietf:params:xml:ns:caldav}'; $props = $this->server->getProperties('calendars/user1/UUID-123467', [$ns . 'max-resource-size', $ns . 'supported-calendar-data', $ns . 'supported-collation-set']); $this->assertEquals([$ns . 'max-resource-size' => 10000000, $ns . 'supported-calendar-data' => new Xml\Property\SupportedCalendarData(), $ns . 'supported-collation-set' => new Xml\Property\SupportedCollationSet()], $props); }
/** * Checks if the submitted iCalendar data is in fact, valid. * * An exception is thrown if it's not. * * @param resource|string $data * @param string $path * @param bool $modified Should be set to true, if this event handler * changed &$data. * @param RequestInterface $request The http request. * @param ResponseInterface $response The http response. * @param bool $isNew Is the item a new one, or an update. * @return void */ protected function validateICalendar(&$data, $path, &$modified, RequestInterface $request, ResponseInterface $response, $isNew) { // If it's a stream, we convert it to a string first. if (is_resource($data)) { $data = stream_get_contents($data); } $before = $data; try { // If the data starts with a [, we can reasonably assume we're dealing // with a jCal object. if (substr($data, 0, 1) === '[') { $vobj = VObject\Reader::readJson($data); // Converting $data back to iCalendar, as that's what we // technically support everywhere. $data = $vobj->serialize(); $modified = true; } else { $vobj = VObject\Reader::read($data); } } catch (VObject\ParseException $e) { throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid iCalendar 2.0 data. Parse error: ' . $e->getMessage()); } if ($vobj->name !== 'VCALENDAR') { throw new DAV\Exception\UnsupportedMediaType('This collection can only support iCalendar objects.'); } $sCCS = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'; // Get the Supported Components for the target calendar list($parentPath) = Uri\split($path); $calendarProperties = $this->server->getProperties($parentPath, [$sCCS]); if (isset($calendarProperties[$sCCS])) { $supportedComponents = $calendarProperties[$sCCS]->getValue(); } else { $supportedComponents = ['VJOURNAL', 'VTODO', 'VEVENT']; } $foundType = null; foreach ($vobj->getComponents() as $component) { switch ($component->name) { case 'VTIMEZONE': continue 2; case 'VEVENT': case 'VTODO': case 'VJOURNAL': $foundType = $component->name; break; } } if (!$foundType || !in_array($foundType, $supportedComponents)) { throw new Exception\InvalidComponentType('iCalendar objects must at least have a component of type ' . implode(', ', $supportedComponents)); } $options = VObject\Node::PROFILE_CALDAV; $prefer = $this->server->getHTTPPrefer(); if ($prefer['handling'] !== 'strict') { $options |= VObject\Node::REPAIR; } $messages = $vobj->validate($options); $highestLevel = 0; $warningMessage = null; // $messages contains a list of problems with the vcard, along with // their severity. foreach ($messages as $message) { if ($message['level'] > $highestLevel) { // Recording the highest reported error level. $highestLevel = $message['level']; $warningMessage = $message['message']; } switch ($message['level']) { case 1: // Level 1 means that there was a problem, but it was repaired. $modified = true; break; case 2: // Level 2 means a warning, but not critical break; case 3: // Level 3 means a critical error throw new DAV\Exception\UnsupportedMediaType('Validation error in iCalendar: ' . $message['message']); } } if ($warningMessage) { $response->setHeader('X-Sabre-Ew-Gross', 'iCalendar validation warning: ' . $warningMessage); } // We use an extra variable to allow event handles to tell us wether // the object was modified or not. // // This helps us determine if we need to re-serialize the object. $subModified = false; $this->server->emit('calendarObjectChange', [$request, $response, $vobj, $parentPath, &$subModified, $isNew]); if ($modified || $subModified) { // An event handler told us that it modified the object. $data = $vobj->serialize(); // Using md5 to figure out if there was an *actual* change. if (!$modified && strcmp($data, $before) !== 0) { $modified = true; } } // Destroy circular references so PHP will garbage collect the object. $vobj->destroy(); }