ICSExportPlugin.php 12 KB

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