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