From da8b3b85c6be4a83b05ddf66492b9fb5e3fab998 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Sat, 18 Nov 2023 09:53:42 +0100 Subject: [PATCH] feat(caldav): add repair steps in sabre calendarobject change hook Signed-off-by: Thomas Citharel --- .../composer/composer/autoload_classmap.php | 4 + .../dav/composer/composer/autoload_static.php | 4 + apps/dav/lib/CalDAV/Repair/Description.php | 80 +++++++++++++ apps/dav/lib/CalDAV/Repair/IRepairStep.php | 37 ++++++ apps/dav/lib/CalDAV/Repair/Plugin.php | 72 ++++++++++++ .../lib/CalDAV/Repair/RepairStepFactory.php | 46 ++++++++ apps/dav/lib/Server.php | 6 + .../tests/unit/CalDAV/Repair/PluginTest.php | 109 ++++++++++++++++++ 8 files changed, 358 insertions(+) create mode 100644 apps/dav/lib/CalDAV/Repair/Description.php create mode 100644 apps/dav/lib/CalDAV/Repair/IRepairStep.php create mode 100644 apps/dav/lib/CalDAV/Repair/Plugin.php create mode 100644 apps/dav/lib/CalDAV/Repair/RepairStepFactory.php create mode 100644 apps/dav/tests/unit/CalDAV/Repair/PluginTest.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 5d6b077ad532e..063ae4ea0fd75 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -84,6 +84,10 @@ 'OCA\\DAV\\CalDAV\\Reminder\\NotificationTypeDoesNotExistException' => $baseDir . '/../lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php', 'OCA\\DAV\\CalDAV\\Reminder\\Notifier' => $baseDir . '/../lib/CalDAV/Reminder/Notifier.php', 'OCA\\DAV\\CalDAV\\Reminder\\ReminderService' => $baseDir . '/../lib/CalDAV/Reminder/ReminderService.php', + 'OCA\\DAV\\CalDAV\\Repair\\Description' => $baseDir . '/../lib/CalDAV/Repair/Description.php', + 'OCA\\DAV\\CalDAV\\Repair\\IRepairStep' => $baseDir . '/../lib/CalDAV/Repair/IRepairStep.php', + 'OCA\\DAV\\CalDAV\\Repair\\Plugin' => $baseDir . '/../lib/CalDAV/Repair/Plugin.php', + 'OCA\\DAV\\CalDAV\\Repair\\RepairStepFactory' => $baseDir . '/../lib/CalDAV/Repair/RepairStepFactory.php', 'OCA\\DAV\\CalDAV\\ResourceBooking\\AbstractPrincipalBackend' => $baseDir . '/../lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php', 'OCA\\DAV\\CalDAV\\ResourceBooking\\ResourcePrincipalBackend' => $baseDir . '/../lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php', 'OCA\\DAV\\CalDAV\\ResourceBooking\\RoomPrincipalBackend' => $baseDir . '/../lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 2456604a9cbde..2abee4af27e73 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -99,6 +99,10 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\Reminder\\NotificationTypeDoesNotExistException' => __DIR__ . '/..' . '/../lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php', 'OCA\\DAV\\CalDAV\\Reminder\\Notifier' => __DIR__ . '/..' . '/../lib/CalDAV/Reminder/Notifier.php', 'OCA\\DAV\\CalDAV\\Reminder\\ReminderService' => __DIR__ . '/..' . '/../lib/CalDAV/Reminder/ReminderService.php', + 'OCA\\DAV\\CalDAV\\Repair\\Description' => __DIR__ . '/..' . '/../lib/CalDAV/Repair/Description.php', + 'OCA\\DAV\\CalDAV\\Repair\\IRepairStep' => __DIR__ . '/..' . '/../lib/CalDAV/Repair/IRepairStep.php', + 'OCA\\DAV\\CalDAV\\Repair\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/Repair/Plugin.php', + 'OCA\\DAV\\CalDAV\\Repair\\RepairStepFactory' => __DIR__ . '/..' . '/../lib/CalDAV/Repair/RepairStepFactory.php', 'OCA\\DAV\\CalDAV\\ResourceBooking\\AbstractPrincipalBackend' => __DIR__ . '/..' . '/../lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php', 'OCA\\DAV\\CalDAV\\ResourceBooking\\ResourcePrincipalBackend' => __DIR__ . '/..' . '/../lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php', 'OCA\\DAV\\CalDAV\\ResourceBooking\\RoomPrincipalBackend' => __DIR__ . '/..' . '/../lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php', diff --git a/apps/dav/lib/CalDAV/Repair/Description.php b/apps/dav/lib/CalDAV/Repair/Description.php new file mode 100644 index 0000000000000..196e7b1793f5f --- /dev/null +++ b/apps/dav/lib/CalDAV/Repair/Description.php @@ -0,0 +1,80 @@ + + * + * @author Thomas Citharel + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ +namespace OCA\DAV\CalDAV\Repair; + +use Sabre\VObject\Component; +use Sabre\VObject\Component\VCalendar; + +class Description implements IRepairStep { + + private const X_ALT_DESC_PROP_NAME = "X-ALT-DESC"; + + private const SUPPORTED_COMPONENTS = ['VEVENT', 'VTODO']; + + public function runOnCreate(): bool { + return false; + } + + public function onCalendarObjectChange(?VCalendar $oldVCalendar, ?VCalendar $newVCalendar, bool &$modified): void { + $keyedOldComponents = []; + foreach ($oldVCalendar->children() as $child) { + if (!($child instanceof Component)) { + continue; + } + $keyedOldComponents[$child->UID] = $child; + } + + foreach (self::SUPPORTED_COMPONENTS as $supportedComponent) { + foreach ($newVCalendar->{$supportedComponent} as $newComponent) { + $this->onCalendarComponentChange($keyedOldComponents[$newComponent->UID], $newComponent, $modified); + } + } + } + + public function onCalendarComponentChange(?Component $oldObject, ?Component $newObject, bool &$modified): void { + // Get presence of description fields + $hasOldDescription = isset($oldObject->DESCRIPTION); + $hasNewDescription = isset($newObject->DESCRIPTION); + $hasOldXAltDesc = isset($oldObject->{self::X_ALT_DESC_PROP_NAME}); + $hasNewXAltDesc = isset($newObject->{self::X_ALT_DESC_PROP_NAME}); + $hasOldAltRep = isset($oldObject->DESCRIPTION['ALTREP']); + $hasNewAltRep = isset($newObject->DESCRIPTION['ALTREP']); + + // If all description fields are present, then verify consistency + if ($hasOldDescription && $hasNewDescription && (($hasOldXAltDesc && $hasNewXAltDesc) || ($hasOldAltRep && $hasNewAltRep))) { + // Compare descriptions + $isSameDescription = (string) $oldObject->DESCRIPTION === (string) $newObject->DESCRIPTION; + $isSameXAltDesc = (string) $oldObject->{self::X_ALT_DESC_PROP_NAME} === (string) $newObject->{self::X_ALT_DESC_PROP_NAME}; + $isSameAltRep = (string) $oldObject->DESCRIPTION['ALTREP'] === (string) $newObject->DESCRIPTION['ALTREP']; + + // If the description changed, but not the alternate one, then delete the latest + if (!$isSameDescription && $isSameXAltDesc) { + unset($newObject->{self::X_ALT_DESC_PROP_NAME}); + $modified = true; + } + if (!$isSameDescription && $isSameAltRep) { + unset($newObject->DESCRIPTION['ALTREP']); + $modified = true; + } + } + } +} diff --git a/apps/dav/lib/CalDAV/Repair/IRepairStep.php b/apps/dav/lib/CalDAV/Repair/IRepairStep.php new file mode 100644 index 0000000000000..fc21741516828 --- /dev/null +++ b/apps/dav/lib/CalDAV/Repair/IRepairStep.php @@ -0,0 +1,37 @@ + + * + * @author Thomas Citharel + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ +namespace OCA\DAV\CalDAV\Repair; + +use Sabre\VObject\Component\VCalendar; + +interface IRepairStep { + /** + * Returns true if the step will be run on new data as well as updated one + */ + public function runOnCreate(): bool; + + /** + * The callback to implement while checking. If it runs on create, beware that oldObject will logically be null for this condition. + * Fix the updated object by editing the $newObject and setting $modified to true. + */ + public function onCalendarObjectChange(?VCalendar $oldVCalendar, ?VCalendar $newVCalendar, bool &$modified): void; +} diff --git a/apps/dav/lib/CalDAV/Repair/Plugin.php b/apps/dav/lib/CalDAV/Repair/Plugin.php new file mode 100644 index 0000000000000..fafdad6d441c5 --- /dev/null +++ b/apps/dav/lib/CalDAV/Repair/Plugin.php @@ -0,0 +1,72 @@ + + * + * @author Thomas Citharel + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ +namespace OCA\DAV\CalDAV\Repair; + +use Sabre\CalDAV\ICalendarObject; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Reader; + +class Plugin extends ServerPlugin { + + private Server $server; + + public function __construct(private RepairStepFactory $repairStepFactory) { } + + /** + * Returns the name of the plugin. + * + * Using this name other plugins will be able to access other plugins + * using Server::getPlugin + */ + public function getPluginName(): string { + return 'nc-caldav-repair'; + } + + public function initialize(Server $server): void { + $this->server = $server; + $server->on('calendarObjectChange', [$this, 'calendarObjectChange']); + } + + public function calendarObjectChange(RequestInterface $request, ResponseInterface $response, VCalendar $vCal, string $calendarPath, bool &$modified, bool $isNew): void { + foreach ($this->repairStepFactory->getRepairSteps() as $repairStep) { + if ($repairStep->runOnCreate() && $isNew) { + $repairStep->onCalendarObjectChange(null, $vCal, $modified); + } else if (!$isNew) { + try { + /** @var ICalendarObject $node */ + $node = $this->server->tree->getNodeForPath($request->getPath()); + /** @var VCalendar $oldObj */ + $oldObj = Reader::read($node->get()); + $repairStep->onCalendarObjectChange($oldObj, $vCal, $modified); + } catch (NotFound) { + // Nothing, we just skip + } + } + } + + } +} diff --git a/apps/dav/lib/CalDAV/Repair/RepairStepFactory.php b/apps/dav/lib/CalDAV/Repair/RepairStepFactory.php new file mode 100644 index 0000000000000..470750280e3aa --- /dev/null +++ b/apps/dav/lib/CalDAV/Repair/RepairStepFactory.php @@ -0,0 +1,46 @@ + + * + * @author Thomas Citharel + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ +namespace OCA\DAV\CalDAV\Repair; + +class RepairStepFactory { + /** + * @var IRepairStep[] + */ + private array $repairSteps = []; + + /** + * @return IRepairStep[] + */ + public function getRepairSteps(): array { + return $this->repairSteps; + } + + public function addRepairStep(IRepairStep $repairStep): self { + $this->repairSteps[] = $repairStep; + return $this; + } + + public function registerRepairStep(string $repairStep): self { + $this->addRepairStep(new $repairStep); + return $this; + } +} diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index dedb959c1cd6a..ea46b6dcc3ca5 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -39,6 +39,9 @@ use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\BulkUpload\BulkUploadPlugin; use OCA\DAV\CalDAV\BirthdayService; +use OCA\DAV\CalDAV\Repair\Plugin as RepairPlugin; +use OCA\DAV\CalDAV\Repair\Description; +use OCA\DAV\CalDAV\Repair\RepairStepFactory; use OCA\DAV\CardDAV\HasPhotoPlugin; use OCA\DAV\CardDAV\ImageExportPlugin; use OCA\DAV\CardDAV\MultiGetExportPlugin; @@ -178,6 +181,9 @@ public function __construct(IRequest $request, string $baseUri) { $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); $this->server->addPlugin(new \OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin(\OC::$server->getConfig(), $logger)); $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OC::$server->getConfig(), \OC::$server->get(LoggerInterface::class))); + $repairStepFactory = \OCP\Server::get(RepairStepFactory::class); + $repairStepFactory->registerRepairStep(Description::class); + $this->server->addPlugin(new RepairPlugin($repairStepFactory)); if (\OC::$server->getConfig()->getAppValue('dav', 'sendInvitations', 'yes') === 'yes') { $this->server->addPlugin(\OC::$server->query(\OCA\DAV\CalDAV\Schedule\IMipPlugin::class)); } diff --git a/apps/dav/tests/unit/CalDAV/Repair/PluginTest.php b/apps/dav/tests/unit/CalDAV/Repair/PluginTest.php new file mode 100644 index 0000000000000..22009458c2091 --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/Repair/PluginTest.php @@ -0,0 +1,109 @@ + + * + * @author Thomas Citharel + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ +namespace OCA\DAV\Tests\unit\CalDAV\Repair; + +use OCA\DAV\CalDAV\Repair\IRepairStep; +use OCA\DAV\CalDAV\Repair\Plugin; +use OCA\DAV\CalDAV\Repair\RepairStepFactory; +use PHPUnit\Framework\MockObject\MockObject; +use Sabre\CalDAV\ICalendarObject; +use Sabre\DAV\Server; +use Sabre\DAV\Tree; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use Sabre\VObject\Component; +use Sabre\VObject\Component\VCalendar; +use Test\TestCase; + +class PluginTest extends TestCase { + + private RequestInterface|MockObject $request; + private ResponseInterface|MockObject $response; + private Tree|MockObject $tree; + private IRepairStep|MockObject $repairStep; + + private Plugin $plugin; + + protected function setUp(): void { + parent::setUp(); + $this->request = $this->createMock(RequestInterface::class); + $this->response = $this->createMock(ResponseInterface::class); + $server = $this->createMock(Server::class); + $this->tree = $this->createMock(Tree::class); + $server->tree = $this->tree; + $this->repairStep = $this->createMock(IRepairStep::class); + $this->repairStepFactory = new RepairStepFactory(); + $this->repairStepFactory->addRepairStep($this->repairStep); + + $this->plugin = new Plugin($this->repairStepFactory); + $this->plugin->initialize($server); + } + + /** + * @dataProvider dataForTestRunRepairStepsOnCalendarData + */ + public function testRunRepairStepsOnCalendarData(VCalendar $VCalendar, ?VCalendar $oldVCalendar, bool $modified, bool $isNew, bool $repairStepRunOnCreate): void { + $modifiedChanged = false; + $this->repairStep->expects($this->once())->method('runOnCreate')->willReturn($repairStepRunOnCreate); + $this->repairStep->expects($this->once())->method('onCalendarObjectChange')->with(self::callback(function (?VCalendar $value) use ($oldVCalendar) { + // Can't simply check object equality because of missing references to parents, so checking the serialized value + self::assertSame($oldVCalendar?->serialize(), $value?->serialize()); + return true; + }), self::callback(function (VCalendar $value) use ($VCalendar) { + self::assertSame($VCalendar->serialize(), $value->serialize()); + return true; + }), $modifiedChanged); + $node = $this->createMock(ICalendarObject::class); + $node->expects($isNew ? $this->never() : $this->once())->method('get')->willReturn($oldVCalendar?->serialize()); + $this->request->expects($isNew ? $this->never() : $this->once())->method('getPath')->willReturn('/a-path'); + $this->tree->expects($isNew ? $this->never() : $this->once())->method('getNodeForPath')->with('/a-path')->willReturn($node); + $this->plugin->calendarObjectChange($this->request, $this->response, $VCalendar, '', $modifiedChanged, $isNew); + self::assertSame($modified, $modifiedChanged); + } + + public function dataForTestRunRepairStepsOnCalendarData(): array { + + $vCalendar = new VCalendar(); + $oldVCalendar = new VCalendar(); + + $vCalendar->add('VEVENT', [ + 'UID' => 'uid-1234', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting', + 'DTSTART' => new \DateTime('2016-01-01 00:00:00'), + ]); + + $oldVCalendar->add('VEVENT', [ + 'UID' => 'uid-1234', + 'LAST-MODIFIED' => 123456, + 'SEQUENCE' => 2, + 'SUMMARY' => 'Fellowship meeting updated', + 'DTSTART' => new \DateTime('2018-01-01 00:00:00'), + ]); + + return [ + [$vCalendar, null, false, true, true], + [$vCalendar, $oldVCalendar, false, false, false] + ]; + } +}