1<?php
2
3namespace Sabre\CalDAV;
4
5use DateTime;
6use DateTimeZone;
7use Sabre\DAV;
8use Sabre\DAV\Exception\BadRequest;
9use Sabre\HTTP\RequestInterface;
10use Sabre\HTTP\ResponseInterface;
11use Sabre\VObject;
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) 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        $calendarNode = $this->server->tree->getNodeForPath($path);
174
175        $blobs = [];
176        if ($start || $end || $componentType) {
177
178            // If there was a start or end filter, we need to enlist
179            // calendarQuery for speed.
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                // Destroy circular references to PHP will GC the object.
239                $vtimezoneObj->destroy();
240                unset($vtimezoneObj);
241            } else {
242                // Defaulting to UTC.
243                $calendarTimeZone = new DateTimeZone('UTC');
244            }
245
246            $mergedCalendar = $mergedCalendar->expand($start, $end, $calendarTimeZone);
247        }
248
249        $filenameExtension = '.ics';
250
251        switch ($format) {
252            case 'text/calendar' :
253                $mergedCalendar = $mergedCalendar->serialize();
254                $filenameExtension = '.ics';
255                break;
256            case 'application/calendar+json' :
257                $mergedCalendar = json_encode($mergedCalendar->jsonSerialize());
258                $filenameExtension = '.json';
259                break;
260        }
261
262        $filename = preg_replace(
263            '/[^a-zA-Z0-9-_ ]/um',
264            '',
265            $calendarNode->getName()
266        );
267        $filename .= '-' . date('Y-m-d') . $filenameExtension;
268
269        $response->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
270        $response->setHeader('Content-Type', $format);
271
272        $response->setStatus(200);
273        $response->setBody($mergedCalendar);
274
275    }
276
277    /**
278     * Merges all calendar objects, and builds one big iCalendar blob.
279     *
280     * @param array $properties Some CalDAV properties
281     * @param array $inputObjects
282     * @return VObject\Component\VCalendar
283     */
284    function mergeObjects(array $properties, array $inputObjects) {
285
286        $calendar = new VObject\Component\VCalendar();
287        $calendar->VERSION = '2.0';
288        if (DAV\Server::$exposeVersion) {
289            $calendar->PRODID = '-//SabreDAV//SabreDAV ' . DAV\Version::VERSION . '//EN';
290        } else {
291            $calendar->PRODID = '-//SabreDAV//SabreDAV//EN';
292        }
293        if (isset($properties['{DAV:}displayname'])) {
294            $calendar->{'X-WR-CALNAME'} = $properties['{DAV:}displayname'];
295        }
296        if (isset($properties['{http://apple.com/ns/ical/}calendar-color'])) {
297            $calendar->{'X-APPLE-CALENDAR-COLOR'} = $properties['{http://apple.com/ns/ical/}calendar-color'];
298        }
299
300        $collectedTimezones = [];
301
302        $timezones = [];
303        $objects = [];
304
305        foreach ($inputObjects as $href => $inputObject) {
306
307            $nodeComp = VObject\Reader::read($inputObject);
308
309            foreach ($nodeComp->children() as $child) {
310
311                switch ($child->name) {
312                    case 'VEVENT' :
313                    case 'VTODO' :
314                    case 'VJOURNAL' :
315                        $objects[] = clone $child;
316                        break;
317
318                    // VTIMEZONE is special, because we need to filter out the duplicates
319                    case 'VTIMEZONE' :
320                        // Naively just checking tzid.
321                        if (in_array((string)$child->TZID, $collectedTimezones)) continue;
322
323                        $timezones[] = clone $child;
324                        $collectedTimezones[] = $child->TZID;
325                        break;
326
327                }
328
329            }
330            // Destroy circular references to PHP will GC the object.
331            $nodeComp->destroy();
332            unset($nodeComp);
333
334        }
335
336        foreach ($timezones as $tz) $calendar->add($tz);
337        foreach ($objects as $obj) $calendar->add($obj);
338
339        return $calendar;
340
341    }
342
343    /**
344     * Returns a plugin name.
345     *
346     * Using this name other plugins will be able to access other plugins
347     * using \Sabre\DAV\Server::getPlugin
348     *
349     * @return string
350     */
351    function getPluginName() {
352
353        return 'ics-export';
354
355    }
356
357    /**
358     * Returns a bunch of meta-data about the plugin.
359     *
360     * Providing this information is optional, and is mainly displayed by the
361     * Browser plugin.
362     *
363     * The description key in the returned array may contain html and will not
364     * be sanitized.
365     *
366     * @return array
367     */
368    function getPluginInfo() {
369
370        return [
371            'name'        => $this->getPluginName(),
372            'description' => 'Adds the ability to export CalDAV calendars as a single iCalendar file.',
373            'link'        => 'http://sabre.io/dav/ics-export-plugin/',
374        ];
375
376    }
377
378}
379