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