From 414fa48d84a33a21cdccf2a89f4b42275e0d7d7b Mon Sep 17 00:00:00 2001 From: Peter Carlson Date: Mon, 23 Feb 2026 20:21:41 -0800 Subject: [PATCH 1/4] feat: add database layer for per-share alarm suppression Add migration, entity, and mapper for the calendar_share_alarms table. This stores per-share preferences for whether VALARM components should be stripped from CalDAV responses for shared calendars. Ref: https://github.com/nextcloud/calendar/issues/7498 --- lib/Db/ShareAlarmSetting.php | 37 +++++++ lib/Db/ShareAlarmSettingMapper.php | 98 +++++++++++++++++++ .../Version5050Date20250701000005.php | 57 +++++++++++ 3 files changed, 192 insertions(+) create mode 100644 lib/Db/ShareAlarmSetting.php create mode 100644 lib/Db/ShareAlarmSettingMapper.php create mode 100644 lib/Migration/Version5050Date20250701000005.php diff --git a/lib/Db/ShareAlarmSetting.php b/lib/Db/ShareAlarmSetting.php new file mode 100644 index 0000000000..431e84f12f --- /dev/null +++ b/lib/Db/ShareAlarmSetting.php @@ -0,0 +1,37 @@ +addType('calendarId', Types::INTEGER); + $this->addType('suppressAlarms', Types::BOOLEAN); + } +} diff --git a/lib/Db/ShareAlarmSettingMapper.php b/lib/Db/ShareAlarmSettingMapper.php new file mode 100644 index 0000000000..00f42bdf4b --- /dev/null +++ b/lib/Db/ShareAlarmSettingMapper.php @@ -0,0 +1,98 @@ + + */ +class ShareAlarmSettingMapper extends QBMapper { + + public function __construct(IDBConnection $db) { + parent::__construct($db, 'calendar_share_alarms'); + } + + /** + * Find the alarm suppression setting for a specific calendar and sharee + * + * @param int $calendarId The internal calendar ID + * @param string $principalUri The sharee's principal URI + * @return ShareAlarmSetting + * @throws DoesNotExistException When no setting exists + */ + public function findByCalendarAndPrincipal(int $calendarId, string $principalUri): ShareAlarmSetting { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('calendar_id', $qb->createNamedParameter($calendarId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('principal_uri', $qb->createNamedParameter($principalUri))); + return $this->findEntity($qb); + } + + /** + * Check whether alarms should be suppressed for a given calendar and sharee + * + * @param int $calendarId The internal calendar ID + * @param string $principalUri The sharee's principal URI + * @return bool True if alarms should be suppressed + */ + public function isSuppressed(int $calendarId, string $principalUri): bool { + try { + $setting = $this->findByCalendarAndPrincipal($calendarId, $principalUri); + return $setting->getSuppressAlarms(); + } catch (DoesNotExistException) { + return false; + } + } + + /** + * Find all alarm settings for a given calendar + * + * @param int $calendarId The internal calendar ID + * @return ShareAlarmSetting[] + */ + public function findAllByCalendarId(int $calendarId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('calendar_id', $qb->createNamedParameter($calendarId, IQueryBuilder::PARAM_INT))); + return $this->findEntities($qb); + } + + /** + * Delete all alarm settings for a calendar + * + * @param int $calendarId The internal calendar ID + */ + public function deleteByCalendarId(int $calendarId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('calendar_id', $qb->createNamedParameter($calendarId, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + } + + /** + * Delete alarm setting for a specific share + * + * @param int $calendarId The internal calendar ID + * @param string $principalUri The sharee's principal URI + */ + public function deleteByCalendarAndPrincipal(int $calendarId, string $principalUri): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('calendar_id', $qb->createNamedParameter($calendarId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('principal_uri', $qb->createNamedParameter($principalUri))); + $qb->executeStatement(); + } +} diff --git a/lib/Migration/Version5050Date20250701000005.php b/lib/Migration/Version5050Date20250701000005.php new file mode 100644 index 0000000000..0481ada310 --- /dev/null +++ b/lib/Migration/Version5050Date20250701000005.php @@ -0,0 +1,57 @@ +hasTable('calendar_share_alarms')) { + return $schema; + } + + $table = $schema->createTable('calendar_share_alarms'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('calendar_id', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('principal_uri', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('suppress_alarms', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['calendar_id', 'principal_uri'], 'cal_share_alarm_unique'); + + return $schema; + } +} From c9e160493ef28105765cc0e8a4e6ce8bc3d41cda Mon Sep 17 00:00:00 2001 From: Peter Carlson Date: Mon, 23 Feb 2026 20:22:02 -0800 Subject: [PATCH 2/4] feat: add SabreDAV plugin to strip VALARM from shared calendars Register a SabreDAV plugin via SabrePluginAddEvent that intercepts CalDAV REPORT and GET responses. When alarm suppression is enabled for a share, VALARM components are stripped from the ICS data before it reaches the sharee's client. Hooks into propFind (priority 600) for REPORT responses and afterMethod:GET for direct .ics fetches. Uses an in-memory cache to avoid repeated DB queries within a single request. Ref: https://github.com/nextcloud/calendar/issues/7498 --- lib/AppInfo/Application.php | 4 + lib/Dav/StripAlarmsPlugin.php | 235 ++++++++++++++++++++++++ lib/Listener/SabrePluginAddListener.php | 42 +++++ 3 files changed, 281 insertions(+) create mode 100644 lib/Dav/StripAlarmsPlugin.php create mode 100644 lib/Listener/SabrePluginAddListener.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 9022e92aa6..76b4c7139c 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -12,11 +12,13 @@ use OCA\Calendar\Listener\AppointmentBookedListener; use OCA\Calendar\Listener\CalendarReferenceListener; use OCA\Calendar\Listener\NotifyPushListener; +use OCA\Calendar\Listener\SabrePluginAddListener; use OCA\Calendar\Listener\UserDeletedListener; use OCA\Calendar\Notification\Notifier; use OCA\Calendar\Profile\AppointmentsAction; use OCA\Calendar\Reference\ReferenceProvider; use OCA\Calendar\UserMigration\Migrator; +use OCA\DAV\Events\SabrePluginAddEvent; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -63,6 +65,8 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(CalendarObjectUpdatedEvent::class, NotifyPushListener::class); $context->registerEventListener(CalendarObjectDeletedEvent::class, NotifyPushListener::class); + $context->registerEventListener(SabrePluginAddEvent::class, SabrePluginAddListener::class); + $context->registerNotifierService(Notifier::class); $context->registerUserMigrator(Migrator::class); diff --git a/lib/Dav/StripAlarmsPlugin.php b/lib/Dav/StripAlarmsPlugin.php new file mode 100644 index 0000000000..eb291f3287 --- /dev/null +++ b/lib/Dav/StripAlarmsPlugin.php @@ -0,0 +1,235 @@ + In-memory cache for suppression lookups per request */ + private array $suppressionCache = []; + + public function __construct( + private readonly ShareAlarmSettingMapper $mapper, + private readonly LoggerInterface $logger, + ) { + } + + /** + * Returns the plugin name + * + * @return string + */ + public function getPluginName(): string { + return 'nc-calendar-strip-alarms'; + } + + /** + * Register event handlers on the SabreDAV server + * + * @param Server $server The SabreDAV server instance + */ + public function initialize(Server $server): void { + $this->server = $server; + // Priority 600: runs after CalDAV plugin's propFind handlers (150-550) + // so that calendar-data is already populated + $server->on('propFind', [$this, 'handlePropFind'], 600); + // Handle direct GET requests on calendar objects + $server->on('afterMethod:GET', [$this, 'handleAfterGet']); + } + + /** + * Handle propFind events for REPORT responses (calendar-multiget, calendar-query) + * + * Checks if the calendar-data property contains VALARM components that + * should be stripped for this sharee, and removes them. + * + * @param PropFind $propFind The PropFind object + * @param INode $node The node being queried + */ + public function handlePropFind(PropFind $propFind, INode $node): void { + if (!($node instanceof ICalendarObject)) { + return; + } + + if (!$this->shouldStripAlarms($node)) { + return; + } + + $calendarData = $propFind->get('{urn:ietf:params:xml:ns:caldav}calendar-data'); + if ($calendarData === null) { + return; + } + + $stripped = $this->stripVAlarms($calendarData); + if ($stripped !== null) { + $propFind->set('{urn:ietf:params:xml:ns:caldav}calendar-data', $stripped); + } + } + + /** + * Handle afterMethod:GET events for direct GET requests on calendar objects + * + * @param RequestInterface $request The HTTP request + * @param ResponseInterface $response The HTTP response + */ + public function handleAfterGet(RequestInterface $request, ResponseInterface $response): void { + $path = $request->getPath(); + + try { + $node = $this->server->tree->getNodeForPath($path); + } catch (\Exception) { + return; + } + + if (!($node instanceof ICalendarObject)) { + return; + } + + if (!$this->shouldStripAlarms($node)) { + return; + } + + $body = $response->getBodyAsString(); + if (empty($body)) { + return; + } + + $stripped = $this->stripVAlarms($body); + if ($stripped !== null) { + $response->setBody($stripped); + } + } + + /** + * Determine whether VALARM components should be stripped from this node + * + * Checks if the node belongs to a shared calendar where the owner + * has enabled alarm suppression for the current sharee. + * + * @param ICalendarObject $node The calendar object node + * @return bool True if alarms should be stripped + */ + private function shouldStripAlarms(ICalendarObject $node): bool { + $calendarInfo = $this->getCalendarInfo($node); + if ($calendarInfo === null) { + return false; + } + + $ownerPrincipal = $calendarInfo['{http://owncloud.org/ns}owner-principal'] ?? null; + $principalUri = $calendarInfo['principaluri'] ?? null; + + // Not a shared calendar if owner matches current principal + if ($ownerPrincipal === null || $principalUri === null || $ownerPrincipal === $principalUri) { + return false; + } + + $calendarId = $calendarInfo['id'] ?? null; + if ($calendarId === null) { + return false; + } + + $cacheKey = $calendarId . ':' . $principalUri; + if (isset($this->suppressionCache[$cacheKey])) { + return $this->suppressionCache[$cacheKey]; + } + + $result = $this->mapper->isSuppressed((int)$calendarId, $principalUri); + $this->suppressionCache[$cacheKey] = $result; + return $result; + } + + /** + * Extract calendarInfo from a CalendarObject node + * + * Tries direct method access first, then falls back to looking up + * the parent Calendar node from the server tree. + * + * @param ICalendarObject $node The calendar object node + * @return array|null The calendarInfo array, or null if unavailable + */ + private function getCalendarInfo(ICalendarObject $node): ?array { + // Nextcloud's CalendarObject extends Sabre's CalendarObject which + // stores calendarInfo as a protected property. Try reflection to + // access it if no public method is available. + if (method_exists($node, 'getCalendarInfo')) { + return $node->getCalendarInfo(); + } + + // Fallback: use reflection to access the protected calendarInfo property + try { + $reflection = new \ReflectionClass($node); + $property = $reflection->getProperty('calendarInfo'); + return $property->getValue($node); + } catch (\ReflectionException) { + // Reflection failed, try parent node lookup + } + + // Last resort: look up the parent Calendar node from the server tree + try { + $path = $this->server->getRequestUri(); + $parentPath = dirname($path); + $parent = $this->server->tree->getNodeForPath($parentPath); + if (method_exists($parent, 'getCalendarInfo')) { + return $parent->getCalendarInfo(); + } + } catch (\Exception $e) { + $this->logger->debug('Could not determine calendar info for alarm stripping', [ + 'exception' => $e, + ]); + } + + return null; + } + + /** + * Strip all VALARM components from ICS data + * + * Follows the same pattern as CalendarObject::removeVAlarms() in the + * Nextcloud server's apps/dav/lib/CalDAV/CalendarObject.php. + * + * @param string $calendarData Raw ICS data + * @return string|null Modified ICS data with VALARMs removed, or null on error + */ + private function stripVAlarms(string $calendarData): ?string { + try { + $vObject = Reader::read($calendarData); + $subcomponents = $vObject->getComponents(); + + foreach ($subcomponents as $subcomponent) { + unset($subcomponent->VALARM); + } + + $result = $vObject->serialize(); + $vObject->destroy(); + return $result; + } catch (\Exception $e) { + $this->logger->warning('Failed to strip VALARM from calendar data', [ + 'exception' => $e, + ]); + return null; + } + } +} diff --git a/lib/Listener/SabrePluginAddListener.php b/lib/Listener/SabrePluginAddListener.php new file mode 100644 index 0000000000..559eea7768 --- /dev/null +++ b/lib/Listener/SabrePluginAddListener.php @@ -0,0 +1,42 @@ + + */ +class SabrePluginAddListener implements IEventListener { + + public function __construct( + private readonly StripAlarmsPlugin $plugin, + ) { + } + + /** + * Handle the SabrePluginAddEvent by registering the alarm stripping plugin + * + * @param Event $event The event to handle + */ + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof SabrePluginAddEvent)) { + return; + } + + $event->getServer()->addPlugin($this->plugin); + } +} From e980ac9dc09340df2a0ac3e5062b75edfea01d4b Mon Sep 17 00:00:00 2001 From: Peter Carlson Date: Mon, 23 Feb 2026 20:22:19 -0800 Subject: [PATCH 3/4] feat: add API endpoints for alarm suppression settings Add ShareAlarmController with GET and POST endpoints at /v1/share-alarm for reading and toggling per-share alarm suppression. Resolves calendar DAV URLs to internal IDs via CalDavBackend and verifies calendar ownership. Ref: https://github.com/nextcloud/calendar/issues/7498 --- appinfo/routes.php | 3 + lib/Controller/ShareAlarmController.php | 145 ++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 lib/Controller/ShareAlarmController.php diff --git a/appinfo/routes.php b/appinfo/routes.php index ad4d168801..4f98ab177c 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -43,6 +43,9 @@ ['name' => 'contact#getContactGroupMembers', 'url' => '/v1/autocompletion/groupmembers', 'verb' => 'POST'], // Settings ['name' => 'settings#setConfig', 'url' => '/v1/config/{key}', 'verb' => 'POST'], + // Share alarm suppression + ['name' => 'shareAlarm#get', 'url' => '/v1/share-alarm', 'verb' => 'GET'], + ['name' => 'shareAlarm#toggle', 'url' => '/v1/share-alarm', 'verb' => 'POST'], // Tools ['name' => 'email#sendEmailPublicLink', 'url' => '/v1/public/sendmail', 'verb' => 'POST'], ], diff --git a/lib/Controller/ShareAlarmController.php b/lib/Controller/ShareAlarmController.php new file mode 100644 index 0000000000..4fcc65d00e --- /dev/null +++ b/lib/Controller/ShareAlarmController.php @@ -0,0 +1,145 @@ +userId === null) { + return JsonResponse::fail(null, Http::STATUS_UNAUTHORIZED); + } + + $calendarId = $this->resolveCalendarId($calendarUrl); + if ($calendarId === null) { + return JsonResponse::fail('Calendar not found', Http::STATUS_NOT_FOUND); + } + + $settings = $this->mapper->findAllByCalendarId($calendarId); + $result = []; + foreach ($settings as $setting) { + $result[$setting->getPrincipalUri()] = $setting->getSuppressAlarms(); + } + + return JsonResponse::success($result); + } + + /** + * Toggle alarm suppression for a specific share + * + * @NoAdminRequired + * + * @param string $calendarUrl The owner's calendar DAV URL + * @param string $principalUri The sharee's principal URI + * @param bool $suppressAlarms Whether to suppress alarms + * @return JsonResponse + */ + public function toggle(string $calendarUrl, string $principalUri, bool $suppressAlarms): JsonResponse { + if ($this->userId === null) { + return JsonResponse::fail(null, Http::STATUS_UNAUTHORIZED); + } + + $calendarId = $this->resolveCalendarId($calendarUrl); + if ($calendarId === null) { + return JsonResponse::fail('Calendar not found', Http::STATUS_NOT_FOUND); + } + + try { + $setting = $this->mapper->findByCalendarAndPrincipal($calendarId, $principalUri); + $setting->setSuppressAlarms($suppressAlarms); + $this->mapper->update($setting); + } catch (DoesNotExistException) { + $setting = new ShareAlarmSetting(); + $setting->setCalendarId($calendarId); + $setting->setPrincipalUri($principalUri); + $setting->setSuppressAlarms($suppressAlarms); + $this->mapper->insert($setting); + } catch (\Exception $e) { + $this->logger->error('Failed to toggle alarm suppression', [ + 'exception' => $e, + 'calendarUrl' => $calendarUrl, + 'principalUri' => $principalUri, + ]); + return JsonResponse::fail(null, Http::STATUS_INTERNAL_SERVER_ERROR); + } + + return JsonResponse::success(['suppressAlarms' => $suppressAlarms]); + } + + /** + * Resolve a calendar DAV URL to its internal integer ID + * + * Parses the URL to extract the owner and calendar URI, then looks up + * the calendar in the CalDAV backend. Also verifies the current user + * owns the calendar. + * + * @param string $calendarUrl The calendar DAV URL (e.g. /remote.php/dav/calendars/owner/calname/) + * @return int|null The internal calendar ID, or null if not found or not owned + */ + private function resolveCalendarId(string $calendarUrl): ?int { + // Extract owner and calendar URI from the URL + // Expected format: .../calendars/{owner}/{calendarUri}/... + if (!preg_match('#/calendars/([^/]+)/([^/]+)#', $calendarUrl, $matches)) { + $this->logger->warning('Could not parse calendar URL', ['calendarUrl' => $calendarUrl]); + return null; + } + + $ownerName = $matches[1]; + $calendarUri = $matches[2]; + + // Verify the current user is the calendar owner + if ($ownerName !== $this->userId) { + $this->logger->warning('User attempted to modify alarm settings for a calendar they do not own', [ + 'userId' => $this->userId, + 'ownerName' => $ownerName, + ]); + return null; + } + + $principalUri = 'principals/users/' . $ownerName; + $calendars = $this->calDavBackend->getCalendarsForUser($principalUri); + + foreach ($calendars as $calendar) { + if ($calendar['uri'] === $calendarUri) { + return (int)$calendar['id']; + } + } + + return null; + } +} From 0590c0378d3bf7874437c2fe3556a603b81e5614 Mon Sep 17 00:00:00 2001 From: Peter Carlson Date: Mon, 23 Feb 2026 20:22:40 -0800 Subject: [PATCH 4/4] feat: add UI for per-share alarm suppression toggle Add "suppress alarms" checkbox to ShareItem in the EditCalendarModal. The owner can toggle alarm suppression per sharee. Settings are loaded when the modal opens and persisted via the share-alarm API. Also fixes a pre-existing Vue 3 migration bug where the isWriteable watcher fired on mount and toggled permissions unintentionally. Both checkboxes now use @update:modelValue instead of @update:checked. Ref: https://github.com/nextcloud/calendar/issues/7498 --- .../AppNavigation/EditCalendarModal.vue | 7 ++- .../EditCalendarModal/ShareItem.vue | 60 ++++++++++++++++--- src/models/calendarShare.js | 2 + src/services/shareAlarmService.js | 43 +++++++++++++ src/store/calendars.js | 39 ++++++++++++ 5 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 src/services/shareAlarmService.js diff --git a/src/components/AppNavigation/EditCalendarModal.vue b/src/components/AppNavigation/EditCalendarModal.vue index 30692897ea..954550b49b 100644 --- a/src/components/AppNavigation/EditCalendarModal.vue +++ b/src/components/AppNavigation/EditCalendarModal.vue @@ -6,7 +6,7 @@