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