ICSExportPlugin.php 13 KB

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