Skip to content

Feat/valarm suppression shared calendars#8006

Open
howudodat wants to merge 4 commits intonextcloud:mainfrom
howudodat:feat/valarm-suppression-shared-calendars
Open

Feat/valarm suppression shared calendars#8006
howudodat wants to merge 4 commits intonextcloud:mainfrom
howudodat:feat/valarm-suppression-shared-calendars

Conversation

@howudodat
Copy link

Owner-Controlled VALARM Suppression for Shared Calendars

Problem

When a Nextcloud calendar is shared with read-write access, VALARM (alarm/reminder) components from the owner's events are transmitted to the sharee via CalDAV. Android calendar apps synced via DAVx5 have no per-calendar notification suppression, causing unwanted alerts on the sharee's device.

Existing behavior: Nextcloud server's CalendarObject::get() (in apps/dav) already strips VALARM for read-only shared calendars. For read-write shares, VALARM is preserved.

Reference: Issue #7498

Solution

Register a SabreDAV plugin from the calendar app via the SabrePluginAddEvent mechanism (NC 28+; this app targets NC 32+). The plugin intercepts CalDAV responses and strips VALARM when the owner has enabled suppression for that share. A new database table stores the per-share preference, and the sharing UI exposes the toggle.

The owner controls the setting: in the calendar edit/share modal, each sharee row has a "suppress alarms" checkbox alongside the existing "can edit" checkbox.


Plan

Architecture

┌─────────────────────────────────────────────────────────────────┐
│  Frontend (Vue/JS)                                              │
│                                                                 │
│  EditCalendarModal.vue                                          │
│    └─ ShareItem.vue  ←── "suppress alarms" checkbox per sharee  │
│         └─ calendars.js store                                   │
│              └─ shareAlarmService.js  ←── axios API calls        │
└─────────────────────┬───────────────────────────────────────────┘
                      │  POST/GET /v1/share-alarm
                      ▼
┌─────────────────────────────────────────────────────────────────┐
│  Backend (PHP)                                                  │
│                                                                 │
│  ShareAlarmController.php  ←── resolves calendar URL to ID,     │
│    │                           verifies ownership               │
│    └─ ShareAlarmSettingMapper.php  ←── reads/writes DB          │
│         └─ calendar_share_alarms table                          │
│                                                                 │
│  StripAlarmsPlugin.php  ←── SabreDAV plugin (registered via     │
│    │                        SabrePluginAddEvent)                 │
│    ├─ propFind handler (priority 600) for REPORT responses      │
│    ├─ afterMethod:GET handler for direct GET requests            │
│    └─ ShareAlarmSettingMapper.isSuppressed() with in-mem cache  │
└─────────────────────────────────────────────────────────────────┘

Data Flow

  1. Owner opens EditCalendarModal, sees "suppress alarms" checkbox per sharee
  2. Toggling calls POST /v1/share-alarm → upserts record in calendar_share_alarms
  3. Sharee's CalDAV client (DAVx5) fetches events via REPORT or GET
  4. StripAlarmsPlugin intercepts, checks DB (cached), strips VALARM from ICS data
  5. Sharee receives clean ICS without alarm components → no unwanted notifications

Files Created

File Purpose
lib/Migration/Version5050Date20250701000005.php DB migration: calendar_share_alarms table with calendar_id, principal_uri, suppress_alarms
lib/Db/ShareAlarmSetting.php Entity class for alarm suppression setting
lib/Db/ShareAlarmSettingMapper.php QBMapper with isSuppressed(), findAllByCalendarId(), and cleanup methods
lib/Dav/StripAlarmsPlugin.php SabreDAV plugin: propFind (priority 600) + afterMethod:GET hooks, in-memory cache, VALARM stripping via Sabre\VObject\Reader
lib/Listener/SabrePluginAddListener.php Registers StripAlarmsPlugin via SabrePluginAddEvent
lib/Controller/ShareAlarmController.php API controller: GET /v1/share-alarm and POST /v1/share-alarm, ownership verification, calendar URL→ID resolution via CalDavBackend
src/services/shareAlarmService.js Frontend API service using @nextcloud/axios

Files Modified

File Change
lib/AppInfo/Application.php Registered SabrePluginAddListener for SabrePluginAddEvent
appinfo/routes.php Added GET and POST /v1/share-alarm routes
src/models/calendarShare.js Added suppressAlarms: false to default share object
src/store/calendars.js Added loadShareAlarmSettings and toggleShareAlarmSuppression actions
src/components/AppNavigation/EditCalendarModal/ShareItem.vue Added "suppress alarms" NcCheckboxRadioSwitch, watcher, and updateAlarmSuppression() method
src/components/AppNavigation/EditCalendarModal.vue Loads alarm settings when modal opens for owned calendars with shares

Database Schema

CREATE TABLE calendar_share_alarms (
    id          BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    calendar_id BIGINT UNSIGNED NOT NULL,   -- internal calendar ID
    principal_uri VARCHAR(255) NOT NULL,     -- e.g. 'principals/users/alice'
    suppress_alarms BOOLEAN NOT NULL DEFAULT FALSE,
    UNIQUE INDEX cal_share_alarm_unique (calendar_id, principal_uri)
);

API Endpoints

GET /apps/calendar/v1/share-alarm?calendarUrl=...

Returns suppression state for all shares of a calendar.

{
    "status": "success",
    "data": {
        "principals/users/alice": true,
        "principals/users/bob": false
    }
}

POST /apps/calendar/v1/share-alarm

Toggles suppression for one share.

{
    "calendarUrl": "/remote.php/dav/calendars/owner/calname/",
    "principalUri": "principals/users/alice",
    "suppressAlarms": true
}

SabreDAV Plugin Details

StripAlarmsPlugin hooks into two interception points:

  1. propFind (priority 600) — For REPORT requests (calendar-multiget, calendar-query). Runs after the CalDAV plugin (priority 150-550) has populated {urn:ietf:params:xml:ns:caldav}calendar-data. Calls propFind->get() to read the ICS, strips VALARM, calls propFind->set() to replace.

  2. afterMethod:GET — For direct GET on .ics files. Reads response->getBodyAsString(), strips VALARM, calls response->setBody().

VALARM stripping follows the same pattern as the server's CalendarObject::removeVAlarms():

$vObject = Reader::read($calendarData);
foreach ($vObject->getComponents() as $subcomponent) {
    unset($subcomponent->VALARM);
}
return $vObject->serialize();

Performance: An in-memory $suppressionCache array (keyed by calendarId:principalUri) avoids repeated DB lookups for objects in the same calendar during a single REPORT response.

CalendarInfo access: Uses reflection as fallback to access the protected calendarInfo property on Sabre's CalendarObject, with a last-resort parent node lookup via the server tree.


Known Caveats

  1. calendarInfo access: The plugin uses reflection to access the protected calendarInfo property on CalendarObject. This should be tested against the actual Nextcloud server version. If getCalendarInfo() becomes public in a future NC version, the reflection fallback becomes unnecessary.

  2. Calendar ID resolution: The controller resolves the calendar URL to an internal ID via CalDavBackend::getCalendarsForUser(). This adds a dependency on the DAV app's backend class (OCA\DAV\CalDAV\CalDavBackend).

  3. Principal URI format mismatch: Frontend uses principal:principals/users/alice (cdav-library format), backend uses principals/users/alice. The store actions strip the principal: prefix before API calls.

  4. propFind->set() after lazy eval: The PropFind::set() behavior after get() triggers lazy evaluation needs verification against the SabreDAV version bundled with NC 32+.


Verification

Unit Tests

  • ShareAlarmSettingMapper: CRUD operations, isSuppressed() returns false for missing records
  • StripAlarmsPlugin: Mock CalendarObject node with calendarInfo, verify VALARM stripping when enabled and no-op when disabled

Manual End-to-End

  1. Owner creates calendar with events containing VALARM
  2. Owner shares calendar with read-write access to another user
  3. Owner opens EditCalendarModal, enables "suppress alarms" for the sharee
  4. Sharee syncs via DAVx5 or fetches via:
    curl -u sharee:pass https://cloud.example.com/remote.php/dav/calendars/sharee/shared-cal/event.ics
  5. Verify the ICS response contains no VALARM components
  6. Toggle off, re-sync, verify VALARM is present again

Peter Carlson added 4 commits February 23, 2026 20:21
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: nextcloud#7498
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: nextcloud#7498
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: nextcloud#7498
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: nextcloud#7498
@tcitworld
Copy link
Member

TL;DR: doing things properly is much harder

This is not a bad idea, but:

  • the actual issue should be fixed directly more properly. VALARMS should be always removed even with write access, as per the CalendarServer original specs (which Nextcloud calendar sharing is kinda based upon through Sabre)

    Alarms set by the sharer SHOULD NOT be propagated to sharees by default. Clients SHOULD NOT automatically enable triggering of alarms on shared calendars that have just been accepted without confirmation by the user.

  • this means we need users with write-access shared calendars to have their own copy of the calendar data (which requires some kind of huge refactoring) so that they can set their own alarms only for them (see 5.5.4. Per-user Calendar Data in the same document)
  • it should be handled in the apps/dav app in the server repository, not in this app

@SebastianKrupinski
Copy link
Contributor

Hi,

I would agree with @tcitworld. This should be fixed in the dav app which is the calendaring backend, but this will require some thought on implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants