From d9aec9bee456658ed91abb1b343a920bc19e611a Mon Sep 17 00:00:00 2001 From: Hamza Date: Sun, 24 May 2026 14:54:15 +0200 Subject: [PATCH] fix(caldav-delegation): send notification to delegator Signed-off-by: Hamza --- .../composer/composer/autoload_classmap.php | 3 + .../dav/composer/composer/autoload_static.php | 3 + apps/dav/lib/AppInfo/Application.php | 7 + apps/dav/lib/CalDAV/EmbeddedCalDavServer.php | 2 + .../Schedule/DelegateActionCapturePlugin.php | 80 ++++++ .../CalDAV/Schedule/DelegateActionContext.php | 31 +++ .../CalendarDelegateActionListener.php | 262 ++++++++++++++++++ apps/dav/lib/Server.php | 2 + 8 files changed, 390 insertions(+) create mode 100644 apps/dav/lib/CalDAV/Schedule/DelegateActionCapturePlugin.php create mode 100644 apps/dav/lib/CalDAV/Schedule/DelegateActionContext.php create mode 100644 apps/dav/lib/Listener/CalendarDelegateActionListener.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index f5861328d1a21..18606a64db13e 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -121,6 +121,8 @@ 'OCA\\DAV\\CalDAV\\ResourceBooking\\ResourcePrincipalBackend' => $baseDir . '/../lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php', 'OCA\\DAV\\CalDAV\\ResourceBooking\\RoomPrincipalBackend' => $baseDir . '/../lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php', 'OCA\\DAV\\CalDAV\\RetentionService' => $baseDir . '/../lib/CalDAV/RetentionService.php', + 'OCA\\DAV\\CalDAV\\Schedule\\DelegateActionCapturePlugin' => $baseDir . '/../lib/CalDAV/Schedule/DelegateActionCapturePlugin.php', + 'OCA\\DAV\\CalDAV\\Schedule\\DelegateActionContext' => $baseDir . '/../lib/CalDAV/Schedule/DelegateActionContext.php', 'OCA\\DAV\\CalDAV\\Schedule\\IMipPlugin' => $baseDir . '/../lib/CalDAV/Schedule/IMipPlugin.php', 'OCA\\DAV\\CalDAV\\Schedule\\IMipService' => $baseDir . '/../lib/CalDAV/Schedule/IMipService.php', 'OCA\\DAV\\CalDAV\\Schedule\\Plugin' => $baseDir . '/../lib/CalDAV/Schedule/Plugin.php', @@ -331,6 +333,7 @@ 'OCA\\DAV\\Listener\\AddressbookListener' => $baseDir . '/../lib/Listener/AddressbookListener.php', 'OCA\\DAV\\Listener\\BirthdayListener' => $baseDir . '/../lib/Listener/BirthdayListener.php', 'OCA\\DAV\\Listener\\CalendarContactInteractionListener' => $baseDir . '/../lib/Listener/CalendarContactInteractionListener.php', + 'OCA\\DAV\\Listener\\CalendarDelegateActionListener' => $baseDir . '/../lib/Listener/CalendarDelegateActionListener.php', 'OCA\\DAV\\Listener\\CalendarDeletionDefaultUpdaterListener' => $baseDir . '/../lib/Listener/CalendarDeletionDefaultUpdaterListener.php', 'OCA\\DAV\\Listener\\CalendarFederationNotificationListener' => $baseDir . '/../lib/Listener/CalendarFederationNotificationListener.php', 'OCA\\DAV\\Listener\\CalendarObjectReminderUpdaterListener' => $baseDir . '/../lib/Listener/CalendarObjectReminderUpdaterListener.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 25b6fcefd976d..2fa3243461c2c 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -136,6 +136,8 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\ResourceBooking\\ResourcePrincipalBackend' => __DIR__ . '/..' . '/../lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php', 'OCA\\DAV\\CalDAV\\ResourceBooking\\RoomPrincipalBackend' => __DIR__ . '/..' . '/../lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php', 'OCA\\DAV\\CalDAV\\RetentionService' => __DIR__ . '/..' . '/../lib/CalDAV/RetentionService.php', + 'OCA\\DAV\\CalDAV\\Schedule\\DelegateActionCapturePlugin' => __DIR__ . '/..' . '/../lib/CalDAV/Schedule/DelegateActionCapturePlugin.php', + 'OCA\\DAV\\CalDAV\\Schedule\\DelegateActionContext' => __DIR__ . '/..' . '/../lib/CalDAV/Schedule/DelegateActionContext.php', 'OCA\\DAV\\CalDAV\\Schedule\\IMipPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/Schedule/IMipPlugin.php', 'OCA\\DAV\\CalDAV\\Schedule\\IMipService' => __DIR__ . '/..' . '/../lib/CalDAV/Schedule/IMipService.php', 'OCA\\DAV\\CalDAV\\Schedule\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/Schedule/Plugin.php', @@ -346,6 +348,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Listener\\AddressbookListener' => __DIR__ . '/..' . '/../lib/Listener/AddressbookListener.php', 'OCA\\DAV\\Listener\\BirthdayListener' => __DIR__ . '/..' . '/../lib/Listener/BirthdayListener.php', 'OCA\\DAV\\Listener\\CalendarContactInteractionListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarContactInteractionListener.php', + 'OCA\\DAV\\Listener\\CalendarDelegateActionListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarDelegateActionListener.php', 'OCA\\DAV\\Listener\\CalendarDeletionDefaultUpdaterListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarDeletionDefaultUpdaterListener.php', 'OCA\\DAV\\Listener\\CalendarFederationNotificationListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarFederationNotificationListener.php', 'OCA\\DAV\\Listener\\CalendarObjectReminderUpdaterListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarObjectReminderUpdaterListener.php', diff --git a/apps/dav/lib/AppInfo/Application.php b/apps/dav/lib/AppInfo/Application.php index 6b0f22a5022a5..97281c9150fa2 100644 --- a/apps/dav/lib/AppInfo/Application.php +++ b/apps/dav/lib/AppInfo/Application.php @@ -48,6 +48,7 @@ use OCA\DAV\Listener\AddressbookListener; use OCA\DAV\Listener\BirthdayListener; use OCA\DAV\Listener\CalendarContactInteractionListener; +use OCA\DAV\Listener\CalendarDelegateActionListener; use OCA\DAV\Listener\CalendarDeletionDefaultUpdaterListener; use OCA\DAV\Listener\CalendarFederationNotificationListener; use OCA\DAV\Listener\CalendarObjectReminderUpdaterListener; @@ -217,6 +218,12 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(CalendarObjectUpdatedEvent::class, CalendarFederationNotificationListener::class); $context->registerEventListener(CalendarObjectDeletedEvent::class, CalendarFederationNotificationListener::class); + $context->registerEventListener(CalendarObjectCreatedEvent::class, CalendarDelegateActionListener::class); + $context->registerEventListener(CalendarObjectUpdatedEvent::class, CalendarDelegateActionListener::class); + $context->registerEventListener(CalendarObjectDeletedEvent::class, CalendarDelegateActionListener::class); + $context->registerEventListener(CalendarObjectMovedToTrashEvent::class, CalendarDelegateActionListener::class); + $context->registerEventListener(CalendarObjectRestoredEvent::class, CalendarDelegateActionListener::class); + $context->registerNotifierService(NotifierCalDAV::class); $context->registerNotifierService(NotifierCardDAV::class); diff --git a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php index 6037fa3194e33..522c4163f612b 100644 --- a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php +++ b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php @@ -13,6 +13,7 @@ use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; use OCA\DAV\CalDAV\Auth\PublicPrincipalPlugin; use OCA\DAV\CalDAV\Publishing\PublishPlugin; +use OCA\DAV\CalDAV\Schedule\DelegateActionCapturePlugin; use OCA\DAV\CalDAV\Schedule\IMipPlugin; use OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin; use OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin; @@ -97,6 +98,7 @@ public function __construct(bool $public = true) { if ($appConfig->getValueString('dav', 'sendInvitations', 'yes') === 'yes') { $this->server->addPlugin(Server::get(IMipPlugin::class)); } + $this->server->addPlugin(Server::get(DelegateActionCapturePlugin::class)); // collection preload plugin $this->server->addPlugin(new PropFindPreloadNotifyPlugin()); diff --git a/apps/dav/lib/CalDAV/Schedule/DelegateActionCapturePlugin.php b/apps/dav/lib/CalDAV/Schedule/DelegateActionCapturePlugin.php new file mode 100644 index 0000000000000..05a6cea02993c --- /dev/null +++ b/apps/dav/lib/CalDAV/Schedule/DelegateActionCapturePlugin.php @@ -0,0 +1,80 @@ +server = $server; + $server->on('beforeWriteContent', [$this, 'beforeWriteContent'], 10); + $server->on('beforeUnbind', [$this, 'beforeUnbind'], 10); + } + + #[\Override] + public function getPluginName(): string { + return 'nc-delegate-action-capture'; + } + + public function beforeWriteContent(string $uri, INode $node, $data, $modified): void { + $this->capture($node); + } + + public function beforeUnbind(string $path): void { + if ($this->server === null) { + return; + } + try { + $node = $this->server->tree->getNodeForPath($path); + } catch (Throwable) { + return; + } + $this->capture($node); + } + + private function capture(INode $node): void { + if (!$node instanceof CalendarObject) { + return; + } + try { + $raw = $node->get(); + if ($raw === '') { + return; + } + $vCalendar = Reader::read($raw); + if ($vCalendar instanceof VCalendar) { + $this->context->setPrevious($vCalendar); + } + } catch (Throwable) { + // Capture is best-effort: swallow malformed iCalendar. + } + } +} diff --git a/apps/dav/lib/CalDAV/Schedule/DelegateActionContext.php b/apps/dav/lib/CalDAV/Schedule/DelegateActionContext.php new file mode 100644 index 0000000000000..e4166cfb41598 --- /dev/null +++ b/apps/dav/lib/CalDAV/Schedule/DelegateActionContext.php @@ -0,0 +1,31 @@ +previous = $vCalendar; + } + + public function getPrevious(): ?VCalendar { + return $this->previous; + } +} diff --git a/apps/dav/lib/Listener/CalendarDelegateActionListener.php b/apps/dav/lib/Listener/CalendarDelegateActionListener.php new file mode 100644 index 0000000000000..ac271a77ba388 --- /dev/null +++ b/apps/dav/lib/Listener/CalendarDelegateActionListener.php @@ -0,0 +1,262 @@ + + */ +class CalendarDelegateActionListener implements IEventListener { + + private const ACTION_CREATE = 'create'; + private const ACTION_UPDATE = 'update'; + private const ACTION_DELETE = 'delete'; + private const ACTION_TRASH = 'trash'; + private const ACTION_RESTORE = 'restore'; + + public function __construct( + private readonly IUserSession $userSession, + private readonly IUserManager $userManager, + private readonly ProxyMapper $proxyMapper, + private readonly IMailer $mailer, + private readonly IL10NFactory $l10nFactory, + private readonly IMipService $imipService, + private readonly DelegateActionContext $delegateActionContext, + private readonly LoggerInterface $logger, + ) { + } + + #[\Override] + public function handle(Event $event): void { + $action = match (true) { + $event instanceof CalendarObjectCreatedEvent => self::ACTION_CREATE, + $event instanceof CalendarObjectUpdatedEvent => self::ACTION_UPDATE, + $event instanceof CalendarObjectDeletedEvent => self::ACTION_DELETE, + $event instanceof CalendarObjectMovedToTrashEvent => self::ACTION_TRASH, + $event instanceof CalendarObjectRestoredEvent => self::ACTION_RESTORE, + default => null, + }; + if ($action === null) { + return; + } + + $actor = $this->userSession->getUser(); + if ($actor === null) { + return; + } + + $calendarInfo = $event->getCalendarData(); + $ownerPrincipalUri = $calendarInfo['principaluri'] ?? null; + if (!is_string($ownerPrincipalUri) || !str_starts_with($ownerPrincipalUri, 'principals/users/')) { + return; + } + + [, $ownerUid] = \Sabre\Uri\split($ownerPrincipalUri); + if ($ownerUid === $actor->getUID()) { + return; + } + + if (!$this->actorIsProxyOf($actor->getUID(), $ownerPrincipalUri)) { + return; + } + + $owner = $this->userManager->get($ownerUid); + if ($owner === null) { + return; + } + $ownerEmail = $owner->getEMailAddress(); + if ($ownerEmail === null || $ownerEmail === '') { + return; + } + + try { + $this->sendNotification($action, $actor, $owner, $ownerEmail, $calendarInfo, $event->getObjectData()); + } catch (Throwable $e) { + $this->logger->warning('Could not send delegate-action notification to calendar owner', [ + 'app' => 'dav', + 'owner' => $ownerUid, + 'actor' => $actor->getUID(), + 'action' => $action, + 'exception' => $e, + ]); + } + } + + private function actorIsProxyOf(string $actorUid, string $ownerPrincipalUri): bool { + $actorPrincipalUri = 'principals/users/' . $actorUid; + foreach ($this->proxyMapper->getProxiesOf($ownerPrincipalUri) as $proxy) { + if ($proxy->getProxyId() === $actorPrincipalUri) { + return true; + } + } + return false; + } + + private function sendNotification( + string $action, + IUser $actor, + IUser $owner, + string $ownerEmail, + array $calendarInfo, + array $objectData, + ): void { + $l = $this->l10nFactory->get('dav', $this->l10nFactory->getUserLanguage($owner)); + + $newVCalendar = $this->readVCalendar($objectData['calendardata'] ?? null); + $newVEvent = $this->firstVEvent($newVCalendar); + if ($newVEvent === null) { + // Without a VEVENT there is nothing meaningful to describe. + return; + } + + $oldVCalendar = $this->delegateActionContext->getPrevious(); + $oldVEvent = $this->firstVEvent($oldVCalendar); + + $actorName = $actor->getDisplayName() ?: $actor->getUID(); + $calendarName = (string)($calendarInfo['{DAV:}displayname'] ?? $calendarInfo['uri'] ?? 'calendar'); + + // Build the same data payload IMipPlugin uses, so addBulletList renders + // the familiar title/when/location/url/description list — with diff + // strikethroughs when an old version is available. + $isCancellation = $action === self::ACTION_DELETE || $action === self::ACTION_TRASH; + $data = $isCancellation + ? $this->imipService->buildCancelledBodyData($newVEvent) + : $this->imipService->buildBodyData($newVEvent, $action === self::ACTION_UPDATE ? $oldVEvent : null); + + $summary = (string)($newVEvent->SUMMARY ?? $l->t('Untitled event')); + + [$subject, $heading] = $this->subjectAndHeading($l, $action, $actorName, $summary, $calendarName); + + $template = $this->mailer->createEMailTemplate('dav.delegateAction.' . $action, [ + 'actor' => $actorName, + 'calendar' => $calendarName, + 'event' => $summary, + ]); + $template->addHeader(); + $template->setSubject($subject); + $template->addHeading($heading); + + // Attribution row (who did it, on which calendar) — sits above the + // event details so the owner immediately sees the responsible delegate. + $template->addBodyListItem($actorName, $l->t('Delegate:')); + $template->addBodyListItem($calendarName, $l->t('Calendar:')); + + $this->imipService->addBulletList($template, $newVEvent, $data); + + $template->addFooter(); + + $message = $this->mailer->createMessage(); + $message->setFrom([Util::getDefaultEmailAddress('invitations-noreply') => $actorName]); + $message->setTo([$ownerEmail => $owner->getDisplayName() ?: $owner->getUID()]); + $message->setSubject($subject); + $message->useTemplate($template); + + // Attach the raw iCalendar so the owner's client can pick up the change. + if ($action !== self::ACTION_DELETE) { + $calendarData = $objectData['calendardata'] ?? null; + if (is_resource($calendarData)) { + $calendarData = stream_get_contents($calendarData); + } + if (is_string($calendarData) && $calendarData !== '') { + $message->attachInline( + $calendarData, + 'event.ics', + 'text/calendar; charset="utf-8"', + ); + } + } + + $this->mailer->send($message); + } + + /** + * @return array{0: string, 1: string} [subject, heading] + */ + private function subjectAndHeading(\OCP\IL10N $l, string $action, string $actorName, string $summary, string $calendarName): array { + return match ($action) { + self::ACTION_CREATE => [ + $l->t('%1$s created "%2$s" on your behalf', [$actorName, $summary]), + $l->t('%1$s created "%2$s" on your calendar "%3$s"', [$actorName, $summary, $calendarName]), + ], + self::ACTION_UPDATE => [ + $l->t('%1$s updated "%2$s" on your behalf', [$actorName, $summary]), + $l->t('%1$s updated "%2$s" on your calendar "%3$s"', [$actorName, $summary, $calendarName]), + ], + self::ACTION_DELETE => [ + $l->t('%1$s deleted "%2$s" on your behalf', [$actorName, $summary]), + $l->t('%1$s permanently deleted "%2$s" from your calendar "%3$s"', [$actorName, $summary, $calendarName]), + ], + self::ACTION_TRASH => [ + $l->t('%1$s moved "%2$s" to the trash on your behalf', [$actorName, $summary]), + $l->t('%1$s moved "%2$s" to the trash on your calendar "%3$s"', [$actorName, $summary, $calendarName]), + ], + self::ACTION_RESTORE => [ + $l->t('%1$s restored "%2$s" on your behalf', [$actorName, $summary]), + $l->t('%1$s restored "%2$s" on your calendar "%3$s"', [$actorName, $summary, $calendarName]), + ], + }; + } + + private function readVCalendar(mixed $calendarData): ?VCalendar { + if (is_resource($calendarData)) { + $calendarData = stream_get_contents($calendarData); + } + if (!is_string($calendarData) || $calendarData === '') { + return null; + } + try { + $vCalendar = Reader::read($calendarData); + } catch (Throwable) { + return null; + } + return $vCalendar instanceof VCalendar ? $vCalendar : null; + } + + private function firstVEvent(?VCalendar $vCalendar): ?VEvent { + if ($vCalendar === null) { + return null; + } + foreach ($vCalendar->VEVENT ?? [] as $vEvent) { + if ($vEvent instanceof VEvent) { + return $vEvent; + } + } + return null; + } +} diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index 86768176536e0..f64b337234a5e 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -16,6 +16,7 @@ use OCA\DAV\CalDAV\EventComparisonService; use OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin; use OCA\DAV\CalDAV\Publishing\PublishPlugin; +use OCA\DAV\CalDAV\Schedule\DelegateActionCapturePlugin; use OCA\DAV\CalDAV\Schedule\IMipPlugin; use OCA\DAV\CalDAV\Schedule\IMipService; use OCA\DAV\CalDAV\Security\RateLimitingPlugin; @@ -358,6 +359,7 @@ public function __construct( \OCP\Server::get(IEmailValidator::class), )); } + $this->server->addPlugin(\OCP\Server::get(DelegateActionCapturePlugin::class)); $this->server->addPlugin(new \OCA\DAV\CalDAV\Search\SearchPlugin()); if ($view !== null) { $this->server->addPlugin(new FilesReportPlugin(