|
15 | 15 | use OCA\DAV\CalDAV\DefaultCalendarValidator; |
16 | 16 | use OCA\DAV\CalDAV\Federation\FederatedCalendar; |
17 | 17 | use OCA\DAV\CalDAV\TipBroker; |
| 18 | +use OCP\Config\IUserConfig; |
| 19 | +use OCP\IAppConfig; |
18 | 20 | use OCP\IConfig; |
19 | 21 | use Psr\Log\LoggerInterface; |
20 | 22 | use Sabre\CalDAV\ICalendar; |
@@ -53,13 +55,13 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { |
53 | 55 | public const CALENDAR_USER_TYPE = '{' . self::NS_CALDAV . '}calendar-user-type'; |
54 | 56 | public const SCHEDULE_DEFAULT_CALENDAR_URL = '{' . Plugin::NS_CALDAV . '}schedule-default-calendar-URL'; |
55 | 57 |
|
56 | | - /** |
57 | | - * @param IConfig $config |
58 | | - */ |
59 | 58 | public function __construct( |
60 | 59 | private IConfig $config, |
61 | 60 | private LoggerInterface $logger, |
62 | 61 | private DefaultCalendarValidator $defaultCalendarValidator, |
| 62 | + private CalDavBackend $caldavBackend, |
| 63 | + private IUserConfig $userConfig, |
| 64 | + private IAppConfig $appConfig, |
63 | 65 | ) { |
64 | 66 | } |
65 | 67 |
|
@@ -257,12 +259,16 @@ public function scheduleLocalDelivery(ITip\Message $iTipMessage):void { |
257 | 259 | /** @var VEvent|null $vevent */ |
258 | 260 | $vevent = $iTipMessage->message->VEVENT ?? null; |
259 | 261 |
|
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'); |
263 | 265 | } |
264 | 266 |
|
265 | | - parent::scheduleLocalDelivery($iTipMessage); |
| 267 | + if ($vevent && strcasecmp($iTipMessage->method, 'REQUEST') === 0) { |
| 268 | + $this->applyRecipientReminderPolicy($iTipMessage); |
| 269 | + } |
| 270 | + |
| 271 | + $this->delegateToSabre($iTipMessage); |
266 | 272 | // We only care when the message was successfully delivered locally |
267 | 273 | // Log all possible codes returned from the parent method that mean something went wrong |
268 | 274 | // 3.7, 3.8, 5.0, 5.2 |
@@ -777,4 +783,226 @@ private function handleSameOrganizerException( |
777 | 783 | } |
778 | 784 | } |
779 | 785 | } |
| 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 | + } |
780 | 1008 | } |
0 commit comments