CalendarMigrator.php 16 KB

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