CalendarMigrator.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\DAV\UserMigration;
  8. use OCA\DAV\AppInfo\Application;
  9. use OCA\DAV\CalDAV\CalDavBackend;
  10. use OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin;
  11. use OCA\DAV\CalDAV\Plugin as CalDAVPlugin;
  12. use OCA\DAV\Connector\Sabre\CachingTree;
  13. use OCA\DAV\Connector\Sabre\Server as SabreDavServer;
  14. use OCA\DAV\RootCollection;
  15. use OCP\Calendar\ICalendar;
  16. use OCP\Calendar\IManager as ICalendarManager;
  17. use OCP\Defaults;
  18. use OCP\IL10N;
  19. use OCP\IUser;
  20. use OCP\UserMigration\IExportDestination;
  21. use OCP\UserMigration\IImportSource;
  22. use OCP\UserMigration\IMigrator;
  23. use OCP\UserMigration\ISizeEstimationMigrator;
  24. use OCP\UserMigration\TMigratorBasicVersionHandling;
  25. use Sabre\VObject\Component as VObjectComponent;
  26. use Sabre\VObject\Component\VCalendar;
  27. use Sabre\VObject\Component\VTimeZone;
  28. use Sabre\VObject\Property\ICalendar\DateTime;
  29. use Sabre\VObject\Reader as VObjectReader;
  30. use Sabre\VObject\UUIDUtil;
  31. use Symfony\Component\Console\Output\NullOutput;
  32. use Symfony\Component\Console\Output\OutputInterface;
  33. use Throwable;
  34. use function substr;
  35. class CalendarMigrator implements IMigrator, ISizeEstimationMigrator {
  36. use TMigratorBasicVersionHandling;
  37. private CalDavBackend $calDavBackend;
  38. private ICalendarManager $calendarManager;
  39. // ICSExportPlugin is injected as the mergeObjects() method is required and is not to be used as a SabreDAV server plugin
  40. private ICSExportPlugin $icsExportPlugin;
  41. private Defaults $defaults;
  42. private IL10N $l10n;
  43. private SabreDavServer $sabreDavServer;
  44. private const USERS_URI_ROOT = 'principals/users/';
  45. private const FILENAME_EXT = '.ics';
  46. private const MIGRATED_URI_PREFIX = 'migrated-';
  47. private const EXPORT_ROOT = Application::APP_ID . '/calendars/';
  48. public function __construct(
  49. CalDavBackend $calDavBackend,
  50. ICalendarManager $calendarManager,
  51. ICSExportPlugin $icsExportPlugin,
  52. Defaults $defaults,
  53. IL10N $l10n,
  54. ) {
  55. $this->calDavBackend = $calDavBackend;
  56. $this->calendarManager = $calendarManager;
  57. $this->icsExportPlugin = $icsExportPlugin;
  58. $this->defaults = $defaults;
  59. $this->l10n = $l10n;
  60. $root = new RootCollection();
  61. $this->sabreDavServer = new SabreDavServer(new CachingTree($root));
  62. $this->sabreDavServer->addPlugin(new CalDAVPlugin());
  63. }
  64. private function getPrincipalUri(IUser $user): string {
  65. return CalendarMigrator::USERS_URI_ROOT . $user->getUID();
  66. }
  67. /**
  68. * @return array{name: string, vCalendar: VCalendar}
  69. *
  70. * @throws CalendarMigratorException
  71. * @throws InvalidCalendarException
  72. */
  73. private function getCalendarExportData(IUser $user, ICalendar $calendar, OutputInterface $output): array {
  74. $userId = $user->getUID();
  75. $uri = $calendar->getUri();
  76. $path = CalDAVPlugin::CALENDAR_ROOT . "/$userId/$uri";
  77. /**
  78. * @see \Sabre\CalDAV\ICSExportPlugin::httpGet() implementation reference
  79. */
  80. $properties = $this->sabreDavServer->getProperties($path, [
  81. '{DAV:}resourcetype',
  82. '{DAV:}displayname',
  83. '{http://sabredav.org/ns}sync-token',
  84. '{DAV:}sync-token',
  85. '{http://apple.com/ns/ical/}calendar-color',
  86. ]);
  87. // Filter out invalid (e.g. deleted) calendars
  88. if (!isset($properties['{DAV:}resourcetype']) || !$properties['{DAV:}resourcetype']->is('{' . CalDAVPlugin::NS_CALDAV . '}calendar')) {
  89. throw new InvalidCalendarException();
  90. }
  91. /**
  92. * @see \Sabre\CalDAV\ICSExportPlugin::generateResponse() implementation reference
  93. */
  94. $calDataProp = '{' . CalDAVPlugin::NS_CALDAV . '}calendar-data';
  95. $calendarNode = $this->sabreDavServer->tree->getNodeForPath($path);
  96. $nodes = $this->sabreDavServer->getPropertiesIteratorForPath($path, [$calDataProp], 1);
  97. $blobs = [];
  98. foreach ($nodes as $node) {
  99. if (isset($node[200][$calDataProp])) {
  100. $blobs[$node['href']] = $node[200][$calDataProp];
  101. }
  102. }
  103. $mergedCalendar = $this->icsExportPlugin->mergeObjects(
  104. $properties,
  105. $blobs,
  106. );
  107. $problems = $mergedCalendar->validate();
  108. if (!empty($problems)) {
  109. $output->writeln('Skipping calendar "' . $properties['{DAV:}displayname'] . '" containing invalid calendar data');
  110. throw new InvalidCalendarException();
  111. }
  112. return [
  113. 'name' => $calendarNode->getName(),
  114. 'vCalendar' => $mergedCalendar,
  115. ];
  116. }
  117. /**
  118. * @return array<int, array{name: string, vCalendar: VCalendar}>
  119. *
  120. * @throws CalendarMigratorException
  121. */
  122. private function getCalendarExports(IUser $user, OutputInterface $output): array {
  123. $principalUri = $this->getPrincipalUri($user);
  124. return array_values(array_filter(array_map(
  125. function (ICalendar $calendar) use ($user, $output) {
  126. try {
  127. return $this->getCalendarExportData($user, $calendar, $output);
  128. } catch (InvalidCalendarException $e) {
  129. // Allow this exception as invalid (e.g. deleted) calendars are not to be exported
  130. return null;
  131. }
  132. },
  133. $this->calendarManager->getCalendarsForPrincipal($principalUri),
  134. )));
  135. }
  136. /**
  137. * @throws InvalidCalendarException
  138. */
  139. private function getUniqueCalendarUri(IUser $user, string $initialCalendarUri): string {
  140. $principalUri = $this->getPrincipalUri($user);
  141. $initialCalendarUri = substr($initialCalendarUri, 0, strlen(CalendarMigrator::MIGRATED_URI_PREFIX)) === CalendarMigrator::MIGRATED_URI_PREFIX
  142. ? $initialCalendarUri
  143. : CalendarMigrator::MIGRATED_URI_PREFIX . $initialCalendarUri;
  144. if ($initialCalendarUri === '') {
  145. throw new InvalidCalendarException();
  146. }
  147. $existingCalendarUris = array_map(
  148. fn (ICalendar $calendar) => $calendar->getUri(),
  149. $this->calendarManager->getCalendarsForPrincipal($principalUri),
  150. );
  151. $calendarUri = $initialCalendarUri;
  152. $acc = 1;
  153. while (in_array($calendarUri, $existingCalendarUris, true)) {
  154. $calendarUri = $initialCalendarUri . "-$acc";
  155. ++$acc;
  156. }
  157. return $calendarUri;
  158. }
  159. /**
  160. * {@inheritDoc}
  161. */
  162. public function getEstimatedExportSize(IUser $user): int|float {
  163. $calendarExports = $this->getCalendarExports($user, new NullOutput());
  164. $calendarCount = count($calendarExports);
  165. // 150B for top-level properties
  166. $size = ($calendarCount * 150) / 1024;
  167. $componentCount = array_sum(array_map(
  168. function (array $data): int {
  169. /** @var VCalendar $vCalendar */
  170. $vCalendar = $data['vCalendar'];
  171. return count($vCalendar->getComponents());
  172. },
  173. $calendarExports,
  174. ));
  175. // 450B for each component (events, todos, alarms, etc.)
  176. $size += ($componentCount * 450) / 1024;
  177. return ceil($size);
  178. }
  179. /**
  180. * {@inheritDoc}
  181. */
  182. public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void {
  183. $output->writeln('Exporting calendars into ' . CalendarMigrator::EXPORT_ROOT . '…');
  184. $calendarExports = $this->getCalendarExports($user, $output);
  185. if (empty($calendarExports)) {
  186. $output->writeln('No calendars to export…');
  187. }
  188. try {
  189. /**
  190. * @var string $name
  191. * @var VCalendar $vCalendar
  192. */
  193. foreach ($calendarExports as ['name' => $name, 'vCalendar' => $vCalendar]) {
  194. // Set filename to sanitized calendar name
  195. $filename = preg_replace('/[^a-z0-9-_]/iu', '', $name) . CalendarMigrator::FILENAME_EXT;
  196. $exportPath = CalendarMigrator::EXPORT_ROOT . $filename;
  197. $exportDestination->addFileContents($exportPath, $vCalendar->serialize());
  198. }
  199. } catch (Throwable $e) {
  200. throw new CalendarMigratorException('Could not export calendars', 0, $e);
  201. }
  202. }
  203. /**
  204. * @return array<string, VTimeZone>
  205. */
  206. private function getCalendarTimezones(VCalendar $vCalendar): array {
  207. /** @var VTimeZone[] $calendarTimezones */
  208. $calendarTimezones = array_filter(
  209. $vCalendar->getComponents(),
  210. fn ($component) => $component->name === 'VTIMEZONE',
  211. );
  212. /** @var array<string, VTimeZone> $calendarTimezoneMap */
  213. $calendarTimezoneMap = [];
  214. foreach ($calendarTimezones as $vTimeZone) {
  215. $calendarTimezoneMap[$vTimeZone->getTimeZone()->getName()] = $vTimeZone;
  216. }
  217. return $calendarTimezoneMap;
  218. }
  219. /**
  220. * @return VTimeZone[]
  221. */
  222. private function getTimezonesForComponent(VCalendar $vCalendar, VObjectComponent $component): array {
  223. $componentTimezoneIds = [];
  224. foreach ($component->children() as $child) {
  225. if ($child instanceof DateTime && isset($child->parameters['TZID'])) {
  226. $timezoneId = $child->parameters['TZID']->getValue();
  227. if (!in_array($timezoneId, $componentTimezoneIds, true)) {
  228. $componentTimezoneIds[] = $timezoneId;
  229. }
  230. }
  231. }
  232. $calendarTimezoneMap = $this->getCalendarTimezones($vCalendar);
  233. return array_values(array_filter(array_map(
  234. fn (string $timezoneId) => $calendarTimezoneMap[$timezoneId],
  235. $componentTimezoneIds,
  236. )));
  237. }
  238. private function sanitizeComponent(VObjectComponent $component): VObjectComponent {
  239. // Operate on the component clone to prevent mutation of the original
  240. $component = clone $component;
  241. // Remove RSVP parameters to prevent automatically sending invitation emails to attendees on import
  242. foreach ($component->children() as $child) {
  243. if (
  244. $child->name === 'ATTENDEE'
  245. && isset($child->parameters['RSVP'])
  246. ) {
  247. unset($child->parameters['RSVP']);
  248. }
  249. }
  250. return $component;
  251. }
  252. /**
  253. * @return VObjectComponent[]
  254. */
  255. private function getRequiredImportComponents(VCalendar $vCalendar, VObjectComponent $component): array {
  256. $component = $this->sanitizeComponent($component);
  257. /** @var array<int, VTimeZone> $timezoneComponents */
  258. $timezoneComponents = $this->getTimezonesForComponent($vCalendar, $component);
  259. return [
  260. ...$timezoneComponents,
  261. $component,
  262. ];
  263. }
  264. private function initCalendarObject(): VCalendar {
  265. $vCalendarObject = new VCalendar();
  266. $vCalendarObject->PRODID = '-//IDN nextcloud.com//Migrated calendar//EN';
  267. return $vCalendarObject;
  268. }
  269. /**
  270. * @throws InvalidCalendarException
  271. */
  272. private function importCalendarObject(int $calendarId, VCalendar $vCalendarObject, string $filename, OutputInterface $output): void {
  273. try {
  274. $this->calDavBackend->createCalendarObject(
  275. $calendarId,
  276. UUIDUtil::getUUID() . CalendarMigrator::FILENAME_EXT,
  277. $vCalendarObject->serialize(),
  278. CalDavBackend::CALENDAR_TYPE_CALENDAR,
  279. );
  280. } catch (Throwable $e) {
  281. $output->writeln("Error creating calendar object, rolling back creation of \"$filename\" calendar…");
  282. $this->calDavBackend->deleteCalendar($calendarId, true);
  283. throw new InvalidCalendarException();
  284. }
  285. }
  286. /**
  287. * @throws InvalidCalendarException
  288. */
  289. private function importCalendar(IUser $user, string $filename, string $initialCalendarUri, VCalendar $vCalendar, OutputInterface $output): void {
  290. $principalUri = $this->getPrincipalUri($user);
  291. $calendarUri = $this->getUniqueCalendarUri($user, $initialCalendarUri);
  292. $calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [
  293. '{DAV:}displayname' => isset($vCalendar->{'X-WR-CALNAME'}) ? $vCalendar->{'X-WR-CALNAME'}->getValue() : $this->l10n->t('Migrated calendar (%1$s)', [$filename]),
  294. '{http://apple.com/ns/ical/}calendar-color' => isset($vCalendar->{'X-APPLE-CALENDAR-COLOR'}) ? $vCalendar->{'X-APPLE-CALENDAR-COLOR'}->getValue() : $this->defaults->getColorPrimary(),
  295. 'components' => implode(
  296. ',',
  297. array_reduce(
  298. $vCalendar->getComponents(),
  299. function (array $componentNames, VObjectComponent $component) {
  300. /** @var array<int, string> $componentNames */
  301. return !in_array($component->name, $componentNames, true)
  302. ? [...$componentNames, $component->name]
  303. : $componentNames;
  304. },
  305. [],
  306. )
  307. ),
  308. ]);
  309. /** @var VObjectComponent[] $calendarComponents */
  310. $calendarComponents = array_values(array_filter(
  311. $vCalendar->getComponents(),
  312. // VTIMEZONE components are handled separately and added to the calendar object only if depended on by the component
  313. fn (VObjectComponent $component) => $component->name !== 'VTIMEZONE',
  314. ));
  315. /** @var array<string, VObjectComponent[]> $groupedCalendarComponents */
  316. $groupedCalendarComponents = [];
  317. /** @var VObjectComponent[] $ungroupedCalendarComponents */
  318. $ungroupedCalendarComponents = [];
  319. foreach ($calendarComponents as $component) {
  320. if (isset($component->UID)) {
  321. $uid = $component->UID->getValue();
  322. // Components with the same UID (e.g. recurring events) are grouped together into a single calendar object
  323. if (isset($groupedCalendarComponents[$uid])) {
  324. $groupedCalendarComponents[$uid][] = $component;
  325. } else {
  326. $groupedCalendarComponents[$uid] = [$component];
  327. }
  328. } else {
  329. $ungroupedCalendarComponents[] = $component;
  330. }
  331. }
  332. foreach ($groupedCalendarComponents as $uid => $components) {
  333. // Construct and import a calendar object containing all components of a group
  334. $vCalendarObject = $this->initCalendarObject();
  335. foreach ($components as $component) {
  336. foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) {
  337. $vCalendarObject->add($component);
  338. }
  339. }
  340. $this->importCalendarObject($calendarId, $vCalendarObject, $filename, $output);
  341. }
  342. foreach ($ungroupedCalendarComponents as $component) {
  343. // Construct and import a calendar object for a single component
  344. $vCalendarObject = $this->initCalendarObject();
  345. foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) {
  346. $vCalendarObject->add($component);
  347. }
  348. $this->importCalendarObject($calendarId, $vCalendarObject, $filename, $output);
  349. }
  350. }
  351. /**
  352. * {@inheritDoc}
  353. *
  354. * @throws CalendarMigratorException
  355. */
  356. public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void {
  357. if ($importSource->getMigratorVersion($this->getId()) === null) {
  358. $output->writeln('No version for ' . static::class . ', skipping import…');
  359. return;
  360. }
  361. $output->writeln('Importing calendars from ' . CalendarMigrator::EXPORT_ROOT . '…');
  362. $calendarImports = $importSource->getFolderListing(CalendarMigrator::EXPORT_ROOT);
  363. if (empty($calendarImports)) {
  364. $output->writeln('No calendars to import…');
  365. }
  366. foreach ($calendarImports as $filename) {
  367. $importPath = CalendarMigrator::EXPORT_ROOT . $filename;
  368. try {
  369. /** @var VCalendar $vCalendar */
  370. $vCalendar = VObjectReader::read(
  371. $importSource->getFileAsStream($importPath),
  372. VObjectReader::OPTION_FORGIVING,
  373. );
  374. } catch (Throwable $e) {
  375. $output->writeln("Failed to read file \"$importPath\", skipping…");
  376. continue;
  377. }
  378. $problems = $vCalendar->validate();
  379. if (!empty($problems)) {
  380. $output->writeln("Invalid calendar data contained in \"$importPath\", skipping…");
  381. continue;
  382. }
  383. $splitFilename = explode('.', $filename, 2);
  384. if (count($splitFilename) !== 2) {
  385. $output->writeln("Invalid filename \"$filename\", expected filename of the format \"<calendar_name>" . CalendarMigrator::FILENAME_EXT . '", skipping…');
  386. continue;
  387. }
  388. [$initialCalendarUri, $ext] = $splitFilename;
  389. try {
  390. $this->importCalendar(
  391. $user,
  392. $filename,
  393. $initialCalendarUri,
  394. $vCalendar,
  395. $output,
  396. );
  397. } catch (InvalidCalendarException $e) {
  398. // Allow this exception to skip a failed import
  399. } finally {
  400. $vCalendar->destroy();
  401. }
  402. }
  403. }
  404. /**
  405. * {@inheritDoc}
  406. */
  407. public function getId(): string {
  408. return 'calendar';
  409. }
  410. /**
  411. * {@inheritDoc}
  412. */
  413. public function getDisplayName(): string {
  414. return $this->l10n->t('Calendar');
  415. }
  416. /**
  417. * {@inheritDoc}
  418. */
  419. public function getDescription(): string {
  420. return $this->l10n->t('Calendars including events, details and attendees');
  421. }
  422. }