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