1<?php
2
3namespace Sabre\CalDAV;
4
5use DateTimeZone;
6use Sabre\DAV;
7use Sabre\VObject;
8use Sabre\HTTP\RequestInterface;
9use Sabre\HTTP\ResponseInterface;
10use Sabre\DAV\Exception\BadRequest;
11use DateTime;
12
13/**
14 * ICS Exporter
15 *
16 * This plugin adds the ability to export entire calendars as .ics files.
17 * This is useful for clients that don't support CalDAV yet. They often do
18 * support ics files.
19 *
20 * To use this, point a http client to a caldav calendar, and add ?expand to
21 * the url.
22 *
23 * Further options that can be added to the url:
24 *   start=123456789 - Only return events after the given unix timestamp
25 *   end=123245679   - Only return events from before the given unix timestamp
26 *   expand=1        - Strip timezone information and expand recurring events.
27 *                     If you'd like to expand, you _must_ also specify start
28 *                     and end.
29 *
30 * By default this plugin returns data in the text/calendar format (iCalendar
31 * 2.0). If you'd like to receive jCal data instead, you can use an Accept
32 * header:
33 *
34 * Accept: application/calendar+json
35 *
36 * Alternatively, you can also specify this in the url using
37 * accept=application/calendar+json, or accept=jcal for short. If the url
38 * parameter and Accept header is specified, the url parameter wins.
39 *
40 * Note that specifying a start or end data implies that only events will be
41 * returned. VTODO and VJOURNAL will be stripped.
42 *
43 * @copyright Copyright (C) 2007-2015 fruux GmbH (https://fruux.com/).
44 * @author Evert Pot (http://evertpot.com/)
45 * @license http://sabre.io/license/ Modified BSD License
46 */
47class ICSExportPlugin extends DAV\ServerPlugin {
48
49    /**
50     * Reference to Server class
51     *
52     * @var \Sabre\DAV\Server
53     */
54    protected $server;
55
56    /**
57     * Initializes the plugin and registers event handlers
58     *
59     * @param \Sabre\DAV\Server $server
60     * @return void
61     */
62    function initialize(DAV\Server $server) {
63
64        $this->server = $server;
65        $server->on('method:GET', [$this, 'httpGet'], 90);
66        $server->on('browserButtonActions', function($path, $node, &$actions) {
67            if ($node instanceof ICalendar) {
68                $actions .= '<a href="' . htmlspecialchars($path, ENT_QUOTES, 'UTF-8') . '?export"><span class="oi" data-glyph="calendar"></span></a>';
69            }
70        });
71
72    }
73
74    /**
75     * Intercepts GET requests on calendar urls ending with ?export.
76     *
77     * @param RequestInterface $request
78     * @param ResponseInterface $response
79     * @return bool
80     */
81    function httpGet(RequestInterface $request, ResponseInterface $response) {
82
83        $queryParams = $request->getQueryParameters();
84        if (!array_key_exists('export', $queryParams)) return;
85
86        $path = $request->getPath();
87
88        $node = $this->server->getProperties($path, [
89            '{DAV:}resourcetype',
90            '{DAV:}displayname',
91            '{http://sabredav.org/ns}sync-token',
92            '{DAV:}sync-token',
93            '{http://apple.com/ns/ical/}calendar-color',
94        ]);
95
96        if (!isset($node['{DAV:}resourcetype']) || !$node['{DAV:}resourcetype']->is('{' . Plugin::NS_CALDAV . '}calendar')) {
97            return;
98        }
99        // Marking the transactionType, for logging purposes.
100        $this->server->transactionType = 'get-calendar-export';
101
102        $properties = $node;
103
104        $start = null;
105        $end = null;
106        $expand = false;
107        $componentType = false;
108        if (isset($queryParams['start'])) {
109            if (!ctype_digit($queryParams['start'])) {
110                throw new BadRequest('The start= parameter must contain a unix timestamp');
111            }
112            $start = DateTime::createFromFormat('U', $queryParams['start']);
113        }
114        if (isset($queryParams['end'])) {
115            if (!ctype_digit($queryParams['end'])) {
116                throw new BadRequest('The end= parameter must contain a unix timestamp');
117            }
118            $end = DateTime::createFromFormat('U', $queryParams['end']);
119        }
120        if (isset($queryParams['expand']) && !!$queryParams['expand']) {
121            if (!$start || !$end) {
122                throw new BadRequest('If you\'d like to expand recurrences, you must specify both a start= and end= parameter.');
123            }
124            $expand = true;
125            $componentType = 'VEVENT';
126        }
127        if (isset($queryParams['componentType'])) {
128            if (!in_array($queryParams['componentType'], ['VEVENT', 'VTODO', 'VJOURNAL'])) {
129                throw new BadRequest('You are not allowed to search for components of type: ' . $queryParams['componentType'] . ' here');
130            }
131            $componentType = $queryParams['componentType'];
132        }
133
134        $format = \Sabre\HTTP\Util::Negotiate(
135            $request->getHeader('Accept'),
136            [
137                'text/calendar',
138                'application/calendar+json',
139            ]
140        );
141
142        if (isset($queryParams['accept'])) {
143            if ($queryParams['accept'] === 'application/calendar+json' || $queryParams['accept'] === 'jcal') {
144                $format = 'application/calendar+json';
145            }
146        }
147        if (!$format) {
148            $format = 'text/calendar';
149        }
150
151        $this->generateResponse($path, $start, $end, $expand, $componentType, $format, $properties, $response);
152
153        // Returning false to break the event chain
154        return false;
155
156    }
157
158    /**
159     * This method is responsible for generating the actual, full response.
160     *
161     * @param string $path
162     * @param DateTime|null $start
163     * @param DateTime|null $end
164     * @param bool $expand
165     * @param string $componentType
166     * @param string $format
167     * @param array $properties
168     * @param ResponseInterface $response
169     */
170    protected function generateResponse($path, $start, $end, $expand, $componentType, $format, $properties, ResponseInterface $response) {
171
172        $calDataProp = '{' . Plugin::NS_CALDAV . '}calendar-data';
173
174        $blobs = [];
175        if ($start || $end || $componentType) {
176
177            // If there was a start or end filter, we need to enlist
178            // calendarQuery for speed.
179            $calendarNode = $this->server->tree->getNodeForPath($path);
180            $queryResult = $calendarNode->calendarQuery([
181                'name'         => 'VCALENDAR',
182                'comp-filters' => [
183                    [
184                        'name'           => $componentType,
185                        'comp-filters'   => [],
186                        'prop-filters'   => [],
187                        'is-not-defined' => false,
188                        'time-range'     => [
189                            'start' => $start,
190                            'end'   => $end,
191                        ],
192                    ],
193                ],
194                'prop-filters'   => [],
195                'is-not-defined' => false,
196                'time-range'     => null,
197            ]);
198
199            // queryResult is just a list of base urls. We need to prefix the
200            // calendar path.
201            $queryResult = array_map(
202                function($item) use ($path) {
203                    return $path . '/' . $item;
204                },
205                $queryResult
206            );
207            $nodes = $this->server->getPropertiesForMultiplePaths($queryResult, [$calDataProp]);
208            unset($queryResult);
209
210        } else {
211            $nodes = $this->server->getPropertiesForPath($path, [$calDataProp], 1);
212        }
213
214        // Flattening the arrays
215        foreach ($nodes as $node) {
216            if (isset($node[200][$calDataProp])) {
217                $blobs[$node['href']] = $node[200][$calDataProp];
218            }
219        }
220        unset($nodes);
221
222        $mergedCalendar = $this->mergeObjects(
223            $properties,
224            $blobs
225        );
226
227        if ($expand) {
228            $calendarTimeZone = null;
229            // We're expanding, and for that we need to figure out the
230            // calendar's timezone.
231            $tzProp = '{' . Plugin::NS_CALDAV . '}calendar-timezone';
232            $tzResult = $this->server->getProperties($path, [$tzProp]);
233            if (isset($tzResult[$tzProp])) {
234                // This property contains a VCALENDAR with a single
235                // VTIMEZONE.
236                $vtimezoneObj = VObject\Reader::read($tzResult[$tzProp]);
237                $calendarTimeZone = $vtimezoneObj->VTIMEZONE->getTimeZone();
238                unset($vtimezoneObj);
239            } else {
240                // Defaulting to UTC.
241                $calendarTimeZone = new DateTimeZone('UTC');
242            }
243
244            $mergedCalendar->expand($start, $end, $calendarTimeZone);
245        }
246
247        $response->setHeader('Content-Type', $format);
248
249        switch ($format) {
250            case 'text/calendar' :
251                $mergedCalendar = $mergedCalendar->serialize();
252                break;
253            case 'application/calendar+json' :
254                $mergedCalendar = json_encode($mergedCalendar->jsonSerialize());
255                break;
256        }
257
258        $response->setStatus(200);
259        $response->setBody($mergedCalendar);
260
261    }
262
263    /**
264     * Merges all calendar objects, and builds one big iCalendar blob.
265     *
266     * @param array $properties Some CalDAV properties
267     * @param array $inputObjects
268     * @return VObject\Component\VCalendar
269     */
270    function mergeObjects(array $properties, array $inputObjects) {
271
272        $calendar = new VObject\Component\VCalendar();
273        $calendar->version = '2.0';
274        if (DAV\Server::$exposeVersion) {
275            $calendar->prodid = '-//SabreDAV//SabreDAV ' . DAV\Version::VERSION . '//EN';
276        } else {
277            $calendar->prodid = '-//SabreDAV//SabreDAV//EN';
278        }
279        if (isset($properties['{DAV:}displayname'])) {
280            $calendar->{'X-WR-CALNAME'} = $properties['{DAV:}displayname'];
281        }
282        if (isset($properties['{http://apple.com/ns/ical/}calendar-color'])) {
283            $calendar->{'X-APPLE-CALENDAR-COLOR'} = $properties['{http://apple.com/ns/ical/}calendar-color'];
284        }
285
286        $collectedTimezones = [];
287
288        $timezones = [];
289        $objects = [];
290
291        foreach ($inputObjects as $href => $inputObject) {
292
293            $nodeComp = VObject\Reader::read($inputObject);
294
295            foreach ($nodeComp->children() as $child) {
296
297                switch ($child->name) {
298                    case 'VEVENT' :
299                    case 'VTODO' :
300                    case 'VJOURNAL' :
301                        $objects[] = $child;
302                        break;
303
304                    // VTIMEZONE is special, because we need to filter out the duplicates
305                    case 'VTIMEZONE' :
306                        // Naively just checking tzid.
307                        if (in_array((string)$child->TZID, $collectedTimezones)) continue;
308
309                        $timezones[] = $child;
310                        $collectedTimezones[] = $child->TZID;
311                        break;
312
313                }
314
315            }
316
317        }
318
319        foreach ($timezones as $tz) $calendar->add($tz);
320        foreach ($objects as $obj) $calendar->add($obj);
321
322        return $calendar;
323
324    }
325
326    /**
327     * Returns a plugin name.
328     *
329     * Using this name other plugins will be able to access other plugins
330     * using \Sabre\DAV\Server::getPlugin
331     *
332     * @return string
333     */
334    function getPluginName() {
335
336        return 'ics-export';
337
338    }
339
340    /**
341     * Returns a bunch of meta-data about the plugin.
342     *
343     * Providing this information is optional, and is mainly displayed by the
344     * Browser plugin.
345     *
346     * The description key in the returned array may contain html and will not
347     * be sanitized.
348     *
349     * @return array
350     */
351    function getPluginInfo() {
352
353        return [
354            'name'        => $this->getPluginName(),
355            'description' => 'Adds the ability to export CalDAV calendars as a single iCalendar file.',
356            'link'        => 'http://sabre.io/dav/ics-export-plugin/',
357        ];
358
359    }
360
361}
362