1
0

CalendarMigrator.php 15 KB

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