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/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/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; + } +} 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/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/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); + } +} 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; + } +} 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 @@