CalendarMigrator.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  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. private function getUniqueCalendarUri(IUser $user, string $initialCalendarUri): string {
  154. $principalUri = $this->getPrincipalUri($user);
  155. $initialCalendarUri = substr($initialCalendarUri, 0, strlen(CalendarMigrator::MIGRATED_URI_PREFIX)) === CalendarMigrator::MIGRATED_URI_PREFIX
  156. ? $initialCalendarUri
  157. : CalendarMigrator::MIGRATED_URI_PREFIX . $initialCalendarUri;
  158. if ($initialCalendarUri === '') {
  159. throw new CalendarMigratorException('Failed to get unique calendar URI');
  160. }
  161. $existingCalendarUris = array_map(
  162. fn (ICalendar $calendar) => $calendar->getUri(),
  163. $this->calendarManager->getCalendarsForPrincipal($principalUri),
  164. );
  165. $calendarUri = $initialCalendarUri;
  166. $acc = 1;
  167. while (in_array($calendarUri, $existingCalendarUris, true)) {
  168. $calendarUri = $initialCalendarUri . "-$acc";
  169. ++$acc;
  170. }
  171. return $calendarUri;
  172. }
  173. /**
  174. * {@inheritDoc}
  175. */
  176. public function getEstimatedExportSize(IUser $user): int|float {
  177. $calendarExports = $this->getCalendarExports($user, new NullOutput());
  178. $calendarCount = count($calendarExports);
  179. // 150B for top-level properties
  180. $size = ($calendarCount * 150) / 1024;
  181. $componentCount = array_sum(array_map(
  182. function (array $data): int {
  183. /** @var VCalendar $vCalendar */
  184. $vCalendar = $data['vCalendar'];
  185. return count($vCalendar->getComponents());
  186. },
  187. $calendarExports,
  188. ));
  189. // 450B for each component (events, todos, alarms, etc.)
  190. $size += ($componentCount * 450) / 1024;
  191. return ceil($size);
  192. }
  193. /**
  194. * {@inheritDoc}
  195. */
  196. public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void {
  197. $output->writeln('Exporting calendars into ' . CalendarMigrator::EXPORT_ROOT . '…');
  198. $calendarExports = $this->getCalendarExports($user, $output);
  199. if (empty($calendarExports)) {
  200. $output->writeln('No calendars to export…');
  201. }
  202. try {
  203. /**
  204. * @var string $name
  205. * @var VCalendar $vCalendar
  206. */
  207. foreach ($calendarExports as ['name' => $name, 'vCalendar' => $vCalendar]) {
  208. // Set filename to sanitized calendar name
  209. $filename = preg_replace('/[^a-z0-9-_]/iu', '', $name) . CalendarMigrator::FILENAME_EXT;
  210. $exportPath = CalendarMigrator::EXPORT_ROOT . $filename;
  211. $exportDestination->addFileContents($exportPath, $vCalendar->serialize());
  212. }
  213. } catch (Throwable $e) {
  214. throw new CalendarMigratorException('Could not export calendars', 0, $e);
  215. }
  216. }
  217. /**
  218. * @return array<string, VTimeZone>
  219. */
  220. private function getCalendarTimezones(VCalendar $vCalendar): array {
  221. /** @var VTimeZone[] $calendarTimezones */
  222. $calendarTimezones = array_filter(
  223. $vCalendar->getComponents(),
  224. fn ($component) => $component->name === 'VTIMEZONE',
  225. );
  226. /** @var array<string, VTimeZone> $calendarTimezoneMap */
  227. $calendarTimezoneMap = [];
  228. foreach ($calendarTimezones as $vTimeZone) {
  229. $calendarTimezoneMap[$vTimeZone->getTimeZone()->getName()] = $vTimeZone;
  230. }
  231. return $calendarTimezoneMap;
  232. }
  233. /**
  234. * @return VTimeZone[]
  235. */
  236. private function getTimezonesForComponent(VCalendar $vCalendar, VObjectComponent $component): array {
  237. $componentTimezoneIds = [];
  238. foreach ($component->children() as $child) {
  239. if ($child instanceof DateTime && isset($child->parameters['TZID'])) {
  240. $timezoneId = $child->parameters['TZID']->getValue();
  241. if (!in_array($timezoneId, $componentTimezoneIds, true)) {
  242. $componentTimezoneIds[] = $timezoneId;
  243. }
  244. }
  245. }
  246. $calendarTimezoneMap = $this->getCalendarTimezones($vCalendar);
  247. return array_values(array_filter(array_map(
  248. fn (string $timezoneId) => $calendarTimezoneMap[$timezoneId],
  249. $componentTimezoneIds,
  250. )));
  251. }
  252. private function sanitizeComponent(VObjectComponent $component): VObjectComponent {
  253. // Operate on the component clone to prevent mutation of the original
  254. $component = clone $component;
  255. // Remove RSVP parameters to prevent automatically sending invitation emails to attendees on import
  256. foreach ($component->children() as $child) {
  257. if (
  258. $child->name === 'ATTENDEE'
  259. && isset($child->parameters['RSVP'])
  260. ) {
  261. unset($child->parameters['RSVP']);
  262. }
  263. }
  264. return $component;
  265. }
  266. /**
  267. * @return VObjectComponent[]
  268. */
  269. private function getRequiredImportComponents(VCalendar $vCalendar, VObjectComponent $component): array {
  270. $component = $this->sanitizeComponent($component);
  271. /** @var array<int, VTimeZone> $timezoneComponents */
  272. $timezoneComponents = $this->getTimezonesForComponent($vCalendar, $component);
  273. return [
  274. ...$timezoneComponents,
  275. $component,
  276. ];
  277. }
  278. private function initCalendarObject(): VCalendar {
  279. $vCalendarObject = new VCalendar();
  280. $vCalendarObject->PRODID = '-//IDN nextcloud.com//Migrated calendar//EN';
  281. return $vCalendarObject;
  282. }
  283. /**
  284. * @throws InvalidCalendarException
  285. */
  286. private function importCalendarObject(int $calendarId, VCalendar $vCalendarObject, string $filename, OutputInterface $output): void {
  287. try {
  288. $this->calDavBackend->createCalendarObject(
  289. $calendarId,
  290. UUIDUtil::getUUID() . CalendarMigrator::FILENAME_EXT,
  291. $vCalendarObject->serialize(),
  292. CalDavBackend::CALENDAR_TYPE_CALENDAR,
  293. );
  294. } catch (Throwable $e) {
  295. $output->writeln("Error creating calendar object, rolling back creation of \"$filename\" calendar…");
  296. $this->calDavBackend->deleteCalendar($calendarId, true);
  297. throw new InvalidCalendarException();
  298. }
  299. }
  300. /**
  301. * @throws InvalidCalendarException
  302. */
  303. private function importCalendar(IUser $user, string $filename, string $initialCalendarUri, VCalendar $vCalendar, OutputInterface $output): void {
  304. $principalUri = $this->getPrincipalUri($user);
  305. $calendarUri = $this->getUniqueCalendarUri($user, $initialCalendarUri);
  306. $calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [
  307. '{DAV:}displayname' => isset($vCalendar->{'X-WR-CALNAME'}) ? $vCalendar->{'X-WR-CALNAME'}->getValue() : $this->l10n->t('Migrated calendar (%1$s)', [$filename]),
  308. '{http://apple.com/ns/ical/}calendar-color' => isset($vCalendar->{'X-APPLE-CALENDAR-COLOR'}) ? $vCalendar->{'X-APPLE-CALENDAR-COLOR'}->getValue() : $this->defaults->getColorPrimary(),
  309. 'components' => implode(
  310. ',',
  311. array_reduce(
  312. $vCalendar->getComponents(),
  313. function (array $componentNames, VObjectComponent $component) {
  314. /** @var array<int, string> $componentNames */
  315. return !in_array($component->name, $componentNames, true)
  316. ? [...$componentNames, $component->name]
  317. : $componentNames;
  318. },
  319. [],
  320. )
  321. ),
  322. ]);
  323. /** @var VObjectComponent[] $calendarComponents */
  324. $calendarComponents = array_values(array_filter(
  325. $vCalendar->getComponents(),
  326. // VTIMEZONE components are handled separately and added to the calendar object only if depended on by the component
  327. fn (VObjectComponent $component) => $component->name !== 'VTIMEZONE',
  328. ));
  329. /** @var array<string, VObjectComponent[]> $groupedCalendarComponents */
  330. $groupedCalendarComponents = [];
  331. /** @var VObjectComponent[] $ungroupedCalendarComponents */
  332. $ungroupedCalendarComponents = [];
  333. foreach ($calendarComponents as $component) {
  334. if (isset($component->UID)) {
  335. $uid = $component->UID->getValue();
  336. // Components with the same UID (e.g. recurring events) are grouped together into a single calendar object
  337. if (isset($groupedCalendarComponents[$uid])) {
  338. $groupedCalendarComponents[$uid][] = $component;
  339. } else {
  340. $groupedCalendarComponents[$uid] = [$component];
  341. }
  342. } else {
  343. $ungroupedCalendarComponents[] = $component;
  344. }
  345. }
  346. foreach ($groupedCalendarComponents as $uid => $components) {
  347. // Construct and import a calendar object containing all components of a group
  348. $vCalendarObject = $this->initCalendarObject();
  349. foreach ($components as $component) {
  350. foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) {
  351. $vCalendarObject->add($component);
  352. }
  353. }
  354. $this->importCalendarObject($calendarId, $vCalendarObject, $filename, $output);
  355. }
  356. foreach ($ungroupedCalendarComponents as $component) {
  357. // Construct and import a calendar object for a single component
  358. $vCalendarObject = $this->initCalendarObject();
  359. foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) {
  360. $vCalendarObject->add($component);
  361. }
  362. $this->importCalendarObject($calendarId, $vCalendarObject, $filename, $output);
  363. }
  364. }
  365. /**
  366. * {@inheritDoc}
  367. *
  368. * @throws CalendarMigratorException
  369. */
  370. public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void {
  371. if ($importSource->getMigratorVersion($this->getId()) === null) {
  372. $output->writeln('No version for ' . static::class . ', skipping import…');
  373. return;
  374. }
  375. $output->writeln('Importing calendars from ' . CalendarMigrator::EXPORT_ROOT . '…');
  376. $calendarImports = $importSource->getFolderListing(CalendarMigrator::EXPORT_ROOT);
  377. if (empty($calendarImports)) {
  378. $output->writeln('No calendars to import…');
  379. }
  380. foreach ($calendarImports as $filename) {
  381. $importPath = CalendarMigrator::EXPORT_ROOT . $filename;
  382. try {
  383. /** @var VCalendar $vCalendar */
  384. $vCalendar = VObjectReader::read(
  385. $importSource->getFileAsStream($importPath),
  386. VObjectReader::OPTION_FORGIVING,
  387. );
  388. } catch (Throwable $e) {
  389. throw new CalendarMigratorException("Failed to read file \"$importPath\"", 0, $e);
  390. }
  391. $problems = $vCalendar->validate();
  392. if (!empty($problems)) {
  393. throw new CalendarMigratorException("Invalid calendar data contained in \"$importPath\"");
  394. }
  395. $splitFilename = explode('.', $filename, 2);
  396. if (count($splitFilename) !== 2) {
  397. throw new CalendarMigratorException("Invalid filename \"$filename\", expected filename of the format \"<calendar_name>" . CalendarMigrator::FILENAME_EXT . '"');
  398. }
  399. [$initialCalendarUri, $ext] = $splitFilename;
  400. try {
  401. $this->importCalendar(
  402. $user,
  403. $filename,
  404. $initialCalendarUri,
  405. $vCalendar,
  406. $output,
  407. );
  408. } catch (InvalidCalendarException $e) {
  409. // Allow this exception to skip a failed import
  410. } finally {
  411. $vCalendar->destroy();
  412. }
  413. }
  414. }
  415. /**
  416. * {@inheritDoc}
  417. */
  418. public function getId(): string {
  419. return 'calendar';
  420. }
  421. /**
  422. * {@inheritDoc}
  423. */
  424. public function getDisplayName(): string {
  425. return $this->l10n->t('Calendar');
  426. }
  427. /**
  428. * {@inheritDoc}
  429. */
  430. public function getDescription(): string {
  431. return $this->l10n->t('Calendars including events, details and attendees');
  432. }
  433. }