Skip to content

Commit 72702d7

Browse files
ndo84bwlufer22
andcommitted
fix(dav): default reminder on received invitations + preserve recipient VALARMs
Signed-off-by: Nico Donath <ndo84bw@gmx.de> Co-Authored-By: Lucas Ferreira da Silva <lufer22@users.noreply.github.com> Assisted-by: ClaudeCode:claude-opus-4-7
1 parent 426cbeb commit 72702d7

6 files changed

Lines changed: 530 additions & 12 deletions

File tree

apps/dav/appinfo/v1/caldav.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@
118118

119119
$server->addPlugin(new \Sabre\DAV\Sync\Plugin());
120120
$server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin());
121-
$server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class)));
121+
$server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class), $calDavBackend, Server::get(\OCP\Config\IUserConfig::class), Server::get(\OCP\IAppConfig::class)));
122122

123123
if ($sendInvitations) {
124124
$server->addPlugin(Server::get(IMipPlugin::class));

apps/dav/lib/CalDAV/EmbeddedCalDavServer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ public function __construct(bool $public = true) {
8686
// calendar plugins
8787
$this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin());
8888
$this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin());
89-
$this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class)));
89+
$this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class), Server::get(\OCA\DAV\CalDAV\CalDavBackend::class), Server::get(\OCP\Config\IUserConfig::class), Server::get(\OCP\IAppConfig::class)));
9090
$this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin());
9191
$this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin());
9292
//$this->server->addPlugin(new \OCA\DAV\DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest()));

apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public function __construct(bool $public = true) {
8383
// calendar plugins
8484
$this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin());
8585
$this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin());
86-
$this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class)));
86+
$this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class), Server::get(\OCA\DAV\CalDAV\CalDavBackend::class), Server::get(\OCP\Config\IUserConfig::class), Server::get(\OCP\IAppConfig::class)));
8787
$this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin());
8888
$this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin());
8989
//$this->server->addPlugin(new \OCA\DAV\DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest()));

apps/dav/lib/CalDAV/Schedule/Plugin.php

Lines changed: 235 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use OCA\DAV\CalDAV\DefaultCalendarValidator;
1616
use OCA\DAV\CalDAV\Federation\FederatedCalendar;
1717
use OCA\DAV\CalDAV\TipBroker;
18+
use OCP\Config\IUserConfig;
19+
use OCP\IAppConfig;
1820
use OCP\IConfig;
1921
use Psr\Log\LoggerInterface;
2022
use Sabre\CalDAV\ICalendar;
@@ -53,13 +55,13 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
5355
public const CALENDAR_USER_TYPE = '{' . self::NS_CALDAV . '}calendar-user-type';
5456
public const SCHEDULE_DEFAULT_CALENDAR_URL = '{' . Plugin::NS_CALDAV . '}schedule-default-calendar-URL';
5557

56-
/**
57-
* @param IConfig $config
58-
*/
5958
public function __construct(
6059
private IConfig $config,
6160
private LoggerInterface $logger,
6261
private DefaultCalendarValidator $defaultCalendarValidator,
62+
private CalDavBackend $caldavBackend,
63+
private IUserConfig $userConfig,
64+
private IAppConfig $appConfig,
6365
) {
6466
}
6567

@@ -257,12 +259,16 @@ public function scheduleLocalDelivery(ITip\Message $iTipMessage):void {
257259
/** @var VEvent|null $vevent */
258260
$vevent = $iTipMessage->message->VEVENT ?? null;
259261

260-
// Strip VALARMs from incoming VEVENT
261-
if ($vevent && isset($vevent->VALARM)) {
262-
$vevent->remove('VALARM');
262+
// A remote organizer must not drive alerts on the recipient's devices.
263+
foreach ($iTipMessage->message->VEVENT ?? [] as $component) {
264+
$component->remove('VALARM');
263265
}
264266

265-
parent::scheduleLocalDelivery($iTipMessage);
267+
if ($vevent && strcasecmp($iTipMessage->method, 'REQUEST') === 0) {
268+
$this->applyRecipientReminderPolicy($iTipMessage);
269+
}
270+
271+
$this->delegateToSabre($iTipMessage);
266272
// We only care when the message was successfully delivered locally
267273
// Log all possible codes returned from the parent method that mean something went wrong
268274
// 3.7, 3.8, 5.0, 5.2
@@ -777,4 +783,226 @@ private function handleSameOrganizerException(
777783
}
778784
}
779785
}
786+
787+
/**
788+
* Thin testable seam around the parent's `scheduleLocalDelivery`. Tests subclass and
789+
* override this to skip the Sabre delivery while still exercising our hook code.
790+
*
791+
* @param ITip\Message $iTipMessage
792+
*/
793+
protected function delegateToSabre(ITip\Message $iTipMessage): void {
794+
parent::scheduleLocalDelivery($iTipMessage);
795+
}
796+
797+
/**
798+
* For an incoming REQUEST, preserve the recipient's existing VALARMs or inject their default reminder.
799+
*
800+
* @param ITip\Message $iTipMessage
801+
*/
802+
private function applyRecipientReminderPolicy(ITip\Message $iTipMessage): void {
803+
try {
804+
/** @var \Sabre\DAVACL\Plugin|null $aclPlugin */
805+
$aclPlugin = $this->server->getPlugin('acl');
806+
if (!$aclPlugin) {
807+
return;
808+
}
809+
$principalUri = $aclPlugin->getPrincipalByUri($iTipMessage->recipient);
810+
if (!$principalUri) {
811+
return;
812+
}
813+
814+
$calendarUserType = $this->getCalendarUserTypeForPrincipal($principalUri);
815+
if ($calendarUserType !== null
816+
&& (strcasecmp($calendarUserType, 'ROOM') === 0 || strcasecmp($calendarUserType, 'RESOURCE') === 0)) {
817+
return;
818+
}
819+
820+
// Skip when the recipient is the organizer (self-invite).
821+
/** @var VEvent|null $incomingVEvent */
822+
$incomingVEvent = $iTipMessage->message->VEVENT ?? null;
823+
if ($incomingVEvent && isset($incomingVEvent->ORGANIZER)) {
824+
$organizerAddresses = $this->getAddressesForPrincipal($principalUri);
825+
/** @var Property&Property\ICalendar\CalAddress $organizer */
826+
$organizer = $incomingVEvent->ORGANIZER;
827+
if (in_array($organizer->getNormalizedValue(), $organizerAddresses, true)) {
828+
return;
829+
}
830+
}
831+
832+
$userId = $this->principalUriToUserId($principalUri);
833+
if ($userId === null) {
834+
return;
835+
}
836+
837+
$existingVAlarms = $this->collectExistingValarmsForRecipient($principalUri, $iTipMessage->uid);
838+
if ($existingVAlarms !== null) {
839+
// An empty set means the recipient deleted them on purpose; never re-inject a default.
840+
foreach ($iTipMessage->message->VEVENT as $vevent) {
841+
$key = $this->veventRecurrenceKey($vevent);
842+
foreach ($existingVAlarms[$key] ?? [] as $valarm) {
843+
$vevent->add(clone $valarm);
844+
}
845+
}
846+
return;
847+
}
848+
849+
$toggle = $this->userConfig->getValueString($userId, 'calendar', 'applyDefaultReminderToInvitations', 'yes');
850+
if ($toggle !== 'yes') {
851+
return;
852+
}
853+
$offset = $this->resolveDefaultReminderOffset($userId, $incomingVEvent);
854+
if ($offset !== 'none') {
855+
$this->injectDefaultReminder($iTipMessage, $offset);
856+
}
857+
} catch (\Throwable $e) {
858+
// Best-effort: never let reminder handling break delivery.
859+
$this->logger->debug('Failed to apply recipient reminder policy on invitation', ['exception' => $e]);
860+
}
861+
}
862+
863+
/**
864+
* Collect every VALARM from the recipient's existing local copy of the given UID, keyed by
865+
* RECURRENCE-ID (empty string for the master VEVENT). Returns null when the recipient has no
866+
* local copy of this UID yet, which is the signal for the first-receipt path.
867+
*
868+
* @param string $principalUri
869+
* @param string $uid
870+
* @return array<string, list<Component>>|null
871+
*/
872+
private function collectExistingValarmsForRecipient(string $principalUri, string $uid): ?array {
873+
$objectPath = $this->caldavBackend->getCalendarObjectByUID($principalUri, $uid);
874+
if ($objectPath === null) {
875+
return null;
876+
}
877+
[$calendarUri, $objectUri] = explode('/', $objectPath, 2);
878+
$calendar = $this->caldavBackend->getCalendarByUri($principalUri, $calendarUri);
879+
if (!$calendar) {
880+
return [];
881+
}
882+
$objectData = $this->caldavBackend->getCalendarObject($calendar['id'], $objectUri);
883+
if (empty($objectData['calendardata'])) {
884+
return [];
885+
}
886+
$vCal = Reader::read($objectData['calendardata']);
887+
$result = [];
888+
foreach ($vCal->VEVENT ?? [] as $vevent) {
889+
if (!isset($vevent->VALARM)) {
890+
continue;
891+
}
892+
$key = $this->veventRecurrenceKey($vevent);
893+
$result[$key] = [];
894+
foreach ($vevent->VALARM as $valarm) {
895+
$result[$key][] = $valarm;
896+
}
897+
}
898+
return $result;
899+
}
900+
901+
/**
902+
* Stable key per VEVENT component: '' for the master, otherwise the RECURRENCE-ID as an
903+
* absolute timestamp so TZID-local and UTC spellings of the same instance still match.
904+
*
905+
* @param VEvent $vevent
906+
* @return string
907+
*/
908+
private function veventRecurrenceKey(VEvent $vevent): string {
909+
/** @var \Sabre\VObject\Property\ICalendar\DateTime|null $rid */
910+
$rid = $vevent->{'RECURRENCE-ID'} ?? null;
911+
if ($rid === null) {
912+
return '';
913+
}
914+
try {
915+
return (string)$rid->getDateTime()->getTimestamp();
916+
} catch (\Throwable $e) {
917+
return (string)$rid;
918+
}
919+
}
920+
921+
/**
922+
* Resolves the per-user default reminder offset for a first-receipt invitation,
923+
* matching the calendar app's frontend fallback chain.
924+
*
925+
* @param string $userId
926+
* @param VEvent|null $vevent
927+
* @return string 'none' to skip, otherwise a signed integer-as-string (seconds before start).
928+
*/
929+
private function resolveDefaultReminderOffset(string $userId, ?VEvent $vevent): string {
930+
$isAllDay = $vevent !== null && isset($vevent->DTSTART) && !$vevent->DTSTART->hasTime();
931+
$typedKey = $isAllDay ? 'defaultReminderFullDay' : 'defaultReminderPartDay';
932+
$typed = $this->userConfig->getValueString($userId, 'calendar', $typedKey, 'none');
933+
if (filter_var($typed, FILTER_VALIDATE_INT) !== false) {
934+
return $typed;
935+
}
936+
$legacy = $this->userConfig->getValueString($userId, 'calendar', 'defaultReminder', 'none');
937+
if (filter_var($legacy, FILTER_VALIDATE_INT) !== false) {
938+
return $legacy;
939+
}
940+
$admin = $this->appConfig->getValueString('calendar', 'defaultReminder', 'none');
941+
if (filter_var($admin, FILTER_VALIDATE_INT) !== false) {
942+
return $admin;
943+
}
944+
return 'none';
945+
}
946+
947+
/**
948+
* Adds a DISPLAY VALARM to the master VEVENT of the iTip message.
949+
*
950+
* @param ITip\Message $iTipMessage
951+
* @param string $offset Signed integer (seconds, negative = before start), as stored by the calendar app.
952+
*/
953+
private function injectDefaultReminder(ITip\Message $iTipMessage, string $offset): void {
954+
/** @var VEvent|null $vevent */
955+
$vevent = $iTipMessage->message->VEVENT ?? null;
956+
if ($vevent === null) {
957+
return;
958+
}
959+
$seconds = (int)$offset;
960+
$duration = ($seconds < 0 ? '-' : '') . $this->secondsToIso8601Duration(abs($seconds));
961+
$alarm = $iTipMessage->message->createComponent('VALARM');
962+
$alarm->add($iTipMessage->message->createProperty('ACTION', 'DISPLAY'));
963+
$alarm->add($iTipMessage->message->createProperty('DESCRIPTION', 'Reminder'));
964+
$alarm->add($iTipMessage->message->createProperty('TRIGGER', $duration, ['RELATED' => 'START']));
965+
$vevent->add($alarm);
966+
}
967+
968+
/**
969+
* Converts seconds to an ISO 8601 duration string. Helper derived from
970+
* lufer22's nextcloud/server#48226.
971+
*
972+
* @param int $secs Non-negative.
973+
* @return string
974+
*/
975+
private function secondsToIso8601Duration(int $secs): string {
976+
$day = 24 * 60 * 60;
977+
$hour = 60 * 60;
978+
$minute = 60;
979+
if ($secs === 0) {
980+
return 'PT0S';
981+
}
982+
if ($secs % $day === 0) {
983+
return 'P' . (int)($secs / $day) . 'D';
984+
}
985+
if ($secs % $hour === 0) {
986+
return 'PT' . (int)($secs / $hour) . 'H';
987+
}
988+
if ($secs % $minute === 0) {
989+
return 'PT' . (int)($secs / $minute) . 'M';
990+
}
991+
return 'PT' . $secs . 'S';
992+
}
993+
994+
/**
995+
* Maps a Sabre principal URI to a first-class Nextcloud user id, or null.
996+
*
997+
* @param string $principalUri e.g. 'principals/users/alice'.
998+
* @return string|null
999+
*/
1000+
private function principalUriToUserId(string $principalUri): ?string {
1001+
$prefix = 'principals/users/';
1002+
if (!str_starts_with($principalUri, $prefix)) {
1003+
return null;
1004+
}
1005+
$userId = substr($principalUri, strlen($prefix));
1006+
return $userId === '' ? null : $userId;
1007+
}
7801008
}

apps/dav/lib/Server.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ public function __construct(
205205
$this->server->addPlugin(new DAV\Sharing\Plugin($authBackend, \OCP\Server::get(IRequest::class), \OCP\Server::get(IConfig::class), \OCP\Server::get(RateLimiting::class)));
206206
$this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin());
207207
$this->server->addPlugin(new ICSExportPlugin(\OCP\Server::get(IConfig::class), $logger));
208-
$this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OCP\Server::get(IConfig::class), \OCP\Server::get(LoggerInterface::class), \OCP\Server::get(DefaultCalendarValidator::class)));
208+
$this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OCP\Server::get(IConfig::class), \OCP\Server::get(LoggerInterface::class), \OCP\Server::get(DefaultCalendarValidator::class), \OCP\Server::get(\OCA\DAV\CalDAV\CalDavBackend::class), \OCP\Server::get(\OCP\Config\IUserConfig::class), \OCP\Server::get(\OCP\IAppConfig::class)));
209209

210210
$this->server->addPlugin(\OCP\Server::get(\OCA\DAV\CalDAV\Trashbin\Plugin::class));
211211
$this->server->addPlugin(new \OCA\DAV\CalDAV\WebcalCaching\Plugin($this->request));

0 commit comments

Comments
 (0)