aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dav/lib/CalDAV
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dav/lib/CalDAV')
-rw-r--r--apps/dav/lib/CalDAV/Activity/Backend.php272
-rw-r--r--apps/dav/lib/CalDAV/Activity/Filter/Calendar.php43
-rw-r--r--apps/dav/lib/CalDAV/Activity/Filter/Todo.php43
-rw-r--r--apps/dav/lib/CalDAV/Activity/Provider/Base.php125
-rw-r--r--apps/dav/lib/CalDAV/Activity/Provider/Calendar.php149
-rw-r--r--apps/dav/lib/CalDAV/Activity/Provider/Event.php192
-rw-r--r--apps/dav/lib/CalDAV/Activity/Provider/Todo.php105
-rw-r--r--apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php30
-rw-r--r--apps/dav/lib/CalDAV/Activity/Setting/Calendar.php45
-rw-r--r--apps/dav/lib/CalDAV/Activity/Setting/Event.php45
-rw-r--r--apps/dav/lib/CalDAV/Activity/Setting/Todo.php46
-rw-r--r--apps/dav/lib/CalDAV/AppCalendar/AppCalendar.php194
-rw-r--r--apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php58
-rw-r--r--apps/dav/lib/CalDAV/AppCalendar/CalendarObject.php134
-rw-r--r--apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php21
-rw-r--r--apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php20
-rw-r--r--apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php67
-rw-r--r--apps/dav/lib/CalDAV/BirthdayService.php404
-rw-r--r--apps/dav/lib/CalDAV/CachedSubscription.php196
-rw-r--r--apps/dav/lib/CalDAV/CachedSubscriptionImpl.php102
-rw-r--r--apps/dav/lib/CalDAV/CachedSubscriptionObject.php49
-rw-r--r--apps/dav/lib/CalDAV/CachedSubscriptionProvider.php40
-rw-r--r--apps/dav/lib/CalDAV/CalDavBackend.php3272
-rw-r--r--apps/dav/lib/CalDAV/Calendar.php262
-rw-r--r--apps/dav/lib/CalDAV/CalendarHome.php142
-rw-r--r--apps/dav/lib/CalDAV/CalendarImpl.php301
-rw-r--r--apps/dav/lib/CalDAV/CalendarManager.php47
-rw-r--r--apps/dav/lib/CalDAV/CalendarObject.php89
-rw-r--r--apps/dav/lib/CalDAV/CalendarProvider.php93
-rw-r--r--apps/dav/lib/CalDAV/CalendarRoot.php63
-rw-r--r--apps/dav/lib/CalDAV/DefaultCalendarValidator.php41
-rw-r--r--apps/dav/lib/CalDAV/EmbeddedCalDavServer.php122
-rw-r--r--apps/dav/lib/CalDAV/EventComparisonService.php100
-rw-r--r--apps/dav/lib/CalDAV/EventReader.php771
-rw-r--r--apps/dav/lib/CalDAV/EventReaderRDate.php35
-rw-r--r--apps/dav/lib/CalDAV/EventReaderRRule.php87
-rw-r--r--apps/dav/lib/CalDAV/Export/ExportService.php107
-rw-r--r--apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php26
-rw-r--r--apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php72
-rw-r--r--apps/dav/lib/CalDAV/IRestorable.php24
-rw-r--r--apps/dav/lib/CalDAV/Integration/ExternalCalendar.php110
-rw-r--r--apps/dav/lib/CalDAV/Integration/ICalendarProvider.php54
-rw-r--r--apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php129
-rw-r--r--apps/dav/lib/CalDAV/Outbox.php116
-rw-r--r--apps/dav/lib/CalDAV/Plugin.php54
-rw-r--r--apps/dav/lib/CalDAV/Principal/Collection.php26
-rw-r--r--apps/dav/lib/CalDAV/Principal/User.php24
-rw-r--r--apps/dav/lib/CalDAV/Proxy/Proxy.php36
-rw-r--r--apps/dav/lib/CalDAV/Proxy/ProxyMapper.php63
-rw-r--r--apps/dav/lib/CalDAV/PublicCalendar.php32
-rw-r--r--apps/dav/lib/CalDAV/PublicCalendarObject.php24
-rw-r--r--apps/dav/lib/CalDAV/PublicCalendarRoot.php55
-rw-r--r--apps/dav/lib/CalDAV/Publishing/PublishPlugin.php183
-rw-r--r--apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php45
-rw-r--r--apps/dav/lib/CalDAV/Reminder/Backend.php197
-rw-r--r--apps/dav/lib/CalDAV/Reminder/INotificationProvider.php34
-rw-r--r--apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php157
-rw-r--r--apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php23
-rw-r--r--apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php442
-rw-r--r--apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php23
-rw-r--r--apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php114
-rw-r--r--apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php69
-rw-r--r--apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php23
-rw-r--r--apps/dav/lib/CalDAV/Reminder/Notifier.php311
-rw-r--r--apps/dav/lib/CalDAV/Reminder/ReminderService.php836
-rw-r--r--apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php494
-rw-r--r--apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php33
-rw-r--r--apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php33
-rw-r--r--apps/dav/lib/CalDAV/RetentionService.php57
-rw-r--r--apps/dav/lib/CalDAV/Schedule/IMipPlugin.php608
-rw-r--r--apps/dav/lib/CalDAV/Schedule/IMipService.php1294
-rw-r--r--apps/dav/lib/CalDAV/Schedule/Plugin.php717
-rw-r--r--apps/dav/lib/CalDAV/Search/SearchPlugin.php40
-rw-r--r--apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php26
-rw-r--r--apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php30
-rw-r--r--apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php30
-rw-r--r--apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php27
-rw-r--r--apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php26
-rw-r--r--apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php28
-rw-r--r--apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php44
-rw-r--r--apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php87
-rw-r--r--apps/dav/lib/CalDAV/Sharing/Backend.php30
-rw-r--r--apps/dav/lib/CalDAV/Sharing/Service.php21
-rw-r--r--apps/dav/lib/CalDAV/Status/StatusService.php186
-rw-r--r--apps/dav/lib/CalDAV/TimeZoneFactory.php213
-rw-r--r--apps/dav/lib/CalDAV/TimezoneService.php93
-rw-r--r--apps/dav/lib/CalDAV/TipBroker.php187
-rw-r--r--apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php106
-rw-r--r--apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php133
-rw-r--r--apps/dav/lib/CalDAV/Trashbin/Plugin.php114
-rw-r--r--apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php65
-rw-r--r--apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php106
-rw-r--r--apps/dav/lib/CalDAV/UpcomingEvent.php69
-rw-r--r--apps/dav/lib/CalDAV/UpcomingEventsService.php86
-rw-r--r--apps/dav/lib/CalDAV/Validation/CalDavValidatePlugin.php40
-rw-r--r--apps/dav/lib/CalDAV/WebcalCaching/Connection.php143
-rw-r--r--apps/dav/lib/CalDAV/WebcalCaching/Plugin.php141
-rw-r--r--apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php264
98 files changed, 13922 insertions, 2863 deletions
diff --git a/apps/dav/lib/CalDAV/Activity/Backend.php b/apps/dav/lib/CalDAV/Activity/Backend.php
index 9f929dc195b..f0c49e6e28c 100644
--- a/apps/dav/lib/CalDAV/Activity/Backend.php
+++ b/apps/dav/lib/CalDAV/Activity/Backend.php
@@ -1,37 +1,21 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Activity;
-
use OCA\DAV\CalDAV\Activity\Provider\Calendar;
use OCA\DAV\CalDAV\Activity\Provider\Event;
+use OCA\DAV\CalDAV\CalDavBackend;
use OCP\Activity\IEvent;
use OCP\Activity\IManager as IActivityManager;
+use OCP\App\IAppManager;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IUser;
+use OCP\IUserManager;
use OCP\IUserSession;
use Sabre\VObject\Reader;
@@ -42,24 +26,13 @@ use Sabre\VObject\Reader;
*/
class Backend {
- /** @var IActivityManager */
- protected $activityManager;
-
- /** @var IGroupManager */
- protected $groupManager;
-
- /** @var IUserSession */
- protected $userSession;
-
- /**
- * @param IActivityManager $activityManager
- * @param IGroupManager $groupManager
- * @param IUserSession $userSession
- */
- public function __construct(IActivityManager $activityManager, IGroupManager $groupManager, IUserSession $userSession) {
- $this->activityManager = $activityManager;
- $this->groupManager = $groupManager;
- $this->userSession = $userSession;
+ public function __construct(
+ protected IActivityManager $activityManager,
+ protected IGroupManager $groupManager,
+ protected IUserSession $userSession,
+ protected IAppManager $appManager,
+ protected IUserManager $userManager,
+ ) {
}
/**
@@ -83,12 +56,32 @@ class Backend {
}
/**
+ * Creates activities when a calendar was moved to trash
+ *
+ * @param array $calendarData
+ * @param array $shares
+ */
+ public function onCalendarMovedToTrash(array $calendarData, array $shares): void {
+ $this->triggerCalendarActivity(Calendar::SUBJECT_MOVE_TO_TRASH, $calendarData, $shares);
+ }
+
+ /**
+ * Creates activities when a calendar was restored
+ *
+ * @param array $calendarData
+ * @param array $shares
+ */
+ public function onCalendarRestored(array $calendarData, array $shares): void {
+ $this->triggerCalendarActivity(Calendar::SUBJECT_RESTORE, $calendarData, $shares);
+ }
+
+ /**
* Creates activities when a calendar was deleted
*
* @param array $calendarData
* @param array $shares
*/
- public function onCalendarDelete(array $calendarData, array $shares) {
+ public function onCalendarDelete(array $calendarData, array $shares): void {
$this->triggerCalendarActivity(Calendar::SUBJECT_DELETE, $calendarData, $shares);
}
@@ -98,7 +91,7 @@ class Backend {
* @param array $calendarData
* @param bool $publishStatus
*/
- public function onCalendarPublication(array $calendarData, $publishStatus) {
+ public function onCalendarPublication(array $calendarData, bool $publishStatus): void {
$this->triggerCalendarActivity($publishStatus ? Calendar::SUBJECT_PUBLISH : Calendar::SUBJECT_UNPUBLISH, $calendarData);
}
@@ -127,7 +120,7 @@ class Backend {
$event = $this->activityManager->generateEvent();
$event->setApp('dav')
- ->setObject('calendar', (int) $calendarData['id'])
+ ->setObject('calendar', (int)$calendarData['id'])
->setType('calendar')
->setAuthor($currentUser);
@@ -144,13 +137,18 @@ class Backend {
}
foreach ($users as $user) {
+ if ($action === Calendar::SUBJECT_DELETE && !$this->userManager->userExists($user)) {
+ // Avoid creating calendar_delete activities for deleted users
+ continue;
+ }
+
$event->setAffectedUser($user)
->setSubject(
$user === $currentUser ? $action . '_self' : $action,
[
'actor' => $currentUser,
'calendar' => [
- 'id' => (int) $calendarData['id'],
+ 'id' => (int)$calendarData['id'],
'uri' => $calendarData['uri'],
'name' => $calendarData['{DAV:}displayname'],
],
@@ -181,7 +179,7 @@ class Backend {
$event = $this->activityManager->generateEvent();
$event->setApp('dav')
- ->setObject('calendar', (int) $calendarData['id'])
+ ->setObject('calendar', (int)$calendarData['id'])
->setType('calendar')
->setAuthor($currentUser);
@@ -206,7 +204,7 @@ class Backend {
$parameters = [
'actor' => $event->getAuthor(),
'calendar' => [
- 'id' => (int) $calendarData['id'],
+ 'id' => (int)$calendarData['id'],
'uri' => $calendarData['uri'],
'name' => $calendarData['{DAV:}displayname'],
],
@@ -215,7 +213,7 @@ class Backend {
if ($owner === $event->getAuthor()) {
$subject = Calendar::SUBJECT_UNSHARE_USER . '_you';
- } else if ($principal[2] === $event->getAuthor()) {
+ } elseif ($principal[2] === $event->getAuthor()) {
$subject = Calendar::SUBJECT_UNSHARE_USER . '_self';
} else {
$event->setAffectedUser($event->getAuthor())
@@ -229,13 +227,13 @@ class Backend {
->setSubject($subject, $parameters);
$this->activityManager->publish($event);
}
- } else if ($principal[1] === 'groups') {
+ } elseif ($principal[1] === 'groups') {
$this->triggerActivityGroup($principal[2], $event, $calendarData, Calendar::SUBJECT_UNSHARE_USER);
$parameters = [
'actor' => $event->getAuthor(),
'calendar' => [
- 'id' => (int) $calendarData['id'],
+ 'id' => (int)$calendarData['id'],
'uri' => $calendarData['uri'],
'name' => $calendarData['{DAV:}displayname'],
],
@@ -277,7 +275,7 @@ class Backend {
$parameters = [
'actor' => $event->getAuthor(),
'calendar' => [
- 'id' => (int) $calendarData['id'],
+ 'id' => (int)$calendarData['id'],
'uri' => $calendarData['uri'],
'name' => $calendarData['{DAV:}displayname'],
],
@@ -298,13 +296,13 @@ class Backend {
->setSubject($subject, $parameters);
$this->activityManager->publish($event);
}
- } else if ($principal[1] === 'groups') {
+ } elseif ($principal[1] === 'groups') {
$this->triggerActivityGroup($principal[2], $event, $calendarData, Calendar::SUBJECT_SHARE_USER);
$parameters = [
'actor' => $event->getAuthor(),
'calendar' => [
- 'id' => (int) $calendarData['id'],
+ 'id' => (int)$calendarData['id'],
'uri' => $calendarData['uri'],
'name' => $calendarData['{DAV:}displayname'],
],
@@ -382,7 +380,7 @@ class Backend {
[
'actor' => $event->getAuthor(),
'calendar' => [
- 'id' => (int) $properties['id'],
+ 'id' => (int)$properties['id'],
'uri' => $properties['uri'],
'name' => $properties['{DAV:}displayname'],
],
@@ -415,53 +413,171 @@ class Backend {
$currentUser = $owner;
}
+ $classification = $objectData['classification'] ?? CalDavBackend::CLASSIFICATION_PUBLIC;
$object = $this->getObjectNameAndType($objectData);
+
+ if (!$object) {
+ return;
+ }
+
$action = $action . '_' . $object['type'];
- if ($object['type'] === 'todo' && strpos($action, Event::SUBJECT_OBJECT_UPDATE) === 0 && $object['status'] === 'COMPLETED') {
+ if ($object['type'] === 'todo' && str_starts_with($action, Event::SUBJECT_OBJECT_UPDATE) && $object['status'] === 'COMPLETED') {
$action .= '_completed';
- } else if ($object['type'] === 'todo' && strpos($action, Event::SUBJECT_OBJECT_UPDATE) === 0 && $object['status'] === 'NEEDS-ACTION') {
+ } elseif ($object['type'] === 'todo' && str_starts_with($action, Event::SUBJECT_OBJECT_UPDATE) && $object['status'] === 'NEEDS-ACTION') {
$action .= '_needs_action';
}
$event = $this->activityManager->generateEvent();
$event->setApp('dav')
- ->setObject('calendar', (int) $calendarData['id'])
+ ->setObject('calendar', (int)$calendarData['id'])
->setType($object['type'] === 'event' ? 'calendar_event' : 'calendar_todo')
->setAuthor($currentUser);
$users = $this->getUsersForShares($shares);
$users[] = $owner;
- foreach ($users as $user) {
+ // Users for share can return the owner itself if the calendar is published
+ foreach (array_unique($users) as $user) {
+ if ($classification === CalDavBackend::CLASSIFICATION_PRIVATE && $user !== $owner) {
+ // Private events are only shown to the owner
+ continue;
+ }
+
+ $params = [
+ 'actor' => $event->getAuthor(),
+ 'calendar' => [
+ 'id' => (int)$calendarData['id'],
+ 'uri' => $calendarData['uri'],
+ 'name' => $calendarData['{DAV:}displayname'],
+ ],
+ 'object' => [
+ 'id' => $object['id'],
+ 'name' => $classification === CalDavBackend::CLASSIFICATION_CONFIDENTIAL && $user !== $owner ? 'Busy' : $object['name'],
+ 'classified' => $classification === CalDavBackend::CLASSIFICATION_CONFIDENTIAL && $user !== $owner,
+ ],
+ ];
+
+ if ($object['type'] === 'event' && !str_contains($action, Event::SUBJECT_OBJECT_DELETE) && $this->appManager->isEnabledForUser('calendar')) {
+ $params['object']['link']['object_uri'] = $objectData['uri'];
+ $params['object']['link']['calendar_uri'] = $calendarData['uri'];
+ $params['object']['link']['owner'] = $owner;
+ }
+
+
$event->setAffectedUser($user)
->setSubject(
$user === $currentUser ? $action . '_self' : $action,
- [
- 'actor' => $event->getAuthor(),
- 'calendar' => [
- 'id' => (int) $calendarData['id'],
- 'uri' => $calendarData['uri'],
- 'name' => $calendarData['{DAV:}displayname'],
- ],
- 'object' => [
- 'id' => $object['id'],
- 'name' => $object['name'],
- ],
- ]
+ $params
);
+
+ $this->activityManager->publish($event);
+ }
+ }
+
+ /**
+ * Creates activities when a calendar object was moved
+ */
+ public function onMovedCalendarObject(array $sourceCalendarData, array $targetCalendarData, array $sourceShares, array $targetShares, array $objectData): void {
+ if (!isset($targetCalendarData['principaluri'])) {
+ return;
+ }
+
+ $sourcePrincipal = explode('/', $sourceCalendarData['principaluri']);
+ $sourceOwner = array_pop($sourcePrincipal);
+
+ $targetPrincipal = explode('/', $targetCalendarData['principaluri']);
+ $targetOwner = array_pop($targetPrincipal);
+
+ if ($sourceOwner !== $targetOwner) {
+ $this->onTouchCalendarObject(
+ Event::SUBJECT_OBJECT_DELETE,
+ $sourceCalendarData,
+ $sourceShares,
+ $objectData
+ );
+ $this->onTouchCalendarObject(
+ Event::SUBJECT_OBJECT_ADD,
+ $targetCalendarData,
+ $targetShares,
+ $objectData
+ );
+ return;
+ }
+
+ $currentUser = $this->userSession->getUser();
+ if ($currentUser instanceof IUser) {
+ $currentUser = $currentUser->getUID();
+ } else {
+ $currentUser = $targetOwner;
+ }
+
+ $classification = $objectData['classification'] ?? CalDavBackend::CLASSIFICATION_PUBLIC;
+ $object = $this->getObjectNameAndType($objectData);
+
+ if (!$object) {
+ return;
+ }
+
+ $event = $this->activityManager->generateEvent();
+ $event->setApp('dav')
+ ->setObject('calendar', (int)$targetCalendarData['id'])
+ ->setType($object['type'] === 'event' ? 'calendar_event' : 'calendar_todo')
+ ->setAuthor($currentUser);
+
+ $users = $this->getUsersForShares(array_intersect($sourceShares, $targetShares));
+ $users[] = $targetOwner;
+
+ // Users for share can return the owner itself if the calendar is published
+ foreach (array_unique($users) as $user) {
+ if ($classification === CalDavBackend::CLASSIFICATION_PRIVATE && $user !== $targetOwner) {
+ // Private events are only shown to the owner
+ continue;
+ }
+
+ $params = [
+ 'actor' => $event->getAuthor(),
+ 'sourceCalendar' => [
+ 'id' => (int)$sourceCalendarData['id'],
+ 'uri' => $sourceCalendarData['uri'],
+ 'name' => $sourceCalendarData['{DAV:}displayname'],
+ ],
+ 'targetCalendar' => [
+ 'id' => (int)$targetCalendarData['id'],
+ 'uri' => $targetCalendarData['uri'],
+ 'name' => $targetCalendarData['{DAV:}displayname'],
+ ],
+ 'object' => [
+ 'id' => $object['id'],
+ 'name' => $classification === CalDavBackend::CLASSIFICATION_CONFIDENTIAL && $user !== $targetOwner ? 'Busy' : $object['name'],
+ 'classified' => $classification === CalDavBackend::CLASSIFICATION_CONFIDENTIAL && $user !== $targetOwner,
+ ],
+ ];
+
+ if ($object['type'] === 'event' && $this->appManager->isEnabledForUser('calendar')) {
+ $params['object']['link']['object_uri'] = $objectData['uri'];
+ $params['object']['link']['calendar_uri'] = $targetCalendarData['uri'];
+ $params['object']['link']['owner'] = $targetOwner;
+ }
+
+ $event->setAffectedUser($user)
+ ->setSubject(
+ $user === $currentUser ? Event::SUBJECT_OBJECT_MOVE . '_' . $object['type'] . '_self' : Event::SUBJECT_OBJECT_MOVE . '_' . $object['type'],
+ $params
+ );
+
$this->activityManager->publish($event);
}
}
/**
* @param array $objectData
- * @return string[]|bool
+ * @return string[]|false
*/
protected function getObjectNameAndType(array $objectData) {
$vObject = Reader::read($objectData['calendardata']);
$component = $componentType = null;
- foreach($vObject->getComponents() as $component) {
+ foreach ($vObject->getComponents() as $component) {
if (in_array($component->name, ['VEVENT', 'VTODO'])) {
$componentType = $component->name;
break;
@@ -474,9 +590,9 @@ class Backend {
}
if ($componentType === 'VEVENT') {
- return ['id' => (string) $component->UID, 'name' => (string) $component->SUMMARY, 'type' => 'event'];
+ return ['id' => (string)$component->UID, 'name' => (string)$component->SUMMARY, 'type' => 'event'];
}
- return ['id' => (string) $component->UID, 'name' => (string) $component->SUMMARY, 'type' => 'todo', 'status' => (string) $component->STATUS];
+ return ['id' => (string)$component->UID, 'name' => (string)$component->SUMMARY, 'type' => 'todo', 'status' => (string)$component->STATUS];
}
/**
@@ -488,11 +604,11 @@ class Backend {
protected function getUsersForShares(array $shares) {
$users = $groups = [];
foreach ($shares as $share) {
- $prinical = explode('/', $share['{http://owncloud.org/ns}principal']);
- if ($prinical[1] === 'users') {
- $users[] = $prinical[2];
- } else if ($prinical[1] === 'groups') {
- $groups[] = $prinical[2];
+ $principal = explode('/', $share['{http://owncloud.org/ns}principal']);
+ if ($principal[1] === 'users') {
+ $users[] = $principal[2];
+ } elseif ($principal[1] === 'groups') {
+ $groups[] = $principal[2];
}
}
diff --git a/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php b/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php
index 7456074915b..78579ee84b7 100644
--- a/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php
+++ b/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php
@@ -1,44 +1,21 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Activity\Filter;
-
use OCP\Activity\IFilter;
use OCP\IL10N;
use OCP\IURLGenerator;
class Calendar implements IFilter {
- /** @var IL10N */
- protected $l;
-
- /** @var IURLGenerator */
- protected $url;
-
- public function __construct(IL10N $l, IURLGenerator $url) {
- $this->l = $l;
- $this->url = $url;
+ public function __construct(
+ protected IL10N $l,
+ protected IURLGenerator $url,
+ ) {
}
/**
@@ -59,8 +36,8 @@ class Calendar implements IFilter {
/**
* @return int whether the filter should be rather on the top or bottom of
- * the admin section. The filters are arranged in ascending order of the
- * priority values. It is required to return a value between 0 and 100.
+ * the admin section. The filters are arranged in ascending order of the
+ * priority values. It is required to return a value between 0 and 100.
* @since 11.0.0
*/
public function getPriority() {
@@ -72,7 +49,7 @@ class Calendar implements IFilter {
* @since 11.0.0
*/
public function getIcon() {
- return $this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar-dark.svg'));
+ return $this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar.svg'));
}
/**
diff --git a/apps/dav/lib/CalDAV/Activity/Filter/Todo.php b/apps/dav/lib/CalDAV/Activity/Filter/Todo.php
index 5bc08c8f2dd..b001f90c28d 100644
--- a/apps/dav/lib/CalDAV/Activity/Filter/Todo.php
+++ b/apps/dav/lib/CalDAV/Activity/Filter/Todo.php
@@ -1,44 +1,21 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Activity\Filter;
-
use OCP\Activity\IFilter;
use OCP\IL10N;
use OCP\IURLGenerator;
class Todo implements IFilter {
- /** @var IL10N */
- protected $l;
-
- /** @var IURLGenerator */
- protected $url;
-
- public function __construct(IL10N $l, IURLGenerator $url) {
- $this->l = $l;
- $this->url = $url;
+ public function __construct(
+ protected IL10N $l,
+ protected IURLGenerator $url,
+ ) {
}
/**
@@ -54,13 +31,13 @@ class Todo implements IFilter {
* @since 11.0.0
*/
public function getName() {
- return $this->l->t('Todos');
+ return $this->l->t('Tasks');
}
/**
* @return int whether the filter should be rather on the top or bottom of
- * the admin section. The filters are arranged in ascending order of the
- * priority values. It is required to return a value between 0 and 100.
+ * the admin section. The filters are arranged in ascending order of the
+ * priority values. It is required to return a value between 0 and 100.
* @since 11.0.0
*/
public function getPriority() {
diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Base.php b/apps/dav/lib/CalDAV/Activity/Provider/Base.php
index b6d8a5be736..558abe0ca1a 100644
--- a/apps/dav/lib/CalDAV/Activity/Provider/Base.php
+++ b/apps/dav/lib/CalDAV/Activity/Provider/Base.php
@@ -1,80 +1,38 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Activity\Provider;
use OCA\DAV\CalDAV\CalDavBackend;
use OCP\Activity\IEvent;
use OCP\Activity\IProvider;
+use OCP\IGroup;
+use OCP\IGroupManager;
use OCP\IL10N;
-use OCP\IUser;
+use OCP\IURLGenerator;
use OCP\IUserManager;
abstract class Base implements IProvider {
-
- /** @var IUserManager */
- protected $userManager;
-
- /** @var string[] cached displayNames - key is the UID and value the displayname */
- protected $displayNames = [];
+ /** @var string[] */
+ protected $groupDisplayNames = [];
/**
* @param IUserManager $userManager
+ * @param IGroupManager $groupManager
+ * @param IURLGenerator $url
*/
- public function __construct(IUserManager $userManager) {
- $this->userManager = $userManager;
- }
-
- /**
- * @param IEvent $event
- * @param string $subject
- * @param array $parameters
- */
- protected function setSubjects(IEvent $event, $subject, array $parameters) {
- $placeholders = $replacements = [];
- foreach ($parameters as $placeholder => $parameter) {
- $placeholders[] = '{' . $placeholder . '}';
- $replacements[] = $parameter['name'];
- }
-
- $event->setParsedSubject(str_replace($placeholders, $replacements, $subject))
- ->setRichSubject($subject, $parameters);
+ public function __construct(
+ protected IUserManager $userManager,
+ protected IGroupManager $groupManager,
+ protected IURLGenerator $url,
+ ) {
}
- /**
- * @param array $eventData
- * @return array
- */
- protected function generateObjectParameter($eventData) {
- if (!is_array($eventData) || !isset($eventData['id']) || !isset($eventData['name'])) {
- throw new \InvalidArgumentException();
- }
-
- return [
- 'type' => 'calendar-event',
- 'id' => $eventData['id'],
- 'name' => $eventData['name'],
- ];
+ protected function setSubjects(IEvent $event, string $subject, array $parameters): void {
+ $event->setRichSubject($subject, $parameters);
}
/**
@@ -83,18 +41,18 @@ abstract class Base implements IProvider {
* @return array
*/
protected function generateCalendarParameter($data, IL10N $l) {
- if ($data['uri'] === CalDavBackend::PERSONAL_CALENDAR_URI &&
- $data['name'] === CalDavBackend::PERSONAL_CALENDAR_NAME) {
+ if ($data['uri'] === CalDavBackend::PERSONAL_CALENDAR_URI
+ && $data['name'] === CalDavBackend::PERSONAL_CALENDAR_NAME) {
return [
'type' => 'calendar',
- 'id' => $data['id'],
+ 'id' => (string)$data['id'],
'name' => $l->t('Personal'),
];
}
return [
'type' => 'calendar',
- 'id' => $data['id'],
+ 'id' => (string)$data['id'],
'name' => $data['name'],
];
}
@@ -107,49 +65,44 @@ abstract class Base implements IProvider {
protected function generateLegacyCalendarParameter($id, $name) {
return [
'type' => 'calendar',
- 'id' => $id,
+ 'id' => (string)$id,
'name' => $name,
];
}
- /**
- * @param string $id
- * @return array
- */
- protected function generateGroupParameter($id) {
+ protected function generateUserParameter(string $uid): array {
return [
- 'type' => 'group',
- 'id' => $id,
- 'name' => $id,
+ 'type' => 'user',
+ 'id' => $uid,
+ 'name' => $this->userManager->getDisplayName($uid) ?? $uid,
];
}
/**
- * @param string $uid
+ * @param string $gid
* @return array
*/
- protected function generateUserParameter($uid) {
- if (!isset($this->displayNames[$uid])) {
- $this->displayNames[$uid] = $this->getDisplayName($uid);
+ protected function generateGroupParameter($gid) {
+ if (!isset($this->groupDisplayNames[$gid])) {
+ $this->groupDisplayNames[$gid] = $this->getGroupDisplayName($gid);
}
return [
- 'type' => 'user',
- 'id' => $uid,
- 'name' => $this->displayNames[$uid],
+ 'type' => 'user-group',
+ 'id' => $gid,
+ 'name' => $this->groupDisplayNames[$gid],
];
}
/**
- * @param string $uid
+ * @param string $gid
* @return string
*/
- protected function getDisplayName($uid) {
- $user = $this->userManager->get($uid);
- if ($user instanceof IUser) {
- return $user->getDisplayName();
- } else {
- return $uid;
+ protected function getGroupDisplayName($gid) {
+ $group = $this->groupManager->get($gid);
+ if ($group instanceof IGroup) {
+ return $group->getDisplayName();
}
+ return $gid;
}
}
diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php
index ff129144285..8c93ddae431 100644
--- a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php
+++ b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php
@@ -1,76 +1,54 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Activity\Provider;
+use OCP\Activity\Exceptions\UnknownActivityException;
use OCP\Activity\IEvent;
use OCP\Activity\IEventMerger;
use OCP\Activity\IManager;
+use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\L10N\IFactory;
class Calendar extends Base {
-
- const SUBJECT_ADD = 'calendar_add';
- const SUBJECT_UPDATE = 'calendar_update';
- const SUBJECT_DELETE = 'calendar_delete';
- const SUBJECT_PUBLISH = 'calendar_publish';
- const SUBJECT_UNPUBLISH = 'calendar_unpublish';
- const SUBJECT_SHARE_USER = 'calendar_user_share';
- const SUBJECT_SHARE_GROUP = 'calendar_group_share';
- const SUBJECT_UNSHARE_USER = 'calendar_user_unshare';
- const SUBJECT_UNSHARE_GROUP = 'calendar_group_unshare';
-
- /** @var IFactory */
- protected $languageFactory;
+ public const SUBJECT_ADD = 'calendar_add';
+ public const SUBJECT_UPDATE = 'calendar_update';
+ public const SUBJECT_MOVE_TO_TRASH = 'calendar_move_to_trash';
+ public const SUBJECT_RESTORE = 'calendar_restore';
+ public const SUBJECT_DELETE = 'calendar_delete';
+ public const SUBJECT_PUBLISH = 'calendar_publish';
+ public const SUBJECT_UNPUBLISH = 'calendar_unpublish';
+ public const SUBJECT_SHARE_USER = 'calendar_user_share';
+ public const SUBJECT_SHARE_GROUP = 'calendar_group_share';
+ public const SUBJECT_UNSHARE_USER = 'calendar_user_unshare';
+ public const SUBJECT_UNSHARE_GROUP = 'calendar_group_unshare';
/** @var IL10N */
protected $l;
- /** @var IURLGenerator */
- protected $url;
-
- /** @var IManager */
- protected $activityManager;
-
- /** @var IEventMerger */
- protected $eventMerger;
-
/**
* @param IFactory $languageFactory
* @param IURLGenerator $url
* @param IManager $activityManager
* @param IUserManager $userManager
+ * @param IGroupManager $groupManager
* @param IEventMerger $eventMerger
*/
- public function __construct(IFactory $languageFactory, IURLGenerator $url, IManager $activityManager, IUserManager $userManager, IEventMerger $eventMerger) {
- parent::__construct($userManager);
- $this->languageFactory = $languageFactory;
- $this->url = $url;
- $this->activityManager = $activityManager;
- $this->eventMerger = $eventMerger;
+ public function __construct(
+ protected IFactory $languageFactory,
+ IURLGenerator $url,
+ protected IManager $activityManager,
+ IUserManager $userManager,
+ IGroupManager $groupManager,
+ protected IEventMerger $eventMerger,
+ ) {
+ parent::__construct($userManager, $groupManager, $url);
}
/**
@@ -78,12 +56,12 @@ class Calendar extends Base {
* @param IEvent $event
* @param IEvent|null $previousEvent
* @return IEvent
- * @throws \InvalidArgumentException
+ * @throws UnknownActivityException
* @since 11.0.0
*/
- public function parse($language, IEvent $event, IEvent $previousEvent = null) {
+ public function parse($language, IEvent $event, ?IEvent $previousEvent = null) {
if ($event->getApp() !== 'dav' || $event->getType() !== 'calendar') {
- throw new \InvalidArgumentException();
+ throw new UnknownActivityException();
}
$this->l = $this->languageFactory->get('dav', $language);
@@ -91,52 +69,57 @@ class Calendar extends Base {
if ($this->activityManager->getRequirePNG()) {
$event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar-dark.png')));
} else {
- $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar-dark.svg')));
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar.svg')));
}
if ($event->getSubject() === self::SUBJECT_ADD) {
$subject = $this->l->t('{actor} created calendar {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_ADD . '_self') {
+ } elseif ($event->getSubject() === self::SUBJECT_ADD . '_self') {
$subject = $this->l->t('You created calendar {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_DELETE) {
+ } elseif ($event->getSubject() === self::SUBJECT_DELETE) {
$subject = $this->l->t('{actor} deleted calendar {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_DELETE . '_self') {
+ } elseif ($event->getSubject() === self::SUBJECT_DELETE . '_self') {
$subject = $this->l->t('You deleted calendar {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_UPDATE) {
+ } elseif ($event->getSubject() === self::SUBJECT_UPDATE) {
$subject = $this->l->t('{actor} updated calendar {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_UPDATE . '_self') {
+ } elseif ($event->getSubject() === self::SUBJECT_UPDATE . '_self') {
$subject = $this->l->t('You updated calendar {calendar}');
-
- } else if ($event->getSubject() === self::SUBJECT_PUBLISH . '_self') {
+ } elseif ($event->getSubject() === self::SUBJECT_MOVE_TO_TRASH) {
+ $subject = $this->l->t('{actor} deleted calendar {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_MOVE_TO_TRASH . '_self') {
+ $subject = $this->l->t('You deleted calendar {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_RESTORE) {
+ $subject = $this->l->t('{actor} restored calendar {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_RESTORE . '_self') {
+ $subject = $this->l->t('You restored calendar {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_PUBLISH . '_self') {
$subject = $this->l->t('You shared calendar {calendar} as public link');
- } else if ($event->getSubject() === self::SUBJECT_UNPUBLISH . '_self') {
+ } elseif ($event->getSubject() === self::SUBJECT_UNPUBLISH . '_self') {
$subject = $this->l->t('You removed public link for calendar {calendar}');
-
- } else if ($event->getSubject() === self::SUBJECT_SHARE_USER) {
+ } elseif ($event->getSubject() === self::SUBJECT_SHARE_USER) {
$subject = $this->l->t('{actor} shared calendar {calendar} with you');
- } else if ($event->getSubject() === self::SUBJECT_SHARE_USER . '_you') {
+ } elseif ($event->getSubject() === self::SUBJECT_SHARE_USER . '_you') {
$subject = $this->l->t('You shared calendar {calendar} with {user}');
- } else if ($event->getSubject() === self::SUBJECT_SHARE_USER . '_by') {
+ } elseif ($event->getSubject() === self::SUBJECT_SHARE_USER . '_by') {
$subject = $this->l->t('{actor} shared calendar {calendar} with {user}');
- } else if ($event->getSubject() === self::SUBJECT_UNSHARE_USER) {
+ } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER) {
$subject = $this->l->t('{actor} unshared calendar {calendar} from you');
- } else if ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_you') {
+ } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_you') {
$subject = $this->l->t('You unshared calendar {calendar} from {user}');
- } else if ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_by') {
+ } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_by') {
$subject = $this->l->t('{actor} unshared calendar {calendar} from {user}');
- } else if ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_self') {
+ } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_USER . '_self') {
$subject = $this->l->t('{actor} unshared calendar {calendar} from themselves');
-
- } else if ($event->getSubject() === self::SUBJECT_SHARE_GROUP . '_you') {
+ } elseif ($event->getSubject() === self::SUBJECT_SHARE_GROUP . '_you') {
$subject = $this->l->t('You shared calendar {calendar} with group {group}');
- } else if ($event->getSubject() === self::SUBJECT_SHARE_GROUP . '_by') {
+ } elseif ($event->getSubject() === self::SUBJECT_SHARE_GROUP . '_by') {
$subject = $this->l->t('{actor} shared calendar {calendar} with group {group}');
- } else if ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_you') {
+ } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_you') {
$subject = $this->l->t('You unshared calendar {calendar} from group {group}');
- } else if ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_by') {
+ } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_by') {
$subject = $this->l->t('{actor} unshared calendar {calendar} from group {group}');
} else {
- throw new \InvalidArgumentException();
+ throw new UnknownActivityException();
}
$parsedParameters = $this->getParameters($event);
@@ -148,7 +131,7 @@ class Calendar extends Base {
if (isset($parsedParameters['user'])) {
// Couldn't group by calendar, maybe we can group by users
$event = $this->eventMerger->mergeEvents('user', $event, $previousEvent);
- } else if (isset($parsedParameters['group'])) {
+ } elseif (isset($parsedParameters['group'])) {
// Couldn't group by calendar, maybe we can group by groups
$event = $this->eventMerger->mergeEvents('group', $event, $previousEvent);
}
@@ -174,6 +157,12 @@ class Calendar extends Base {
case self::SUBJECT_DELETE . '_self':
case self::SUBJECT_UPDATE:
case self::SUBJECT_UPDATE . '_self':
+ case self::SUBJECT_MOVE_TO_TRASH:
+ case self::SUBJECT_MOVE_TO_TRASH . '_self':
+ case self::SUBJECT_RESTORE:
+ case self::SUBJECT_RESTORE . '_self':
+ case self::SUBJECT_PUBLISH . '_self':
+ case self::SUBJECT_UNPUBLISH . '_self':
case self::SUBJECT_SHARE_USER:
case self::SUBJECT_UNSHARE_USER:
case self::SUBJECT_UNSHARE_USER . '_self':
@@ -229,32 +218,32 @@ class Calendar extends Base {
case self::SUBJECT_UNSHARE_USER . '_self':
return [
'actor' => $this->generateUserParameter($parameters[0]),
- 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]),
+ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]),
];
case self::SUBJECT_SHARE_USER . '_you':
case self::SUBJECT_UNSHARE_USER . '_you':
return [
'user' => $this->generateUserParameter($parameters[0]),
- 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]),
+ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]),
];
case self::SUBJECT_SHARE_USER . '_by':
case self::SUBJECT_UNSHARE_USER . '_by':
return [
'user' => $this->generateUserParameter($parameters[0]),
- 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]),
+ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]),
'actor' => $this->generateUserParameter($parameters[2]),
];
case self::SUBJECT_SHARE_GROUP . '_you':
case self::SUBJECT_UNSHARE_GROUP . '_you':
return [
'group' => $this->generateGroupParameter($parameters[0]),
- 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]),
+ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]),
];
case self::SUBJECT_SHARE_GROUP . '_by':
case self::SUBJECT_UNSHARE_GROUP . '_by':
return [
'group' => $this->generateGroupParameter($parameters[0]),
- 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]),
+ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]),
'actor' => $this->generateUserParameter($parameters[2]),
];
}
diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Event.php b/apps/dav/lib/CalDAV/Activity/Provider/Event.php
index eabd2e517c0..87551d7840b 100644
--- a/apps/dav/lib/CalDAV/Activity/Provider/Event.php
+++ b/apps/dav/lib/CalDAV/Activity/Provider/Event.php
@@ -1,70 +1,92 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Activity\Provider;
+use OCP\Activity\Exceptions\UnknownActivityException;
use OCP\Activity\IEvent;
use OCP\Activity\IEventMerger;
use OCP\Activity\IManager;
+use OCP\App\IAppManager;
+use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\L10N\IFactory;
class Event extends Base {
-
- const SUBJECT_OBJECT_ADD = 'object_add';
- const SUBJECT_OBJECT_UPDATE = 'object_update';
- const SUBJECT_OBJECT_DELETE = 'object_delete';
-
- /** @var IFactory */
- protected $languageFactory;
+ public const SUBJECT_OBJECT_ADD = 'object_add';
+ public const SUBJECT_OBJECT_UPDATE = 'object_update';
+ public const SUBJECT_OBJECT_MOVE = 'object_move';
+ public const SUBJECT_OBJECT_MOVE_TO_TRASH = 'object_move_to_trash';
+ public const SUBJECT_OBJECT_RESTORE = 'object_restore';
+ public const SUBJECT_OBJECT_DELETE = 'object_delete';
/** @var IL10N */
protected $l;
- /** @var IURLGenerator */
- protected $url;
-
- /** @var IManager */
- protected $activityManager;
-
- /** @var IEventMerger */
- protected $eventMerger;
-
/**
* @param IFactory $languageFactory
* @param IURLGenerator $url
* @param IManager $activityManager
* @param IUserManager $userManager
+ * @param IGroupManager $groupManager
* @param IEventMerger $eventMerger
+ * @param IAppManager $appManager
*/
- public function __construct(IFactory $languageFactory, IURLGenerator $url, IManager $activityManager, IUserManager $userManager, IEventMerger $eventMerger) {
- parent::__construct($userManager);
- $this->languageFactory = $languageFactory;
- $this->url = $url;
- $this->activityManager = $activityManager;
- $this->eventMerger = $eventMerger;
+ public function __construct(
+ protected IFactory $languageFactory,
+ IURLGenerator $url,
+ protected IManager $activityManager,
+ IUserManager $userManager,
+ IGroupManager $groupManager,
+ protected IEventMerger $eventMerger,
+ protected IAppManager $appManager,
+ ) {
+ parent::__construct($userManager, $groupManager, $url);
+ }
+
+ /**
+ * @param array $eventData
+ * @return array
+ */
+ protected function generateObjectParameter(array $eventData, string $affectedUser): array {
+ if (!isset($eventData['id']) || !isset($eventData['name'])) {
+ throw new \InvalidArgumentException();
+ }
+
+ $params = [
+ 'type' => 'calendar-event',
+ 'id' => $eventData['id'],
+ 'name' => trim($eventData['name']) !== '' ? $eventData['name'] : $this->l->t('Untitled event'),
+ ];
+
+ if (isset($eventData['link']) && is_array($eventData['link']) && $this->appManager->isEnabledForUser('calendar')) {
+ try {
+ // The calendar app needs to be manually loaded for the routes to be loaded
+ $this->appManager->loadApp('calendar');
+ $linkData = $eventData['link'];
+ $calendarUri = $this->urlencodeLowerHex($linkData['calendar_uri']);
+ if ($affectedUser === $linkData['owner']) {
+ $objectId = base64_encode($this->url->getWebroot() . '/remote.php/dav/calendars/' . $linkData['owner'] . '/' . $calendarUri . '/' . $linkData['object_uri']);
+ } else {
+ // Can't use the "real" owner and calendar names here because we create a custom
+ // calendar for incoming shares with the name "<calendar>_shared_by_<sharer>".
+ // Hack: Fix the link by generating it for the incoming shared calendar instead,
+ // as seen from the affected user.
+ $objectId = base64_encode($this->url->getWebroot() . '/remote.php/dav/calendars/' . $affectedUser . '/' . $calendarUri . '_shared_by_' . $linkData['owner'] . '/' . $linkData['object_uri']);
+ }
+ $params['link'] = $this->url->linkToRouteAbsolute('calendar.view.indexdirect.edit', [
+ 'objectId' => $objectId,
+ ]);
+ } catch (\Exception $error) {
+ // Do nothing
+ }
+ }
+ return $params;
}
/**
@@ -72,12 +94,12 @@ class Event extends Base {
* @param IEvent $event
* @param IEvent|null $previousEvent
* @return IEvent
- * @throws \InvalidArgumentException
+ * @throws UnknownActivityException
* @since 11.0.0
*/
- public function parse($language, IEvent $event, IEvent $previousEvent = null) {
+ public function parse($language, IEvent $event, ?IEvent $previousEvent = null) {
if ($event->getApp() !== 'dav' || $event->getType() !== 'calendar_event') {
- throw new \InvalidArgumentException();
+ throw new UnknownActivityException();
}
$this->l = $this->languageFactory->get('dav', $language);
@@ -85,23 +107,35 @@ class Event extends Base {
if ($this->activityManager->getRequirePNG()) {
$event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar-dark.png')));
} else {
- $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar-dark.svg')));
+ $event->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar.svg')));
}
if ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_event') {
$subject = $this->l->t('{actor} created event {event} in calendar {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_event_self') {
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_event_self') {
$subject = $this->l->t('You created event {event} in calendar {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_event') {
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_event') {
$subject = $this->l->t('{actor} deleted event {event} from calendar {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_event_self') {
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_event_self') {
$subject = $this->l->t('You deleted event {event} from calendar {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_event') {
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_event') {
$subject = $this->l->t('{actor} updated event {event} in calendar {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_event_self') {
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_event_self') {
$subject = $this->l->t('You updated event {event} in calendar {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_event') {
+ $subject = $this->l->t('{actor} moved event {event} from calendar {sourceCalendar} to calendar {targetCalendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_event_self') {
+ $subject = $this->l->t('You moved event {event} from calendar {sourceCalendar} to calendar {targetCalendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event') {
+ $subject = $this->l->t('{actor} deleted event {event} from calendar {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event_self') {
+ $subject = $this->l->t('You deleted event {event} from calendar {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_RESTORE . '_event') {
+ $subject = $this->l->t('{actor} restored event {event} of calendar {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_RESTORE . '_event_self') {
+ $subject = $this->l->t('You restored event {event} of calendar {calendar}');
} else {
- throw new \InvalidArgumentException();
+ throw new UnknownActivityException();
}
$parsedParameters = $this->getParameters($event);
@@ -126,17 +160,39 @@ class Event extends Base {
case self::SUBJECT_OBJECT_ADD . '_event':
case self::SUBJECT_OBJECT_DELETE . '_event':
case self::SUBJECT_OBJECT_UPDATE . '_event':
+ case self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event':
+ case self::SUBJECT_OBJECT_RESTORE . '_event':
return [
'actor' => $this->generateUserParameter($parameters['actor']),
'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l),
- 'event' => $this->generateObjectParameter($parameters['object']),
+ 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()),
];
case self::SUBJECT_OBJECT_ADD . '_event_self':
case self::SUBJECT_OBJECT_DELETE . '_event_self':
case self::SUBJECT_OBJECT_UPDATE . '_event_self':
+ case self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event_self':
+ case self::SUBJECT_OBJECT_RESTORE . '_event_self':
return [
'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l),
- 'event' => $this->generateObjectParameter($parameters['object']),
+ 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()),
+ ];
+ }
+ }
+
+ if (isset($parameters['sourceCalendar']) && isset($parameters['targetCalendar'])) {
+ switch ($subject) {
+ case self::SUBJECT_OBJECT_MOVE . '_event':
+ return [
+ 'actor' => $this->generateUserParameter($parameters['actor']),
+ 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l),
+ 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l),
+ 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()),
+ ];
+ case self::SUBJECT_OBJECT_MOVE . '_event_self':
+ return [
+ 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l),
+ 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l),
+ 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()),
];
}
}
@@ -152,18 +208,38 @@ class Event extends Base {
case self::SUBJECT_OBJECT_UPDATE . '_event':
return [
'actor' => $this->generateUserParameter($parameters[0]),
- 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]),
- 'event' => $this->generateObjectParameter($parameters[2]),
+ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]),
+ 'event' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()),
];
case self::SUBJECT_OBJECT_ADD . '_event_self':
case self::SUBJECT_OBJECT_DELETE . '_event_self':
case self::SUBJECT_OBJECT_UPDATE . '_event_self':
return [
- 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]),
- 'event' => $this->generateObjectParameter($parameters[2]),
+ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]),
+ 'event' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()),
];
}
throw new \InvalidArgumentException();
}
+
+ private function generateClassifiedObjectParameter(array $eventData, string $affectedUser): array {
+ $parameter = $this->generateObjectParameter($eventData, $affectedUser);
+ if (!empty($eventData['classified'])) {
+ $parameter['name'] = $this->l->t('Busy');
+ }
+ return $parameter;
+ }
+
+ /**
+ * Return urlencoded string but with lower cased hex sequences.
+ * The remaining casing will be untouched.
+ */
+ private function urlencodeLowerHex(string $raw): string {
+ return preg_replace_callback(
+ '/%[0-9A-F]{2}/',
+ static fn (array $matches) => strtolower($matches[0]),
+ urlencode($raw),
+ );
+ }
}
diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Todo.php b/apps/dav/lib/CalDAV/Activity/Provider/Todo.php
index 7ee91184794..fc0625ec970 100644
--- a/apps/dav/lib/CalDAV/Activity/Provider/Todo.php
+++ b/apps/dav/lib/CalDAV/Activity/Provider/Todo.php
@@ -1,28 +1,12 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Activity\Provider;
+use OCP\Activity\Exceptions\UnknownActivityException;
use OCP\Activity\IEvent;
class Todo extends Event {
@@ -32,12 +16,12 @@ class Todo extends Event {
* @param IEvent $event
* @param IEvent|null $previousEvent
* @return IEvent
- * @throws \InvalidArgumentException
+ * @throws UnknownActivityException
* @since 11.0.0
*/
- public function parse($language, IEvent $event, IEvent $previousEvent = null) {
+ public function parse($language, IEvent $event, ?IEvent $previousEvent = null) {
if ($event->getApp() !== 'dav' || $event->getType() !== 'calendar_todo') {
- throw new \InvalidArgumentException();
+ throw new UnknownActivityException();
}
$this->l = $this->languageFactory->get('dav', $language);
@@ -49,28 +33,31 @@ class Todo extends Event {
}
if ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_todo') {
- $subject = $this->l->t('{actor} created todo {todo} in list {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_todo_self') {
- $subject = $this->l->t('You created todo {todo} in list {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_todo') {
- $subject = $this->l->t('{actor} deleted todo {todo} from list {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_todo_self') {
- $subject = $this->l->t('You deleted todo {todo} from list {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo') {
- $subject = $this->l->t('{actor} updated todo {todo} in list {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_self') {
- $subject = $this->l->t('You updated todo {todo} in list {calendar}');
-
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_completed') {
- $subject = $this->l->t('{actor} solved todo {todo} in list {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_completed_self') {
- $subject = $this->l->t('You solved todo {todo} in list {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action') {
- $subject = $this->l->t('{actor} reopened todo {todo} in list {calendar}');
- } else if ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self') {
- $subject = $this->l->t('You reopened todo {todo} in list {calendar}');
+ $subject = $this->l->t('{actor} created to-do {todo} in list {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_todo_self') {
+ $subject = $this->l->t('You created to-do {todo} in list {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_todo') {
+ $subject = $this->l->t('{actor} deleted to-do {todo} from list {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_todo_self') {
+ $subject = $this->l->t('You deleted to-do {todo} from list {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo') {
+ $subject = $this->l->t('{actor} updated to-do {todo} in list {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_self') {
+ $subject = $this->l->t('You updated to-do {todo} in list {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_completed') {
+ $subject = $this->l->t('{actor} solved to-do {todo} in list {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_completed_self') {
+ $subject = $this->l->t('You solved to-do {todo} in list {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action') {
+ $subject = $this->l->t('{actor} reopened to-do {todo} in list {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self') {
+ $subject = $this->l->t('You reopened to-do {todo} in list {calendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_todo') {
+ $subject = $this->l->t('{actor} moved to-do {todo} from list {sourceCalendar} to list {targetCalendar}');
+ } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_todo_self') {
+ $subject = $this->l->t('You moved to-do {todo} from list {sourceCalendar} to list {targetCalendar}');
} else {
- throw new \InvalidArgumentException();
+ throw new UnknownActivityException();
}
$parsedParameters = $this->getParameters($event);
@@ -100,7 +87,7 @@ class Todo extends Event {
return [
'actor' => $this->generateUserParameter($parameters['actor']),
'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l),
- 'todo' => $this->generateObjectParameter($parameters['object']),
+ 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()),
];
case self::SUBJECT_OBJECT_ADD . '_todo_self':
case self::SUBJECT_OBJECT_DELETE . '_todo_self':
@@ -109,7 +96,25 @@ class Todo extends Event {
case self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self':
return [
'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l),
- 'todo' => $this->generateObjectParameter($parameters['object']),
+ 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()),
+ ];
+ }
+ }
+
+ if (isset($parameters['sourceCalendar']) && isset($parameters['targetCalendar'])) {
+ switch ($subject) {
+ case self::SUBJECT_OBJECT_MOVE . '_todo':
+ return [
+ 'actor' => $this->generateUserParameter($parameters['actor']),
+ 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l),
+ 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l),
+ 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()),
+ ];
+ case self::SUBJECT_OBJECT_MOVE . '_todo_self':
+ return [
+ 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l),
+ 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l),
+ 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()),
];
}
}
@@ -127,8 +132,8 @@ class Todo extends Event {
case self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action':
return [
'actor' => $this->generateUserParameter($parameters[0]),
- 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]),
- 'todo' => $this->generateObjectParameter($parameters[2]),
+ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]),
+ 'todo' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()),
];
case self::SUBJECT_OBJECT_ADD . '_todo_self':
case self::SUBJECT_OBJECT_DELETE . '_todo_self':
@@ -136,8 +141,8 @@ class Todo extends Event {
case self::SUBJECT_OBJECT_UPDATE . '_todo_completed_self':
case self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self':
return [
- 'calendar' => $this->generateLegacyCalendarParameter((int)$event->getObjectId(), $parameters[1]),
- 'todo' => $this->generateObjectParameter($parameters[2]),
+ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]),
+ 'todo' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()),
];
}
diff --git a/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php b/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php
new file mode 100644
index 00000000000..7ab7f16dbbb
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Activity\Setting;
+
+use OCP\Activity\ActivitySettings;
+use OCP\IL10N;
+
+abstract class CalDAVSetting extends ActivitySettings {
+ /**
+ * @param IL10N $l
+ */
+ public function __construct(
+ protected IL10N $l,
+ ) {
+ }
+
+ public function getGroupIdentifier() {
+ return 'calendar';
+ }
+
+ public function getGroupName() {
+ return $this->l->t('Calendar, contacts and tasks');
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php b/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php
index 24d2cef913e..0ad86a919bc 100644
--- a/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php
+++ b/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php
@@ -1,44 +1,12 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Activity\Setting;
-
-use OCP\Activity\ISetting;
-use OCP\IL10N;
-
-class Calendar implements ISetting {
-
- /** @var IL10N */
- protected $l;
-
- /**
- * @param IL10N $l
- */
- public function __construct(IL10N $l) {
- $this->l = $l;
- }
-
+class Calendar extends CalDAVSetting {
/**
* @return string Lowercase a-z and underscore only identifier
* @since 11.0.0
@@ -57,8 +25,8 @@ class Calendar implements ISetting {
/**
* @return int whether the filter should be rather on the top or bottom of
- * the admin section. The filters are arranged in ascending order of the
- * priority values. It is required to return a value between 0 and 100.
+ * the admin section. The filters are arranged in ascending order of the
+ * priority values. It is required to return a value between 0 and 100.
* @since 11.0.0
*/
public function getPriority() {
@@ -97,4 +65,3 @@ class Calendar implements ISetting {
return false;
}
}
-
diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Event.php b/apps/dav/lib/CalDAV/Activity/Setting/Event.php
index 14b22ad220e..ea9476d6f08 100644
--- a/apps/dav/lib/CalDAV/Activity/Setting/Event.php
+++ b/apps/dav/lib/CalDAV/Activity/Setting/Event.php
@@ -1,44 +1,12 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Activity\Setting;
-
-use OCP\Activity\ISetting;
-use OCP\IL10N;
-
-class Event implements ISetting {
-
- /** @var IL10N */
- protected $l;
-
- /**
- * @param IL10N $l
- */
- public function __construct(IL10N $l) {
- $this->l = $l;
- }
-
+class Event extends CalDAVSetting {
/**
* @return string Lowercase a-z and underscore only identifier
* @since 11.0.0
@@ -57,8 +25,8 @@ class Event implements ISetting {
/**
* @return int whether the filter should be rather on the top or bottom of
- * the admin section. The filters are arranged in ascending order of the
- * priority values. It is required to return a value between 0 and 100.
+ * the admin section. The filters are arranged in ascending order of the
+ * priority values. It is required to return a value between 0 and 100.
* @since 11.0.0
*/
public function getPriority() {
@@ -97,4 +65,3 @@ class Event implements ISetting {
return false;
}
}
-
diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Todo.php b/apps/dav/lib/CalDAV/Activity/Setting/Todo.php
index 272843198a9..ed8377b0ffa 100644
--- a/apps/dav/lib/CalDAV/Activity/Setting/Todo.php
+++ b/apps/dav/lib/CalDAV/Activity/Setting/Todo.php
@@ -1,43 +1,12 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Activity\Setting;
-
-use OCP\Activity\ISetting;
-use OCP\IL10N;
-
-class Todo implements ISetting {
-
- /** @var IL10N */
- protected $l;
-
- /**
- * @param IL10N $l
- */
- public function __construct(IL10N $l) {
- $this->l = $l;
- }
+class Todo extends CalDAVSetting {
/**
* @return string Lowercase a-z and underscore only identifier
@@ -52,13 +21,13 @@ class Todo implements ISetting {
* @since 11.0.0
*/
public function getName() {
- return $this->l->t('A calendar <strong>todo</strong> was modified');
+ return $this->l->t('A calendar <strong>to-do</strong> was modified');
}
/**
* @return int whether the filter should be rather on the top or bottom of
- * the admin section. The filters are arranged in ascending order of the
- * priority values. It is required to return a value between 0 and 100.
+ * the admin section. The filters are arranged in ascending order of the
+ * priority values. It is required to return a value between 0 and 100.
* @since 11.0.0
*/
public function getPriority() {
@@ -97,4 +66,3 @@ class Todo implements ISetting {
return false;
}
}
-
diff --git a/apps/dav/lib/CalDAV/AppCalendar/AppCalendar.php b/apps/dav/lib/CalDAV/AppCalendar/AppCalendar.php
new file mode 100644
index 00000000000..87d26324c32
--- /dev/null
+++ b/apps/dav/lib/CalDAV/AppCalendar/AppCalendar.php
@@ -0,0 +1,194 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV\AppCalendar;
+
+use OCA\DAV\CalDAV\Integration\ExternalCalendar;
+use OCA\DAV\CalDAV\Plugin;
+use OCP\Calendar\ICalendar;
+use OCP\Calendar\ICreateFromString;
+use OCP\Constants;
+use Sabre\CalDAV\CalendarQueryValidator;
+use Sabre\CalDAV\ICalendarObject;
+use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAV\Exception\NotFound;
+use Sabre\DAV\PropPatch;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Reader;
+
+class AppCalendar extends ExternalCalendar {
+ protected ICalendar $calendar;
+
+ public function __construct(
+ string $appId,
+ ICalendar $calendar,
+ protected string $principal,
+ ) {
+ parent::__construct($appId, $calendar->getUri());
+ $this->calendar = $calendar;
+ }
+
+ /**
+ * Return permissions supported by the backend calendar
+ * @return int Permissions based on \OCP\Constants
+ */
+ public function getPermissions(): int {
+ // Make sure to only promote write support if the backend implement the correct interface
+ if ($this->calendar instanceof ICreateFromString) {
+ return $this->calendar->getPermissions();
+ }
+ return Constants::PERMISSION_READ;
+ }
+
+ public function getOwner(): ?string {
+ return $this->principal;
+ }
+
+ public function getGroup(): ?string {
+ return null;
+ }
+
+ public function getACL(): array {
+ $acl = [
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{DAV:}write-properties',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ]
+ ];
+ if ($this->getPermissions() & Constants::PERMISSION_CREATE) {
+ $acl[] = [
+ 'privilege' => '{DAV:}write',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ];
+ }
+ return $acl;
+ }
+
+ public function setACL(array $acl): void {
+ throw new Forbidden('Setting ACL is not supported on this node');
+ }
+
+ public function getSupportedPrivilegeSet(): ?array {
+ // Use the default one
+ return null;
+ }
+
+ public function getLastModified(): ?int {
+ // unknown
+ return null;
+ }
+
+ public function delete(): void {
+ // No method for deleting a calendar in OCP\Calendar\ICalendar
+ throw new Forbidden('Deleting an entry is not implemented');
+ }
+
+ public function createFile($name, $data = null) {
+ if ($this->calendar instanceof ICreateFromString) {
+ if (is_resource($data)) {
+ $data = stream_get_contents($data) ?: null;
+ }
+ $this->calendar->createFromString($name, is_null($data) ? '' : $data);
+ return null;
+ } else {
+ throw new Forbidden('Creating a new entry is not allowed');
+ }
+ }
+
+ public function getProperties($properties) {
+ return [
+ '{DAV:}displayname' => $this->calendar->getDisplayName() ?: $this->calendar->getKey(),
+ '{http://apple.com/ns/ical/}calendar-color' => $this->calendar->getDisplayColor() ?: '#0082c9',
+ '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VEVENT', 'VJOURNAL', 'VTODO']),
+ ];
+ }
+
+ public function calendarQuery(array $filters) {
+ $result = [];
+ $objects = $this->getChildren();
+
+ foreach ($objects as $object) {
+ if ($this->validateFilterForObject($object, $filters)) {
+ $result[] = $object->getName();
+ }
+ }
+
+ return $result;
+ }
+
+ protected function validateFilterForObject(ICalendarObject $object, array $filters): bool {
+ /** @var \Sabre\VObject\Component\VCalendar */
+ $vObject = Reader::read($object->get());
+
+ $validator = new CalendarQueryValidator();
+ $result = $validator->validate($vObject, $filters);
+
+ // Destroy circular references so PHP will GC the object.
+ $vObject->destroy();
+
+ return $result;
+ }
+
+ public function childExists($name): bool {
+ try {
+ $this->getChild($name);
+ return true;
+ } catch (NotFound $error) {
+ return false;
+ }
+ }
+
+ public function getChild($name) {
+ // Try to get calendar by filename
+ $children = $this->calendar->search($name, ['X-FILENAME']);
+ if (count($children) === 0) {
+ // If nothing found try to get by UID from filename
+ $pos = strrpos($name, '.ics');
+ $children = $this->calendar->search(substr($name, 0, $pos ?: null), ['UID']);
+ }
+
+ if (count($children) > 0) {
+ return new CalendarObject($this, $this->calendar, new VCalendar($children));
+ }
+
+ throw new NotFound('Node not found');
+ }
+
+ /**
+ * @return ICalendarObject[]
+ */
+ public function getChildren(): array {
+ $objects = $this->calendar->search('');
+ // We need to group by UID (actually by filename but we do not have that information)
+ $result = [];
+ foreach ($objects as $object) {
+ $uid = (string)$object['UID'] ?: uniqid();
+ if (!isset($result[$uid])) {
+ $result[$uid] = [];
+ }
+ $result[$uid][] = $object;
+ }
+
+ return array_map(function (array $children) {
+ return new CalendarObject($this, $this->calendar, new VCalendar($children));
+ }, $result);
+ }
+
+ public function propPatch(PropPatch $propPatch): void {
+ // no setDisplayColor or setDisplayName in \OCP\Calendar\ICalendar
+ }
+}
diff --git a/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php b/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php
new file mode 100644
index 00000000000..72f2ed2c163
--- /dev/null
+++ b/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV\AppCalendar;
+
+use OCA\DAV\CalDAV\CachedSubscriptionImpl;
+use OCA\DAV\CalDAV\CalendarImpl;
+use OCA\DAV\CalDAV\Integration\ExternalCalendar;
+use OCA\DAV\CalDAV\Integration\ICalendarProvider;
+use OCP\Calendar\IManager;
+use Psr\Log\LoggerInterface;
+
+/* Plugin for wrapping application generated calendars registered in nextcloud core (OCP\Calendar\ICalendarProvider) */
+class AppCalendarPlugin implements ICalendarProvider {
+ public function __construct(
+ protected IManager $manager,
+ protected LoggerInterface $logger,
+ ) {
+ }
+
+ public function getAppID(): string {
+ return 'dav-wrapper';
+ }
+
+ public function fetchAllForCalendarHome(string $principalUri): array {
+ return array_map(function ($calendar) use (&$principalUri) {
+ return new AppCalendar($this->getAppID(), $calendar, $principalUri);
+ }, $this->getWrappedCalendars($principalUri));
+ }
+
+ public function hasCalendarInCalendarHome(string $principalUri, string $calendarUri): bool {
+ return count($this->getWrappedCalendars($principalUri, [ $calendarUri ])) > 0;
+ }
+
+ public function getCalendarInCalendarHome(string $principalUri, string $calendarUri): ?ExternalCalendar {
+ $calendars = $this->getWrappedCalendars($principalUri, [ $calendarUri ]);
+ if (count($calendars) > 0) {
+ return new AppCalendar($this->getAppID(), $calendars[0], $principalUri);
+ }
+
+ return null;
+ }
+
+ protected function getWrappedCalendars(string $principalUri, array $calendarUris = []): array {
+ return array_values(
+ array_filter($this->manager->getCalendarsForPrincipal($principalUri, $calendarUris), function ($c) {
+ // We must not provide a wrapper for DAV calendars
+ return ! (($c instanceof CalendarImpl) || ($c instanceof CachedSubscriptionImpl));
+ })
+ );
+ }
+}
diff --git a/apps/dav/lib/CalDAV/AppCalendar/CalendarObject.php b/apps/dav/lib/CalDAV/AppCalendar/CalendarObject.php
new file mode 100644
index 00000000000..3c62a26df54
--- /dev/null
+++ b/apps/dav/lib/CalDAV/AppCalendar/CalendarObject.php
@@ -0,0 +1,134 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV\AppCalendar;
+
+use OCP\Calendar\ICalendar;
+use OCP\Calendar\ICreateFromString;
+use OCP\Constants;
+use Sabre\CalDAV\ICalendarObject;
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAV\Exception\NotFound;
+use Sabre\DAVACL\IACL;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Property\ICalendar\DateTime;
+
+class CalendarObject implements ICalendarObject, IACL {
+ public function __construct(
+ private AppCalendar $calendar,
+ private ICalendar|ICreateFromString $backend,
+ private VCalendar $vobject,
+ ) {
+ }
+
+ public function getOwner() {
+ return $this->calendar->getOwner();
+ }
+
+ public function getGroup() {
+ return $this->calendar->getGroup();
+ }
+
+ public function getACL(): array {
+ $acl = [
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ]
+ ];
+ if ($this->calendar->getPermissions() & Constants::PERMISSION_UPDATE) {
+ $acl[] = [
+ 'privilege' => '{DAV:}write-content',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ];
+ }
+ return $acl;
+ }
+
+ public function setACL(array $acl): void {
+ throw new Forbidden('Setting ACL is not supported on this node');
+ }
+
+ public function getSupportedPrivilegeSet(): ?array {
+ return null;
+ }
+
+ public function put($data): void {
+ if ($this->backend instanceof ICreateFromString && $this->calendar->getPermissions() & Constants::PERMISSION_UPDATE) {
+ if (is_resource($data)) {
+ $data = stream_get_contents($data) ?: '';
+ }
+ $this->backend->createFromString($this->getName(), $data);
+ } else {
+ throw new Forbidden('This calendar-object is read-only');
+ }
+ }
+
+ public function get(): string {
+ return $this->vobject->serialize();
+ }
+
+ public function getContentType(): string {
+ return 'text/calendar; charset=utf-8';
+ }
+
+ public function getETag(): ?string {
+ return null;
+ }
+
+ public function getSize() {
+ return mb_strlen($this->vobject->serialize());
+ }
+
+ public function delete(): void {
+ if ($this->backend instanceof ICreateFromString && $this->calendar->getPermissions() & Constants::PERMISSION_DELETE) {
+ /** @var \Sabre\VObject\Component[] */
+ $components = $this->vobject->getBaseComponents();
+ foreach ($components as $key => $component) {
+ $components[$key]->STATUS = 'CANCELLED';
+ $components[$key]->SEQUENCE = isset($component->SEQUENCE) ? ((int)$component->SEQUENCE->getValue()) + 1 : 1;
+ if ($component->name === 'VEVENT') {
+ $components[$key]->METHOD = 'CANCEL';
+ }
+ }
+ $this->backend->createFromString($this->getName(), (new VCalendar($components))->serialize());
+ } else {
+ throw new Forbidden('This calendar-object is read-only');
+ }
+ }
+
+ public function getName(): string {
+ // Every object is required to have an UID
+ $base = $this->vobject->getBaseComponent();
+ // This should never happen except the app provides invalid calendars (VEvent, VTodo... all require UID to be present)
+ if ($base === null) {
+ throw new NotFound('Invalid node');
+ }
+ if (isset($base->{'X-FILENAME'})) {
+ return (string)$base->{'X-FILENAME'};
+ }
+ return (string)$base->UID . '.ics';
+ }
+
+ public function setName($name): void {
+ throw new Forbidden('This calendar-object is read-only');
+ }
+
+ public function getLastModified(): ?int {
+ $base = $this->vobject->getBaseComponent();
+ if ($base !== null && $this->vobject->getBaseComponent()->{'LAST-MODIFIED'}) {
+ /** @var DateTime */
+ $lastModified = $this->vobject->getBaseComponent()->{'LAST-MODIFIED'};
+ return $lastModified->getDateTime()->getTimestamp();
+ }
+ return null;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php b/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php
new file mode 100644
index 00000000000..71b9acb939b
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV\Auth;
+
+use Sabre\DAV\Auth\Plugin;
+
+/**
+ * Set a custom principal uri to allow public requests to its calendar
+ */
+class CustomPrincipalPlugin extends Plugin {
+ public function setCurrentPrincipal(?string $currentPrincipal): void {
+ $this->currentPrincipal = $currentPrincipal;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php b/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php
new file mode 100644
index 00000000000..ed89638451e
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php
@@ -0,0 +1,20 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Auth;
+
+use Sabre\DAV\Auth\Plugin;
+
+/**
+ * Defines the public facing principal option
+ */
+class PublicPrincipalPlugin extends Plugin {
+ public function getCurrentPrincipal(): ?string {
+ return 'principals/system/public';
+ }
+}
diff --git a/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php b/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php
index 497d7112b3c..681709cdb6f 100644
--- a/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php
+++ b/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php
@@ -1,35 +1,20 @@
<?php
+
/**
- * @copyright 2017, Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\BirthdayCalendar;
use OCA\DAV\CalDAV\BirthdayService;
use OCA\DAV\CalDAV\CalendarHome;
+use OCP\AppFramework\Http;
+use OCP\IConfig;
+use OCP\IUser;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
-use OCP\IConfig;
/**
* Class EnablePlugin
@@ -38,17 +23,7 @@ use OCP\IConfig;
* @package OCA\DAV\CalDAV\BirthdayCalendar
*/
class EnablePlugin extends ServerPlugin {
- const NS_Nextcloud = 'http://nextcloud.com/ns';
-
- /**
- * @var IConfig
- */
- protected $config;
-
- /**
- * @var BirthdayService
- */
- protected $birthdayService;
+ public const NS_Nextcloud = 'http://nextcloud.com/ns';
/**
* @var Server
@@ -60,10 +35,13 @@ class EnablePlugin extends ServerPlugin {
*
* @param IConfig $config
* @param BirthdayService $birthdayService
+ * @param IUser $user
*/
- public function __construct(IConfig $config, BirthdayService $birthdayService) {
- $this->config = $config;
- $this->birthdayService = $birthdayService;
+ public function __construct(
+ protected IConfig $config,
+ protected BirthdayService $birthdayService,
+ private IUser $user,
+ ) {
}
/**
@@ -86,7 +64,7 @@ class EnablePlugin extends ServerPlugin {
*
* @return string
*/
- public function getPluginName() {
+ public function getPluginName() {
return 'nc-enable-birthday-calendar';
}
@@ -116,23 +94,26 @@ class EnablePlugin extends ServerPlugin {
*/
public function httpPost(RequestInterface $request, ResponseInterface $response) {
$node = $this->server->tree->getNodeForPath($this->server->getRequestUri());
- if (!($node instanceof CalendarHome)) {
+ if (!$node instanceof CalendarHome) {
return;
}
$requestBody = $request->getBodyAsString();
$this->server->xml->parse($requestBody, $request->getUrl(), $documentType);
- if ($documentType !== '{'.self::NS_Nextcloud.'}enable-birthday-calendar') {
+ if ($documentType !== '{' . self::NS_Nextcloud . '}enable-birthday-calendar') {
return;
}
- $principalUri = $node->getOwner();
- $userId = substr($principalUri, 17);
+ $owner = substr($node->getOwner(), 17);
+ if ($owner !== $this->user->getUID()) {
+ $this->server->httpResponse->setStatus(Http::STATUS_FORBIDDEN);
+ return false;
+ }
- $this->config->setUserValue($userId, 'dav', 'generateBirthdayCalendar', 'yes');
- $this->birthdayService->syncUser($userId);
+ $this->config->setUserValue($this->user->getUID(), 'dav', 'generateBirthdayCalendar', 'yes');
+ $this->birthdayService->syncUser($this->user->getUID());
- $this->server->httpResponse->setStatus(204);
+ $this->server->httpResponse->setStatus(Http::STATUS_NO_CONTENT);
return false;
}
diff --git a/apps/dav/lib/CalDAV/BirthdayService.php b/apps/dav/lib/CalDAV/BirthdayService.php
index 62d218f0a2a..680b228766f 100644
--- a/apps/dav/lib/CalDAV/BirthdayService.php
+++ b/apps/dav/lib/CalDAV/BirthdayService.php
@@ -1,37 +1,20 @@
<?php
+
+declare(strict_types=1);
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @copyright Copyright (c) 2016, Georg Ehrke
- *
- * @author Achim Königs <garfonso@tratschtante.de>
- * @author Georg Ehrke <oc.list@georgehrke.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
-
namespace OCA\DAV\CalDAV;
use Exception;
use OCA\DAV\CardDAV\CardDavBackend;
use OCA\DAV\DAV\GroupPrincipalBackend;
use OCP\IConfig;
+use OCP\IDBConnection;
+use OCP\IL10N;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VCard;
use Sabre\VObject\DateTimeParser;
@@ -40,72 +23,66 @@ use Sabre\VObject\InvalidDataException;
use Sabre\VObject\Property\VCard\DateAndOrTime;
use Sabre\VObject\Reader;
+/**
+ * Class BirthdayService
+ *
+ * @package OCA\DAV\CalDAV
+ */
class BirthdayService {
-
- const BIRTHDAY_CALENDAR_URI = 'contact_birthdays';
-
- /** @var GroupPrincipalBackend */
- private $principalBackend;
-
- /** @var CalDavBackend */
- private $calDavBackEnd;
-
- /** @var CardDavBackend */
- private $cardDavBackEnd;
-
- /** @var IConfig */
- private $config;
+ public const BIRTHDAY_CALENDAR_URI = 'contact_birthdays';
+ public const EXCLUDE_FROM_BIRTHDAY_CALENDAR_PROPERTY_NAME = 'X-NC-EXCLUDE-FROM-BIRTHDAY-CALENDAR';
/**
* BirthdayService constructor.
- *
- * @param CalDavBackend $calDavBackEnd
- * @param CardDavBackend $cardDavBackEnd
- * @param GroupPrincipalBackend $principalBackend
- * @param IConfig $config;
*/
- public function __construct(CalDavBackend $calDavBackEnd, CardDavBackend $cardDavBackEnd, GroupPrincipalBackend $principalBackend, IConfig $config) {
- $this->calDavBackEnd = $calDavBackEnd;
- $this->cardDavBackEnd = $cardDavBackEnd;
- $this->principalBackend = $principalBackend;
- $this->config = $config;
+ public function __construct(
+ private CalDavBackend $calDavBackEnd,
+ private CardDavBackend $cardDavBackEnd,
+ private GroupPrincipalBackend $principalBackend,
+ private IConfig $config,
+ private IDBConnection $dbConnection,
+ private IL10N $l10n,
+ ) {
}
- /**
- * @param int $addressBookId
- * @param string $cardUri
- * @param string $cardData
- */
- public function onCardChanged($addressBookId, $cardUri, $cardData) {
+ public function onCardChanged(int $addressBookId,
+ string $cardUri,
+ string $cardData): void {
if (!$this->isGloballyEnabled()) {
return;
}
$targetPrincipals = $this->getAllAffectedPrincipals($addressBookId);
$book = $this->cardDavBackEnd->getAddressBookById($addressBookId);
+ if ($book === null) {
+ return;
+ }
$targetPrincipals[] = $book['principaluri'];
$datesToSync = [
- ['postfix' => '', 'field' => 'BDAY', 'symbol' => '*'],
- ['postfix' => '-death', 'field' => 'DEATHDATE', 'symbol' => "†"],
- ['postfix' => '-anniversary', 'field' => 'ANNIVERSARY', 'symbol' => "⚭"],
+ ['postfix' => '', 'field' => 'BDAY'],
+ ['postfix' => '-death', 'field' => 'DEATHDATE'],
+ ['postfix' => '-anniversary', 'field' => 'ANNIVERSARY'],
];
+
foreach ($targetPrincipals as $principalUri) {
if (!$this->isUserEnabled($principalUri)) {
continue;
}
+ $reminderOffset = $this->getReminderOffsetForUser($principalUri);
+
$calendar = $this->ensureCalendarExists($principalUri);
+ if ($calendar === null) {
+ return;
+ }
foreach ($datesToSync as $type) {
- $this->updateCalendar($cardUri, $cardData, $book, $calendar['id'], $type);
+ $this->updateCalendar($cardUri, $cardData, $book, (int)$calendar['id'], $type, $reminderOffset);
}
}
}
- /**
- * @param int $addressBookId
- * @param string $cardUri
- */
- public function onCardDeleted($addressBookId, $cardUri) {
+ public function onCardDeleted(int $addressBookId,
+ string $cardUri): void {
if (!$this->isGloballyEnabled()) {
return;
}
@@ -120,38 +97,41 @@ class BirthdayService {
$calendar = $this->ensureCalendarExists($principalUri);
foreach (['', '-death', '-anniversary'] as $tag) {
- $objectUri = $book['uri'] . '-' . $cardUri . $tag .'.ics';
- $this->calDavBackEnd->deleteCalendarObject($calendar['id'], $objectUri);
+ $objectUri = $book['uri'] . '-' . $cardUri . $tag . '.ics';
+ $this->calDavBackEnd->deleteCalendarObject($calendar['id'], $objectUri, CalDavBackend::CALENDAR_TYPE_CALENDAR, true);
}
}
}
/**
- * @param string $principal
- * @return array|null
* @throws \Sabre\DAV\Exception\BadRequest
*/
- public function ensureCalendarExists($principal) {
- $book = $this->calDavBackEnd->getCalendarByUri($principal, self::BIRTHDAY_CALENDAR_URI);
- if (!is_null($book)) {
- return $book;
+ public function ensureCalendarExists(string $principal): ?array {
+ $calendar = $this->calDavBackEnd->getCalendarByUri($principal, self::BIRTHDAY_CALENDAR_URI);
+ if (!is_null($calendar)) {
+ return $calendar;
}
$this->calDavBackEnd->createCalendar($principal, self::BIRTHDAY_CALENDAR_URI, [
- '{DAV:}displayname' => 'Contact birthdays',
- '{http://apple.com/ns/ical/}calendar-color' => '#FFFFCA',
- 'components' => 'VEVENT',
+ '{DAV:}displayname' => $this->l10n->t('Contact birthdays'),
+ '{http://apple.com/ns/ical/}calendar-color' => '#E9D859',
+ 'components' => 'VEVENT',
]);
return $this->calDavBackEnd->getCalendarByUri($principal, self::BIRTHDAY_CALENDAR_URI);
}
/**
- * @param string $cardData
- * @param string $dateField
- * @param string $summarySymbol
- * @return null|VCalendar
+ * @param $cardData
+ * @param $dateField
+ * @param $postfix
+ * @param $reminderOffset
+ * @return VCalendar|null
+ * @throws InvalidDataException
*/
- public function buildDateFromContact($cardData, $dateField, $summarySymbol) {
+ public function buildDateFromContact(string $cardData,
+ string $dateField,
+ string $postfix,
+ ?string $reminderOffset):?VCalendar {
if (empty($cardData)) {
return null;
}
@@ -167,6 +147,10 @@ class BirthdayService {
return null;
}
+ if (isset($doc->{self::EXCLUDE_FROM_BIRTHDAY_CALENDAR_PROPERTY_NAME})) {
+ return null;
+ }
+
if (!isset($doc->{$dateField})) {
return null;
}
@@ -188,27 +172,43 @@ class BirthdayService {
} catch (InvalidDataException $e) {
return null;
}
+ if ($dateParts['year'] !== null) {
+ $parameters = $birthday->parameters();
+ $omitYear = (isset($parameters['X-APPLE-OMIT-YEAR'])
+ && $parameters['X-APPLE-OMIT-YEAR'] === $dateParts['year']);
+ // 'X-APPLE-OMIT-YEAR' is not always present, at least iOS 12.4 uses the hard coded date of 1604 (the start of the gregorian calendar) when the year is unknown
+ if ($omitYear || (int)$dateParts['year'] === 1604) {
+ $dateParts['year'] = null;
+ }
+ }
- $unknownYear = false;
- if (!$dateParts['year']) {
- $birthday = '1900-' . $dateParts['month'] . '-' . $dateParts['date'];
+ $originalYear = null;
+ if ($dateParts['year'] !== null) {
+ $originalYear = (int)$dateParts['year'];
+ }
- $unknownYear = true;
+ $leapDay = ((int)$dateParts['month'] === 2
+ && (int)$dateParts['date'] === 29);
+ if ($dateParts['year'] === null || $originalYear < 1970) {
+ $birthday = ($leapDay ? '1972-' : '1970-')
+ . $dateParts['month'] . '-' . $dateParts['date'];
}
try {
- $date = new \DateTime($birthday);
+ if ($birthday instanceof DateAndOrTime) {
+ $date = $birthday->getDateTime();
+ } else {
+ $date = new \DateTimeImmutable($birthday);
+ }
} catch (Exception $e) {
return null;
}
- if ($unknownYear) {
- $summary = $doc->FN->getValue() . ' ' . $summarySymbol;
- } else {
- $year = (int)$date->format('Y');
- $summary = $doc->FN->getValue() . " ($summarySymbol$year)";
- }
+
+ $summary = $this->formatTitle($dateField, $doc->FN->getValue(), $originalYear, $this->dbConnection->supports4ByteText());
+
$vCal = new VCalendar();
$vCal->VERSION = '2.0';
+ $vCal->PRODID = '-//IDN nextcloud.com//Birthday calendar//EN';
$vEvent = $vCal->createComponent('VEVENT');
$vEvent->add('DTSTART');
$vEvent->DTSTART->setDateTime(
@@ -216,20 +216,35 @@ class BirthdayService {
);
$vEvent->DTSTART['VALUE'] = 'DATE';
$vEvent->add('DTEND');
- $date->add(new \DateInterval('P1D'));
+
+ $dtEndDate = (new \DateTime())->setTimestamp($date->getTimeStamp());
+ $dtEndDate->add(new \DateInterval('P1D'));
$vEvent->DTEND->setDateTime(
- $date
+ $dtEndDate
);
+
$vEvent->DTEND['VALUE'] = 'DATE';
- $vEvent->{'UID'} = $doc->UID;
+ $vEvent->{'UID'} = $doc->UID . $postfix;
$vEvent->{'RRULE'} = 'FREQ=YEARLY';
+ if ($leapDay) {
+ /* Sabre\VObject supports BYMONTHDAY only if BYMONTH
+ * is also set */
+ $vEvent->{'RRULE'} = 'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1';
+ }
$vEvent->{'SUMMARY'} = $summary;
$vEvent->{'TRANSP'} = 'TRANSPARENT';
- $alarm = $vCal->createComponent('VALARM');
- $alarm->add($vCal->createProperty('TRIGGER', '-PT0M', ['VALUE' => 'DURATION']));
- $alarm->add($vCal->createProperty('ACTION', 'DISPLAY'));
- $alarm->add($vCal->createProperty('DESCRIPTION', $vEvent->{'SUMMARY'}));
- $vEvent->add($alarm);
+ $vEvent->{'X-NEXTCLOUD-BC-FIELD-TYPE'} = $dateField;
+ $vEvent->{'X-NEXTCLOUD-BC-UNKNOWN-YEAR'} = $dateParts['year'] === null ? '1' : '0';
+ if ($originalYear !== null) {
+ $vEvent->{'X-NEXTCLOUD-BC-YEAR'} = (string)$originalYear;
+ }
+ if ($reminderOffset) {
+ $alarm = $vCal->createComponent('VALARM');
+ $alarm->add($vCal->createProperty('TRIGGER', $reminderOffset, ['VALUE' => 'DURATION']));
+ $alarm->add($vCal->createProperty('ACTION', 'DISPLAY'));
+ $alarm->add($vCal->createProperty('DESCRIPTION', $vEvent->{'SUMMARY'}));
+ $vEvent->add($alarm);
+ }
$vCal->add($vEvent);
return $vCal;
}
@@ -237,14 +252,31 @@ class BirthdayService {
/**
* @param string $user
*/
- public function syncUser($user) {
- $principal = 'principals/users/'.$user;
+ public function resetForUser(string $user):void {
+ $principal = 'principals/users/' . $user;
+ $calendar = $this->calDavBackEnd->getCalendarByUri($principal, self::BIRTHDAY_CALENDAR_URI);
+ if (!$calendar) {
+ return; // The user's birthday calendar doesn't exist, no need to purge it
+ }
+ $calendarObjects = $this->calDavBackEnd->getCalendarObjects($calendar['id'], CalDavBackend::CALENDAR_TYPE_CALENDAR);
+
+ foreach ($calendarObjects as $calendarObject) {
+ $this->calDavBackEnd->deleteCalendarObject($calendar['id'], $calendarObject['uri'], CalDavBackend::CALENDAR_TYPE_CALENDAR, true);
+ }
+ }
+
+ /**
+ * @param string $user
+ * @throws \Sabre\DAV\Exception\BadRequest
+ */
+ public function syncUser(string $user):void {
+ $principal = 'principals/users/' . $user;
$this->ensureCalendarExists($principal);
$books = $this->cardDavBackEnd->getAddressBooksForUser($principal);
- foreach($books as $book) {
+ foreach ($books as $book) {
$cards = $this->cardDavBackEnd->getCards($book['id']);
- foreach($cards as $card) {
- $this->onCardChanged($book['id'], $card['uri'], $card['carddata']);
+ foreach ($cards as $card) {
+ $this->onCardChanged((int)$book['id'], $card['uri'], $card['carddata']);
}
}
}
@@ -254,25 +286,25 @@ class BirthdayService {
* @param VCalendar $newCalendarData
* @return bool
*/
- public function birthdayEvenChanged($existingCalendarData, $newCalendarData) {
+ public function birthdayEvenChanged(string $existingCalendarData,
+ VCalendar $newCalendarData):bool {
try {
$existingBirthday = Reader::read($existingCalendarData);
} catch (Exception $ex) {
return true;
}
- if ($newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue() ||
- $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue()
- ) {
- return true;
- }
- return false;
+
+ return (
+ $newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue()
+ || $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue()
+ );
}
/**
* @param integer $addressBookId
* @return mixed
*/
- protected function getAllAffectedPrincipals($addressBookId) {
+ protected function getAllAffectedPrincipals(int $addressBookId) {
$targetPrincipals = [];
$shares = $this->cardDavBackEnd->getShares($addressBookId);
foreach ($shares as $share) {
@@ -290,21 +322,43 @@ class BirthdayService {
/**
* @param string $cardUri
- * @param string $cardData
+ * @param string $cardData
* @param array $book
* @param int $calendarId
- * @param string[] $type
+ * @param array $type
+ * @param string $reminderOffset
+ * @throws InvalidDataException
+ * @throws \Sabre\DAV\Exception\BadRequest
*/
- private function updateCalendar($cardUri, $cardData, $book, $calendarId, $type) {
+ private function updateCalendar(string $cardUri,
+ string $cardData,
+ array $book,
+ int $calendarId,
+ array $type,
+ ?string $reminderOffset):void {
$objectUri = $book['uri'] . '-' . $cardUri . $type['postfix'] . '.ics';
- $calendarData = $this->buildDateFromContact($cardData, $type['field'], $type['symbol']);
+ $calendarData = $this->buildDateFromContact($cardData, $type['field'], $type['postfix'], $reminderOffset);
$existing = $this->calDavBackEnd->getCalendarObject($calendarId, $objectUri);
- if (is_null($calendarData)) {
- if (!is_null($existing)) {
- $this->calDavBackEnd->deleteCalendarObject($calendarId, $objectUri);
+ if ($calendarData === null) {
+ if ($existing !== null) {
+ $this->calDavBackEnd->deleteCalendarObject($calendarId, $objectUri, CalDavBackend::CALENDAR_TYPE_CALENDAR, true);
}
} else {
- if (is_null($existing)) {
+ if ($existing === null) {
+ // not found by URI, but maybe by UID
+ // happens when a contact with birthday is moved to a different address book
+ $calendarInfo = $this->calDavBackEnd->getCalendarById($calendarId);
+ $extraData = $this->calDavBackEnd->getDenormalizedData($calendarData->serialize());
+
+ if ($calendarInfo && array_key_exists('principaluri', $calendarInfo)) {
+ $existing2path = $this->calDavBackEnd->getCalendarObjectByUID($calendarInfo['principaluri'], $extraData['uid']);
+ if ($existing2path !== null && array_key_exists('uri', $calendarInfo)) {
+ // delete the old birthday entry first so that we do not get duplicate UIDs
+ $existing2objectUri = substr($existing2path, strlen($calendarInfo['uri']) + 1);
+ $this->calDavBackEnd->deleteCalendarObject($calendarId, $existing2objectUri, CalDavBackend::CALENDAR_TYPE_CALENDAR, true);
+ }
+ }
+
$this->calDavBackEnd->createCalendarObject($calendarId, $objectUri, $calendarData->serialize());
} else {
if ($this->birthdayEvenChanged($existing['calendardata'], $calendarData)) {
@@ -319,20 +373,32 @@ class BirthdayService {
*
* @return bool
*/
- private function isGloballyEnabled() {
- $isGloballyEnabled = $this->config->getAppValue('dav', 'generateBirthdayCalendar', 'yes');
- return $isGloballyEnabled === 'yes';
+ private function isGloballyEnabled():bool {
+ return $this->config->getAppValue('dav', 'generateBirthdayCalendar', 'yes') === 'yes';
}
/**
- * checks if the user opted-out of birthday calendars
+ * Extracts the userId part of a principal
*
- * @param $userPrincipal
+ * @param string $userPrincipal
+ * @return string|null
+ */
+ private function principalToUserId(string $userPrincipal):?string {
+ if (str_starts_with($userPrincipal, 'principals/users/')) {
+ return substr($userPrincipal, 17);
+ }
+ return null;
+ }
+
+ /**
+ * Checks if the user opted-out of birthday calendars
+ *
+ * @param string $userPrincipal The user principal to check for
* @return bool
*/
- private function isUserEnabled($userPrincipal) {
- if (strpos($userPrincipal, 'principals/users/') === 0) {
- $userId = substr($userPrincipal, 17);
+ private function isUserEnabled(string $userPrincipal):bool {
+ $userId = $this->principalToUserId($userPrincipal);
+ if ($userId !== null) {
$isEnabled = $this->config->getUserValue($userId, 'dav', 'generateBirthdayCalendar', 'yes');
return $isEnabled === 'yes';
}
@@ -341,4 +407,86 @@ class BirthdayService {
return true;
}
+ /**
+ * Get the reminder offset value for a user. This is a duration string (e.g.
+ * PT9H) or null if no reminder is wanted.
+ *
+ * @param string $userPrincipal
+ * @return string|null
+ */
+ private function getReminderOffsetForUser(string $userPrincipal):?string {
+ $userId = $this->principalToUserId($userPrincipal);
+ if ($userId !== null) {
+ return $this->config->getUserValue($userId, 'dav', 'birthdayCalendarReminderOffset', 'PT9H') ?: null;
+ }
+
+ // not sure how we got here, just be on the safe side and return the default value
+ return 'PT9H';
+ }
+
+ /**
+ * Formats title of Birthday event
+ *
+ * @param string $field Field name like BDAY, ANNIVERSARY, ...
+ * @param string $name Name of contact
+ * @param int|null $year Year of birth, anniversary, ...
+ * @param bool $supports4Byte Whether or not the database supports 4 byte chars
+ * @return string The formatted title
+ */
+ private function formatTitle(string $field,
+ string $name,
+ ?int $year = null,
+ bool $supports4Byte = true):string {
+ if ($supports4Byte) {
+ switch ($field) {
+ case 'BDAY':
+ return implode('', [
+ '🎂 ',
+ $name,
+ $year ? (' (' . $year . ')') : '',
+ ]);
+
+ case 'DEATHDATE':
+ return implode('', [
+ $this->l10n->t('Death of %s', [$name]),
+ $year ? (' (' . $year . ')') : '',
+ ]);
+
+ case 'ANNIVERSARY':
+ return implode('', [
+ '💍 ',
+ $name,
+ $year ? (' (' . $year . ')') : '',
+ ]);
+
+ default:
+ return '';
+ }
+ } else {
+ switch ($field) {
+ case 'BDAY':
+ return implode('', [
+ $name,
+ ' ',
+ $year ? ('(*' . $year . ')') : '*',
+ ]);
+
+ case 'DEATHDATE':
+ return implode('', [
+ $this->l10n->t('Death of %s', [$name]),
+ $year ? (' (' . $year . ')') : '',
+ ]);
+
+ case 'ANNIVERSARY':
+ return implode('', [
+ $name,
+ ' ',
+ $year ? ('(⚭' . $year . ')') : '⚭',
+ ]);
+
+ default:
+ return '';
+ }
+ }
+ }
}
diff --git a/apps/dav/lib/CalDAV/CachedSubscription.php b/apps/dav/lib/CalDAV/CachedSubscription.php
new file mode 100644
index 00000000000..75ee5cb440f
--- /dev/null
+++ b/apps/dav/lib/CalDAV/CachedSubscription.php
@@ -0,0 +1,196 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV;
+
+use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException;
+use Sabre\DAV\Exception\MethodNotAllowed;
+use Sabre\DAV\Exception\NotFound;
+use Sabre\DAV\INode;
+use Sabre\DAV\PropPatch;
+
+/**
+ * Class CachedSubscription
+ *
+ * @package OCA\DAV\CalDAV
+ * @property CalDavBackend $caldavBackend
+ */
+class CachedSubscription extends \Sabre\CalDAV\Calendar {
+
+ /**
+ * @return string
+ */
+ public function getPrincipalURI():string {
+ return $this->calendarInfo['principaluri'];
+ }
+
+ /**
+ * @return array
+ */
+ public function getACL() {
+ return [
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner() . '/calendar-proxy-write',
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner() . '/calendar-proxy-read',
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{' . Plugin::NS_CALDAV . '}read-free-busy',
+ 'principal' => '{DAV:}authenticated',
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{DAV:}write-properties',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ]
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function getChildACL() {
+ return [
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ],
+
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner() . '/calendar-proxy-write',
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner() . '/calendar-proxy-read',
+ 'protected' => true,
+ ],
+ ];
+ }
+
+ /**
+ * @return null|string
+ */
+ public function getOwner() {
+ if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) {
+ return $this->calendarInfo['{http://owncloud.org/ns}owner-principal'];
+ }
+ return parent::getOwner();
+ }
+
+
+ public function delete() {
+ $this->caldavBackend->deleteSubscription($this->calendarInfo['id']);
+ }
+
+ /**
+ * @param PropPatch $propPatch
+ */
+ public function propPatch(PropPatch $propPatch) {
+ $this->caldavBackend->updateSubscription($this->calendarInfo['id'], $propPatch);
+ }
+
+ /**
+ * @param string $name
+ * @return CalendarObject|\Sabre\CalDAV\ICalendarObject
+ * @throws NotFound
+ */
+ public function getChild($name) {
+ $obj = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $name, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
+ if (!$obj) {
+ throw new NotFound('Calendar object not found');
+ }
+
+ $obj['acl'] = $this->getChildACL();
+ return new CachedSubscriptionObject($this->caldavBackend, $this->calendarInfo, $obj);
+ }
+
+ /**
+ * @return INode[]
+ */
+ public function getChildren(): array {
+ $objs = $this->caldavBackend->getCalendarObjects($this->calendarInfo['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
+
+ $children = [];
+ foreach ($objs as $obj) {
+ $children[] = new CachedSubscriptionObject($this->caldavBackend, $this->calendarInfo, $obj);
+ }
+
+ return $children;
+ }
+
+ /**
+ * @param array $paths
+ * @return array
+ */
+ public function getMultipleChildren(array $paths):array {
+ $objs = $this->caldavBackend->getMultipleCalendarObjects($this->calendarInfo['id'], $paths, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
+
+ $children = [];
+ foreach ($objs as $obj) {
+ $children[] = new CachedSubscriptionObject($this->caldavBackend, $this->calendarInfo, $obj);
+ }
+
+ return $children;
+ }
+
+ /**
+ * @param string $name
+ * @param null|resource|string $data
+ * @return null|string
+ * @throws MethodNotAllowed
+ */
+ public function createFile($name, $data = null) {
+ throw new MethodNotAllowed('Creating objects in cached subscription is not allowed');
+ }
+
+ /**
+ * @param string $name
+ * @return bool
+ */
+ public function childExists($name):bool {
+ $obj = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $name, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
+ if (!$obj) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @param array $filters
+ * @return array
+ */
+ public function calendarQuery(array $filters):array {
+ return $this->caldavBackend->calendarQuery($this->calendarInfo['id'], $filters, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getChanges($syncToken, $syncLevel, $limit = null) {
+ if (!$syncToken && $limit) {
+ throw new UnsupportedLimitOnInitialSyncException();
+ }
+
+ return parent::getChanges($syncToken, $syncLevel, $limit);
+ }
+}
diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php
new file mode 100644
index 00000000000..cc1bab6d4fc
--- /dev/null
+++ b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php
@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV;
+
+use OCP\Calendar\ICalendar;
+use OCP\Calendar\ICalendarIsEnabled;
+use OCP\Calendar\ICalendarIsShared;
+use OCP\Calendar\ICalendarIsWritable;
+use OCP\Constants;
+
+class CachedSubscriptionImpl implements ICalendar, ICalendarIsEnabled, ICalendarIsShared, ICalendarIsWritable {
+
+ public function __construct(
+ private CachedSubscription $calendar,
+ /** @var array<string, mixed> */
+ private array $calendarInfo,
+ private CalDavBackend $backend,
+ ) {
+ }
+
+ /**
+ * @return string defining the technical unique key
+ * @since 13.0.0
+ */
+ public function getKey(): string {
+ return (string)$this->calendarInfo['id'];
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getUri(): string {
+ return $this->calendarInfo['uri'];
+ }
+
+ /**
+ * In comparison to getKey() this function returns a human readable (maybe translated) name
+ * @since 13.0.0
+ */
+ public function getDisplayName(): ?string {
+ return $this->calendarInfo['{DAV:}displayname'];
+ }
+
+ /**
+ * Calendar color
+ * @since 13.0.0
+ */
+ public function getDisplayColor(): ?string {
+ return $this->calendarInfo['{http://apple.com/ns/ical/}calendar-color'];
+ }
+
+ public function search(string $pattern, array $searchProperties = [], array $options = [], $limit = null, $offset = null): array {
+ return $this->backend->search($this->calendarInfo, $pattern, $searchProperties, $options, $limit, $offset);
+ }
+
+ /**
+ * @return int build up using \OCP\Constants
+ * @since 13.0.0
+ */
+ public function getPermissions(): int {
+ $permissions = $this->calendar->getACL();
+ $result = 0;
+ foreach ($permissions as $permission) {
+ switch ($permission['privilege']) {
+ case '{DAV:}read':
+ $result |= Constants::PERMISSION_READ;
+ break;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * @since 32.0.0
+ */
+ public function isEnabled(): bool {
+ return $this->calendarInfo['{http://owncloud.org/ns}calendar-enabled'] ?? true;
+ }
+
+ public function isWritable(): bool {
+ return false;
+ }
+
+ public function isDeleted(): bool {
+ return false;
+ }
+
+ public function isShared(): bool {
+ return true;
+ }
+
+ public function getSource(): string {
+ return $this->calendarInfo['source'];
+ }
+}
diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionObject.php b/apps/dav/lib/CalDAV/CachedSubscriptionObject.php
new file mode 100644
index 00000000000..dc9141a61b8
--- /dev/null
+++ b/apps/dav/lib/CalDAV/CachedSubscriptionObject.php
@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV;
+
+use Sabre\DAV\Exception\MethodNotAllowed;
+
+/**
+ * Class CachedSubscriptionObject
+ *
+ * @package OCA\DAV\CalDAV
+ * @property CalDavBackend $caldavBackend
+ */
+class CachedSubscriptionObject extends \Sabre\CalDAV\CalendarObject {
+
+ /**
+ * @inheritdoc
+ */
+ public function get() {
+ // Pre-populating the 'calendardata' is optional, if we don't have it
+ // already we fetch it from the backend.
+ if (!isset($this->objectData['calendardata'])) {
+ $this->objectData = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $this->objectData['uri'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
+ }
+
+ return $this->objectData['calendardata'];
+ }
+
+ /**
+ * @param resource|string $calendarData
+ * @return string
+ * @throws MethodNotAllowed
+ */
+ public function put($calendarData) {
+ throw new MethodNotAllowed('Creating objects in a cached subscription is not allowed');
+ }
+
+ /**
+ * @throws MethodNotAllowed
+ */
+ public function delete() {
+ throw new MethodNotAllowed('Deleting objects in a cached subscription is not allowed');
+ }
+}
diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionProvider.php b/apps/dav/lib/CalDAV/CachedSubscriptionProvider.php
new file mode 100644
index 00000000000..d64f039d05b
--- /dev/null
+++ b/apps/dav/lib/CalDAV/CachedSubscriptionProvider.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV;
+
+use OCP\Calendar\ICalendarProvider;
+
+class CachedSubscriptionProvider implements ICalendarProvider {
+
+ public function __construct(
+ private CalDavBackend $calDavBackend,
+ ) {
+ }
+
+ public function getCalendars(string $principalUri, array $calendarUris = []): array {
+ $calendarInfos = $this->calDavBackend->getSubscriptionsForUser($principalUri);
+
+ if (count($calendarUris) > 0) {
+ $calendarInfos = array_filter($calendarInfos, fn (array $subscription) => in_array($subscription['uri'], $calendarUris));
+ }
+
+ $calendarInfos = array_values(array_filter($calendarInfos));
+
+ $iCalendars = [];
+ foreach ($calendarInfos as $calendarInfo) {
+ $calendar = new CachedSubscription($this->calDavBackend, $calendarInfo);
+ $iCalendars[] = new CachedSubscriptionImpl(
+ $calendar,
+ $calendarInfo,
+ $this->calDavBackend,
+ );
+ }
+ return $iCalendars;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php
index 169bf6ff6a5..d5b0d875ede 100644
--- a/apps/dav/lib/CalDAV/CalDavBackend.php
+++ b/apps/dav/lib/CalDAV/CalDavBackend.php
@@ -1,46 +1,52 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @copyright Copyright (c) 2017 Georg Ehrke
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author nhirokinet <nhirokinet@nhiroki.net>
- * @author Robin Appelman <robin@icewind.nl>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Stefan Weil <sw@weilnetz.de>
- * @author Thomas Citharel <tcit@tcit.fr>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
-
namespace OCA\DAV\CalDAV;
+use DateTime;
+use DateTimeImmutable;
+use DateTimeInterface;
+use Generator;
+use OCA\DAV\AppInfo\Application;
+use OCA\DAV\CalDAV\Sharing\Backend;
+use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\Sharing\IShareable;
+use OCA\DAV\Events\CachedCalendarObjectCreatedEvent;
+use OCA\DAV\Events\CachedCalendarObjectDeletedEvent;
+use OCA\DAV\Events\CachedCalendarObjectUpdatedEvent;
+use OCA\DAV\Events\CalendarCreatedEvent;
+use OCA\DAV\Events\CalendarDeletedEvent;
+use OCA\DAV\Events\CalendarMovedToTrashEvent;
+use OCA\DAV\Events\CalendarPublishedEvent;
+use OCA\DAV\Events\CalendarRestoredEvent;
+use OCA\DAV\Events\CalendarShareUpdatedEvent;
+use OCA\DAV\Events\CalendarUnpublishedEvent;
+use OCA\DAV\Events\CalendarUpdatedEvent;
+use OCA\DAV\Events\SubscriptionCreatedEvent;
+use OCA\DAV\Events\SubscriptionDeletedEvent;
+use OCA\DAV\Events\SubscriptionUpdatedEvent;
+use OCP\AppFramework\Db\TTransactional;
+use OCP\Calendar\CalendarExportOptions;
+use OCP\Calendar\Events\CalendarObjectCreatedEvent;
+use OCP\Calendar\Events\CalendarObjectDeletedEvent;
+use OCP\Calendar\Events\CalendarObjectMovedEvent;
+use OCP\Calendar\Events\CalendarObjectMovedToTrashEvent;
+use OCP\Calendar\Events\CalendarObjectRestoredEvent;
+use OCP\Calendar\Events\CalendarObjectUpdatedEvent;
+use OCP\Calendar\Exceptions\CalendarException;
+use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
-use OCA\DAV\Connector\Sabre\Principal;
-use OCA\DAV\DAV\Sharing\Backend;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IConfig;
use OCP\IDBConnection;
-use OCP\IGroupManager;
-use OCP\ILogger;
-use OCP\IUser;
use OCP\IUserManager;
use OCP\Security\ISecureRandom;
+use Psr\Log\LoggerInterface;
+use RuntimeException;
use Sabre\CalDAV\Backend\AbstractBackend;
use Sabre\CalDAV\Backend\SchedulingSupport;
use Sabre\CalDAV\Backend\SubscriptionSupport;
@@ -48,13 +54,13 @@ use Sabre\CalDAV\Backend\SyncSupport;
use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp;
use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
use Sabre\DAV;
+use Sabre\DAV\Exception\BadRequest;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\PropPatch;
-use Sabre\HTTP\URLUtil;
+use Sabre\Uri;
use Sabre\VObject\Component;
use Sabre\VObject\Component\VCalendar;
-use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Component\VTimeZone;
use Sabre\VObject\DateTimeParser;
use Sabre\VObject\InvalidDataException;
@@ -62,9 +68,22 @@ use Sabre\VObject\ParseException;
use Sabre\VObject\Property;
use Sabre\VObject\Reader;
use Sabre\VObject\Recur\EventIterator;
-use Sabre\Uri;
-use Symfony\Component\EventDispatcher\EventDispatcherInterface;
-use Symfony\Component\EventDispatcher\GenericEvent;
+use Sabre\VObject\Recur\MaxInstancesExceededException;
+use Sabre\VObject\Recur\NoInstancesException;
+use function array_column;
+use function array_map;
+use function array_merge;
+use function array_values;
+use function explode;
+use function is_array;
+use function is_resource;
+use function pathinfo;
+use function rewind;
+use function settype;
+use function sprintf;
+use function str_replace;
+use function strtolower;
+use function time;
/**
* Class CalDavBackend
@@ -72,11 +91,31 @@ use Symfony\Component\EventDispatcher\GenericEvent;
* Code is heavily inspired by https://github.com/fruux/sabre-dav/blob/master/lib/CalDAV/Backend/PDO.php
*
* @package OCA\DAV\CalDAV
+ *
+ * @psalm-type CalendarInfo = array{
+ * id: int,
+ * uri: string,
+ * principaluri: string,
+ * '{http://calendarserver.org/ns/}getctag': string,
+ * '{http://sabredav.org/ns}sync-token': int,
+ * '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set': \Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet,
+ * '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp': \Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp,
+ * '{DAV:}displayname': string,
+ * '{urn:ietf:params:xml:ns:caldav}calendar-timezone': ?string,
+ * '{http://nextcloud.com/ns}owner-displayname': string,
+ * }
*/
class CalDavBackend extends AbstractBackend implements SyncSupport, SubscriptionSupport, SchedulingSupport {
+ use TTransactional;
- const PERSONAL_CALENDAR_URI = 'personal';
- const PERSONAL_CALENDAR_NAME = 'Personal';
+ public const CALENDAR_TYPE_CALENDAR = 0;
+ public const CALENDAR_TYPE_SUBSCRIPTION = 1;
+
+ public const PERSONAL_CALENDAR_URI = 'personal';
+ public const PERSONAL_CALENDAR_NAME = 'Personal';
+
+ public const RESOURCE_BOOKING_CALENDAR_URI = 'calendar';
+ public const RESOURCE_BOOKING_CALENDAR_NAME = 'Calendar';
/**
* We need to specify a max date, because we need to stop *somewhere*
@@ -86,27 +125,27 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* in 2038-01-19 to avoid problems when the date is converted
* to a unix timestamp.
*/
- const MAX_DATE = '2038-01-01';
+ public const MAX_DATE = '2038-01-01';
- const ACCESS_PUBLIC = 4;
- const CLASSIFICATION_PUBLIC = 0;
- const CLASSIFICATION_PRIVATE = 1;
- const CLASSIFICATION_CONFIDENTIAL = 2;
+ public const ACCESS_PUBLIC = 4;
+ public const CLASSIFICATION_PUBLIC = 0;
+ public const CLASSIFICATION_PRIVATE = 1;
+ public const CLASSIFICATION_CONFIDENTIAL = 2;
/**
- * List of CalDAV properties, and how they map to database field names
+ * List of CalDAV properties, and how they map to database field names and their type
* Add your own properties by simply adding on to this array.
*
- * Note that only string-based properties are supported here.
- *
* @var array
+ * @psalm-var array<string, string[]>
*/
- public $propertyMap = [
- '{DAV:}displayname' => 'displayname',
- '{urn:ietf:params:xml:ns:caldav}calendar-description' => 'description',
- '{urn:ietf:params:xml:ns:caldav}calendar-timezone' => 'timezone',
- '{http://apple.com/ns/ical/}calendar-order' => 'calendarorder',
- '{http://apple.com/ns/ical/}calendar-color' => 'calendarcolor',
+ public array $propertyMap = [
+ '{DAV:}displayname' => ['displayname', 'string'],
+ '{urn:ietf:params:xml:ns:caldav}calendar-description' => ['description', 'string'],
+ '{urn:ietf:params:xml:ns:caldav}calendar-timezone' => ['timezone', 'string'],
+ '{http://apple.com/ns/ical/}calendar-order' => ['calendarorder', 'int'],
+ '{http://apple.com/ns/ical/}calendar-color' => ['calendarcolor', 'string'],
+ '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => ['deleted_at', 'int'],
];
/**
@@ -114,23 +153,38 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
*
* @var array
*/
- public $subscriptionPropertyMap = [
- '{DAV:}displayname' => 'displayname',
- '{http://apple.com/ns/ical/}refreshrate' => 'refreshrate',
- '{http://apple.com/ns/ical/}calendar-order' => 'calendarorder',
- '{http://apple.com/ns/ical/}calendar-color' => 'calendarcolor',
- '{http://calendarserver.org/ns/}subscribed-strip-todos' => 'striptodos',
- '{http://calendarserver.org/ns/}subscribed-strip-alarms' => 'stripalarms',
- '{http://calendarserver.org/ns/}subscribed-strip-attachments' => 'stripattachments',
+ public array $subscriptionPropertyMap = [
+ '{DAV:}displayname' => ['displayname', 'string'],
+ '{http://apple.com/ns/ical/}refreshrate' => ['refreshrate', 'string'],
+ '{http://apple.com/ns/ical/}calendar-order' => ['calendarorder', 'int'],
+ '{http://apple.com/ns/ical/}calendar-color' => ['calendarcolor', 'string'],
+ '{http://calendarserver.org/ns/}subscribed-strip-todos' => ['striptodos', 'bool'],
+ '{http://calendarserver.org/ns/}subscribed-strip-alarms' => ['stripalarms', 'string'],
+ '{http://calendarserver.org/ns/}subscribed-strip-attachments' => ['stripattachments', 'string'],
];
- /** @var array properties to index */
- public static $indexProperties = ['CATEGORIES', 'COMMENT', 'DESCRIPTION',
- 'LOCATION', 'RESOURCES', 'STATUS', 'SUMMARY', 'ATTENDEE', 'CONTACT',
- 'ORGANIZER'];
+ /**
+ * properties to index
+ *
+ * This list has to be kept in sync with ICalendarQuery::SEARCH_PROPERTY_*
+ *
+ * @see \OCP\Calendar\ICalendarQuery
+ */
+ private const INDEXED_PROPERTIES = [
+ 'CATEGORIES',
+ 'COMMENT',
+ 'DESCRIPTION',
+ 'LOCATION',
+ 'RESOURCES',
+ 'STATUS',
+ 'SUMMARY',
+ 'ATTENDEE',
+ 'CONTACT',
+ 'ORGANIZER'
+ ];
/** @var array parameters to index */
- public static $indexParameters = [
+ public static array $indexParameters = [
'ATTENDEE' => ['CN'],
'ORGANIZER' => ['CN'],
];
@@ -138,86 +192,95 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
/**
* @var string[] Map of uid => display name
*/
- protected $userDisplayNames;
-
- /** @var IDBConnection */
- private $db;
-
- /** @var Backend */
- private $sharingBackend;
-
- /** @var Principal */
- private $principalBackend;
-
- /** @var IUserManager */
- private $userManager;
-
- /** @var ISecureRandom */
- private $random;
-
- /** @var ILogger */
- private $logger;
-
- /** @var EventDispatcherInterface */
- private $dispatcher;
-
- /** @var bool */
- private $legacyEndpoint;
-
- /** @var string */
- private $dbObjectPropertiesTable = 'calendarobjects_props';
-
- /**
- * CalDavBackend constructor.
- *
- * @param IDBConnection $db
- * @param Principal $principalBackend
- * @param IUserManager $userManager
- * @param IGroupManager $groupManager
- * @param ISecureRandom $random
- * @param ILogger $logger
- * @param EventDispatcherInterface $dispatcher
- * @param bool $legacyEndpoint
- */
- public function __construct(IDBConnection $db,
- Principal $principalBackend,
- IUserManager $userManager,
- IGroupManager $groupManager,
- ISecureRandom $random,
- ILogger $logger,
- EventDispatcherInterface $dispatcher,
- $legacyEndpoint = false) {
- $this->db = $db;
- $this->principalBackend = $principalBackend;
- $this->userManager = $userManager;
- $this->sharingBackend = new Backend($this->db, $this->userManager, $groupManager, $principalBackend, 'calendar');
- $this->random = $random;
- $this->logger = $logger;
- $this->dispatcher = $dispatcher;
- $this->legacyEndpoint = $legacyEndpoint;
+ protected array $userDisplayNames;
+
+ private string $dbObjectsTable = 'calendarobjects';
+ private string $dbObjectPropertiesTable = 'calendarobjects_props';
+ private string $dbObjectInvitationsTable = 'calendar_invitations';
+ private array $cachedObjects = [];
+
+ public function __construct(
+ private IDBConnection $db,
+ private Principal $principalBackend,
+ private IUserManager $userManager,
+ private ISecureRandom $random,
+ private LoggerInterface $logger,
+ private IEventDispatcher $dispatcher,
+ private IConfig $config,
+ private Sharing\Backend $calendarSharingBackend,
+ private bool $legacyEndpoint = false,
+ ) {
}
/**
- * Return the number of calendars for a principal
+ * Return the number of calendars owned by the given principal.
*
- * By default this excludes the automatically generated birthday calendar
+ * Calendars shared with the given principal are not counted!
*
- * @param $principalUri
- * @param bool $excludeBirthday
- * @return int
+ * By default, this excludes the automatically generated birthday calendar.
*/
- public function getCalendarsForUserCount($principalUri, $excludeBirthday = true) {
+ public function getCalendarsForUserCount(string $principalUri, bool $excludeBirthday = true): int {
$principalUri = $this->convertPrincipal($principalUri, true);
$query = $this->db->getQueryBuilder();
- $query->select($query->createFunction('COUNT(*)'))
- ->from('calendars')
- ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
+ $query->select($query->func()->count('*'))
+ ->from('calendars');
+
+ if ($principalUri === '') {
+ $query->where($query->expr()->emptyString('principaluri'));
+ } else {
+ $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
+ }
if ($excludeBirthday) {
$query->andWhere($query->expr()->neq('uri', $query->createNamedParameter(BirthdayService::BIRTHDAY_CALENDAR_URI)));
}
- return (int)$query->execute()->fetchColumn();
+ $result = $query->executeQuery();
+ $column = (int)$result->fetchOne();
+ $result->closeCursor();
+ return $column;
+ }
+
+ /**
+ * Return the number of subscriptions for a principal
+ */
+ public function getSubscriptionsForUserCount(string $principalUri): int {
+ $principalUri = $this->convertPrincipal($principalUri, true);
+ $query = $this->db->getQueryBuilder();
+ $query->select($query->func()->count('*'))
+ ->from('calendarsubscriptions');
+
+ if ($principalUri === '') {
+ $query->where($query->expr()->emptyString('principaluri'));
+ } else {
+ $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
+ }
+
+ $result = $query->executeQuery();
+ $column = (int)$result->fetchOne();
+ $result->closeCursor();
+ return $column;
+ }
+
+ /**
+ * @return array{id: int, deleted_at: int}[]
+ */
+ public function getDeletedCalendars(int $deletedBefore): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select(['id', 'deleted_at'])
+ ->from('calendars')
+ ->where($qb->expr()->isNotNull('deleted_at'))
+ ->andWhere($qb->expr()->lt('deleted_at', $qb->createNamedParameter($deletedBefore)));
+ $result = $qb->executeQuery();
+ $calendars = [];
+ while (($row = $result->fetch()) !== false) {
+ $calendars[] = [
+ 'id' => (int)$row['id'],
+ 'deleted_at' => (int)$row['deleted_at'],
+ ];
+ }
+ $result->closeCursor();
+ return $calendars;
}
/**
@@ -245,135 +308,153 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param string $principalUri
* @return array
*/
- function getCalendarsForUser($principalUri) {
- $principalUriOriginal = $principalUri;
- $principalUri = $this->convertPrincipal($principalUri, true);
- $fields = array_values($this->propertyMap);
- $fields[] = 'id';
- $fields[] = 'uri';
- $fields[] = 'synctoken';
- $fields[] = 'components';
- $fields[] = 'principaluri';
- $fields[] = 'transparent';
-
- // Making fields a comma-delimited list
- $query = $this->db->getQueryBuilder();
- $query->select($fields)->from('calendars')
- ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)))
+ public function getCalendarsForUser($principalUri) {
+ return $this->atomic(function () use ($principalUri) {
+ $principalUriOriginal = $principalUri;
+ $principalUri = $this->convertPrincipal($principalUri, true);
+ $fields = array_column($this->propertyMap, 0);
+ $fields[] = 'id';
+ $fields[] = 'uri';
+ $fields[] = 'synctoken';
+ $fields[] = 'components';
+ $fields[] = 'principaluri';
+ $fields[] = 'transparent';
+
+ // Making fields a comma-delimited list
+ $query = $this->db->getQueryBuilder();
+ $query->select($fields)
+ ->from('calendars')
->orderBy('calendarorder', 'ASC');
- $stmt = $query->execute();
-
- $calendars = [];
- while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
- $components = [];
- if ($row['components']) {
- $components = explode(',',$row['components']);
- }
-
- $calendar = [
- 'id' => $row['id'],
- 'uri' => $row['uri'],
- 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint),
- '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'),
- '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0',
- '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components),
- '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'),
- '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint),
- ];
-
- foreach($this->propertyMap as $xmlName=>$dbName) {
- $calendar[$xmlName] = $row[$dbName];
+ if ($principalUri === '') {
+ $query->where($query->expr()->emptyString('principaluri'));
+ } else {
+ $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
}
- $this->addOwnerPrincipal($calendar);
+ $result = $query->executeQuery();
- if (!isset($calendars[$calendar['id']])) {
- $calendars[$calendar['id']] = $calendar;
- }
- }
+ $calendars = [];
+ while ($row = $result->fetch()) {
+ $row['principaluri'] = (string)$row['principaluri'];
+ $components = [];
+ if ($row['components']) {
+ $components = explode(',', $row['components']);
+ }
- $stmt->closeCursor();
+ $calendar = [
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint),
+ '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'),
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
+ '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components),
+ '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'),
+ '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint),
+ ];
- // query for shared calendars
- $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true);
- $principals = array_map(function($principal) {
- return urldecode($principal);
- }, $principals);
- $principals[]= $principalUri;
+ $calendar = $this->rowToCalendar($row, $calendar);
+ $calendar = $this->addOwnerPrincipalToCalendar($calendar);
+ $calendar = $this->addResourceTypeToCalendar($row, $calendar);
- $fields = array_values($this->propertyMap);
- $fields[] = 'a.id';
- $fields[] = 'a.uri';
- $fields[] = 'a.synctoken';
- $fields[] = 'a.components';
- $fields[] = 'a.principaluri';
- $fields[] = 'a.transparent';
- $fields[] = 's.access';
- $query = $this->db->getQueryBuilder();
- $result = $query->select($fields)
- ->from('dav_shares', 's')
- ->join('s', 'calendars', 'a', $query->expr()->eq('s.resourceid', 'a.id'))
- ->where($query->expr()->in('s.principaluri', $query->createParameter('principaluri')))
- ->andWhere($query->expr()->eq('s.type', $query->createParameter('type')))
- ->setParameter('type', 'calendar')
- ->setParameter('principaluri', $principals, \Doctrine\DBAL\Connection::PARAM_STR_ARRAY)
- ->execute();
-
- $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
- while($row = $result->fetch()) {
- if ($row['principaluri'] === $principalUri) {
- continue;
+ if (!isset($calendars[$calendar['id']])) {
+ $calendars[$calendar['id']] = $calendar;
+ }
}
+ $result->closeCursor();
- $readOnly = (int) $row['access'] === Backend::ACCESS_READ;
- if (isset($calendars[$row['id']])) {
- if ($readOnly) {
- // New share can not have more permissions then the old one.
+ // query for shared calendars
+ $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true);
+ $principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUriOriginal));
+ $principals[] = $principalUri;
+
+ $fields = array_column($this->propertyMap, 0);
+ $fields = array_map(function (string $field) {
+ return 'a.' . $field;
+ }, $fields);
+ $fields[] = 'a.id';
+ $fields[] = 'a.uri';
+ $fields[] = 'a.synctoken';
+ $fields[] = 'a.components';
+ $fields[] = 'a.principaluri';
+ $fields[] = 'a.transparent';
+ $fields[] = 's.access';
+
+ $select = $this->db->getQueryBuilder();
+ $subSelect = $this->db->getQueryBuilder();
+
+ $subSelect->select('resourceid')
+ ->from('dav_shares', 'd')
+ ->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
+ ->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY));
+
+ $select->select($fields)
+ ->from('dav_shares', 's')
+ ->join('s', 'calendars', 'a', $select->expr()->eq('s.resourceid', 'a.id', IQueryBuilder::PARAM_INT))
+ ->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY))
+ ->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('calendar', IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
+ ->andWhere($select->expr()->notIn('a.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
+
+ $results = $select->executeQuery();
+
+ $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
+ while ($row = $results->fetch()) {
+ $row['principaluri'] = (string)$row['principaluri'];
+ if ($row['principaluri'] === $principalUri) {
continue;
}
- if (isset($calendars[$row['id']][$readOnlyPropertyName]) &&
- $calendars[$row['id']][$readOnlyPropertyName] === 0) {
- // Old share is already read-write, no more permissions can be gained
- continue;
- }
- }
- list(, $name) = Uri\split($row['principaluri']);
- $uri = $row['uri'] . '_shared_by_' . $name;
- $row['displayname'] = $row['displayname'] . ' (' . $this->getUserDisplayName($name) . ')';
- $components = [];
- if ($row['components']) {
- $components = explode(',',$row['components']);
- }
- $calendar = [
- 'id' => $row['id'],
- 'uri' => $uri,
- 'principaluri' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint),
- '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'),
- '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0',
- '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components),
- '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp('transparent'),
- '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint),
- $readOnlyPropertyName => $readOnly,
- ];
+ $readOnly = (int)$row['access'] === Backend::ACCESS_READ;
+ if (isset($calendars[$row['id']])) {
+ if ($readOnly) {
+ // New share can not have more permissions than the old one.
+ continue;
+ }
+ if (isset($calendars[$row['id']][$readOnlyPropertyName])
+ && $calendars[$row['id']][$readOnlyPropertyName] === 0) {
+ // Old share is already read-write, no more permissions can be gained
+ continue;
+ }
+ }
- foreach($this->propertyMap as $xmlName=>$dbName) {
- $calendar[$xmlName] = $row[$dbName];
- }
+ [, $name] = Uri\split($row['principaluri']);
+ $uri = $row['uri'] . '_shared_by_' . $name;
+ $row['displayname'] = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? ($name ?? '')) . ')';
+ $components = [];
+ if ($row['components']) {
+ $components = explode(',', $row['components']);
+ }
+ $calendar = [
+ 'id' => $row['id'],
+ 'uri' => $uri,
+ 'principaluri' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint),
+ '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'),
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
+ '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components),
+ '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp('transparent'),
+ '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint),
+ $readOnlyPropertyName => $readOnly,
+ ];
- $this->addOwnerPrincipal($calendar);
+ $calendar = $this->rowToCalendar($row, $calendar);
+ $calendar = $this->addOwnerPrincipalToCalendar($calendar);
+ $calendar = $this->addResourceTypeToCalendar($row, $calendar);
- $calendars[$calendar['id']] = $calendar;
- }
- $result->closeCursor();
+ $calendars[$calendar['id']] = $calendar;
+ }
+ $result->closeCursor();
- return array_values($calendars);
+ return array_values($calendars);
+ }, $this->db);
}
+ /**
+ * @param $principalUri
+ * @return array
+ */
public function getUsersOwnCalendars($principalUri) {
$principalUri = $this->convertPrincipal($principalUri, true);
- $fields = array_values($this->propertyMap);
+ $fields = array_column($this->propertyMap, 0);
$fields[] = 'id';
$fields[] = 'uri';
$fields[] = 'synctoken';
@@ -385,27 +466,27 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$query->select($fields)->from('calendars')
->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)))
->orderBy('calendarorder', 'ASC');
- $stmt = $query->execute();
+ $stmt = $query->executeQuery();
$calendars = [];
- while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+ while ($row = $stmt->fetch()) {
+ $row['principaluri'] = (string)$row['principaluri'];
$components = [];
if ($row['components']) {
- $components = explode(',',$row['components']);
+ $components = explode(',', $row['components']);
}
$calendar = [
'id' => $row['id'],
'uri' => $row['uri'],
'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint),
- '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'),
- '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0',
+ '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'),
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
'{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components),
'{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'),
];
- foreach($this->propertyMap as $xmlName=>$dbName) {
- $calendar[$xmlName] = $row[$dbName];
- }
- $this->addOwnerPrincipal($calendar);
+ $calendar = $this->rowToCalendar($row, $calendar);
+ $calendar = $this->addOwnerPrincipalToCalendar($calendar);
+ $calendar = $this->addResourceTypeToCalendar($row, $calendar);
if (!isset($calendars[$calendar['id']])) {
$calendars[$calendar['id']] = $calendar;
@@ -415,26 +496,11 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
return array_values($calendars);
}
-
- private function getUserDisplayName($uid) {
- if (!isset($this->userDisplayNames[$uid])) {
- $user = $this->userManager->get($uid);
-
- if ($user instanceof IUser) {
- $this->userDisplayNames[$uid] = $user->getDisplayName();
- } else {
- $this->userDisplayNames[$uid] = $uid;
- }
- }
-
- return $this->userDisplayNames[$uid];
- }
-
/**
* @return array
*/
public function getPublicCalendars() {
- $fields = array_values($this->propertyMap);
+ $fields = array_column($this->propertyMap, 0);
$fields[] = 'a.id';
$fields[] = 'a.uri';
$fields[] = 'a.synctoken';
@@ -450,21 +516,22 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
->join('s', 'calendars', 'a', $query->expr()->eq('s.resourceid', 'a.id'))
->where($query->expr()->in('s.access', $query->createNamedParameter(self::ACCESS_PUBLIC)))
->andWhere($query->expr()->eq('s.type', $query->createNamedParameter('calendar')))
- ->execute();
+ ->executeQuery();
- while($row = $result->fetch()) {
- list(, $name) = Uri\split($row['principaluri']);
+ while ($row = $result->fetch()) {
+ $row['principaluri'] = (string)$row['principaluri'];
+ [, $name] = Uri\split($row['principaluri']);
$row['displayname'] = $row['displayname'] . "($name)";
$components = [];
if ($row['components']) {
- $components = explode(',',$row['components']);
+ $components = explode(',', $row['components']);
}
$calendar = [
'id' => $row['id'],
'uri' => $row['publicuri'],
'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint),
- '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'),
- '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0',
+ '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'),
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
'{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components),
'{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'),
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], $this->legacyEndpoint),
@@ -472,11 +539,9 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}public' => (int)$row['access'] === self::ACCESS_PUBLIC,
];
- foreach($this->propertyMap as $xmlName=>$dbName) {
- $calendar[$xmlName] = $row[$dbName];
- }
-
- $this->addOwnerPrincipal($calendar);
+ $calendar = $this->rowToCalendar($row, $calendar);
+ $calendar = $this->addOwnerPrincipalToCalendar($calendar);
+ $calendar = $this->addResourceTypeToCalendar($row, $calendar);
if (!isset($calendars[$calendar['id']])) {
$calendars[$calendar['id']] = $calendar;
@@ -493,7 +558,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @throws NotFound
*/
public function getPublicCalendar($uri) {
- $fields = array_values($this->propertyMap);
+ $fields = array_column($this->propertyMap, 0);
$fields[] = 'a.id';
$fields[] = 'a.uri';
$fields[] = 'a.synctoken';
@@ -509,9 +574,9 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
->where($query->expr()->in('s.access', $query->createNamedParameter(self::ACCESS_PUBLIC)))
->andWhere($query->expr()->eq('s.type', $query->createNamedParameter('calendar')))
->andWhere($query->expr()->eq('s.publicuri', $query->createNamedParameter($uri)))
- ->execute();
+ ->executeQuery();
- $row = $result->fetch(\PDO::FETCH_ASSOC);
+ $row = $result->fetch();
$result->closeCursor();
@@ -519,18 +584,19 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
throw new NotFound('Node with name \'' . $uri . '\' could not be found');
}
- list(, $name) = Uri\split($row['principaluri']);
+ $row['principaluri'] = (string)$row['principaluri'];
+ [, $name] = Uri\split($row['principaluri']);
$row['displayname'] = $row['displayname'] . ' ' . "($name)";
$components = [];
if ($row['components']) {
- $components = explode(',',$row['components']);
+ $components = explode(',', $row['components']);
}
$calendar = [
'id' => $row['id'],
'uri' => $row['publicuri'],
'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint),
- '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'),
- '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0',
+ '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'),
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
'{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components),
'{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'),
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint),
@@ -538,14 +604,11 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}public' => (int)$row['access'] === self::ACCESS_PUBLIC,
];
- foreach($this->propertyMap as $xmlName=>$dbName) {
- $calendar[$xmlName] = $row[$dbName];
- }
-
- $this->addOwnerPrincipal($calendar);
+ $calendar = $this->rowToCalendar($row, $calendar);
+ $calendar = $this->addOwnerPrincipalToCalendar($calendar);
+ $calendar = $this->addResourceTypeToCalendar($row, $calendar);
return $calendar;
-
}
/**
@@ -554,7 +617,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @return array|null
*/
public function getCalendarByUri($principal, $uri) {
- $fields = array_values($this->propertyMap);
+ $fields = array_column($this->propertyMap, 0);
$fields[] = 'id';
$fields[] = 'uri';
$fields[] = 'synctoken';
@@ -568,40 +631,43 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
->setMaxResults(1);
- $stmt = $query->execute();
+ $stmt = $query->executeQuery();
- $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+ $row = $stmt->fetch();
$stmt->closeCursor();
if ($row === false) {
return null;
}
+ $row['principaluri'] = (string)$row['principaluri'];
$components = [];
if ($row['components']) {
- $components = explode(',',$row['components']);
+ $components = explode(',', $row['components']);
}
$calendar = [
'id' => $row['id'],
'uri' => $row['uri'],
'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint),
- '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'),
- '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0',
+ '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'),
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
'{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components),
'{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'),
];
- foreach($this->propertyMap as $xmlName=>$dbName) {
- $calendar[$xmlName] = $row[$dbName];
- }
-
- $this->addOwnerPrincipal($calendar);
+ $calendar = $this->rowToCalendar($row, $calendar);
+ $calendar = $this->addOwnerPrincipalToCalendar($calendar);
+ $calendar = $this->addResourceTypeToCalendar($row, $calendar);
return $calendar;
}
- public function getCalendarById($calendarId) {
- $fields = array_values($this->propertyMap);
+ /**
+ * @psalm-return CalendarInfo|null
+ * @return array|null
+ */
+ public function getCalendarById(int $calendarId): ?array {
+ $fields = array_column($this->propertyMap, 0);
$fields[] = 'id';
$fields[] = 'uri';
$fields[] = 'synctoken';
@@ -614,36 +680,111 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$query->select($fields)->from('calendars')
->where($query->expr()->eq('id', $query->createNamedParameter($calendarId)))
->setMaxResults(1);
- $stmt = $query->execute();
+ $stmt = $query->executeQuery();
- $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+ $row = $stmt->fetch();
$stmt->closeCursor();
if ($row === false) {
return null;
}
+ $row['principaluri'] = (string)$row['principaluri'];
$components = [];
if ($row['components']) {
- $components = explode(',',$row['components']);
+ $components = explode(',', $row['components']);
}
$calendar = [
'id' => $row['id'],
'uri' => $row['uri'],
'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint),
- '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'),
- '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0',
+ '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'),
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?? 0,
'{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components),
'{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'),
];
- foreach($this->propertyMap as $xmlName=>$dbName) {
- $calendar[$xmlName] = $row[$dbName];
+ $calendar = $this->rowToCalendar($row, $calendar);
+ $calendar = $this->addOwnerPrincipalToCalendar($calendar);
+ $calendar = $this->addResourceTypeToCalendar($row, $calendar);
+
+ return $calendar;
+ }
+
+ /**
+ * @param $subscriptionId
+ */
+ public function getSubscriptionById($subscriptionId) {
+ $fields = array_column($this->subscriptionPropertyMap, 0);
+ $fields[] = 'id';
+ $fields[] = 'uri';
+ $fields[] = 'source';
+ $fields[] = 'synctoken';
+ $fields[] = 'principaluri';
+ $fields[] = 'lastmodified';
+
+ $query = $this->db->getQueryBuilder();
+ $query->select($fields)
+ ->from('calendarsubscriptions')
+ ->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId)))
+ ->orderBy('calendarorder', 'asc');
+ $stmt = $query->executeQuery();
+
+ $row = $stmt->fetch();
+ $stmt->closeCursor();
+ if ($row === false) {
+ return null;
}
- $this->addOwnerPrincipal($calendar);
+ $row['principaluri'] = (string)$row['principaluri'];
+ $subscription = [
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'principaluri' => $row['principaluri'],
+ 'source' => $row['source'],
+ 'lastmodified' => $row['lastmodified'],
+ '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']),
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
+ ];
- return $calendar;
+ return $this->rowToSubscription($row, $subscription);
+ }
+
+ public function getSubscriptionByUri(string $principal, string $uri): ?array {
+ $fields = array_column($this->subscriptionPropertyMap, 0);
+ $fields[] = 'id';
+ $fields[] = 'uri';
+ $fields[] = 'source';
+ $fields[] = 'synctoken';
+ $fields[] = 'principaluri';
+ $fields[] = 'lastmodified';
+
+ $query = $this->db->getQueryBuilder();
+ $query->select($fields)
+ ->from('calendarsubscriptions')
+ ->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
+ ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal)))
+ ->setMaxResults(1);
+ $stmt = $query->executeQuery();
+
+ $row = $stmt->fetch();
+ $stmt->closeCursor();
+ if ($row === false) {
+ return null;
+ }
+
+ $row['principaluri'] = (string)$row['principaluri'];
+ $subscription = [
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'principaluri' => $row['principaluri'],
+ 'source' => $row['source'],
+ 'lastmodified' => $row['lastmodified'],
+ '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']),
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
+ ];
+
+ return $this->rowToSubscription($row, $subscription);
}
/**
@@ -656,16 +797,21 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param string $calendarUri
* @param array $properties
* @return int
- * @suppress SqlInjectionChecker
+ *
+ * @throws CalendarException
*/
- function createCalendar($principalUri, $calendarUri, array $properties) {
+ public function createCalendar($principalUri, $calendarUri, array $properties) {
+ if (strlen($calendarUri) > 255) {
+ throw new CalendarException('URI too long. Calendar not created');
+ }
+
$values = [
'principaluri' => $this->convertPrincipal($principalUri, true),
- 'uri' => $calendarUri,
- 'synctoken' => 1,
- 'transparent' => 0,
- 'components' => 'VEVENT,VTODO',
- 'displayname' => $calendarUri
+ 'uri' => $calendarUri,
+ 'synctoken' => 1,
+ 'transparent' => 0,
+ 'components' => 'VEVENT,VTODO,VJOURNAL',
+ 'displayname' => $calendarUri
];
// Default value
@@ -674,33 +820,38 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
if (!($properties[$sccs] instanceof SupportedCalendarComponentSet)) {
throw new DAV\Exception('The ' . $sccs . ' property must be of type: \Sabre\CalDAV\Property\SupportedCalendarComponentSet');
}
- $values['components'] = implode(',',$properties[$sccs]->getValue());
+ $values['components'] = implode(',', $properties[$sccs]->getValue());
+ } elseif (isset($properties['components'])) {
+ // Allow to provide components internally without having
+ // to create a SupportedCalendarComponentSet object
+ $values['components'] = $properties['components'];
}
+
$transp = '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp';
if (isset($properties[$transp])) {
- $values['transparent'] = (int) ($properties[$transp]->getValue() === 'transparent');
+ $values['transparent'] = (int)($properties[$transp]->getValue() === 'transparent');
}
- foreach($this->propertyMap as $xmlName=>$dbName) {
+ foreach ($this->propertyMap as $xmlName => [$dbName, $type]) {
if (isset($properties[$xmlName])) {
$values[$dbName] = $properties[$xmlName];
}
}
- $query = $this->db->getQueryBuilder();
- $query->insert('calendars');
- foreach($values as $column => $value) {
- $query->setValue($column, $query->createNamedParameter($value));
- }
- $query->execute();
- $calendarId = $query->getLastInsertId();
+ [$calendarId, $calendarData] = $this->atomic(function () use ($values) {
+ $query = $this->db->getQueryBuilder();
+ $query->insert('calendars');
+ foreach ($values as $column => $value) {
+ $query->setValue($column, $query->createNamedParameter($value));
+ }
+ $query->executeStatement();
+ $calendarId = $query->getLastInsertId();
+
+ $calendarData = $this->getCalendarById($calendarId);
+ return [$calendarId, $calendarData];
+ }, $this->db);
- $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::createCalendar', new GenericEvent(
- '\OCA\DAV\CalDAV\CalDavBackend::createCalendar',
- [
- 'calendarId' => $calendarId,
- 'calendarData' => $this->getCalendarById($calendarId),
- ]));
+ $this->dispatcher->dispatchTyped(new CalendarCreatedEvent((int)$calendarId, $calendarData));
return $calendarId;
}
@@ -721,47 +872,41 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param PropPatch $propPatch
* @return void
*/
- function updateCalendar($calendarId, PropPatch $propPatch) {
+ public function updateCalendar($calendarId, PropPatch $propPatch) {
$supportedProperties = array_keys($this->propertyMap);
$supportedProperties[] = '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp';
- /**
- * @suppress SqlInjectionChecker
- */
- $propPatch->handle($supportedProperties, function($mutations) use ($calendarId) {
+ $propPatch->handle($supportedProperties, function ($mutations) use ($calendarId) {
$newValues = [];
foreach ($mutations as $propertyName => $propertyValue) {
-
switch ($propertyName) {
- case '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' :
+ case '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp':
$fieldName = 'transparent';
- $newValues[$fieldName] = (int) ($propertyValue->getValue() === 'transparent');
+ $newValues[$fieldName] = (int)($propertyValue->getValue() === 'transparent');
break;
- default :
- $fieldName = $this->propertyMap[$propertyName];
+ default:
+ $fieldName = $this->propertyMap[$propertyName][0];
$newValues[$fieldName] = $propertyValue;
break;
}
-
- }
- $query = $this->db->getQueryBuilder();
- $query->update('calendars');
- foreach ($newValues as $fieldName => $value) {
- $query->set($fieldName, $query->createNamedParameter($value));
}
- $query->where($query->expr()->eq('id', $query->createNamedParameter($calendarId)));
- $query->execute();
+ [$calendarData, $shares] = $this->atomic(function () use ($calendarId, $newValues) {
+ $query = $this->db->getQueryBuilder();
+ $query->update('calendars');
+ foreach ($newValues as $fieldName => $value) {
+ $query->set($fieldName, $query->createNamedParameter($value));
+ }
+ $query->where($query->expr()->eq('id', $query->createNamedParameter($calendarId)));
+ $query->executeStatement();
+
+ $this->addChanges($calendarId, [''], 2);
- $this->addChange($calendarId, "", 2);
+ $calendarData = $this->getCalendarById($calendarId);
+ $shares = $this->getShares($calendarId);
+ return [$calendarData, $shares];
+ }, $this->db);
- $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateCalendar', new GenericEvent(
- '\OCA\DAV\CalDAV\CalDavBackend::updateCalendar',
- [
- 'calendarId' => $calendarId,
- 'calendarData' => $this->getCalendarById($calendarId),
- 'shares' => $this->getShares($calendarId),
- 'propertyMutations' => $mutations,
- ]));
+ $this->dispatcher->dispatchTyped(new CalendarUpdatedEvent($calendarId, $calendarData, $shares, $mutations));
return true;
});
@@ -773,30 +918,163 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param mixed $calendarId
* @return void
*/
- function deleteCalendar($calendarId) {
- $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::deleteCalendar', new GenericEvent(
- '\OCA\DAV\CalDAV\CalDavBackend::deleteCalendar',
- [
- 'calendarId' => $calendarId,
- 'calendarData' => $this->getCalendarById($calendarId),
- 'shares' => $this->getShares($calendarId),
- ]));
-
- $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ?');
- $stmt->execute([$calendarId]);
-
- $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendars` WHERE `id` = ?');
- $stmt->execute([$calendarId]);
+ public function deleteCalendar($calendarId, bool $forceDeletePermanently = false) {
+ $this->atomic(function () use ($calendarId, $forceDeletePermanently): void {
+ // The calendar is deleted right away if this is either enforced by the caller
+ // or the special contacts birthday calendar or when the preference of an empty
+ // retention (0 seconds) is set, which signals a disabled trashbin.
+ $calendarData = $this->getCalendarById($calendarId);
+ $isBirthdayCalendar = isset($calendarData['uri']) && $calendarData['uri'] === BirthdayService::BIRTHDAY_CALENDAR_URI;
+ $trashbinDisabled = $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0';
+ if ($forceDeletePermanently || $isBirthdayCalendar || $trashbinDisabled) {
+ $calendarData = $this->getCalendarById($calendarId);
+ $shares = $this->getShares($calendarId);
+
+ $this->purgeCalendarInvitations($calendarId);
+
+ $qbDeleteCalendarObjectProps = $this->db->getQueryBuilder();
+ $qbDeleteCalendarObjectProps->delete($this->dbObjectPropertiesTable)
+ ->where($qbDeleteCalendarObjectProps->expr()->eq('calendarid', $qbDeleteCalendarObjectProps->createNamedParameter($calendarId)))
+ ->andWhere($qbDeleteCalendarObjectProps->expr()->eq('calendartype', $qbDeleteCalendarObjectProps->createNamedParameter(self::CALENDAR_TYPE_CALENDAR)))
+ ->executeStatement();
+
+ $qbDeleteCalendarObjects = $this->db->getQueryBuilder();
+ $qbDeleteCalendarObjects->delete('calendarobjects')
+ ->where($qbDeleteCalendarObjects->expr()->eq('calendarid', $qbDeleteCalendarObjects->createNamedParameter($calendarId)))
+ ->andWhere($qbDeleteCalendarObjects->expr()->eq('calendartype', $qbDeleteCalendarObjects->createNamedParameter(self::CALENDAR_TYPE_CALENDAR)))
+ ->executeStatement();
+
+ $qbDeleteCalendarChanges = $this->db->getQueryBuilder();
+ $qbDeleteCalendarChanges->delete('calendarchanges')
+ ->where($qbDeleteCalendarChanges->expr()->eq('calendarid', $qbDeleteCalendarChanges->createNamedParameter($calendarId)))
+ ->andWhere($qbDeleteCalendarChanges->expr()->eq('calendartype', $qbDeleteCalendarChanges->createNamedParameter(self::CALENDAR_TYPE_CALENDAR)))
+ ->executeStatement();
+
+ $this->calendarSharingBackend->deleteAllShares($calendarId);
+
+ $qbDeleteCalendar = $this->db->getQueryBuilder();
+ $qbDeleteCalendar->delete('calendars')
+ ->where($qbDeleteCalendar->expr()->eq('id', $qbDeleteCalendar->createNamedParameter($calendarId)))
+ ->executeStatement();
+
+ // Only dispatch if we actually deleted anything
+ if ($calendarData) {
+ $this->dispatcher->dispatchTyped(new CalendarDeletedEvent($calendarId, $calendarData, $shares));
+ }
+ } else {
+ $qbMarkCalendarDeleted = $this->db->getQueryBuilder();
+ $qbMarkCalendarDeleted->update('calendars')
+ ->set('deleted_at', $qbMarkCalendarDeleted->createNamedParameter(time()))
+ ->where($qbMarkCalendarDeleted->expr()->eq('id', $qbMarkCalendarDeleted->createNamedParameter($calendarId)))
+ ->executeStatement();
+
+ $calendarData = $this->getCalendarById($calendarId);
+ $shares = $this->getShares($calendarId);
+ if ($calendarData) {
+ $this->dispatcher->dispatchTyped(new CalendarMovedToTrashEvent(
+ $calendarId,
+ $calendarData,
+ $shares
+ ));
+ }
+ }
+ }, $this->db);
+ }
- $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarchanges` WHERE `calendarid` = ?');
- $stmt->execute([$calendarId]);
+ public function restoreCalendar(int $id): void {
+ $this->atomic(function () use ($id): void {
+ $qb = $this->db->getQueryBuilder();
+ $update = $qb->update('calendars')
+ ->set('deleted_at', $qb->createNamedParameter(null))
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT));
+ $update->executeStatement();
+
+ $calendarData = $this->getCalendarById($id);
+ $shares = $this->getShares($id);
+ if ($calendarData === null) {
+ throw new RuntimeException('Calendar data that was just written can\'t be read back. Check your database configuration.');
+ }
+ $this->dispatcher->dispatchTyped(new CalendarRestoredEvent(
+ $id,
+ $calendarData,
+ $shares
+ ));
+ }, $this->db);
+ }
- $this->sharingBackend->deleteAllShares($calendarId);
+ /**
+ * Returns all calendar entries as a stream of data
+ *
+ * @since 32.0.0
+ *
+ * @return Generator<array>
+ */
+ public function exportCalendar(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, ?CalendarExportOptions $options = null): Generator {
+ // extract options
+ $rangeStart = $options?->getRangeStart();
+ $rangeCount = $options?->getRangeCount();
+ // construct query
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from('calendarobjects')
+ ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)))
+ ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType)))
+ ->andWhere($qb->expr()->isNull('deleted_at'));
+ if ($rangeStart !== null) {
+ $qb->andWhere($qb->expr()->gt('uid', $qb->createNamedParameter($rangeStart)));
+ }
+ if ($rangeCount !== null) {
+ $qb->setMaxResults($rangeCount);
+ }
+ if ($rangeStart !== null || $rangeCount !== null) {
+ $qb->orderBy('uid', 'ASC');
+ }
+ $rs = $qb->executeQuery();
+ // iterate through results
+ try {
+ while (($row = $rs->fetch()) !== false) {
+ yield $row;
+ }
+ } finally {
+ $rs->closeCursor();
+ }
+ }
+ /**
+ * Returns all calendar objects with limited metadata for a calendar
+ *
+ * Every item contains an array with the following keys:
+ * * id - the table row id
+ * * etag - An arbitrary string
+ * * uri - a unique key which will be used to construct the uri. This can
+ * be any arbitrary string.
+ * * calendardata - The iCalendar-compatible calendar data
+ *
+ * @param mixed $calendarId
+ * @param int $calendarType
+ * @return array
+ */
+ public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR):array {
$query = $this->db->getQueryBuilder();
- $query->delete($this->dbObjectPropertiesTable)
+ $query->select(['id','uid', 'etag', 'uri', 'calendardata'])
+ ->from('calendarobjects')
->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
- ->execute();
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)))
+ ->andWhere($query->expr()->isNull('deleted_at'));
+ $stmt = $query->executeQuery();
+
+ $result = [];
+ while (($row = $stmt->fetch()) !== false) {
+ $result[$row['uid']] = [
+ 'id' => $row['id'],
+ 'etag' => $row['etag'],
+ 'uri' => $row['uri'],
+ 'calendardata' => $row['calendardata'],
+ ];
+ }
+ $stmt->closeCursor();
+
+ return $result;
}
/**
@@ -805,8 +1083,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param string $principaluri
* @return void
*/
- function deleteAllSharesByUser($principaluri) {
- $this->sharingBackend->deleteAllSharesByUser($principaluri);
+ public function deleteAllSharesByUser($principaluri) {
+ $this->calendarSharingBackend->deleteAllSharesByUser($principaluri);
}
/**
@@ -838,28 +1116,100 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* amount of times this is needed is reduced by a great degree.
*
* @param mixed $calendarId
+ * @param int $calendarType
* @return array
*/
- function getCalendarObjects($calendarId) {
+ public function getCalendarObjects($calendarId, $calendarType = self::CALENDAR_TYPE_CALENDAR):array {
$query = $this->db->getQueryBuilder();
$query->select(['id', 'uri', 'lastmodified', 'etag', 'calendarid', 'size', 'componenttype', 'classification'])
->from('calendarobjects')
- ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)));
- $stmt = $query->execute();
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)))
+ ->andWhere($query->expr()->isNull('deleted_at'));
+ $stmt = $query->executeQuery();
$result = [];
- foreach($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
+ while (($row = $stmt->fetch()) !== false) {
$result[] = [
- 'id' => $row['id'],
- 'uri' => $row['uri'],
- 'lastmodified' => $row['lastmodified'],
- 'etag' => '"' . $row['etag'] . '"',
- 'calendarid' => $row['calendarid'],
- 'size' => (int)$row['size'],
- 'component' => strtolower($row['componenttype']),
- 'classification'=> (int)$row['classification']
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'lastmodified' => $row['lastmodified'],
+ 'etag' => '"' . $row['etag'] . '"',
+ 'calendarid' => $row['calendarid'],
+ 'size' => (int)$row['size'],
+ 'component' => strtolower($row['componenttype']),
+ 'classification' => (int)$row['classification']
];
}
+ $stmt->closeCursor();
+
+ return $result;
+ }
+
+ public function getDeletedCalendarObjects(int $deletedBefore): array {
+ $query = $this->db->getQueryBuilder();
+ $query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.calendartype', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at'])
+ ->from('calendarobjects', 'co')
+ ->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
+ ->where($query->expr()->isNotNull('co.deleted_at'))
+ ->andWhere($query->expr()->lt('co.deleted_at', $query->createNamedParameter($deletedBefore)));
+ $stmt = $query->executeQuery();
+
+ $result = [];
+ while (($row = $stmt->fetch()) !== false) {
+ $result[] = [
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'lastmodified' => $row['lastmodified'],
+ 'etag' => '"' . $row['etag'] . '"',
+ 'calendarid' => (int)$row['calendarid'],
+ 'calendartype' => (int)$row['calendartype'],
+ 'size' => (int)$row['size'],
+ 'component' => strtolower($row['componenttype']),
+ 'classification' => (int)$row['classification'],
+ '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'],
+ ];
+ }
+ $stmt->closeCursor();
+
+ return $result;
+ }
+
+ /**
+ * Return all deleted calendar objects by the given principal that are not
+ * in deleted calendars.
+ *
+ * @param string $principalUri
+ * @return array
+ * @throws Exception
+ */
+ public function getDeletedCalendarObjectsByPrincipal(string $principalUri): array {
+ $query = $this->db->getQueryBuilder();
+ $query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at'])
+ ->selectAlias('c.uri', 'calendaruri')
+ ->from('calendarobjects', 'co')
+ ->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
+ ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)))
+ ->andWhere($query->expr()->isNotNull('co.deleted_at'))
+ ->andWhere($query->expr()->isNull('c.deleted_at'));
+ $stmt = $query->executeQuery();
+
+ $result = [];
+ while ($row = $stmt->fetch()) {
+ $result[] = [
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'lastmodified' => $row['lastmodified'],
+ 'etag' => '"' . $row['etag'] . '"',
+ 'calendarid' => $row['calendarid'],
+ 'calendaruri' => $row['calendaruri'],
+ 'size' => (int)$row['size'],
+ 'component' => strtolower($row['componenttype']),
+ 'classification' => (int)$row['classification'],
+ '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'],
+ ];
+ }
+ $stmt->closeCursor();
return $result;
}
@@ -878,30 +1228,46 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
*
* @param mixed $calendarId
* @param string $objectUri
+ * @param int $calendarType
* @return array|null
*/
- function getCalendarObject($calendarId, $objectUri) {
-
+ public function getCalendarObject($calendarId, $objectUri, int $calendarType = self::CALENDAR_TYPE_CALENDAR) {
+ $key = $calendarId . '::' . $objectUri . '::' . $calendarType;
+ if (isset($this->cachedObjects[$key])) {
+ return $this->cachedObjects[$key];
+ }
$query = $this->db->getQueryBuilder();
- $query->select(['id', 'uri', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification'])
- ->from('calendarobjects')
- ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
- ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri)));
- $stmt = $query->execute();
- $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+ $query->select(['id', 'uri', 'uid', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification', 'deleted_at'])
+ ->from('calendarobjects')
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
+ ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)));
+ $stmt = $query->executeQuery();
+ $row = $stmt->fetch();
+ $stmt->closeCursor();
- if(!$row) return null;
+ if (!$row) {
+ return null;
+ }
+
+ $object = $this->rowToCalendarObject($row);
+ $this->cachedObjects[$key] = $object;
+ return $object;
+ }
+ private function rowToCalendarObject(array $row): array {
return [
- 'id' => $row['id'],
- 'uri' => $row['uri'],
- 'lastmodified' => $row['lastmodified'],
- 'etag' => '"' . $row['etag'] . '"',
- 'calendarid' => $row['calendarid'],
- 'size' => (int)$row['size'],
- 'calendardata' => $this->readBlob($row['calendardata']),
- 'component' => strtolower($row['componenttype']),
- 'classification'=> (int)$row['classification']
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'uid' => $row['uid'],
+ 'lastmodified' => $row['lastmodified'],
+ 'etag' => '"' . $row['etag'] . '"',
+ 'calendarid' => $row['calendarid'],
+ 'size' => (int)$row['size'],
+ 'calendardata' => $this->readBlob($row['calendardata']),
+ 'component' => strtolower($row['componenttype']),
+ 'classification' => (int)$row['classification'],
+ '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'],
];
}
@@ -915,9 +1281,10 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
*
* @param mixed $calendarId
* @param string[] $uris
+ * @param int $calendarType
* @return array
*/
- function getMultipleCalendarObjects($calendarId, array $uris) {
+ public function getMultipleCalendarObjects($calendarId, array $uris, $calendarType = self::CALENDAR_TYPE_CALENDAR):array {
if (empty($uris)) {
return [];
}
@@ -929,27 +1296,30 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$query->select(['id', 'uri', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification'])
->from('calendarobjects')
->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
- ->andWhere($query->expr()->in('uri', $query->createParameter('uri')));
+ ->andWhere($query->expr()->in('uri', $query->createParameter('uri')))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)))
+ ->andWhere($query->expr()->isNull('deleted_at'));
foreach ($chunks as $uris) {
$query->setParameter('uri', $uris, IQueryBuilder::PARAM_STR_ARRAY);
- $result = $query->execute();
+ $result = $query->executeQuery();
while ($row = $result->fetch()) {
$objects[] = [
- 'id' => $row['id'],
- 'uri' => $row['uri'],
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
'lastmodified' => $row['lastmodified'],
- 'etag' => '"' . $row['etag'] . '"',
- 'calendarid' => $row['calendarid'],
- 'size' => (int)$row['size'],
+ 'etag' => '"' . $row['etag'] . '"',
+ 'calendarid' => $row['calendarid'],
+ 'size' => (int)$row['size'],
'calendardata' => $this->readBlob($row['calendardata']),
- 'component' => strtolower($row['componenttype']),
+ 'component' => strtolower($row['componenttype']),
'classification' => (int)$row['classification']
];
}
$result->closeCursor();
}
+
return $objects;
}
@@ -969,56 +1339,83 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param mixed $calendarId
* @param string $objectUri
* @param string $calendarData
+ * @param int $calendarType
* @return string
*/
- function createCalendarObject($calendarId, $objectUri, $calendarData) {
+ public function createCalendarObject($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) {
+ $this->cachedObjects = [];
$extraData = $this->getDenormalizedData($calendarData);
- $q = $this->db->getQueryBuilder();
- $q->select($q->createFunction('COUNT(*)'))
- ->from('calendarobjects')
- ->where($q->expr()->eq('calendarid', $q->createNamedParameter($calendarId)))
- ->andWhere($q->expr()->eq('uid', $q->createNamedParameter($extraData['uid'])));
-
- $result = $q->execute();
- $count = (int) $result->fetchColumn();
- $result->closeCursor();
-
- if ($count !== 0) {
- throw new \Sabre\DAV\Exception\BadRequest('Calendar object with uid already exists in this calendar collection.');
- }
+ return $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $extraData, $calendarType) {
+ // Try to detect duplicates
+ $qb = $this->db->getQueryBuilder();
+ $qb->select($qb->func()->count('*'))
+ ->from('calendarobjects')
+ ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)))
+ ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($extraData['uid'])))
+ ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType)))
+ ->andWhere($qb->expr()->isNull('deleted_at'));
+ $result = $qb->executeQuery();
+ $count = (int)$result->fetchOne();
+ $result->closeCursor();
- $query = $this->db->getQueryBuilder();
- $query->insert('calendarobjects')
- ->values([
- 'calendarid' => $query->createNamedParameter($calendarId),
- 'uri' => $query->createNamedParameter($objectUri),
- 'calendardata' => $query->createNamedParameter($calendarData, IQueryBuilder::PARAM_LOB),
- 'lastmodified' => $query->createNamedParameter(time()),
- 'etag' => $query->createNamedParameter($extraData['etag']),
- 'size' => $query->createNamedParameter($extraData['size']),
- 'componenttype' => $query->createNamedParameter($extraData['componentType']),
- 'firstoccurence' => $query->createNamedParameter($extraData['firstOccurence']),
- 'lastoccurence' => $query->createNamedParameter($extraData['lastOccurence']),
- 'classification' => $query->createNamedParameter($extraData['classification']),
- 'uid' => $query->createNamedParameter($extraData['uid']),
- ])
- ->execute();
+ if ($count !== 0) {
+ throw new BadRequest('Calendar object with uid already exists in this calendar collection.');
+ }
+ // For a more specific error message we also try to explicitly look up the UID but as a deleted entry
+ $qbDel = $this->db->getQueryBuilder();
+ $qbDel->select('*')
+ ->from('calendarobjects')
+ ->where($qbDel->expr()->eq('calendarid', $qbDel->createNamedParameter($calendarId)))
+ ->andWhere($qbDel->expr()->eq('uid', $qbDel->createNamedParameter($extraData['uid'])))
+ ->andWhere($qbDel->expr()->eq('calendartype', $qbDel->createNamedParameter($calendarType)))
+ ->andWhere($qbDel->expr()->isNotNull('deleted_at'));
+ $result = $qbDel->executeQuery();
+ $found = $result->fetch();
+ $result->closeCursor();
+ if ($found !== false) {
+ // the object existed previously but has been deleted
+ // remove the trashbin entry and continue as if it was a new object
+ $this->deleteCalendarObject($calendarId, $found['uri']);
+ }
- $this->updateProperties($calendarId, $objectUri, $calendarData);
+ $query = $this->db->getQueryBuilder();
+ $query->insert('calendarobjects')
+ ->values([
+ 'calendarid' => $query->createNamedParameter($calendarId),
+ 'uri' => $query->createNamedParameter($objectUri),
+ 'calendardata' => $query->createNamedParameter($calendarData, IQueryBuilder::PARAM_LOB),
+ 'lastmodified' => $query->createNamedParameter(time()),
+ 'etag' => $query->createNamedParameter($extraData['etag']),
+ 'size' => $query->createNamedParameter($extraData['size']),
+ 'componenttype' => $query->createNamedParameter($extraData['componentType']),
+ 'firstoccurence' => $query->createNamedParameter($extraData['firstOccurence']),
+ 'lastoccurence' => $query->createNamedParameter($extraData['lastOccurence']),
+ 'classification' => $query->createNamedParameter($extraData['classification']),
+ 'uid' => $query->createNamedParameter($extraData['uid']),
+ 'calendartype' => $query->createNamedParameter($calendarType),
+ ])
+ ->executeStatement();
+
+ $this->updateProperties($calendarId, $objectUri, $calendarData, $calendarType);
+ $this->addChanges($calendarId, [$objectUri], 1, $calendarType);
+
+ $objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType);
+ assert($objectRow !== null);
+
+ if ($calendarType === self::CALENDAR_TYPE_CALENDAR) {
+ $calendarRow = $this->getCalendarById($calendarId);
+ $shares = $this->getShares($calendarId);
+
+ $this->dispatcher->dispatchTyped(new CalendarObjectCreatedEvent($calendarId, $calendarRow, $shares, $objectRow));
+ } else {
+ $subscriptionRow = $this->getSubscriptionById($calendarId);
- $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::createCalendarObject', new GenericEvent(
- '\OCA\DAV\CalDAV\CalDavBackend::createCalendarObject',
- [
- 'calendarId' => $calendarId,
- 'calendarData' => $this->getCalendarById($calendarId),
- 'shares' => $this->getShares($calendarId),
- 'objectData' => $this->getCalendarObject($calendarId, $objectUri),
- ]
- ));
- $this->addChange($calendarId, $objectUri, 1);
+ $this->dispatcher->dispatchTyped(new CachedCalendarObjectCreatedEvent($calendarId, $subscriptionRow, [], $objectRow));
+ }
- return '"' . $extraData['etag'] . '"';
+ return '"' . $extraData['etag'] . '"';
+ }, $this->db);
}
/**
@@ -1037,13 +1434,16 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param mixed $calendarId
* @param string $objectUri
* @param string $calendarData
+ * @param int $calendarType
* @return string
*/
- function updateCalendarObject($calendarId, $objectUri, $calendarData) {
+ public function updateCalendarObject($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) {
+ $this->cachedObjects = [];
$extraData = $this->getDenormalizedData($calendarData);
- $query = $this->db->getQueryBuilder();
- $query->update('calendarobjects')
+ return $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $extraData, $calendarType) {
+ $query = $this->db->getQueryBuilder();
+ $query->update('calendarobjects')
->set('calendardata', $query->createNamedParameter($calendarData, IQueryBuilder::PARAM_LOB))
->set('lastmodified', $query->createNamedParameter(time()))
->set('etag', $query->createNamedParameter($extraData['etag']))
@@ -1053,44 +1453,88 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
->set('lastoccurence', $query->createNamedParameter($extraData['lastOccurence']))
->set('classification', $query->createNamedParameter($extraData['classification']))
->set('uid', $query->createNamedParameter($extraData['uid']))
- ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
- ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri)))
- ->execute();
-
- $this->updateProperties($calendarId, $objectUri, $calendarData);
-
- $data = $this->getCalendarObject($calendarId, $objectUri);
- if (is_array($data)) {
- $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateCalendarObject', new GenericEvent(
- '\OCA\DAV\CalDAV\CalDavBackend::updateCalendarObject',
- [
- 'calendarId' => $calendarId,
- 'calendarData' => $this->getCalendarById($calendarId),
- 'shares' => $this->getShares($calendarId),
- 'objectData' => $data,
- ]
- ));
- }
- $this->addChange($calendarId, $objectUri, 2);
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
+ ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)))
+ ->executeStatement();
+
+ $this->updateProperties($calendarId, $objectUri, $calendarData, $calendarType);
+ $this->addChanges($calendarId, [$objectUri], 2, $calendarType);
+
+ $objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType);
+ if (is_array($objectRow)) {
+ if ($calendarType === self::CALENDAR_TYPE_CALENDAR) {
+ $calendarRow = $this->getCalendarById($calendarId);
+ $shares = $this->getShares($calendarId);
+
+ $this->dispatcher->dispatchTyped(new CalendarObjectUpdatedEvent($calendarId, $calendarRow, $shares, $objectRow));
+ } else {
+ $subscriptionRow = $this->getSubscriptionById($calendarId);
- return '"' . $extraData['etag'] . '"';
+ $this->dispatcher->dispatchTyped(new CachedCalendarObjectUpdatedEvent($calendarId, $subscriptionRow, [], $objectRow));
+ }
+ }
+
+ return '"' . $extraData['etag'] . '"';
+ }, $this->db);
}
/**
- * @param int $calendarObjectId
- * @param int $classification
+ * Moves a calendar object from calendar to calendar.
+ *
+ * @param string $sourcePrincipalUri
+ * @param int $sourceObjectId
+ * @param string $targetPrincipalUri
+ * @param int $targetCalendarId
+ * @param string $tragetObjectUri
+ * @param int $calendarType
+ * @return bool
+ * @throws Exception
*/
- public function setClassification($calendarObjectId, $classification) {
- if (!in_array($classification, [
- self::CLASSIFICATION_PUBLIC, self::CLASSIFICATION_PRIVATE, self::CLASSIFICATION_CONFIDENTIAL
- ])) {
- throw new \InvalidArgumentException();
- }
- $query = $this->db->getQueryBuilder();
- $query->update('calendarobjects')
- ->set('classification', $query->createNamedParameter($classification))
- ->where($query->expr()->eq('id', $query->createNamedParameter($calendarObjectId)))
- ->execute();
+ public function moveCalendarObject(string $sourcePrincipalUri, int $sourceObjectId, string $targetPrincipalUri, int $targetCalendarId, string $tragetObjectUri, int $calendarType = self::CALENDAR_TYPE_CALENDAR): bool {
+ $this->cachedObjects = [];
+ return $this->atomic(function () use ($sourcePrincipalUri, $sourceObjectId, $targetPrincipalUri, $targetCalendarId, $tragetObjectUri, $calendarType) {
+ $object = $this->getCalendarObjectById($sourcePrincipalUri, $sourceObjectId);
+ if (empty($object)) {
+ return false;
+ }
+
+ $sourceCalendarId = $object['calendarid'];
+ $sourceObjectUri = $object['uri'];
+
+ $query = $this->db->getQueryBuilder();
+ $query->update('calendarobjects')
+ ->set('calendarid', $query->createNamedParameter($targetCalendarId, IQueryBuilder::PARAM_INT))
+ ->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR))
+ ->where($query->expr()->eq('id', $query->createNamedParameter($sourceObjectId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
+ ->executeStatement();
+
+ $this->purgeProperties($sourceCalendarId, $sourceObjectId);
+ $this->updateProperties($targetCalendarId, $tragetObjectUri, $object['calendardata'], $calendarType);
+
+ $this->addChanges($sourceCalendarId, [$sourceObjectUri], 3, $calendarType);
+ $this->addChanges($targetCalendarId, [$tragetObjectUri], 1, $calendarType);
+
+ $object = $this->getCalendarObjectById($targetPrincipalUri, $sourceObjectId);
+ // Calendar Object wasn't found - possibly because it was deleted in the meantime by a different client
+ if (empty($object)) {
+ return false;
+ }
+
+ $targetCalendarRow = $this->getCalendarById($targetCalendarId);
+ // the calendar this event is being moved to does not exist any longer
+ if (empty($targetCalendarRow)) {
+ return false;
+ }
+
+ if ($calendarType === self::CALENDAR_TYPE_CALENDAR) {
+ $sourceShares = $this->getShares($sourceCalendarId);
+ $targetShares = $this->getShares($targetCalendarId);
+ $sourceCalendarRow = $this->getCalendarById($sourceCalendarId);
+ $this->dispatcher->dispatchTyped(new CalendarObjectMovedEvent($sourceCalendarId, $sourceCalendarRow, $targetCalendarId, $targetCalendarRow, $sourceShares, $targetShares, $object));
+ }
+ return true;
+ }, $this->db);
}
/**
@@ -1100,28 +1544,142 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
*
* @param mixed $calendarId
* @param string $objectUri
+ * @param int $calendarType
+ * @param bool $forceDeletePermanently
* @return void
*/
- function deleteCalendarObject($calendarId, $objectUri) {
- $data = $this->getCalendarObject($calendarId, $objectUri);
- if (is_array($data)) {
- $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::deleteCalendarObject', new GenericEvent(
- '\OCA\DAV\CalDAV\CalDavBackend::deleteCalendarObject',
- [
- 'calendarId' => $calendarId,
- 'calendarData' => $this->getCalendarById($calendarId),
- 'shares' => $this->getShares($calendarId),
- 'objectData' => $data,
- ]
- ));
- }
+ public function deleteCalendarObject($calendarId, $objectUri, $calendarType = self::CALENDAR_TYPE_CALENDAR, bool $forceDeletePermanently = false) {
+ $this->cachedObjects = [];
+ $this->atomic(function () use ($calendarId, $objectUri, $calendarType, $forceDeletePermanently): void {
+ $data = $this->getCalendarObject($calendarId, $objectUri, $calendarType);
+
+ if ($data === null) {
+ // Nothing to delete
+ return;
+ }
- $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ? AND `uri` = ?');
- $stmt->execute([$calendarId, $objectUri]);
+ if ($forceDeletePermanently || $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0') {
+ $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ? AND `uri` = ? AND `calendartype` = ?');
+ $stmt->execute([$calendarId, $objectUri, $calendarType]);
- $this->purgeProperties($calendarId, $data['id']);
+ $this->purgeProperties($calendarId, $data['id']);
- $this->addChange($calendarId, $objectUri, 3);
+ $this->purgeObjectInvitations($data['uid']);
+
+ if ($calendarType === self::CALENDAR_TYPE_CALENDAR) {
+ $calendarRow = $this->getCalendarById($calendarId);
+ $shares = $this->getShares($calendarId);
+
+ $this->dispatcher->dispatchTyped(new CalendarObjectDeletedEvent($calendarId, $calendarRow, $shares, $data));
+ } else {
+ $subscriptionRow = $this->getSubscriptionById($calendarId);
+
+ $this->dispatcher->dispatchTyped(new CachedCalendarObjectDeletedEvent($calendarId, $subscriptionRow, [], $data));
+ }
+ } else {
+ $pathInfo = pathinfo($data['uri']);
+ if (!empty($pathInfo['extension'])) {
+ // Append a suffix to "free" the old URI for recreation
+ $newUri = sprintf(
+ '%s-deleted.%s',
+ $pathInfo['filename'],
+ $pathInfo['extension']
+ );
+ } else {
+ $newUri = sprintf(
+ '%s-deleted',
+ $pathInfo['filename']
+ );
+ }
+
+ // Try to detect conflicts before the DB does
+ // As unlikely as it seems, this can happen when the user imports, then deletes, imports and deletes again
+ $newObject = $this->getCalendarObject($calendarId, $newUri, $calendarType);
+ if ($newObject !== null) {
+ throw new Forbidden("A calendar object with URI $newUri already exists in calendar $calendarId, therefore this object can't be moved into the trashbin");
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $markObjectDeletedQuery = $qb->update('calendarobjects')
+ ->set('deleted_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
+ ->set('uri', $qb->createNamedParameter($newUri))
+ ->where(
+ $qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)),
+ $qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
+ $qb->expr()->eq('uri', $qb->createNamedParameter($objectUri))
+ );
+ $markObjectDeletedQuery->executeStatement();
+
+ $calendarData = $this->getCalendarById($calendarId);
+ if ($calendarData !== null) {
+ $this->dispatcher->dispatchTyped(
+ new CalendarObjectMovedToTrashEvent(
+ $calendarId,
+ $calendarData,
+ $this->getShares($calendarId),
+ $data
+ )
+ );
+ }
+ }
+
+ $this->addChanges($calendarId, [$objectUri], 3, $calendarType);
+ }, $this->db);
+ }
+
+ /**
+ * @param mixed $objectData
+ *
+ * @throws Forbidden
+ */
+ public function restoreCalendarObject(array $objectData): void {
+ $this->cachedObjects = [];
+ $this->atomic(function () use ($objectData): void {
+ $id = (int)$objectData['id'];
+ $restoreUri = str_replace('-deleted.ics', '.ics', $objectData['uri']);
+ $targetObject = $this->getCalendarObject(
+ $objectData['calendarid'],
+ $restoreUri
+ );
+ if ($targetObject !== null) {
+ throw new Forbidden("Can not restore calendar $id because a calendar object with the URI $restoreUri already exists");
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $update = $qb->update('calendarobjects')
+ ->set('uri', $qb->createNamedParameter($restoreUri))
+ ->set('deleted_at', $qb->createNamedParameter(null))
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT));
+ $update->executeStatement();
+
+ // Make sure this change is tracked in the changes table
+ $qb2 = $this->db->getQueryBuilder();
+ $selectObject = $qb2->select('calendardata', 'uri', 'calendarid', 'calendartype')
+ ->selectAlias('componenttype', 'component')
+ ->from('calendarobjects')
+ ->where($qb2->expr()->eq('id', $qb2->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT));
+ $result = $selectObject->executeQuery();
+ $row = $result->fetch();
+ $result->closeCursor();
+ if ($row === false) {
+ // Welp, this should possibly not have happened, but let's ignore
+ return;
+ }
+ $this->addChanges($row['calendarid'], [$row['uri']], 1, (int)$row['calendartype']);
+
+ $calendarRow = $this->getCalendarById((int)$row['calendarid']);
+ if ($calendarRow === null) {
+ throw new RuntimeException('Calendar object data that was just written can\'t be read back. Check your database configuration.');
+ }
+ $this->dispatcher->dispatchTyped(
+ new CalendarObjectRestoredEvent(
+ (int)$objectData['calendarid'],
+ $calendarRow,
+ $this->getShares((int)$row['calendarid']),
+ $row
+ )
+ );
+ }, $this->db);
}
/**
@@ -1164,16 +1722,17 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* Note that especially time-range-filters may be difficult to parse. A
* time-range filter specified on a VEVENT must for instance also handle
* recurrence rules correctly.
- * A good example of how to interprete all these filters can also simply
+ * A good example of how to interpret all these filters can also simply
* be found in Sabre\CalDAV\CalendarQueryFilter. This class is as correct
* as possible, so it gives you a good idea on what type of stuff you need
* to think of.
*
* @param mixed $calendarId
* @param array $filters
+ * @param int $calendarType
* @return array
*/
- function calendarQuery($calendarId, array $filters) {
+ public function calendarQuery($calendarId, array $filters, $calendarType = self::CALENDAR_TYPE_CALENDAR):array {
$componentType = null;
$requirePostFilter = true;
$timeRange = null;
@@ -1192,7 +1751,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$requirePostFilter = false;
}
// There was a time-range filter
- if ($componentType === 'VEVENT' && isset($filters['comp-filters'][0]['time-range'])) {
+ if ($componentType === 'VEVENT' && isset($filters['comp-filters'][0]['time-range']) && is_array($filters['comp-filters'][0]['time-range'])) {
$timeRange = $filters['comp-filters'][0]['time-range'];
// If start time OR the end time is not specified, we can do a
@@ -1201,16 +1760,13 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$requirePostFilter = false;
}
}
-
- }
- $columns = ['uri'];
- if ($requirePostFilter) {
- $columns = ['uri', 'calendardata'];
}
$query = $this->db->getQueryBuilder();
- $query->select($columns)
+ $query->select(['id', 'uri', 'uid', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification', 'deleted_at'])
->from('calendarobjects')
- ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)));
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)))
+ ->andWhere($query->expr()->isNull('deleted_at'));
if ($componentType) {
$query->andWhere($query->expr()->eq('componenttype', $query->createNamedParameter($componentType)));
@@ -1223,25 +1779,36 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$query->andWhere($query->expr()->lt('firstoccurence', $query->createNamedParameter($timeRange['end']->getTimeStamp())));
}
- $stmt = $query->execute();
+ $stmt = $query->executeQuery();
$result = [];
- while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+ while ($row = $stmt->fetch()) {
+ // if we leave it as a blob we can't read it both from the post filter and the rowToCalendarObject
+ if (isset($row['calendardata'])) {
+ $row['calendardata'] = $this->readBlob($row['calendardata']);
+ }
+
if ($requirePostFilter) {
// validateFilterForObject will parse the calendar data
// catch parsing errors
try {
$matches = $this->validateFilterForObject($row, $filters);
- } catch(ParseException $ex) {
- $this->logger->logException($ex, [
+ } catch (ParseException $ex) {
+ $this->logger->error('Caught parsing exception for calendar data. This usually indicates invalid calendar data. calendar-id:' . $calendarId . ' uri:' . $row['uri'], [
'app' => 'dav',
- 'message' => 'Caught parsing exception for calendar data. This usually indicates invalid calendar data. calendar-id:'.$calendarId.' uri:'.$row['uri']
+ 'exception' => $ex,
]);
continue;
} catch (InvalidDataException $ex) {
- $this->logger->logException($ex, [
+ $this->logger->error('Caught invalid data exception for calendar data. This usually indicates invalid calendar data. calendar-id:' . $calendarId . ' uri:' . $row['uri'], [
+ 'app' => 'dav',
+ 'exception' => $ex,
+ ]);
+ continue;
+ } catch (MaxInstancesExceededException $ex) {
+ $this->logger->warning('Caught max instances exceeded exception for calendar data. This usually indicates too much recurring (more than 3500) event in calendar data. Object uri: ' . $row['uri'], [
'app' => 'dav',
- 'message' => 'Caught invalid data exception for calendar data. This usually indicates invalid calendar data. calendar-id:'.$calendarId.' uri:'.$row['uri']
+ 'exception' => $ex,
]);
continue;
}
@@ -1251,6 +1818,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
}
}
$result[] = $row['uri'];
+ $key = $calendarId . '::' . $row['uri'] . '::' . $calendarType;
+ $this->cachedObjects[$key] = $this->rowToCalendarObject($row);
}
return $result;
@@ -1259,120 +1828,129 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
/**
* custom Nextcloud search extension for CalDAV
*
+ * TODO - this should optionally cover cached calendar objects as well
+ *
* @param string $principalUri
* @param array $filters
* @param integer|null $limit
* @param integer|null $offset
* @return array
*/
- public function calendarSearch($principalUri, array $filters, $limit=null, $offset=null) {
- $calendars = $this->getCalendarsForUser($principalUri);
- $ownCalendars = [];
- $sharedCalendars = [];
+ public function calendarSearch($principalUri, array $filters, $limit = null, $offset = null) {
+ return $this->atomic(function () use ($principalUri, $filters, $limit, $offset) {
+ $calendars = $this->getCalendarsForUser($principalUri);
+ $ownCalendars = [];
+ $sharedCalendars = [];
- $uriMapper = [];
+ $uriMapper = [];
- foreach($calendars as $calendar) {
- if ($calendar['{http://owncloud.org/ns}owner-principal'] === $principalUri) {
- $ownCalendars[] = $calendar['id'];
- } else {
- $sharedCalendars[] = $calendar['id'];
+ foreach ($calendars as $calendar) {
+ if ($calendar['{http://owncloud.org/ns}owner-principal'] === $principalUri) {
+ $ownCalendars[] = $calendar['id'];
+ } else {
+ $sharedCalendars[] = $calendar['id'];
+ }
+ $uriMapper[$calendar['id']] = $calendar['uri'];
+ }
+ if (count($ownCalendars) === 0 && count($sharedCalendars) === 0) {
+ return [];
}
- $uriMapper[$calendar['id']] = $calendar['uri'];
- }
- if (count($ownCalendars) === 0 && count($sharedCalendars) === 0) {
- return [];
- }
-
- $query = $this->db->getQueryBuilder();
- // Calendar id expressions
- $calendarExpressions = [];
- foreach($ownCalendars as $id) {
- $calendarExpressions[] = $query->expr()
- ->eq('c.calendarid', $query->createNamedParameter($id));
- }
- foreach($sharedCalendars as $id) {
- $calendarExpressions[] = $query->expr()->andX(
- $query->expr()->eq('c.calendarid',
- $query->createNamedParameter($id)),
- $query->expr()->eq('c.classification',
- $query->createNamedParameter(self::CLASSIFICATION_PUBLIC))
- );
- }
- if (count($calendarExpressions) === 1) {
- $calExpr = $calendarExpressions[0];
- } else {
- $calExpr = call_user_func_array([$query->expr(), 'orX'], $calendarExpressions);
- }
+ $query = $this->db->getQueryBuilder();
+ // Calendar id expressions
+ $calendarExpressions = [];
+ foreach ($ownCalendars as $id) {
+ $calendarExpressions[] = $query->expr()->andX(
+ $query->expr()->eq('c.calendarid',
+ $query->createNamedParameter($id)),
+ $query->expr()->eq('c.calendartype',
+ $query->createNamedParameter(self::CALENDAR_TYPE_CALENDAR)));
+ }
+ foreach ($sharedCalendars as $id) {
+ $calendarExpressions[] = $query->expr()->andX(
+ $query->expr()->eq('c.calendarid',
+ $query->createNamedParameter($id)),
+ $query->expr()->eq('c.classification',
+ $query->createNamedParameter(self::CLASSIFICATION_PUBLIC)),
+ $query->expr()->eq('c.calendartype',
+ $query->createNamedParameter(self::CALENDAR_TYPE_CALENDAR)));
+ }
- // Component expressions
- $compExpressions = [];
- foreach($filters['comps'] as $comp) {
- $compExpressions[] = $query->expr()
- ->eq('c.componenttype', $query->createNamedParameter($comp));
- }
+ if (count($calendarExpressions) === 1) {
+ $calExpr = $calendarExpressions[0];
+ } else {
+ $calExpr = call_user_func_array([$query->expr(), 'orX'], $calendarExpressions);
+ }
- if (count($compExpressions) === 1) {
- $compExpr = $compExpressions[0];
- } else {
- $compExpr = call_user_func_array([$query->expr(), 'orX'], $compExpressions);
- }
+ // Component expressions
+ $compExpressions = [];
+ foreach ($filters['comps'] as $comp) {
+ $compExpressions[] = $query->expr()
+ ->eq('c.componenttype', $query->createNamedParameter($comp));
+ }
- if (!isset($filters['props'])) {
- $filters['props'] = [];
- }
- if (!isset($filters['params'])) {
- $filters['params'] = [];
- }
+ if (count($compExpressions) === 1) {
+ $compExpr = $compExpressions[0];
+ } else {
+ $compExpr = call_user_func_array([$query->expr(), 'orX'], $compExpressions);
+ }
- $propParamExpressions = [];
- foreach($filters['props'] as $prop) {
- $propParamExpressions[] = $query->expr()->andX(
- $query->expr()->eq('i.name', $query->createNamedParameter($prop)),
- $query->expr()->isNull('i.parameter')
- );
- }
- foreach($filters['params'] as $param) {
- $propParamExpressions[] = $query->expr()->andX(
- $query->expr()->eq('i.name', $query->createNamedParameter($param['property'])),
- $query->expr()->eq('i.parameter', $query->createNamedParameter($param['parameter']))
- );
- }
+ if (!isset($filters['props'])) {
+ $filters['props'] = [];
+ }
+ if (!isset($filters['params'])) {
+ $filters['params'] = [];
+ }
- if (count($propParamExpressions) === 1) {
- $propParamExpr = $propParamExpressions[0];
- } else {
- $propParamExpr = call_user_func_array([$query->expr(), 'orX'], $propParamExpressions);
- }
+ $propParamExpressions = [];
+ foreach ($filters['props'] as $prop) {
+ $propParamExpressions[] = $query->expr()->andX(
+ $query->expr()->eq('i.name', $query->createNamedParameter($prop)),
+ $query->expr()->isNull('i.parameter')
+ );
+ }
+ foreach ($filters['params'] as $param) {
+ $propParamExpressions[] = $query->expr()->andX(
+ $query->expr()->eq('i.name', $query->createNamedParameter($param['property'])),
+ $query->expr()->eq('i.parameter', $query->createNamedParameter($param['parameter']))
+ );
+ }
- $query->select(['c.calendarid', 'c.uri'])
- ->from($this->dbObjectPropertiesTable, 'i')
- ->join('i', 'calendarobjects', 'c', $query->expr()->eq('i.objectid', 'c.id'))
- ->where($calExpr)
- ->andWhere($compExpr)
- ->andWhere($propParamExpr)
- ->andWhere($query->expr()->iLike('i.value',
- $query->createNamedParameter('%'.$this->db->escapeLikeParameter($filters['search-term']).'%')));
+ if (count($propParamExpressions) === 1) {
+ $propParamExpr = $propParamExpressions[0];
+ } else {
+ $propParamExpr = call_user_func_array([$query->expr(), 'orX'], $propParamExpressions);
+ }
- if ($offset) {
- $query->setFirstResult($offset);
- }
- if ($limit) {
- $query->setMaxResults($limit);
- }
+ $query->select(['c.calendarid', 'c.uri'])
+ ->from($this->dbObjectPropertiesTable, 'i')
+ ->join('i', 'calendarobjects', 'c', $query->expr()->eq('i.objectid', 'c.id'))
+ ->where($calExpr)
+ ->andWhere($compExpr)
+ ->andWhere($propParamExpr)
+ ->andWhere($query->expr()->iLike('i.value',
+ $query->createNamedParameter('%' . $this->db->escapeLikeParameter($filters['search-term']) . '%')))
+ ->andWhere($query->expr()->isNull('deleted_at'));
+
+ if ($offset) {
+ $query->setFirstResult($offset);
+ }
+ if ($limit) {
+ $query->setMaxResults($limit);
+ }
- $stmt = $query->execute();
+ $stmt = $query->executeQuery();
- $result = [];
- while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
- $path = $uriMapper[$row['calendarid']] . '/' . $row['uri'];
- if (!in_array($path, $result)) {
- $result[] = $path;
+ $result = [];
+ while ($row = $stmt->fetch()) {
+ $path = $uriMapper[$row['calendarid']] . '/' . $row['uri'];
+ if (!in_array($path, $result)) {
+ $result[] = $path;
+ }
}
- }
- return $result;
+ return $result;
+ }, $this->db);
}
/**
@@ -1387,78 +1965,155 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
*
* @return array
*/
- public function search(array $calendarInfo, $pattern, array $searchProperties,
- array $options, $limit, $offset) {
+ public function search(
+ array $calendarInfo,
+ $pattern,
+ array $searchProperties,
+ array $options,
+ $limit,
+ $offset,
+ ) {
$outerQuery = $this->db->getQueryBuilder();
$innerQuery = $this->db->getQueryBuilder();
+ if (isset($calendarInfo['source'])) {
+ $calendarType = self::CALENDAR_TYPE_SUBSCRIPTION;
+ } else {
+ $calendarType = self::CALENDAR_TYPE_CALENDAR;
+ }
+
$innerQuery->selectDistinct('op.objectid')
->from($this->dbObjectPropertiesTable, 'op')
->andWhere($innerQuery->expr()->eq('op.calendarid',
- $outerQuery->createNamedParameter($calendarInfo['id'])));
+ $outerQuery->createNamedParameter($calendarInfo['id'])))
+ ->andWhere($innerQuery->expr()->eq('op.calendartype',
+ $outerQuery->createNamedParameter($calendarType)));
+
+ $outerQuery->select('c.id', 'c.calendardata', 'c.componenttype', 'c.uid', 'c.uri')
+ ->from('calendarobjects', 'c')
+ ->where($outerQuery->expr()->isNull('deleted_at'));
// only return public items for shared calendars for now
- if ($calendarInfo['principaluri'] !== $calendarInfo['{http://owncloud.org/ns}owner-principal']) {
- $innerQuery->andWhere($innerQuery->expr()->eq('c.classification',
+ if (isset($calendarInfo['{http://owncloud.org/ns}owner-principal']) === false || $calendarInfo['principaluri'] !== $calendarInfo['{http://owncloud.org/ns}owner-principal']) {
+ $outerQuery->andWhere($outerQuery->expr()->eq('c.classification',
$outerQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC)));
}
- $or = $innerQuery->expr()->orX();
- foreach($searchProperties as $searchProperty) {
- $or->add($innerQuery->expr()->eq('op.name',
- $outerQuery->createNamedParameter($searchProperty)));
+ if (!empty($searchProperties)) {
+ $or = [];
+ foreach ($searchProperties as $searchProperty) {
+ $or[] = $innerQuery->expr()->eq('op.name',
+ $outerQuery->createNamedParameter($searchProperty));
+ }
+ $innerQuery->andWhere($innerQuery->expr()->orX(...$or));
}
- $innerQuery->andWhere($or);
if ($pattern !== '') {
$innerQuery->andWhere($innerQuery->expr()->iLike('op.value',
- $outerQuery->createNamedParameter('%' .
- $this->db->escapeLikeParameter($pattern) . '%')));
+ $outerQuery->createNamedParameter('%'
+ . $this->db->escapeLikeParameter($pattern) . '%')));
}
- $outerQuery->select('c.id', 'c.calendardata', 'c.componenttype', 'c.uid', 'c.uri')
- ->from('calendarobjects', 'c');
+ $start = null;
+ $end = null;
- if (isset($options['timerange'])) {
- if (isset($options['timerange']['start'])) {
- $outerQuery->andWhere($outerQuery->expr()->gt('lastoccurence',
- $outerQuery->createNamedParameter($options['timerange']['start']->getTimeStamp)));
+ $hasLimit = is_int($limit);
+ $hasTimeRange = false;
- }
- if (isset($options['timerange']['end'])) {
- $outerQuery->andWhere($outerQuery->expr()->lt('firstoccurence',
- $outerQuery->createNamedParameter($options['timerange']['end']->getTimeStamp)));
- }
+ if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
+ /** @var DateTimeInterface $start */
+ $start = $options['timerange']['start'];
+ $outerQuery->andWhere(
+ $outerQuery->expr()->gt(
+ 'lastoccurence',
+ $outerQuery->createNamedParameter($start->getTimestamp())
+ )
+ );
+ $hasTimeRange = true;
}
- if (isset($options['types'])) {
- $or = $outerQuery->expr()->orX();
- foreach($options['types'] as $type) {
- $or->add($outerQuery->expr()->eq('componenttype',
- $outerQuery->createNamedParameter($type)));
- }
- $outerQuery->andWhere($or);
+ if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
+ /** @var DateTimeInterface $end */
+ $end = $options['timerange']['end'];
+ $outerQuery->andWhere(
+ $outerQuery->expr()->lt(
+ 'firstoccurence',
+ $outerQuery->createNamedParameter($end->getTimestamp())
+ )
+ );
+ $hasTimeRange = true;
}
- $outerQuery->andWhere($outerQuery->expr()->in('c.id',
- $outerQuery->createFunction($innerQuery->getSQL())));
-
- if ($offset) {
- $outerQuery->setFirstResult($offset);
+ if (isset($options['uid'])) {
+ $outerQuery->andWhere($outerQuery->expr()->eq('uid', $outerQuery->createNamedParameter($options['uid'])));
}
- if ($limit) {
+
+ if (!empty($options['types'])) {
+ $or = [];
+ foreach ($options['types'] as $type) {
+ $or[] = $outerQuery->expr()->eq('componenttype',
+ $outerQuery->createNamedParameter($type));
+ }
+ $outerQuery->andWhere($outerQuery->expr()->orX(...$or));
+ }
+
+ $outerQuery->andWhere($outerQuery->expr()->in('c.id', $outerQuery->createFunction($innerQuery->getSQL())));
+
+ // Without explicit order by its undefined in which order the SQL server returns the events.
+ // For the pagination with hasLimit and hasTimeRange, a stable ordering is helpful.
+ $outerQuery->addOrderBy('id');
+
+ $offset = (int)$offset;
+ $outerQuery->setFirstResult($offset);
+
+ $calendarObjects = [];
+
+ if ($hasLimit && $hasTimeRange) {
+ /**
+ * Event recurrences are evaluated at runtime because the database only knows the first and last occurrence.
+ *
+ * Given, a user created 8 events with a yearly reoccurrence and two for events tomorrow.
+ * The upcoming event widget asks the CalDAV backend for 7 events within the next 14 days.
+ *
+ * If limit 7 is applied to the SQL query, we find the 7 events with a yearly reoccurrence
+ * and discard the events after evaluating the reoccurrence rules because they are not due within
+ * the next 14 days and end up with an empty result even if there are two events to show.
+ *
+ * The workaround for search requests with a limit and time range is asking for more row than requested
+ * and retrying if we have not reached the limit.
+ *
+ * 25 rows and 3 retries is entirely arbitrary.
+ */
+ $maxResults = (int)max($limit, 25);
+ $outerQuery->setMaxResults($maxResults);
+
+ for ($attempt = $objectsCount = 0; $attempt < 3 && $objectsCount < $limit; $attempt++) {
+ $objectsCount = array_push($calendarObjects, ...$this->searchCalendarObjects($outerQuery, $start, $end));
+ $outerQuery->setFirstResult($offset += $maxResults);
+ }
+
+ $calendarObjects = array_slice($calendarObjects, 0, $limit, false);
+ } else {
$outerQuery->setMaxResults($limit);
+ $calendarObjects = $this->searchCalendarObjects($outerQuery, $start, $end);
}
- $result = $outerQuery->execute();
- $calendarObjects = $result->fetchAll();
-
- return array_map(function($o) {
+ $calendarObjects = array_map(function ($o) use ($options) {
$calendarData = Reader::read($o['calendardata']);
+
+ // Expand recurrences if an explicit time range is requested
+ if ($calendarData instanceof VCalendar
+ && isset($options['timerange']['start'], $options['timerange']['end'])) {
+ $calendarData = $calendarData->expand(
+ $options['timerange']['start'],
+ $options['timerange']['end'],
+ );
+ }
+
$comps = $calendarData->getComponents();
$objects = [];
$timezones = [];
- foreach($comps as $comp) {
+ foreach ($comps as $comp) {
if ($comp instanceof VTimeZone) {
$timezones[] = $comp;
} else {
@@ -1471,14 +2126,80 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
'type' => $o['componenttype'],
'uid' => $o['uid'],
'uri' => $o['uri'],
- 'objects' => array_map(function($c) {
+ 'objects' => array_map(function ($c) {
return $this->transformSearchData($c);
}, $objects),
- 'timezones' => array_map(function($c) {
+ 'timezones' => array_map(function ($c) {
return $this->transformSearchData($c);
}, $timezones),
];
}, $calendarObjects);
+
+ usort($calendarObjects, function (array $a, array $b) {
+ /** @var DateTimeImmutable $startA */
+ $startA = $a['objects'][0]['DTSTART'][0] ?? new DateTimeImmutable(self::MAX_DATE);
+ /** @var DateTimeImmutable $startB */
+ $startB = $b['objects'][0]['DTSTART'][0] ?? new DateTimeImmutable(self::MAX_DATE);
+
+ return $startA->getTimestamp() <=> $startB->getTimestamp();
+ });
+
+ return $calendarObjects;
+ }
+
+ private function searchCalendarObjects(IQueryBuilder $query, ?DateTimeInterface $start, ?DateTimeInterface $end): array {
+ $calendarObjects = [];
+ $filterByTimeRange = ($start instanceof DateTimeInterface) || ($end instanceof DateTimeInterface);
+
+ $result = $query->executeQuery();
+
+ while (($row = $result->fetch()) !== false) {
+ if ($filterByTimeRange === false) {
+ // No filter required
+ $calendarObjects[] = $row;
+ continue;
+ }
+
+ try {
+ $isValid = $this->validateFilterForObject($row, [
+ 'name' => 'VCALENDAR',
+ 'comp-filters' => [
+ [
+ 'name' => 'VEVENT',
+ 'comp-filters' => [],
+ 'prop-filters' => [],
+ 'is-not-defined' => false,
+ 'time-range' => [
+ 'start' => $start,
+ 'end' => $end,
+ ],
+ ],
+ ],
+ 'prop-filters' => [],
+ 'is-not-defined' => false,
+ 'time-range' => null,
+ ]);
+ } catch (MaxInstancesExceededException $ex) {
+ $this->logger->warning('Caught max instances exceeded exception for calendar data. This usually indicates too much recurring (more than 3500) event in calendar data. Object uri: ' . $row['uri'], [
+ 'app' => 'dav',
+ 'exception' => $ex,
+ ]);
+ continue;
+ }
+
+ if (is_resource($row['calendardata'])) {
+ // Put the stream back to the beginning so it can be read another time
+ rewind($row['calendardata']);
+ }
+
+ if ($isValid) {
+ $calendarObjects[] = $row;
+ }
+ }
+
+ $result->closeCursor();
+
+ return $calendarObjects;
}
/**
@@ -1490,12 +2211,12 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
/** @var Component[] $subComponents */
$subComponents = $comp->getComponents();
/** @var Property[] $properties */
- $properties = array_filter($comp->children(), function($c) {
+ $properties = array_filter($comp->children(), function ($c) {
return $c instanceof Property;
});
$validationRules = $comp->getValidationRules();
- foreach($subComponents as $subComponent) {
+ foreach ($subComponents as $subComponent) {
$name = $subComponent->name;
if (!isset($data[$name])) {
$data[$name] = [];
@@ -1503,7 +2224,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$data[$name][] = $this->transformSearchData($subComponent);
}
- foreach($properties as $property) {
+ foreach ($properties as $property) {
$name = $property->name;
if (!isset($validationRules[$name])) {
$validationRules[$name] = '*';
@@ -1543,6 +2264,148 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
}
/**
+ * @param string $principalUri
+ * @param string $pattern
+ * @param array $componentTypes
+ * @param array $searchProperties
+ * @param array $searchParameters
+ * @param array $options
+ * @return array
+ */
+ public function searchPrincipalUri(string $principalUri,
+ string $pattern,
+ array $componentTypes,
+ array $searchProperties,
+ array $searchParameters,
+ array $options = [],
+ ): array {
+ return $this->atomic(function () use ($principalUri, $pattern, $componentTypes, $searchProperties, $searchParameters, $options) {
+ $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false;
+
+ $calendarObjectIdQuery = $this->db->getQueryBuilder();
+ $calendarOr = [];
+ $searchOr = [];
+
+ // Fetch calendars and subscription
+ $calendars = $this->getCalendarsForUser($principalUri);
+ $subscriptions = $this->getSubscriptionsForUser($principalUri);
+ foreach ($calendars as $calendar) {
+ $calendarAnd = $calendarObjectIdQuery->expr()->andX(
+ $calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$calendar['id'])),
+ $calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_CALENDAR)),
+ );
+
+ // If it's shared, limit search to public events
+ if (isset($calendar['{http://owncloud.org/ns}owner-principal'])
+ && $calendar['principaluri'] !== $calendar['{http://owncloud.org/ns}owner-principal']) {
+ $calendarAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC)));
+ }
+
+ $calendarOr[] = $calendarAnd;
+ }
+ foreach ($subscriptions as $subscription) {
+ $subscriptionAnd = $calendarObjectIdQuery->expr()->andX(
+ $calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$subscription['id'])),
+ $calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)),
+ );
+
+ // If it's shared, limit search to public events
+ if (isset($subscription['{http://owncloud.org/ns}owner-principal'])
+ && $subscription['principaluri'] !== $subscription['{http://owncloud.org/ns}owner-principal']) {
+ $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC)));
+ }
+
+ $calendarOr[] = $subscriptionAnd;
+ }
+
+ foreach ($searchProperties as $property) {
+ $propertyAnd = $calendarObjectIdQuery->expr()->andX(
+ $calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR)),
+ $calendarObjectIdQuery->expr()->isNull('cob.parameter'),
+ );
+
+ $searchOr[] = $propertyAnd;
+ }
+ foreach ($searchParameters as $property => $parameter) {
+ $parameterAnd = $calendarObjectIdQuery->expr()->andX(
+ $calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR)),
+ $calendarObjectIdQuery->expr()->eq('cob.parameter', $calendarObjectIdQuery->createNamedParameter($parameter, IQueryBuilder::PARAM_STR_ARRAY)),
+ );
+
+ $searchOr[] = $parameterAnd;
+ }
+
+ if (empty($calendarOr)) {
+ return [];
+ }
+ if (empty($searchOr)) {
+ return [];
+ }
+
+ $calendarObjectIdQuery->selectDistinct('cob.objectid')
+ ->from($this->dbObjectPropertiesTable, 'cob')
+ ->leftJoin('cob', 'calendarobjects', 'co', $calendarObjectIdQuery->expr()->eq('co.id', 'cob.objectid'))
+ ->andWhere($calendarObjectIdQuery->expr()->in('co.componenttype', $calendarObjectIdQuery->createNamedParameter($componentTypes, IQueryBuilder::PARAM_STR_ARRAY)))
+ ->andWhere($calendarObjectIdQuery->expr()->orX(...$calendarOr))
+ ->andWhere($calendarObjectIdQuery->expr()->orX(...$searchOr))
+ ->andWhere($calendarObjectIdQuery->expr()->isNull('deleted_at'));
+
+ if ($pattern !== '') {
+ if (!$escapePattern) {
+ $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter($pattern)));
+ } else {
+ $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%')));
+ }
+ }
+
+ if (isset($options['limit'])) {
+ $calendarObjectIdQuery->setMaxResults($options['limit']);
+ }
+ if (isset($options['offset'])) {
+ $calendarObjectIdQuery->setFirstResult($options['offset']);
+ }
+ if (isset($options['timerange'])) {
+ if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) {
+ $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt(
+ 'lastoccurence',
+ $calendarObjectIdQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()),
+ ));
+ }
+ if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) {
+ $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt(
+ 'firstoccurence',
+ $calendarObjectIdQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()),
+ ));
+ }
+ }
+
+ $result = $calendarObjectIdQuery->executeQuery();
+ $matches = [];
+ while (($row = $result->fetch()) !== false) {
+ $matches[] = (int)$row['objectid'];
+ }
+ $result->closeCursor();
+
+ $query = $this->db->getQueryBuilder();
+ $query->select('calendardata', 'uri', 'calendarid', 'calendartype')
+ ->from('calendarobjects')
+ ->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY)));
+
+ $result = $query->executeQuery();
+ $calendarObjects = [];
+ while (($array = $result->fetch()) !== false) {
+ $array['calendarid'] = (int)$array['calendarid'];
+ $array['calendartype'] = (int)$array['calendartype'];
+ $array['calendardata'] = $this->readBlob($array['calendardata']);
+
+ $calendarObjects[] = $array;
+ }
+ $result->closeCursor();
+ return $calendarObjects;
+ }, $this->db);
+ }
+
+ /**
* Searches through all of a users calendars and calendar objects to find
* an object with a specific UID.
*
@@ -1561,24 +2424,55 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param string $uid
* @return string|null
*/
- function getCalendarObjectByUID($principalUri, $uid) {
-
+ public function getCalendarObjectByUID($principalUri, $uid) {
$query = $this->db->getQueryBuilder();
$query->selectAlias('c.uri', 'calendaruri')->selectAlias('co.uri', 'objecturi')
->from('calendarobjects', 'co')
->leftJoin('co', 'calendars', 'c', $query->expr()->eq('co.calendarid', 'c.id'))
->where($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri)))
- ->andWhere($query->expr()->eq('co.uid', $query->createNamedParameter($uid)));
-
- $stmt = $query->execute();
-
- if ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+ ->andWhere($query->expr()->eq('co.uid', $query->createNamedParameter($uid)))
+ ->andWhere($query->expr()->isNull('co.deleted_at'));
+ $stmt = $query->executeQuery();
+ $row = $stmt->fetch();
+ $stmt->closeCursor();
+ if ($row) {
return $row['calendaruri'] . '/' . $row['objecturi'];
}
return null;
}
+ public function getCalendarObjectById(string $principalUri, int $id): ?array {
+ $query = $this->db->getQueryBuilder();
+ $query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.calendardata', 'co.componenttype', 'co.classification', 'co.deleted_at'])
+ ->selectAlias('c.uri', 'calendaruri')
+ ->from('calendarobjects', 'co')
+ ->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
+ ->where($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri)))
+ ->andWhere($query->expr()->eq('co.id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT));
+ $stmt = $query->executeQuery();
+ $row = $stmt->fetch();
+ $stmt->closeCursor();
+
+ if (!$row) {
+ return null;
+ }
+
+ return [
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
+ 'lastmodified' => $row['lastmodified'],
+ 'etag' => '"' . $row['etag'] . '"',
+ 'calendarid' => $row['calendarid'],
+ 'calendaruri' => $row['calendaruri'],
+ 'size' => (int)$row['size'],
+ 'calendardata' => $this->readBlob($row['calendardata']),
+ 'component' => strtolower($row['componenttype']),
+ 'classification' => (int)$row['classification'],
+ 'deleted_at' => isset($row['deleted_at']) ? ((int)$row['deleted_at']) : null,
+ ];
+ }
+
/**
* The getChanges method returns all the changes that have happened, since
* the specified syncToken in the specified calendar.
@@ -1632,72 +2526,76 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param string $calendarId
* @param string $syncToken
* @param int $syncLevel
- * @param int $limit
- * @return array
+ * @param int|null $limit
+ * @param int $calendarType
+ * @return ?array
*/
- function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limit = null) {
- // Current synctoken
- $stmt = $this->db->prepare('SELECT `synctoken` FROM `*PREFIX*calendars` WHERE `id` = ?');
- $stmt->execute([ $calendarId ]);
- $currentToken = $stmt->fetchColumn(0);
-
- if (is_null($currentToken)) {
- return null;
- }
-
- $result = [
- 'syncToken' => $currentToken,
- 'added' => [],
- 'modified' => [],
- 'deleted' => [],
- ];
-
- if ($syncToken) {
-
- $query = "SELECT `uri`, `operation` FROM `*PREFIX*calendarchanges` WHERE `synctoken` >= ? AND `synctoken` < ? AND `calendarid` = ? ORDER BY `synctoken`";
- if ($limit>0) {
- $query.= " LIMIT " . (int)$limit;
+ public function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limit = null, $calendarType = self::CALENDAR_TYPE_CALENDAR) {
+ $table = $calendarType === self::CALENDAR_TYPE_CALENDAR ? 'calendars': 'calendarsubscriptions';
+
+ return $this->atomic(function () use ($calendarId, $syncToken, $syncLevel, $limit, $calendarType, $table) {
+ // Current synctoken
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('synctoken')
+ ->from($table)
+ ->where(
+ $qb->expr()->eq('id', $qb->createNamedParameter($calendarId))
+ );
+ $stmt = $qb->executeQuery();
+ $currentToken = $stmt->fetchOne();
+ $initialSync = !is_numeric($syncToken);
+
+ if ($currentToken === false) {
+ return null;
}
- // Fetching all changes
- $stmt = $this->db->prepare($query);
- $stmt->execute([$syncToken, $currentToken, $calendarId]);
-
- $changes = [];
-
- // This loop ensures that any duplicates are overwritten, only the
- // last change on a node is relevant.
- while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
-
- $changes[$row['uri']] = $row['operation'];
-
+ // evaluate if this is a initial sync and construct appropriate command
+ if ($initialSync) {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('uri')
+ ->from('calendarobjects')
+ ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)))
+ ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType)))
+ ->andWhere($qb->expr()->isNull('deleted_at'));
+ } else {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('uri', $qb->func()->max('operation'))
+ ->from('calendarchanges')
+ ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)))
+ ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType)))
+ ->andWhere($qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)))
+ ->andWhere($qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)))
+ ->groupBy('uri');
}
-
- foreach($changes as $uri => $operation) {
-
- switch($operation) {
- case 1 :
- $result['added'][] = $uri;
- break;
- case 2 :
- $result['modified'][] = $uri;
- break;
- case 3 :
- $result['deleted'][] = $uri;
- break;
+ // evaluate if limit exists
+ if (is_numeric($limit)) {
+ $qb->setMaxResults($limit);
+ }
+ // execute command
+ $stmt = $qb->executeQuery();
+ // build results
+ $result = ['syncToken' => $currentToken, 'added' => [], 'modified' => [], 'deleted' => []];
+ // retrieve results
+ if ($initialSync) {
+ $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN);
+ } else {
+ // \PDO::FETCH_NUM is needed due to the inconsistent field names
+ // produced by doctrine for MAX() with different databases
+ while ($entry = $stmt->fetch(\PDO::FETCH_NUM)) {
+ // assign uri (column 0) to appropriate mutation based on operation (column 1)
+ // forced (int) is needed as doctrine with OCI returns the operation field as string not integer
+ match ((int)$entry[1]) {
+ 1 => $result['added'][] = $entry[0],
+ 2 => $result['modified'][] = $entry[0],
+ 3 => $result['deleted'][] = $entry[0],
+ default => $this->logger->debug('Unknown calendar change operation detected')
+ };
}
-
}
- } else {
- // No synctoken supplied, this is the initial sync.
- $query = "SELECT `uri` FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ?";
- $stmt = $this->db->prepare($query);
- $stmt->execute([$calendarId]);
-
- $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN);
- }
- return $result;
+ $stmt->closeCursor();
+ return $result;
+ }, $this->db);
}
/**
@@ -1732,42 +2630,36 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param string $principalUri
* @return array
*/
- function getSubscriptionsForUser($principalUri) {
- $fields = array_values($this->subscriptionPropertyMap);
+ public function getSubscriptionsForUser($principalUri) {
+ $fields = array_column($this->subscriptionPropertyMap, 0);
$fields[] = 'id';
$fields[] = 'uri';
$fields[] = 'source';
$fields[] = 'principaluri';
$fields[] = 'lastmodified';
+ $fields[] = 'synctoken';
$query = $this->db->getQueryBuilder();
$query->select($fields)
->from('calendarsubscriptions')
->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)))
->orderBy('calendarorder', 'asc');
- $stmt =$query->execute();
+ $stmt = $query->executeQuery();
$subscriptions = [];
- while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
-
+ while ($row = $stmt->fetch()) {
$subscription = [
- 'id' => $row['id'],
- 'uri' => $row['uri'],
+ 'id' => $row['id'],
+ 'uri' => $row['uri'],
'principaluri' => $row['principaluri'],
- 'source' => $row['source'],
+ 'source' => $row['source'],
'lastmodified' => $row['lastmodified'],
'{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']),
+ '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0',
];
- foreach($this->subscriptionPropertyMap as $xmlName=>$dbName) {
- if (!is_null($row[$dbName])) {
- $subscription[$xmlName] = $row[$dbName];
- }
- }
-
- $subscriptions[] = $subscription;
-
+ $subscriptions[] = $this->rowToSubscription($row, $subscription);
}
return $subscriptions;
@@ -1784,43 +2676,48 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param array $properties
* @return mixed
*/
- function createSubscription($principalUri, $uri, array $properties) {
-
+ public function createSubscription($principalUri, $uri, array $properties) {
if (!isset($properties['{http://calendarserver.org/ns/}source'])) {
throw new Forbidden('The {http://calendarserver.org/ns/}source property is required when creating subscriptions');
}
$values = [
'principaluri' => $principalUri,
- 'uri' => $uri,
- 'source' => $properties['{http://calendarserver.org/ns/}source']->getHref(),
+ 'uri' => $uri,
+ 'source' => $properties['{http://calendarserver.org/ns/}source']->getHref(),
'lastmodified' => time(),
];
$propertiesBoolean = ['striptodos', 'stripalarms', 'stripattachments'];
- foreach($this->subscriptionPropertyMap as $xmlName=>$dbName) {
+ foreach ($this->subscriptionPropertyMap as $xmlName => [$dbName, $type]) {
if (array_key_exists($xmlName, $properties)) {
- $values[$dbName] = $properties[$xmlName];
- if (in_array($dbName, $propertiesBoolean)) {
- $values[$dbName] = true;
+ $values[$dbName] = $properties[$xmlName];
+ if (in_array($dbName, $propertiesBoolean)) {
+ $values[$dbName] = true;
}
}
}
- $valuesToInsert = array();
+ [$subscriptionId, $subscriptionRow] = $this->atomic(function () use ($values) {
+ $valuesToInsert = [];
+ $query = $this->db->getQueryBuilder();
+ foreach (array_keys($values) as $name) {
+ $valuesToInsert[$name] = $query->createNamedParameter($values[$name]);
+ }
+ $query->insert('calendarsubscriptions')
+ ->values($valuesToInsert)
+ ->executeStatement();
- $query = $this->db->getQueryBuilder();
+ $subscriptionId = $query->getLastInsertId();
- foreach (array_keys($values) as $name) {
- $valuesToInsert[$name] = $query->createNamedParameter($values[$name]);
- }
+ $subscriptionRow = $this->getSubscriptionById($subscriptionId);
+ return [$subscriptionId, $subscriptionRow];
+ }, $this->db);
- $query->insert('calendarsubscriptions')
- ->values($valuesToInsert)
- ->execute();
+ $this->dispatcher->dispatchTyped(new SubscriptionCreatedEvent($subscriptionId, $subscriptionRow));
- return $this->db->lastInsertId('*PREFIX*calendarsubscriptions');
+ return $subscriptionId;
}
/**
@@ -1839,37 +2736,38 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param PropPatch $propPatch
* @return void
*/
- function updateSubscription($subscriptionId, PropPatch $propPatch) {
+ public function updateSubscription($subscriptionId, PropPatch $propPatch) {
$supportedProperties = array_keys($this->subscriptionPropertyMap);
$supportedProperties[] = '{http://calendarserver.org/ns/}source';
- /**
- * @suppress SqlInjectionChecker
- */
- $propPatch->handle($supportedProperties, function($mutations) use ($subscriptionId) {
-
+ $propPatch->handle($supportedProperties, function ($mutations) use ($subscriptionId) {
$newValues = [];
- foreach($mutations as $propertyName=>$propertyValue) {
+ foreach ($mutations as $propertyName => $propertyValue) {
if ($propertyName === '{http://calendarserver.org/ns/}source') {
$newValues['source'] = $propertyValue->getHref();
} else {
- $fieldName = $this->subscriptionPropertyMap[$propertyName];
+ $fieldName = $this->subscriptionPropertyMap[$propertyName][0];
$newValues[$fieldName] = $propertyValue;
}
}
- $query = $this->db->getQueryBuilder();
- $query->update('calendarsubscriptions')
- ->set('lastmodified', $query->createNamedParameter(time()));
- foreach($newValues as $fieldName=>$value) {
- $query->set($fieldName, $query->createNamedParameter($value));
- }
- $query->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId)))
- ->execute();
+ $subscriptionRow = $this->atomic(function () use ($subscriptionId, $newValues) {
+ $query = $this->db->getQueryBuilder();
+ $query->update('calendarsubscriptions')
+ ->set('lastmodified', $query->createNamedParameter(time()));
+ foreach ($newValues as $fieldName => $value) {
+ $query->set($fieldName, $query->createNamedParameter($value));
+ }
+ $query->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId)))
+ ->executeStatement();
- return true;
+ return $this->getSubscriptionById($subscriptionId);
+ }, $this->db);
+ $this->dispatcher->dispatchTyped(new SubscriptionUpdatedEvent((int)$subscriptionId, $subscriptionRow, [], $mutations));
+
+ return true;
});
}
@@ -1879,11 +2777,35 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param mixed $subscriptionId
* @return void
*/
- function deleteSubscription($subscriptionId) {
- $query = $this->db->getQueryBuilder();
- $query->delete('calendarsubscriptions')
- ->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId)))
- ->execute();
+ public function deleteSubscription($subscriptionId) {
+ $this->atomic(function () use ($subscriptionId): void {
+ $subscriptionRow = $this->getSubscriptionById($subscriptionId);
+
+ $query = $this->db->getQueryBuilder();
+ $query->delete('calendarsubscriptions')
+ ->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId)))
+ ->executeStatement();
+
+ $query = $this->db->getQueryBuilder();
+ $query->delete('calendarobjects')
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
+ ->executeStatement();
+
+ $query->delete('calendarchanges')
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
+ ->executeStatement();
+
+ $query->delete($this->dbObjectPropertiesTable)
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
+ ->executeStatement();
+
+ if ($subscriptionRow) {
+ $this->dispatcher->dispatchTyped(new SubscriptionDeletedEvent((int)$subscriptionId, $subscriptionRow, []));
+ }
+ }, $this->db);
}
/**
@@ -1902,26 +2824,26 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param string $objectUri
* @return array
*/
- function getSchedulingObject($principalUri, $objectUri) {
+ public function getSchedulingObject($principalUri, $objectUri) {
$query = $this->db->getQueryBuilder();
$stmt = $query->select(['uri', 'calendardata', 'lastmodified', 'etag', 'size'])
->from('schedulingobjects')
->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)))
->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri)))
- ->execute();
+ ->executeQuery();
- $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+ $row = $stmt->fetch();
- if(!$row) {
+ if (!$row) {
return null;
}
return [
- 'uri' => $row['uri'],
- 'calendardata' => $row['calendardata'],
- 'lastmodified' => $row['lastmodified'],
- 'etag' => '"' . $row['etag'] . '"',
- 'size' => (int)$row['size'],
+ 'uri' => $row['uri'],
+ 'calendardata' => $row['calendardata'],
+ 'lastmodified' => $row['lastmodified'],
+ 'etag' => '"' . $row['etag'] . '"',
+ 'size' => (int)$row['size'],
];
}
@@ -1936,25 +2858,26 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param string $principalUri
* @return array
*/
- function getSchedulingObjects($principalUri) {
+ public function getSchedulingObjects($principalUri) {
$query = $this->db->getQueryBuilder();
$stmt = $query->select(['uri', 'calendardata', 'lastmodified', 'etag', 'size'])
- ->from('schedulingobjects')
- ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)))
- ->execute();
+ ->from('schedulingobjects')
+ ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)))
+ ->executeQuery();
- $result = [];
- foreach($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) {
- $result[] = [
- 'calendardata' => $row['calendardata'],
- 'uri' => $row['uri'],
- 'lastmodified' => $row['lastmodified'],
- 'etag' => '"' . $row['etag'] . '"',
- 'size' => (int)$row['size'],
+ $results = [];
+ while (($row = $stmt->fetch()) !== false) {
+ $results[] = [
+ 'calendardata' => $row['calendardata'],
+ 'uri' => $row['uri'],
+ 'lastmodified' => $row['lastmodified'],
+ 'etag' => '"' . $row['etag'] . '"',
+ 'size' => (int)$row['size'],
];
}
+ $stmt->closeCursor();
- return $result;
+ return $results;
}
/**
@@ -1964,12 +2887,51 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param string $objectUri
* @return void
*/
- function deleteSchedulingObject($principalUri, $objectUri) {
+ public function deleteSchedulingObject($principalUri, $objectUri) {
+ $this->cachedObjects = [];
$query = $this->db->getQueryBuilder();
$query->delete('schedulingobjects')
- ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)))
- ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri)))
- ->execute();
+ ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)))
+ ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri)))
+ ->executeStatement();
+ }
+
+ /**
+ * Deletes all scheduling objects last modified before $modifiedBefore from the inbox collection.
+ *
+ * @param int $modifiedBefore
+ * @param int $limit
+ * @return void
+ */
+ public function deleteOutdatedSchedulingObjects(int $modifiedBefore, int $limit): void {
+ $query = $this->db->getQueryBuilder();
+ $query->select('id')
+ ->from('schedulingobjects')
+ ->where($query->expr()->lt('lastmodified', $query->createNamedParameter($modifiedBefore)))
+ ->setMaxResults($limit);
+ $result = $query->executeQuery();
+ $count = $result->rowCount();
+ if ($count === 0) {
+ return;
+ }
+ $ids = array_map(static function (array $id) {
+ return (int)$id[0];
+ }, $result->fetchAll(\PDO::FETCH_NUM));
+ $result->closeCursor();
+
+ $numDeleted = 0;
+ $deleteQuery = $this->db->getQueryBuilder();
+ $deleteQuery->delete('schedulingobjects')
+ ->where($deleteQuery->expr()->in('id', $deleteQuery->createParameter('ids'), IQueryBuilder::PARAM_INT_ARRAY));
+ foreach (array_chunk($ids, 1000) as $chunk) {
+ $deleteQuery->setParameter('ids', $chunk, IQueryBuilder::PARAM_INT_ARRAY);
+ $numDeleted += $deleteQuery->executeStatement();
+ }
+
+ if ($numDeleted === $limit) {
+ $this->logger->info("Deleted $limit scheduling objects, continuing with next batch");
+ $this->deleteOutdatedSchedulingObjects($modifiedBefore, $limit);
+ }
}
/**
@@ -1980,42 +2942,105 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param string $objectData
* @return void
*/
- function createSchedulingObject($principalUri, $objectUri, $objectData) {
+ public function createSchedulingObject($principalUri, $objectUri, $objectData) {
+ $this->cachedObjects = [];
$query = $this->db->getQueryBuilder();
$query->insert('schedulingobjects')
->values([
'principaluri' => $query->createNamedParameter($principalUri),
- 'calendardata' => $query->createNamedParameter($objectData),
+ 'calendardata' => $query->createNamedParameter($objectData, IQueryBuilder::PARAM_LOB),
'uri' => $query->createNamedParameter($objectUri),
'lastmodified' => $query->createNamedParameter(time()),
'etag' => $query->createNamedParameter(md5($objectData)),
'size' => $query->createNamedParameter(strlen($objectData))
])
- ->execute();
+ ->executeStatement();
}
/**
* Adds a change record to the calendarchanges table.
*
* @param mixed $calendarId
- * @param string $objectUri
+ * @param string[] $objectUris
* @param int $operation 1 = add, 2 = modify, 3 = delete.
+ * @param int $calendarType
* @return void
*/
- protected function addChange($calendarId, $objectUri, $operation) {
+ protected function addChanges(int $calendarId, array $objectUris, int $operation, int $calendarType = self::CALENDAR_TYPE_CALENDAR): void {
+ $this->cachedObjects = [];
+ $table = $calendarType === self::CALENDAR_TYPE_CALENDAR ? 'calendars': 'calendarsubscriptions';
+
+ $this->atomic(function () use ($calendarId, $objectUris, $operation, $calendarType, $table): void {
+ $query = $this->db->getQueryBuilder();
+ $query->select('synctoken')
+ ->from($table)
+ ->where($query->expr()->eq('id', $query->createNamedParameter($calendarId)));
+ $result = $query->executeQuery();
+ $syncToken = (int)$result->fetchOne();
+ $result->closeCursor();
+
+ $query = $this->db->getQueryBuilder();
+ $query->insert('calendarchanges')
+ ->values([
+ 'uri' => $query->createParameter('uri'),
+ 'synctoken' => $query->createNamedParameter($syncToken),
+ 'calendarid' => $query->createNamedParameter($calendarId),
+ 'operation' => $query->createNamedParameter($operation),
+ 'calendartype' => $query->createNamedParameter($calendarType),
+ 'created_at' => $query->createNamedParameter(time()),
+ ]);
+ foreach ($objectUris as $uri) {
+ $query->setParameter('uri', $uri);
+ $query->executeStatement();
+ }
- $stmt = $this->db->prepare('INSERT INTO `*PREFIX*calendarchanges` (`uri`, `synctoken`, `calendarid`, `operation`) SELECT ?, `synctoken`, ?, ? FROM `*PREFIX*calendars` WHERE `id` = ?');
- $stmt->execute([
- $objectUri,
- $calendarId,
- $operation,
- $calendarId
- ]);
- $stmt = $this->db->prepare('UPDATE `*PREFIX*calendars` SET `synctoken` = `synctoken` + 1 WHERE `id` = ?');
- $stmt->execute([
- $calendarId
- ]);
+ $query = $this->db->getQueryBuilder();
+ $query->update($table)
+ ->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT))
+ ->where($query->expr()->eq('id', $query->createNamedParameter($calendarId)))
+ ->executeStatement();
+ }, $this->db);
+ }
+
+ public function restoreChanges(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR): void {
+ $this->cachedObjects = [];
+ $this->atomic(function () use ($calendarId, $calendarType): void {
+ $qbAdded = $this->db->getQueryBuilder();
+ $qbAdded->select('uri')
+ ->from('calendarobjects')
+ ->where(
+ $qbAdded->expr()->andX(
+ $qbAdded->expr()->eq('calendarid', $qbAdded->createNamedParameter($calendarId)),
+ $qbAdded->expr()->eq('calendartype', $qbAdded->createNamedParameter($calendarType)),
+ $qbAdded->expr()->isNull('deleted_at'),
+ )
+ );
+ $resultAdded = $qbAdded->executeQuery();
+ $addedUris = $resultAdded->fetchAll(\PDO::FETCH_COLUMN);
+ $resultAdded->closeCursor();
+ // Track everything as changed
+ // Tracking the creation is not necessary because \OCA\DAV\CalDAV\CalDavBackend::getChangesForCalendar
+ // only returns the last change per object.
+ $this->addChanges($calendarId, $addedUris, 2, $calendarType);
+
+ $qbDeleted = $this->db->getQueryBuilder();
+ $qbDeleted->select('uri')
+ ->from('calendarobjects')
+ ->where(
+ $qbDeleted->expr()->andX(
+ $qbDeleted->expr()->eq('calendarid', $qbDeleted->createNamedParameter($calendarId)),
+ $qbDeleted->expr()->eq('calendartype', $qbDeleted->createNamedParameter($calendarType)),
+ $qbDeleted->expr()->isNotNull('deleted_at'),
+ )
+ );
+ $resultDeleted = $qbDeleted->executeQuery();
+ $deletedUris = array_map(function (string $uri) {
+ return str_replace('-deleted.ics', '.ics', $uri);
+ }, $resultDeleted->fetchAll(\PDO::FETCH_COLUMN));
+ $resultDeleted->closeCursor();
+ $this->addChanges($calendarId, $deletedUris, 3, $calendarType);
+ }, $this->db);
}
/**
@@ -2033,29 +3058,42 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param string $calendarData
* @return array
*/
- public function getDenormalizedData($calendarData) {
-
+ public function getDenormalizedData(string $calendarData): array {
$vObject = Reader::read($calendarData);
+ $vEvents = [];
$componentType = null;
$component = null;
$firstOccurrence = null;
$lastOccurrence = null;
$uid = null;
$classification = self::CLASSIFICATION_PUBLIC;
- foreach($vObject->getComponents() as $component) {
- if ($component->name!=='VTIMEZONE') {
- $componentType = $component->name;
- $uid = (string)$component->UID;
- break;
+ $hasDTSTART = false;
+ foreach ($vObject->getComponents() as $component) {
+ if ($component->name !== 'VTIMEZONE') {
+ // Finding all VEVENTs, and track them
+ if ($component->name === 'VEVENT') {
+ $vEvents[] = $component;
+ if ($component->DTSTART) {
+ $hasDTSTART = true;
+ }
+ }
+ // Track first component type and uid
+ if ($uid === null) {
+ $componentType = $component->name;
+ $uid = (string)$component->UID;
+ }
}
}
if (!$componentType) {
- throw new \Sabre\DAV\Exception\BadRequest('Calendar objects must have a VJOURNAL, VEVENT or VTODO component');
+ throw new BadRequest('Calendar objects must have a VJOURNAL, VEVENT or VTODO component');
}
- if ($componentType === 'VEVENT' && $component->DTSTART) {
- $firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp();
+
+ if ($hasDTSTART) {
+ $component = $vEvents[0];
+
// Finding the last occurrence is a bit harder
- if (!isset($component->RRULE)) {
+ if (!isset($component->RRULE) && count($vEvents) === 1) {
+ $firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp();
if (isset($component->DTEND)) {
$lastOccurrence = $component->DTEND->getDateTime()->getTimeStamp();
} elseif (isset($component->DURATION)) {
@@ -2070,20 +3108,27 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$lastOccurrence = $firstOccurrence;
}
} else {
- $it = new EventIterator($vObject, (string)$component->UID);
- $maxDate = new \DateTime(self::MAX_DATE);
+ try {
+ $it = new EventIterator($vEvents);
+ } catch (NoInstancesException $e) {
+ $this->logger->debug('Caught no instance exception for calendar data. This usually indicates invalid calendar data.', [
+ 'app' => 'dav',
+ 'exception' => $e,
+ ]);
+ throw new Forbidden($e->getMessage());
+ }
+ $maxDate = new DateTime(self::MAX_DATE);
+ $firstOccurrence = $it->getDtStart()->getTimestamp();
if ($it->isInfinite()) {
$lastOccurrence = $maxDate->getTimestamp();
} else {
$end = $it->getDtEnd();
- while($it->valid() && $end < $maxDate) {
+ while ($it->valid() && $end < $maxDate) {
$end = $it->getDtEnd();
$it->next();
-
}
$lastOccurrence = $end->getTimestamp();
}
-
}
}
@@ -2103,13 +3148,16 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
'size' => strlen($calendarData),
'componentType' => $componentType,
'firstOccurence' => is_null($firstOccurrence) ? null : max(0, $firstOccurrence),
- 'lastOccurence' => $lastOccurrence,
+ 'lastOccurence' => is_null($lastOccurrence) ? null : max(0, $lastOccurrence),
'uid' => $uid,
'classification' => $classification
];
-
}
+ /**
+ * @param $cardData
+ * @return bool|string
+ */
private function readBlob($cardData) {
if (is_resource($cardData)) {
return stream_get_contents($cardData);
@@ -2119,71 +3167,73 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
}
/**
- * @param IShareable $shareable
- * @param array $add
- * @param array $remove
+ * @param list<array{href: string, commonName: string, readOnly: bool}> $add
+ * @param list<string> $remove
*/
- public function updateShares($shareable, $add, $remove) {
- $calendarId = $shareable->getResourceId();
- $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateShares', new GenericEvent(
- '\OCA\DAV\CalDAV\CalDavBackend::updateShares',
- [
- 'calendarId' => $calendarId,
- 'calendarData' => $this->getCalendarById($calendarId),
- 'shares' => $this->getShares($calendarId),
- 'add' => $add,
- 'remove' => $remove,
- ]));
- $this->sharingBackend->updateShares($shareable, $add, $remove);
+ public function updateShares(IShareable $shareable, array $add, array $remove): void {
+ $this->atomic(function () use ($shareable, $add, $remove): void {
+ $calendarId = $shareable->getResourceId();
+ $calendarRow = $this->getCalendarById($calendarId);
+ if ($calendarRow === null) {
+ throw new \RuntimeException('Trying to update shares for non-existing calendar: ' . $calendarId);
+ }
+ $oldShares = $this->getShares($calendarId);
+
+ $this->calendarSharingBackend->updateShares($shareable, $add, $remove, $oldShares);
+
+ $this->dispatcher->dispatchTyped(new CalendarShareUpdatedEvent($calendarId, $calendarRow, $oldShares, $add, $remove));
+ }, $this->db);
}
/**
- * @param int $resourceId
- * @return array
+ * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}>
*/
- public function getShares($resourceId) {
- return $this->sharingBackend->getShares($resourceId);
+ public function getShares(int $resourceId): array {
+ return $this->calendarSharingBackend->getShares($resourceId);
+ }
+
+ public function preloadShares(array $resourceIds): void {
+ $this->calendarSharingBackend->preloadShares($resourceIds);
}
/**
* @param boolean $value
- * @param \OCA\DAV\CalDAV\Calendar $calendar
+ * @param Calendar $calendar
* @return string|null
*/
public function setPublishStatus($value, $calendar) {
+ return $this->atomic(function () use ($value, $calendar) {
+ $calendarId = $calendar->getResourceId();
+ $calendarData = $this->getCalendarById($calendarId);
- $calendarId = $calendar->getResourceId();
- $this->dispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::publishCalendar', new GenericEvent(
- '\OCA\DAV\CalDAV\CalDavBackend::updateShares',
- [
- 'calendarId' => $calendarId,
- 'calendarData' => $this->getCalendarById($calendarId),
- 'public' => $value,
- ]));
+ $query = $this->db->getQueryBuilder();
+ if ($value) {
+ $publicUri = $this->random->generate(16, ISecureRandom::CHAR_HUMAN_READABLE);
+ $query->insert('dav_shares')
+ ->values([
+ 'principaluri' => $query->createNamedParameter($calendar->getPrincipalURI()),
+ 'type' => $query->createNamedParameter('calendar'),
+ 'access' => $query->createNamedParameter(self::ACCESS_PUBLIC),
+ 'resourceid' => $query->createNamedParameter($calendar->getResourceId()),
+ 'publicuri' => $query->createNamedParameter($publicUri)
+ ]);
+ $query->executeStatement();
- $query = $this->db->getQueryBuilder();
- if ($value) {
- $publicUri = $this->random->generate(16, ISecureRandom::CHAR_HUMAN_READABLE);
- $query->insert('dav_shares')
- ->values([
- 'principaluri' => $query->createNamedParameter($calendar->getPrincipalURI()),
- 'type' => $query->createNamedParameter('calendar'),
- 'access' => $query->createNamedParameter(self::ACCESS_PUBLIC),
- 'resourceid' => $query->createNamedParameter($calendar->getResourceId()),
- 'publicuri' => $query->createNamedParameter($publicUri)
- ]);
- $query->execute();
- return $publicUri;
- }
- $query->delete('dav_shares')
- ->where($query->expr()->eq('resourceid', $query->createNamedParameter($calendar->getResourceId())))
- ->andWhere($query->expr()->eq('access', $query->createNamedParameter(self::ACCESS_PUBLIC)));
- $query->execute();
- return null;
+ $this->dispatcher->dispatchTyped(new CalendarPublishedEvent($calendarId, $calendarData, $publicUri));
+ return $publicUri;
+ }
+ $query->delete('dav_shares')
+ ->where($query->expr()->eq('resourceid', $query->createNamedParameter($calendar->getResourceId())))
+ ->andWhere($query->expr()->eq('access', $query->createNamedParameter(self::ACCESS_PUBLIC)));
+ $query->executeStatement();
+
+ $this->dispatcher->dispatchTyped(new CalendarUnpublishedEvent($calendarId, $calendarData));
+ return null;
+ }, $this->db);
}
/**
- * @param \OCA\DAV\CalDAV\Calendar $calendar
+ * @param Calendar $calendar
* @return mixed
*/
public function getPublishStatus($calendar) {
@@ -2192,7 +3242,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
->from('dav_shares')
->where($query->expr()->eq('resourceid', $query->createNamedParameter($calendar->getResourceId())))
->andWhere($query->expr()->eq('access', $query->createNamedParameter(self::ACCESS_PUBLIC)))
- ->execute();
+ ->executeQuery();
$row = $result->fetch();
$result->closeCursor();
@@ -2201,103 +3251,207 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
/**
* @param int $resourceId
- * @param array $acl
- * @return array
+ * @param list<array{privilege: string, principal: string, protected: bool}> $acl
+ * @return list<array{privilege: string, principal: string, protected: bool}>
*/
- public function applyShareAcl($resourceId, $acl) {
- return $this->sharingBackend->applyShareAcl($resourceId, $acl);
+ public function applyShareAcl(int $resourceId, array $acl): array {
+ $shares = $this->calendarSharingBackend->getShares($resourceId);
+ return $this->calendarSharingBackend->applyShareAcl($shares, $acl);
}
-
-
/**
* update properties table
*
* @param int $calendarId
* @param string $objectUri
* @param string $calendarData
+ * @param int $calendarType
*/
- public function updateProperties($calendarId, $objectUri, $calendarData) {
- $objectId = $this->getCalendarObjectId($calendarId, $objectUri);
-
- try {
- $vCalendar = $this->readCalendarData($calendarData);
- } catch (\Exception $ex) {
- return;
- }
-
- $this->purgeProperties($calendarId, $objectId);
-
- $query = $this->db->getQueryBuilder();
- $query->insert($this->dbObjectPropertiesTable)
- ->values(
- [
- 'calendarid' => $query->createNamedParameter($calendarId),
- 'objectid' => $query->createNamedParameter($objectId),
- 'name' => $query->createParameter('name'),
- 'parameter' => $query->createParameter('parameter'),
- 'value' => $query->createParameter('value'),
- ]
- );
-
- $indexComponents = ['VEVENT', 'VJOURNAL', 'VTODO'];
- foreach ($vCalendar->getComponents() as $component) {
- if (!in_array($component->name, $indexComponents)) {
- continue;
+ public function updateProperties($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) {
+ $this->cachedObjects = [];
+ $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $calendarType): void {
+ $objectId = $this->getCalendarObjectId($calendarId, $objectUri, $calendarType);
+
+ try {
+ $vCalendar = $this->readCalendarData($calendarData);
+ } catch (\Exception $ex) {
+ return;
}
- foreach ($component->children() as $property) {
- if (in_array($property->name, self::$indexProperties)) {
- $value = $property->getValue();
- // is this a shitty db?
- if (!$this->db->supports4ByteText()) {
- $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value);
- }
- $value = substr($value, 0, 254);
+ $this->purgeProperties($calendarId, $objectId);
- $query->setParameter('name', $property->name);
- $query->setParameter('parameter', null);
- $query->setParameter('value', $value);
- $query->execute();
+ $query = $this->db->getQueryBuilder();
+ $query->insert($this->dbObjectPropertiesTable)
+ ->values(
+ [
+ 'calendarid' => $query->createNamedParameter($calendarId),
+ 'calendartype' => $query->createNamedParameter($calendarType),
+ 'objectid' => $query->createNamedParameter($objectId),
+ 'name' => $query->createParameter('name'),
+ 'parameter' => $query->createParameter('parameter'),
+ 'value' => $query->createParameter('value'),
+ ]
+ );
+
+ $indexComponents = ['VEVENT', 'VJOURNAL', 'VTODO'];
+ foreach ($vCalendar->getComponents() as $component) {
+ if (!in_array($component->name, $indexComponents)) {
+ continue;
}
- if (in_array($property->name, array_keys(self::$indexParameters))) {
- $parameters = $property->parameters();
- $indexedParametersForProperty = self::$indexParameters[$property->name];
+ foreach ($component->children() as $property) {
+ if (in_array($property->name, self::INDEXED_PROPERTIES, true)) {
+ $value = $property->getValue();
+ // is this a shitty db?
+ if (!$this->db->supports4ByteText()) {
+ $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value);
+ }
+ $value = mb_strcut($value, 0, 254);
- foreach ($parameters as $key => $value) {
- if (in_array($key, $indexedParametersForProperty)) {
- // is this a shitty db?
- if ($this->db->supports4ByteText()) {
- $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value);
- }
- $value = substr($value, 0, 254);
+ $query->setParameter('name', $property->name);
+ $query->setParameter('parameter', null);
+ $query->setParameter('value', mb_strcut($value, 0, 254));
+ $query->executeStatement();
+ }
- $query->setParameter('name', $property->name);
- $query->setParameter('parameter', substr($key, 0, 254));
- $query->setParameter('value', substr($value, 0, 254));
- $query->execute();
+ if (array_key_exists($property->name, self::$indexParameters)) {
+ $parameters = $property->parameters();
+ $indexedParametersForProperty = self::$indexParameters[$property->name];
+
+ foreach ($parameters as $key => $value) {
+ if (in_array($key, $indexedParametersForProperty)) {
+ // is this a shitty db?
+ if ($this->db->supports4ByteText()) {
+ $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value);
+ }
+
+ $query->setParameter('name', $property->name);
+ $query->setParameter('parameter', mb_strcut($key, 0, 254));
+ $query->setParameter('value', mb_strcut($value, 0, 254));
+ $query->executeStatement();
+ }
}
}
}
}
- }
+ }, $this->db);
}
/**
* deletes all birthday calendars
*/
public function deleteAllBirthdayCalendars() {
- $query = $this->db->getQueryBuilder();
- $result = $query->select(['id'])->from('calendars')
- ->where($query->expr()->eq('uri',
- $query->createNamedParameter(BirthdayService::BIRTHDAY_CALENDAR_URI)))
- ->execute();
+ $this->atomic(function (): void {
+ $query = $this->db->getQueryBuilder();
+ $result = $query->select(['id'])->from('calendars')
+ ->where($query->expr()->eq('uri', $query->createNamedParameter(BirthdayService::BIRTHDAY_CALENDAR_URI)))
+ ->executeQuery();
+
+ while (($row = $result->fetch()) !== false) {
+ $this->deleteCalendar(
+ $row['id'],
+ true // No data to keep in the trashbin, if the user re-enables then we regenerate
+ );
+ }
+ $result->closeCursor();
+ }, $this->db);
+ }
+
+ /**
+ * @param $subscriptionId
+ */
+ public function purgeAllCachedEventsForSubscription($subscriptionId) {
+ $this->atomic(function () use ($subscriptionId): void {
+ $query = $this->db->getQueryBuilder();
+ $query->select('uri')
+ ->from('calendarobjects')
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)));
+ $stmt = $query->executeQuery();
+
+ $uris = [];
+ while (($row = $stmt->fetch()) !== false) {
+ $uris[] = $row['uri'];
+ }
+ $stmt->closeCursor();
+
+ $query = $this->db->getQueryBuilder();
+ $query->delete('calendarobjects')
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
+ ->executeStatement();
- $ids = $result->fetchAll();
- foreach($ids as $id) {
- $this->deleteCalendar($id['id']);
+ $query = $this->db->getQueryBuilder();
+ $query->delete('calendarchanges')
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
+ ->executeStatement();
+
+ $query = $this->db->getQueryBuilder();
+ $query->delete($this->dbObjectPropertiesTable)
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
+ ->executeStatement();
+
+ $this->addChanges($subscriptionId, $uris, 3, self::CALENDAR_TYPE_SUBSCRIPTION);
+ }, $this->db);
+ }
+
+ /**
+ * @param int $subscriptionId
+ * @param array<int> $calendarObjectIds
+ * @param array<string> $calendarObjectUris
+ */
+ public function purgeCachedEventsForSubscription(int $subscriptionId, array $calendarObjectIds, array $calendarObjectUris): void {
+ if (empty($calendarObjectUris)) {
+ return;
}
+
+ $this->atomic(function () use ($subscriptionId, $calendarObjectIds, $calendarObjectUris): void {
+ foreach (array_chunk($calendarObjectIds, 1000) as $chunk) {
+ $query = $this->db->getQueryBuilder();
+ $query->delete($this->dbObjectPropertiesTable)
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
+ ->andWhere($query->expr()->in('id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
+ ->executeStatement();
+
+ $query = $this->db->getQueryBuilder();
+ $query->delete('calendarobjects')
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
+ ->andWhere($query->expr()->in('id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY))
+ ->executeStatement();
+ }
+
+ foreach (array_chunk($calendarObjectUris, 1000) as $chunk) {
+ $query = $this->db->getQueryBuilder();
+ $query->delete('calendarchanges')
+ ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)))
+ ->andWhere($query->expr()->in('uri', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY))
+ ->executeStatement();
+ }
+ $this->addChanges($subscriptionId, $calendarObjectUris, 3, self::CALENDAR_TYPE_SUBSCRIPTION);
+ }, $this->db);
+ }
+
+ /**
+ * Move a calendar from one user to another
+ *
+ * @param string $uriName
+ * @param string $uriOrigin
+ * @param string $uriDestination
+ * @param string $newUriName (optional) the new uriName
+ */
+ public function moveCalendar($uriName, $uriOrigin, $uriDestination, $newUriName = null) {
+ $query = $this->db->getQueryBuilder();
+ $query->update('calendars')
+ ->set('principaluri', $query->createNamedParameter($uriDestination))
+ ->set('uri', $query->createNamedParameter($newUriName ?: $uriName))
+ ->where($query->expr()->eq('principaluri', $query->createNamedParameter($uriOrigin)))
+ ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($uriName)))
+ ->executeStatement();
}
/**
@@ -2317,11 +3471,12 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @param int $objectId
*/
protected function purgeProperties($calendarId, $objectId) {
+ $this->cachedObjects = [];
$query = $this->db->getQueryBuilder();
$query->delete($this->dbObjectPropertiesTable)
->where($query->expr()->eq('objectid', $query->createNamedParameter($objectId)))
->andWhere($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)));
- $query->execute();
+ $query->executeStatement();
}
/**
@@ -2329,15 +3484,18 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
*
* @param int $calendarId
* @param string $uri
+ * @param int $calendarType
* @return int
*/
- protected function getCalendarObjectId($calendarId, $uri) {
+ protected function getCalendarObjectId($calendarId, $uri, $calendarType):int {
$query = $this->db->getQueryBuilder();
- $query->select('id')->from('calendarobjects')
+ $query->select('id')
+ ->from('calendarobjects')
->where($query->expr()->eq('uri', $query->createNamedParameter($uri)))
- ->andWhere($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)));
+ ->andWhere($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)))
+ ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType)));
- $result = $query->execute();
+ $result = $query->executeQuery();
$objectIds = $result->fetch();
$result->closeCursor();
@@ -2348,9 +3506,44 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
return (int)$objectIds['id'];
}
+ /**
+ * @throws \InvalidArgumentException
+ */
+ public function pruneOutdatedSyncTokens(int $keep, int $retention): int {
+ if ($keep < 0) {
+ throw new \InvalidArgumentException();
+ }
+
+ $query = $this->db->getQueryBuilder();
+ $query->select($query->func()->max('id'))
+ ->from('calendarchanges');
+
+ $result = $query->executeQuery();
+ $maxId = (int)$result->fetchOne();
+ $result->closeCursor();
+ if (!$maxId || $maxId < $keep) {
+ return 0;
+ }
+
+ $query = $this->db->getQueryBuilder();
+ $query->delete('calendarchanges')
+ ->where(
+ $query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT),
+ $query->expr()->lte('created_at', $query->createNamedParameter($retention)),
+ );
+ return $query->executeStatement();
+ }
+
+ /**
+ * return legacy endpoint principal name to new principal name
+ *
+ * @param $principalUri
+ * @param $toV2
+ * @return string
+ */
private function convertPrincipal($principalUri, $toV2) {
if ($this->principalBackend->getPrincipalPrefix() === 'principals') {
- list(, $name) = Uri\split($principalUri);
+ [, $name] = Uri\split($principalUri);
if ($toV2 === true) {
return "principals/users/$name";
}
@@ -2359,7 +3552,11 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
return $principalUri;
}
- private function addOwnerPrincipal(&$calendarInfo) {
+ /**
+ * adds information about an owner to the calendar data
+ *
+ */
+ private function addOwnerPrincipalToCalendar(array $calendarInfo): array {
$ownerPrincipalKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal';
$displaynameKey = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}owner-displayname';
if (isset($calendarInfo[$ownerPrincipalKey])) {
@@ -2372,5 +3569,122 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
if (isset($principalInformation['{DAV:}displayname'])) {
$calendarInfo[$displaynameKey] = $principalInformation['{DAV:}displayname'];
}
+ return $calendarInfo;
+ }
+
+ private function addResourceTypeToCalendar(array $row, array $calendar): array {
+ if (isset($row['deleted_at'])) {
+ // Columns is set and not null -> this is a deleted calendar
+ // we send a custom resourcetype to hide the deleted calendar
+ // from ordinary DAV clients, but the Calendar app will know
+ // how to handle this special resource.
+ $calendar['{DAV:}resourcetype'] = new DAV\Xml\Property\ResourceType([
+ '{DAV:}collection',
+ sprintf('{%s}deleted-calendar', \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD),
+ ]);
+ }
+ return $calendar;
+ }
+
+ /**
+ * Amend the calendar info with database row data
+ *
+ * @param array $row
+ * @param array $calendar
+ *
+ * @return array
+ */
+ private function rowToCalendar($row, array $calendar): array {
+ foreach ($this->propertyMap as $xmlName => [$dbName, $type]) {
+ $value = $row[$dbName];
+ if ($value !== null) {
+ settype($value, $type);
+ }
+ $calendar[$xmlName] = $value;
+ }
+ return $calendar;
+ }
+
+ /**
+ * Amend the subscription info with database row data
+ *
+ * @param array $row
+ * @param array $subscription
+ *
+ * @return array
+ */
+ private function rowToSubscription($row, array $subscription): array {
+ foreach ($this->subscriptionPropertyMap as $xmlName => [$dbName, $type]) {
+ $value = $row[$dbName];
+ if ($value !== null) {
+ settype($value, $type);
+ }
+ $subscription[$xmlName] = $value;
+ }
+ return $subscription;
+ }
+
+ /**
+ * delete all invitations from a given calendar
+ *
+ * @since 31.0.0
+ *
+ * @param int $calendarId
+ *
+ * @return void
+ */
+ protected function purgeCalendarInvitations(int $calendarId): void {
+ // select all calendar object uid's
+ $cmd = $this->db->getQueryBuilder();
+ $cmd->select('uid')
+ ->from($this->dbObjectsTable)
+ ->where($cmd->expr()->eq('calendarid', $cmd->createNamedParameter($calendarId)));
+ $allIds = $cmd->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
+ // delete all links that match object uid's
+ $cmd = $this->db->getQueryBuilder();
+ $cmd->delete($this->dbObjectInvitationsTable)
+ ->where($cmd->expr()->in('uid', $cmd->createParameter('uids'), IQueryBuilder::PARAM_STR_ARRAY));
+ foreach (array_chunk($allIds, 1000) as $chunkIds) {
+ $cmd->setParameter('uids', $chunkIds, IQueryBuilder::PARAM_STR_ARRAY);
+ $cmd->executeStatement();
+ }
+ }
+
+ /**
+ * Delete all invitations from a given calendar event
+ *
+ * @since 31.0.0
+ *
+ * @param string $eventId UID of the event
+ *
+ * @return void
+ */
+ protected function purgeObjectInvitations(string $eventId): void {
+ $cmd = $this->db->getQueryBuilder();
+ $cmd->delete($this->dbObjectInvitationsTable)
+ ->where($cmd->expr()->eq('uid', $cmd->createNamedParameter($eventId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR));
+ $cmd->executeStatement();
+ }
+
+ public function unshare(IShareable $shareable, string $principal): void {
+ $this->atomic(function () use ($shareable, $principal): void {
+ $calendarData = $this->getCalendarById($shareable->getResourceId());
+ if ($calendarData === null) {
+ throw new \RuntimeException('Trying to update shares for non-existing calendar: ' . $shareable->getResourceId());
+ }
+
+ $oldShares = $this->getShares($shareable->getResourceId());
+ $unshare = $this->calendarSharingBackend->unshare($shareable, $principal);
+
+ if ($unshare) {
+ $this->dispatcher->dispatchTyped(new CalendarShareUpdatedEvent(
+ $shareable->getResourceId(),
+ $calendarData,
+ $oldShares,
+ [],
+ [$principal]
+ ));
+ }
+ }, $this->db);
}
}
diff --git a/apps/dav/lib/CalDAV/Calendar.php b/apps/dav/lib/CalDAV/Calendar.php
index 02808ab5662..deb00caa93d 100644
--- a/apps/dav/lib/CalDAV/Calendar.php
+++ b/apps/dav/lib/CalDAV/Calendar.php
@@ -1,84 +1,74 @@
<?php
+
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Thomas Citharel <tcit@tcit.fr>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\DAV\CalDAV;
+use DateTimeImmutable;
+use DateTimeInterface;
+use OCA\DAV\CalDAV\Trashbin\Plugin as TrashbinPlugin;
use OCA\DAV\DAV\Sharing\IShareable;
+use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException;
+use OCP\DB\Exception;
use OCP\IConfig;
use OCP\IL10N;
+use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Backend\BackendInterface;
use Sabre\DAV\Exception\Forbidden;
use Sabre\DAV\Exception\NotFound;
+use Sabre\DAV\IMoveTarget;
+use Sabre\DAV\INode;
use Sabre\DAV\PropPatch;
/**
* Class Calendar
*
* @package OCA\DAV\CalDAV
- * @property BackendInterface|CalDavBackend $caldavBackend
+ * @property CalDavBackend $caldavBackend
*/
-class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
-
- /** @var IConfig */
- private $config;
+class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable, IMoveTarget {
+ protected IL10N $l10n;
+ private bool $useTrashbin = true;
+
+ public function __construct(
+ BackendInterface $caldavBackend,
+ array $calendarInfo,
+ IL10N $l10n,
+ private IConfig $config,
+ private LoggerInterface $logger,
+ ) {
+ // Convert deletion date to ISO8601 string
+ if (isset($calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT])) {
+ $calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT] = (new DateTimeImmutable())
+ ->setTimestamp($calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT])
+ ->format(DateTimeInterface::ATOM);
+ }
- public function __construct(BackendInterface $caldavBackend, $calendarInfo, IL10N $l10n, IConfig $config) {
parent::__construct($caldavBackend, $calendarInfo);
- if ($this->getName() === BirthdayService::BIRTHDAY_CALENDAR_URI) {
+ if ($this->getName() === BirthdayService::BIRTHDAY_CALENDAR_URI && strcasecmp($this->calendarInfo['{DAV:}displayname'], 'Contact birthdays') === 0) {
$this->calendarInfo['{DAV:}displayname'] = $l10n->t('Contact birthdays');
}
- if ($this->getName() === CalDavBackend::PERSONAL_CALENDAR_URI &&
- $this->calendarInfo['{DAV:}displayname'] === CalDavBackend::PERSONAL_CALENDAR_NAME) {
+ if ($this->getName() === CalDavBackend::PERSONAL_CALENDAR_URI
+ && $this->calendarInfo['{DAV:}displayname'] === CalDavBackend::PERSONAL_CALENDAR_NAME) {
$this->calendarInfo['{DAV:}displayname'] = $l10n->t('Personal');
}
+ $this->l10n = $l10n;
+ }
- $this->config = $config;
+ public function getUri(): string {
+ return $this->calendarInfo['uri'];
}
/**
- * Updates the list of shares.
- *
- * The first array is a list of people that are to be added to the
- * resource.
- *
- * Every element in the add array has the following properties:
- * * href - A url. Usually a mailto: address
- * * commonName - Usually a first and last name, or false
- * * summary - A description of the share, can also be false
- * * readOnly - A boolean value
- *
- * Every element in the remove array is just the address string.
- *
- * @param array $add
- * @param array $remove
- * @return void
+ * {@inheritdoc}
* @throws Forbidden
*/
- public function updateShares(array $add, array $remove) {
+ public function updateShares(array $add, array $remove): void {
if ($this->isShared()) {
throw new Forbidden();
}
@@ -95,19 +85,16 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
* * readOnly - boolean
* * summary - Optional, a description for the share
*
- * @return array
+ * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}>
*/
- public function getShares() {
+ public function getShares(): array {
if ($this->isShared()) {
return [];
}
return $this->caldavBackend->getShares($this->getResourceId());
}
- /**
- * @return int
- */
- public function getResourceId() {
+ public function getResourceId(): int {
return $this->calendarInfo['id'];
}
@@ -118,33 +105,70 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
return $this->calendarInfo['principaluri'];
}
+ /**
+ * @param int $resourceId
+ * @param list<array{privilege: string, principal: string, protected: bool}> $acl
+ * @return list<array{privilege: string, principal: ?string, protected: bool}>
+ */
public function getACL() {
- $acl = [
+ $acl = [
[
'privilege' => '{DAV:}read',
'principal' => $this->getOwner(),
'protected' => true,
- ]];
+ ],
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner() . '/calendar-proxy-write',
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner() . '/calendar-proxy-read',
+ 'protected' => true,
+ ],
+ ];
+
if ($this->getName() !== BirthdayService::BIRTHDAY_CALENDAR_URI) {
$acl[] = [
'privilege' => '{DAV:}write',
'principal' => $this->getOwner(),
'protected' => true,
];
+ $acl[] = [
+ 'privilege' => '{DAV:}write',
+ 'principal' => $this->getOwner() . '/calendar-proxy-write',
+ 'protected' => true,
+ ];
} else {
$acl[] = [
'privilege' => '{DAV:}write-properties',
'principal' => $this->getOwner(),
'protected' => true,
];
+ $acl[] = [
+ 'privilege' => '{DAV:}write-properties',
+ 'principal' => $this->getOwner() . '/calendar-proxy-write',
+ 'protected' => true,
+ ];
+ }
+
+ $acl[] = [
+ 'privilege' => '{DAV:}write-properties',
+ 'principal' => $this->getOwner() . '/calendar-proxy-read',
+ 'protected' => true,
+ ];
+
+ if (!$this->isShared()) {
+ return $acl;
}
if ($this->getOwner() !== parent::getOwner()) {
- $acl[] = [
- 'privilege' => '{DAV:}read',
- 'principal' => parent::getOwner(),
- 'protected' => true,
- ];
+ $acl[] = [
+ 'privilege' => '{DAV:}read',
+ 'principal' => parent::getOwner(),
+ 'protected' => true,
+ ];
if ($this->canWrite()) {
$acl[] = [
'privilege' => '{DAV:}write',
@@ -168,22 +192,25 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
}
$acl = $this->caldavBackend->applyShareAcl($this->getResourceId(), $acl);
-
- if (!$this->isShared()) {
- return $acl;
- }
-
- $allowedPrincipals = [$this->getOwner(), parent::getOwner(), 'principals/system/public'];
- return array_filter($acl, function($rule) use ($allowedPrincipals) {
- return in_array($rule['principal'], $allowedPrincipals);
+ $allowedPrincipals = [
+ $this->getOwner(),
+ $this->getOwner() . '/calendar-proxy-read',
+ $this->getOwner() . '/calendar-proxy-write',
+ parent::getOwner(),
+ 'principals/system/public'
+ ];
+ /** @var list<array{privilege: string, principal: string, protected: bool}> $acl */
+ $acl = array_filter($acl, function (array $rule) use ($allowedPrincipals): bool {
+ return \in_array($rule['principal'], $allowedPrincipals, true);
});
+ return $acl;
}
public function getChildACL() {
return $this->getACL();
}
- public function getOwner() {
+ public function getOwner(): ?string {
if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) {
return $this->calendarInfo['{http://owncloud.org/ns}owner-principal'];
}
@@ -191,20 +218,8 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
}
public function delete() {
- if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal']) &&
- $this->calendarInfo['{http://owncloud.org/ns}owner-principal'] !== $this->calendarInfo['principaluri']) {
- $principal = 'principal:' . parent::getOwner();
- $shares = $this->caldavBackend->getShares($this->getResourceId());
- $shares = array_filter($shares, function($share) use ($principal){
- return $share['href'] === $principal;
- });
- if (empty($shares)) {
- throw new Forbidden();
- }
-
- $this->caldavBackend->updateShares($this, [], [
- 'href' => $principal
- ]);
+ if ($this->isShared()) {
+ $this->caldavBackend->unshare($this, 'principal:' . $this->getPrincipalURI());
return;
}
@@ -217,7 +232,10 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
$this->config->setUserValue($userId, 'dav', 'generateBirthdayCalendar', 'no');
}
- parent::delete();
+ $this->caldavBackend->deleteCalendar(
+ $this->calendarInfo['id'],
+ !$this->useTrashbin
+ );
}
public function propPatch(PropPatch $propPatch) {
@@ -229,7 +247,6 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
}
public function getChild($name) {
-
$obj = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $name);
if (!$obj) {
@@ -242,12 +259,10 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
$obj['acl'] = $this->getChildACL();
- return new CalendarObject($this->caldavBackend, $this->calendarInfo, $obj);
-
+ return new CalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj);
}
public function getChildren() {
-
$objs = $this->caldavBackend->getCalendarObjects($this->calendarInfo['id']);
$children = [];
foreach ($objs as $obj) {
@@ -255,14 +270,12 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
continue;
}
$obj['acl'] = $this->getChildACL();
- $children[] = new CalendarObject($this->caldavBackend, $this->calendarInfo, $obj);
+ $children[] = new CalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj);
}
return $children;
-
}
public function getMultipleChildren(array $paths) {
-
$objs = $this->caldavBackend->getMultipleCalendarObjects($this->calendarInfo['id'], $paths);
$children = [];
foreach ($objs as $obj) {
@@ -270,10 +283,9 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
continue;
}
$obj['acl'] = $this->getChildACL();
- $children[] = new CalendarObject($this->caldavBackend, $this->calendarInfo, $obj);
+ $children[] = new CalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj);
}
return $children;
-
}
public function childExists($name) {
@@ -289,7 +301,6 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
}
public function calendarQuery(array $filters) {
-
$uris = $this->caldavBackend->calendarQuery($this->calendarInfo['id'], $filters);
if ($this->isShared()) {
return array_filter($uris, function ($uri) {
@@ -317,7 +328,11 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
return $this->caldavBackend->getPublishStatus($this);
}
- private function canWrite() {
+ public function canWrite() {
+ if ($this->getName() === BirthdayService::BIRTHDAY_CALENDAR_URI) {
+ return false;
+ }
+
if (isset($this->calendarInfo['{http://owncloud.org/ns}read-only'])) {
return !$this->calendarInfo['{http://owncloud.org/ns}read-only'];
}
@@ -328,7 +343,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
return isset($this->calendarInfo['{http://owncloud.org/ns}public']);
}
- protected function isShared() {
+ public function isShared() {
if (!isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) {
return false;
}
@@ -340,4 +355,53 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IShareable {
return isset($this->calendarInfo['{http://calendarserver.org/ns/}source']);
}
+ public function isDeleted(): bool {
+ if (!isset($this->calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT])) {
+ return false;
+ }
+ return $this->calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT] !== null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getChanges($syncToken, $syncLevel, $limit = null) {
+ if (!$syncToken && $limit) {
+ throw new UnsupportedLimitOnInitialSyncException();
+ }
+
+ return parent::getChanges($syncToken, $syncLevel, $limit);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function restore(): void {
+ $this->caldavBackend->restoreCalendar((int)$this->calendarInfo['id']);
+ }
+
+ public function disableTrashbin(): void {
+ $this->useTrashbin = false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function moveInto($targetName, $sourcePath, INode $sourceNode) {
+ if (!($sourceNode instanceof CalendarObject)) {
+ return false;
+ }
+ try {
+ return $this->caldavBackend->moveCalendarObject(
+ $sourceNode->getOwner(),
+ $sourceNode->getId(),
+ $this->getOwner(),
+ $this->getResourceId(),
+ $targetName,
+ );
+ } catch (Exception $e) {
+ $this->logger->error('Could not move calendar object: ' . $e->getMessage(), ['exception' => $e]);
+ return false;
+ }
+ }
}
diff --git a/apps/dav/lib/CalDAV/CalendarHome.php b/apps/dav/lib/CalDAV/CalendarHome.php
index 3e645db459f..89b78ba9007 100644
--- a/apps/dav/lib/CalDAV/CalendarHome.php
+++ b/apps/dav/lib/CalDAV/CalendarHome.php
@@ -1,52 +1,57 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\DAV\CalDAV;
+use OCA\DAV\AppInfo\PluginManager;
+use OCA\DAV\CalDAV\Integration\ExternalCalendar;
+use OCA\DAV\CalDAV\Integration\ICalendarProvider;
+use OCA\DAV\CalDAV\Trashbin\TrashbinHome;
+use OCP\App\IAppManager;
+use OCP\IConfig;
+use OCP\IL10N;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Backend\BackendInterface;
use Sabre\CalDAV\Backend\NotificationSupport;
use Sabre\CalDAV\Backend\SchedulingSupport;
use Sabre\CalDAV\Backend\SubscriptionSupport;
use Sabre\CalDAV\Schedule\Inbox;
-use Sabre\CalDAV\Schedule\Outbox;
use Sabre\CalDAV\Subscriptions\Subscription;
-use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\Exception\MethodNotAllowed;
+use Sabre\DAV\Exception\NotFound;
+use Sabre\DAV\INode;
use Sabre\DAV\MkCol;
class CalendarHome extends \Sabre\CalDAV\CalendarHome {
- /** @var \OCP\IL10N */
+ /** @var IL10N */
private $l10n;
- /** @var \OCP\IConfig */
+ /** @var IConfig */
private $config;
- public function __construct(BackendInterface $caldavBackend, $principalInfo) {
+ /** @var PluginManager */
+ private $pluginManager;
+ private ?array $cachedChildren = null;
+
+ public function __construct(
+ BackendInterface $caldavBackend,
+ array $principalInfo,
+ private LoggerInterface $logger,
+ private bool $returnCachedSubscriptions,
+ ) {
parent::__construct($caldavBackend, $principalInfo);
$this->l10n = \OC::$server->getL10N('dav');
- $this->config = \OC::$server->getConfig();
+ $this->config = Server::get(IConfig::class);
+ $this->pluginManager = new PluginManager(
+ \OC::$server,
+ Server::get(IAppManager::class)
+ );
}
/**
@@ -59,10 +64,13 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome {
/**
* @inheritdoc
*/
- function createExtendedCollection($name, MkCol $mkCol) {
- $reservedNames = [BirthdayService::BIRTHDAY_CALENDAR_URI];
+ public function createExtendedCollection($name, MkCol $mkCol): void {
+ $reservedNames = [
+ BirthdayService::BIRTHDAY_CALENDAR_URI,
+ TrashbinHome::NAME,
+ ];
- if (in_array($name, $reservedNames)) {
+ if (\in_array($name, $reservedNames, true) || ExternalCalendar::doesViolateReservedName($name)) {
throw new MethodNotAllowed('The resource you tried to create has a reserved name');
}
@@ -72,16 +80,19 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome {
/**
* @inheritdoc
*/
- function getChildren() {
+ public function getChildren() {
+ if ($this->cachedChildren) {
+ return $this->cachedChildren;
+ }
$calendars = $this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']);
$objects = [];
foreach ($calendars as $calendar) {
- $objects[] = new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config);
+ $objects[] = new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger);
}
if ($this->caldavBackend instanceof SchedulingSupport) {
$objects[] = new Inbox($this->caldavBackend, $this->principalInfo['uri']);
- $objects[] = new Outbox($this->principalInfo['uri']);
+ $objects[] = new Outbox($this->config, $this->principalInfo['uri']);
}
// We're adding a notifications node, if it's supported by the backend.
@@ -89,45 +100,94 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome {
$objects[] = new \Sabre\CalDAV\Notifications\Collection($this->caldavBackend, $this->principalInfo['uri']);
}
+ if ($this->caldavBackend instanceof CalDavBackend) {
+ $objects[] = new TrashbinHome($this->caldavBackend, $this->principalInfo);
+ }
+
// If the backend supports subscriptions, we'll add those as well,
if ($this->caldavBackend instanceof SubscriptionSupport) {
foreach ($this->caldavBackend->getSubscriptionsForUser($this->principalInfo['uri']) as $subscription) {
- $objects[] = new Subscription($this->caldavBackend, $subscription);
+ if ($this->returnCachedSubscriptions) {
+ $objects[] = new CachedSubscription($this->caldavBackend, $subscription);
+ } else {
+ $objects[] = new Subscription($this->caldavBackend, $subscription);
+ }
+ }
+ }
+
+ foreach ($this->pluginManager->getCalendarPlugins() as $calendarPlugin) {
+ /** @var ICalendarProvider $calendarPlugin */
+ $calendars = $calendarPlugin->fetchAllForCalendarHome($this->principalInfo['uri']);
+ foreach ($calendars as $calendar) {
+ $objects[] = $calendar;
}
}
+ $this->cachedChildren = $objects;
return $objects;
}
/**
- * @inheritdoc
+ * @param string $name
+ *
+ * @return INode
*/
- function getChild($name) {
+ public function getChild($name) {
// Special nodes
if ($name === 'inbox' && $this->caldavBackend instanceof SchedulingSupport) {
return new Inbox($this->caldavBackend, $this->principalInfo['uri']);
}
if ($name === 'outbox' && $this->caldavBackend instanceof SchedulingSupport) {
- return new Outbox($this->principalInfo['uri']);
+ return new Outbox($this->config, $this->principalInfo['uri']);
}
if ($name === 'notifications' && $this->caldavBackend instanceof NotificationSupport) {
- return new \Sabre\CalDAv\Notifications\Collection($this->caldavBackend, $this->principalInfo['uri']);
+ return new \Sabre\CalDAV\Notifications\Collection($this->caldavBackend, $this->principalInfo['uri']);
+ }
+ if ($name === TrashbinHome::NAME && $this->caldavBackend instanceof CalDavBackend) {
+ return new TrashbinHome($this->caldavBackend, $this->principalInfo);
+ }
+
+ // Calendar - this covers all "regular" calendars, but not shared
+ // only check if the method is available
+ if ($this->caldavBackend instanceof CalDavBackend) {
+ $calendar = $this->caldavBackend->getCalendarByUri($this->principalInfo['uri'], $name);
+ if (!empty($calendar)) {
+ return new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger);
+ }
}
- // Calendars
+ // Fallback to cover shared calendars
foreach ($this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']) as $calendar) {
if ($calendar['uri'] === $name) {
- return new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config);
+ return new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger);
}
}
if ($this->caldavBackend instanceof SubscriptionSupport) {
foreach ($this->caldavBackend->getSubscriptionsForUser($this->principalInfo['uri']) as $subscription) {
if ($subscription['uri'] === $name) {
+ if ($this->returnCachedSubscriptions) {
+ return new CachedSubscription($this->caldavBackend, $subscription);
+ }
+
return new Subscription($this->caldavBackend, $subscription);
}
}
+ }
+ if (ExternalCalendar::isAppGeneratedCalendar($name)) {
+ [$appId, $calendarUri] = ExternalCalendar::splitAppGeneratedCalendarUri($name);
+
+ foreach ($this->pluginManager->getCalendarPlugins() as $calendarPlugin) {
+ /** @var ICalendarProvider $calendarPlugin */
+ if ($calendarPlugin->getAppId() !== $appId) {
+ continue;
+ }
+
+ if ($calendarPlugin->hasCalendarInCalendarHome($this->principalInfo['uri'], $calendarUri)) {
+ return $calendarPlugin->getCalendarInCalendarHome($this->principalInfo['uri'], $calendarUri);
+ }
+ }
}
throw new NotFound('Node with name \'' . $name . '\' could not be found');
@@ -138,7 +198,7 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome {
* @param integer|null $limit
* @param integer|null $offset
*/
- function calendarSearch(array $filters, $limit=null, $offset=null) {
+ public function calendarSearch(array $filters, $limit = null, $offset = null) {
$principalUri = $this->principalInfo['uri'];
return $this->caldavBackend->calendarSearch($principalUri, $filters, $limit, $offset);
}
diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php
index cfdf821a563..5f912da732e 100644
--- a/apps/dav/lib/CalDAV/CalendarImpl.php
+++ b/apps/dav/lib/CalDAV/CalendarImpl.php
@@ -1,106 +1,116 @@
<?php
+
+declare(strict_types=1);
+
/**
- * @copyright 2017, Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV;
+use Generator;
+use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin;
+use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer;
+use OCP\Calendar\CalendarExportOptions;
+use OCP\Calendar\Exceptions\CalendarException;
+use OCP\Calendar\ICalendarExport;
+use OCP\Calendar\ICalendarIsEnabled;
+use OCP\Calendar\ICalendarIsShared;
+use OCP\Calendar\ICalendarIsWritable;
+use OCP\Calendar\ICreateFromString;
+use OCP\Calendar\IHandleImipMessage;
use OCP\Constants;
-use OCP\Calendar\ICalendar;
-
-class CalendarImpl implements ICalendar {
-
- /** @var CalDavBackend */
- private $backend;
+use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp;
+use Sabre\DAV\Exception\Conflict;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\Component\VTimeZone;
+use Sabre\VObject\ITip\Message;
+use Sabre\VObject\Property;
+use Sabre\VObject\Reader;
+use function Sabre\Uri\split as uriSplit;
- /** @var Calendar */
- private $calendar;
-
- /** @var array */
- private $calendarInfo;
+class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIsWritable, ICalendarIsShared, ICalendarExport, ICalendarIsEnabled {
+ public function __construct(
+ private Calendar $calendar,
+ /** @var array<string, mixed> */
+ private array $calendarInfo,
+ private CalDavBackend $backend,
+ ) {
+ }
/**
- * CalendarImpl constructor.
- *
- * @param Calendar $calendar
- * @param array $calendarInfo
- * @param CalDavBackend $backend
+ * @return string defining the technical unique key
+ * @since 13.0.0
*/
- public function __construct(Calendar $calendar, array $calendarInfo,
- CalDavBackend $backend) {
- $this->calendar = $calendar;
- $this->calendarInfo = $calendarInfo;
- $this->backend = $backend;
+ public function getKey(): string {
+ return (string)$this->calendarInfo['id'];
}
-
+
/**
- * @return string defining the technical unique key
- * @since 13.0.0
+ * {@inheritDoc}
*/
- public function getKey() {
- return $this->calendarInfo['id'];
+ public function getUri(): string {
+ return $this->calendarInfo['uri'];
}
/**
* In comparison to getKey() this function returns a human readable (maybe translated) name
- * @return null|string
* @since 13.0.0
*/
- public function getDisplayName() {
+ public function getDisplayName(): ?string {
return $this->calendarInfo['{DAV:}displayname'];
}
/**
* Calendar color
- * @return null|string
* @since 13.0.0
*/
- public function getDisplayColor() {
+ public function getDisplayColor(): ?string {
return $this->calendarInfo['{http://apple.com/ns/ical/}calendar-color'];
}
- /**
- * @param string $pattern which should match within the $searchProperties
- * @param array $searchProperties defines the properties within the query pattern should match
- * @param array $options - optional parameters:
- * ['timerange' => ['start' => new DateTime(...), 'end' => new DateTime(...)]]
- * @param integer|null $limit - limit number of search results
- * @param integer|null $offset - offset for paging of search results
- * @return array an array of events/journals/todos which are arrays of key-value-pairs
- * @since 13.0.0
- */
- public function search($pattern, array $searchProperties=[], array $options=[], $limit=null, $offset=null) {
+ public function getSchedulingTransparency(): ?ScheduleCalendarTransp {
+ return $this->calendarInfo['{' . \OCA\DAV\CalDAV\Schedule\Plugin::NS_CALDAV . '}schedule-calendar-transp'];
+ }
+
+ public function getSchedulingTimezone(): ?VTimeZone {
+ $tzProp = '{' . \OCA\DAV\CalDAV\Schedule\Plugin::NS_CALDAV . '}calendar-timezone';
+ if (!isset($this->calendarInfo[$tzProp])) {
+ return null;
+ }
+ // This property contains a VCALENDAR with a single VTIMEZONE
+ /** @var string $timezoneProp */
+ $timezoneProp = $this->calendarInfo[$tzProp];
+ /** @var VCalendar $vobj */
+ $vobj = Reader::read($timezoneProp);
+ $components = $vobj->getComponents();
+ if (empty($components)) {
+ return null;
+ }
+ /** @var VTimeZone $vtimezone */
+ $vtimezone = $components[0];
+ return $vtimezone;
+ }
+
+ public function search(string $pattern, array $searchProperties = [], array $options = [], $limit = null, $offset = null): array {
return $this->backend->search($this->calendarInfo, $pattern,
$searchProperties, $options, $limit, $offset);
}
/**
- * @return integer build up using \OCP\Constants
+ * @return int build up using \OCP\Constants
* @since 13.0.0
*/
- public function getPermissions() {
+ public function getPermissions(): int {
$permissions = $this->calendar->getACL();
$result = 0;
foreach ($permissions as $permission) {
- switch($permission['privilege']) {
+ if ($this->calendarInfo['principaluri'] !== $permission['principal']) {
+ continue;
+ }
+
+ switch ($permission['privilege']) {
case '{DAV:}read':
$result |= Constants::PERMISSION_READ;
break;
@@ -116,4 +126,167 @@ class CalendarImpl implements ICalendar {
return $result;
}
+
+ /**
+ * @since 32.0.0
+ */
+ public function isEnabled(): bool {
+ return $this->calendarInfo['{http://owncloud.org/ns}calendar-enabled'] ?? true;
+ }
+
+ /**
+ * @since 31.0.0
+ */
+ public function isWritable(): bool {
+ return $this->calendar->canWrite();
+ }
+
+ /**
+ * @since 26.0.0
+ */
+ public function isDeleted(): bool {
+ return $this->calendar->isDeleted();
+ }
+
+ /**
+ * @since 31.0.0
+ */
+ public function isShared(): bool {
+ return $this->calendar->isShared();
+ }
+
+ /**
+ * @throws CalendarException
+ */
+ private function createFromStringInServer(
+ string $name,
+ string $calendarData,
+ \OCA\DAV\Connector\Sabre\Server $server,
+ ): void {
+ /** @var CustomPrincipalPlugin $plugin */
+ $plugin = $server->getPlugin('auth');
+ // we're working around the previous implementation
+ // that only allowed the public system principal to be used
+ // so set the custom principal here
+ $plugin->setCurrentPrincipal($this->calendar->getPrincipalURI());
+
+ if (empty($this->calendarInfo['uri'])) {
+ throw new CalendarException('Could not write to calendar as URI parameter is missing');
+ }
+
+ // Build full calendar path
+ [, $user] = uriSplit($this->calendar->getPrincipalURI());
+ $fullCalendarFilename = sprintf('calendars/%s/%s/%s', $user, $this->calendarInfo['uri'], $name);
+
+ // Force calendar change URI
+ /** @var Schedule\Plugin $schedulingPlugin */
+ $schedulingPlugin = $server->getPlugin('caldav-schedule');
+ $schedulingPlugin->setPathOfCalendarObjectChange($fullCalendarFilename);
+
+ $stream = fopen('php://memory', 'rb+');
+ fwrite($stream, $calendarData);
+ rewind($stream);
+ try {
+ $server->createFile($fullCalendarFilename, $stream);
+ } catch (Conflict $e) {
+ throw new CalendarException('Could not create new calendar event: ' . $e->getMessage(), 0, $e);
+ } finally {
+ fclose($stream);
+ }
+ }
+
+ public function createFromString(string $name, string $calendarData): void {
+ $server = new EmbeddedCalDavServer(false);
+ $this->createFromStringInServer($name, $calendarData, $server->getServer());
+ }
+
+ public function createFromStringMinimal(string $name, string $calendarData): void {
+ $server = new InvitationResponseServer(false);
+ $this->createFromStringInServer($name, $calendarData, $server->getServer());
+ }
+
+ /**
+ * @throws CalendarException
+ */
+ public function handleIMipMessage(string $name, string $calendarData): void {
+ $server = $this->getInvitationResponseServer();
+
+ /** @var CustomPrincipalPlugin $plugin */
+ $plugin = $server->getServer()->getPlugin('auth');
+ // we're working around the previous implementation
+ // that only allowed the public system principal to be used
+ // so set the custom principal here
+ $plugin->setCurrentPrincipal($this->calendar->getPrincipalURI());
+
+ if (empty($this->calendarInfo['uri'])) {
+ throw new CalendarException('Could not write to calendar as URI parameter is missing');
+ }
+ // Force calendar change URI
+ /** @var Schedule\Plugin $schedulingPlugin */
+ $schedulingPlugin = $server->getServer()->getPlugin('caldav-schedule');
+ // Let sabre handle the rest
+ $iTipMessage = new Message();
+ /** @var VCalendar $vObject */
+ $vObject = Reader::read($calendarData);
+ /** @var VEvent $vEvent */
+ $vEvent = $vObject->{'VEVENT'};
+
+ if ($vObject->{'METHOD'} === null) {
+ throw new CalendarException('No Method provided for scheduling data. Could not process message');
+ }
+
+ if (!isset($vEvent->{'ORGANIZER'}) || !isset($vEvent->{'ATTENDEE'})) {
+ throw new CalendarException('Could not process scheduling data, neccessary data missing from ICAL');
+ }
+ $organizer = $vEvent->{'ORGANIZER'}->getValue();
+ $attendee = $vEvent->{'ATTENDEE'}->getValue();
+
+ $iTipMessage->method = $vObject->{'METHOD'}->getValue();
+ if ($iTipMessage->method === 'REQUEST') {
+ $iTipMessage->sender = $organizer;
+ $iTipMessage->recipient = $attendee;
+ } elseif ($iTipMessage->method === 'REPLY') {
+ if ($server->isExternalAttendee($vEvent->{'ATTENDEE'}->getValue())) {
+ $iTipMessage->recipient = $organizer;
+ } else {
+ $iTipMessage->recipient = $attendee;
+ }
+ $iTipMessage->sender = $attendee;
+ } elseif ($iTipMessage->method === 'CANCEL') {
+ $iTipMessage->recipient = $attendee;
+ $iTipMessage->sender = $organizer;
+ }
+ $iTipMessage->uid = isset($vEvent->{'UID'}) ? $vEvent->{'UID'}->getValue() : '';
+ $iTipMessage->component = 'VEVENT';
+ $iTipMessage->sequence = isset($vEvent->{'SEQUENCE'}) ? (int)$vEvent->{'SEQUENCE'}->getValue() : 0;
+ $iTipMessage->message = $vObject;
+ $server->server->emit('schedule', [$iTipMessage]);
+ }
+
+ public function getInvitationResponseServer(): InvitationResponseServer {
+ return new InvitationResponseServer(false);
+ }
+
+ /**
+ * Export objects
+ *
+ * @since 32.0.0
+ *
+ * @return Generator<mixed, \Sabre\VObject\Component\VCalendar, mixed, mixed>
+ */
+ public function export(?CalendarExportOptions $options = null): Generator {
+ foreach (
+ $this->backend->exportCalendar(
+ $this->calendarInfo['id'],
+ $this->backend::CALENDAR_TYPE_CALENDAR,
+ $options
+ ) as $event
+ ) {
+ $vObject = Reader::read($event['calendardata']);
+ if ($vObject instanceof VCalendar) {
+ yield $vObject;
+ }
+ }
+ }
+
}
diff --git a/apps/dav/lib/CalDAV/CalendarManager.php b/apps/dav/lib/CalDAV/CalendarManager.php
index 59590def5ec..a2d2f1cda8a 100644
--- a/apps/dav/lib/CalDAV/CalendarManager.php
+++ b/apps/dav/lib/CalDAV/CalendarManager.php
@@ -1,43 +1,18 @@
<?php
+
/**
- * @copyright 2017, Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV;
use OCP\Calendar\IManager;
use OCP\IConfig;
use OCP\IL10N;
+use Psr\Log\LoggerInterface;
class CalendarManager {
- /** @var CalDavBackend */
- private $backend;
-
- /** @var IL10N */
- private $l10n;
-
- /** @var IConfig */
- private $config;
-
/**
* CalendarManager constructor.
*
@@ -45,10 +20,12 @@ class CalendarManager {
* @param IL10N $l10n
* @param IConfig $config
*/
- public function __construct(CalDavBackend $backend, IL10N $l10n, IConfig $config) {
- $this->backend = $backend;
- $this->l10n = $l10n;
- $this->config = $config;
+ public function __construct(
+ private CalDavBackend $backend,
+ private IL10N $l10n,
+ private IConfig $config,
+ private LoggerInterface $logger,
+ ) {
}
/**
@@ -65,8 +42,8 @@ class CalendarManager {
* @param array $calendars
*/
private function register(IManager $cm, array $calendars) {
- foreach($calendars as $calendarInfo) {
- $calendar = new Calendar($this->backend, $calendarInfo, $this->l10n, $this->config);
+ foreach ($calendars as $calendarInfo) {
+ $calendar = new Calendar($this->backend, $calendarInfo, $this->l10n, $this->config, $this->logger);
$cm->registerCalendar(new CalendarImpl(
$calendar,
$calendarInfo,
diff --git a/apps/dav/lib/CalDAV/CalendarObject.php b/apps/dav/lib/CalDAV/CalendarObject.php
index 0db592898af..02178b4236f 100644
--- a/apps/dav/lib/CalDAV/CalendarObject.php
+++ b/apps/dav/lib/CalDAV/CalendarObject.php
@@ -1,31 +1,13 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @copyright Copyright (c) 2017, Georg Ehrke
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
-
-
namespace OCA\DAV\CalDAV;
-
+use OCP\IL10N;
use Sabre\VObject\Component;
use Sabre\VObject\Property;
use Sabre\VObject\Reader;
@@ -36,11 +18,16 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject {
* CalendarObject constructor.
*
* @param CalDavBackend $caldavBackend
+ * @param IL10N $l10n
* @param array $calendarInfo
* @param array $objectData
*/
- public function __construct(CalDavBackend $caldavBackend, array $calendarInfo,
- array $objectData) {
+ public function __construct(
+ CalDavBackend $caldavBackend,
+ protected IL10N $l10n,
+ array $calendarInfo,
+ array $objectData,
+ ) {
parent::__construct($caldavBackend, $calendarInfo, $objectData);
if ($this->isShared()) {
@@ -51,7 +38,7 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject {
/**
* @inheritdoc
*/
- function get() {
+ public function get() {
$data = parent::get();
if (!$this->isShared()) {
@@ -73,6 +60,10 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject {
return $vObject->serialize();
}
+ public function getId(): int {
+ return (int)$this->objectData['id'];
+ }
+
protected function isShared() {
if (!isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) {
return false;
@@ -85,32 +76,33 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject {
* @param Component\VCalendar $vObject
* @return void
*/
- private static function createConfidentialObject(Component\VCalendar $vObject) {
+ private function createConfidentialObject(Component\VCalendar $vObject): void {
/** @var Component $vElement */
- $vElement = null;
- if(isset($vObject->VEVENT)) {
- $vElement = $vObject->VEVENT;
- }
- if(isset($vObject->VJOURNAL)) {
- $vElement = $vObject->VJOURNAL;
- }
- if(isset($vObject->VTODO)) {
- $vElement = $vObject->VTODO;
- }
- if(!is_null($vElement)) {
+ $vElements = array_filter($vObject->getComponents(), static function ($vElement) {
+ return $vElement instanceof Component\VEvent || $vElement instanceof Component\VJournal || $vElement instanceof Component\VTodo;
+ });
+
+ foreach ($vElements as $vElement) {
+ if (empty($vElement->select('SUMMARY'))) {
+ $vElement->add('SUMMARY', $this->l10n->t('Busy')); // This is needed to mask "Untitled Event" events
+ }
foreach ($vElement->children() as &$property) {
/** @var Property $property */
- switch($property->name) {
+ switch ($property->name) {
case 'CREATED':
case 'DTSTART':
case 'RRULE':
+ case 'RECURRENCE-ID':
+ case 'RDATE':
case 'DURATION':
case 'DTEND':
case 'CLASS':
+ case 'EXRULE':
+ case 'EXDATE':
case 'UID':
break;
case 'SUMMARY':
- $property->setValue('Busy');
+ $property->setValue($this->l10n->t('Busy'));
break;
default:
$vElement->__unset($property->name);
@@ -128,7 +120,7 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject {
private function removeVAlarms(Component\VCalendar $vObject) {
$subcomponents = $vObject->getComponents();
- foreach($subcomponents as $subcomponent) {
+ foreach ($subcomponents as $subcomponent) {
unset($subcomponent->VALARM);
}
}
@@ -142,4 +134,19 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject {
}
return true;
}
+
+ public function getCalendarId(): int {
+ return (int)$this->objectData['calendarid'];
+ }
+
+ public function getPrincipalUri(): string {
+ return $this->calendarInfo['principaluri'];
+ }
+
+ public function getOwner(): ?string {
+ if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) {
+ return $this->calendarInfo['{http://owncloud.org/ns}owner-principal'];
+ }
+ return parent::getOwner();
+ }
}
diff --git a/apps/dav/lib/CalDAV/CalendarProvider.php b/apps/dav/lib/CalDAV/CalendarProvider.php
new file mode 100644
index 00000000000..a8b818e59aa
--- /dev/null
+++ b/apps/dav/lib/CalDAV/CalendarProvider.php
@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV;
+
+use OCA\DAV\Db\Property;
+use OCA\DAV\Db\PropertyMapper;
+use OCP\Calendar\ICalendarProvider;
+use OCP\IConfig;
+use OCP\IL10N;
+use Psr\Log\LoggerInterface;
+
+class CalendarProvider implements ICalendarProvider {
+
+ public function __construct(
+ private CalDavBackend $calDavBackend,
+ private IL10N $l10n,
+ private IConfig $config,
+ private LoggerInterface $logger,
+ private PropertyMapper $propertyMapper,
+ ) {
+ }
+
+ public function getCalendars(string $principalUri, array $calendarUris = []): array {
+
+ $calendarInfos = $this->calDavBackend->getCalendarsForUser($principalUri) ?? [];
+
+ if (!empty($calendarUris)) {
+ $calendarInfos = array_filter($calendarInfos, function ($calendar) use ($calendarUris) {
+ return in_array($calendar['uri'], $calendarUris);
+ });
+ }
+
+ $additionalProperties = $this->getAdditionalPropertiesForCalendars($calendarInfos);
+ $iCalendars = [];
+ foreach ($calendarInfos as $calendarInfo) {
+ $user = str_replace('principals/users/', '', $calendarInfo['principaluri']);
+ $path = 'calendars/' . $user . '/' . $calendarInfo['uri'];
+
+ $calendarInfo = array_merge($calendarInfo, $additionalProperties[$path] ?? []);
+
+ $calendar = new Calendar($this->calDavBackend, $calendarInfo, $this->l10n, $this->config, $this->logger);
+ $iCalendars[] = new CalendarImpl(
+ $calendar,
+ $calendarInfo,
+ $this->calDavBackend,
+ );
+ }
+ return $iCalendars;
+ }
+
+ /**
+ * @param array{
+ * principaluri: string,
+ * uri: string,
+ * }[] $uris
+ * @return array<string, array<string, string|bool>>
+ */
+ private function getAdditionalPropertiesForCalendars(array $uris): array {
+ $calendars = [];
+ foreach ($uris as $uri) {
+ /** @var string $user */
+ $user = str_replace('principals/users/', '', $uri['principaluri']);
+ if (!array_key_exists($user, $calendars)) {
+ $calendars[$user] = [];
+ }
+ $calendars[$user][] = 'calendars/' . $user . '/' . $uri['uri'];
+ }
+
+ $properties = $this->propertyMapper->findPropertiesByPathsAndUsers($calendars);
+
+ $list = [];
+ foreach ($properties as $property) {
+ if ($property instanceof Property) {
+ if (!isset($list[$property->getPropertypath()])) {
+ $list[$property->getPropertypath()] = [];
+ }
+
+ $list[$property->getPropertypath()][$property->getPropertyname()] = match ($property->getPropertyname()) {
+ '{http://owncloud.org/ns}calendar-enabled' => (bool)$property->getPropertyvalue(),
+ default => $property->getPropertyvalue()
+ };
+ }
+ }
+
+ return $list;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/CalendarRoot.php b/apps/dav/lib/CalDAV/CalendarRoot.php
index 2c1c8bb4ef2..c0a313955bb 100644
--- a/apps/dav/lib/CalDAV/CalendarRoot.php
+++ b/apps/dav/lib/CalDAV/CalendarRoot.php
@@ -1,30 +1,49 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- *
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\DAV\CalDAV;
+use Psr\Log\LoggerInterface;
+use Sabre\CalDAV\Backend;
+use Sabre\DAVACL\PrincipalBackend;
+
class CalendarRoot extends \Sabre\CalDAV\CalendarRoot {
+ private array $returnCachedSubscriptions = [];
+
+ public function __construct(
+ PrincipalBackend\BackendInterface $principalBackend,
+ Backend\BackendInterface $caldavBackend,
+ $principalPrefix,
+ private LoggerInterface $logger,
+ ) {
+ parent::__construct($principalBackend, $caldavBackend, $principalPrefix);
+ }
+
+ public function getChildForPrincipal(array $principal) {
+ return new CalendarHome(
+ $this->caldavBackend,
+ $principal,
+ $this->logger,
+ array_key_exists($principal['uri'], $this->returnCachedSubscriptions)
+ );
+ }
+
+ public function getName() {
+ if ($this->principalPrefix === 'principals/calendar-resources'
+ || $this->principalPrefix === 'principals/calendar-rooms') {
+ $parts = explode('/', $this->principalPrefix);
+
+ return $parts[1];
+ }
+
+ return parent::getName();
+ }
- function getChildForPrincipal(array $principal) {
- return new CalendarHome($this->caldavBackend, $principal);
+ public function enableReturnCachedSubscriptions(string $principalUri): void {
+ $this->returnCachedSubscriptions['principals/users/' . $principalUri] = true;
}
-} \ No newline at end of file
+}
diff --git a/apps/dav/lib/CalDAV/DefaultCalendarValidator.php b/apps/dav/lib/CalDAV/DefaultCalendarValidator.php
new file mode 100644
index 00000000000..266e07ef255
--- /dev/null
+++ b/apps/dav/lib/CalDAV/DefaultCalendarValidator.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV;
+
+use Sabre\DAV\Exception as DavException;
+
+class DefaultCalendarValidator {
+ /**
+ * Check if a given Calendar node is suitable to be used as the default calendar for scheduling.
+ *
+ * @throws DavException If the calendar is not suitable to be used as the default calendar
+ */
+ public function validateScheduleDefaultCalendar(Calendar $calendar): void {
+ // Sanity checks for a calendar that should handle invitations
+ if ($calendar->isSubscription()
+ || !$calendar->canWrite()
+ || $calendar->isShared()
+ || $calendar->isDeleted()) {
+ throw new DavException('Calendar is a subscription, not writable, shared or deleted');
+ }
+
+ // Calendar must support VEVENTs
+ $sCCS = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set';
+ $calendarProperties = $calendar->getProperties([$sCCS]);
+ if (isset($calendarProperties[$sCCS])) {
+ $supportedComponents = $calendarProperties[$sCCS]->getValue();
+ } else {
+ $supportedComponents = ['VJOURNAL', 'VTODO', 'VEVENT'];
+ }
+ if (!in_array('VEVENT', $supportedComponents, true)) {
+ throw new DavException('Calendar does not support VEVENT components');
+ }
+ }
+}
diff --git a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php
new file mode 100644
index 00000000000..d9d6d840c5e
--- /dev/null
+++ b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php
@@ -0,0 +1,122 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV;
+
+use OCA\DAV\AppInfo\PluginManager;
+use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin;
+use OCA\DAV\CalDAV\Auth\PublicPrincipalPlugin;
+use OCA\DAV\CalDAV\Publishing\PublishPlugin;
+use OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin;
+use OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin;
+use OCA\DAV\Connector\Sabre\CachingTree;
+use OCA\DAV\Connector\Sabre\DavAclPlugin;
+use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin;
+use OCA\DAV\Connector\Sabre\LockPlugin;
+use OCA\DAV\Connector\Sabre\MaintenancePlugin;
+use OCA\DAV\Connector\Sabre\PropFindPreloadNotifyPlugin;
+use OCA\DAV\Events\SabrePluginAuthInitEvent;
+use OCA\DAV\RootCollection;
+use OCA\Theming\ThemingDefaults;
+use OCP\App\IAppManager;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IAppConfig;
+use OCP\IConfig;
+use OCP\IURLGenerator;
+use OCP\L10N\IFactory as IL10NFactory;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+
+class EmbeddedCalDavServer {
+ private readonly \OCA\DAV\Connector\Sabre\Server $server;
+
+ public function __construct(bool $public = true) {
+ $baseUri = \OC::$WEBROOT . '/remote.php/dav/';
+ $logger = Server::get(LoggerInterface::class);
+ $dispatcher = Server::get(IEventDispatcher::class);
+ $appConfig = Server::get(IAppConfig::class);
+ $l10nFactory = Server::get(IL10NFactory::class);
+ $l10n = $l10nFactory->get('dav');
+
+ $root = new RootCollection();
+ $this->server = new \OCA\DAV\Connector\Sabre\Server(new CachingTree($root));
+
+ // Add maintenance plugin
+ $this->server->addPlugin(new MaintenancePlugin(Server::get(IConfig::class), $l10n));
+
+ // Set URL explicitly due to reverse-proxy situations
+ $this->server->httpRequest->setUrl($baseUri);
+ $this->server->setBaseUri($baseUri);
+
+ $this->server->addPlugin(new BlockLegacyClientPlugin(
+ Server::get(IConfig::class),
+ Server::get(ThemingDefaults::class),
+ ));
+ $this->server->addPlugin(new AnonymousOptionsPlugin());
+
+ // allow custom principal uri option
+ if ($public) {
+ $this->server->addPlugin(new PublicPrincipalPlugin());
+ } else {
+ $this->server->addPlugin(new CustomPrincipalPlugin());
+ }
+
+ // allow setup of additional auth backends
+ $event = new SabrePluginAuthInitEvent($this->server);
+ $dispatcher->dispatchTyped($event);
+
+ $this->server->addPlugin(new ExceptionLoggerPlugin('webdav', $logger));
+ $this->server->addPlugin(new LockPlugin());
+ $this->server->addPlugin(new \Sabre\DAV\Sync\Plugin());
+
+ // acl
+ $acl = new DavAclPlugin();
+ $acl->principalCollectionSet = [
+ 'principals/users', 'principals/groups'
+ ];
+ $this->server->addPlugin($acl);
+
+ // calendar plugins
+ $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin());
+ $this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin());
+ $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class)));
+ $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin());
+ $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin());
+ //$this->server->addPlugin(new \OCA\DAV\DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest()));
+ $this->server->addPlugin(new PublishPlugin(
+ Server::get(IConfig::class),
+ Server::get(IURLGenerator::class)
+ ));
+ if ($appConfig->getValueString('dav', 'sendInvitations', 'yes') === 'yes') {
+ $this->server->addPlugin(Server::get(\OCA\DAV\CalDAV\Schedule\IMipPlugin::class));
+ }
+
+ // collection preload plugin
+ $this->server->addPlugin(new PropFindPreloadNotifyPlugin());
+
+ // wait with registering these until auth is handled and the filesystem is setup
+ $this->server->on('beforeMethod:*', function () use ($root): void {
+ // register plugins from apps
+ $pluginManager = new PluginManager(
+ \OC::$server,
+ Server::get(IAppManager::class)
+ );
+ foreach ($pluginManager->getAppPlugins() as $appPlugin) {
+ $this->server->addPlugin($appPlugin);
+ }
+ foreach ($pluginManager->getAppCollections() as $appCollection) {
+ $root->addChild($appCollection);
+ }
+ });
+ }
+
+ public function getServer(): \OCA\DAV\Connector\Sabre\Server {
+ return $this->server;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/EventComparisonService.php b/apps/dav/lib/CalDAV/EventComparisonService.php
new file mode 100644
index 00000000000..63395e7ce1c
--- /dev/null
+++ b/apps/dav/lib/CalDAV/EventComparisonService.php
@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV;
+
+use OCA\DAV\CalDAV\Schedule\IMipService;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Component\VEvent;
+
+class EventComparisonService {
+
+ /** @var string[] */
+ private const EVENT_DIFF = [
+ 'RECURRENCE-ID',
+ 'RRULE',
+ 'SEQUENCE',
+ 'LAST-MODIFIED'
+ ];
+
+
+ /**
+ * If found, remove the event from $eventsToFilter that
+ * is identical to the passed $filterEvent
+ * and return whether an identical event was found
+ *
+ * This function takes into account the SEQUENCE,
+ * RRULE, RECURRENCE-ID and LAST-MODIFIED parameters
+ *
+ * @param VEvent $filterEvent
+ * @param array $eventsToFilter
+ * @return bool true if there was an identical event found and removed, false if there wasn't
+ */
+ private function removeIfUnchanged(VEvent $filterEvent, array &$eventsToFilter): bool {
+ $filterEventData = [];
+ foreach (self::EVENT_DIFF as $eventDiff) {
+ $filterEventData[] = IMipService::readPropertyWithDefault($filterEvent, $eventDiff, '');
+ }
+
+ /** @var VEvent $component */
+ foreach ($eventsToFilter as $k => $eventToFilter) {
+ $eventToFilterData = [];
+ foreach (self::EVENT_DIFF as $eventDiff) {
+ $eventToFilterData[] = IMipService::readPropertyWithDefault($eventToFilter, $eventDiff, '');
+ }
+ // events are identical and can be removed
+ if ($filterEventData === $eventToFilterData) {
+ unset($eventsToFilter[$k]);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Compare two VCalendars with each other and find all changed elements
+ *
+ * Returns an array of old and new events
+ *
+ * Old events are only detected if they are also changed
+ * If there is no corresponding old event for a VEvent, it
+ * has been newly created
+ *
+ * @param VCalendar $new
+ * @param VCalendar|null $old
+ * @return array<string, VEvent[]|null>
+ */
+ public function findModified(VCalendar $new, ?VCalendar $old): array {
+ $newEventComponents = $new->getComponents();
+
+ foreach ($newEventComponents as $k => $event) {
+ if (!$event instanceof VEvent) {
+ unset($newEventComponents[$k]);
+ }
+ }
+
+ if (empty($old)) {
+ return ['old' => null, 'new' => $newEventComponents];
+ }
+
+ $oldEventComponents = $old->getComponents();
+ if (is_array($oldEventComponents) && !empty($oldEventComponents)) {
+ foreach ($oldEventComponents as $k => $event) {
+ if (!$event instanceof VEvent) {
+ unset($oldEventComponents[$k]);
+ continue;
+ }
+ if ($this->removeIfUnchanged($event, $newEventComponents)) {
+ unset($oldEventComponents[$k]);
+ }
+ }
+ }
+
+ return ['old' => array_values($oldEventComponents), 'new' => array_values($newEventComponents)];
+ }
+}
diff --git a/apps/dav/lib/CalDAV/EventReader.php b/apps/dav/lib/CalDAV/EventReader.php
new file mode 100644
index 00000000000..ee2b8f33f9a
--- /dev/null
+++ b/apps/dav/lib/CalDAV/EventReader.php
@@ -0,0 +1,771 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV;
+
+use DateTime;
+use DateTimeImmutable;
+use DateTimeInterface;
+use DateTimeZone;
+use InvalidArgumentException;
+
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\Reader;
+
+class EventReader {
+
+ protected VEvent $baseEvent;
+ protected DateTimeInterface $baseEventStartDate;
+ protected DateTimeZone $baseEventStartTimeZone;
+ protected DateTimeInterface $baseEventEndDate;
+ protected DateTimeZone $baseEventEndTimeZone;
+ protected bool $baseEventStartDateFloating = false;
+ protected bool $baseEventEndDateFloating = false;
+ protected int $baseEventDuration;
+
+ protected ?EventReaderRRule $rruleIterator = null;
+ protected ?EventReaderRDate $rdateIterator = null;
+ protected ?EventReaderRRule $eruleIterator = null;
+ protected ?EventReaderRDate $edateIterator = null;
+
+ protected array $recurrenceModified;
+ protected ?DateTimeInterface $recurrenceCurrentDate;
+
+ protected array $dayNamesMap = [
+ 'MO' => 'Monday', 'TU' => 'Tuesday', 'WE' => 'Wednesday', 'TH' => 'Thursday', 'FR' => 'Friday', 'SA' => 'Saturday', 'SU' => 'Sunday'
+ ];
+ protected array $monthNamesMap = [
+ 1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', 5 => 'May', 6 => 'June',
+ 7 => 'July', 8 => 'August', 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December'
+ ];
+ protected array $relativePositionNamesMap = [
+ 1 => 'First', 2 => 'Second', 3 => 'Third', 4 => 'Fourth', 5 => 'Fifth',
+ -1 => 'Last', -2 => 'Second Last', -3 => 'Third Last', -4 => 'Fourth Last', -5 => 'Fifth Last'
+ ];
+
+ /**
+ * Initilizes the Event Reader
+ *
+ * There is several ways to set up the iterator.
+ *
+ * 1. You can pass a VCALENDAR component (as object or string) and a UID.
+ * 2. You can pass an array of VEVENTs (all UIDS should match).
+ * 3. You can pass a single VEVENT component (as object or string).
+ *
+ * Only the second method is recommended. The other 1 and 3 will be removed
+ * at some point in the future.
+ *
+ * The $uid parameter is only required for the first method.
+ *
+ * @since 30.0.0
+ *
+ * @param VCalendar|VEvent|Array|String $input
+ * @param string|null $uid
+ * @param DateTimeZone|null $timeZone reference timezone for floating dates and times
+ */
+ public function __construct(VCalendar|VEvent|array|string $input, ?string $uid = null, ?DateTimeZone $timeZone = null) {
+
+ $timeZoneFactory = new TimeZoneFactory();
+
+ // evaluate if the input is a string and convert it to and vobject if required
+ if (is_string($input)) {
+ $input = Reader::read($input);
+ }
+ // evaluate if input is a single event vobject and convert it to a collection
+ if ($input instanceof VEvent) {
+ $events = [$input];
+ }
+ // evaluate if input is a calendar vobject
+ elseif ($input instanceof VCalendar) {
+ // Calendar + UID mode.
+ if ($uid === null) {
+ throw new InvalidArgumentException('The UID argument is required when a VCALENDAR object is used');
+ }
+ // extract events from calendar
+ $events = $input->getByUID($uid);
+ // evaluate if any event where found
+ if (count($events) === 0) {
+ throw new InvalidArgumentException('This VCALENDAR did not have an event with UID: ' . $uid);
+ }
+ // extract calendar timezone
+ if (isset($input->VTIMEZONE) && isset($input->VTIMEZONE->TZID)) {
+ $calendarTimeZone = $timeZoneFactory->fromName($input->VTIMEZONE->TZID->getValue());
+ }
+ }
+ // evaluate if input is a collection of event vobjects
+ elseif (is_array($input)) {
+ $events = $input;
+ } else {
+ throw new InvalidArgumentException('Invalid input data type');
+ }
+ // find base event instance and remove it from events collection
+ foreach ($events as $key => $vevent) {
+ if (!isset($vevent->{'RECURRENCE-ID'})) {
+ $this->baseEvent = $vevent;
+ unset($events[$key]);
+ }
+ }
+
+ // No base event was found. CalDAV does allow cases where only
+ // overridden instances are stored.
+ //
+ // In this particular case, we're just going to grab the first
+ // event and use that instead. This may not always give the
+ // desired result.
+ if (!isset($this->baseEvent) && count($events) > 0) {
+ $this->baseEvent = array_shift($events);
+ }
+
+ // determine the event starting time zone
+ // we require this to align all other dates times
+ // evaluate if timezone parameter was used (treat this as a override)
+ if ($timeZone !== null) {
+ $this->baseEventStartTimeZone = $timeZone;
+ }
+ // evaluate if event start date has a timezone parameter
+ elseif (isset($this->baseEvent->DTSTART->parameters['TZID'])) {
+ $this->baseEventStartTimeZone = $timeZoneFactory->fromName($this->baseEvent->DTSTART->parameters['TZID']->getValue()) ?? new DateTimeZone('UTC');
+ }
+ // evaluate if event parent calendar has a time zone
+ elseif (isset($calendarTimeZone)) {
+ $this->baseEventStartTimeZone = clone $calendarTimeZone;
+ }
+ // otherwise, as a last resort use the UTC timezone
+ else {
+ $this->baseEventStartTimeZone = new DateTimeZone('UTC');
+ }
+
+ // determine the event end time zone
+ // we require this to align all other dates and times
+ // evaluate if timezone parameter was used (treat this as a override)
+ if ($timeZone !== null) {
+ $this->baseEventEndTimeZone = $timeZone;
+ }
+ // evaluate if event end date has a timezone parameter
+ elseif (isset($this->baseEvent->DTEND->parameters['TZID'])) {
+ $this->baseEventEndTimeZone = $timeZoneFactory->fromName($this->baseEvent->DTEND->parameters['TZID']->getValue()) ?? new DateTimeZone('UTC');
+ }
+ // evaluate if event parent calendar has a time zone
+ elseif (isset($calendarTimeZone)) {
+ $this->baseEventEndTimeZone = clone $calendarTimeZone;
+ }
+ // otherwise, as a last resort use the start date time zone
+ else {
+ $this->baseEventEndTimeZone = clone $this->baseEventStartTimeZone;
+ }
+ // extract start date and time
+ $this->baseEventStartDate = $this->baseEvent->DTSTART->getDateTime($this->baseEventStartTimeZone);
+ $this->baseEventStartDateFloating = $this->baseEvent->DTSTART->isFloating();
+ // determine event end date and duration
+ // evaluate if end date exists
+ // extract end date and calculate duration
+ if (isset($this->baseEvent->DTEND)) {
+ $this->baseEventEndDate = $this->baseEvent->DTEND->getDateTime($this->baseEventEndTimeZone);
+ $this->baseEventEndDateFloating = $this->baseEvent->DTEND->isFloating();
+ $this->baseEventDuration
+ = $this->baseEvent->DTEND->getDateTime($this->baseEventEndTimeZone)->getTimeStamp()
+ - $this->baseEventStartDate->getTimeStamp();
+ }
+ // evaluate if duration exists
+ // extract duration and calculate end date
+ elseif (isset($this->baseEvent->DURATION)) {
+ $this->baseEventEndDate = DateTimeImmutable::createFromInterface($this->baseEventStartDate)
+ ->add($this->baseEvent->DURATION->getDateInterval());
+ $this->baseEventDuration = $this->baseEventEndDate->getTimestamp() - $this->baseEventStartDate->getTimestamp();
+ }
+ // evaluate if start date is floating
+ // set duration to 24 hours and calculate the end date
+ // according to the rfc any event without a end date or duration is a complete day
+ elseif ($this->baseEventStartDateFloating == true) {
+ $this->baseEventDuration = 86400;
+ $this->baseEventEndDate = DateTimeImmutable::createFromInterface($this->baseEventStartDate)
+ ->setTimestamp($this->baseEventStartDate->getTimestamp() + $this->baseEventDuration);
+ }
+ // otherwise, set duration to zero this should never happen
+ else {
+ $this->baseEventDuration = 0;
+ $this->baseEventEndDate = $this->baseEventStartDate;
+ }
+ // evaluate if RRULE exist and construct iterator
+ if (isset($this->baseEvent->RRULE)) {
+ $this->rruleIterator = new EventReaderRRule(
+ $this->baseEvent->RRULE->getParts(),
+ $this->baseEventStartDate
+ );
+ }
+ // evaluate if RDATE exist and construct iterator
+ if (isset($this->baseEvent->RDATE)) {
+ $dates = [];
+ foreach ($this->baseEvent->RDATE as $entry) {
+ $dates[] = $entry->getValue();
+ }
+ $this->rdateIterator = new EventReaderRDate(
+ implode(',', $dates),
+ $this->baseEventStartDate
+ );
+ }
+ // evaluate if EXRULE exist and construct iterator
+ if (isset($this->baseEvent->EXRULE)) {
+ $this->eruleIterator = new EventReaderRRule(
+ $this->baseEvent->EXRULE->getParts(),
+ $this->baseEventStartDate
+ );
+ }
+ // evaluate if EXDATE exist and construct iterator
+ if (isset($this->baseEvent->EXDATE)) {
+ $dates = [];
+ foreach ($this->baseEvent->EXDATE as $entry) {
+ $dates[] = $entry->getValue();
+ }
+ $this->edateIterator = new EventReaderRDate(
+ implode(',', $dates),
+ $this->baseEventStartDate
+ );
+ }
+ // construct collection of modified events with recurrence id as hash
+ foreach ($events as $vevent) {
+ $this->recurrenceModified[$vevent->{'RECURRENCE-ID'}->getDateTime($this->baseEventStartTimeZone)->getTimeStamp()] = $vevent;
+ }
+
+ $this->recurrenceCurrentDate = clone $this->baseEventStartDate;
+ }
+
+ /**
+ * retrieve date and time of event start
+ *
+ * @since 30.0.0
+ *
+ * @return DateTime
+ */
+ public function startDateTime(): DateTime {
+ return DateTime::createFromInterface($this->baseEventStartDate);
+ }
+
+ /**
+ * retrieve time zone of event start
+ *
+ * @since 30.0.0
+ *
+ * @return DateTimeZone
+ */
+ public function startTimeZone(): DateTimeZone {
+ return $this->baseEventStartTimeZone;
+ }
+
+ /**
+ * retrieve date and time of event end
+ *
+ * @since 30.0.0
+ *
+ * @return DateTime
+ */
+ public function endDateTime(): DateTime {
+ return DateTime::createFromInterface($this->baseEventEndDate);
+ }
+
+ /**
+ * retrieve time zone of event end
+ *
+ * @since 30.0.0
+ *
+ * @return DateTimeZone
+ */
+ public function endTimeZone(): DateTimeZone {
+ return $this->baseEventEndTimeZone;
+ }
+
+ /**
+ * is this an all day event
+ *
+ * @since 30.0.0
+ *
+ * @return bool
+ */
+ public function entireDay(): bool {
+ return $this->baseEventStartDateFloating;
+ }
+
+ /**
+ * is this a recurring event
+ *
+ * @since 30.0.0
+ *
+ * @return bool
+ */
+ public function recurs(): bool {
+ return ($this->rruleIterator !== null || $this->rdateIterator !== null);
+ }
+
+ /**
+ * event recurrence pattern
+ *
+ * @since 30.0.0
+ *
+ * @return string|null R - Relative or A - Absolute
+ */
+ public function recurringPattern(): ?string {
+ if ($this->rruleIterator === null && $this->rdateIterator === null) {
+ return null;
+ }
+ if ($this->rruleIterator?->isRelative()) {
+ return 'R';
+ }
+ return 'A';
+ }
+
+ /**
+ * event recurrence precision
+ *
+ * @since 30.0.0
+ *
+ * @return string|null daily, weekly, monthly, yearly, fixed
+ */
+ public function recurringPrecision(): ?string {
+ if ($this->rruleIterator !== null) {
+ return $this->rruleIterator->precision();
+ }
+ if ($this->rdateIterator !== null) {
+ return 'fixed';
+ }
+ return null;
+ }
+
+ /**
+ * event recurrence interval
+ *
+ * @since 30.0.0
+ *
+ * @return int|null
+ */
+ public function recurringInterval(): ?int {
+ return $this->rruleIterator?->interval();
+ }
+
+ /**
+ * event recurrence conclusion
+ *
+ * returns true if RRULE with UNTIL or COUNT (calculated) is used
+ * returns true RDATE is used
+ * returns false if RRULE or RDATE are absent, or RRRULE is infinite
+ *
+ * @since 30.0.0
+ *
+ * @return bool
+ */
+ public function recurringConcludes(): bool {
+
+ // retrieve rrule conclusions
+ if ($this->rruleIterator?->concludesOn() !== null
+ || $this->rruleIterator?->concludesAfter() !== null) {
+ return true;
+ }
+ // retrieve rdate conclusions
+ if ($this->rdateIterator?->concludesAfter() !== null) {
+ return true;
+ }
+
+ return false;
+
+ }
+
+ /**
+ * event recurrence conclusion iterations
+ *
+ * returns the COUNT value if RRULE is used
+ * returns the collection count if RDATE is used
+ * returns combined count of RRULE COUNT and RDATE if both are used
+ * returns null if RRULE and RDATE are absent
+ *
+ * @since 30.0.0
+ *
+ * @return int|null
+ */
+ public function recurringConcludesAfter(): ?int {
+
+ // construct count place holder
+ $count = 0;
+ // retrieve and add RRULE iterations count
+ $count += (int)$this->rruleIterator?->concludesAfter();
+ // retrieve and add RDATE iterations count
+ $count += (int)$this->rdateIterator?->concludesAfter();
+ // return count
+ return !empty($count) ? $count : null;
+
+ }
+
+ /**
+ * event recurrence conclusion date
+ *
+ * returns the last date of UNTIL or COUNT (calculated) if RRULE is used
+ * returns the last date in the collection if RDATE is used
+ * returns the highest date if both RRULE and RDATE are used
+ * returns null if RRULE and RDATE are absent or RRULE is infinite
+ *
+ * @since 30.0.0
+ *
+ * @return DateTime|null
+ */
+ public function recurringConcludesOn(): ?DateTime {
+
+ if ($this->rruleIterator !== null) {
+ // retrieve rrule conclusion date
+ $rrule = $this->rruleIterator->concludes();
+ // evaluate if rrule conclusion is null
+ // if this is null that means the recurrence is infinate
+ if ($rrule === null) {
+ return null;
+ }
+ }
+ // retrieve rdate conclusion date
+ if ($this->rdateIterator !== null) {
+ $rdate = $this->rdateIterator->concludes();
+ }
+ // evaluate if both rrule and rdate have date
+ if (isset($rdate) && isset($rrule)) {
+ // return the highest date
+ return (($rdate > $rrule) ? $rdate : $rrule);
+ } elseif (isset($rrule)) {
+ return $rrule;
+ } elseif (isset($rdate)) {
+ return $rdate;
+ }
+
+ return null;
+
+ }
+
+ /**
+ * event recurrence days of the week
+ *
+ * returns collection of RRULE BYDAY day(s) ['MO','WE','FR']
+ * returns blank collection if RRULE is absent, RDATE presents or absents has no affect
+ *
+ * @since 30.0.0
+ *
+ * @return array
+ */
+ public function recurringDaysOfWeek(): array {
+ // evaluate if RRULE exists and return day(s) of the week
+ return $this->rruleIterator !== null ? $this->rruleIterator->daysOfWeek() : [];
+ }
+
+ /**
+ * event recurrence days of the week (named)
+ *
+ * returns collection of RRULE BYDAY day(s) ['Monday','Wednesday','Friday']
+ * returns blank collection if RRULE is absent, RDATE presents or absents has no affect
+ *
+ * @since 30.0.0
+ *
+ * @return array
+ */
+ public function recurringDaysOfWeekNamed(): array {
+ // evaluate if RRULE exists and extract day(s) of the week
+ $days = $this->rruleIterator !== null ? $this->rruleIterator->daysOfWeek() : [];
+ // convert numberic month to month name
+ foreach ($days as $key => $value) {
+ $days[$key] = $this->dayNamesMap[$value];
+ }
+ // return names collection
+ return $days;
+ }
+
+ /**
+ * event recurrence days of the month
+ *
+ * returns collection of RRULE BYMONTHDAY day(s) [7, 15, 31]
+ * returns blank collection if RRULE is absent, RDATE presents or absents has no affect
+ *
+ * @since 30.0.0
+ *
+ * @return array
+ */
+ public function recurringDaysOfMonth(): array {
+ // evaluate if RRULE exists and return day(s) of the month
+ return $this->rruleIterator !== null ? $this->rruleIterator->daysOfMonth() : [];
+ }
+
+ /**
+ * event recurrence days of the year
+ *
+ * returns collection of RRULE BYYEARDAY day(s) [57, 205, 365]
+ * returns blank collection if RRULE is absent, RDATE presents or absents has no affect
+ *
+ * @since 30.0.0
+ *
+ * @return array
+ */
+ public function recurringDaysOfYear(): array {
+ // evaluate if RRULE exists and return day(s) of the year
+ return $this->rruleIterator !== null ? $this->rruleIterator->daysOfYear() : [];
+ }
+
+ /**
+ * event recurrence weeks of the month
+ *
+ * returns collection of RRULE SETPOS weeks(s) [1, 3, -1]
+ * returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect
+ *
+ * @since 30.0.0
+ *
+ * @return array
+ */
+ public function recurringWeeksOfMonth(): array {
+ // evaluate if RRULE exists and RRULE is relative return relative position(s)
+ return $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : [];
+ }
+
+ /**
+ * event recurrence weeks of the month (named)
+ *
+ * returns collection of RRULE SETPOS weeks(s) [1, 3, -1]
+ * returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect
+ *
+ * @since 30.0.0
+ *
+ * @return array
+ */
+ public function recurringWeeksOfMonthNamed(): array {
+ // evaluate if RRULE exists and extract relative position(s)
+ $positions = $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : [];
+ // convert numberic relative position to relative label
+ foreach ($positions as $key => $value) {
+ $positions[$key] = $this->relativePositionNamesMap[$value];
+ }
+ // return positions collection
+ return $positions;
+ }
+
+ /**
+ * event recurrence weeks of the year
+ *
+ * returns collection of RRULE BYWEEKNO weeks(s) [12, 32, 52]
+ * returns blank collection if RRULE is absent, RDATE presents or absents has no affect
+ *
+ * @since 30.0.0
+ *
+ * @return array
+ */
+ public function recurringWeeksOfYear(): array {
+ // evaluate if RRULE exists and return weeks(s) of the year
+ return $this->rruleIterator !== null ? $this->rruleIterator->weeksOfYear() : [];
+ }
+
+ /**
+ * event recurrence months of the year
+ *
+ * returns collection of RRULE BYMONTH month(s) [3, 7, 12]
+ * returns blank collection if RRULE is absent, RDATE presents or absents has no affect
+ *
+ * @since 30.0.0
+ *
+ * @return array
+ */
+ public function recurringMonthsOfYear(): array {
+ // evaluate if RRULE exists and return month(s) of the year
+ return $this->rruleIterator !== null ? $this->rruleIterator->monthsOfYear() : [];
+ }
+
+ /**
+ * event recurrence months of the year (named)
+ *
+ * returns collection of RRULE BYMONTH month(s) [3, 7, 12]
+ * returns blank collection if RRULE is absent, RDATE presents or absents has no affect
+ *
+ * @since 30.0.0
+ *
+ * @return array
+ */
+ public function recurringMonthsOfYearNamed(): array {
+ // evaluate if RRULE exists and extract month(s) of the year
+ $months = $this->rruleIterator !== null ? $this->rruleIterator->monthsOfYear() : [];
+ // convert numberic month to month name
+ foreach ($months as $key => $value) {
+ $months[$key] = $this->monthNamesMap[$value];
+ }
+ // return months collection
+ return $months;
+ }
+
+ /**
+ * event recurrence relative positions
+ *
+ * returns collection of RRULE SETPOS value(s) [1, 5, -3]
+ * returns blank collection if RRULE is absent, RDATE presents or absents has no affect
+ *
+ * @since 30.0.0
+ *
+ * @return array
+ */
+ public function recurringRelativePosition(): array {
+ // evaluate if RRULE exists and return relative position(s)
+ return $this->rruleIterator !== null ? $this->rruleIterator->relativePosition() : [];
+ }
+
+ /**
+ * event recurrence relative positions (named)
+ *
+ * returns collection of RRULE SETPOS [1, 3, -1]
+ * returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect
+ *
+ * @since 30.0.0
+ *
+ * @return array
+ */
+ public function recurringRelativePositionNamed(): array {
+ // evaluate if RRULE exists and extract relative position(s)
+ $positions = $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : [];
+ // convert numberic relative position to relative label
+ foreach ($positions as $key => $value) {
+ $positions[$key] = $this->relativePositionNamesMap[$value];
+ }
+ // return positions collection
+ return $positions;
+ }
+
+ /**
+ * event recurrence date
+ *
+ * returns date of currently selected recurrence
+ *
+ * @since 30.0.0
+ *
+ * @return DateTime
+ */
+ public function recurrenceDate(): ?DateTime {
+ if ($this->recurrenceCurrentDate !== null) {
+ return DateTime::createFromInterface($this->recurrenceCurrentDate);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * event recurrence rewind
+ *
+ * sets the current recurrence to the first recurrence in the collection
+ *
+ * @since 30.0.0
+ *
+ * @return void
+ */
+ public function recurrenceRewind(): void {
+ // rewind and increment rrule
+ if ($this->rruleIterator !== null) {
+ $this->rruleIterator->rewind();
+ }
+ // rewind and increment rdate
+ if ($this->rdateIterator !== null) {
+ $this->rdateIterator->rewind();
+ }
+ // rewind and increment exrule
+ if ($this->eruleIterator !== null) {
+ $this->eruleIterator->rewind();
+ }
+ // rewind and increment exdate
+ if ($this->edateIterator !== null) {
+ $this->edateIterator->rewind();
+ }
+ // set current date to event start date
+ $this->recurrenceCurrentDate = clone $this->baseEventStartDate;
+ }
+
+ /**
+ * event recurrence advance
+ *
+ * sets the current recurrence to the next recurrence in the collection
+ *
+ * @since 30.0.0
+ *
+ * @return void
+ */
+ public function recurrenceAdvance(): void {
+ // place holders
+ $nextOccurrenceDate = null;
+ $nextExceptionDate = null;
+ $rruleDate = null;
+ $rdateDate = null;
+ $eruleDate = null;
+ $edateDate = null;
+ // evaludate if rrule is set and advance one interation past current date
+ if ($this->rruleIterator !== null) {
+ // forward rrule to the next future date
+ while ($this->rruleIterator->valid() && $this->rruleIterator->current() <= $this->recurrenceCurrentDate) {
+ $this->rruleIterator->next();
+ }
+ $rruleDate = $this->rruleIterator->current();
+ }
+ // evaludate if rdate is set and advance one interation past current date
+ if ($this->rdateIterator !== null) {
+ // forward rdate to the next future date
+ while ($this->rdateIterator->valid() && $this->rdateIterator->current() <= $this->recurrenceCurrentDate) {
+ $this->rdateIterator->next();
+ }
+ $rdateDate = $this->rdateIterator->current();
+ }
+ if ($rruleDate !== null && $rdateDate !== null) {
+ $nextOccurrenceDate = ($rruleDate <= $rdateDate) ? $rruleDate : $rdateDate;
+ } elseif ($rruleDate !== null) {
+ $nextOccurrenceDate = $rruleDate;
+ } elseif ($rdateDate !== null) {
+ $nextOccurrenceDate = $rdateDate;
+ }
+
+ // evaludate if exrule is set and advance one interation past current date
+ if ($this->eruleIterator !== null) {
+ // forward exrule to the next future date
+ while ($this->eruleIterator->valid() && $this->eruleIterator->current() <= $this->recurrenceCurrentDate) {
+ $this->eruleIterator->next();
+ }
+ $eruleDate = $this->eruleIterator->current();
+ }
+ // evaludate if exdate is set and advance one interation past current date
+ if ($this->edateIterator !== null) {
+ // forward exdate to the next future date
+ while ($this->edateIterator->valid() && $this->edateIterator->current() <= $this->recurrenceCurrentDate) {
+ $this->edateIterator->next();
+ }
+ $edateDate = $this->edateIterator->current();
+ }
+ // evaludate if exrule and exdate are set and set nextExDate to the first next date
+ if ($eruleDate !== null && $edateDate !== null) {
+ $nextExceptionDate = ($eruleDate <= $edateDate) ? $eruleDate : $edateDate;
+ } elseif ($eruleDate !== null) {
+ $nextExceptionDate = $eruleDate;
+ } elseif ($edateDate !== null) {
+ $nextExceptionDate = $edateDate;
+ }
+ // if the next date is part of exrule or exdate find another date
+ if ($nextOccurrenceDate !== null && $nextExceptionDate !== null && $nextOccurrenceDate == $nextExceptionDate) {
+ $this->recurrenceCurrentDate = $nextOccurrenceDate;
+ $this->recurrenceAdvance();
+ } else {
+ $this->recurrenceCurrentDate = $nextOccurrenceDate;
+ }
+ }
+
+ /**
+ * event recurrence advance
+ *
+ * sets the current recurrence to the next recurrence in the collection after the specific date
+ *
+ * @since 30.0.0
+ *
+ * @param DateTimeInterface $dt date and time to advance
+ *
+ * @return void
+ */
+ public function recurrenceAdvanceTo(DateTimeInterface $dt): void {
+ while ($this->recurrenceCurrentDate !== null && $this->recurrenceCurrentDate < $dt) {
+ $this->recurrenceAdvance();
+ }
+ }
+
+}
diff --git a/apps/dav/lib/CalDAV/EventReaderRDate.php b/apps/dav/lib/CalDAV/EventReaderRDate.php
new file mode 100644
index 00000000000..20234d06c00
--- /dev/null
+++ b/apps/dav/lib/CalDAV/EventReaderRDate.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV;
+
+use DateTime;
+
+class EventReaderRDate extends \Sabre\VObject\Recur\RDateIterator {
+
+ public function concludes(): ?DateTime {
+ return $this->concludesOn();
+ }
+
+ public function concludesAfter(): ?int {
+ return !empty($this->dates) ? count($this->dates) : null;
+ }
+
+ public function concludesOn(): ?DateTime {
+ if (count($this->dates) > 0) {
+ return new DateTime(
+ $this->dates[array_key_last($this->dates)],
+ $this->startDate->getTimezone()
+ );
+ }
+
+ return null;
+ }
+
+}
diff --git a/apps/dav/lib/CalDAV/EventReaderRRule.php b/apps/dav/lib/CalDAV/EventReaderRRule.php
new file mode 100644
index 00000000000..d2b4968c479
--- /dev/null
+++ b/apps/dav/lib/CalDAV/EventReaderRRule.php
@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV;
+
+use DateTime;
+use DateTimeInterface;
+
+class EventReaderRRule extends \Sabre\VObject\Recur\RRuleIterator {
+
+ public function precision(): string {
+ return $this->frequency;
+ }
+
+ public function interval(): int {
+ return $this->interval;
+ }
+
+ public function concludes(): ?DateTime {
+ // evaluate if until value is a date
+ if ($this->until instanceof DateTimeInterface) {
+ return DateTime::createFromInterface($this->until);
+ }
+ // evaluate if count value is higher than 0
+ if ($this->count > 0) {
+ // temporarily store current recurrence date and counter
+ $currentReccuranceDate = $this->currentDate;
+ $currentCounter = $this->counter;
+ // iterate over occurrences until last one (subtract 2 from count for start and end occurrence)
+ while ($this->counter <= ($this->count - 2)) {
+ $this->next();
+ }
+ // temporarly store last reccurance date
+ $lastReccuranceDate = $this->currentDate;
+ // restore current recurrence date and counter
+ $this->currentDate = $currentReccuranceDate;
+ $this->counter = $currentCounter;
+ // return last recurrence date
+ return DateTime::createFromInterface($lastReccuranceDate);
+ }
+
+ return null;
+ }
+
+ public function concludesAfter(): ?int {
+ return !empty($this->count) ? $this->count : null;
+ }
+
+ public function concludesOn(): ?DateTime {
+ return isset($this->until) ? DateTime::createFromInterface($this->until) : null;
+ }
+
+ public function daysOfWeek(): array {
+ return is_array($this->byDay) ? $this->byDay : [];
+ }
+
+ public function daysOfMonth(): array {
+ return is_array($this->byMonthDay) ? $this->byMonthDay : [];
+ }
+
+ public function daysOfYear(): array {
+ return is_array($this->byYearDay) ? $this->byYearDay : [];
+ }
+
+ public function weeksOfYear(): array {
+ return is_array($this->byWeekNo) ? $this->byWeekNo : [];
+ }
+
+ public function monthsOfYear(): array {
+ return is_array($this->byMonth) ? $this->byMonth : [];
+ }
+
+ public function isRelative(): bool {
+ return isset($this->bySetPos);
+ }
+
+ public function relativePosition(): array {
+ return is_array($this->bySetPos) ? $this->bySetPos : [];
+ }
+
+}
diff --git a/apps/dav/lib/CalDAV/Export/ExportService.php b/apps/dav/lib/CalDAV/Export/ExportService.php
new file mode 100644
index 00000000000..552b9e2b675
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Export/ExportService.php
@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Export;
+
+use Generator;
+use OCP\Calendar\CalendarExportOptions;
+use OCP\Calendar\ICalendarExport;
+use OCP\ServerVersion;
+use Sabre\VObject\Component;
+use Sabre\VObject\Writer;
+
+/**
+ * Calendar Export Service
+ */
+class ExportService {
+
+ public const FORMATS = ['ical', 'jcal', 'xcal'];
+ private string $systemVersion;
+
+ public function __construct(ServerVersion $serverVersion) {
+ $this->systemVersion = $serverVersion->getVersionString();
+ }
+
+ /**
+ * Generates serialized content stream for a calendar and objects based in selected format
+ *
+ * @return Generator<string>
+ */
+ public function export(ICalendarExport $calendar, CalendarExportOptions $options): Generator {
+ // output start of serialized content based on selected format
+ yield $this->exportStart($options->getFormat());
+ // iterate through each returned vCalendar entry
+ // extract each component except timezones, convert to appropriate format and output
+ // extract any timezones and save them but do not output
+ $timezones = [];
+ foreach ($calendar->export($options) as $entry) {
+ $consecutive = false;
+ foreach ($entry->getComponents() as $vComponent) {
+ if ($vComponent->name === 'VTIMEZONE') {
+ if (isset($vComponent->TZID) && !isset($timezones[$vComponent->TZID->getValue()])) {
+ $timezones[$vComponent->TZID->getValue()] = clone $vComponent;
+ }
+ } else {
+ yield $this->exportObject($vComponent, $options->getFormat(), $consecutive);
+ $consecutive = true;
+ }
+ }
+ }
+ // iterate through each saved vTimezone entry, convert to appropriate format and output
+ foreach ($timezones as $vComponent) {
+ yield $this->exportObject($vComponent, $options->getFormat(), $consecutive);
+ $consecutive = true;
+ }
+ // output end of serialized content based on selected format
+ yield $this->exportFinish($options->getFormat());
+ }
+
+ /**
+ * Generates serialized content start based on selected format
+ */
+ private function exportStart(string $format): string {
+ return match ($format) {
+ 'jcal' => '["vcalendar",[["version",{},"text","2.0"],["prodid",{},"text","-\/\/IDN nextcloud.com\/\/Calendar Export v' . $this->systemVersion . '\/\/EN"]],[',
+ 'xcal' => '<?xml version="1.0" encoding="UTF-8"?><icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0"><vcalendar><properties><version><text>2.0</text></version><prodid><text>-//IDN nextcloud.com//Calendar Export v' . $this->systemVersion . '//EN</text></prodid></properties><components>',
+ default => "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//IDN nextcloud.com//Calendar Export v" . $this->systemVersion . "//EN\n"
+ };
+ }
+
+ /**
+ * Generates serialized content end based on selected format
+ */
+ private function exportFinish(string $format): string {
+ return match ($format) {
+ 'jcal' => ']]',
+ 'xcal' => '</components></vcalendar></icalendar>',
+ default => "END:VCALENDAR\n"
+ };
+ }
+
+ /**
+ * Generates serialized content for a component based on selected format
+ */
+ private function exportObject(Component $vobject, string $format, bool $consecutive): string {
+ return match ($format) {
+ 'jcal' => $consecutive ? ',' . Writer::writeJson($vobject) : Writer::writeJson($vobject),
+ 'xcal' => $this->exportObjectXml($vobject),
+ default => Writer::write($vobject)
+ };
+ }
+
+ /**
+ * Generates serialized content for a component in xml format
+ */
+ private function exportObjectXml(Component $vobject): string {
+ $writer = new \Sabre\Xml\Writer();
+ $writer->openMemory();
+ $writer->setIndent(false);
+ $vobject->xmlSerialize($writer);
+ return $writer->outputMemory();
+ }
+
+}
diff --git a/apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php b/apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php
new file mode 100644
index 00000000000..c2c474a90fe
--- /dev/null
+++ b/apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV\FreeBusy;
+
+use Sabre\VObject\Component\VCalendar;
+
+/**
+ * @psalm-suppress PropertyNotSetInConstructor
+ */
+class FreeBusyGenerator extends \Sabre\VObject\FreeBusyGenerator {
+
+ public function __construct() {
+ parent::__construct();
+ }
+
+ public function getVCalendar(): VCalendar {
+ return new VCalendar();
+ }
+}
diff --git a/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php b/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php
new file mode 100644
index 00000000000..08dc10f7bf4
--- /dev/null
+++ b/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php
@@ -0,0 +1,72 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\ICSExportPlugin;
+
+use OCP\IConfig;
+use Psr\Log\LoggerInterface;
+use Sabre\HTTP\ResponseInterface;
+use Sabre\VObject\DateTimeParser;
+use Sabre\VObject\InvalidDataException;
+use Sabre\VObject\Property\ICalendar\Duration;
+
+/**
+ * Class ICSExportPlugin
+ *
+ * @package OCA\DAV\CalDAV\ICSExportPlugin
+ */
+class ICSExportPlugin extends \Sabre\CalDAV\ICSExportPlugin {
+ /** @var string */
+ private const DEFAULT_REFRESH_INTERVAL = 'PT4H';
+
+ /**
+ * ICSExportPlugin constructor.
+ */
+ public function __construct(
+ private IConfig $config,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function generateResponse($path, $start, $end, $expand, $componentType, $format, $properties, ResponseInterface $response) {
+ if (!isset($properties['{http://nextcloud.com/ns}refresh-interval'])) {
+ $value = $this->config->getAppValue('dav', 'defaultRefreshIntervalExportedCalendars', self::DEFAULT_REFRESH_INTERVAL);
+ $properties['{http://nextcloud.com/ns}refresh-interval'] = $value;
+ }
+
+ return parent::generateResponse($path, $start, $end, $expand, $componentType, $format, $properties, $response);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function mergeObjects(array $properties, array $inputObjects) {
+ $vcalendar = parent::mergeObjects($properties, $inputObjects);
+
+ if (isset($properties['{http://nextcloud.com/ns}refresh-interval'])) {
+ $refreshIntervalValue = $properties['{http://nextcloud.com/ns}refresh-interval'];
+ try {
+ DateTimeParser::parseDuration($refreshIntervalValue);
+ } catch (InvalidDataException $ex) {
+ $this->logger->debug('Invalid refresh interval for exported calendar, falling back to default value ...');
+ $refreshIntervalValue = self::DEFAULT_REFRESH_INTERVAL;
+ }
+
+ // https://tools.ietf.org/html/rfc7986#section-5.7
+ $refreshInterval = new Duration($vcalendar, 'REFRESH-INTERVAL', $refreshIntervalValue);
+ $refreshInterval->add('VALUE', 'DURATION');
+ $vcalendar->add($refreshInterval);
+
+ // Legacy property for compatibility
+ $vcalendar->{'X-PUBLISHED-TTL'} = $refreshIntervalValue;
+ }
+
+ return $vcalendar;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/IRestorable.php b/apps/dav/lib/CalDAV/IRestorable.php
new file mode 100644
index 00000000000..5850e0a5645
--- /dev/null
+++ b/apps/dav/lib/CalDAV/IRestorable.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV;
+
+use Sabre\DAV\Exception;
+
+/**
+ * Interface for nodes that can be restored from the trashbin
+ */
+interface IRestorable {
+
+ /**
+ * Restore this node
+ *
+ * @throws Exception
+ */
+ public function restore(): void;
+}
diff --git a/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php b/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php
new file mode 100644
index 00000000000..acf81638679
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php
@@ -0,0 +1,110 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Integration;
+
+use Sabre\CalDAV;
+use Sabre\DAV;
+
+/**
+ * Class ExternalCalendar
+ *
+ * @package OCA\DAV\CalDAV\Integration
+ * @since 19.0.0
+ */
+abstract class ExternalCalendar implements CalDAV\ICalendar, DAV\IProperties {
+
+ /** @var string */
+ private const PREFIX = 'app-generated';
+
+ /**
+ * @var string
+ *
+ * Double dash is a valid delimiter,
+ * because it will always split the calendarURIs correctly:
+ * - our prefix contains only one dash and won't be split
+ * - appIds are not allowed to contain dashes as per spec:
+ * > must contain only lowercase ASCII characters and underscore
+ * - explode has a limit of three, so even if the app-generated
+ * calendar uri has double dashes, it won't be split
+ */
+ private const DELIMITER = '--';
+
+ /**
+ * ExternalCalendar constructor.
+ *
+ * @param string $appId
+ * @param string $calendarUri
+ */
+ public function __construct(
+ private string $appId,
+ private string $calendarUri,
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ */
+ final public function getName() {
+ return implode(self::DELIMITER, [
+ self::PREFIX,
+ $this->appId,
+ $this->calendarUri,
+ ]);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ final public function setName($name) {
+ throw new DAV\Exception\MethodNotAllowed('Renaming calendars is not yet supported');
+ }
+
+ /**
+ * @inheritDoc
+ */
+ final public function createDirectory($name) {
+ throw new DAV\Exception\MethodNotAllowed('Creating collections in calendar objects is not allowed');
+ }
+
+ /**
+ * Checks whether the calendar uri is app-generated
+ *
+ * @param string $calendarUri
+ * @return bool
+ */
+ public static function isAppGeneratedCalendar(string $calendarUri):bool {
+ return str_starts_with($calendarUri, self::PREFIX) && substr_count($calendarUri, self::DELIMITER) >= 2;
+ }
+
+ /**
+ * Splits an app-generated calendar-uri into appId and calendarUri
+ *
+ * @param string $calendarUri
+ * @return array
+ */
+ public static function splitAppGeneratedCalendarUri(string $calendarUri):array {
+ $array = array_slice(explode(self::DELIMITER, $calendarUri, 3), 1);
+ // Check the array has expected amount of elements
+ // and none of them is an empty string
+ if (\count($array) !== 2 || \in_array('', $array, true)) {
+ throw new \InvalidArgumentException('Provided calendar uri was not app-generated');
+ }
+
+ return $array;
+ }
+
+ /**
+ * Checks whether a calendar-name, the user wants to create, violates
+ * the reserved name for calendar uris
+ *
+ * @param string $calendarUri
+ * @return bool
+ */
+ public static function doesViolateReservedName(string $calendarUri):bool {
+ return str_starts_with($calendarUri, self::PREFIX);
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php b/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php
new file mode 100644
index 00000000000..40a8860dcb4
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php
@@ -0,0 +1,54 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Integration;
+
+/**
+ * Interface ICalendarProvider
+ *
+ * @package OCA\DAV\CalDAV\Integration
+ * @since 19.0.0
+ */
+interface ICalendarProvider {
+
+ /**
+ * Provides the appId of the plugin
+ *
+ * @since 19.0.0
+ * @return string AppId
+ */
+ public function getAppId(): string;
+
+ /**
+ * Fetches all calendars for a given principal uri
+ *
+ * @since 19.0.0
+ * @param string $principalUri E.g. principals/users/user1
+ * @return ExternalCalendar[] Array of all calendars
+ */
+ public function fetchAllForCalendarHome(string $principalUri): array;
+
+ /**
+ * Checks whether plugin has a calendar for a given principalUri and calendarUri
+ *
+ * @since 19.0.0
+ * @param string $principalUri E.g. principals/users/user1
+ * @param string $calendarUri E.g. personal
+ * @return bool True if calendar for principalUri and calendarUri exists, false otherwise
+ */
+ public function hasCalendarInCalendarHome(string $principalUri, string $calendarUri): bool;
+
+ /**
+ * Fetches a calendar for a given principalUri and calendarUri
+ * Returns null if calendar does not exist
+ *
+ * @since 19.0.0
+ * @param string $principalUri E.g. principals/users/user1
+ * @param string $calendarUri E.g. personal
+ * @return ExternalCalendar|null Calendar if it exists, null otherwise
+ */
+ public function getCalendarInCalendarHome(string $principalUri, string $calendarUri): ?ExternalCalendar;
+}
diff --git a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php
new file mode 100644
index 00000000000..c8a7109abde
--- /dev/null
+++ b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php
@@ -0,0 +1,129 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\InvitationResponse;
+
+use OCA\DAV\AppInfo\PluginManager;
+use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin;
+use OCA\DAV\CalDAV\Auth\PublicPrincipalPlugin;
+use OCA\DAV\CalDAV\DefaultCalendarValidator;
+use OCA\DAV\CalDAV\Publishing\PublishPlugin;
+use OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin;
+use OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin;
+use OCA\DAV\Connector\Sabre\CachingTree;
+use OCA\DAV\Connector\Sabre\DavAclPlugin;
+use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin;
+use OCA\DAV\Connector\Sabre\LockPlugin;
+use OCA\DAV\Connector\Sabre\MaintenancePlugin;
+use OCA\DAV\Events\SabrePluginAuthInitEvent;
+use OCA\DAV\RootCollection;
+use OCA\Theming\ThemingDefaults;
+use OCP\App\IAppManager;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\IConfig;
+use OCP\IURLGenerator;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+use Sabre\VObject\ITip\Message;
+
+class InvitationResponseServer {
+ /** @var \OCA\DAV\Connector\Sabre\Server */
+ public $server;
+
+ /**
+ * InvitationResponseServer constructor.
+ */
+ public function __construct(bool $public = true) {
+ $baseUri = \OC::$WEBROOT . '/remote.php/dav/';
+ $logger = Server::get(LoggerInterface::class);
+ $dispatcher = Server::get(IEventDispatcher::class);
+
+ $root = new RootCollection();
+ $this->server = new \OCA\DAV\Connector\Sabre\Server(new CachingTree($root));
+
+ // Add maintenance plugin
+ $this->server->addPlugin(new MaintenancePlugin(Server::get(IConfig::class), \OC::$server->getL10N('dav')));
+
+ // Set URL explicitly due to reverse-proxy situations
+ $this->server->httpRequest->setUrl($baseUri);
+ $this->server->setBaseUri($baseUri);
+
+ $this->server->addPlugin(new BlockLegacyClientPlugin(
+ Server::get(IConfig::class),
+ Server::get(ThemingDefaults::class),
+ ));
+ $this->server->addPlugin(new AnonymousOptionsPlugin());
+
+ // allow custom principal uri option
+ if ($public) {
+ $this->server->addPlugin(new PublicPrincipalPlugin());
+ } else {
+ $this->server->addPlugin(new CustomPrincipalPlugin());
+ }
+
+ // allow setup of additional auth backends
+ $event = new SabrePluginAuthInitEvent($this->server);
+ $dispatcher->dispatchTyped($event);
+
+ $this->server->addPlugin(new ExceptionLoggerPlugin('webdav', $logger));
+ $this->server->addPlugin(new LockPlugin());
+ $this->server->addPlugin(new \Sabre\DAV\Sync\Plugin());
+
+ // acl
+ $acl = new DavAclPlugin();
+ $acl->principalCollectionSet = [
+ 'principals/users', 'principals/groups'
+ ];
+ $this->server->addPlugin($acl);
+
+ // calendar plugins
+ $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin());
+ $this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin());
+ $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class)));
+ $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin());
+ $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin());
+ //$this->server->addPlugin(new \OCA\DAV\DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest()));
+ $this->server->addPlugin(new PublishPlugin(
+ Server::get(IConfig::class),
+ Server::get(IURLGenerator::class)
+ ));
+
+ // wait with registering these until auth is handled and the filesystem is setup
+ $this->server->on('beforeMethod:*', function () use ($root): void {
+ // register plugins from apps
+ $pluginManager = new PluginManager(
+ \OC::$server,
+ Server::get(IAppManager::class)
+ );
+ foreach ($pluginManager->getAppPlugins() as $appPlugin) {
+ $this->server->addPlugin($appPlugin);
+ }
+ foreach ($pluginManager->getAppCollections() as $appCollection) {
+ $root->addChild($appCollection);
+ }
+ });
+ }
+
+ /**
+ * @param Message $iTipMessage
+ * @return void
+ */
+ public function handleITipMessage(Message $iTipMessage) {
+ /** @var \OCA\DAV\CalDAV\Schedule\Plugin $schedulingPlugin */
+ $schedulingPlugin = $this->server->getPlugin('caldav-schedule');
+ $schedulingPlugin->scheduleLocalDelivery($iTipMessage);
+ }
+
+ public function isExternalAttendee(string $principalUri): bool {
+ /** @var \Sabre\DAVACL\Plugin $aclPlugin */
+ $aclPlugin = $this->getServer()->getPlugin('acl');
+ return $aclPlugin->getPrincipalByUri($principalUri) === null;
+ }
+
+ public function getServer(): \OCA\DAV\Connector\Sabre\Server {
+ return $this->server;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Outbox.php b/apps/dav/lib/CalDAV/Outbox.php
new file mode 100644
index 00000000000..608114d8093
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Outbox.php
@@ -0,0 +1,116 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV;
+
+use OCP\IConfig;
+use Sabre\CalDAV\Plugin as CalDAVPlugin;
+
+/**
+ * Class Outbox
+ *
+ * @package OCA\DAV\CalDAV
+ */
+class Outbox extends \Sabre\CalDAV\Schedule\Outbox {
+
+ /** @var null|bool */
+ private $disableFreeBusy = null;
+
+ /**
+ * Outbox constructor.
+ *
+ * @param IConfig $config
+ * @param string $principalUri
+ */
+ public function __construct(
+ private IConfig $config,
+ string $principalUri,
+ ) {
+ parent::__construct($principalUri);
+ }
+
+ /**
+ * Returns a list of ACE's for this node.
+ *
+ * Each ACE has the following properties:
+ * * 'privilege', a string such as {DAV:}read or {DAV:}write. These are
+ * currently the only supported privileges
+ * * 'principal', a url to the principal who owns the node
+ * * 'protected' (optional), indicating that this ACE is not allowed to
+ * be updated.
+ *
+ * @return array
+ */
+ public function getACL() {
+ // getACL is called so frequently that we cache the config result
+ if ($this->disableFreeBusy === null) {
+ $this->disableFreeBusy = ($this->config->getAppValue('dav', 'disableFreeBusy', 'no') === 'yes');
+ }
+
+ $commonAcl = [
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner() . '/calendar-proxy-read',
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner() . '/calendar-proxy-write',
+ 'protected' => true,
+ ],
+ ];
+
+ // schedule-send is an aggregate privilege for:
+ // - schedule-send-invite
+ // - schedule-send-reply
+ // - schedule-send-freebusy
+ //
+ // If FreeBusy is disabled, we have to remove the latter privilege
+
+ if ($this->disableFreeBusy) {
+ return array_merge($commonAcl, [
+ [
+ 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send-invite',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send-invite',
+ 'principal' => $this->getOwner() . '/calendar-proxy-write',
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send-reply',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send-reply',
+ 'principal' => $this->getOwner() . '/calendar-proxy-write',
+ 'protected' => true,
+ ],
+ ]);
+ }
+
+ return array_merge($commonAcl, [
+ [
+ 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{' . CalDAVPlugin::NS_CALDAV . '}schedule-send',
+ 'principal' => $this->getOwner() . '/calendar-proxy-write',
+ 'protected' => true,
+ ],
+ ]);
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Plugin.php b/apps/dav/lib/CalDAV/Plugin.php
index 167ea4ffd69..24448ae71ab 100644
--- a/apps/dav/lib/CalDAV/Plugin.php
+++ b/apps/dav/lib/CalDAV/Plugin.php
@@ -1,43 +1,37 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud GmbH.
- *
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud GmbH.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
-
namespace OCA\DAV\CalDAV;
-use Sabre\HTTP\URLUtil;
-
class Plugin extends \Sabre\CalDAV\Plugin {
+ public const SYSTEM_CALENDAR_ROOT = 'system-calendars';
/**
- * @inheritdoc
+ * Returns the path to a principal's calendar home.
+ *
+ * The return url must not end with a slash.
+ * This function should return null in case a principal did not have
+ * a calendar home.
+ *
+ * @param string $principalUrl
+ * @return string|null
*/
- function getCalendarHomeForPrincipal($principalUrl) {
-
+ public function getCalendarHomeForPrincipal($principalUrl) {
if (strrpos($principalUrl, 'principals/users', -strlen($principalUrl)) !== false) {
- list(, $principalId) = \Sabre\Uri\split($principalUrl);
- return self::CALENDAR_ROOT .'/' . $principalId;
+ [, $principalId] = \Sabre\Uri\split($principalUrl);
+ return self::CALENDAR_ROOT . '/' . $principalId;
+ }
+ if (strrpos($principalUrl, 'principals/calendar-resources', -strlen($principalUrl)) !== false) {
+ [, $principalId] = \Sabre\Uri\split($principalUrl);
+ return self::SYSTEM_CALENDAR_ROOT . '/calendar-resources/' . $principalId;
+ }
+ if (strrpos($principalUrl, 'principals/calendar-rooms', -strlen($principalUrl)) !== false) {
+ [, $principalId] = \Sabre\Uri\split($principalUrl);
+ return self::SYSTEM_CALENDAR_ROOT . '/calendar-rooms/' . $principalId;
}
-
- return;
}
-
}
diff --git a/apps/dav/lib/CalDAV/Principal/Collection.php b/apps/dav/lib/CalDAV/Principal/Collection.php
index cadfc66c26b..b76fde66464 100644
--- a/apps/dav/lib/CalDAV/Principal/Collection.php
+++ b/apps/dav/lib/CalDAV/Principal/Collection.php
@@ -1,30 +1,11 @@
<?php
/**
- * @copyright Copyright (c) 2017, Christoph Seitz <christoph.seitz@posteo.de>
- *
- * @author Christoph Seitz <christoph.seitz@posteo.de>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Principal;
-use OCA\DAV\CalDAV\Principal\User;
-
/**
* Class Collection
*
@@ -38,8 +19,7 @@ class Collection extends \Sabre\CalDAV\Principal\Collection {
* @param array $principalInfo
* @return User
*/
- function getChildForPrincipal(array $principalInfo) {
+ public function getChildForPrincipal(array $principalInfo) {
return new User($this->principalBackend, $principalInfo);
}
-
}
diff --git a/apps/dav/lib/CalDAV/Principal/User.php b/apps/dav/lib/CalDAV/Principal/User.php
index 85b0401e865..047d83827ed 100644
--- a/apps/dav/lib/CalDAV/Principal/User.php
+++ b/apps/dav/lib/CalDAV/Principal/User.php
@@ -1,26 +1,9 @@
<?php
/**
- * @copyright Copyright (c) 2017, Christoph Seitz <christoph.seitz@posteo.de>
- *
- * @author Christoph Seitz <christoph.seitz@posteo.de>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OCA\DAV\CalDAV\Principal;
/**
@@ -42,7 +25,7 @@ class User extends \Sabre\CalDAV\Principal\User {
*
* @return array
*/
- function getACL() {
+ public function getACL() {
$acl = parent::getACL();
$acl[] = [
'privilege' => '{DAV:}read',
@@ -51,5 +34,4 @@ class User extends \Sabre\CalDAV\Principal\User {
];
return $acl;
}
-
}
diff --git a/apps/dav/lib/CalDAV/Proxy/Proxy.php b/apps/dav/lib/CalDAV/Proxy/Proxy.php
new file mode 100644
index 00000000000..ef1ad8c634f
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Proxy/Proxy.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Proxy;
+
+use OCP\AppFramework\Db\Entity;
+use OCP\DB\Types;
+
+/**
+ * @method string getOwnerId()
+ * @method void setOwnerId(string $ownerId)
+ * @method string getProxyId()
+ * @method void setProxyId(string $proxyId)
+ * @method int getPermissions()
+ * @method void setPermissions(int $permissions)
+ */
+class Proxy extends Entity {
+
+ /** @var string */
+ protected $ownerId;
+ /** @var string */
+ protected $proxyId;
+ /** @var int */
+ protected $permissions;
+
+ public function __construct() {
+ $this->addType('ownerId', Types::STRING);
+ $this->addType('proxyId', Types::STRING);
+ $this->addType('permissions', Types::INTEGER);
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php b/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php
new file mode 100644
index 00000000000..3b9b9c3d9eb
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Proxy;
+
+use OCP\AppFramework\Db\QBMapper;
+use OCP\IDBConnection;
+
+/**
+ * Class ProxyMapper
+ *
+ * @package OCA\DAV\CalDAV\Proxy
+ *
+ * @template-extends QBMapper<Proxy>
+ */
+class ProxyMapper extends QBMapper {
+ public const PERMISSION_READ = 1;
+ public const PERMISSION_WRITE = 2;
+
+ /**
+ * ProxyMapper constructor.
+ *
+ * @param IDBConnection $db
+ */
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'dav_cal_proxy', Proxy::class);
+ }
+
+ /**
+ * @param string $proxyId The principal uri that can act as a proxy for the resulting calendars
+ *
+ * @return Proxy[]
+ */
+ public function getProxiesFor(string $proxyId): array {
+ $qb = $this->db->getQueryBuilder();
+
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('proxy_id', $qb->createNamedParameter($proxyId)));
+
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * @param string $ownerId The principal uri that has the resulting proxies for their calendars
+ *
+ * @return Proxy[]
+ */
+ public function getProxiesOf(string $ownerId): array {
+ $qb = $this->db->getQueryBuilder();
+
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('owner_id', $qb->createNamedParameter($ownerId)));
+
+ return $this->findEntities($qb);
+ }
+}
diff --git a/apps/dav/lib/CalDAV/PublicCalendar.php b/apps/dav/lib/CalDAV/PublicCalendar.php
index f65ac9797b8..9af6e544165 100644
--- a/apps/dav/lib/CalDAV/PublicCalendar.php
+++ b/apps/dav/lib/CalDAV/PublicCalendar.php
@@ -1,24 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2017, Georg Ehrke
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV;
@@ -42,7 +26,7 @@ class PublicCalendar extends Calendar {
}
$obj['acl'] = $this->getChildACL();
- return new PublicCalendarObject($this->caldavBackend, $this->calendarInfo, $obj);
+ return new PublicCalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj);
}
/**
@@ -56,7 +40,7 @@ class PublicCalendar extends Calendar {
continue;
}
$obj['acl'] = $this->getChildACL();
- $children[] = new PublicCalendarObject($this->caldavBackend, $this->calendarInfo, $obj);
+ $children[] = new PublicCalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj);
}
return $children;
}
@@ -73,7 +57,7 @@ class PublicCalendar extends Calendar {
continue;
}
$obj['acl'] = $this->getChildACL();
- $children[] = new PublicCalendarObject($this->caldavBackend, $this->calendarInfo, $obj);
+ $children[] = new PublicCalendarObject($this->caldavBackend, $this->l10n, $this->calendarInfo, $obj);
}
return $children;
}
@@ -82,7 +66,7 @@ class PublicCalendar extends Calendar {
* public calendars are always shared
* @return bool
*/
- protected function isShared() {
+ public function isShared() {
return true;
}
-} \ No newline at end of file
+}
diff --git a/apps/dav/lib/CalDAV/PublicCalendarObject.php b/apps/dav/lib/CalDAV/PublicCalendarObject.php
index aaeea64b237..2ab40b94347 100644
--- a/apps/dav/lib/CalDAV/PublicCalendarObject.php
+++ b/apps/dav/lib/CalDAV/PublicCalendarObject.php
@@ -1,24 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2017, Georg Ehrke
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV;
@@ -31,4 +15,4 @@ class PublicCalendarObject extends CalendarObject {
protected function isShared() {
return true;
}
-} \ No newline at end of file
+}
diff --git a/apps/dav/lib/CalDAV/PublicCalendarRoot.php b/apps/dav/lib/CalDAV/PublicCalendarRoot.php
index 9385f487bda..edfb9f8dccc 100644
--- a/apps/dav/lib/CalDAV/PublicCalendarRoot.php
+++ b/apps/dav/lib/CalDAV/PublicCalendarRoot.php
@@ -1,44 +1,19 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- * @author Lukas Reschke <lukas@statuscode.ch>
- * @author Thomas Citharel <tcit@tcit.fr>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\DAV\CalDAV;
use OCP\IConfig;
use OCP\IL10N;
+use Psr\Log\LoggerInterface;
use Sabre\DAV\Collection;
class PublicCalendarRoot extends Collection {
- /** @var CalDavBackend */
- protected $caldavBackend;
-
- /** @var \OCP\IL10N */
- protected $l10n;
-
- /** @var \OCP\IConfig */
- protected $config;
-
/**
* PublicCalendarRoot constructor.
*
@@ -46,33 +21,33 @@ class PublicCalendarRoot extends Collection {
* @param IL10N $l10n
* @param IConfig $config
*/
- function __construct(CalDavBackend $caldavBackend, IL10N $l10n,
- IConfig $config) {
- $this->caldavBackend = $caldavBackend;
- $this->l10n = $l10n;
- $this->config = $config;
-
+ public function __construct(
+ protected CalDavBackend $caldavBackend,
+ protected IL10N $l10n,
+ protected IConfig $config,
+ private LoggerInterface $logger,
+ ) {
}
/**
* @inheritdoc
*/
- function getName() {
+ public function getName() {
return 'public-calendars';
}
/**
* @inheritdoc
*/
- function getChild($name) {
+ public function getChild($name) {
$calendar = $this->caldavBackend->getPublicCalendar($name);
- return new PublicCalendar($this->caldavBackend, $calendar, $this->l10n, $this->config);
+ return new PublicCalendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger);
}
/**
* @inheritdoc
*/
- function getChildren() {
+ public function getChildren() {
return [];
}
}
diff --git a/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php b/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php
index 53059818039..76378e7a1c5 100644
--- a/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php
+++ b/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php
@@ -1,43 +1,27 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Thomas Citharel <tcit@tcit.fr>
- *
- * @author Thomas Citharel <tcit@tcit.fr>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Publishing;
-use Sabre\DAV\PropFind;
+use OCA\DAV\CalDAV\Calendar;
+use OCA\DAV\CalDAV\Publishing\Xml\Publisher;
+use OCP\AppFramework\Http;
+use OCP\IConfig;
+use OCP\IURLGenerator;
+use Sabre\CalDAV\Xml\Property\AllowedSharingModes;
+use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\INode;
+use Sabre\DAV\PropFind;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
-use Sabre\DAV\Exception\NotFound;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
-use Sabre\CalDAV\Xml\Property\AllowedSharingModes;
-use OCA\DAV\CalDAV\Publishing\Xml\Publisher;
-use OCA\DAV\CalDAV\Calendar;
-use OCP\IURLGenerator;
-use OCP\IConfig;
class PublishPlugin extends ServerPlugin {
- const NS_CALENDARSERVER = 'http://calendarserver.org/ns/';
+ public const NS_CALENDARSERVER = 'http://calendarserver.org/ns/';
/**
* Reference to SabreDAV server object.
@@ -47,28 +31,21 @@ class PublishPlugin extends ServerPlugin {
protected $server;
/**
- * Config instance to get instance secret.
- *
- * @var IConfig
- */
- protected $config;
-
- /**
- * URL Generator for absolute URLs.
- *
- * @var IURLGenerator
- */
- protected $urlGenerator;
-
- /**
* PublishPlugin constructor.
*
* @param IConfig $config
* @param IURLGenerator $urlGenerator
*/
- public function __construct(IConfig $config, IURLGenerator $urlGenerator) {
- $this->config = $config;
- $this->urlGenerator = $urlGenerator;
+ public function __construct(
+ /**
+ * Config instance to get instance secret.
+ */
+ protected IConfig $config,
+ /**
+ * URL Generator for absolute URLs.
+ */
+ protected IURLGenerator $urlGenerator,
+ ) {
}
/**
@@ -92,7 +69,7 @@ class PublishPlugin extends ServerPlugin {
*
* @return string
*/
- public function getPluginName() {
+ public function getPluginName() {
return 'oc-calendar-publishing';
}
@@ -110,23 +87,31 @@ class PublishPlugin extends ServerPlugin {
$this->server = $server;
$this->server->on('method:POST', [$this, 'httpPost']);
- $this->server->on('propFind', [$this, 'propFind']);
+ $this->server->on('propFind', [$this, 'propFind']);
}
public function propFind(PropFind $propFind, INode $node) {
if ($node instanceof Calendar) {
- $propFind->handle('{'.self::NS_CALENDARSERVER.'}publish-url', function () use ($node) {
+ $propFind->handle('{' . self::NS_CALENDARSERVER . '}publish-url', function () use ($node) {
if ($node->getPublishStatus()) {
// We return the publish-url only if the calendar is published.
$token = $node->getPublishStatus();
- $publishUrl = $this->urlGenerator->getAbsoluteURL($this->server->getBaseUri().'public-calendars/').$token;
+ $publishUrl = $this->urlGenerator->getAbsoluteURL($this->server->getBaseUri() . 'public-calendars/') . $token;
return new Publisher($publishUrl, true);
}
});
- $propFind->handle('{'.self::NS_CALENDARSERVER.'}allowed-sharing-modes', function() use ($node) {
- return new AllowedSharingModes(!$node->isSubscription(), !$node->isSubscription());
+ $propFind->handle('{' . self::NS_CALENDARSERVER . '}allowed-sharing-modes', function () use ($node) {
+ $canShare = (!$node->isSubscription() && $node->canWrite());
+ $canPublish = (!$node->isSubscription() && $node->canWrite());
+
+ if ($this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes') {
+ $canShare = $canShare && ($node->getOwner() === $node->getPrincipalURI());
+ $canPublish = $canPublish && ($node->getOwner() === $node->getPrincipalURI());
+ }
+
+ return new AllowedSharingModes($canShare, $canPublish);
});
}
}
@@ -143,8 +128,8 @@ class PublishPlugin extends ServerPlugin {
$path = $request->getPath();
// Only handling xml
- $contentType = $request->getHeader('Content-Type');
- if (strpos($contentType, 'application/xml') === false && strpos($contentType, 'text/xml') === false) {
+ $contentType = (string)$request->getHeader('Content-Type');
+ if (!str_contains($contentType, 'application/xml') && !str_contains($contentType, 'text/xml')) {
return;
}
@@ -170,60 +155,74 @@ class PublishPlugin extends ServerPlugin {
switch ($documentType) {
- case '{'.self::NS_CALENDARSERVER.'}publish-calendar' :
+ case '{' . self::NS_CALENDARSERVER . '}publish-calendar':
+
+ // We can only deal with IShareableCalendar objects
+ if (!$node instanceof Calendar) {
+ return;
+ }
+ $this->server->transactionType = 'post-publish-calendar';
+
+ // Getting ACL info
+ $acl = $this->server->getPlugin('acl');
- // We can only deal with IShareableCalendar objects
- if (!$node instanceof Calendar) {
- return;
- }
- $this->server->transactionType = 'post-publish-calendar';
+ // If there's no ACL support, we allow everything
+ if ($acl) {
+ /** @var \Sabre\DAVACL\Plugin $acl */
+ $acl->checkPrivileges($path, '{DAV:}write');
- // Getting ACL info
- $acl = $this->server->getPlugin('acl');
+ $limitSharingToOwner = $this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes';
+ $isOwner = $acl->getCurrentUserPrincipal() === $node->getOwner();
+ if ($limitSharingToOwner && !$isOwner) {
+ return;
+ }
+ }
- // If there's no ACL support, we allow everything
- if ($acl) {
- $acl->checkPrivileges($path, '{DAV:}write');
- }
+ $node->setPublishStatus(true);
- $node->setPublishStatus(true);
+ // iCloud sends back the 202, so we will too.
+ $response->setStatus(Http::STATUS_ACCEPTED);
- // iCloud sends back the 202, so we will too.
- $response->setStatus(202);
+ // Adding this because sending a response body may cause issues,
+ // and I wanted some type of indicator the response was handled.
+ $response->setHeader('X-Sabre-Status', 'everything-went-well');
- // Adding this because sending a response body may cause issues,
- // and I wanted some type of indicator the response was handled.
- $response->setHeader('X-Sabre-Status', 'everything-went-well');
+ // Breaking the event chain
+ return false;
- // Breaking the event chain
- return false;
+ case '{' . self::NS_CALENDARSERVER . '}unpublish-calendar':
- case '{'.self::NS_CALENDARSERVER.'}unpublish-calendar' :
+ // We can only deal with IShareableCalendar objects
+ if (!$node instanceof Calendar) {
+ return;
+ }
+ $this->server->transactionType = 'post-unpublish-calendar';
- // We can only deal with IShareableCalendar objects
- if (!$node instanceof Calendar) {
- return;
- }
- $this->server->transactionType = 'post-unpublish-calendar';
+ // Getting ACL info
+ $acl = $this->server->getPlugin('acl');
- // Getting ACL info
- $acl = $this->server->getPlugin('acl');
+ // If there's no ACL support, we allow everything
+ if ($acl) {
+ /** @var \Sabre\DAVACL\Plugin $acl */
+ $acl->checkPrivileges($path, '{DAV:}write');
- // If there's no ACL support, we allow everything
- if ($acl) {
- $acl->checkPrivileges($path, '{DAV:}write');
- }
+ $limitSharingToOwner = $this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes';
+ $isOwner = $acl->getCurrentUserPrincipal() === $node->getOwner();
+ if ($limitSharingToOwner && !$isOwner) {
+ return;
+ }
+ }
- $node->setPublishStatus(false);
+ $node->setPublishStatus(false);
- $response->setStatus(200);
+ $response->setStatus(Http::STATUS_OK);
- // Adding this because sending a response body may cause issues,
- // and I wanted some type of indicator the response was handled.
- $response->setHeader('X-Sabre-Status', 'everything-went-well');
+ // Adding this because sending a response body may cause issues,
+ // and I wanted some type of indicator the response was handled.
+ $response->setHeader('X-Sabre-Status', 'everything-went-well');
- // Breaking the event chain
- return false;
+ // Breaking the event chain
+ return false;
}
}
diff --git a/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php b/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php
index 6d5e05e0cc3..fb9b7298f9b 100644
--- a/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php
+++ b/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php
@@ -1,24 +1,8 @@
<?php
+
/**
- * @copyright Copyright (c) 2016 Thomas Citharel <tcit@tcit.fr>
- *
- * @author Thomas Citharel <tcit@tcit.fr>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Publishing\Xml;
@@ -28,33 +12,24 @@ use Sabre\Xml\XmlSerializable;
class Publisher implements XmlSerializable {
/**
- * @var string $publishUrl
- */
- protected $publishUrl;
-
- /**
- * @var boolean $isPublished
- */
- protected $isPublished;
-
- /**
* @param string $publishUrl
* @param boolean $isPublished
*/
- function __construct($publishUrl, $isPublished) {
- $this->publishUrl = $publishUrl;
- $this->isPublished = $isPublished;
+ public function __construct(
+ protected $publishUrl,
+ protected $isPublished,
+ ) {
}
/**
* @return string
*/
- function getValue() {
+ public function getValue() {
return $this->publishUrl;
}
/**
- * The xmlSerialize metod is called during xml writing.
+ * The xmlSerialize method is called during xml writing.
*
* Use the $writer argument to write its own xml serialization.
*
@@ -72,7 +47,7 @@ class Publisher implements XmlSerializable {
* @param Writer $writer
* @return void
*/
- function xmlSerialize(Writer $writer) {
+ public function xmlSerialize(Writer $writer) {
if (!$this->isPublished) {
// for pre-publish-url
$writer->write($this->publishUrl);
diff --git a/apps/dav/lib/CalDAV/Reminder/Backend.php b/apps/dav/lib/CalDAV/Reminder/Backend.php
new file mode 100644
index 00000000000..329af3a2f56
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Reminder/Backend.php
@@ -0,0 +1,197 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Reminder;
+
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IDBConnection;
+
+/**
+ * Class Backend
+ *
+ * @package OCA\DAV\CalDAV\Reminder
+ */
+class Backend {
+
+ /**
+ * Backend constructor.
+ *
+ * @param IDBConnection $db
+ * @param ITimeFactory $timeFactory
+ */
+ public function __construct(
+ protected IDBConnection $db,
+ protected ITimeFactory $timeFactory,
+ ) {
+ }
+
+ /**
+ * Get all reminders with a notification date before now
+ *
+ * @return array
+ * @throws \Exception
+ */
+ public function getRemindersToProcess():array {
+ $query = $this->db->getQueryBuilder();
+ $query->select(['cr.id', 'cr.calendar_id','cr.object_id','cr.is_recurring','cr.uid','cr.recurrence_id','cr.is_recurrence_exception','cr.event_hash','cr.alarm_hash','cr.type','cr.is_relative','cr.notification_date','cr.is_repeat_based','co.calendardata', 'c.displayname', 'c.principaluri'])
+ ->from('calendar_reminders', 'cr')
+ ->where($query->expr()->lte('cr.notification_date', $query->createNamedParameter($this->timeFactory->getTime())))
+ ->join('cr', 'calendarobjects', 'co', $query->expr()->eq('cr.object_id', 'co.id'))
+ ->join('cr', 'calendars', 'c', $query->expr()->eq('cr.calendar_id', 'c.id'))
+ ->groupBy('cr.event_hash', 'cr.notification_date', 'cr.type', 'cr.id', 'cr.calendar_id', 'cr.object_id', 'cr.is_recurring', 'cr.uid', 'cr.recurrence_id', 'cr.is_recurrence_exception', 'cr.alarm_hash', 'cr.is_relative', 'cr.is_repeat_based', 'co.calendardata', 'c.displayname', 'c.principaluri');
+ $stmt = $query->executeQuery();
+
+ return array_map(
+ [$this, 'fixRowTyping'],
+ $stmt->fetchAll()
+ );
+ }
+
+ /**
+ * Get all scheduled reminders for an event
+ *
+ * @param int $objectId
+ * @return array
+ */
+ public function getAllScheduledRemindersForEvent(int $objectId):array {
+ $query = $this->db->getQueryBuilder();
+ $query->select('*')
+ ->from('calendar_reminders')
+ ->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId)));
+ $stmt = $query->executeQuery();
+
+ return array_map(
+ [$this, 'fixRowTyping'],
+ $stmt->fetchAll()
+ );
+ }
+
+ /**
+ * Insert a new reminder into the database
+ *
+ * @param int $calendarId
+ * @param int $objectId
+ * @param string $uid
+ * @param bool $isRecurring
+ * @param int $recurrenceId
+ * @param bool $isRecurrenceException
+ * @param string $eventHash
+ * @param string $alarmHash
+ * @param string $type
+ * @param bool $isRelative
+ * @param int $notificationDate
+ * @param bool $isRepeatBased
+ * @return int The insert id
+ */
+ public function insertReminder(int $calendarId,
+ int $objectId,
+ string $uid,
+ bool $isRecurring,
+ int $recurrenceId,
+ bool $isRecurrenceException,
+ string $eventHash,
+ string $alarmHash,
+ string $type,
+ bool $isRelative,
+ int $notificationDate,
+ bool $isRepeatBased):int {
+ $query = $this->db->getQueryBuilder();
+ $query->insert('calendar_reminders')
+ ->values([
+ 'calendar_id' => $query->createNamedParameter($calendarId),
+ 'object_id' => $query->createNamedParameter($objectId),
+ 'uid' => $query->createNamedParameter($uid),
+ 'is_recurring' => $query->createNamedParameter($isRecurring ? 1 : 0),
+ 'recurrence_id' => $query->createNamedParameter($recurrenceId),
+ 'is_recurrence_exception' => $query->createNamedParameter($isRecurrenceException ? 1 : 0),
+ 'event_hash' => $query->createNamedParameter($eventHash),
+ 'alarm_hash' => $query->createNamedParameter($alarmHash),
+ 'type' => $query->createNamedParameter($type),
+ 'is_relative' => $query->createNamedParameter($isRelative ? 1 : 0),
+ 'notification_date' => $query->createNamedParameter($notificationDate),
+ 'is_repeat_based' => $query->createNamedParameter($isRepeatBased ? 1 : 0),
+ ])
+ ->executeStatement();
+
+ return $query->getLastInsertId();
+ }
+
+ /**
+ * Sets a new notificationDate on an existing reminder
+ *
+ * @param int $reminderId
+ * @param int $newNotificationDate
+ */
+ public function updateReminder(int $reminderId,
+ int $newNotificationDate):void {
+ $query = $this->db->getQueryBuilder();
+ $query->update('calendar_reminders')
+ ->set('notification_date', $query->createNamedParameter($newNotificationDate))
+ ->where($query->expr()->eq('id', $query->createNamedParameter($reminderId)))
+ ->executeStatement();
+ }
+
+ /**
+ * Remove a reminder by it's id
+ *
+ * @param integer $reminderId
+ * @return void
+ */
+ public function removeReminder(int $reminderId):void {
+ $query = $this->db->getQueryBuilder();
+
+ $query->delete('calendar_reminders')
+ ->where($query->expr()->eq('id', $query->createNamedParameter($reminderId)))
+ ->executeStatement();
+ }
+
+ /**
+ * Cleans reminders in database
+ *
+ * @param int $objectId
+ */
+ public function cleanRemindersForEvent(int $objectId):void {
+ $query = $this->db->getQueryBuilder();
+
+ $query->delete('calendar_reminders')
+ ->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId)))
+ ->executeStatement();
+ }
+
+ /**
+ * Remove all reminders for a calendar
+ *
+ * @param int $calendarId
+ * @return void
+ */
+ public function cleanRemindersForCalendar(int $calendarId):void {
+ $query = $this->db->getQueryBuilder();
+
+ $query->delete('calendar_reminders')
+ ->where($query->expr()->eq('calendar_id', $query->createNamedParameter($calendarId)))
+ ->executeStatement();
+ }
+
+ /**
+ * @param array $row
+ * @return array
+ */
+ private function fixRowTyping(array $row): array {
+ $row['id'] = (int)$row['id'];
+ $row['calendar_id'] = (int)$row['calendar_id'];
+ $row['object_id'] = (int)$row['object_id'];
+ $row['is_recurring'] = (bool)$row['is_recurring'];
+ $row['recurrence_id'] = (int)$row['recurrence_id'];
+ $row['is_recurrence_exception'] = (bool)$row['is_recurrence_exception'];
+ $row['is_relative'] = (bool)$row['is_relative'];
+ $row['notification_date'] = (int)$row['notification_date'];
+ $row['is_repeat_based'] = (bool)$row['is_repeat_based'];
+
+ return $row;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php b/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php
new file mode 100644
index 00000000000..31d60f1531d
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Reminder;
+
+use OCP\IUser;
+use Sabre\VObject\Component\VEvent;
+
+/**
+ * Interface INotificationProvider
+ *
+ * @package OCA\DAV\CalDAV\Reminder
+ */
+interface INotificationProvider {
+
+ /**
+ * Send notification
+ *
+ * @param VEvent $vevent
+ * @param string|null $calendarDisplayName
+ * @param string[] $principalEmailAddresses All email addresses associated to the principal owning the calendar object
+ * @param IUser[] $users
+ * @return void
+ */
+ public function send(VEvent $vevent,
+ ?string $calendarDisplayName,
+ array $principalEmailAddresses,
+ array $users = []): void;
+}
diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php
new file mode 100644
index 00000000000..94edff98e52
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php
@@ -0,0 +1,157 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Reminder\NotificationProvider;
+
+use OCA\DAV\CalDAV\Reminder\INotificationProvider;
+use OCP\IConfig;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\L10N\IFactory as L10NFactory;
+use Psr\Log\LoggerInterface;
+use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\DateTimeParser;
+use Sabre\VObject\Property;
+
+/**
+ * Class AbstractProvider
+ *
+ * @package OCA\DAV\CalDAV\Reminder\NotificationProvider
+ */
+abstract class AbstractProvider implements INotificationProvider {
+
+ /** @var string */
+ public const NOTIFICATION_TYPE = '';
+
+ /** @var IL10N[] */
+ private $l10ns;
+
+ /** @var string */
+ private $fallbackLanguage;
+
+ public function __construct(
+ protected LoggerInterface $logger,
+ protected L10NFactory $l10nFactory,
+ protected IURLGenerator $urlGenerator,
+ protected IConfig $config,
+ ) {
+ }
+
+ /**
+ * Send notification
+ *
+ * @param VEvent $vevent
+ * @param string|null $calendarDisplayName
+ * @param string[] $principalEmailAddresses
+ * @param IUser[] $users
+ * @return void
+ */
+ abstract public function send(VEvent $vevent,
+ ?string $calendarDisplayName,
+ array $principalEmailAddresses,
+ array $users = []): void;
+
+ /**
+ * @return string
+ */
+ protected function getFallbackLanguage():string {
+ if ($this->fallbackLanguage) {
+ return $this->fallbackLanguage;
+ }
+
+ $fallbackLanguage = $this->l10nFactory->findGenericLanguage();
+ $this->fallbackLanguage = $fallbackLanguage;
+
+ return $fallbackLanguage;
+ }
+
+ /**
+ * @param string $lang
+ * @return bool
+ */
+ protected function hasL10NForLang(string $lang):bool {
+ return $this->l10nFactory->languageExists('dav', $lang);
+ }
+
+ /**
+ * @param string $lang
+ * @return IL10N
+ */
+ protected function getL10NForLang(string $lang):IL10N {
+ if (isset($this->l10ns[$lang])) {
+ return $this->l10ns[$lang];
+ }
+
+ $l10n = $this->l10nFactory->get('dav', $lang);
+ $this->l10ns[$lang] = $l10n;
+
+ return $l10n;
+ }
+
+ /**
+ * @param VEvent $vevent
+ * @return string
+ */
+ private function getStatusOfEvent(VEvent $vevent):string {
+ if ($vevent->STATUS) {
+ return (string)$vevent->STATUS;
+ }
+
+ // Doesn't say so in the standard,
+ // but we consider events without a status
+ // to be confirmed
+ return 'CONFIRMED';
+ }
+
+ /**
+ * @param VEvent $vevent
+ * @return bool
+ */
+ protected function isEventTentative(VEvent $vevent):bool {
+ return $this->getStatusOfEvent($vevent) === 'TENTATIVE';
+ }
+
+ /**
+ * @param VEvent $vevent
+ * @return Property\ICalendar\DateTime
+ */
+ protected function getDTEndFromEvent(VEvent $vevent):Property\ICalendar\DateTime {
+ if (isset($vevent->DTEND)) {
+ return $vevent->DTEND;
+ }
+
+ if (isset($vevent->DURATION)) {
+ $isFloating = $vevent->DTSTART->isFloating();
+ /** @var Property\ICalendar\DateTime $end */
+ $end = clone $vevent->DTSTART;
+ $endDateTime = $end->getDateTime();
+ $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue()));
+ $end->setDateTime($endDateTime, $isFloating);
+
+ return $end;
+ }
+
+ if (!$vevent->DTSTART->hasTime()) {
+ $isFloating = $vevent->DTSTART->isFloating();
+ /** @var Property\ICalendar\DateTime $end */
+ $end = clone $vevent->DTSTART;
+ $endDateTime = $end->getDateTime();
+ $endDateTime = $endDateTime->modify('+1 day');
+ $end->setDateTime($endDateTime, $isFloating);
+
+ return $end;
+ }
+
+ return clone $vevent->DTSTART;
+ }
+
+ protected function getCalendarDisplayNameFallback(string $lang): string {
+ return $this->getL10NForLang($lang)->t('Untitled calendar');
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php
new file mode 100644
index 00000000000..01d51489a3b
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Reminder\NotificationProvider;
+
+/**
+ * Class AudioProvider
+ *
+ * This class only extends PushProvider at the moment. It does not provide true
+ * audio-alarms yet, but it's better than no alarm at all right now.
+ *
+ * @package OCA\DAV\CalDAV\Reminder\NotificationProvider
+ */
+class AudioProvider extends PushProvider {
+
+ /** @var string */
+ public const NOTIFICATION_TYPE = 'AUDIO';
+}
diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php
new file mode 100644
index 00000000000..0fd39a9e459
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php
@@ -0,0 +1,442 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Reminder\NotificationProvider;
+
+use DateTime;
+use OCP\IConfig;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\L10N\IFactory as L10NFactory;
+use OCP\Mail\Headers\AutoSubmitted;
+use OCP\Mail\IEMailTemplate;
+use OCP\Mail\IMailer;
+use OCP\Util;
+use Psr\Log\LoggerInterface;
+use Sabre\VObject;
+use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\Parameter;
+use Sabre\VObject\Property;
+
+/**
+ * Class EmailProvider
+ *
+ * @package OCA\DAV\CalDAV\Reminder\NotificationProvider
+ */
+class EmailProvider extends AbstractProvider {
+ /** @var string */
+ public const NOTIFICATION_TYPE = 'EMAIL';
+
+ public function __construct(
+ IConfig $config,
+ private IMailer $mailer,
+ LoggerInterface $logger,
+ L10NFactory $l10nFactory,
+ IURLGenerator $urlGenerator,
+ ) {
+ parent::__construct($logger, $l10nFactory, $urlGenerator, $config);
+ }
+
+ /**
+ * Send out notification via email
+ *
+ * @param VEvent $vevent
+ * @param string|null $calendarDisplayName
+ * @param string[] $principalEmailAddresses
+ * @param array $users
+ * @throws \Exception
+ */
+ public function send(VEvent $vevent,
+ ?string $calendarDisplayName,
+ array $principalEmailAddresses,
+ array $users = []):void {
+ $fallbackLanguage = $this->getFallbackLanguage();
+
+ $organizerEmailAddress = null;
+ if (isset($vevent->ORGANIZER)) {
+ $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER);
+ }
+
+ $emailAddressesOfSharees = $this->getEMailAddressesOfAllUsersWithWriteAccessToCalendar($users);
+ $emailAddressesOfAttendees = [];
+ if (count($principalEmailAddresses) === 0
+ || ($organizerEmailAddress && in_array($organizerEmailAddress, $principalEmailAddresses, true))
+ ) {
+ $emailAddressesOfAttendees = $this->getAllEMailAddressesFromEvent($vevent);
+ }
+
+ // Quote from php.net:
+ // If the input arrays have the same string keys, then the later value for that key will overwrite the previous one.
+ // => if there are duplicate email addresses, it will always take the system value
+ $emailAddresses = array_merge(
+ $emailAddressesOfAttendees,
+ $emailAddressesOfSharees
+ );
+
+ $sortedByLanguage = $this->sortEMailAddressesByLanguage($emailAddresses, $fallbackLanguage);
+ $organizer = $this->getOrganizerEMailAndNameFromEvent($vevent);
+
+ foreach ($sortedByLanguage as $lang => $emailAddresses) {
+ if (!$this->hasL10NForLang($lang)) {
+ $lang = $fallbackLanguage;
+ }
+ $l10n = $this->getL10NForLang($lang);
+ $fromEMail = Util::getDefaultEmailAddress('reminders-noreply');
+
+ $template = $this->mailer->createEMailTemplate('dav.calendarReminder');
+ $template->addHeader();
+ $this->addSubjectAndHeading($template, $l10n, $vevent);
+ $this->addBulletList($template, $l10n, $calendarDisplayName ?? $this->getCalendarDisplayNameFallback($lang), $vevent);
+ $template->addFooter();
+
+ foreach ($emailAddresses as $emailAddress) {
+ if (!$this->mailer->validateMailAddress($emailAddress)) {
+ $this->logger->error('Email address {address} for reminder notification is incorrect', ['app' => 'dav', 'address' => $emailAddress]);
+ continue;
+ }
+
+ $message = $this->mailer->createMessage();
+ $message->setFrom([$fromEMail]);
+ if ($organizer) {
+ $message->setReplyTo($organizer);
+ }
+ $message->setTo([$emailAddress]);
+ $message->useTemplate($template);
+ $message->setAutoSubmitted(AutoSubmitted::VALUE_AUTO_GENERATED);
+
+ try {
+ $failed = $this->mailer->send($message);
+ if ($failed) {
+ $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]);
+ }
+ } catch (\Exception $ex) {
+ $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]);
+ }
+ }
+ }
+ }
+
+ /**
+ * @param IEMailTemplate $template
+ * @param IL10N $l10n
+ * @param VEvent $vevent
+ */
+ private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, VEvent $vevent):void {
+ $template->setSubject('Notification: ' . $this->getTitleFromVEvent($vevent, $l10n));
+ $template->addHeading($this->getTitleFromVEvent($vevent, $l10n));
+ }
+
+ /**
+ * @param IEMailTemplate $template
+ * @param IL10N $l10n
+ * @param string $calendarDisplayName
+ * @param array $eventData
+ */
+ private function addBulletList(IEMailTemplate $template,
+ IL10N $l10n,
+ string $calendarDisplayName,
+ VEvent $vevent):void {
+ $template->addBodyListItem($calendarDisplayName, $l10n->t('Calendar:'),
+ $this->getAbsoluteImagePath('actions/info.png'));
+
+ $template->addBodyListItem($this->generateDateString($l10n, $vevent), $l10n->t('Date:'),
+ $this->getAbsoluteImagePath('places/calendar.png'));
+
+ if (isset($vevent->LOCATION)) {
+ $template->addBodyListItem((string)$vevent->LOCATION, $l10n->t('Where:'),
+ $this->getAbsoluteImagePath('actions/address.png'));
+ }
+ if (isset($vevent->DESCRIPTION)) {
+ $template->addBodyListItem((string)$vevent->DESCRIPTION, $l10n->t('Description:'),
+ $this->getAbsoluteImagePath('actions/more.png'));
+ }
+ }
+
+ private function getAbsoluteImagePath(string $path):string {
+ return $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->imagePath('core', $path)
+ );
+ }
+
+ /**
+ * @param VEvent $vevent
+ * @return array|null
+ */
+ private function getOrganizerEMailAndNameFromEvent(VEvent $vevent):?array {
+ if (!$vevent->ORGANIZER) {
+ return null;
+ }
+
+ $organizer = $vevent->ORGANIZER;
+ if (strcasecmp($organizer->getValue(), 'mailto:') !== 0) {
+ return null;
+ }
+
+ $organizerEMail = substr($organizer->getValue(), 7);
+
+ if (!$this->mailer->validateMailAddress($organizerEMail)) {
+ return null;
+ }
+
+ $name = $organizer->offsetGet('CN');
+ if ($name instanceof Parameter) {
+ return [$organizerEMail => $name];
+ }
+
+ return [$organizerEMail];
+ }
+
+ /**
+ * @param array<string, array{LANG?: string}> $emails
+ * @return array<string, string[]>
+ */
+ private function sortEMailAddressesByLanguage(array $emails,
+ string $defaultLanguage):array {
+ $sortedByLanguage = [];
+
+ foreach ($emails as $emailAddress => $parameters) {
+ if (isset($parameters['LANG'])) {
+ $lang = $parameters['LANG'];
+ } else {
+ $lang = $defaultLanguage;
+ }
+
+ if (!isset($sortedByLanguage[$lang])) {
+ $sortedByLanguage[$lang] = [];
+ }
+
+ $sortedByLanguage[$lang][] = $emailAddress;
+ }
+
+ return $sortedByLanguage;
+ }
+
+ /**
+ * @param VEvent $vevent
+ * @return array<string, array{LANG?: string}>
+ */
+ private function getAllEMailAddressesFromEvent(VEvent $vevent):array {
+ $emailAddresses = [];
+
+ if (isset($vevent->ATTENDEE)) {
+ foreach ($vevent->ATTENDEE as $attendee) {
+ if (!($attendee instanceof VObject\Property)) {
+ continue;
+ }
+
+ $cuType = $this->getCUTypeOfAttendee($attendee);
+ if (\in_array($cuType, ['RESOURCE', 'ROOM', 'UNKNOWN'])) {
+ // Don't send emails to things
+ continue;
+ }
+
+ $partstat = $this->getPartstatOfAttendee($attendee);
+ if ($partstat === 'DECLINED') {
+ // Don't send out emails to people who declined
+ continue;
+ }
+ if ($partstat === 'DELEGATED') {
+ $delegates = $attendee->offsetGet('DELEGATED-TO');
+ if (!($delegates instanceof VObject\Parameter)) {
+ continue;
+ }
+
+ $emailAddressesOfDelegates = $delegates->getParts();
+ foreach ($emailAddressesOfDelegates as $addressesOfDelegate) {
+ if (strcasecmp($addressesOfDelegate, 'mailto:') === 0) {
+ $delegateEmail = substr($addressesOfDelegate, 7);
+ if ($this->mailer->validateMailAddress($delegateEmail)) {
+ $emailAddresses[$delegateEmail] = [];
+ }
+ }
+ }
+
+ continue;
+ }
+
+ $emailAddressOfAttendee = $this->getEMailAddressOfAttendee($attendee);
+ if ($emailAddressOfAttendee !== null) {
+ $properties = [];
+
+ $langProp = $attendee->offsetGet('LANG');
+ if ($langProp instanceof VObject\Parameter && $langProp->getValue() !== null) {
+ $properties['LANG'] = $langProp->getValue();
+ }
+
+ $emailAddresses[$emailAddressOfAttendee] = $properties;
+ }
+ }
+ }
+
+ if (isset($vevent->ORGANIZER) && $this->hasAttendeeMailURI($vevent->ORGANIZER)) {
+ $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER);
+ if ($organizerEmailAddress !== null) {
+ $emailAddresses[$organizerEmailAddress] = [];
+ }
+ }
+
+ return $emailAddresses;
+ }
+
+ private function getCUTypeOfAttendee(VObject\Property $attendee):string {
+ $cuType = $attendee->offsetGet('CUTYPE');
+ if ($cuType instanceof VObject\Parameter) {
+ return strtoupper($cuType->getValue());
+ }
+
+ return 'INDIVIDUAL';
+ }
+
+ private function getPartstatOfAttendee(VObject\Property $attendee):string {
+ $partstat = $attendee->offsetGet('PARTSTAT');
+ if ($partstat instanceof VObject\Parameter) {
+ return strtoupper($partstat->getValue());
+ }
+
+ return 'NEEDS-ACTION';
+ }
+
+ private function hasAttendeeMailURI(VObject\Property $attendee): bool {
+ return stripos($attendee->getValue(), 'mailto:') === 0;
+ }
+
+ private function getEMailAddressOfAttendee(VObject\Property $attendee): ?string {
+ if (!$this->hasAttendeeMailURI($attendee)) {
+ return null;
+ }
+ $attendeeEMail = substr($attendee->getValue(), 7);
+ if (!$this->mailer->validateMailAddress($attendeeEMail)) {
+ return null;
+ }
+
+ return $attendeeEMail;
+ }
+
+ /**
+ * @param IUser[] $users
+ * @return array<string, array{LANG?: string}>
+ */
+ private function getEMailAddressesOfAllUsersWithWriteAccessToCalendar(array $users):array {
+ $emailAddresses = [];
+
+ foreach ($users as $user) {
+ $emailAddress = $user->getEMailAddress();
+ if ($emailAddress) {
+ $lang = $this->l10nFactory->getUserLanguage($user);
+ if ($lang) {
+ $emailAddresses[$emailAddress] = [
+ 'LANG' => $lang,
+ ];
+ } else {
+ $emailAddresses[$emailAddress] = [];
+ }
+ }
+ }
+
+ return $emailAddresses;
+ }
+
+ /**
+ * @throws \Exception
+ */
+ private function generateDateString(IL10N $l10n, VEvent $vevent): string {
+ $isAllDay = $vevent->DTSTART instanceof Property\ICalendar\Date;
+
+ /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */
+ /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */
+ /** @var \DateTimeImmutable $dtstartDt */
+ $dtstartDt = $vevent->DTSTART->getDateTime();
+ /** @var \DateTimeImmutable $dtendDt */
+ $dtendDt = $this->getDTEndFromEvent($vevent)->getDateTime();
+
+ $diff = $dtstartDt->diff($dtendDt);
+
+ $dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM));
+ $dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM));
+
+ if ($isAllDay) {
+ // One day event
+ if ($diff->days === 1) {
+ return $this->getDateString($l10n, $dtstartDt);
+ }
+
+ return implode(' - ', [
+ $this->getDateString($l10n, $dtstartDt),
+ $this->getDateString($l10n, $dtendDt),
+ ]);
+ }
+
+ $startTimezone = $endTimezone = null;
+ if (!$vevent->DTSTART->isFloating()) {
+ $startTimezone = $vevent->DTSTART->getDateTime()->getTimezone()->getName();
+ $endTimezone = $this->getDTEndFromEvent($vevent)->getDateTime()->getTimezone()->getName();
+ }
+
+ $localeStart = implode(', ', [
+ $this->getWeekDayName($l10n, $dtstartDt),
+ $this->getDateTimeString($l10n, $dtstartDt)
+ ]);
+
+ // always show full date with timezone if timezones are different
+ if ($startTimezone !== $endTimezone) {
+ $localeEnd = implode(', ', [
+ $this->getWeekDayName($l10n, $dtendDt),
+ $this->getDateTimeString($l10n, $dtendDt)
+ ]);
+
+ return $localeStart
+ . ' (' . $startTimezone . ') '
+ . ' - '
+ . $localeEnd
+ . ' (' . $endTimezone . ')';
+ }
+
+ // Show only the time if the day is the same
+ $localeEnd = $this->isDayEqual($dtstartDt, $dtendDt)
+ ? $this->getTimeString($l10n, $dtendDt)
+ : implode(', ', [
+ $this->getWeekDayName($l10n, $dtendDt),
+ $this->getDateTimeString($l10n, $dtendDt)
+ ]);
+
+ return $localeStart
+ . ' - '
+ . $localeEnd
+ . ' (' . $startTimezone . ')';
+ }
+
+ private function isDayEqual(DateTime $dtStart,
+ DateTime $dtEnd):bool {
+ return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d');
+ }
+
+ private function getWeekDayName(IL10N $l10n, DateTime $dt):string {
+ return (string)$l10n->l('weekdayName', $dt, ['width' => 'abbreviated']);
+ }
+
+ private function getDateString(IL10N $l10n, DateTime $dt):string {
+ return (string)$l10n->l('date', $dt, ['width' => 'medium']);
+ }
+
+ private function getDateTimeString(IL10N $l10n, DateTime $dt):string {
+ return (string)$l10n->l('datetime', $dt, ['width' => 'medium|short']);
+ }
+
+ private function getTimeString(IL10N $l10n, DateTime $dt):string {
+ return (string)$l10n->l('time', $dt, ['width' => 'short']);
+ }
+
+ private function getTitleFromVEvent(VEvent $vevent, IL10N $l10n):string {
+ if (isset($vevent->SUMMARY)) {
+ return (string)$vevent->SUMMARY;
+ }
+
+ return $l10n->t('Untitled event');
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php
new file mode 100644
index 00000000000..15994bacf49
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Reminder\NotificationProvider;
+
+class ProviderNotAvailableException extends \Exception {
+
+ /**
+ * ProviderNotAvailableException constructor.
+ *
+ * @since 16.0.0
+ *
+ * @param string $type ReminderType
+ */
+ public function __construct(string $type) {
+ parent::__construct("No notification provider for type $type available");
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php
new file mode 100644
index 00000000000..a3f0cce547a
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php
@@ -0,0 +1,114 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Reminder\NotificationProvider;
+
+use OCA\DAV\AppInfo\Application;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IConfig;
+use OCP\IURLGenerator;
+use OCP\IUser;
+use OCP\L10N\IFactory as L10NFactory;
+use OCP\Notification\IManager;
+use OCP\Notification\INotification;
+use Psr\Log\LoggerInterface;
+use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\Property;
+
+/**
+ * Class PushProvider
+ *
+ * @package OCA\DAV\CalDAV\Reminder\NotificationProvider
+ */
+class PushProvider extends AbstractProvider {
+
+ /** @var string */
+ public const NOTIFICATION_TYPE = 'DISPLAY';
+
+ public function __construct(
+ IConfig $config,
+ private IManager $manager,
+ LoggerInterface $logger,
+ L10NFactory $l10nFactory,
+ IURLGenerator $urlGenerator,
+ private ITimeFactory $timeFactory,
+ ) {
+ parent::__construct($logger, $l10nFactory, $urlGenerator, $config);
+ }
+
+ /**
+ * Send push notification to all users.
+ *
+ * @param VEvent $vevent
+ * @param string|null $calendarDisplayName
+ * @param string[] $principalEmailAddresses
+ * @param IUser[] $users
+ * @throws \Exception
+ */
+ public function send(VEvent $vevent,
+ ?string $calendarDisplayName,
+ array $principalEmailAddresses,
+ array $users = []):void {
+ if ($this->config->getAppValue('dav', 'sendEventRemindersPush', 'yes') !== 'yes') {
+ return;
+ }
+
+ $eventDetails = $this->extractEventDetails($vevent);
+ $eventUUID = (string)$vevent->UID;
+ if (!$eventUUID) {
+ return;
+ };
+ $eventUUIDHash = hash('sha256', $eventUUID, false);
+
+ foreach ($users as $user) {
+ $eventDetails['calendar_displayname'] = $calendarDisplayName ?? $this->getCalendarDisplayNameFallback($this->l10nFactory->getUserLanguage($user));
+
+ /** @var INotification $notification */
+ $notification = $this->manager->createNotification();
+ $notification->setApp(Application::APP_ID)
+ ->setUser($user->getUID())
+ ->setDateTime($this->timeFactory->getDateTime())
+ ->setObject(Application::APP_ID, $eventUUIDHash)
+ ->setSubject('calendar_reminder', [
+ 'title' => $eventDetails['title'],
+ 'start_atom' => $eventDetails['start_atom']
+ ])
+ ->setMessage('calendar_reminder', $eventDetails);
+
+ $this->manager->notify($notification);
+ }
+ }
+
+ /**
+ * @throws \Exception
+ */
+ protected function extractEventDetails(VEvent $vevent):array {
+ /** @var Property\ICalendar\DateTime $start */
+ $start = $vevent->DTSTART;
+ $end = $this->getDTEndFromEvent($vevent);
+
+ return [
+ 'title' => isset($vevent->SUMMARY)
+ ? ((string)$vevent->SUMMARY)
+ : null,
+ 'description' => isset($vevent->DESCRIPTION)
+ ? ((string)$vevent->DESCRIPTION)
+ : null,
+ 'location' => isset($vevent->LOCATION)
+ ? ((string)$vevent->LOCATION)
+ : null,
+ 'all_day' => $start instanceof Property\ICalendar\Date,
+ 'start_atom' => $start->getDateTime()->format(\DateTimeInterface::ATOM),
+ 'start_is_floating' => $start->isFloating(),
+ 'start_timezone' => $start->getDateTime()->getTimezone()->getName(),
+ 'end_atom' => $end->getDateTime()->format(\DateTimeInterface::ATOM),
+ 'end_is_floating' => $end->isFloating(),
+ 'end_timezone' => $end->getDateTime()->getTimezone()->getName(),
+ ];
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php b/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php
new file mode 100644
index 00000000000..265db09b061
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Reminder;
+
+use OCA\DAV\CalDAV\Reminder\NotificationProvider\ProviderNotAvailableException;
+use OCP\AppFramework\QueryException;
+use OCP\Server;
+
+/**
+ * Class NotificationProviderManager
+ *
+ * @package OCA\DAV\CalDAV\Reminder
+ */
+class NotificationProviderManager {
+
+ /** @var INotificationProvider[] */
+ private $providers = [];
+
+ /**
+ * Checks whether a provider for a given ACTION exists
+ *
+ * @param string $type
+ * @return bool
+ */
+ public function hasProvider(string $type):bool {
+ return (\in_array($type, ReminderService::REMINDER_TYPES, true)
+ && isset($this->providers[$type]));
+ }
+
+ /**
+ * Get provider for a given ACTION
+ *
+ * @param string $type
+ * @return INotificationProvider
+ * @throws NotificationProvider\ProviderNotAvailableException
+ * @throws NotificationTypeDoesNotExistException
+ */
+ public function getProvider(string $type):INotificationProvider {
+ if (in_array($type, ReminderService::REMINDER_TYPES, true)) {
+ if (isset($this->providers[$type])) {
+ return $this->providers[$type];
+ }
+ throw new ProviderNotAvailableException($type);
+ }
+ throw new NotificationTypeDoesNotExistException($type);
+ }
+
+ /**
+ * Registers a new provider
+ *
+ * @param string $providerClassName
+ * @throws QueryException
+ */
+ public function registerProvider(string $providerClassName):void {
+ $provider = Server::get($providerClassName);
+
+ if (!$provider instanceof INotificationProvider) {
+ throw new \InvalidArgumentException('Invalid notification provider registered');
+ }
+
+ $this->providers[$provider::NOTIFICATION_TYPE] = $provider;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php b/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php
new file mode 100644
index 00000000000..6fd2a29ede5
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Reminder;
+
+class NotificationTypeDoesNotExistException extends \Exception {
+
+ /**
+ * NotificationTypeDoesNotExistException constructor.
+ *
+ * @since 16.0.0
+ *
+ * @param string $type ReminderType
+ */
+ public function __construct(string $type) {
+ parent::__construct("Type $type is not an accepted type of notification");
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Reminder/Notifier.php b/apps/dav/lib/CalDAV/Reminder/Notifier.php
new file mode 100644
index 00000000000..137fb509f56
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Reminder/Notifier.php
@@ -0,0 +1,311 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Reminder;
+
+use DateTime;
+use OCA\DAV\AppInfo\Application;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+use OCP\L10N\IFactory;
+use OCP\Notification\AlreadyProcessedException;
+use OCP\Notification\INotification;
+use OCP\Notification\INotifier;
+use OCP\Notification\UnknownNotificationException;
+
+/**
+ * Class Notifier
+ *
+ * @package OCA\DAV\CalDAV\Reminder
+ */
+class Notifier implements INotifier {
+
+ /** @var IL10N */
+ private $l10n;
+
+ /**
+ * Notifier constructor.
+ *
+ * @param IFactory $l10nFactory
+ * @param IURLGenerator $urlGenerator
+ * @param ITimeFactory $timeFactory
+ */
+ public function __construct(
+ private IFactory $l10nFactory,
+ private IURLGenerator $urlGenerator,
+ private ITimeFactory $timeFactory,
+ ) {
+ }
+
+ /**
+ * Identifier of the notifier, only use [a-z0-9_]
+ *
+ * @return string
+ * @since 17.0.0
+ */
+ public function getID():string {
+ return Application::APP_ID;
+ }
+
+ /**
+ * Human readable name describing the notifier
+ *
+ * @return string
+ * @since 17.0.0
+ */
+ public function getName():string {
+ return $this->l10nFactory->get('dav')->t('Calendar');
+ }
+
+ /**
+ * Prepare sending the notification
+ *
+ * @param INotification $notification
+ * @param string $languageCode The code of the language that should be used to prepare the notification
+ * @return INotification
+ * @throws UnknownNotificationException
+ */
+ public function prepare(INotification $notification,
+ string $languageCode):INotification {
+ if ($notification->getApp() !== Application::APP_ID) {
+ throw new UnknownNotificationException('Notification not from this app');
+ }
+
+ // Read the language from the notification
+ $this->l10n = $this->l10nFactory->get('dav', $languageCode);
+
+ // Handle notifier subjects
+ switch ($notification->getSubject()) {
+ case 'calendar_reminder':
+ return $this->prepareReminderNotification($notification);
+
+ default:
+ throw new UnknownNotificationException('Unknown subject');
+
+ }
+ }
+
+ /**
+ * @param INotification $notification
+ * @return INotification
+ */
+ private function prepareReminderNotification(INotification $notification):INotification {
+ $imagePath = $this->urlGenerator->imagePath('core', 'places/calendar.svg');
+ $iconUrl = $this->urlGenerator->getAbsoluteURL($imagePath);
+ $notification->setIcon($iconUrl);
+
+ $this->prepareNotificationSubject($notification);
+ $this->prepareNotificationMessage($notification);
+
+ return $notification;
+ }
+
+ /**
+ * Sets the notification subject based on the parameters set in PushProvider
+ *
+ * @param INotification $notification
+ */
+ private function prepareNotificationSubject(INotification $notification): void {
+ $parameters = $notification->getSubjectParameters();
+
+ $startTime = \DateTime::createFromFormat(\DateTimeInterface::ATOM, $parameters['start_atom']);
+ $now = $this->timeFactory->getDateTime();
+ $title = $this->getTitleFromParameters($parameters);
+
+ $diff = $startTime->diff($now);
+ if ($diff === false) {
+ return;
+ }
+
+ $components = [];
+ if ($diff->y) {
+ $components[] = $this->l10n->n('%n year', '%n years', $diff->y);
+ }
+ if ($diff->m) {
+ $components[] = $this->l10n->n('%n month', '%n months', $diff->m);
+ }
+ if ($diff->d) {
+ $components[] = $this->l10n->n('%n day', '%n days', $diff->d);
+ }
+ if ($diff->h) {
+ $components[] = $this->l10n->n('%n hour', '%n hours', $diff->h);
+ }
+ if ($diff->i) {
+ $components[] = $this->l10n->n('%n minute', '%n minutes', $diff->i);
+ }
+
+ if (count($components) > 0 && !$this->hasPhpDatetimeDiffBug()) {
+ // Limiting to the first three components to prevent
+ // the string from getting too long
+ $firstThreeComponents = array_slice($components, 0, 2);
+ $diffLabel = implode(', ', $firstThreeComponents);
+
+ if ($diff->invert) {
+ $title = $this->l10n->t('%s (in %s)', [$title, $diffLabel]);
+ } else {
+ $title = $this->l10n->t('%s (%s ago)', [$title, $diffLabel]);
+ }
+ }
+
+ $notification->setParsedSubject($title);
+ }
+
+ /**
+ * @see https://github.com/nextcloud/server/issues/41615
+ * @see https://github.com/php/php-src/issues/9699
+ */
+ private function hasPhpDatetimeDiffBug(): bool {
+ $d1 = DateTime::createFromFormat(\DateTimeInterface::ATOM, '2023-11-22T11:52:00+01:00');
+ $d2 = new DateTime('2023-11-22T10:52:03', new \DateTimeZone('UTC'));
+
+ // The difference is 3 seconds, not -1year+11months+…
+ return $d1->diff($d2)->y < 0;
+ }
+
+ /**
+ * Sets the notification message based on the parameters set in PushProvider
+ *
+ * @param INotification $notification
+ */
+ private function prepareNotificationMessage(INotification $notification): void {
+ $parameters = $notification->getMessageParameters();
+
+ $description = [
+ $this->l10n->t('Calendar: %s', $parameters['calendar_displayname']),
+ $this->l10n->t('Date: %s', $this->generateDateString($parameters)),
+ ];
+ if ($parameters['description']) {
+ $description[] = $this->l10n->t('Description: %s', $parameters['description']);
+ }
+ if ($parameters['location']) {
+ $description[] = $this->l10n->t('Where: %s', $parameters['location']);
+ }
+
+ $message = implode("\r\n", $description);
+ $notification->setParsedMessage($message);
+ }
+
+ /**
+ * @param array $parameters
+ * @return string
+ */
+ private function getTitleFromParameters(array $parameters):string {
+ return $parameters['title'] ?? $this->l10n->t('Untitled event');
+ }
+
+ /**
+ * @param array $parameters
+ * @return string
+ * @throws \Exception
+ */
+ private function generateDateString(array $parameters):string {
+ $startDateTime = DateTime::createFromFormat(\DateTimeInterface::ATOM, $parameters['start_atom']);
+ $endDateTime = DateTime::createFromFormat(\DateTimeInterface::ATOM, $parameters['end_atom']);
+
+ // If the event has already ended, dismiss the notification
+ if ($endDateTime < $this->timeFactory->getDateTime()) {
+ throw new AlreadyProcessedException();
+ }
+
+ $isAllDay = $parameters['all_day'];
+ $diff = $startDateTime->diff($endDateTime);
+
+ if ($isAllDay) {
+ // One day event
+ if ($diff->days === 1) {
+ return $this->getDateString($startDateTime);
+ }
+
+ return implode(' - ', [
+ $this->getDateString($startDateTime),
+ $this->getDateString($endDateTime),
+ ]);
+ }
+
+ $startTimezone = $endTimezone = null;
+ if (!$parameters['start_is_floating']) {
+ $startTimezone = $parameters['start_timezone'];
+ $endTimezone = $parameters['end_timezone'];
+ }
+
+ $localeStart = implode(', ', [
+ $this->getWeekDayName($startDateTime),
+ $this->getDateTimeString($startDateTime)
+ ]);
+
+ // always show full date with timezone if timezones are different
+ if ($startTimezone !== $endTimezone) {
+ $localeEnd = implode(', ', [
+ $this->getWeekDayName($endDateTime),
+ $this->getDateTimeString($endDateTime)
+ ]);
+
+ return $localeStart
+ . ' (' . $startTimezone . ') '
+ . ' - '
+ . $localeEnd
+ . ' (' . $endTimezone . ')';
+ }
+
+ // Show only the time if the day is the same
+ $localeEnd = $this->isDayEqual($startDateTime, $endDateTime)
+ ? $this->getTimeString($endDateTime)
+ : implode(', ', [
+ $this->getWeekDayName($endDateTime),
+ $this->getDateTimeString($endDateTime)
+ ]);
+
+ return $localeStart
+ . ' - '
+ . $localeEnd
+ . ' (' . $startTimezone . ')';
+ }
+
+ /**
+ * @param DateTime $dtStart
+ * @param DateTime $dtEnd
+ * @return bool
+ */
+ private function isDayEqual(DateTime $dtStart,
+ DateTime $dtEnd):bool {
+ return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d');
+ }
+
+ /**
+ * @param DateTime $dt
+ * @return string
+ */
+ private function getWeekDayName(DateTime $dt):string {
+ return (string)$this->l10n->l('weekdayName', $dt, ['width' => 'abbreviated']);
+ }
+
+ /**
+ * @param DateTime $dt
+ * @return string
+ */
+ private function getDateString(DateTime $dt):string {
+ return (string)$this->l10n->l('date', $dt, ['width' => 'medium']);
+ }
+
+ /**
+ * @param DateTime $dt
+ * @return string
+ */
+ private function getDateTimeString(DateTime $dt):string {
+ return (string)$this->l10n->l('datetime', $dt, ['width' => 'medium|short']);
+ }
+
+ /**
+ * @param DateTime $dt
+ * @return string
+ */
+ private function getTimeString(DateTime $dt):string {
+ return (string)$this->l10n->l('time', $dt, ['width' => 'short']);
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Reminder/ReminderService.php b/apps/dav/lib/CalDAV/Reminder/ReminderService.php
new file mode 100644
index 00000000000..c75090e1560
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Reminder/ReminderService.php
@@ -0,0 +1,836 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Reminder;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCA\DAV\Connector\Sabre\Principal;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IConfig;
+use OCP\IGroup;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserManager;
+use Psr\Log\LoggerInterface;
+use Sabre\VObject;
+use Sabre\VObject\Component\VAlarm;
+use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\InvalidDataException;
+use Sabre\VObject\ParseException;
+use Sabre\VObject\Recur\EventIterator;
+use Sabre\VObject\Recur\MaxInstancesExceededException;
+use Sabre\VObject\Recur\NoInstancesException;
+use function count;
+use function strcasecmp;
+
+class ReminderService {
+
+ public const REMINDER_TYPE_EMAIL = 'EMAIL';
+ public const REMINDER_TYPE_DISPLAY = 'DISPLAY';
+ public const REMINDER_TYPE_AUDIO = 'AUDIO';
+
+ /**
+ * @var String[]
+ *
+ * Official RFC5545 reminder types
+ */
+ public const REMINDER_TYPES = [
+ self::REMINDER_TYPE_EMAIL,
+ self::REMINDER_TYPE_DISPLAY,
+ self::REMINDER_TYPE_AUDIO
+ ];
+
+ public function __construct(
+ private Backend $backend,
+ private NotificationProviderManager $notificationProviderManager,
+ private IUserManager $userManager,
+ private IGroupManager $groupManager,
+ private CalDavBackend $caldavBackend,
+ private ITimeFactory $timeFactory,
+ private IConfig $config,
+ private LoggerInterface $logger,
+ private Principal $principalConnector,
+ ) {
+ }
+
+ /**
+ * Process reminders to activate
+ *
+ * @throws NotificationProvider\ProviderNotAvailableException
+ * @throws NotificationTypeDoesNotExistException
+ */
+ public function processReminders() :void {
+ $reminders = $this->backend->getRemindersToProcess();
+ $this->logger->debug('{numReminders} reminders to process', [
+ 'numReminders' => count($reminders),
+ ]);
+
+ foreach ($reminders as $reminder) {
+ $calendarData = is_resource($reminder['calendardata'])
+ ? stream_get_contents($reminder['calendardata'])
+ : $reminder['calendardata'];
+
+ if (!$calendarData) {
+ continue;
+ }
+
+ $vcalendar = $this->parseCalendarData($calendarData);
+ if (!$vcalendar) {
+ $this->logger->debug('Reminder {id} does not belong to a valid calendar', [
+ 'id' => $reminder['id'],
+ ]);
+ $this->backend->removeReminder($reminder['id']);
+ continue;
+ }
+
+ try {
+ $vevent = $this->getVEventByRecurrenceId($vcalendar, $reminder['recurrence_id'], $reminder['is_recurrence_exception']);
+ } catch (MaxInstancesExceededException $e) {
+ $this->logger->debug('Recurrence with too many instances detected, skipping VEVENT', ['exception' => $e]);
+ $this->backend->removeReminder($reminder['id']);
+ continue;
+ }
+
+ if (!$vevent) {
+ $this->logger->debug('Reminder {id} does not belong to a valid event', [
+ 'id' => $reminder['id'],
+ ]);
+ $this->backend->removeReminder($reminder['id']);
+ continue;
+ }
+
+ if ($this->wasEventCancelled($vevent)) {
+ $this->logger->debug('Reminder {id} belongs to a cancelled event', [
+ 'id' => $reminder['id'],
+ ]);
+ $this->deleteOrProcessNext($reminder, $vevent);
+ continue;
+ }
+
+ if (!$this->notificationProviderManager->hasProvider($reminder['type'])) {
+ $this->logger->debug('Reminder {id} does not belong to a valid notification provider', [
+ 'id' => $reminder['id'],
+ ]);
+ $this->deleteOrProcessNext($reminder, $vevent);
+ continue;
+ }
+
+ if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedUsers', 'yes') === 'no') {
+ $users = $this->getAllUsersWithWriteAccessToCalendar($reminder['calendar_id']);
+ } else {
+ $users = [];
+ }
+
+ $user = $this->getUserFromPrincipalURI($reminder['principaluri']);
+ if ($user) {
+ $users[] = $user;
+ }
+
+ $userPrincipalEmailAddresses = [];
+ $userPrincipal = $this->principalConnector->getPrincipalByPath($reminder['principaluri']);
+ if ($userPrincipal) {
+ $userPrincipalEmailAddresses = $this->principalConnector->getEmailAddressesOfPrincipal($userPrincipal);
+ }
+
+ $this->logger->debug('Reminder {id} will be sent to {numUsers} users', [
+ 'id' => $reminder['id'],
+ 'numUsers' => count($users),
+ ]);
+ $notificationProvider = $this->notificationProviderManager->getProvider($reminder['type']);
+ $notificationProvider->send($vevent, $reminder['displayname'], $userPrincipalEmailAddresses, $users);
+
+ $this->deleteOrProcessNext($reminder, $vevent);
+ }
+ }
+
+ /**
+ * @param array $objectData
+ * @throws VObject\InvalidDataException
+ */
+ public function onCalendarObjectCreate(array $objectData):void {
+ // We only support VEvents for now
+ if (strcasecmp($objectData['component'], 'vevent') !== 0) {
+ return;
+ }
+
+ $calendarData = is_resource($objectData['calendardata'])
+ ? stream_get_contents($objectData['calendardata'])
+ : $objectData['calendardata'];
+
+ if (!$calendarData) {
+ return;
+ }
+
+ $vcalendar = $this->parseCalendarData($calendarData);
+ if (!$vcalendar) {
+ return;
+ }
+ $calendarTimeZone = $this->getCalendarTimeZone((int)$objectData['calendarid']);
+
+ $vevents = $this->getAllVEventsFromVCalendar($vcalendar);
+ if (count($vevents) === 0) {
+ return;
+ }
+
+ $uid = (string)$vevents[0]->UID;
+ $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents);
+ $masterItem = $this->getMasterItemFromListOfVEvents($vevents);
+ $now = $this->timeFactory->getDateTime();
+ $isRecurring = $masterItem ? $this->isRecurring($masterItem) : false;
+
+ foreach ($recurrenceExceptions as $recurrenceException) {
+ $eventHash = $this->getEventHash($recurrenceException);
+
+ if (!isset($recurrenceException->VALARM)) {
+ continue;
+ }
+
+ foreach ($recurrenceException->VALARM as $valarm) {
+ /** @var VAlarm $valarm */
+ $alarmHash = $this->getAlarmHash($valarm);
+ $triggerTime = $valarm->getEffectiveTriggerTime();
+ $diff = $now->diff($triggerTime);
+ if ($diff->invert === 1) {
+ continue;
+ }
+
+ $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone,
+ $eventHash, $alarmHash, true, true);
+ $this->writeRemindersToDatabase($alarms);
+ }
+ }
+
+ if ($masterItem) {
+ $processedAlarms = [];
+ $masterAlarms = [];
+ $masterHash = $this->getEventHash($masterItem);
+
+ if (!isset($masterItem->VALARM)) {
+ return;
+ }
+
+ foreach ($masterItem->VALARM as $valarm) {
+ $masterAlarms[] = $this->getAlarmHash($valarm);
+ }
+
+ try {
+ $iterator = new EventIterator($vevents, $uid);
+ } catch (NoInstancesException $e) {
+ // This event is recurring, but it doesn't have a single
+ // instance. We are skipping this event from the output
+ // entirely.
+ return;
+ } catch (MaxInstancesExceededException $e) {
+ // The event has more than 3500 recurring-instances
+ // so we can ignore it
+ return;
+ }
+
+ while ($iterator->valid() && count($processedAlarms) < count($masterAlarms)) {
+ $event = $iterator->getEventObject();
+
+ // Recurrence-exceptions are handled separately, so just ignore them here
+ if (\in_array($event, $recurrenceExceptions, true)) {
+ $iterator->next();
+ continue;
+ }
+
+ foreach ($event->VALARM as $valarm) {
+ /** @var VAlarm $valarm */
+ $alarmHash = $this->getAlarmHash($valarm);
+ if (\in_array($alarmHash, $processedAlarms, true)) {
+ continue;
+ }
+
+ if (!\in_array((string)$valarm->ACTION, self::REMINDER_TYPES, true)) {
+ // Action allows x-name, we don't insert reminders
+ // into the database if they are not standard
+ $processedAlarms[] = $alarmHash;
+ continue;
+ }
+
+ try {
+ $triggerTime = $valarm->getEffectiveTriggerTime();
+ /**
+ * @psalm-suppress DocblockTypeContradiction
+ * https://github.com/vimeo/psalm/issues/9244
+ */
+ if ($triggerTime->getTimezone() === false || $triggerTime->getTimezone()->getName() === 'UTC') {
+ $triggerTime = new DateTimeImmutable(
+ $triggerTime->format('Y-m-d H:i:s'),
+ $calendarTimeZone
+ );
+ }
+ } catch (InvalidDataException $e) {
+ continue;
+ }
+
+ // If effective trigger time is in the past
+ // just skip and generate for next event
+ $diff = $now->diff($triggerTime);
+ if ($diff->invert === 1) {
+ // If an absolute alarm is in the past,
+ // just add it to processedAlarms, so
+ // we don't extend till eternity
+ if (!$this->isAlarmRelative($valarm)) {
+ $processedAlarms[] = $alarmHash;
+ }
+
+ continue;
+ }
+
+ $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone, $masterHash, $alarmHash, $isRecurring, false);
+ $this->writeRemindersToDatabase($alarms);
+ $processedAlarms[] = $alarmHash;
+ }
+
+ $iterator->next();
+ }
+ }
+ }
+
+ /**
+ * @param array $objectData
+ * @throws VObject\InvalidDataException
+ */
+ public function onCalendarObjectEdit(array $objectData):void {
+ // TODO - this can be vastly improved
+ // - get cached reminders
+ // - ...
+
+ $this->onCalendarObjectDelete($objectData);
+ $this->onCalendarObjectCreate($objectData);
+ }
+
+ /**
+ * @param array $objectData
+ * @throws VObject\InvalidDataException
+ */
+ public function onCalendarObjectDelete(array $objectData):void {
+ // We only support VEvents for now
+ if (strcasecmp($objectData['component'], 'vevent') !== 0) {
+ return;
+ }
+
+ $this->backend->cleanRemindersForEvent((int)$objectData['id']);
+ }
+
+ /**
+ * @param VAlarm $valarm
+ * @param array $objectData
+ * @param DateTimeZone $calendarTimeZone
+ * @param string|null $eventHash
+ * @param string|null $alarmHash
+ * @param bool $isRecurring
+ * @param bool $isRecurrenceException
+ * @return array
+ */
+ private function getRemindersForVAlarm(VAlarm $valarm,
+ array $objectData,
+ DateTimeZone $calendarTimeZone,
+ ?string $eventHash = null,
+ ?string $alarmHash = null,
+ bool $isRecurring = false,
+ bool $isRecurrenceException = false):array {
+ if ($eventHash === null) {
+ $eventHash = $this->getEventHash($valarm->parent);
+ }
+ if ($alarmHash === null) {
+ $alarmHash = $this->getAlarmHash($valarm);
+ }
+
+ $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($valarm->parent);
+ $isRelative = $this->isAlarmRelative($valarm);
+ /** @var DateTimeImmutable $notificationDate */
+ $notificationDate = $valarm->getEffectiveTriggerTime();
+ /**
+ * @psalm-suppress DocblockTypeContradiction
+ * https://github.com/vimeo/psalm/issues/9244
+ */
+ if ($notificationDate->getTimezone() === false || $notificationDate->getTimezone()->getName() === 'UTC') {
+ $notificationDate = new DateTimeImmutable(
+ $notificationDate->format('Y-m-d H:i:s'),
+ $calendarTimeZone
+ );
+ }
+ $clonedNotificationDate = new \DateTime('now', $notificationDate->getTimezone());
+ $clonedNotificationDate->setTimestamp($notificationDate->getTimestamp());
+
+ $alarms = [];
+
+ $alarms[] = [
+ 'calendar_id' => $objectData['calendarid'],
+ 'object_id' => $objectData['id'],
+ 'uid' => (string)$valarm->parent->UID,
+ 'is_recurring' => $isRecurring,
+ 'recurrence_id' => $recurrenceId,
+ 'is_recurrence_exception' => $isRecurrenceException,
+ 'event_hash' => $eventHash,
+ 'alarm_hash' => $alarmHash,
+ 'type' => (string)$valarm->ACTION,
+ 'is_relative' => $isRelative,
+ 'notification_date' => $notificationDate->getTimestamp(),
+ 'is_repeat_based' => false,
+ ];
+
+ $repeat = isset($valarm->REPEAT) ? (int)$valarm->REPEAT->getValue() : 0;
+ for ($i = 0; $i < $repeat; $i++) {
+ if ($valarm->DURATION === null) {
+ continue;
+ }
+
+ $clonedNotificationDate->add($valarm->DURATION->getDateInterval());
+ $alarms[] = [
+ 'calendar_id' => $objectData['calendarid'],
+ 'object_id' => $objectData['id'],
+ 'uid' => (string)$valarm->parent->UID,
+ 'is_recurring' => $isRecurring,
+ 'recurrence_id' => $recurrenceId,
+ 'is_recurrence_exception' => $isRecurrenceException,
+ 'event_hash' => $eventHash,
+ 'alarm_hash' => $alarmHash,
+ 'type' => (string)$valarm->ACTION,
+ 'is_relative' => $isRelative,
+ 'notification_date' => $clonedNotificationDate->getTimestamp(),
+ 'is_repeat_based' => true,
+ ];
+ }
+
+ return $alarms;
+ }
+
+ /**
+ * @param array $reminders
+ */
+ private function writeRemindersToDatabase(array $reminders): void {
+ $uniqueReminders = [];
+ foreach ($reminders as $reminder) {
+ $key = $reminder['notification_date'] . $reminder['event_hash'] . $reminder['type'];
+ if (!isset($uniqueReminders[$key])) {
+ $uniqueReminders[$key] = $reminder;
+ }
+ }
+ foreach (array_values($uniqueReminders) as $reminder) {
+ $this->backend->insertReminder(
+ (int)$reminder['calendar_id'],
+ (int)$reminder['object_id'],
+ $reminder['uid'],
+ $reminder['is_recurring'],
+ (int)$reminder['recurrence_id'],
+ $reminder['is_recurrence_exception'],
+ $reminder['event_hash'],
+ $reminder['alarm_hash'],
+ $reminder['type'],
+ $reminder['is_relative'],
+ (int)$reminder['notification_date'],
+ $reminder['is_repeat_based']
+ );
+ }
+ }
+
+ /**
+ * @param array $reminder
+ * @param VEvent $vevent
+ */
+ private function deleteOrProcessNext(array $reminder,
+ VObject\Component\VEvent $vevent):void {
+ if ($reminder['is_repeat_based']
+ || !$reminder['is_recurring']
+ || !$reminder['is_relative']
+ || $reminder['is_recurrence_exception']) {
+ $this->backend->removeReminder($reminder['id']);
+ return;
+ }
+
+ $vevents = $this->getAllVEventsFromVCalendar($vevent->parent);
+ $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents);
+ $now = $this->timeFactory->getDateTime();
+ $calendarTimeZone = $this->getCalendarTimeZone((int)$reminder['calendar_id']);
+
+ try {
+ $iterator = new EventIterator($vevents, $reminder['uid']);
+ } catch (NoInstancesException $e) {
+ // This event is recurring, but it doesn't have a single
+ // instance. We are skipping this event from the output
+ // entirely.
+ return;
+ }
+
+ try {
+ while ($iterator->valid()) {
+ $event = $iterator->getEventObject();
+
+ // Recurrence-exceptions are handled separately, so just ignore them here
+ if (\in_array($event, $recurrenceExceptions, true)) {
+ $iterator->next();
+ continue;
+ }
+
+ $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($event);
+ if ($reminder['recurrence_id'] >= $recurrenceId) {
+ $iterator->next();
+ continue;
+ }
+
+ foreach ($event->VALARM as $valarm) {
+ /** @var VAlarm $valarm */
+ $alarmHash = $this->getAlarmHash($valarm);
+ if ($alarmHash !== $reminder['alarm_hash']) {
+ continue;
+ }
+
+ $triggerTime = $valarm->getEffectiveTriggerTime();
+
+ // If effective trigger time is in the past
+ // just skip and generate for next event
+ $diff = $now->diff($triggerTime);
+ if ($diff->invert === 1) {
+ continue;
+ }
+
+ $this->backend->removeReminder($reminder['id']);
+ $alarms = $this->getRemindersForVAlarm($valarm, [
+ 'calendarid' => $reminder['calendar_id'],
+ 'id' => $reminder['object_id'],
+ ], $calendarTimeZone, $reminder['event_hash'], $alarmHash, true, false);
+ $this->writeRemindersToDatabase($alarms);
+
+ // Abort generating reminders after creating one successfully
+ return;
+ }
+
+ $iterator->next();
+ }
+ } catch (MaxInstancesExceededException $e) {
+ // Using debug logger as this isn't really an error
+ $this->logger->debug('Recurrence with too many instances detected, skipping VEVENT', ['exception' => $e]);
+ }
+
+ $this->backend->removeReminder($reminder['id']);
+ }
+
+ /**
+ * @param int $calendarId
+ * @return IUser[]
+ */
+ private function getAllUsersWithWriteAccessToCalendar(int $calendarId):array {
+ $shares = $this->caldavBackend->getShares($calendarId);
+
+ $users = [];
+ $userIds = [];
+ $groups = [];
+ foreach ($shares as $share) {
+ // Only consider writable shares
+ if ($share['readOnly']) {
+ continue;
+ }
+
+ $principal = explode('/', $share['{http://owncloud.org/ns}principal']);
+ if ($principal[1] === 'users') {
+ $user = $this->userManager->get($principal[2]);
+ if ($user) {
+ $users[] = $user;
+ $userIds[] = $principal[2];
+ }
+ } elseif ($principal[1] === 'groups') {
+ $groups[] = $principal[2];
+ }
+ }
+
+ foreach ($groups as $gid) {
+ $group = $this->groupManager->get($gid);
+ if ($group instanceof IGroup) {
+ foreach ($group->getUsers() as $user) {
+ if (!\in_array($user->getUID(), $userIds, true)) {
+ $users[] = $user;
+ $userIds[] = $user->getUID();
+ }
+ }
+ }
+ }
+
+ return $users;
+ }
+
+ /**
+ * Gets a hash of the event.
+ * If the hash changes, we have to update all relative alarms.
+ *
+ * @param VEvent $vevent
+ * @return string
+ */
+ private function getEventHash(VEvent $vevent):string {
+ $properties = [
+ (string)$vevent->DTSTART->serialize(),
+ ];
+
+ if ($vevent->DTEND) {
+ $properties[] = (string)$vevent->DTEND->serialize();
+ }
+ if ($vevent->DURATION) {
+ $properties[] = (string)$vevent->DURATION->serialize();
+ }
+ if ($vevent->{'RECURRENCE-ID'}) {
+ $properties[] = (string)$vevent->{'RECURRENCE-ID'}->serialize();
+ }
+ if ($vevent->RRULE) {
+ $properties[] = (string)$vevent->RRULE->serialize();
+ }
+ if ($vevent->EXDATE) {
+ $properties[] = (string)$vevent->EXDATE->serialize();
+ }
+ if ($vevent->RDATE) {
+ $properties[] = (string)$vevent->RDATE->serialize();
+ }
+
+ return md5(implode('::', $properties));
+ }
+
+ /**
+ * Gets a hash of the alarm.
+ * If the hash changes, we have to update oc_dav_reminders.
+ *
+ * @param VAlarm $valarm
+ * @return string
+ */
+ private function getAlarmHash(VAlarm $valarm):string {
+ $properties = [
+ (string)$valarm->ACTION->serialize(),
+ (string)$valarm->TRIGGER->serialize(),
+ ];
+
+ if ($valarm->DURATION) {
+ $properties[] = (string)$valarm->DURATION->serialize();
+ }
+ if ($valarm->REPEAT) {
+ $properties[] = (string)$valarm->REPEAT->serialize();
+ }
+
+ return md5(implode('::', $properties));
+ }
+
+ /**
+ * @param VObject\Component\VCalendar $vcalendar
+ * @param int $recurrenceId
+ * @param bool $isRecurrenceException
+ * @return VEvent|null
+ */
+ private function getVEventByRecurrenceId(VObject\Component\VCalendar $vcalendar,
+ int $recurrenceId,
+ bool $isRecurrenceException):?VEvent {
+ $vevents = $this->getAllVEventsFromVCalendar($vcalendar);
+ if (count($vevents) === 0) {
+ return null;
+ }
+
+ $uid = (string)$vevents[0]->UID;
+ $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents);
+ $masterItem = $this->getMasterItemFromListOfVEvents($vevents);
+
+ // Handle recurrence-exceptions first, because recurrence-expansion is expensive
+ if ($isRecurrenceException) {
+ foreach ($recurrenceExceptions as $recurrenceException) {
+ if ($this->getEffectiveRecurrenceIdOfVEvent($recurrenceException) === $recurrenceId) {
+ return $recurrenceException;
+ }
+ }
+
+ return null;
+ }
+
+ if ($masterItem) {
+ try {
+ $iterator = new EventIterator($vevents, $uid);
+ } catch (NoInstancesException $e) {
+ // This event is recurring, but it doesn't have a single
+ // instance. We are skipping this event from the output
+ // entirely.
+ return null;
+ }
+
+ while ($iterator->valid()) {
+ $event = $iterator->getEventObject();
+
+ // Recurrence-exceptions are handled separately, so just ignore them here
+ if (\in_array($event, $recurrenceExceptions, true)) {
+ $iterator->next();
+ continue;
+ }
+
+ if ($this->getEffectiveRecurrenceIdOfVEvent($event) === $recurrenceId) {
+ return $event;
+ }
+
+ $iterator->next();
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @param VEvent $vevent
+ * @return string
+ */
+ private function getStatusOfEvent(VEvent $vevent):string {
+ if ($vevent->STATUS) {
+ return (string)$vevent->STATUS;
+ }
+
+ // Doesn't say so in the standard,
+ // but we consider events without a status
+ // to be confirmed
+ return 'CONFIRMED';
+ }
+
+ /**
+ * @param VObject\Component\VEvent $vevent
+ * @return bool
+ */
+ private function wasEventCancelled(VObject\Component\VEvent $vevent):bool {
+ return $this->getStatusOfEvent($vevent) === 'CANCELLED';
+ }
+
+ /**
+ * @param string $calendarData
+ * @return VObject\Component\VCalendar|null
+ */
+ private function parseCalendarData(string $calendarData):?VObject\Component\VCalendar {
+ try {
+ return VObject\Reader::read($calendarData,
+ VObject\Reader::OPTION_FORGIVING);
+ } catch (ParseException $ex) {
+ return null;
+ }
+ }
+
+ /**
+ * @param string $principalUri
+ * @return IUser|null
+ */
+ private function getUserFromPrincipalURI(string $principalUri):?IUser {
+ if (!$principalUri) {
+ return null;
+ }
+
+ if (stripos($principalUri, 'principals/users/') !== 0) {
+ return null;
+ }
+
+ $userId = substr($principalUri, 17);
+ return $this->userManager->get($userId);
+ }
+
+ /**
+ * @param VObject\Component\VCalendar $vcalendar
+ * @return VObject\Component\VEvent[]
+ */
+ private function getAllVEventsFromVCalendar(VObject\Component\VCalendar $vcalendar):array {
+ $vevents = [];
+
+ foreach ($vcalendar->children() as $child) {
+ if (!($child instanceof VObject\Component)) {
+ continue;
+ }
+
+ if ($child->name !== 'VEVENT') {
+ continue;
+ }
+ // Ignore invalid events with no DTSTART
+ if ($child->DTSTART === null) {
+ continue;
+ }
+
+ $vevents[] = $child;
+ }
+
+ return $vevents;
+ }
+
+ /**
+ * @param array $vevents
+ * @return VObject\Component\VEvent[]
+ */
+ private function getRecurrenceExceptionFromListOfVEvents(array $vevents):array {
+ return array_values(array_filter($vevents, function (VEvent $vevent) {
+ return $vevent->{'RECURRENCE-ID'} !== null;
+ }));
+ }
+
+ /**
+ * @param array $vevents
+ * @return VEvent|null
+ */
+ private function getMasterItemFromListOfVEvents(array $vevents):?VEvent {
+ $elements = array_values(array_filter($vevents, function (VEvent $vevent) {
+ return $vevent->{'RECURRENCE-ID'} === null;
+ }));
+
+ if (count($elements) === 0) {
+ return null;
+ }
+ if (count($elements) > 1) {
+ throw new \TypeError('Multiple master objects');
+ }
+
+ return $elements[0];
+ }
+
+ /**
+ * @param VAlarm $valarm
+ * @return bool
+ */
+ private function isAlarmRelative(VAlarm $valarm):bool {
+ $trigger = $valarm->TRIGGER;
+ return $trigger instanceof VObject\Property\ICalendar\Duration;
+ }
+
+ /**
+ * @param VEvent $vevent
+ * @return int
+ */
+ private function getEffectiveRecurrenceIdOfVEvent(VEvent $vevent):int {
+ if (isset($vevent->{'RECURRENCE-ID'})) {
+ return $vevent->{'RECURRENCE-ID'}->getDateTime()->getTimestamp();
+ }
+
+ return $vevent->DTSTART->getDateTime()->getTimestamp();
+ }
+
+ /**
+ * @param VEvent $vevent
+ * @return bool
+ */
+ private function isRecurring(VEvent $vevent):bool {
+ return isset($vevent->RRULE) || isset($vevent->RDATE);
+ }
+
+ /**
+ * @param int $calendarid
+ *
+ * @return DateTimeZone
+ */
+ private function getCalendarTimeZone(int $calendarid): DateTimeZone {
+ $calendarInfo = $this->caldavBackend->getCalendarById($calendarid);
+ $tzProp = '{urn:ietf:params:xml:ns:caldav}calendar-timezone';
+ if (empty($calendarInfo[$tzProp])) {
+ // Defaulting to UTC
+ return new DateTimeZone('UTC');
+ }
+ // This property contains a VCALENDAR with a single VTIMEZONE
+ /** @var string $timezoneProp */
+ $timezoneProp = $calendarInfo[$tzProp];
+ /** @var VObject\Component\VCalendar $vtimezoneObj */
+ $vtimezoneObj = VObject\Reader::read($timezoneProp);
+ /** @var VObject\Component\VTimeZone $vtimezone */
+ $vtimezone = $vtimezoneObj->VTIMEZONE;
+ return $vtimezone->getTimeZone();
+ }
+}
diff --git a/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php
new file mode 100644
index 00000000000..68bb3373346
--- /dev/null
+++ b/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php
@@ -0,0 +1,494 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\ResourceBooking;
+
+use OCA\DAV\CalDAV\Proxy\ProxyMapper;
+use OCA\DAV\Traits\PrincipalProxyTrait;
+use OCP\Calendar\Resource\IResourceMetadata;
+use OCP\Calendar\Room\IRoomMetadata;
+use OCP\DB\Exception;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+use OCP\IGroupManager;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+use Sabre\DAV\PropPatch;
+use Sabre\DAVACL\PrincipalBackend\BackendInterface;
+use function array_intersect;
+use function array_map;
+use function array_merge;
+use function array_unique;
+use function array_values;
+
+abstract class AbstractPrincipalBackend implements BackendInterface {
+
+ /** @var string */
+ private $dbTableName;
+
+ /** @var string */
+ private $dbMetaDataTableName;
+
+ /** @var string */
+ private $dbForeignKeyName;
+
+ public function __construct(
+ private IDBConnection $db,
+ private IUserSession $userSession,
+ private IGroupManager $groupManager,
+ private LoggerInterface $logger,
+ private ProxyMapper $proxyMapper,
+ private string $principalPrefix,
+ string $dbPrefix,
+ private string $cuType,
+ ) {
+ $this->dbTableName = 'calendar_' . $dbPrefix . 's';
+ $this->dbMetaDataTableName = $this->dbTableName . '_md';
+ $this->dbForeignKeyName = $dbPrefix . '_id';
+ }
+
+ use PrincipalProxyTrait;
+
+ /**
+ * Returns a list of principals based on a prefix.
+ *
+ * This prefix will often contain something like 'principals'. You are only
+ * expected to return principals that are in this base path.
+ *
+ * You are expected to return at least a 'uri' for every user, you can
+ * return any additional properties if you wish so. Common properties are:
+ * {DAV:}displayname
+ *
+ * @param string $prefixPath
+ * @return string[]
+ */
+ public function getPrincipalsByPrefix($prefixPath): array {
+ $principals = [];
+
+ if ($prefixPath === $this->principalPrefix) {
+ $query = $this->db->getQueryBuilder();
+ $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
+ ->from($this->dbTableName);
+ $stmt = $query->execute();
+
+ $metaDataQuery = $this->db->getQueryBuilder();
+ $metaDataQuery->select([$this->dbForeignKeyName, 'key', 'value'])
+ ->from($this->dbMetaDataTableName);
+ $metaDataStmt = $metaDataQuery->execute();
+ $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
+
+ $metaDataById = [];
+ foreach ($metaDataRows as $metaDataRow) {
+ if (!isset($metaDataById[$metaDataRow[$this->dbForeignKeyName]])) {
+ $metaDataById[$metaDataRow[$this->dbForeignKeyName]] = [];
+ }
+
+ $metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']]
+ = $metaDataRow['value'];
+ }
+
+ while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+ $id = $row['id'];
+
+ if (isset($metaDataById[$id])) {
+ $principals[] = $this->rowToPrincipal($row, $metaDataById[$id]);
+ } else {
+ $principals[] = $this->rowToPrincipal($row);
+ }
+ }
+
+ $stmt->closeCursor();
+ }
+
+ return $principals;
+ }
+
+ /**
+ * Returns a specific principal, specified by its path.
+ * The returned structure should be the exact same as from
+ * getPrincipalsByPrefix.
+ *
+ * @param string $prefixPath
+ *
+ * @return array
+ */
+ public function getPrincipalByPath($path) {
+ if (!str_starts_with($path, $this->principalPrefix)) {
+ return null;
+ }
+ [, $name] = \Sabre\Uri\split($path);
+
+ [$backendId, $resourceId] = explode('-', $name, 2);
+
+ $query = $this->db->getQueryBuilder();
+ $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
+ ->from($this->dbTableName)
+ ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
+ ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
+ $stmt = $query->execute();
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+ if (!$row) {
+ return null;
+ }
+
+ $metaDataQuery = $this->db->getQueryBuilder();
+ $metaDataQuery->select(['key', 'value'])
+ ->from($this->dbMetaDataTableName)
+ ->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
+ $metaDataStmt = $metaDataQuery->execute();
+ $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
+ $metadata = [];
+
+ foreach ($metaDataRows as $metaDataRow) {
+ $metadata[$metaDataRow['key']] = $metaDataRow['value'];
+ }
+
+ return $this->rowToPrincipal($row, $metadata);
+ }
+
+ /**
+ * @param int $id
+ * @return string[]|null
+ */
+ public function getPrincipalById($id): ?array {
+ $query = $this->db->getQueryBuilder();
+ $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
+ ->from($this->dbTableName)
+ ->where($query->expr()->eq('id', $query->createNamedParameter($id)));
+ $stmt = $query->execute();
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+ if (!$row) {
+ return null;
+ }
+
+ $metaDataQuery = $this->db->getQueryBuilder();
+ $metaDataQuery->select(['key', 'value'])
+ ->from($this->dbMetaDataTableName)
+ ->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
+ $metaDataStmt = $metaDataQuery->execute();
+ $metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
+ $metadata = [];
+
+ foreach ($metaDataRows as $metaDataRow) {
+ $metadata[$metaDataRow['key']] = $metaDataRow['value'];
+ }
+
+ return $this->rowToPrincipal($row, $metadata);
+ }
+
+ /**
+ * @param string $path
+ * @param PropPatch $propPatch
+ * @return int
+ */
+ public function updatePrincipal($path, PropPatch $propPatch): int {
+ return 0;
+ }
+
+ /**
+ * @param string $prefixPath
+ * @param string $test
+ *
+ * @return array
+ */
+ public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') {
+ $results = [];
+ if (\count($searchProperties) === 0) {
+ return [];
+ }
+ if ($prefixPath !== $this->principalPrefix) {
+ return [];
+ }
+
+ $user = $this->userSession->getUser();
+ if (!$user) {
+ return [];
+ }
+ $usersGroups = $this->groupManager->getUserGroupIds($user);
+
+ foreach ($searchProperties as $prop => $value) {
+ switch ($prop) {
+ case '{http://sabredav.org/ns}email-address':
+ $query = $this->db->getQueryBuilder();
+ $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
+ ->from($this->dbTableName)
+ ->where($query->expr()->iLike('email', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
+
+ $stmt = $query->execute();
+ $principals = [];
+ while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+ if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
+ continue;
+ }
+ $principals[] = $this->rowToPrincipal($row)['uri'];
+ }
+ $results[] = $principals;
+
+ $stmt->closeCursor();
+ break;
+
+ case '{DAV:}displayname':
+ $query = $this->db->getQueryBuilder();
+ $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
+ ->from($this->dbTableName)
+ ->where($query->expr()->iLike('displayname', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
+
+ $stmt = $query->execute();
+ $principals = [];
+ while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+ if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
+ continue;
+ }
+ $principals[] = $this->rowToPrincipal($row)['uri'];
+ }
+ $results[] = $principals;
+
+ $stmt->closeCursor();
+ break;
+
+ case '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set':
+ // If you add support for more search properties that qualify as a user-address,
+ // please also add them to the array below
+ $results[] = $this->searchPrincipals($this->principalPrefix, [
+ '{http://sabredav.org/ns}email-address' => $value,
+ ], 'anyof');
+ break;
+
+ case IRoomMetadata::FEATURES:
+ $results[] = $this->searchPrincipalsByRoomFeature($prop, $value);
+ break;
+
+ case IRoomMetadata::CAPACITY:
+ case IResourceMetadata::VEHICLE_SEATING_CAPACITY:
+ $results[] = $this->searchPrincipalsByCapacity($prop, $value);
+ break;
+
+ default:
+ $results[] = $this->searchPrincipalsByMetadataKey($prop, $value, $usersGroups);
+ break;
+ }
+ }
+
+ // results is an array of arrays, so this is not the first search result
+ // but the results of the first searchProperty
+ if (count($results) === 1) {
+ return $results[0];
+ }
+
+ switch ($test) {
+ case 'anyof':
+ return array_values(array_unique(array_merge(...$results)));
+
+ case 'allof':
+ default:
+ return array_values(array_intersect(...$results));
+ }
+ }
+
+ /**
+ * @param string $key
+ * @return IQueryBuilder
+ */
+ private function getMetadataQuery(string $key): IQueryBuilder {
+ $query = $this->db->getQueryBuilder();
+ $query->select([$this->dbForeignKeyName])
+ ->from($this->dbMetaDataTableName)
+ ->where($query->expr()->eq('key', $query->createNamedParameter($key)));
+ return $query;
+ }
+
+ /**
+ * Searches principals based on their metadata keys.
+ * This allows to search for all principals with a specific key.
+ * e.g.:
+ * '{http://nextcloud.com/ns}room-building-address' => 'ABC Street 123, ...'
+ *
+ * @param string $key
+ * @param string $value
+ * @param string[] $usersGroups
+ * @return string[]
+ */
+ private function searchPrincipalsByMetadataKey(string $key, string $value, array $usersGroups = []): array {
+ $query = $this->getMetadataQuery($key);
+ $query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
+ return $this->getRows($query, $usersGroups);
+ }
+
+ /**
+ * Searches principals based on room features
+ * e.g.:
+ * '{http://nextcloud.com/ns}room-features' => 'TV,PROJECTOR'
+ *
+ * @param string $key
+ * @param string $value
+ * @param string[] $usersGroups
+ * @return string[]
+ */
+ private function searchPrincipalsByRoomFeature(string $key, string $value, array $usersGroups = []): array {
+ $query = $this->getMetadataQuery($key);
+ foreach (explode(',', $value) as $v) {
+ $query->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($v) . '%')));
+ }
+ return $this->getRows($query, $usersGroups);
+ }
+
+ /**
+ * Searches principals based on room seating capacity or vehicle capacity
+ * e.g.:
+ * '{http://nextcloud.com/ns}room-seating-capacity' => '100'
+ *
+ * @param string $key
+ * @param string $value
+ * @param string[] $usersGroups
+ * @return string[]
+ */
+ private function searchPrincipalsByCapacity(string $key, string $value, array $usersGroups = []): array {
+ $query = $this->getMetadataQuery($key);
+ $query->andWhere($query->expr()->gte('value', $query->createNamedParameter($value)));
+ return $this->getRows($query, $usersGroups);
+ }
+
+ /**
+ * @param IQueryBuilder $query
+ * @param string[] $usersGroups
+ * @return string[]
+ */
+ private function getRows(IQueryBuilder $query, array $usersGroups): array {
+ try {
+ $stmt = $query->executeQuery();
+ } catch (Exception $e) {
+ $this->logger->error('Could not search resources: ' . $e->getMessage(), ['exception' => $e]);
+ }
+
+ $rows = [];
+ while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+ $principalRow = $this->getPrincipalById($row[$this->dbForeignKeyName]);
+ if (!$principalRow) {
+ continue;
+ }
+
+ $rows[] = $principalRow;
+ }
+
+ $stmt->closeCursor();
+
+ $filteredRows = array_filter($rows, function ($row) use ($usersGroups) {
+ return $this->isAllowedToAccessResource($row, $usersGroups);
+ });
+
+ return array_map(static function ($row): string {
+ return $row['uri'];
+ }, $filteredRows);
+ }
+
+ /**
+ * @param string $uri
+ * @param string $principalPrefix
+ * @return null|string
+ * @throws Exception
+ */
+ public function findByUri($uri, $principalPrefix): ?string {
+ $user = $this->userSession->getUser();
+ if (!$user) {
+ return null;
+ }
+ $usersGroups = $this->groupManager->getUserGroupIds($user);
+
+ if (str_starts_with($uri, 'mailto:')) {
+ $email = substr($uri, 7);
+ $query = $this->db->getQueryBuilder();
+ $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
+ ->from($this->dbTableName)
+ ->where($query->expr()->eq('email', $query->createNamedParameter($email)));
+
+ $stmt = $query->execute();
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+ if (!$row) {
+ return null;
+ }
+ if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
+ return null;
+ }
+
+ return $this->rowToPrincipal($row)['uri'];
+ }
+
+ if (str_starts_with($uri, 'principal:')) {
+ $path = substr($uri, 10);
+ if (!str_starts_with($path, $this->principalPrefix)) {
+ return null;
+ }
+
+ [, $name] = \Sabre\Uri\split($path);
+ [$backendId, $resourceId] = explode('-', $name, 2);
+
+ $query = $this->db->getQueryBuilder();
+ $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
+ ->from($this->dbTableName)
+ ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
+ ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
+ $stmt = $query->execute();
+ $row = $stmt->fetch(\PDO::FETCH_ASSOC);
+
+ if (!$row) {
+ return null;
+ }
+ if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
+ return null;
+ }
+
+ return $this->rowToPrincipal($row)['uri'];
+ }
+
+ return null;
+ }
+
+ /**
+ * convert database row to principal
+ *
+ * @param string[] $row
+ * @param string[] $metadata
+ * @return string[]
+ */
+ private function rowToPrincipal(array $row, array $metadata = []): array {
+ return array_merge([
+ 'uri' => $this->principalPrefix . '/' . $row['backend_id'] . '-' . $row['resource_id'],
+ '{DAV:}displayname' => $row['displayname'],
+ '{http://sabredav.org/ns}email-address' => $row['email'],
+ '{urn:ietf:params:xml:ns:caldav}calendar-user-type' => $this->cuType,
+ ], $metadata);
+ }
+
+ /**
+ * @param array $row
+ * @param array $userGroups
+ * @return bool
+ */
+ private function isAllowedToAccessResource(array $row, array $userGroups): bool {
+ if (!isset($row['group_restrictions'])
+ || $row['group_restrictions'] === null
+ || $row['group_restrictions'] === '') {
+ return true;
+ }
+
+ // group restrictions contains something, but not parsable, deny access and log warning
+ $json = json_decode($row['group_restrictions'], null, 512, JSON_THROW_ON_ERROR);
+ if (!\is_array($json)) {
+ $this->logger->info('group_restrictions field could not be parsed for ' . $this->dbTableName . '::' . $row['id'] . ', denying access to resource');
+ return false;
+ }
+
+ // empty array => no group restrictions
+ if (empty($json)) {
+ return true;
+ }
+
+ return !empty(array_intersect($json, $userGroups));
+ }
+}
diff --git a/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php
new file mode 100644
index 00000000000..c70d93daf52
--- /dev/null
+++ b/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\ResourceBooking;
+
+use OCA\DAV\CalDAV\Proxy\ProxyMapper;
+use OCP\IDBConnection;
+use OCP\IGroupManager;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Class ResourcePrincipalBackend
+ *
+ * @package OCA\DAV\CalDAV\ResourceBooking
+ */
+class ResourcePrincipalBackend extends AbstractPrincipalBackend {
+
+ /**
+ * ResourcePrincipalBackend constructor.
+ */
+ public function __construct(IDBConnection $dbConnection,
+ IUserSession $userSession,
+ IGroupManager $groupManager,
+ LoggerInterface $logger,
+ ProxyMapper $proxyMapper) {
+ parent::__construct($dbConnection, $userSession, $groupManager, $logger,
+ $proxyMapper, 'principals/calendar-resources', 'resource', 'RESOURCE');
+ }
+}
diff --git a/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php
new file mode 100644
index 00000000000..5704b23ae14
--- /dev/null
+++ b/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\ResourceBooking;
+
+use OCA\DAV\CalDAV\Proxy\ProxyMapper;
+use OCP\IDBConnection;
+use OCP\IGroupManager;
+use OCP\IUserSession;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Class RoomPrincipalBackend
+ *
+ * @package OCA\DAV\CalDAV\ResourceBooking
+ */
+class RoomPrincipalBackend extends AbstractPrincipalBackend {
+
+ /**
+ * RoomPrincipalBackend constructor.
+ */
+ public function __construct(IDBConnection $dbConnection,
+ IUserSession $userSession,
+ IGroupManager $groupManager,
+ LoggerInterface $logger,
+ ProxyMapper $proxyMapper) {
+ parent::__construct($dbConnection, $userSession, $groupManager, $logger,
+ $proxyMapper, 'principals/calendar-rooms', 'room', 'ROOM');
+ }
+}
diff --git a/apps/dav/lib/CalDAV/RetentionService.php b/apps/dav/lib/CalDAV/RetentionService.php
new file mode 100644
index 00000000000..399d1a46639
--- /dev/null
+++ b/apps/dav/lib/CalDAV/RetentionService.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV;
+
+use OCA\DAV\AppInfo\Application;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IConfig;
+use function max;
+
+class RetentionService {
+ public const RETENTION_CONFIG_KEY = 'calendarRetentionObligation';
+ private const DEFAULT_RETENTION_SECONDS = 30 * 24 * 60 * 60;
+
+ public function __construct(
+ private IConfig $config,
+ private ITimeFactory $time,
+ private CalDavBackend $calDavBackend,
+ ) {
+ }
+
+ public function getDuration(): int {
+ return max(
+ (int)$this->config->getAppValue(
+ Application::APP_ID,
+ self::RETENTION_CONFIG_KEY,
+ (string)self::DEFAULT_RETENTION_SECONDS
+ ),
+ 0 // Just making sure we don't delete things in the future when a negative number is passed
+ );
+ }
+
+ public function cleanUp(): void {
+ $retentionTime = $this->getDuration();
+ $now = $this->time->getTime();
+
+ $calendars = $this->calDavBackend->getDeletedCalendars($now - $retentionTime);
+ foreach ($calendars as $calendar) {
+ $this->calDavBackend->deleteCalendar($calendar['id'], true);
+ }
+
+ $objects = $this->calDavBackend->getDeletedCalendarObjects($now - $retentionTime);
+ foreach ($objects as $object) {
+ $this->calDavBackend->deleteCalendarObject(
+ $object['calendarid'],
+ $object['uri'],
+ $object['calendartype'],
+ true
+ );
+ }
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php
index 781baa2032d..2af6b162d8d 100644
--- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php
+++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php
@@ -1,48 +1,35 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, ownCloud, Inc.
- * @copyright Copyright (c) 2017, Georg Ehrke
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- * @author Joas Schilling <coding@schilljs.com>
- * @author Leon Klingele <leon@struktur.de>
- * @author Thomas Müller <thomas.mueller@tmit.eu>
- *
- * @license AGPL-3.0
- *
- * This code is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License, version 3,
- * as published by the Free Software Foundation.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License, version 3,
- * along with this program. If not, see <http://www.gnu.org/licenses/>
- *
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-FileCopyrightText: 2007-2015 fruux GmbH (https://fruux.com/)
+ * SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\DAV\CalDAV\Schedule;
+use OCA\DAV\CalDAV\CalendarObject;
+use OCA\DAV\CalDAV\EventComparisonService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Defaults;
-use OCP\IConfig;
-use OCP\IL10N;
-use OCP\ILogger;
-use OCP\IURLGenerator;
-use OCP\L10N\IFactory as L10NFactory;
-use OCP\Mail\IEMailTemplate;
+use OCP\IAppConfig;
+use OCP\IUserSession;
use OCP\Mail\IMailer;
+use OCP\Mail\Provider\Address;
+use OCP\Mail\Provider\Attachment;
+use OCP\Mail\Provider\IManager as IMailManager;
+use OCP\Mail\Provider\IMessageSend;
+use OCP\Util;
+use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Schedule\IMipPlugin as SabreIMipPlugin;
-use Sabre\DAV\Xml\Element\Prop;
+use Sabre\DAV;
+use Sabre\DAV\INode;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
-use Sabre\VObject\DateTimeParser;
use Sabre\VObject\ITip\Message;
use Sabre\VObject\Parameter;
-use Sabre\VObject\Property;
-use Sabre\VObject\Recur\EventIterator;
+use Sabre\VObject\Reader;
+
/**
* iMIP handler.
*
@@ -59,56 +46,47 @@ use Sabre\VObject\Recur\EventIterator;
*/
class IMipPlugin extends SabreIMipPlugin {
- /** @var string */
- private $userId;
-
- /** @var IConfig */
- private $config;
-
- /** @var IMailer */
- private $mailer;
-
- /** @var ILogger */
- private $logger;
-
- /** @var ITimeFactory */
- private $timeFactory;
-
- /** @var L10NFactory */
- private $l10nFactory;
-
- /** @var IURLGenerator */
- private $urlGenerator;
-
- /** @var Defaults */
- private $defaults;
-
- const MAX_DATE = '2038-01-01';
+ private ?VCalendar $vCalendar = null;
+ public const MAX_DATE = '2038-01-01';
+ public const METHOD_REQUEST = 'request';
+ public const METHOD_REPLY = 'reply';
+ public const METHOD_CANCEL = 'cancel';
+ public const IMIP_INDENT = 15;
+
+ public function __construct(
+ private IAppConfig $config,
+ private IMailer $mailer,
+ private LoggerInterface $logger,
+ private ITimeFactory $timeFactory,
+ private Defaults $defaults,
+ private IUserSession $userSession,
+ private IMipService $imipService,
+ private EventComparisonService $eventComparisonService,
+ private IMailManager $mailManager,
+ ) {
+ parent::__construct('');
+ }
- const METHOD_REQUEST = 'request';
- const METHOD_REPLY = 'reply';
- const METHOD_CANCEL = 'cancel';
+ public function initialize(DAV\Server $server): void {
+ parent::initialize($server);
+ $server->on('beforeWriteContent', [$this, 'beforeWriteContent'], 10);
+ }
/**
- * @param IConfig $config
- * @param IMailer $mailer
- * @param ILogger $logger
- * @param ITimeFactory $timeFactory
- * @param L10NFactory $l10nFactory
- * @param IUrlGenerator $urlGenerator
- * @param Defaults $defaults
- * @param string $userId
+ * Check quota before writing content
+ *
+ * @param string $uri target file URI
+ * @param INode $node Sabre Node
+ * @param resource $data data
+ * @param bool $modified modified
*/
- public function __construct(IConfig $config, IMailer $mailer, ILogger $logger, ITimeFactory $timeFactory, L10NFactory $l10nFactory, IURLGenerator $urlGenerator, Defaults $defaults, $userId) {
- parent::__construct('');
- $this->userId = $userId;
- $this->config = $config;
- $this->mailer = $mailer;
- $this->logger = $logger;
- $this->timeFactory = $timeFactory;
- $this->l10nFactory = $l10nFactory;
- $this->urlGenerator = $urlGenerator;
- $this->defaults = $defaults;
+ public function beforeWriteContent($uri, INode $node, $data, $modified): void {
+ if (!$node instanceof CalendarObject) {
+ return;
+ }
+ /** @var VCalendar $vCalendar */
+ $vCalendar = Reader::read($node->get());
+ $this->setVCalendar($vCalendar);
}
/**
@@ -119,8 +97,7 @@ class IMipPlugin extends SabreIMipPlugin {
*/
public function schedule(Message $iTipMessage) {
- // Not sending any emails if the system considers the update
- // insignificant.
+ // Not sending any emails if the system considers the update insignificant
if (!$iTipMessage->significantChange) {
if (!$iTipMessage->scheduleStatus) {
$iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email';
@@ -128,345 +105,234 @@ class IMipPlugin extends SabreIMipPlugin {
return;
}
- $summary = $iTipMessage->message->VEVENT->SUMMARY;
-
- if (parse_url($iTipMessage->sender, PHP_URL_SCHEME) !== 'mailto') {
- return;
- }
-
- if (parse_url($iTipMessage->recipient, PHP_URL_SCHEME) !== 'mailto') {
+ if (parse_url($iTipMessage->sender, PHP_URL_SCHEME) !== 'mailto'
+ || parse_url($iTipMessage->recipient, PHP_URL_SCHEME) !== 'mailto') {
return;
}
// don't send out mails for events that already took place
- if ($this->isEventInThePast($iTipMessage->message)) {
+ $lastOccurrence = $this->imipService->getLastOccurrence($iTipMessage->message);
+ $currentTime = $this->timeFactory->getTime();
+ if ($lastOccurrence < $currentTime) {
return;
}
// Strip off mailto:
- $sender = substr($iTipMessage->sender, 7);
$recipient = substr($iTipMessage->recipient, 7);
+ if (!$this->mailer->validateMailAddress($recipient)) {
+ // Nothing to send if the recipient doesn't have a valid email address
+ $iTipMessage->scheduleStatus = '5.0; EMail delivery failed';
+ return;
+ }
+ $recipientName = $iTipMessage->recipientName ? (string)$iTipMessage->recipientName : null;
+
+ $newEvents = $iTipMessage->message;
+ $oldEvents = $this->getVCalendar();
+
+ $modified = $this->eventComparisonService->findModified($newEvents, $oldEvents);
+ /** @var VEvent $vEvent */
+ $vEvent = array_pop($modified['new']);
+ /** @var VEvent $oldVevent */
+ $oldVevent = !empty($modified['old']) && is_array($modified['old']) ? array_pop($modified['old']) : null;
+ $isModified = isset($oldVevent);
+
+ // No changed events after all - this shouldn't happen if there is significant change yet here we are
+ // The scheduling status is debatable
+ if (empty($vEvent)) {
+ $this->logger->warning('iTip message said the change was significant but comparison did not detect any updated VEvents');
+ $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email';
+ return;
+ }
- $senderName = $iTipMessage->senderName ?: null;
- $recipientName = $iTipMessage->recipientName ?: null;
-
- /** @var VEvent $vevent */
- $vevent = $iTipMessage->message->VEVENT;
-
- $attendee = $this->getCurrentAttendee($iTipMessage);
- $defaultLang = $this->config->getUserValue($this->userId, 'core', 'lang', $this->l10nFactory->findLanguage());
- $lang = $this->getAttendeeLangOrDefault($defaultLang, $attendee);
- $l10n = $this->l10nFactory->get('dav', $lang);
-
- $meetingAttendeeName = $recipientName ?: $recipient;
- $meetingInviteeName = $senderName ?: $sender;
-
- $meetingTitle = $vevent->SUMMARY;
- $meetingDescription = $vevent->DESCRIPTION;
-
- $start = $vevent->DTSTART;
- if (isset($vevent->DTEND)) {
- $end = $vevent->DTEND;
- } elseif (isset($vevent->DURATION)) {
- $isFloating = $vevent->DTSTART->isFloating();
- $end = clone $vevent->DTSTART;
- $endDateTime = $end->getDateTime();
- $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue()));
- $end->setDateTime($endDateTime, $isFloating);
- } elseif (!$vevent->DTSTART->hasTime()) {
- $isFloating = $vevent->DTSTART->isFloating();
- $end = clone $vevent->DTSTART;
- $endDateTime = $end->getDateTime();
- $endDateTime = $endDateTime->modify('+1 day');
- $end->setDateTime($endDateTime, $isFloating);
+ // we (should) have one event component left
+ // as the ITip\Broker creates one iTip message per change
+ // and triggers the "schedule" event once per message
+ // we also might not have an old event as this could be a new
+ // invitation, or a new recurrence exception
+ $attendee = $this->imipService->getCurrentAttendee($iTipMessage);
+ if ($attendee === null) {
+ $uid = $vEvent->UID ?? 'no UID found';
+ $this->logger->debug('Could not find recipient ' . $recipient . ' as attendee for event with UID ' . $uid);
+ $iTipMessage->scheduleStatus = '5.0; EMail delivery failed';
+ return;
+ }
+ // Don't send emails to rooms, resources and circles
+ if ($this->imipService->isRoomOrResource($attendee)
+ || $this->imipService->isCircle($attendee)) {
+ $this->logger->debug('No invitation sent as recipient is room, resource or circle', [
+ 'attendee' => $recipient,
+ ]);
+ $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email';
+ return;
+ }
+ $this->imipService->setL10n($attendee);
+
+ // Build the sender name.
+ // Due to a bug in sabre, the senderName property for an iTIP message can actually also be a VObject Property
+ // If the iTIP message senderName is null or empty use the user session name as the senderName
+ if (($iTipMessage->senderName instanceof Parameter) && !empty(trim($iTipMessage->senderName->getValue()))) {
+ $senderName = trim($iTipMessage->senderName->getValue());
+ } elseif (is_string($iTipMessage->senderName) && !empty(trim($iTipMessage->senderName))) {
+ $senderName = trim($iTipMessage->senderName);
+ } elseif ($this->userSession->getUser() !== null) {
+ $senderName = trim($this->userSession->getUser()->getDisplayName());
} else {
- $end = clone $vevent->DTSTART;
+ $senderName = '';
}
- $meetingWhen = $this->generateWhenString($l10n, $start, $end);
-
- $meetingUrl = $vevent->URL;
- $meetingLocation = $vevent->LOCATION;
-
- $defaultVal = '--';
+ $sender = substr($iTipMessage->sender, 7);
- $method = self::METHOD_REQUEST;
+ $replyingAttendee = null;
switch (strtolower($iTipMessage->method)) {
case self::METHOD_REPLY:
$method = self::METHOD_REPLY;
+ $data = $this->imipService->buildReplyBodyData($vEvent);
+ $replyingAttendee = $this->imipService->getReplyingAttendee($iTipMessage);
break;
case self::METHOD_CANCEL:
$method = self::METHOD_CANCEL;
+ $data = $this->imipService->buildCancelledBodyData($vEvent);
+ break;
+ default:
+ $method = self::METHOD_REQUEST;
+ $data = $this->imipService->buildBodyData($vEvent, $oldVevent);
break;
}
- $data = array(
- 'attendee_name' => (string)$meetingAttendeeName ?: $defaultVal,
- 'invitee_name' => (string)$meetingInviteeName ?: $defaultVal,
- 'meeting_title' => (string)$meetingTitle ?: $defaultVal,
- 'meeting_description' => (string)$meetingDescription ?: $defaultVal,
- 'meeting_url' => (string)$meetingUrl ?: $defaultVal,
- );
-
- $fromEMail = \OCP\Util::getDefaultEmailAddress('invitations-noreply');
- $fromName = $l10n->t('%s via %s', [$senderName, $this->defaults->getName()]);
+ $data['attendee_name'] = ($recipientName ?: $recipient);
+ $data['invitee_name'] = ($senderName ?: $sender);
- $message = $this->mailer->createMessage()
- ->setFrom([$fromEMail => $fromName])
- ->setReplyTo([$sender => $senderName])
- ->setTo([$recipient => $recipientName]);
+ $fromEMail = Util::getDefaultEmailAddress('invitations-noreply');
+ $fromName = $this->imipService->getFrom($senderName, $this->defaults->getName());
$template = $this->mailer->createEMailTemplate('dav.calendarInvite.' . $method, $data);
$template->addHeader();
- $this->addSubjectAndHeading($template, $l10n, $method, $summary,
- $meetingAttendeeName, $meetingInviteeName);
- $this->addBulletList($template, $l10n, $meetingWhen, $meetingLocation,
- $meetingDescription, $meetingUrl);
+ $this->imipService->addSubjectAndHeading($template, $method, $data['invitee_name'], $data['meeting_title'], $isModified, $replyingAttendee);
+ $this->imipService->addBulletList($template, $vEvent, $data);
+
+ // Only add response buttons to invitation requests: Fix Issue #11230
+ if (strcasecmp($method, self::METHOD_REQUEST) === 0 && $this->imipService->getAttendeeRsvpOrReqForParticipant($attendee)) {
+
+ /*
+ ** Only offer invitation accept/reject buttons, which link back to the
+ ** nextcloud server, to recipients who can access the nextcloud server via
+ ** their internet/intranet. Issue #12156
+ **
+ ** The app setting is stored in the appconfig database table.
+ **
+ ** For nextcloud servers accessible to the public internet, the default
+ ** "invitation_link_recipients" value "yes" (all recipients) is appropriate.
+ **
+ ** When the nextcloud server is restricted behind a firewall, accessible
+ ** only via an internal network or via vpn, you can set "dav.invitation_link_recipients"
+ ** to the email address or email domain, or comma separated list of addresses or domains,
+ ** of recipients who can access the server.
+ **
+ ** To always deliver URLs, set invitation_link_recipients to "yes".
+ ** To suppress URLs entirely, set invitation_link_recipients to boolean "no".
+ */
+
+ $recipientDomain = substr(strrchr($recipient, '@'), 1);
+ $invitationLinkRecipients = explode(',', preg_replace('/\s+/', '', strtolower($this->config->getValueString('dav', 'invitation_link_recipients', 'yes'))));
+
+ if (strcmp('yes', $invitationLinkRecipients[0]) === 0
+ || in_array(strtolower($recipient), $invitationLinkRecipients)
+ || in_array(strtolower($recipientDomain), $invitationLinkRecipients)) {
+ $token = $this->imipService->createInvitationToken($iTipMessage, $vEvent, $lastOccurrence);
+ $this->imipService->addResponseButtons($template, $token);
+ $this->imipService->addMoreOptionsButton($template, $token);
+ }
+ }
$template->addFooter();
- $message->useTemplate($template);
+ // convert iTip Message to string
+ $itip_msg = $iTipMessage->message->serialize();
- $attachment = $this->mailer->createAttachment(
- $iTipMessage->message->serialize(),
- 'event.ics',// TODO(leon): Make file name unique, e.g. add event id
- 'text/calendar; method=' . $iTipMessage->method
- );
- $message->attach($attachment);
+ $mailService = null;
try {
- $failed = $this->mailer->send($message);
- $iTipMessage->scheduleStatus = '1.1; Scheduling message is sent via iMip';
- if ($failed) {
- $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]);
- $iTipMessage->scheduleStatus = '5.0; EMail delivery failed';
- }
- } catch(\Exception $ex) {
- $this->logger->logException($ex, ['app' => 'dav']);
- $iTipMessage->scheduleStatus = '5.0; EMail delivery failed';
- }
- }
-
- /**
- * check if event took place in the past already
- * @param VCalendar $vObject
- * @return bool
- */
- private function isEventInThePast(VCalendar $vObject) {
- /** @var VEvent $component */
- $component = $vObject->VEVENT;
-
- $firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp();
- // Finding the last occurrence is a bit harder
- if (!isset($component->RRULE)) {
- if (isset($component->DTEND)) {
- $lastOccurrence = $component->DTEND->getDateTime()->getTimeStamp();
- } elseif (isset($component->DURATION)) {
- /** @var \DateTime $endDate */
- $endDate = clone $component->DTSTART->getDateTime();
- // $component->DTEND->getDateTime() returns DateTimeImmutable
- $endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue()));
- $lastOccurrence = $endDate->getTimestamp();
- } elseif (!$component->DTSTART->hasTime()) {
- /** @var \DateTime $endDate */
- $endDate = clone $component->DTSTART->getDateTime();
- // $component->DTSTART->getDateTime() returns DateTimeImmutable
- $endDate = $endDate->modify('+1 day');
- $lastOccurrence = $endDate->getTimestamp();
- } else {
- $lastOccurrence = $firstOccurrence;
- }
- } else {
- $it = new EventIterator($vObject, (string)$component->UID);
- $maxDate = new \DateTime(self::MAX_DATE);
- if ($it->isInfinite()) {
- $lastOccurrence = $maxDate->getTimestamp();
- } else {
- $end = $it->getDtEnd();
- while($it->valid() && $end < $maxDate) {
- $end = $it->getDtEnd();
- $it->next();
-
+ if ($this->config->getValueBool('core', 'mail_providers_enabled', true)) {
+ // retrieve user object
+ $user = $this->userSession->getUser();
+ if ($user !== null) {
+ // retrieve appropriate service with the same address as sender
+ $mailService = $this->mailManager->findServiceByAddress($user->getUID(), $sender);
}
- $lastOccurrence = $end->getTimestamp();
}
- }
-
- $currentTime = $this->timeFactory->getTime();
- return $lastOccurrence < $currentTime;
- }
-
-
- /**
- * @param Message $iTipMessage
- * @return null|Property
- */
- private function getCurrentAttendee(Message $iTipMessage) {
- /** @var VEvent $vevent */
- $vevent = $iTipMessage->message->VEVENT;
- $attendees = $vevent->select('ATTENDEE');
- foreach ($attendees as $attendee) {
- /** @var Property $attendee */
- if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) {
- return $attendee;
- }
- }
- return null;
- }
-
- /**
- * @param string $default
- * @param Property|null $attendee
- * @return string
- */
- private function getAttendeeLangOrDefault($default, Property $attendee = null) {
- if ($attendee !== null) {
- $lang = $attendee->offsetGet('LANGUAGE');
- if ($lang instanceof Parameter) {
- return $lang->getValue();
- }
- }
- return $default;
- }
-
- /**
- * @param IL10N $l10n
- * @param Property $dtstart
- * @param Property $dtend
- */
- private function generateWhenString(IL10N $l10n, Property $dtstart, Property $dtend) {
- $isAllDay = $dtstart instanceof Property\ICalendar\Date;
-
- /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */
- /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */
- /** @var \DateTimeImmutable $dtstartDt */
- $dtstartDt = $dtstart->getDateTime();
- /** @var \DateTimeImmutable $dtendDt */
- $dtendDt = $dtend->getDateTime();
-
- $diff = $dtstartDt->diff($dtendDt);
-
- $dtstartDt = new \DateTime($dtstartDt->format(\DateTime::ATOM));
- $dtendDt = new \DateTime($dtendDt->format(\DateTime::ATOM));
-
- if ($isAllDay) {
- // One day event
- if ($diff->days === 1) {
- return $l10n->l('date', $dtstartDt, ['width' => 'medium']);
- }
-
- //event that spans over multiple days
- $localeStart = $l10n->l('date', $dtstartDt, ['width' => 'medium']);
- $localeEnd = $l10n->l('date', $dtendDt, ['width' => 'medium']);
- return $localeStart . ' - ' . $localeEnd;
- }
-
- /** @var Property\ICalendar\DateTime $dtstart */
- /** @var Property\ICalendar\DateTime $dtend */
- $isFloating = $dtstart->isFloating();
- $startTimezone = $endTimezone = null;
- if (!$isFloating) {
- $prop = $dtstart->offsetGet('TZID');
- if ($prop instanceof Parameter) {
- $startTimezone = $prop->getValue();
+ // The display name in Nextcloud can use utf-8.
+ // As the default charset for text/* is us-ascii, it's important to explicitly define it.
+ // See https://www.rfc-editor.org/rfc/rfc6047.html#section-2.4.
+ $contentType = 'text/calendar; method=' . $iTipMessage->method . '; charset="utf-8"';
+
+ // evaluate if a mail service was found and has sending capabilities
+ if ($mailService instanceof IMessageSend) {
+ // construct mail message and set required parameters
+ $message = $mailService->initiateMessage();
+ $message->setFrom(
+ (new Address($sender, $fromName))
+ );
+ $message->setTo(
+ (new Address($recipient, $recipientName))
+ );
+ $message->setSubject($template->renderSubject());
+ $message->setBodyPlain($template->renderText());
+ $message->setBodyHtml($template->renderHtml());
+ // Adding name=event.ics is a trick to make the invitation also appear
+ // as a file attachment in mail clients like Thunderbird or Evolution.
+ $message->setAttachments((new Attachment(
+ $itip_msg,
+ null,
+ $contentType . '; name=event.ics',
+ true
+ )));
+ // send message
+ $mailService->sendMessage($message);
+ } else {
+ // construct symfony mailer message and set required parameters
+ $message = $this->mailer->createMessage();
+ $message->setFrom([$fromEMail => $fromName]);
+ $message->setTo(
+ (($recipientName !== null) ? [$recipient => $recipientName] : [$recipient])
+ );
+ $message->setReplyTo(
+ (($senderName !== null) ? [$sender => $senderName] : [$sender])
+ );
+ $message->useTemplate($template);
+ // Using a different content type because Symfony Mailer/Mime will append the name to
+ // the content type header and attachInline does not allow null.
+ $message->attachInline(
+ $itip_msg,
+ 'event.ics',
+ $contentType,
+ );
+ $failed = $this->mailer->send($message);
}
- $prop = $dtend->offsetGet('TZID');
- if ($prop instanceof Parameter) {
- $endTimezone = $prop->getValue();
+ $iTipMessage->scheduleStatus = '1.1; Scheduling message is sent via iMip';
+ if (!empty($failed)) {
+ $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]);
+ $iTipMessage->scheduleStatus = '5.0; EMail delivery failed';
}
+ } catch (\Exception $ex) {
+ $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]);
+ $iTipMessage->scheduleStatus = '5.0; EMail delivery failed';
}
-
- $localeStart = $l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']) . ', ' .
- $l10n->l('datetime', $dtstartDt, ['width' => 'medium|short']);
-
- // always show full date with timezone if timezones are different
- if ($startTimezone !== $endTimezone) {
- $localeEnd = $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']);
-
- return $localeStart . ' (' . $startTimezone . ') - ' .
- $localeEnd . ' (' . $endTimezone . ')';
- }
-
- // show only end time if date is the same
- if ($this->isDayEqual($dtstartDt, $dtendDt)) {
- $localeEnd = $l10n->l('time', $dtendDt, ['width' => 'short']);
- } else {
- $localeEnd = $l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']) . ', ' .
- $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']);
- }
-
- return $localeStart . ' - ' . $localeEnd . ' (' . $startTimezone . ')';
}
/**
- * @param \DateTime $dtStart
- * @param \DateTime $dtEnd
- * @return bool
+ * @return ?VCalendar
*/
- private function isDayEqual(\DateTime $dtStart, \DateTime $dtEnd) {
- return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d');
+ public function getVCalendar(): ?VCalendar {
+ return $this->vCalendar;
}
/**
- * @param IEMailTemplate $template
- * @param IL10N $l10n
- * @param string $method
- * @param string $summary
- * @param string $attendeeName
- * @param string $inviteeName
+ * @param ?VCalendar $vCalendar
*/
- private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n,
- $method, $summary, $attendeeName, $inviteeName) {
- if ($method === self::METHOD_CANCEL) {
- $template->setSubject('Cancelled: ' . $summary);
- $template->addHeading($l10n->t('Invitation canceled'), $l10n->t('Hello %s,', [$attendeeName]));
- $template->addBodyText($l10n->t('The meeting »%s« with %s was canceled.', [$summary, $inviteeName]));
- } else if ($method === self::METHOD_REPLY) {
- $template->setSubject('Re: ' . $summary);
- $template->addHeading($l10n->t('Invitation updated'), $l10n->t('Hello %s,', [$attendeeName]));
- $template->addBodyText($l10n->t('The meeting »%s« with %s was updated.', [$summary, $inviteeName]));
- } else {
- $template->setSubject('Invitation: ' . $summary);
- $template->addHeading($l10n->t('%s invited you to »%s«', [$inviteeName, $summary]), $l10n->t('Hello %s,', [$attendeeName]));
- }
-
- }
-
- /**
- * @param IEMailTemplate $template
- * @param IL10N $l10n
- * @param string $time
- * @param string $location
- * @param string $description
- * @param string $url
- */
- private function addBulletList(IEMailTemplate $template, IL10N $l10n, $time, $location, $description, $url) {
- $template->addBodyListItem($time, $l10n->t('When:'),
- $this->getAbsoluteImagePath('filetypes/text-calendar.svg'));
-
- if ($location) {
- $template->addBodyListItem($location, $l10n->t('Where:'),
- $this->getAbsoluteImagePath('filetypes/location.svg'));
- }
- if ($description) {
- $template->addBodyListItem((string)$description, $l10n->t('Description:'),
- $this->getAbsoluteImagePath('filetypes/text.svg'));
- }
- if ($url) {
- $template->addBodyListItem((string)$url, $l10n->t('Link:'),
- $this->getAbsoluteImagePath('filetypes/link.svg'));
- }
+ public function setVCalendar(?VCalendar $vCalendar): void {
+ $this->vCalendar = $vCalendar;
}
- /**
- * @param string $path
- * @return string
- */
- private function getAbsoluteImagePath($path) {
- return $this->urlGenerator->getAbsoluteURL(
- $this->urlGenerator->imagePath('core', $path)
- );
- }
}
diff --git a/apps/dav/lib/CalDAV/Schedule/IMipService.php b/apps/dav/lib/CalDAV/Schedule/IMipService.php
new file mode 100644
index 00000000000..54c0bc31849
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Schedule/IMipService.php
@@ -0,0 +1,1294 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV\Schedule;
+
+use OC\URLGenerator;
+use OCA\DAV\CalDAV\EventReader;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\IConfig;
+use OCP\IDBConnection;
+use OCP\IL10N;
+use OCP\L10N\IFactory as L10NFactory;
+use OCP\Mail\IEMailTemplate;
+use OCP\Security\ISecureRandom;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\DateTimeParser;
+use Sabre\VObject\ITip\Message;
+use Sabre\VObject\Parameter;
+use Sabre\VObject\Property;
+use Sabre\VObject\Recur\EventIterator;
+
+class IMipService {
+
+ private IL10N $l10n;
+
+ /** @var string[] */
+ private const STRING_DIFF = [
+ 'meeting_title' => 'SUMMARY',
+ 'meeting_description' => 'DESCRIPTION',
+ 'meeting_url' => 'URL',
+ 'meeting_location' => 'LOCATION'
+ ];
+
+ public function __construct(
+ private URLGenerator $urlGenerator,
+ private IConfig $config,
+ private IDBConnection $db,
+ private ISecureRandom $random,
+ private L10NFactory $l10nFactory,
+ private ITimeFactory $timeFactory,
+ ) {
+ $language = $this->l10nFactory->findGenericLanguage();
+ $locale = $this->l10nFactory->findLocale($language);
+ $this->l10n = $this->l10nFactory->get('dav', $language, $locale);
+ }
+
+ /**
+ * @param string|null $senderName
+ * @param string $default
+ * @return string
+ */
+ public function getFrom(?string $senderName, string $default): string {
+ if ($senderName === null) {
+ return $default;
+ }
+
+ return $this->l10n->t('%1$s via %2$s', [$senderName, $default]);
+ }
+
+ public static function readPropertyWithDefault(VEvent $vevent, string $property, string $default) {
+ if (isset($vevent->$property)) {
+ $value = $vevent->$property->getValue();
+ if (!empty($value)) {
+ return $value;
+ }
+ }
+ return $default;
+ }
+
+ private function generateDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
+ $strikethrough = "<span style='text-decoration: line-through'>%s</span><br />%s";
+ if (!isset($vevent->$property)) {
+ return $default;
+ }
+ $newstring = $vevent->$property->getValue();
+ if (isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newstring) {
+ $oldstring = $oldVEvent->$property->getValue();
+ return sprintf($strikethrough, $oldstring, $newstring);
+ }
+ return $newstring;
+ }
+
+ /**
+ * Like generateDiffString() but linkifies the property values if they are urls.
+ */
+ private function generateLinkifiedDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string {
+ if (!isset($vevent->$property)) {
+ return $default;
+ }
+ /** @var string|null $newString */
+ $newString = $vevent->$property->getValue();
+ $oldString = isset($oldVEvent->$property) ? $oldVEvent->$property->getValue() : null;
+ if ($oldString !== $newString) {
+ return sprintf(
+ "<span style='text-decoration: line-through'>%s</span><br />%s",
+ $this->linkify($oldString) ?? $oldString ?? '',
+ $this->linkify($newString) ?? $newString ?? ''
+ );
+ }
+ return $this->linkify($newString) ?? $newString;
+ }
+
+ /**
+ * Convert a given url to a html link element or return null otherwise.
+ */
+ private function linkify(?string $url): ?string {
+ if ($url === null) {
+ return null;
+ }
+ if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) {
+ return null;
+ }
+
+ return sprintf('<a href="%1$s">%1$s</a>', htmlspecialchars($url));
+ }
+
+ /**
+ * @param VEvent $vEvent
+ * @param VEvent|null $oldVEvent
+ * @return array
+ */
+ public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array {
+
+ // construct event reader
+ $eventReaderCurrent = new EventReader($vEvent);
+ $eventReaderPrevious = !empty($oldVEvent) ? new EventReader($oldVEvent) : null;
+ $defaultVal = '';
+ $data = [];
+ $data['meeting_when'] = $this->generateWhenString($eventReaderCurrent);
+
+ foreach (self::STRING_DIFF as $key => $property) {
+ $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
+ }
+
+ $data['meeting_url_html'] = self::readPropertyWithDefault($vEvent, 'URL', $defaultVal);
+
+ if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
+ $data['meeting_location_html'] = $locationHtml;
+ }
+
+ if (!empty($oldVEvent)) {
+ $oldMeetingWhen = $this->generateWhenString($eventReaderPrevious);
+ $data['meeting_title_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'SUMMARY', $data['meeting_title']);
+ $data['meeting_description_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'DESCRIPTION', $data['meeting_description']);
+ $data['meeting_location_html'] = $this->generateLinkifiedDiffString($vEvent, $oldVEvent, 'LOCATION', $data['meeting_location']);
+
+ $oldUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal);
+ $data['meeting_url_html'] = !empty($oldUrl) && $oldUrl !== $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $oldUrl) : $data['meeting_url'];
+
+ $data['meeting_when_html'] = $oldMeetingWhen !== $data['meeting_when'] ? sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", $oldMeetingWhen, $data['meeting_when']) : $data['meeting_when'];
+ }
+ // generate occurring next string
+ if ($eventReaderCurrent->recurs()) {
+ $data['meeting_occurring'] = $this->generateOccurringString($eventReaderCurrent);
+ }
+ return $data;
+ }
+
+ /**
+ * @param VEvent $vEvent
+ * @return array
+ */
+ public function buildReplyBodyData(VEvent $vEvent): array {
+ // construct event reader
+ $eventReader = new EventReader($vEvent);
+ $defaultVal = '';
+ $data = [];
+ $data['meeting_when'] = $this->generateWhenString($eventReader);
+
+ foreach (self::STRING_DIFF as $key => $property) {
+ $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal);
+ }
+
+ if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) {
+ $data['meeting_location_html'] = $locationHtml;
+ }
+
+ $data['meeting_url_html'] = $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $data['meeting_url']) : '';
+
+ // generate occurring next string
+ if ($eventReader->recurs()) {
+ $data['meeting_occurring'] = $this->generateOccurringString($eventReader);
+ }
+
+ return $data;
+ }
+
+ /**
+ * generates a when string based on if a event has an recurrence or not
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenString(EventReader $er): string {
+ return match ($er->recurs()) {
+ true => $this->generateWhenStringRecurring($er),
+ false => $this->generateWhenStringSingular($er)
+ };
+ }
+
+ /**
+ * generates a when string for a non recurring event
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringSingular(EventReader $er): string {
+ // initialize
+ $startTime = null;
+ $endTime = null;
+ // calculate time difference from now to start of event
+ $occurring = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
+ // extract start date
+ $startDate = $this->l10n->l('date', $er->startDateTime(), ['width' => 'full']);
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order:
+ // In a minute/hour/day/week/month/year on July 1, 2024 for the entire day
+ // In a minute/hour/day/week/month/year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
+ // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 for the entire day
+ // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto)
+ return match ([$occurring['scale'], $endTime !== null]) {
+ ['past', false] => $this->l10n->t(
+ 'In the past on %1$s for the entire day',
+ [$startDate]
+ ),
+ ['minute', false] => $this->l10n->n(
+ 'In a minute on %1$s for the entire day',
+ 'In %n minutes on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['hour', false] => $this->l10n->n(
+ 'In a hour on %1$s for the entire day',
+ 'In %n hours on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['day', false] => $this->l10n->n(
+ 'In a day on %1$s for the entire day',
+ 'In %n days on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['week', false] => $this->l10n->n(
+ 'In a week on %1$s for the entire day',
+ 'In %n weeks on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['month', false] => $this->l10n->n(
+ 'In a month on %1$s for the entire day',
+ 'In %n months on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['year', false] => $this->l10n->n(
+ 'In a year on %1$s for the entire day',
+ 'In %n years on %1$s for the entire day',
+ $occurring['interval'],
+ [$startDate]
+ ),
+ ['past', true] => $this->l10n->t(
+ 'In the past on %1$s between %2$s - %3$s',
+ [$startDate, $startTime, $endTime]
+ ),
+ ['minute', true] => $this->l10n->n(
+ 'In a minute on %1$s between %2$s - %3$s',
+ 'In %n minutes on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ ['hour', true] => $this->l10n->n(
+ 'In a hour on %1$s between %2$s - %3$s',
+ 'In %n hours on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ ['day', true] => $this->l10n->n(
+ 'In a day on %1$s between %2$s - %3$s',
+ 'In %n days on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ ['week', true] => $this->l10n->n(
+ 'In a week on %1$s between %2$s - %3$s',
+ 'In %n weeks on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ ['month', true] => $this->l10n->n(
+ 'In a month on %1$s between %2$s - %3$s',
+ 'In %n months on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ ['year', true] => $this->l10n->n(
+ 'In a year on %1$s between %2$s - %3$s',
+ 'In %n years on %1$s between %2$s - %3$s',
+ $occurring['interval'],
+ [$startDate, $startTime, $endTime]
+ ),
+ default => $this->l10n->t('Could not generate when statement')
+ };
+ }
+
+ /**
+ * generates a when string based on recurrence precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurring(EventReader $er): string {
+ return match ($er->recurringPrecision()) {
+ 'daily' => $this->generateWhenStringRecurringDaily($er),
+ 'weekly' => $this->generateWhenStringRecurringWeekly($er),
+ 'monthly' => $this->generateWhenStringRecurringMonthly($er),
+ 'yearly' => $this->generateWhenStringRecurringYearly($er),
+ 'fixed' => $this->generateWhenStringRecurringFixed($er),
+ };
+ }
+
+ /**
+ * generates a when string for a daily precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurringDaily(EventReader $er): string {
+
+ // initialize
+ $interval = (int)$er->recurringInterval();
+ $startTime = null;
+ $conclusion = null;
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // conclusion
+ if ($er->recurringConcludes()) {
+ $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order:
+ // Every Day for the entire day
+ // Every Day for the entire day until July 13, 2024
+ // Every Day between 8:00 AM - 9:00 AM (America/Toronto)
+ // Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
+ // Every 3 Days for the entire day
+ // Every 3 Days for the entire day until July 13, 2024
+ // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto)
+ // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
+ return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
+ [false, false, false] => $this->l10n->t('Every Day for the entire day'),
+ [false, false, true] => $this->l10n->t('Every Day for the entire day until %1$s', [$conclusion]),
+ [false, true, false] => $this->l10n->t('Every Day between %1$s - %2$s', [$startTime, $endTime]),
+ [false, true, true] => $this->l10n->t('Every Day between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
+ [true, false, false] => $this->l10n->t('Every %1$d Days for the entire day', [$interval]),
+ [true, false, true] => $this->l10n->t('Every %1$d Days for the entire day until %2$s', [$interval, $conclusion]),
+ [true, true, false] => $this->l10n->t('Every %1$d Days between %2$s - %3$s', [$interval, $startTime, $endTime]),
+ [true, true, true] => $this->l10n->t('Every %1$d Days between %2$s - %3$s until %4$s', [$interval, $startTime, $endTime, $conclusion]),
+ default => $this->l10n->t('Could not generate event recurrence statement')
+ };
+
+ }
+
+ /**
+ * generates a when string for a weekly precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurringWeekly(EventReader $er): string {
+
+ // initialize
+ $interval = (int)$er->recurringInterval();
+ $startTime = null;
+ $conclusion = null;
+ // days of the week
+ $days = implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // conclusion
+ if ($er->recurringConcludes()) {
+ $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order:
+ // Every Week on Monday, Wednesday, Friday for the entire day
+ // Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024
+ // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
+ // Every 2 Weeks on Monday, Wednesday, Friday for the entire day
+ // Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024
+ // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
+ return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
+ [false, false, false] => $this->l10n->t('Every Week on %1$s for the entire day', [$days]),
+ [false, false, true] => $this->l10n->t('Every Week on %1$s for the entire day until %2$s', [$days, $conclusion]),
+ [false, true, false] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
+ [false, true, true] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
+ [true, false, false] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day', [$interval, $days]),
+ [true, false, true] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
+ [true, true, false] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
+ [true, true, true] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]),
+ default => $this->l10n->t('Could not generate event recurrence statement')
+ };
+
+ }
+
+ /**
+ * generates a when string for a monthly precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurringMonthly(EventReader $er): string {
+
+ // initialize
+ $interval = (int)$er->recurringInterval();
+ $startTime = null;
+ $conclusion = null;
+ // days of month
+ if ($er->recurringPattern() === 'R') {
+ $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
+ . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
+ } else {
+ $days = implode(', ', $er->recurringDaysOfMonth());
+ }
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // conclusion
+ if ($er->recurringConcludes()) {
+ $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order, output varies depending on if the event is absolute or releative:
+ // Absolute: Every Month on the 1, 8 for the entire day
+ // Relative: Every Month on the First Sunday, Saturday for the entire day
+ // Absolute: Every Month on the 1, 8 for the entire day until December 31, 2024
+ // Relative: Every Month on the First Sunday, Saturday for the entire day until December 31, 2024
+ // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
+ // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
+ // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
+ // Absolute: Every 2 Months on the 1, 8 for the entire day
+ // Relative: Every 2 Months on the First Sunday, Saturday for the entire day
+ // Absolute: Every 2 Months on the 1, 8 for the entire day until December 31, 2024
+ // Relative: Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024
+ // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto)
+ // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
+ // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024
+ return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
+ [false, false, false] => $this->l10n->t('Every Month on the %1$s for the entire day', [$days]),
+ [false, false, true] => $this->l10n->t('Every Month on the %1$s for the entire day until %2$s', [$days, $conclusion]),
+ [false, true, false] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s', [$days, $startTime, $endTime]),
+ [false, true, true] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]),
+ [true, false, false] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day', [$interval, $days]),
+ [true, false, true] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day until %3$s', [$interval, $days, $conclusion]),
+ [true, true, false] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]),
+ [true, true, true] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]),
+ default => $this->l10n->t('Could not generate event recurrence statement')
+ };
+ }
+
+ /**
+ * generates a when string for a yearly precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurringYearly(EventReader $er): string {
+
+ // initialize
+ $interval = (int)$er->recurringInterval();
+ $startTime = null;
+ $conclusion = null;
+ // months of year
+ $months = implode(', ', array_map(function ($value) { return $this->localizeMonthName($value); }, $er->recurringMonthsOfYearNamed()));
+ // days of month
+ if ($er->recurringPattern() === 'R') {
+ $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' '
+ . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed()));
+ } else {
+ $days = $er->startDateTime()->format('jS');
+ }
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // conclusion
+ if ($er->recurringConcludes()) {
+ $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order, output varies depending on if the event is absolute or releative:
+ // Absolute: Every Year in July on the 1st for the entire day
+ // Relative: Every Year in July on the First Sunday, Saturday for the entire day
+ // Absolute: Every Year in July on the 1st for the entire day until July 31, 2026
+ // Relative: Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026
+ // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
+ // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
+ // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
+ // Absolute: Every 2 Years in July on the 1st for the entire day
+ // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day
+ // Absolute: Every 2 Years in July on the 1st for the entire day until July 31, 2026
+ // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026
+ // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto)
+ // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto)
+ // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
+ // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026
+ return match ([($interval > 1), $startTime !== null, $conclusion !== null]) {
+ [false, false, false] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day', [$months, $days]),
+ [false, false, true] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day until %3$s', [$months, $days, $conclusion]),
+ [false, true, false] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s', [$months, $days, $startTime, $endTime]),
+ [false, true, true] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s until %5$s', [$months, $days, $startTime, $endTime, $conclusion]),
+ [true, false, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day', [$interval, $months, $days]),
+ [true, false, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day until %4$s', [$interval, $months, $days, $conclusion]),
+ [true, true, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s', [$interval, $months, $days, $startTime, $endTime]),
+ [true, true, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s until %6$s', [$interval, $months, $days, $startTime, $endTime, $conclusion]),
+ default => $this->l10n->t('Could not generate event recurrence statement')
+ };
+ }
+
+ /**
+ * generates a when string for a fixed precision/frequency
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateWhenStringRecurringFixed(EventReader $er): string {
+ // initialize
+ $startTime = null;
+ $conclusion = null;
+ // time of the day
+ if (!$er->entireDay()) {
+ $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']);
+ $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : '';
+ $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')';
+ }
+ // conclusion
+ $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']);
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order:
+ // On specific dates for the entire day until July 13, 2024
+ // On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024
+ return match ($startTime !== null) {
+ false => $this->l10n->t('On specific dates for the entire day until %1$s', [$conclusion]),
+ true => $this->l10n->t('On specific dates between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]),
+ };
+ }
+
+ /**
+ * generates a occurring next string for a recurring event
+ *
+ * @since 30.0.0
+ *
+ * @param EventReader $er
+ *
+ * @return string
+ */
+ public function generateOccurringString(EventReader $er): string {
+
+ // initialize
+ $occurrence = null;
+ $occurrence2 = null;
+ $occurrence3 = null;
+ // reset to initial occurrence
+ $er->recurrenceRewind();
+ // forward to current date
+ $er->recurrenceAdvanceTo($this->timeFactory->getDateTime());
+ // calculate time difference from now to start of next event occurrence and minimize it
+ $occurrenceIn = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate()));
+ // store next occurrence value
+ $occurrence = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
+ // forward one occurrence
+ $er->recurrenceAdvance();
+ // evaluate if occurrence is valid
+ if ($er->recurrenceDate() !== null) {
+ // store following occurrence value
+ $occurrence2 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
+ // forward one occurrence
+ $er->recurrenceAdvance();
+ // evaluate if occurrence is valid
+ if ($er->recurrenceDate()) {
+ // store following occurrence value
+ $occurrence3 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']);
+ }
+ }
+ // generate localized when string
+ // TRANSLATORS
+ // Indicates when a calendar event will happen, shown on invitation emails
+ // Output produced in order:
+ // In a minute/hour/day/week/month/year on July 1, 2024
+ // In a minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024
+ // In a minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024 and July 5, 2024
+ // In 2 minutes/hours/days/weeks/months/years on July 1, 2024
+ // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024
+ // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024 and July 5, 2024
+ return match ([$occurrenceIn['scale'], $occurrence2 !== null, $occurrence3 !== null]) {
+ ['past', false, false] => $this->l10n->t(
+ 'In the past on %1$s',
+ [$occurrence]
+ ),
+ ['minute', false, false] => $this->l10n->n(
+ 'In a minute on %1$s',
+ 'In %n minutes on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['hour', false, false] => $this->l10n->n(
+ 'In a hour on %1$s',
+ 'In %n hours on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['day', false, false] => $this->l10n->n(
+ 'In a day on %1$s',
+ 'In %n days on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['week', false, false] => $this->l10n->n(
+ 'In a week on %1$s',
+ 'In %n weeks on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['month', false, false] => $this->l10n->n(
+ 'In a month on %1$s',
+ 'In %n months on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['year', false, false] => $this->l10n->n(
+ 'In a year on %1$s',
+ 'In %n years on %1$s',
+ $occurrenceIn['interval'],
+ [$occurrence]
+ ),
+ ['past', true, false] => $this->l10n->t(
+ 'In the past on %1$s then on %2$s',
+ [$occurrence, $occurrence2]
+ ),
+ ['minute', true, false] => $this->l10n->n(
+ 'In a minute on %1$s then on %2$s',
+ 'In %n minutes on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['hour', true, false] => $this->l10n->n(
+ 'In a hour on %1$s then on %2$s',
+ 'In %n hours on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['day', true, false] => $this->l10n->n(
+ 'In a day on %1$s then on %2$s',
+ 'In %n days on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['week', true, false] => $this->l10n->n(
+ 'In a week on %1$s then on %2$s',
+ 'In %n weeks on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['month', true, false] => $this->l10n->n(
+ 'In a month on %1$s then on %2$s',
+ 'In %n months on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['year', true, false] => $this->l10n->n(
+ 'In a year on %1$s then on %2$s',
+ 'In %n years on %1$s then on %2$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2]
+ ),
+ ['past', true, true] => $this->l10n->t(
+ 'In the past on %1$s then on %2$s and %3$s',
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['minute', true, true] => $this->l10n->n(
+ 'In a minute on %1$s then on %2$s and %3$s',
+ 'In %n minutes on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['hour', true, true] => $this->l10n->n(
+ 'In a hour on %1$s then on %2$s and %3$s',
+ 'In %n hours on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['day', true, true] => $this->l10n->n(
+ 'In a day on %1$s then on %2$s and %3$s',
+ 'In %n days on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['week', true, true] => $this->l10n->n(
+ 'In a week on %1$s then on %2$s and %3$s',
+ 'In %n weeks on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['month', true, true] => $this->l10n->n(
+ 'In a month on %1$s then on %2$s and %3$s',
+ 'In %n months on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ ['year', true, true] => $this->l10n->n(
+ 'In a year on %1$s then on %2$s and %3$s',
+ 'In %n years on %1$s then on %2$s and %3$s',
+ $occurrenceIn['interval'],
+ [$occurrence, $occurrence2, $occurrence3]
+ ),
+ default => $this->l10n->t('Could not generate next recurrence statement')
+ };
+
+ }
+
+ /**
+ * @param VEvent $vEvent
+ * @return array
+ */
+ public function buildCancelledBodyData(VEvent $vEvent): array {
+ // construct event reader
+ $eventReaderCurrent = new EventReader($vEvent);
+ $defaultVal = '';
+ $strikethrough = "<span style='text-decoration: line-through'>%s</span>";
+
+ $newMeetingWhen = $this->generateWhenString($eventReaderCurrent);
+ $newSummary = isset($vEvent->SUMMARY) && (string)$vEvent->SUMMARY !== '' ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event');
+ $newDescription = isset($vEvent->DESCRIPTION) && (string)$vEvent->DESCRIPTION !== '' ? (string)$vEvent->DESCRIPTION : $defaultVal;
+ $newUrl = isset($vEvent->URL) && (string)$vEvent->URL !== '' ? sprintf('<a href="%1$s">%1$s</a>', $vEvent->URL) : $defaultVal;
+ $newLocation = isset($vEvent->LOCATION) && (string)$vEvent->LOCATION !== '' ? (string)$vEvent->LOCATION : $defaultVal;
+ $newLocationHtml = $this->linkify($newLocation) ?? $newLocation;
+
+ $data = [];
+ $data['meeting_when_html'] = $newMeetingWhen === '' ?: sprintf($strikethrough, $newMeetingWhen);
+ $data['meeting_when'] = $newMeetingWhen;
+ $data['meeting_title_html'] = sprintf($strikethrough, $newSummary);
+ $data['meeting_title'] = $newSummary !== '' ? $newSummary: $this->l10n->t('Untitled event');
+ $data['meeting_description_html'] = $newDescription !== '' ? sprintf($strikethrough, $newDescription) : '';
+ $data['meeting_description'] = $newDescription;
+ $data['meeting_url_html'] = $newUrl !== '' ? sprintf($strikethrough, $newUrl) : '';
+ $data['meeting_url'] = isset($vEvent->URL) ? (string)$vEvent->URL : '';
+ $data['meeting_location_html'] = $newLocationHtml !== '' ? sprintf($strikethrough, $newLocationHtml) : '';
+ $data['meeting_location'] = $newLocation;
+ return $data;
+ }
+
+ /**
+ * Check if event took place in the past
+ *
+ * @param VCalendar $vObject
+ * @return int
+ */
+ public function getLastOccurrence(VCalendar $vObject) {
+ /** @var VEvent $component */
+ $component = $vObject->VEVENT;
+
+ if (isset($component->RRULE)) {
+ $it = new EventIterator($vObject, (string)$component->UID);
+ $maxDate = new \DateTime(IMipPlugin::MAX_DATE);
+ if ($it->isInfinite()) {
+ return $maxDate->getTimestamp();
+ }
+
+ $end = $it->getDtEnd();
+ while ($it->valid() && $end < $maxDate) {
+ $end = $it->getDtEnd();
+ $it->next();
+ }
+ return $end->getTimestamp();
+ }
+
+ /** @var Property\ICalendar\DateTime $dtStart */
+ $dtStart = $component->DTSTART;
+
+ if (isset($component->DTEND)) {
+ /** @var Property\ICalendar\DateTime $dtEnd */
+ $dtEnd = $component->DTEND;
+ return $dtEnd->getDateTime()->getTimeStamp();
+ }
+
+ if (isset($component->DURATION)) {
+ /** @var \DateTime $endDate */
+ $endDate = clone $dtStart->getDateTime();
+ // $component->DTEND->getDateTime() returns DateTimeImmutable
+ $endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue()));
+ return $endDate->getTimestamp();
+ }
+
+ if (!$dtStart->hasTime()) {
+ /** @var \DateTime $endDate */
+ // $component->DTSTART->getDateTime() returns DateTimeImmutable
+ $endDate = clone $dtStart->getDateTime();
+ $endDate = $endDate->modify('+1 day');
+ return $endDate->getTimestamp();
+ }
+
+ // No computation of end time possible - return start date
+ return $dtStart->getDateTime()->getTimeStamp();
+ }
+
+ /**
+ * @param Property|null $attendee
+ */
+ public function setL10n(?Property $attendee = null) {
+ if ($attendee === null) {
+ return;
+ }
+
+ $lang = $attendee->offsetGet('LANGUAGE');
+ if ($lang instanceof Parameter) {
+ $lang = $lang->getValue();
+ $this->l10n = $this->l10nFactory->get('dav', $lang);
+ }
+ }
+
+ /**
+ * @param Property|null $attendee
+ * @return bool
+ */
+ public function getAttendeeRsvpOrReqForParticipant(?Property $attendee = null) {
+ if ($attendee === null) {
+ return false;
+ }
+
+ $rsvp = $attendee->offsetGet('RSVP');
+ if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) {
+ return true;
+ }
+ $role = $attendee->offsetGet('ROLE');
+ // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16
+ // Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set
+ if ($role === null
+ || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0))
+ || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0))
+ ) {
+ return true;
+ }
+
+ // RFC 5545 3.2.17: default RSVP is false
+ return false;
+ }
+
+ /**
+ * @param IEMailTemplate $template
+ * @param string $method
+ * @param string $sender
+ * @param string $summary
+ * @param string|null $partstat
+ * @param bool $isModified
+ */
+ public function addSubjectAndHeading(IEMailTemplate $template,
+ string $method, string $sender, string $summary, bool $isModified, ?Property $replyingAttendee = null): void {
+ if ($method === IMipPlugin::METHOD_CANCEL) {
+ // TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}"
+ $template->setSubject($this->l10n->t('Cancelled: %1$s', [$summary]));
+ $template->addHeading($this->l10n->t('"%1$s" has been canceled', [$summary]));
+ } elseif ($method === IMipPlugin::METHOD_REPLY) {
+ // TRANSLATORS Subject for email, when an invitation is replied to. Ex: "Re: {{Event Name}}"
+ $template->setSubject($this->l10n->t('Re: %1$s', [$summary]));
+ // Build the strings
+ $partstat = (isset($replyingAttendee)) ? $replyingAttendee->offsetGet('PARTSTAT') : null;
+ $partstat = ($partstat instanceof Parameter) ? $partstat->getValue() : null;
+ switch ($partstat) {
+ case 'ACCEPTED':
+ $template->addHeading($this->l10n->t('%1$s has accepted your invitation', [$sender]));
+ break;
+ case 'TENTATIVE':
+ $template->addHeading($this->l10n->t('%1$s has tentatively accepted your invitation', [$sender]));
+ break;
+ case 'DECLINED':
+ $template->addHeading($this->l10n->t('%1$s has declined your invitation', [$sender]));
+ break;
+ case null:
+ default:
+ $template->addHeading($this->l10n->t('%1$s has responded to your invitation', [$sender]));
+ break;
+ }
+ } elseif ($method === IMipPlugin::METHOD_REQUEST && $isModified) {
+ // TRANSLATORS Subject for email, when an invitation is updated. Ex: "Invitation updated: {{Event Name}}"
+ $template->setSubject($this->l10n->t('Invitation updated: %1$s', [$summary]));
+ $template->addHeading($this->l10n->t('%1$s updated the event "%2$s"', [$sender, $summary]));
+ } else {
+ // TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}"
+ $template->setSubject($this->l10n->t('Invitation: %1$s', [$summary]));
+ $template->addHeading($this->l10n->t('%1$s would like to invite you to "%2$s"', [$sender, $summary]));
+ }
+ }
+
+ /**
+ * @param string $path
+ * @return string
+ */
+ public function getAbsoluteImagePath($path): string {
+ return $this->urlGenerator->getAbsoluteURL(
+ $this->urlGenerator->imagePath('core', $path)
+ );
+ }
+
+ /**
+ * addAttendees: add organizer and attendee names/emails to iMip mail.
+ *
+ * Enable with DAV setting: invitation_list_attendees (default: no)
+ *
+ * The default is 'no', which matches old behavior, and is privacy preserving.
+ *
+ * To enable including attendees in invitation emails:
+ * % php occ config:app:set dav invitation_list_attendees --value yes
+ *
+ * @param IEMailTemplate $template
+ * @param IL10N $this->l10n
+ * @param VEvent $vevent
+ * @author brad2014 on github.com
+ */
+ public function addAttendees(IEMailTemplate $template, VEvent $vevent) {
+ if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') {
+ return;
+ }
+
+ if (isset($vevent->ORGANIZER)) {
+ /** @var Property | Property\ICalendar\CalAddress $organizer */
+ $organizer = $vevent->ORGANIZER;
+ $organizerEmail = substr($organizer->getNormalizedValue(), 7);
+ /** @var string|null $organizerName */
+ $organizerName = isset($organizer->CN) ? $organizer->CN->getValue() : null;
+ $organizerHTML = sprintf('<a href="%s">%s</a>',
+ htmlspecialchars($organizer->getNormalizedValue()),
+ htmlspecialchars($organizerName ?: $organizerEmail));
+ $organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail);
+ if (isset($organizer['PARTSTAT'])) {
+ /** @var Parameter $partstat */
+ $partstat = $organizer['PARTSTAT'];
+ if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
+ $organizerHTML .= ' ✔︎';
+ $organizerText .= ' ✔︎';
+ }
+ }
+ $template->addBodyListItem($organizerHTML, $this->l10n->t('Organizer:'),
+ $this->getAbsoluteImagePath('caldav/organizer.png'),
+ $organizerText, '', IMipPlugin::IMIP_INDENT);
+ }
+
+ $attendees = $vevent->select('ATTENDEE');
+ if (count($attendees) === 0) {
+ return;
+ }
+
+ $attendeesHTML = [];
+ $attendeesText = [];
+ foreach ($attendees as $attendee) {
+ $attendeeEmail = substr($attendee->getNormalizedValue(), 7);
+ $attendeeName = isset($attendee['CN']) ? $attendee['CN']->getValue() : null;
+ $attendeeHTML = sprintf('<a href="%s">%s</a>',
+ htmlspecialchars($attendee->getNormalizedValue()),
+ htmlspecialchars($attendeeName ?: $attendeeEmail));
+ $attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail);
+ if (isset($attendee['PARTSTAT'])) {
+ /** @var Parameter $partstat */
+ $partstat = $attendee['PARTSTAT'];
+ if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) {
+ $attendeeHTML .= ' ✔︎';
+ $attendeeText .= ' ✔︎';
+ }
+ }
+ $attendeesHTML[] = $attendeeHTML;
+ $attendeesText[] = $attendeeText;
+ }
+
+ $template->addBodyListItem(implode('<br/>', $attendeesHTML), $this->l10n->t('Attendees:'),
+ $this->getAbsoluteImagePath('caldav/attendees.png'),
+ implode("\n", $attendeesText), '', IMipPlugin::IMIP_INDENT);
+ }
+
+ /**
+ * @param IEMailTemplate $template
+ * @param VEVENT $vevent
+ * @param $data
+ */
+ public function addBulletList(IEMailTemplate $template, VEvent $vevent, $data) {
+ $template->addBodyListItem(
+ $data['meeting_title_html'] ?? $data['meeting_title'], $this->l10n->t('Title:'),
+ $this->getAbsoluteImagePath('caldav/title.png'), $data['meeting_title'], '', IMipPlugin::IMIP_INDENT);
+ if ($data['meeting_when'] !== '') {
+ $template->addBodyListItem($data['meeting_when_html'] ?? $data['meeting_when'], $this->l10n->t('When:'),
+ $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_when'], '', IMipPlugin::IMIP_INDENT);
+ }
+ if ($data['meeting_location'] !== '') {
+ $template->addBodyListItem($data['meeting_location_html'] ?? $data['meeting_location'], $this->l10n->t('Location:'),
+ $this->getAbsoluteImagePath('caldav/location.png'), $data['meeting_location'], '', IMipPlugin::IMIP_INDENT);
+ }
+ if ($data['meeting_url'] !== '') {
+ $template->addBodyListItem($data['meeting_url_html'] ?? $data['meeting_url'], $this->l10n->t('Link:'),
+ $this->getAbsoluteImagePath('caldav/link.png'), $data['meeting_url'], '', IMipPlugin::IMIP_INDENT);
+ }
+ if (isset($data['meeting_occurring'])) {
+ $template->addBodyListItem($data['meeting_occurring_html'] ?? $data['meeting_occurring'], $this->l10n->t('Occurring:'),
+ $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_occurring'], '', IMipPlugin::IMIP_INDENT);
+ }
+
+ $this->addAttendees($template, $vevent);
+
+ /* Put description last, like an email body, since it can be arbitrarily long */
+ if ($data['meeting_description']) {
+ $template->addBodyListItem($data['meeting_description_html'] ?? $data['meeting_description'], $this->l10n->t('Description:'),
+ $this->getAbsoluteImagePath('caldav/description.png'), $data['meeting_description'], '', IMipPlugin::IMIP_INDENT);
+ }
+ }
+
+ /**
+ * @param Message $iTipMessage
+ * @return null|Property
+ */
+ public function getCurrentAttendee(Message $iTipMessage): ?Property {
+ /** @var VEvent $vevent */
+ $vevent = $iTipMessage->message->VEVENT;
+ $attendees = $vevent->select('ATTENDEE');
+ foreach ($attendees as $attendee) {
+ if ($iTipMessage->method === 'REPLY' && strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) {
+ /** @var Property $attendee */
+ return $attendee;
+ } elseif (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) {
+ /** @var Property $attendee */
+ return $attendee;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @param Message $iTipMessage
+ * @param VEvent $vevent
+ * @param int $lastOccurrence
+ * @return string
+ */
+ public function createInvitationToken(Message $iTipMessage, VEvent $vevent, int $lastOccurrence): string {
+ $token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC);
+
+ $attendee = $iTipMessage->recipient;
+ $organizer = $iTipMessage->sender;
+ $sequence = $iTipMessage->sequence;
+ $recurrenceId = isset($vevent->{'RECURRENCE-ID'})
+ ? $vevent->{'RECURRENCE-ID'}->serialize() : null;
+ $uid = $vevent->{'UID'}?->getValue();
+
+ $query = $this->db->getQueryBuilder();
+ $query->insert('calendar_invitations')
+ ->values([
+ 'token' => $query->createNamedParameter($token),
+ 'attendee' => $query->createNamedParameter($attendee),
+ 'organizer' => $query->createNamedParameter($organizer),
+ 'sequence' => $query->createNamedParameter($sequence),
+ 'recurrenceid' => $query->createNamedParameter($recurrenceId),
+ 'expiration' => $query->createNamedParameter($lastOccurrence),
+ 'uid' => $query->createNamedParameter($uid)
+ ])
+ ->executeStatement();
+
+ return $token;
+ }
+
+ /**
+ * @param IEMailTemplate $template
+ * @param $token
+ */
+ public function addResponseButtons(IEMailTemplate $template, $token) {
+ $template->addBodyButtonGroup(
+ $this->l10n->t('Accept'),
+ $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [
+ 'token' => $token,
+ ]),
+ $this->l10n->t('Decline'),
+ $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [
+ 'token' => $token,
+ ])
+ );
+ }
+
+ public function addMoreOptionsButton(IEMailTemplate $template, $token) {
+ $moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [
+ 'token' => $token,
+ ]);
+ $html = vsprintf('<small><a href="%s">%s</a></small>', [
+ $moreOptionsURL, $this->l10n->t('More options …')
+ ]);
+ $text = $this->l10n->t('More options at %s', [$moreOptionsURL]);
+
+ $template->addBodyText($html, $text);
+ }
+
+ public function getReplyingAttendee(Message $iTipMessage): ?Property {
+ /** @var VEvent $vevent */
+ $vevent = $iTipMessage->message->VEVENT;
+ $attendees = $vevent->select('ATTENDEE');
+ foreach ($attendees as $attendee) {
+ /** @var Property $attendee */
+ if (strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) {
+ return $attendee;
+ }
+ }
+ return null;
+ }
+
+ public function isRoomOrResource(Property $attendee): bool {
+ $cuType = $attendee->offsetGet('CUTYPE');
+ if (!$cuType instanceof Parameter) {
+ return false;
+ }
+ $type = $cuType->getValue() ?? 'INDIVIDUAL';
+ if (\in_array(strtoupper($type), ['RESOURCE', 'ROOM'], true)) {
+ // Don't send emails to things
+ return true;
+ }
+ return false;
+ }
+
+ public function isCircle(Property $attendee): bool {
+ $cuType = $attendee->offsetGet('CUTYPE');
+ if (!$cuType instanceof Parameter) {
+ return false;
+ }
+
+ $uri = $attendee->getValue();
+ if (!$uri) {
+ return false;
+ }
+
+ $cuTypeValue = $cuType->getValue();
+ return $cuTypeValue === 'GROUP' && str_starts_with($uri, 'mailto:circle+');
+ }
+
+ public function minimizeInterval(\DateInterval $dateInterval): array {
+ // evaluate if time interval is in the past
+ if ($dateInterval->invert == 1) {
+ return ['interval' => 1, 'scale' => 'past'];
+ }
+ // evaluate interval parts and return smallest time period
+ if ($dateInterval->y > 0) {
+ $interval = $dateInterval->y;
+ $scale = 'year';
+ } elseif ($dateInterval->m > 0) {
+ $interval = $dateInterval->m;
+ $scale = 'month';
+ } elseif ($dateInterval->d >= 7) {
+ $interval = (int)($dateInterval->d / 7);
+ $scale = 'week';
+ } elseif ($dateInterval->d > 0) {
+ $interval = $dateInterval->d;
+ $scale = 'day';
+ } elseif ($dateInterval->h > 0) {
+ $interval = $dateInterval->h;
+ $scale = 'hour';
+ } else {
+ $interval = $dateInterval->i;
+ $scale = 'minute';
+ }
+
+ return ['interval' => $interval, 'scale' => $scale];
+ }
+
+ /**
+ * Localizes week day names to another language
+ *
+ * @param string $value
+ *
+ * @return string
+ */
+ public function localizeDayName(string $value): string {
+ return match ($value) {
+ 'Monday' => $this->l10n->t('Monday'),
+ 'Tuesday' => $this->l10n->t('Tuesday'),
+ 'Wednesday' => $this->l10n->t('Wednesday'),
+ 'Thursday' => $this->l10n->t('Thursday'),
+ 'Friday' => $this->l10n->t('Friday'),
+ 'Saturday' => $this->l10n->t('Saturday'),
+ 'Sunday' => $this->l10n->t('Sunday'),
+ };
+ }
+
+ /**
+ * Localizes month names to another language
+ *
+ * @param string $value
+ *
+ * @return string
+ */
+ public function localizeMonthName(string $value): string {
+ return match ($value) {
+ 'January' => $this->l10n->t('January'),
+ 'February' => $this->l10n->t('February'),
+ 'March' => $this->l10n->t('March'),
+ 'April' => $this->l10n->t('April'),
+ 'May' => $this->l10n->t('May'),
+ 'June' => $this->l10n->t('June'),
+ 'July' => $this->l10n->t('July'),
+ 'August' => $this->l10n->t('August'),
+ 'September' => $this->l10n->t('September'),
+ 'October' => $this->l10n->t('October'),
+ 'November' => $this->l10n->t('November'),
+ 'December' => $this->l10n->t('December'),
+ };
+ }
+
+ /**
+ * Localizes relative position names to another language
+ *
+ * @param string $value
+ *
+ * @return string
+ */
+ public function localizeRelativePositionName(string $value): string {
+ return match ($value) {
+ 'First' => $this->l10n->t('First'),
+ 'Second' => $this->l10n->t('Second'),
+ 'Third' => $this->l10n->t('Third'),
+ 'Fourth' => $this->l10n->t('Fourth'),
+ 'Fifth' => $this->l10n->t('Fifth'),
+ 'Last' => $this->l10n->t('Last'),
+ 'Second Last' => $this->l10n->t('Second Last'),
+ 'Third Last' => $this->l10n->t('Third Last'),
+ 'Fourth Last' => $this->l10n->t('Fourth Last'),
+ 'Fifth Last' => $this->l10n->t('Fifth Last'),
+ };
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php
index 34df666637c..a001df8b2a8 100644
--- a/apps/dav/lib/CalDAV/Schedule/Plugin.php
+++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php
@@ -1,48 +1,129 @@
<?php
+
/**
- * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl>
- * @copyright Copyright (c) 2016, Joas Schilling <coding@schilljs.com>
- *
- * @author Joas Schilling <coding@schilljs.com>
- * @author Roeland Jago Douma <roeland@famdouma.nl>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Schedule;
+use DateTimeZone;
use OCA\DAV\CalDAV\CalDavBackend;
+use OCA\DAV\CalDAV\Calendar;
use OCA\DAV\CalDAV\CalendarHome;
+use OCA\DAV\CalDAV\CalendarObject;
+use OCA\DAV\CalDAV\DefaultCalendarValidator;
+use OCA\DAV\CalDAV\TipBroker;
+use OCP\IConfig;
+use Psr\Log\LoggerInterface;
+use Sabre\CalDAV\ICalendar;
+use Sabre\CalDAV\ICalendarObject;
+use Sabre\CalDAV\Schedule\ISchedulingObject;
+use Sabre\DAV\Exception as DavException;
use Sabre\DAV\INode;
+use Sabre\DAV\IProperties;
use Sabre\DAV\PropFind;
use Sabre\DAV\Server;
use Sabre\DAV\Xml\Property\LocalHref;
+use Sabre\DAVACL\IACL;
use Sabre\DAVACL\IPrincipal;
+use Sabre\HTTP\RequestInterface;
+use Sabre\HTTP\ResponseInterface;
+use Sabre\VObject\Component;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\DateTimeParser;
+use Sabre\VObject\FreeBusyGenerator;
+use Sabre\VObject\ITip;
+use Sabre\VObject\ITip\SameOrganizerForAllComponentsException;
+use Sabre\VObject\Parameter;
+use Sabre\VObject\Property;
+use Sabre\VObject\Reader;
+use function Sabre\Uri\split;
class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
+ /** @var ITip\Message[] */
+ private $schedulingResponses = [];
+
+ /** @var string|null */
+ private $pathOfCalendarObjectChange = null;
+
+ public const CALENDAR_USER_TYPE = '{' . self::NS_CALDAV . '}calendar-user-type';
+ public const SCHEDULE_DEFAULT_CALENDAR_URL = '{' . Plugin::NS_CALDAV . '}schedule-default-calendar-URL';
+
+ /**
+ * @param IConfig $config
+ */
+ public function __construct(
+ private IConfig $config,
+ private LoggerInterface $logger,
+ private DefaultCalendarValidator $defaultCalendarValidator,
+ ) {
+ }
+
/**
* Initializes the plugin
*
* @param Server $server
* @return void
*/
- function initialize(Server $server) {
+ public function initialize(Server $server) {
parent::initialize($server);
$server->on('propFind', [$this, 'propFindDefaultCalendarUrl'], 90);
+ $server->on('afterWriteContent', [$this, 'dispatchSchedulingResponses']);
+ $server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']);
+
+ // We allow mutating the default calendar URL through the CustomPropertiesBackend
+ // (oc_properties table)
+ $server->protectedProperties = array_filter(
+ $server->protectedProperties,
+ static fn (string $property) => $property !== self::SCHEDULE_DEFAULT_CALENDAR_URL,
+ );
+ }
+
+ /**
+ * Returns an instance of the iTip\Broker.
+ */
+ protected function createITipBroker(): TipBroker {
+ return new TipBroker();
+ }
+
+ /**
+ * Allow manual setting of the object change URL
+ * to support public write
+ *
+ * @param string $path
+ */
+ public function setPathOfCalendarObjectChange(string $path): void {
+ $this->pathOfCalendarObjectChange = $path;
+ }
+
+ /**
+ * This method handler is invoked during fetching of properties.
+ *
+ * We use this event to add calendar-auto-schedule-specific properties.
+ *
+ * @param PropFind $propFind
+ * @param INode $node
+ * @return void
+ */
+ public function propFind(PropFind $propFind, INode $node) {
+ if ($node instanceof IPrincipal) {
+ // overwrite Sabre/Dav's implementation
+ $propFind->handle(self::CALENDAR_USER_TYPE, function () use ($node) {
+ if ($node instanceof IProperties) {
+ $props = $node->getProperties([self::CALENDAR_USER_TYPE]);
+
+ if (isset($props[self::CALENDAR_USER_TYPE])) {
+ return $props[self::CALENDAR_USER_TYPE];
+ }
+ }
+
+ return 'INDIVIDUAL';
+ });
+ }
+
+ parent::propFind($propFind, $node);
}
/**
@@ -58,38 +139,319 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
$result = [];
}
+ // iterate through items and html decode values
+ foreach ($result as $key => $value) {
+ $result[$key] = urldecode($value);
+ }
+
return $result;
}
/**
+ * @param RequestInterface $request
+ * @param ResponseInterface $response
+ * @param VCalendar $vCal
+ * @param mixed $calendarPath
+ * @param mixed $modified
+ * @param mixed $isNew
+ */
+ public function calendarObjectChange(RequestInterface $request, ResponseInterface $response, VCalendar $vCal, $calendarPath, &$modified, $isNew) {
+ // Save the first path we get as a calendar-object-change request
+ if (!$this->pathOfCalendarObjectChange) {
+ $this->pathOfCalendarObjectChange = $request->getPath();
+ }
+
+ try {
+
+ // Do not generate iTip and iMip messages if scheduling is disabled for this message
+ if ($request->getHeader('x-nc-scheduling') === 'false') {
+ return;
+ }
+
+ if (!$this->scheduleReply($this->server->httpRequest)) {
+ return;
+ }
+
+ /** @var Calendar $calendarNode */
+ $calendarNode = $this->server->tree->getNodeForPath($calendarPath);
+ // extract addresses for owner
+ $addresses = $this->getAddressesForPrincipal($calendarNode->getOwner());
+ // determine if request is from a sharee
+ if ($calendarNode->isShared()) {
+ // extract addresses for sharee and add to address collection
+ $addresses = array_merge(
+ $addresses,
+ $this->getAddressesForPrincipal($calendarNode->getPrincipalURI())
+ );
+ }
+ // determine if we are updating a calendar event
+ if (!$isNew) {
+ // retrieve current calendar event node
+ /** @var CalendarObject $currentNode */
+ $currentNode = $this->server->tree->getNodeForPath($request->getPath());
+ // convert calendar event string data to VCalendar object
+ /** @var \Sabre\VObject\Component\VCalendar $currentObject */
+ $currentObject = Reader::read($currentNode->get());
+ } else {
+ $currentObject = null;
+ }
+ // process request
+ $this->processICalendarChange($currentObject, $vCal, $addresses, [], $modified);
+
+ if ($currentObject) {
+ // Destroy circular references so PHP will GC the object.
+ $currentObject->destroy();
+ }
+
+ } catch (SameOrganizerForAllComponentsException $e) {
+ $this->handleSameOrganizerException($e, $vCal, $calendarPath);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function beforeUnbind($path): void {
+ try {
+ parent::beforeUnbind($path);
+ } catch (SameOrganizerForAllComponentsException $e) {
+ $node = $this->server->tree->getNodeForPath($path);
+ if (!$node instanceof ICalendarObject || $node instanceof ISchedulingObject) {
+ throw $e;
+ }
+
+ /** @var VCalendar $vCal */
+ $vCal = Reader::read($node->get());
+ $this->handleSameOrganizerException($e, $vCal, $path);
+ }
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function scheduleLocalDelivery(ITip\Message $iTipMessage):void {
+ /** @var VEvent|null $vevent */
+ $vevent = $iTipMessage->message->VEVENT ?? null;
+
+ // Strip VALARMs from incoming VEVENT
+ if ($vevent && isset($vevent->VALARM)) {
+ $vevent->remove('VALARM');
+ }
+
+ parent::scheduleLocalDelivery($iTipMessage);
+ // We only care when the message was successfully delivered locally
+ // Log all possible codes returned from the parent method that mean something went wrong
+ // 3.7, 3.8, 5.0, 5.2
+ if ($iTipMessage->scheduleStatus !== '1.2;Message delivered locally') {
+ $this->logger->debug('Message not delivered locally with status: ' . $iTipMessage->scheduleStatus);
+ return;
+ }
+ // We only care about request. reply and cancel are properly handled
+ // by parent::scheduleLocalDelivery already
+ if (strcasecmp($iTipMessage->method, 'REQUEST') !== 0) {
+ return;
+ }
+
+ // If parent::scheduleLocalDelivery set scheduleStatus to 1.2,
+ // it means that it was successfully delivered locally.
+ // Meaning that the ACL plugin is loaded and that a principal
+ // exists for the given recipient id, no need to double check
+ /** @var \Sabre\DAVACL\Plugin $aclPlugin */
+ $aclPlugin = $this->server->getPlugin('acl');
+ $principalUri = $aclPlugin->getPrincipalByUri($iTipMessage->recipient);
+ $calendarUserType = $this->getCalendarUserTypeForPrincipal($principalUri);
+ if (strcasecmp($calendarUserType, 'ROOM') !== 0 && strcasecmp($calendarUserType, 'RESOURCE') !== 0) {
+ $this->logger->debug('Calendar user type is neither room nor resource, not processing further');
+ return;
+ }
+
+ $attendee = $this->getCurrentAttendee($iTipMessage);
+ if (!$attendee) {
+ $this->logger->debug('No attendee set for scheduling message');
+ return;
+ }
+
+ // We only respond when a response was actually requested
+ $rsvp = $this->getAttendeeRSVP($attendee);
+ if (!$rsvp) {
+ $this->logger->debug('No RSVP requested for attendee ' . $attendee->getValue());
+ return;
+ }
+
+ if (!$vevent) {
+ $this->logger->debug('No VEVENT set to process on scheduling message');
+ return;
+ }
+
+ // We don't support autoresponses for recurrencing events for now
+ if (isset($vevent->RRULE) || isset($vevent->RDATE)) {
+ $this->logger->debug('VEVENT is a recurring event, autoresponding not supported');
+ return;
+ }
+
+ $dtstart = $vevent->DTSTART;
+ $dtend = $this->getDTEndFromVEvent($vevent);
+ $uid = $vevent->UID->getValue();
+ $sequence = isset($vevent->SEQUENCE) ? $vevent->SEQUENCE->getValue() : 0;
+ $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->serialize() : '';
+
+ $message = <<<EOF
+BEGIN:VCALENDAR
+PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN
+METHOD:REPLY
+VERSION:2.0
+BEGIN:VEVENT
+ATTENDEE;PARTSTAT=%s:%s
+ORGANIZER:%s
+UID:%s
+SEQUENCE:%s
+REQUEST-STATUS:2.0;Success
+%sEND:VEVENT
+END:VCALENDAR
+EOF;
+
+ if ($this->isAvailableAtTime($attendee->getValue(), $dtstart->getDateTime(), $dtend->getDateTime(), $uid)) {
+ $partStat = 'ACCEPTED';
+ } else {
+ $partStat = 'DECLINED';
+ }
+
+ $vObject = Reader::read(vsprintf($message, [
+ $partStat,
+ $iTipMessage->recipient,
+ $iTipMessage->sender,
+ $uid,
+ $sequence,
+ $recurrenceId
+ ]));
+
+ $responseITipMessage = new ITip\Message();
+ $responseITipMessage->uid = $uid;
+ $responseITipMessage->component = 'VEVENT';
+ $responseITipMessage->method = 'REPLY';
+ $responseITipMessage->sequence = $sequence;
+ $responseITipMessage->sender = $iTipMessage->recipient;
+ $responseITipMessage->recipient = $iTipMessage->sender;
+ $responseITipMessage->message = $vObject;
+
+ // We can't dispatch them now already, because the organizers calendar-object
+ // was not yet created. Hence Sabre/DAV won't find a calendar-object, when we
+ // send our reply.
+ $this->schedulingResponses[] = $responseITipMessage;
+ }
+
+ /**
+ * @param string $uri
+ */
+ public function dispatchSchedulingResponses(string $uri):void {
+ if ($uri !== $this->pathOfCalendarObjectChange) {
+ return;
+ }
+
+ foreach ($this->schedulingResponses as $schedulingResponse) {
+ $this->scheduleLocalDelivery($schedulingResponse);
+ }
+ }
+
+ /**
* Always use the personal calendar as target for scheduled events
*
* @param PropFind $propFind
* @param INode $node
* @return void
*/
- function propFindDefaultCalendarUrl(PropFind $propFind, INode $node) {
+ public function propFindDefaultCalendarUrl(PropFind $propFind, INode $node) {
if ($node instanceof IPrincipal) {
- $propFind->handle('{' . self::NS_CALDAV . '}schedule-default-calendar-URL', function() use ($node) {
+ $propFind->handle(self::SCHEDULE_DEFAULT_CALENDAR_URL, function () use ($node) {
/** @var \OCA\DAV\CalDAV\Plugin $caldavPlugin */
$caldavPlugin = $this->server->getPlugin('caldav');
$principalUrl = $node->getPrincipalUrl();
$calendarHomePath = $caldavPlugin->getCalendarHomeForPrincipal($principalUrl);
-
if (!$calendarHomePath) {
return null;
}
+ $isResourceOrRoom = str_starts_with($principalUrl, 'principals/calendar-resources')
+ || str_starts_with($principalUrl, 'principals/calendar-rooms');
+
+ if (str_starts_with($principalUrl, 'principals/users')) {
+ [, $userId] = split($principalUrl);
+ $uri = $this->config->getUserValue($userId, 'dav', 'defaultCalendar', CalDavBackend::PERSONAL_CALENDAR_URI);
+ $displayName = CalDavBackend::PERSONAL_CALENDAR_NAME;
+ } elseif ($isResourceOrRoom) {
+ $uri = CalDavBackend::RESOURCE_BOOKING_CALENDAR_URI;
+ $displayName = CalDavBackend::RESOURCE_BOOKING_CALENDAR_NAME;
+ } else {
+ // How did we end up here?
+ // TODO - throw exception or just ignore?
+ return null;
+ }
+
/** @var CalendarHome $calendarHome */
$calendarHome = $this->server->tree->getNodeForPath($calendarHomePath);
- if (!$calendarHome->childExists(CalDavBackend::PERSONAL_CALENDAR_URI)) {
- $calendarHome->getCalDAVBackend()->createCalendar($principalUrl, CalDavBackend::PERSONAL_CALENDAR_URI, [
- '{DAV:}displayname' => CalDavBackend::PERSONAL_CALENDAR_NAME,
- ]);
+ $currentCalendarDeleted = false;
+ if (!$calendarHome->childExists($uri) || $currentCalendarDeleted = $this->isCalendarDeleted($calendarHome, $uri)) {
+ // If the default calendar doesn't exist
+ if ($isResourceOrRoom) {
+ // Resources or rooms can't be in the trashbin, so we're fine
+ $this->createCalendar($calendarHome, $principalUrl, $uri, $displayName);
+ } else {
+ // And we're not handling scheduling on resource/room booking
+ $userCalendars = [];
+ /**
+ * If the default calendar of the user isn't set and the
+ * fallback doesn't match any of the user's calendar
+ * try to find the first "personal" calendar we can write to
+ * instead of creating a new one.
+ * A appropriate personal calendar to receive invites:
+ * - isn't a calendar subscription
+ * - user can write to it (no virtual/3rd-party calendars)
+ * - calendar isn't a share
+ * - calendar supports VEVENTs
+ */
+ foreach ($calendarHome->getChildren() as $node) {
+ if (!($node instanceof Calendar)) {
+ continue;
+ }
+
+ try {
+ $this->defaultCalendarValidator->validateScheduleDefaultCalendar($node);
+ } catch (DavException $e) {
+ continue;
+ }
+
+ $userCalendars[] = $node;
+ }
+
+ if (count($userCalendars) > 0) {
+ // Calendar backend returns calendar by calendarorder property
+ $uri = $userCalendars[0]->getName();
+ } else {
+ // Otherwise if we have really nothing, create a new calendar
+ if ($currentCalendarDeleted) {
+ // If the calendar exists but is in the trash bin, we try to rename its uri
+ // so that we can create the new one and still restore the previous one
+ // otherwise we just purge the calendar by removing it before recreating it
+ $calendar = $this->getCalendar($calendarHome, $uri);
+ if ($calendar instanceof Calendar) {
+ $backend = $calendarHome->getCalDAVBackend();
+ if ($backend instanceof CalDavBackend) {
+ // If the CalDAV backend supports moving calendars
+ $this->moveCalendar($backend, $principalUrl, $uri, $uri . '-back-' . time());
+ } else {
+ // Otherwise just purge the calendar
+ $calendar->disableTrashbin();
+ $calendar->delete();
+ }
+ }
+ }
+ $this->createCalendar($calendarHome, $principalUrl, $uri, $displayName);
+ }
+ }
}
- $result = $this->server->getPropertiesForPath($calendarHomePath . '/' . CalDavBackend::PERSONAL_CALENDAR_URI, [], 1);
+ $result = $this->server->getPropertiesForPath($calendarHomePath . '/' . $uri, [], 1);
if (empty($result)) {
return null;
}
@@ -98,4 +460,299 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
});
}
}
+
+ /**
+ * Returns a list of addresses that are associated with a principal.
+ *
+ * @param string $principal
+ * @return string|null
+ */
+ protected function getCalendarUserTypeForPrincipal($principal):?string {
+ $calendarUserType = '{' . self::NS_CALDAV . '}calendar-user-type';
+ $properties = $this->server->getProperties(
+ $principal,
+ [$calendarUserType]
+ );
+
+ // If we can't find this information, we'll stop processing
+ if (!isset($properties[$calendarUserType])) {
+ return null;
+ }
+
+ return $properties[$calendarUserType];
+ }
+
+ /**
+ * @param ITip\Message $iTipMessage
+ * @return null|Property
+ */
+ private function getCurrentAttendee(ITip\Message $iTipMessage):?Property {
+ /** @var VEvent $vevent */
+ $vevent = $iTipMessage->message->VEVENT;
+ $attendees = $vevent->select('ATTENDEE');
+ foreach ($attendees as $attendee) {
+ /** @var Property $attendee */
+ if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) {
+ return $attendee;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @param Property|null $attendee
+ * @return bool
+ */
+ private function getAttendeeRSVP(?Property $attendee = null):bool {
+ if ($attendee !== null) {
+ $rsvp = $attendee->offsetGet('RSVP');
+ if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) {
+ return true;
+ }
+ }
+ // RFC 5545 3.2.17: default RSVP is false
+ return false;
+ }
+
+ /**
+ * @param VEvent $vevent
+ * @return Property\ICalendar\DateTime
+ */
+ private function getDTEndFromVEvent(VEvent $vevent):Property\ICalendar\DateTime {
+ if (isset($vevent->DTEND)) {
+ return $vevent->DTEND;
+ }
+
+ if (isset($vevent->DURATION)) {
+ $isFloating = $vevent->DTSTART->isFloating();
+ /** @var Property\ICalendar\DateTime $end */
+ $end = clone $vevent->DTSTART;
+ $endDateTime = $end->getDateTime();
+ $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue()));
+ $end->setDateTime($endDateTime, $isFloating);
+ return $end;
+ }
+
+ if (!$vevent->DTSTART->hasTime()) {
+ $isFloating = $vevent->DTSTART->isFloating();
+ /** @var Property\ICalendar\DateTime $end */
+ $end = clone $vevent->DTSTART;
+ $endDateTime = $end->getDateTime();
+ $endDateTime = $endDateTime->modify('+1 day');
+ $end->setDateTime($endDateTime, $isFloating);
+ return $end;
+ }
+
+ return clone $vevent->DTSTART;
+ }
+
+ /**
+ * @param string $email
+ * @param \DateTimeInterface $start
+ * @param \DateTimeInterface $end
+ * @param string $ignoreUID
+ * @return bool
+ */
+ private function isAvailableAtTime(string $email, \DateTimeInterface $start, \DateTimeInterface $end, string $ignoreUID):bool {
+ // This method is heavily inspired by Sabre\CalDAV\Schedule\Plugin::scheduleLocalDelivery
+ // and Sabre\CalDAV\Schedule\Plugin::getFreeBusyForEmail
+
+ $aclPlugin = $this->server->getPlugin('acl');
+ $this->server->removeListener('propFind', [$aclPlugin, 'propFind']);
+
+ $result = $aclPlugin->principalSearch(
+ ['{http://sabredav.org/ns}email-address' => $this->stripOffMailTo($email)],
+ [
+ '{DAV:}principal-URL',
+ '{' . self::NS_CALDAV . '}calendar-home-set',
+ '{' . self::NS_CALDAV . '}schedule-inbox-URL',
+ '{http://sabredav.org/ns}email-address',
+
+ ]
+ );
+ $this->server->on('propFind', [$aclPlugin, 'propFind'], 20);
+
+
+ // Grabbing the calendar list
+ $objects = [];
+ $calendarTimeZone = new DateTimeZone('UTC');
+
+ $homePath = $result[0][200]['{' . self::NS_CALDAV . '}calendar-home-set']->getHref();
+ /** @var Calendar $node */
+ foreach ($this->server->tree->getNodeForPath($homePath)->getChildren() as $node) {
+
+ if (!$node instanceof ICalendar) {
+ continue;
+ }
+
+ // Getting the list of object uris within the time-range
+ $urls = $node->calendarQuery([
+ 'name' => 'VCALENDAR',
+ 'comp-filters' => [
+ [
+ 'name' => 'VEVENT',
+ 'is-not-defined' => false,
+ 'time-range' => [
+ 'start' => $start,
+ 'end' => $end,
+ ],
+ 'comp-filters' => [],
+ 'prop-filters' => [],
+ ],
+ [
+ 'name' => 'VEVENT',
+ 'is-not-defined' => false,
+ 'time-range' => null,
+ 'comp-filters' => [],
+ 'prop-filters' => [
+ [
+ 'name' => 'UID',
+ 'is-not-defined' => false,
+ 'time-range' => null,
+ 'text-match' => [
+ 'value' => $ignoreUID,
+ 'negate-condition' => true,
+ 'collation' => 'i;octet',
+ ],
+ 'param-filters' => [],
+ ],
+ ]
+ ],
+ ],
+ 'prop-filters' => [],
+ 'is-not-defined' => false,
+ 'time-range' => null,
+ ]);
+
+ foreach ($urls as $url) {
+ $objects[] = $node->getChild($url)->get();
+ }
+ }
+
+ $inboxProps = $this->server->getProperties(
+ $result[0][200]['{' . self::NS_CALDAV . '}schedule-inbox-URL']->getHref(),
+ ['{' . self::NS_CALDAV . '}calendar-availability']
+ );
+
+ $vcalendar = new VCalendar();
+ $vcalendar->METHOD = 'REPLY';
+
+ $generator = new FreeBusyGenerator();
+ $generator->setObjects($objects);
+ $generator->setTimeRange($start, $end);
+ $generator->setBaseObject($vcalendar);
+ $generator->setTimeZone($calendarTimeZone);
+
+ if (isset($inboxProps['{' . self::NS_CALDAV . '}calendar-availability'])) {
+ $generator->setVAvailability(
+ Reader::read(
+ $inboxProps['{' . self::NS_CALDAV . '}calendar-availability']
+ )
+ );
+ }
+
+ $result = $generator->getResult();
+ if (!isset($result->VFREEBUSY)) {
+ return false;
+ }
+
+ /** @var Component $freeBusyComponent */
+ $freeBusyComponent = $result->VFREEBUSY;
+ $freeBusyProperties = $freeBusyComponent->select('FREEBUSY');
+ // If there is no Free-busy property at all, the time-range is empty and available
+ if (count($freeBusyProperties) === 0) {
+ return true;
+ }
+
+ // If more than one Free-Busy property was returned, it means that an event
+ // starts or ends inside this time-range, so it's not available and we return false
+ if (count($freeBusyProperties) > 1) {
+ return false;
+ }
+
+ /** @var Property $freeBusyProperty */
+ $freeBusyProperty = $freeBusyProperties[0];
+ if (!$freeBusyProperty->offsetExists('FBTYPE')) {
+ // If there is no FBTYPE, it means it's busy
+ return false;
+ }
+
+ $fbTypeParameter = $freeBusyProperty->offsetGet('FBTYPE');
+ if (!($fbTypeParameter instanceof Parameter)) {
+ return false;
+ }
+
+ return (strcasecmp($fbTypeParameter->getValue(), 'FREE') === 0);
+ }
+
+ /**
+ * @param string $email
+ * @return string
+ */
+ private function stripOffMailTo(string $email): string {
+ if (stripos($email, 'mailto:') === 0) {
+ return substr($email, 7);
+ }
+
+ return $email;
+ }
+
+ private function getCalendar(CalendarHome $calendarHome, string $uri): INode {
+ return $calendarHome->getChild($uri);
+ }
+
+ private function isCalendarDeleted(CalendarHome $calendarHome, string $uri): bool {
+ $calendar = $this->getCalendar($calendarHome, $uri);
+ return $calendar instanceof Calendar && $calendar->isDeleted();
+ }
+
+ private function createCalendar(CalendarHome $calendarHome, string $principalUri, string $uri, string $displayName): void {
+ $calendarHome->getCalDAVBackend()->createCalendar($principalUri, $uri, [
+ '{DAV:}displayname' => $displayName,
+ ]);
+ }
+
+ private function moveCalendar(CalDavBackend $calDavBackend, string $principalUri, string $oldUri, string $newUri): void {
+ $calDavBackend->moveCalendar($oldUri, $principalUri, $principalUri, $newUri);
+ }
+
+ /**
+ * Try to handle the given exception gracefully or throw it if necessary.
+ *
+ * @throws SameOrganizerForAllComponentsException If the exception should not be ignored
+ */
+ private function handleSameOrganizerException(
+ SameOrganizerForAllComponentsException $e,
+ VCalendar $vCal,
+ string $calendarPath,
+ ): void {
+ // This is very hacky! However, we want to allow saving events with multiple
+ // organizers. Those events are not RFC compliant, but sometimes imported from major
+ // external calendar services (e.g. Google). If the current user is not an organizer of
+ // the event we ignore the exception as no scheduling messages will be sent anyway.
+
+ // It would be cleaner to patch Sabre to validate organizers *after* checking if
+ // scheduling messages are necessary. Currently, organizers are validated first and
+ // afterwards the broker checks if messages should be scheduled. So the code will throw
+ // even if the organizers are not relevant. This is to ensure compliance with RFCs but
+ // a bit too strict for real world usage.
+
+ if (!isset($vCal->VEVENT)) {
+ throw $e;
+ }
+
+ $calendarNode = $this->server->tree->getNodeForPath($calendarPath);
+ if (!($calendarNode instanceof IACL)) {
+ // Should always be an instance of IACL but just to be sure
+ throw $e;
+ }
+
+ $addresses = $this->getAddressesForPrincipal($calendarNode->getOwner());
+ foreach ($vCal->VEVENT as $vevent) {
+ if (in_array($vevent->ORGANIZER->getNormalizedValue(), $addresses, true)) {
+ // User is an organizer => throw the exception
+ throw $e;
+ }
+ }
+ }
}
diff --git a/apps/dav/lib/CalDAV/Search/SearchPlugin.php b/apps/dav/lib/CalDAV/Search/SearchPlugin.php
index 37000aa2eb8..27e39a76305 100644
--- a/apps/dav/lib/CalDAV/Search/SearchPlugin.php
+++ b/apps/dav/lib/CalDAV/Search/SearchPlugin.php
@@ -1,34 +1,19 @@
<?php
+
/**
- * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- * @author Joas Schilling <coding@schilljs.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Search;
+use OCA\DAV\CalDAV\CalendarHome;
+use OCA\DAV\CalDAV\Search\Xml\Request\CalendarSearchReport;
+use OCP\AppFramework\Http;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
-use OCA\DAV\CalDAV\CalendarHome;
class SearchPlugin extends ServerPlugin {
- const NS_Nextcloud = 'http://nextcloud.com/ns';
+ public const NS_Nextcloud = 'http://nextcloud.com/ns';
/**
* Reference to SabreDAV server object.
@@ -77,8 +62,8 @@ class SearchPlugin extends ServerPlugin {
$server->on('report', [$this, 'report']);
- $server->xml->elementMap['{' . self::NS_Nextcloud . '}calendar-search'] =
- 'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport';
+ $server->xml->elementMap['{' . self::NS_Nextcloud . '}calendar-search']
+ = CalendarSearchReport::class;
}
/**
@@ -125,7 +110,7 @@ class SearchPlugin extends ServerPlugin {
* This report is used by clients to request calendar objects based on
* complex conditions.
*
- * @param Xml\Request\CalendarSearchReport $report
+ * @param CalendarSearchReport $report
* @return void
*/
private function calendarSearch($report) {
@@ -138,11 +123,10 @@ class SearchPlugin extends ServerPlugin {
// If we're dealing with the calendar home, the calendar home itself is
// responsible for the calendar-query
if ($node instanceof CalendarHome && $depth === 2) {
-
$nodePaths = $node->calendarSearch($report->filters, $report->limit, $report->offset);
foreach ($nodePaths as $path) {
- list($properties) = $this->server->getPropertiesForPath(
+ [$properties] = $this->server->getPropertiesForPath(
$this->server->getRequestUri() . '/' . $path,
$report->properties);
$result[] = $properties;
@@ -151,7 +135,7 @@ class SearchPlugin extends ServerPlugin {
$prefer = $this->server->getHTTPPrefer();
- $this->server->httpResponse->setStatus(207);
+ $this->server->httpResponse->setStatus(Http::STATUS_MULTI_STATUS);
$this->server->httpResponse->setHeader('Content-Type',
'application/xml; charset=utf-8');
$this->server->httpResponse->setHeader('Vary', 'Brief,Prefer');
diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php
index 5cc8f799f90..21a4fff1caf 100644
--- a/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php
+++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php
@@ -1,31 +1,15 @@
<?php
+
/**
- * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Search\Xml\Filter;
+use OCA\DAV\CalDAV\Search\SearchPlugin;
use Sabre\DAV\Exception\BadRequest;
use Sabre\Xml\Reader;
use Sabre\Xml\XmlDeserializable;
-use OCA\DAV\CalDAV\Search\SearchPlugin;
class CompFilter implements XmlDeserializable {
@@ -34,7 +18,7 @@ class CompFilter implements XmlDeserializable {
* @throws BadRequest
* @return string
*/
- static function xmlDeserialize(Reader $reader) {
+ public static function xmlDeserialize(Reader $reader) {
$att = $reader->parseAttributes();
$componentName = $att['name'];
diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php
index 5fc38315438..a98b325397b 100644
--- a/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php
+++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php
@@ -1,31 +1,15 @@
<?php
+
/**
- * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Search\Xml\Filter;
+use OCA\DAV\CalDAV\Search\SearchPlugin;
use Sabre\DAV\Exception\BadRequest;
use Sabre\Xml\Reader;
use Sabre\Xml\XmlDeserializable;
-use OCA\DAV\CalDAV\Search\SearchPlugin;
class LimitFilter implements XmlDeserializable {
@@ -34,12 +18,12 @@ class LimitFilter implements XmlDeserializable {
* @throws BadRequest
* @return int
*/
- static function xmlDeserialize(Reader $reader) {
+ public static function xmlDeserialize(Reader $reader) {
$value = $reader->parseInnerTree();
if (!is_int($value) && !is_string($value)) {
throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}limit has illegal value');
}
- return intval($value);
+ return (int)$value;
}
-} \ No newline at end of file
+}
diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php
index 7aac59809a2..ef438aa0258 100644
--- a/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php
+++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php
@@ -1,31 +1,15 @@
<?php
+
/**
- * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Search\Xml\Filter;
+use OCA\DAV\CalDAV\Search\SearchPlugin;
use Sabre\DAV\Exception\BadRequest;
use Sabre\Xml\Reader;
use Sabre\Xml\XmlDeserializable;
-use OCA\DAV\CalDAV\Search\SearchPlugin;
class OffsetFilter implements XmlDeserializable {
@@ -34,12 +18,12 @@ class OffsetFilter implements XmlDeserializable {
* @throws BadRequest
* @return int
*/
- static function xmlDeserialize(Reader $reader) {
+ public static function xmlDeserialize(Reader $reader) {
$value = $reader->parseInnerTree();
if (!is_int($value) && !is_string($value)) {
throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}offset has illegal value');
}
- return intval($value);
+ return (int)$value;
}
-} \ No newline at end of file
+}
diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php
index bfcb960d402..0c31f32348a 100644
--- a/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php
+++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php
@@ -1,31 +1,15 @@
<?php
+
/**
- * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Search\Xml\Filter;
+use OCA\DAV\CalDAV\Search\SearchPlugin;
use Sabre\DAV\Exception\BadRequest;
use Sabre\Xml\Reader;
use Sabre\Xml\XmlDeserializable;
-use OCA\DAV\CalDAV\Search\SearchPlugin;
class ParamFilter implements XmlDeserializable {
@@ -34,7 +18,7 @@ class ParamFilter implements XmlDeserializable {
* @throws BadRequest
* @return string
*/
- static function xmlDeserialize(Reader $reader) {
+ public static function xmlDeserialize(Reader $reader) {
$att = $reader->parseAttributes();
$property = $att['property'];
$parameter = $att['name'];
@@ -43,7 +27,6 @@ class ParamFilter implements XmlDeserializable {
if (!is_string($property)) {
throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}param-filter requires a valid property attribute');
-
}
if (!is_string($parameter)) {
throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}param-filter requires a valid parameter attribute');
diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php
index cfb2211fb59..251120e35cc 100644
--- a/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php
+++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php
@@ -1,31 +1,15 @@
<?php
+
/**
- * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Search\Xml\Filter;
+use OCA\DAV\CalDAV\Search\SearchPlugin;
use Sabre\DAV\Exception\BadRequest;
use Sabre\Xml\Reader;
use Sabre\Xml\XmlDeserializable;
-use OCA\DAV\CalDAV\Search\SearchPlugin;
class PropFilter implements XmlDeserializable {
@@ -34,7 +18,7 @@ class PropFilter implements XmlDeserializable {
* @throws BadRequest
* @return string
*/
- static function xmlDeserialize(Reader $reader) {
+ public static function xmlDeserialize(Reader $reader) {
$att = $reader->parseAttributes();
$componentName = $att['name'];
diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php
index ddd36818223..6d6bf958496 100644
--- a/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php
+++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php
@@ -1,31 +1,15 @@
<?php
+
/**
- * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Search\Xml\Filter;
+use OCA\DAV\CalDAV\Search\SearchPlugin;
use Sabre\DAV\Exception\BadRequest;
use Sabre\Xml\Reader;
use Sabre\Xml\XmlDeserializable;
-use OCA\DAV\CalDAV\Search\SearchPlugin;
class SearchTermFilter implements XmlDeserializable {
@@ -34,7 +18,7 @@ class SearchTermFilter implements XmlDeserializable {
* @throws BadRequest
* @return string
*/
- static function xmlDeserialize(Reader $reader) {
+ public static function xmlDeserialize(Reader $reader) {
$value = $reader->parseInnerTree();
if (!is_string($value)) {
throw new BadRequest('The {' . SearchPlugin::NS_Nextcloud . '}search-term has illegal value');
@@ -42,4 +26,4 @@ class SearchTermFilter implements XmlDeserializable {
return $value;
}
-} \ No newline at end of file
+}
diff --git a/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php
index e7c229c7b03..6ece88fa87b 100644
--- a/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php
+++ b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php
@@ -1,31 +1,15 @@
<?php
+
/**
- * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @license GNU AGPL version 3 or any later version
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
- *
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Search\Xml\Request;
+use OCA\DAV\CalDAV\Search\SearchPlugin;
use Sabre\DAV\Exception\BadRequest;
use Sabre\Xml\Reader;
use Sabre\Xml\XmlDeserializable;
-use OCA\DAV\CalDAV\Search\SearchPlugin;
/**
* CalendarSearchReport request parser.
@@ -82,22 +66,22 @@ class CalendarSearchReport implements XmlDeserializable {
* @param Reader $reader
* @return mixed
*/
- static function xmlDeserialize(Reader $reader) {
+ public static function xmlDeserialize(Reader $reader) {
$elems = $reader->parseInnerTree([
- '{http://nextcloud.com/ns}comp-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\CompFilter',
- '{http://nextcloud.com/ns}prop-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter',
+ '{http://nextcloud.com/ns}comp-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\CompFilter',
+ '{http://nextcloud.com/ns}prop-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter',
'{http://nextcloud.com/ns}param-filter' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\ParamFilter',
- '{http://nextcloud.com/ns}search-term' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter',
- '{http://nextcloud.com/ns}limit' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\LimitFilter',
- '{http://nextcloud.com/ns}offset' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\OffsetFilter',
- '{DAV:}prop' => 'Sabre\\Xml\\Element\\KeyValue',
+ '{http://nextcloud.com/ns}search-term' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter',
+ '{http://nextcloud.com/ns}limit' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\LimitFilter',
+ '{http://nextcloud.com/ns}offset' => 'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\OffsetFilter',
+ '{DAV:}prop' => 'Sabre\\Xml\\Element\\KeyValue',
]);
$newProps = [
- 'filters' => [],
+ 'filters' => [],
'properties' => [],
- 'limit' => null,
- 'offset' => null
+ 'limit' => null,
+ 'offset' => null
];
if (!is_array($elems)) {
diff --git a/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php b/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php
new file mode 100644
index 00000000000..311157994e2
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php
@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV\Security;
+
+use OC\Security\RateLimiting\Exception\RateLimitExceededException;
+use OC\Security\RateLimiting\Limiter;
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCA\DAV\Connector\Sabre\Exception\TooManyRequests;
+use OCP\IAppConfig;
+use OCP\IUserManager;
+use Psr\Log\LoggerInterface;
+use Sabre\DAV;
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAV\ServerPlugin;
+use function count;
+use function explode;
+
+class RateLimitingPlugin extends ServerPlugin {
+
+ private Limiter $limiter;
+
+ public function __construct(
+ Limiter $limiter,
+ private IUserManager $userManager,
+ private CalDavBackend $calDavBackend,
+ private LoggerInterface $logger,
+ private IAppConfig $config,
+ private ?string $userId,
+ ) {
+ $this->limiter = $limiter;
+ }
+
+ public function initialize(DAV\Server $server): void {
+ $server->on('beforeBind', [$this, 'beforeBind'], 1);
+ }
+
+ public function beforeBind(string $path): void {
+ if ($this->userId === null) {
+ // We only care about authenticated users here
+ return;
+ }
+ $user = $this->userManager->get($this->userId);
+ if ($user === null) {
+ // We only care about authenticated users here
+ return;
+ }
+
+ $pathParts = explode('/', $path);
+ if (count($pathParts) === 3 && $pathParts[0] === 'calendars') {
+ // Path looks like calendars/username/calendarname so a new calendar or subscription is created
+ try {
+ $this->limiter->registerUserRequest(
+ 'caldav-create-calendar',
+ $this->config->getValueInt('dav', 'rateLimitCalendarCreation', 10),
+ $this->config->getValueInt('dav', 'rateLimitPeriodCalendarCreation', 3600),
+ $user
+ );
+ } catch (RateLimitExceededException $e) {
+ throw new TooManyRequests('Too many calendars created', 0, $e);
+ }
+
+ $calendarLimit = $this->config->getValueInt('dav', 'maximumCalendarsSubscriptions', 30);
+ if ($calendarLimit === -1) {
+ return;
+ }
+ $numCalendars = $this->calDavBackend->getCalendarsForUserCount('principals/users/' . $user->getUID());
+ $numSubscriptions = $this->calDavBackend->getSubscriptionsForUserCount('principals/users/' . $user->getUID());
+
+ if (($numCalendars + $numSubscriptions) >= $calendarLimit) {
+ $this->logger->warning('Maximum number of calendars/subscriptions reached', [
+ 'calendars' => $numCalendars,
+ 'subscription' => $numSubscriptions,
+ 'limit' => $calendarLimit,
+ ]);
+ throw new Forbidden('Calendar limit reached', 0);
+ }
+ }
+ }
+
+}
diff --git a/apps/dav/lib/CalDAV/Sharing/Backend.php b/apps/dav/lib/CalDAV/Sharing/Backend.php
new file mode 100644
index 00000000000..fc5d65b5994
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Sharing/Backend.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV\Sharing;
+
+use OCA\DAV\Connector\Sabre\Principal;
+use OCA\DAV\DAV\Sharing\Backend as SharingBackend;
+use OCP\ICacheFactory;
+use OCP\IGroupManager;
+use OCP\IUserManager;
+use Psr\Log\LoggerInterface;
+
+class Backend extends SharingBackend {
+
+ public function __construct(
+ private IUserManager $userManager,
+ private IGroupManager $groupManager,
+ private Principal $principalBackend,
+ private ICacheFactory $cacheFactory,
+ private Service $service,
+ private LoggerInterface $logger,
+ ) {
+ parent::__construct($this->userManager, $this->groupManager, $this->principalBackend, $this->cacheFactory, $this->service, $this->logger);
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Sharing/Service.php b/apps/dav/lib/CalDAV/Sharing/Service.php
new file mode 100644
index 00000000000..4f0554f09bd
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Sharing/Service.php
@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV\Sharing;
+
+use OCA\DAV\DAV\Sharing\SharingMapper;
+use OCA\DAV\DAV\Sharing\SharingService;
+
+class Service extends SharingService {
+ protected string $resourceType = 'calendar';
+ public function __construct(
+ protected SharingMapper $mapper,
+ ) {
+ parent::__construct($mapper);
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Status/StatusService.php b/apps/dav/lib/CalDAV/Status/StatusService.php
new file mode 100644
index 00000000000..9ee0e9bf356
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Status/StatusService.php
@@ -0,0 +1,186 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Status;
+
+use DateTimeImmutable;
+use OC\Calendar\CalendarQuery;
+use OCA\DAV\CalDAV\CalendarImpl;
+use OCA\UserStatus\Service\StatusService as UserStatusService;
+use OCP\AppFramework\Db\DoesNotExistException;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\Calendar\IManager;
+use OCP\DB\Exception;
+use OCP\ICache;
+use OCP\ICacheFactory;
+use OCP\IUser as User;
+use OCP\IUserManager;
+use OCP\User\IAvailabilityCoordinator;
+use OCP\UserStatus\IUserStatus;
+use Psr\Log\LoggerInterface;
+use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp;
+
+class StatusService {
+ private ICache $cache;
+ public function __construct(
+ private ITimeFactory $timeFactory,
+ private IManager $calendarManager,
+ private IUserManager $userManager,
+ private UserStatusService $userStatusService,
+ private IAvailabilityCoordinator $availabilityCoordinator,
+ private ICacheFactory $cacheFactory,
+ private LoggerInterface $logger,
+ ) {
+ $this->cache = $cacheFactory->createLocal('CalendarStatusService');
+ }
+
+ public function processCalendarStatus(string $userId): void {
+ $user = $this->userManager->get($userId);
+ if ($user === null) {
+ return;
+ }
+
+ $availability = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user);
+ if ($availability !== null && $this->availabilityCoordinator->isInEffect($availability)) {
+ $this->logger->debug('An Absence is in effect, skipping calendar status check', ['user' => $userId]);
+ return;
+ }
+
+ $calendarEvents = $this->cache->get($userId);
+ if ($calendarEvents === null) {
+ $calendarEvents = $this->getCalendarEvents($user);
+ $this->cache->set($userId, $calendarEvents, 300);
+ }
+
+ if (empty($calendarEvents)) {
+ try {
+ $this->userStatusService->revertUserStatus($userId, IUserStatus::MESSAGE_CALENDAR_BUSY);
+ } catch (Exception $e) {
+ if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
+ // A different process might have written another status
+ // update to the DB while we're processing our stuff.
+ // We cannot safely restore the status as we don't know which one is valid at this point
+ // So let's silently log this one and exit
+ $this->logger->debug('Unique constraint violation for live user status', ['exception' => $e]);
+ return;
+ }
+ }
+ $this->logger->debug('No calendar events found for status check', ['user' => $userId]);
+ return;
+ }
+
+ try {
+ $currentStatus = $this->userStatusService->findByUserId($userId);
+ // Was the status set by anything other than the calendar automation?
+ $userStatusTimestamp = $currentStatus->getIsUserDefined() && $currentStatus->getMessageId() !== IUserStatus::MESSAGE_CALENDAR_BUSY ? $currentStatus->getStatusTimestamp() : null;
+ } catch (DoesNotExistException) {
+ $userStatusTimestamp = null;
+ $currentStatus = null;
+ }
+
+ if (($currentStatus !== null && $currentStatus->getMessageId() === IUserStatus::MESSAGE_CALL)
+ || ($currentStatus !== null && $currentStatus->getStatus() === IUserStatus::DND)
+ || ($currentStatus !== null && $currentStatus->getStatus() === IUserStatus::INVISIBLE)) {
+ // We don't overwrite the call status, DND status or Invisible status
+ $this->logger->debug('Higher priority status detected, skipping calendar status change', ['user' => $userId]);
+ return;
+ }
+
+ // Filter events to see if we have any that apply to the calendar status
+ $applicableEvents = array_filter($calendarEvents, static function (array $calendarEvent) use ($userStatusTimestamp): bool {
+ if (empty($calendarEvent['objects'])) {
+ return false;
+ }
+ $component = $calendarEvent['objects'][0];
+ if (isset($component['X-NEXTCLOUD-OUT-OF-OFFICE'])) {
+ return false;
+ }
+ if (isset($component['DTSTART']) && $userStatusTimestamp !== null) {
+ /** @var DateTimeImmutable $dateTime */
+ $dateTime = $component['DTSTART'][0];
+ if ($dateTime instanceof DateTimeImmutable && $userStatusTimestamp > $dateTime->getTimestamp()) {
+ return false;
+ }
+ }
+ // Ignore events that are transparent
+ if (isset($component['TRANSP']) && strcasecmp($component['TRANSP'][0], 'TRANSPARENT') === 0) {
+ return false;
+ }
+ return true;
+ });
+
+ if (empty($applicableEvents)) {
+ try {
+ $this->userStatusService->revertUserStatus($userId, IUserStatus::MESSAGE_CALENDAR_BUSY);
+ } catch (Exception $e) {
+ if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
+ // A different process might have written another status
+ // update to the DB while we're processing our stuff.
+ // We cannot safely restore the status as we don't know which one is valid at this point
+ // So let's silently log this one and exit
+ $this->logger->debug('Unique constraint violation for live user status', ['exception' => $e]);
+ return;
+ }
+ }
+ $this->logger->debug('No status relevant events found, skipping calendar status change', ['user' => $userId]);
+ return;
+ }
+
+ // Only update the status if it's neccesary otherwise we mess up the timestamp
+ if ($currentStatus === null || $currentStatus->getMessageId() !== IUserStatus::MESSAGE_CALENDAR_BUSY) {
+ // One event that fulfills all status conditions is enough
+ // 1. Not an OOO event
+ // 2. Current user status (that is not a calendar status) was not set after the start of this event
+ // 3. Event is not set to be transparent
+ $count = count($applicableEvents);
+ $this->logger->debug("Found $count applicable event(s), changing user status", ['user' => $userId]);
+ $this->userStatusService->setUserStatus(
+ $userId,
+ IUserStatus::BUSY,
+ IUserStatus::MESSAGE_CALENDAR_BUSY,
+ true
+ );
+ }
+ }
+
+ private function getCalendarEvents(User $user): array {
+ $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $user->getUID());
+ if (empty($calendars)) {
+ return [];
+ }
+
+ $query = $this->calendarManager->newQuery('principals/users/' . $user->getUID());
+ foreach ($calendars as $calendarObject) {
+ // We can only work with a calendar if it exposes its scheduling information
+ if (!$calendarObject instanceof CalendarImpl) {
+ continue;
+ }
+
+ $sct = $calendarObject->getSchedulingTransparency();
+ if ($sct !== null && strtolower($sct->getValue()) == ScheduleCalendarTransp::TRANSPARENT) {
+ // If a calendar is marked as 'transparent', it means we must
+ // ignore it for free-busy purposes.
+ continue;
+ }
+ $query->addSearchCalendar($calendarObject->getUri());
+ }
+
+ $dtStart = DateTimeImmutable::createFromMutable($this->timeFactory->getDateTime());
+ $dtEnd = DateTimeImmutable::createFromMutable($this->timeFactory->getDateTime('+5 minutes'));
+
+ // Only query the calendars when there's any to search
+ if ($query instanceof CalendarQuery && !empty($query->getCalendarUris())) {
+ // Query the next hour
+ $query->setTimerangeStart($dtStart);
+ $query->setTimerangeEnd($dtEnd);
+ return $this->calendarManager->searchForPrincipal($query);
+ }
+
+ return [];
+ }
+}
diff --git a/apps/dav/lib/CalDAV/TimeZoneFactory.php b/apps/dav/lib/CalDAV/TimeZoneFactory.php
new file mode 100644
index 00000000000..36a2c97be82
--- /dev/null
+++ b/apps/dav/lib/CalDAV/TimeZoneFactory.php
@@ -0,0 +1,213 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV;
+
+use DateTimeZone;
+
+/**
+ * Class to generate DateTimeZone object with automated Microsoft and IANA handling
+ *
+ * @since 31.0.0
+ */
+class TimeZoneFactory {
+
+ /**
+ * conversion table of Microsoft time zones to IANA time zones
+ *
+ * @var array<string,string> MS2IANA
+ */
+ private const MS2IANA = [
+ 'AUS Central Standard Time' => 'Australia/Darwin',
+ 'Aus Central W. Standard Time' => 'Australia/Eucla',
+ 'AUS Eastern Standard Time' => 'Australia/Sydney',
+ 'Afghanistan Standard Time' => 'Asia/Kabul',
+ 'Alaskan Standard Time' => 'America/Anchorage',
+ 'Aleutian Standard Time' => 'America/Adak',
+ 'Altai Standard Time' => 'Asia/Barnaul',
+ 'Arab Standard Time' => 'Asia/Riyadh',
+ 'Arabian Standard Time' => 'Asia/Dubai',
+ 'Arabic Standard Time' => 'Asia/Baghdad',
+ 'Argentina Standard Time' => 'America/Buenos_Aires',
+ 'Astrakhan Standard Time' => 'Europe/Astrakhan',
+ 'Atlantic Standard Time' => 'America/Halifax',
+ 'Azerbaijan Standard Time' => 'Asia/Baku',
+ 'Azores Standard Time' => 'Atlantic/Azores',
+ 'Bahia Standard Time' => 'America/Bahia',
+ 'Bangladesh Standard Time' => 'Asia/Dhaka',
+ 'Belarus Standard Time' => 'Europe/Minsk',
+ 'Bougainville Standard Time' => 'Pacific/Bougainville',
+ 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde',
+ 'Canada Central Standard Time' => 'America/Regina',
+ 'Caucasus Standard Time' => 'Asia/Yerevan',
+ 'Cen. Australia Standard Time' => 'Australia/Adelaide',
+ 'Central America Standard Time' => 'America/Guatemala',
+ 'Central Asia Standard Time' => 'Asia/Almaty',
+ 'Central Brazilian Standard Time' => 'America/Cuiaba',
+ 'Central Europe Standard Time' => 'Europe/Budapest',
+ 'Central European Standard Time' => 'Europe/Warsaw',
+ 'Central Pacific Standard Time' => 'Pacific/Guadalcanal',
+ 'Central Standard Time' => 'America/Chicago',
+ 'Central Standard Time (Mexico)' => 'America/Mexico_City',
+ 'Chatham Islands Standard Time' => 'Pacific/Chatham',
+ 'China Standard Time' => 'Asia/Shanghai',
+ 'Coordinated Universal Time' => 'UTC',
+ 'Cuba Standard Time' => 'America/Havana',
+ 'Dateline Standard Time' => 'Etc/GMT+12',
+ 'E. Africa Standard Time' => 'Africa/Nairobi',
+ 'E. Australia Standard Time' => 'Australia/Brisbane',
+ 'E. Europe Standard Time' => 'Europe/Chisinau',
+ 'E. South America Standard Time' => 'America/Sao_Paulo',
+ 'Easter Island Standard Time' => 'Pacific/Easter',
+ 'Eastern Standard Time' => 'America/Toronto',
+ 'Eastern Standard Time (Mexico)' => 'America/Cancun',
+ 'Egypt Standard Time' => 'Africa/Cairo',
+ 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg',
+ 'FLE Standard Time' => 'Europe/Kiev',
+ 'Fiji Standard Time' => 'Pacific/Fiji',
+ 'GMT Standard Time' => 'Europe/London',
+ 'GTB Standard Time' => 'Europe/Bucharest',
+ 'Georgian Standard Time' => 'Asia/Tbilisi',
+ 'Greenland Standard Time' => 'America/Godthab',
+ 'Greenland (Danmarkshavn)' => 'America/Godthab',
+ 'Greenwich Standard Time' => 'Atlantic/Reykjavik',
+ 'Haiti Standard Time' => 'America/Port-au-Prince',
+ 'Hawaiian Standard Time' => 'Pacific/Honolulu',
+ 'India Standard Time' => 'Asia/Kolkata',
+ 'Iran Standard Time' => 'Asia/Tehran',
+ 'Israel Standard Time' => 'Asia/Jerusalem',
+ 'Jordan Standard Time' => 'Asia/Amman',
+ 'Kaliningrad Standard Time' => 'Europe/Kaliningrad',
+ 'Kamchatka Standard Time' => 'Asia/Kamchatka',
+ 'Korea Standard Time' => 'Asia/Seoul',
+ 'Libya Standard Time' => 'Africa/Tripoli',
+ 'Line Islands Standard Time' => 'Pacific/Kiritimati',
+ 'Lord Howe Standard Time' => 'Australia/Lord_Howe',
+ 'Magadan Standard Time' => 'Asia/Magadan',
+ 'Magallanes Standard Time' => 'America/Punta_Arenas',
+ 'Malaysia Standard Time' => 'Asia/Kuala_Lumpur',
+ 'Marquesas Standard Time' => 'Pacific/Marquesas',
+ 'Mauritius Standard Time' => 'Indian/Mauritius',
+ 'Mid-Atlantic Standard Time' => 'Atlantic/South_Georgia',
+ 'Middle East Standard Time' => 'Asia/Beirut',
+ 'Montevideo Standard Time' => 'America/Montevideo',
+ 'Morocco Standard Time' => 'Africa/Casablanca',
+ 'Mountain Standard Time' => 'America/Denver',
+ 'Mountain Standard Time (Mexico)' => 'America/Chihuahua',
+ 'Myanmar Standard Time' => 'Asia/Rangoon',
+ 'N. Central Asia Standard Time' => 'Asia/Novosibirsk',
+ 'Namibia Standard Time' => 'Africa/Windhoek',
+ 'Nepal Standard Time' => 'Asia/Kathmandu',
+ 'New Zealand Standard Time' => 'Pacific/Auckland',
+ 'Newfoundland Standard Time' => 'America/St_Johns',
+ 'Norfolk Standard Time' => 'Pacific/Norfolk',
+ 'North Asia East Standard Time' => 'Asia/Irkutsk',
+ 'North Asia Standard Time' => 'Asia/Krasnoyarsk',
+ 'North Korea Standard Time' => 'Asia/Pyongyang',
+ 'Omsk Standard Time' => 'Asia/Omsk',
+ 'Pacific SA Standard Time' => 'America/Santiago',
+ 'Pacific Standard Time' => 'America/Los_Angeles',
+ 'Pacific Standard Time (Mexico)' => 'America/Tijuana',
+ 'Pakistan Standard Time' => 'Asia/Karachi',
+ 'Paraguay Standard Time' => 'America/Asuncion',
+ 'Qyzylorda Standard Time' => 'Asia/Qyzylorda',
+ 'Romance Standard Time' => 'Europe/Paris',
+ 'Russian Standard Time' => 'Europe/Moscow',
+ 'Russia Time Zone 10' => 'Asia/Srednekolymsk',
+ 'Russia Time Zone 3' => 'Europe/Samara',
+ 'SA Eastern Standard Time' => 'America/Cayenne',
+ 'SA Pacific Standard Time' => 'America/Bogota',
+ 'SA Western Standard Time' => 'America/La_Paz',
+ 'SE Asia Standard Time' => 'Asia/Bangkok',
+ 'Saint Pierre Standard Time' => 'America/Miquelon',
+ 'Sakhalin Standard Time' => 'Asia/Sakhalin',
+ 'Samoa Standard Time' => 'Pacific/Apia',
+ 'Sao Tome Standard Time' => 'Africa/Sao_Tome',
+ 'Saratov Standard Time' => 'Europe/Saratov',
+ 'Singapore Standard Time' => 'Asia/Singapore',
+ 'South Africa Standard Time' => 'Africa/Johannesburg',
+ 'South Sudan Standard Time' => 'Africa/Juba',
+ 'Sri Lanka Standard Time' => 'Asia/Colombo',
+ 'Sudan Standard Time' => 'Africa/Khartoum',
+ 'Syria Standard Time' => 'Asia/Damascus',
+ 'Taipei Standard Time' => 'Asia/Taipei',
+ 'Tasmania Standard Time' => 'Australia/Hobart',
+ 'Tocantins Standard Time' => 'America/Araguaina',
+ 'Tokyo Standard Time' => 'Asia/Tokyo',
+ 'Tomsk Standard Time' => 'Asia/Tomsk',
+ 'Tonga Standard Time' => 'Pacific/Tongatapu',
+ 'Transbaikal Standard Time' => 'Asia/Chita',
+ 'Turkey Standard Time' => 'Europe/Istanbul',
+ 'Turks And Caicos Standard Time' => 'America/Grand_Turk',
+ 'US Eastern Standard Time' => 'America/Indianapolis',
+ 'US Mountain Standard Time' => 'America/Phoenix',
+ 'UTC' => 'Etc/GMT',
+ 'UTC+13' => 'Etc/GMT-13',
+ 'UTC+12' => 'Etc/GMT-12',
+ 'UTC-02' => 'Etc/GMT+2',
+ 'UTC-09' => 'Etc/GMT+9',
+ 'UTC-11' => 'Etc/GMT+11',
+ 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar',
+ 'Venezuela Standard Time' => 'America/Caracas',
+ 'Vladivostok Standard Time' => 'Asia/Vladivostok',
+ 'Volgograd Standard Time' => 'Europe/Volgograd',
+ 'W. Australia Standard Time' => 'Australia/Perth',
+ 'W. Central Africa Standard Time' => 'Africa/Lagos',
+ 'W. Europe Standard Time' => 'Europe/Berlin',
+ 'W. Mongolia Standard Time' => 'Asia/Hovd',
+ 'West Asia Standard Time' => 'Asia/Tashkent',
+ 'West Bank Standard Time' => 'Asia/Hebron',
+ 'West Pacific Standard Time' => 'Pacific/Port_Moresby',
+ 'West Samoa Standard Time' => 'Pacific/Apia',
+ 'Yakutsk Standard Time' => 'Asia/Yakutsk',
+ 'Yukon Standard Time' => 'America/Whitehorse',
+ 'Yekaterinburg Standard Time' => 'Asia/Yekaterinburg',
+ ];
+
+ /**
+ * Determines if given time zone name is a Microsoft time zone
+ *
+ * @since 31.0.0
+ *
+ * @param string $name time zone name
+ *
+ * @return bool
+ */
+ public static function isMS(string $name): bool {
+ return isset(self::MS2IANA[$name]);
+ }
+
+ /**
+ * Converts Microsoft time zone name to IANA time zone name
+ *
+ * @since 31.0.0
+ *
+ * @param string $name microsoft time zone
+ *
+ * @return string|null valid IANA time zone name on success, or null on failure
+ */
+ public static function toIANA(string $name): ?string {
+ return isset(self::MS2IANA[$name]) ? self::MS2IANA[$name] : null;
+ }
+
+ /**
+ * Generates DateTimeZone object for given time zone name
+ *
+ * @since 31.0.0
+ *
+ * @param string $name time zone name
+ *
+ * @return DateTimeZone|null
+ */
+ public function fromName(string $name): ?DateTimeZone {
+ // if zone name is MS convert to IANA, otherwise just assume the zone is IANA
+ $zone = @timezone_open(self::toIANA($name) ?? $name);
+ return ($zone instanceof DateTimeZone) ? $zone : null;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/TimezoneService.php b/apps/dav/lib/CalDAV/TimezoneService.php
new file mode 100644
index 00000000000..a7709bde0f9
--- /dev/null
+++ b/apps/dav/lib/CalDAV/TimezoneService.php
@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV;
+
+use OCA\DAV\Db\PropertyMapper;
+use OCP\Calendar\ICalendar;
+use OCP\Calendar\IManager;
+use OCP\IConfig;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Component\VTimeZone;
+use Sabre\VObject\Reader;
+use function array_reduce;
+
+class TimezoneService {
+
+ public function __construct(
+ private IConfig $config,
+ private PropertyMapper $propertyMapper,
+ private IManager $calendarManager,
+ ) {
+ }
+
+ public function getUserTimezone(string $userId): ?string {
+ $fromConfig = $this->config->getUserValue(
+ $userId,
+ 'core',
+ 'timezone',
+ );
+ if ($fromConfig !== '') {
+ return $fromConfig;
+ }
+
+ $availabilityPropPath = 'calendars/' . $userId . '/inbox';
+ $availabilityProp = '{' . Plugin::NS_CALDAV . '}calendar-availability';
+ $availabilities = $this->propertyMapper->findPropertyByPathAndName($userId, $availabilityPropPath, $availabilityProp);
+ if (!empty($availabilities)) {
+ $availability = $availabilities[0]->getPropertyvalue();
+ /** @var VCalendar $vCalendar */
+ $vCalendar = Reader::read($availability);
+ /** @var VTimeZone $vTimezone */
+ $vTimezone = $vCalendar->VTIMEZONE;
+ // Sabre has a fallback to date_default_timezone_get
+ return $vTimezone->getTimeZone()->getName();
+ }
+
+ $principal = 'principals/users/' . $userId;
+ $uri = $this->config->getUserValue($userId, 'dav', 'defaultCalendar', CalDavBackend::PERSONAL_CALENDAR_URI);
+ $calendars = $this->calendarManager->getCalendarsForPrincipal($principal);
+
+ /** @var ?VTimeZone $personalCalendarTimezone */
+ $personalCalendarTimezone = array_reduce($calendars, function (?VTimeZone $acc, ICalendar $calendar) use ($uri) {
+ if ($acc !== null) {
+ return $acc;
+ }
+ if ($calendar->getUri() === $uri && !$calendar->isDeleted() && $calendar instanceof CalendarImpl) {
+ return $calendar->getSchedulingTimezone();
+ }
+ return null;
+ });
+ if ($personalCalendarTimezone !== null) {
+ return $personalCalendarTimezone->getTimeZone()->getName();
+ }
+
+ // No timezone in the personalCalendarTimezone calendar or no personalCalendarTimezone calendar
+ // Loop through all calendars until we find a timezone.
+ /** @var ?VTimeZone $firstTimezone */
+ $firstTimezone = array_reduce($calendars, function (?VTimeZone $acc, ICalendar $calendar) {
+ if ($acc !== null) {
+ return $acc;
+ }
+ if (!$calendar->isDeleted() && $calendar instanceof CalendarImpl) {
+ return $calendar->getSchedulingTimezone();
+ }
+ return null;
+ });
+ if ($firstTimezone !== null) {
+ return $firstTimezone->getTimeZone()->getName();
+ }
+ return null;
+ }
+
+ public function getDefaultTimezone(): string {
+ return $this->config->getSystemValueString('default_timezone', 'UTC');
+ }
+
+}
diff --git a/apps/dav/lib/CalDAV/TipBroker.php b/apps/dav/lib/CalDAV/TipBroker.php
new file mode 100644
index 00000000000..16e68fde1f0
--- /dev/null
+++ b/apps/dav/lib/CalDAV/TipBroker.php
@@ -0,0 +1,187 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV;
+
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\ITip\Broker;
+use Sabre\VObject\ITip\Message;
+
+class TipBroker extends Broker {
+
+ public $significantChangeProperties = [
+ 'DTSTART',
+ 'DTEND',
+ 'DURATION',
+ 'DUE',
+ 'RRULE',
+ 'RDATE',
+ 'EXDATE',
+ 'STATUS',
+ 'SUMMARY',
+ 'DESCRIPTION',
+ 'LOCATION',
+
+ ];
+
+ /**
+ * This method is used in cases where an event got updated, and we
+ * potentially need to send emails to attendees to let them know of updates
+ * in the events.
+ *
+ * We will detect which attendees got added, which got removed and create
+ * specific messages for these situations.
+ *
+ * @return array
+ */
+ protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo) {
+ // Merging attendee lists.
+ $attendees = [];
+ foreach ($oldEventInfo['attendees'] as $attendee) {
+ $attendees[$attendee['href']] = [
+ 'href' => $attendee['href'],
+ 'oldInstances' => $attendee['instances'],
+ 'newInstances' => [],
+ 'name' => $attendee['name'],
+ 'forceSend' => null,
+ ];
+ }
+ foreach ($eventInfo['attendees'] as $attendee) {
+ if (isset($attendees[$attendee['href']])) {
+ $attendees[$attendee['href']]['name'] = $attendee['name'];
+ $attendees[$attendee['href']]['newInstances'] = $attendee['instances'];
+ $attendees[$attendee['href']]['forceSend'] = $attendee['forceSend'];
+ } else {
+ $attendees[$attendee['href']] = [
+ 'href' => $attendee['href'],
+ 'oldInstances' => [],
+ 'newInstances' => $attendee['instances'],
+ 'name' => $attendee['name'],
+ 'forceSend' => $attendee['forceSend'],
+ ];
+ }
+ }
+
+ $messages = [];
+
+ foreach ($attendees as $attendee) {
+ // An organizer can also be an attendee. We should not generate any
+ // messages for those.
+ if ($attendee['href'] === $eventInfo['organizer']) {
+ continue;
+ }
+
+ $message = new Message();
+ $message->uid = $eventInfo['uid'];
+ $message->component = 'VEVENT';
+ $message->sequence = $eventInfo['sequence'];
+ $message->sender = $eventInfo['organizer'];
+ $message->senderName = $eventInfo['organizerName'];
+ $message->recipient = $attendee['href'];
+ $message->recipientName = $attendee['name'];
+
+ // Creating the new iCalendar body.
+ $icalMsg = new VCalendar();
+
+ foreach ($calendar->select('VTIMEZONE') as $timezone) {
+ $icalMsg->add(clone $timezone);
+ }
+ // If there are no instances the attendee is a part of, it means
+ // the attendee was removed and we need to send them a CANCEL message.
+ // Also If the meeting STATUS property was changed to CANCELLED
+ // we need to send the attendee a CANCEL message.
+ if (!$attendee['newInstances'] || $eventInfo['status'] === 'CANCELLED') {
+
+ $message->method = $icalMsg->METHOD = 'CANCEL';
+ $message->significantChange = true;
+ // clone base event
+ $event = clone $eventInfo['instances']['master'];
+ // alter some properties
+ unset($event->ATTENDEE);
+ $event->add('ATTENDEE', $attendee['href'], ['CN' => $attendee['name'],]);
+ $event->DTSTAMP = gmdate('Ymd\\THis\\Z');
+ $event->SEQUENCE = $message->sequence;
+ $icalMsg->add($event);
+
+ } else {
+ // The attendee gets the updated event body
+ $message->method = $icalMsg->METHOD = 'REQUEST';
+
+ // We need to find out that this change is significant. If it's
+ // not, systems may opt to not send messages.
+ //
+ // We do this based on the 'significantChangeHash' which is
+ // some value that changes if there's a certain set of
+ // properties changed in the event, or simply if there's a
+ // difference in instances that the attendee is invited to.
+
+ $oldAttendeeInstances = array_keys($attendee['oldInstances']);
+ $newAttendeeInstances = array_keys($attendee['newInstances']);
+
+ $message->significantChange
+ = $attendee['forceSend'] === 'REQUEST'
+ || count($oldAttendeeInstances) !== count($newAttendeeInstances)
+ || count(array_diff($oldAttendeeInstances, $newAttendeeInstances)) > 0
+ || $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash'];
+
+ foreach ($attendee['newInstances'] as $instanceId => $instanceInfo) {
+ $currentEvent = clone $eventInfo['instances'][$instanceId];
+ if ($instanceId === 'master') {
+ // We need to find a list of events that the attendee
+ // is not a part of to add to the list of exceptions.
+ $exceptions = [];
+ foreach ($eventInfo['instances'] as $instanceId => $vevent) {
+ if (!isset($attendee['newInstances'][$instanceId])) {
+ $exceptions[] = $instanceId;
+ }
+ }
+
+ // If there were exceptions, we need to add it to an
+ // existing EXDATE property, if it exists.
+ if ($exceptions) {
+ if (isset($currentEvent->EXDATE)) {
+ $currentEvent->EXDATE->setParts(array_merge(
+ $currentEvent->EXDATE->getParts(),
+ $exceptions
+ ));
+ } else {
+ $currentEvent->EXDATE = $exceptions;
+ }
+ }
+
+ // Cleaning up any scheduling information that
+ // shouldn't be sent along.
+ unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']);
+ unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']);
+
+ foreach ($currentEvent->ATTENDEE as $attendee) {
+ unset($attendee['SCHEDULE-FORCE-SEND']);
+ unset($attendee['SCHEDULE-STATUS']);
+
+ // We're adding PARTSTAT=NEEDS-ACTION to ensure that
+ // iOS shows an "Inbox Item"
+ if (!isset($attendee['PARTSTAT'])) {
+ $attendee['PARTSTAT'] = 'NEEDS-ACTION';
+ }
+ }
+ }
+
+ $currentEvent->DTSTAMP = gmdate('Ymd\\THis\\Z');
+ $icalMsg->add($currentEvent);
+ }
+ }
+
+ $message->message = $icalMsg;
+ $messages[] = $message;
+ }
+
+ return $messages;
+ }
+
+}
diff --git a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php
new file mode 100644
index 00000000000..d8c429f2056
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php
@@ -0,0 +1,106 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Trashbin;
+
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCA\DAV\CalDAV\IRestorable;
+use Sabre\CalDAV\ICalendarObject;
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAVACL\ACLTrait;
+use Sabre\DAVACL\IACL;
+
+class DeletedCalendarObject implements IACL, ICalendarObject, IRestorable {
+ use ACLTrait;
+
+ public function __construct(
+ private string $name,
+ /** @var mixed[] */
+ private array $objectData,
+ private string $principalUri,
+ private CalDavBackend $calDavBackend,
+ ) {
+ }
+
+ public function delete() {
+ $this->calDavBackend->deleteCalendarObject(
+ $this->objectData['calendarid'],
+ $this->objectData['uri'],
+ CalDavBackend::CALENDAR_TYPE_CALENDAR,
+ true
+ );
+ }
+
+ public function getName() {
+ return $this->name;
+ }
+
+ public function setName($name) {
+ throw new Forbidden();
+ }
+
+ public function getLastModified() {
+ return 0;
+ }
+
+ public function put($data) {
+ throw new Forbidden();
+ }
+
+ public function get() {
+ return $this->objectData['calendardata'];
+ }
+
+ public function getContentType() {
+ $mime = 'text/calendar; charset=utf-8';
+ if (isset($this->objectData['component']) && $this->objectData['component']) {
+ $mime .= '; component=' . $this->objectData['component'];
+ }
+
+ return $mime;
+ }
+
+ public function getETag() {
+ return $this->objectData['etag'];
+ }
+
+ public function getSize() {
+ return (int)$this->objectData['size'];
+ }
+
+ public function restore(): void {
+ $this->calDavBackend->restoreCalendarObject($this->objectData);
+ }
+
+ public function getDeletedAt(): ?int {
+ return $this->objectData['deleted_at'] ? (int)$this->objectData['deleted_at'] : null;
+ }
+
+ public function getCalendarUri(): string {
+ return $this->objectData['calendaruri'];
+ }
+
+ public function getACL(): array {
+ return [
+ [
+ 'privilege' => '{DAV:}read', // For queries
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{DAV:}unbind', // For moving and deletion
+ 'principal' => '{DAV:}owner',
+ 'protected' => true,
+ ],
+ ];
+ }
+
+ public function getOwner() {
+ return $this->principalUri;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php
new file mode 100644
index 00000000000..f75e19689f1
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php
@@ -0,0 +1,133 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Trashbin;
+
+use OCA\DAV\CalDAV\CalDavBackend;
+use Sabre\CalDAV\ICalendarObjectContainer;
+use Sabre\DAV\Exception\BadRequest;
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAV\Exception\NotFound;
+use Sabre\DAV\Exception\NotImplemented;
+use Sabre\DAVACL\ACLTrait;
+use Sabre\DAVACL\IACL;
+use function array_map;
+use function implode;
+use function preg_match;
+
+class DeletedCalendarObjectsCollection implements ICalendarObjectContainer, IACL {
+ use ACLTrait;
+
+ public const NAME = 'objects';
+
+ public function __construct(
+ protected CalDavBackend $caldavBackend,
+ /** @var mixed[] */
+ private array $principalInfo,
+ ) {
+ }
+
+ /**
+ * @see \OCA\DAV\CalDAV\Trashbin\DeletedCalendarObjectsCollection::calendarQuery
+ */
+ public function getChildren() {
+ throw new NotImplemented();
+ }
+
+ public function getChild($name) {
+ if (!preg_match("/(\d+)\\.ics/", $name, $matches)) {
+ throw new NotFound();
+ }
+
+ $data = $this->caldavBackend->getCalendarObjectById(
+ $this->principalInfo['uri'],
+ (int)$matches[1],
+ );
+
+ // If the object hasn't been deleted yet then we don't want to find it here
+ if ($data === null) {
+ throw new NotFound();
+ }
+ if (!isset($data['deleted_at'])) {
+ throw new BadRequest('The calendar object you\'re trying to restore is not marked as deleted');
+ }
+
+ return new DeletedCalendarObject(
+ $this->getRelativeObjectPath($data),
+ $data,
+ $this->principalInfo['uri'],
+ $this->caldavBackend
+ );
+ }
+
+ public function createFile($name, $data = null) {
+ throw new Forbidden();
+ }
+
+ public function createDirectory($name) {
+ throw new Forbidden();
+ }
+
+ public function childExists($name) {
+ try {
+ $this->getChild($name);
+ } catch (NotFound $e) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function delete() {
+ throw new Forbidden();
+ }
+
+ public function getName(): string {
+ return self::NAME;
+ }
+
+ public function setName($name) {
+ throw new Forbidden();
+ }
+
+ public function getLastModified(): int {
+ return 0;
+ }
+
+ public function calendarQuery(array $filters) {
+ return array_map(function (array $calendarObjectInfo) {
+ return $this->getRelativeObjectPath($calendarObjectInfo);
+ }, $this->caldavBackend->getDeletedCalendarObjectsByPrincipal($this->principalInfo['uri']));
+ }
+
+ private function getRelativeObjectPath(array $calendarInfo): string {
+ return implode(
+ '.',
+ [$calendarInfo['id'], 'ics'],
+ );
+ }
+
+ public function getOwner() {
+ return $this->principalInfo['uri'];
+ }
+
+ public function getACL(): array {
+ return [
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->getOwner(),
+ 'protected' => true,
+ ],
+ [
+ 'privilege' => '{DAV:}unbind',
+ 'principal' => '{DAV:}owner',
+ 'protected' => true,
+ ]
+ ];
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Trashbin/Plugin.php b/apps/dav/lib/CalDAV/Trashbin/Plugin.php
new file mode 100644
index 00000000000..6f58b1f3110
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Trashbin/Plugin.php
@@ -0,0 +1,114 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Trashbin;
+
+use Closure;
+use DateTimeImmutable;
+use DateTimeInterface;
+use OCA\DAV\CalDAV\Calendar;
+use OCA\DAV\CalDAV\RetentionService;
+use OCP\IRequest;
+use Sabre\DAV\Exception\NotFound;
+use Sabre\DAV\INode;
+use Sabre\DAV\PropFind;
+use Sabre\DAV\Server;
+use Sabre\DAV\ServerPlugin;
+use Sabre\HTTP\RequestInterface;
+use Sabre\HTTP\ResponseInterface;
+use function array_slice;
+use function implode;
+
+class Plugin extends ServerPlugin {
+ public const PROPERTY_DELETED_AT = '{http://nextcloud.com/ns}deleted-at';
+ public const PROPERTY_CALENDAR_URI = '{http://nextcloud.com/ns}calendar-uri';
+ public const PROPERTY_RETENTION_DURATION = '{http://nextcloud.com/ns}trash-bin-retention-duration';
+
+ /** @var bool */
+ private $disableTrashbin;
+
+ /** @var Server */
+ private $server;
+
+ public function __construct(
+ IRequest $request,
+ private RetentionService $retentionService,
+ ) {
+ $this->disableTrashbin = $request->getHeader('X-NC-CalDAV-No-Trashbin') === '1';
+ }
+
+ public function initialize(Server $server): void {
+ $this->server = $server;
+ $server->on('beforeMethod:*', [$this, 'beforeMethod']);
+ $server->on('propFind', Closure::fromCallable([$this, 'propFind']));
+ }
+
+ public function beforeMethod(RequestInterface $request, ResponseInterface $response): void {
+ if (!$this->disableTrashbin) {
+ return;
+ }
+
+ $path = $request->getPath();
+ $pathParts = explode('/', ltrim($path, '/'));
+ if (\count($pathParts) < 3) {
+ // We are looking for a path like calendars/username/calendarname
+ return;
+ }
+
+ // $calendarPath will look like calendars/username/calendarname
+ $calendarPath = implode(
+ '/',
+ array_slice($pathParts, 0, 3)
+ );
+ try {
+ $calendar = $this->server->tree->getNodeForPath($calendarPath);
+ if (!($calendar instanceof Calendar)) {
+ // This is odd
+ return;
+ }
+
+ /** @var Calendar $calendar */
+ $calendar->disableTrashbin();
+ } catch (NotFound $ex) {
+ return;
+ }
+ }
+
+ private function propFind(
+ PropFind $propFind,
+ INode $node): void {
+ if ($node instanceof DeletedCalendarObject) {
+ $propFind->handle(self::PROPERTY_DELETED_AT, function () use ($node) {
+ $ts = $node->getDeletedAt();
+ if ($ts === null) {
+ return null;
+ }
+
+ return (new DateTimeImmutable())
+ ->setTimestamp($ts)
+ ->format(DateTimeInterface::ATOM);
+ });
+ $propFind->handle(self::PROPERTY_CALENDAR_URI, function () use ($node) {
+ return $node->getCalendarUri();
+ });
+ }
+ if ($node instanceof TrashbinHome) {
+ $propFind->handle(self::PROPERTY_RETENTION_DURATION, function () use ($node) {
+ return $this->retentionService->getDuration();
+ });
+ }
+ }
+
+ public function getFeatures(): array {
+ return ['nc-calendar-trashbin'];
+ }
+
+ public function getPluginName(): string {
+ return 'nc-calendar-trashbin';
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php b/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php
new file mode 100644
index 00000000000..6641148de2b
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php
@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Trashbin;
+
+use OCA\DAV\CalDAV\IRestorable;
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAV\Exception\NotFound;
+use Sabre\DAV\ICollection;
+use Sabre\DAV\IMoveTarget;
+use Sabre\DAV\INode;
+
+class RestoreTarget implements ICollection, IMoveTarget {
+ public const NAME = 'restore';
+
+ public function createFile($name, $data = null) {
+ throw new Forbidden();
+ }
+
+ public function createDirectory($name) {
+ throw new Forbidden();
+ }
+
+ public function getChild($name) {
+ throw new NotFound();
+ }
+
+ public function getChildren(): array {
+ return [];
+ }
+
+ public function childExists($name): bool {
+ return false;
+ }
+
+ public function moveInto($targetName, $sourcePath, INode $sourceNode): bool {
+ if ($sourceNode instanceof IRestorable) {
+ $sourceNode->restore();
+ return true;
+ }
+
+ return false;
+ }
+
+ public function delete() {
+ throw new Forbidden();
+ }
+
+ public function getName(): string {
+ return 'restore';
+ }
+
+ public function setName($name) {
+ throw new Forbidden();
+ }
+
+ public function getLastModified() {
+ return 0;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php b/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php
new file mode 100644
index 00000000000..1c76bd2295d
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php
@@ -0,0 +1,106 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Trashbin;
+
+use OCA\DAV\CalDAV\CalDavBackend;
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAV\Exception\NotFound;
+use Sabre\DAV\ICollection;
+use Sabre\DAV\INode;
+use Sabre\DAV\IProperties;
+use Sabre\DAV\PropPatch;
+use Sabre\DAV\Xml\Property\ResourceType;
+use Sabre\DAVACL\ACLTrait;
+use Sabre\DAVACL\IACL;
+use function in_array;
+use function sprintf;
+
+class TrashbinHome implements IACL, ICollection, IProperties {
+ use ACLTrait;
+
+ public const NAME = 'trashbin';
+
+ public function __construct(
+ private CalDavBackend $caldavBackend,
+ private array $principalInfo,
+ ) {
+ }
+
+ public function getOwner(): string {
+ return $this->principalInfo['uri'];
+ }
+
+ public function createFile($name, $data = null) {
+ throw new Forbidden('Permission denied to create files in the trashbin');
+ }
+
+ public function createDirectory($name) {
+ throw new Forbidden('Permission denied to create a directory in the trashbin');
+ }
+
+ public function getChild($name): INode {
+ switch ($name) {
+ case RestoreTarget::NAME:
+ return new RestoreTarget();
+ case DeletedCalendarObjectsCollection::NAME:
+ return new DeletedCalendarObjectsCollection(
+ $this->caldavBackend,
+ $this->principalInfo
+ );
+ }
+
+ throw new NotFound();
+ }
+
+ public function getChildren(): array {
+ return [
+ new RestoreTarget(),
+ new DeletedCalendarObjectsCollection(
+ $this->caldavBackend,
+ $this->principalInfo
+ ),
+ ];
+ }
+
+ public function childExists($name): bool {
+ return in_array($name, [
+ RestoreTarget::NAME,
+ DeletedCalendarObjectsCollection::NAME,
+ ], true);
+ }
+
+ public function delete() {
+ throw new Forbidden('Permission denied to delete the trashbin');
+ }
+
+ public function getName(): string {
+ return self::NAME;
+ }
+
+ public function setName($name) {
+ throw new Forbidden('Permission denied to rename the trashbin');
+ }
+
+ public function getLastModified(): int {
+ return 0;
+ }
+
+ public function propPatch(PropPatch $propPatch): void {
+ throw new Forbidden('not implemented');
+ }
+
+ public function getProperties($properties): array {
+ return [
+ '{DAV:}resourcetype' => new ResourceType([
+ '{DAV:}collection',
+ sprintf('{%s}trash-bin', \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD),
+ ]),
+ ];
+ }
+}
diff --git a/apps/dav/lib/CalDAV/UpcomingEvent.php b/apps/dav/lib/CalDAV/UpcomingEvent.php
new file mode 100644
index 00000000000..e8b604f460a
--- /dev/null
+++ b/apps/dav/lib/CalDAV/UpcomingEvent.php
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV;
+
+use JsonSerializable;
+use OCA\DAV\ResponseDefinitions;
+
+class UpcomingEvent implements JsonSerializable {
+ public function __construct(
+ private string $uri,
+ private ?int $recurrenceId,
+ private string $calendarUri,
+ private ?int $start,
+ private ?string $summary,
+ private ?string $location,
+ private ?string $calendarAppUrl,
+ ) {
+ }
+
+ public function getUri(): string {
+ return $this->uri;
+ }
+
+ public function getRecurrenceId(): ?int {
+ return $this->recurrenceId;
+ }
+
+ public function getCalendarUri(): string {
+ return $this->calendarUri;
+ }
+
+ public function getStart(): ?int {
+ return $this->start;
+ }
+
+ public function getSummary(): ?string {
+ return $this->summary;
+ }
+
+ public function getLocation(): ?string {
+ return $this->location;
+ }
+
+ public function getCalendarAppUrl(): ?string {
+ return $this->calendarAppUrl;
+ }
+
+ /**
+ * @see ResponseDefinitions
+ */
+ public function jsonSerialize(): array {
+ return [
+ 'uri' => $this->uri,
+ 'recurrenceId' => $this->recurrenceId,
+ 'calendarUri' => $this->calendarUri,
+ 'start' => $this->start,
+ 'summary' => $this->summary,
+ 'location' => $this->location,
+ 'calendarAppUrl' => $this->calendarAppUrl,
+ ];
+ }
+}
diff --git a/apps/dav/lib/CalDAV/UpcomingEventsService.php b/apps/dav/lib/CalDAV/UpcomingEventsService.php
new file mode 100644
index 00000000000..1a8aed5bd71
--- /dev/null
+++ b/apps/dav/lib/CalDAV/UpcomingEventsService.php
@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\DAV\CalDAV;
+
+use OCP\App\IAppManager;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\Calendar\IManager;
+use OCP\IURLGenerator;
+use OCP\IUserManager;
+use function array_map;
+
+class UpcomingEventsService {
+ public function __construct(
+ private IManager $calendarManager,
+ private ITimeFactory $timeFactory,
+ private IUserManager $userManager,
+ private IAppManager $appManager,
+ private IURLGenerator $urlGenerator,
+ ) {
+ }
+
+ /**
+ * @return UpcomingEvent[]
+ */
+ public function getEvents(string $userId, ?string $location = null): array {
+ $searchQuery = $this->calendarManager->newQuery('principals/users/' . $userId);
+ if ($location !== null) {
+ $searchQuery->addSearchProperty('LOCATION');
+ $searchQuery->setSearchPattern($location);
+ }
+ $searchQuery->addType('VEVENT');
+ $searchQuery->setLimit(3);
+ $now = $this->timeFactory->now();
+ $searchQuery->setTimerangeStart($now->modify('-1 minute'));
+ $searchQuery->setTimerangeEnd($now->modify('+1 month'));
+
+ $events = $this->calendarManager->searchForPrincipal($searchQuery);
+ $calendarAppEnabled = $this->appManager->isEnabledForUser(
+ 'calendar',
+ $this->userManager->get($userId),
+ );
+
+ return array_filter(array_map(function (array $event) use ($userId, $calendarAppEnabled) {
+ $calendarAppUrl = null;
+
+ if ($calendarAppEnabled) {
+ $arguments = [
+ 'objectId' => base64_encode($this->urlGenerator->getWebroot() . '/remote.php/dav/calendars/' . $userId . '/' . $event['calendar-uri'] . '/' . $event['uri']),
+ ];
+
+ if (isset($event['RECURRENCE-ID'])) {
+ $arguments['recurrenceId'] = $event['RECURRENCE-ID'][0];
+ }
+ /**
+ * TODO: create a named, deep route in calendar (it's a code smell to just assume this route exists, find an abstraction)
+ * When changing, also adjust for:
+ * - spreed/lib/Service/CalendarIntegrationService.php#getDashboardEvents
+ * - spreed/lib/Service/CalendarIntegrationService.php#getMutualEvents
+ */
+ $calendarAppUrl = $this->urlGenerator->linkToRouteAbsolute('calendar.view.indexdirect.edit', $arguments);
+ }
+
+ if (isset($event['objects'][0]['STATUS']) && $event['objects'][0]['STATUS'][0] === 'CANCELLED') {
+ return false;
+ }
+
+ return new UpcomingEvent(
+ $event['uri'],
+ ($event['objects'][0]['RECURRENCE-ID'][0] ?? null)?->getTimeStamp(),
+ $event['calendar-uri'],
+ $event['objects'][0]['DTSTART'][0]?->getTimestamp(),
+ $event['objects'][0]['SUMMARY'][0] ?? null,
+ $event['objects'][0]['LOCATION'][0] ?? null,
+ $calendarAppUrl,
+ );
+ }, $events));
+ }
+
+}
diff --git a/apps/dav/lib/CalDAV/Validation/CalDavValidatePlugin.php b/apps/dav/lib/CalDAV/Validation/CalDavValidatePlugin.php
new file mode 100644
index 00000000000..b647e63e67b
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Validation/CalDavValidatePlugin.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\Validation;
+
+use OCA\DAV\AppInfo\Application;
+use OCP\IAppConfig;
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAV\Server;
+use Sabre\DAV\ServerPlugin;
+use Sabre\HTTP\RequestInterface;
+use Sabre\HTTP\ResponseInterface;
+
+class CalDavValidatePlugin extends ServerPlugin {
+
+ public function __construct(
+ private IAppConfig $config,
+ ) {
+ }
+
+ public function initialize(Server $server): void {
+ $server->on('beforeMethod:PUT', [$this, 'beforePut']);
+ }
+
+ public function beforePut(RequestInterface $request, ResponseInterface $response): bool {
+ // evaluate if card size exceeds defined limit
+ $eventSizeLimit = $this->config->getValueInt(Application::APP_ID, 'event_size_limit', 10485760);
+ if ((int)$request->getRawServerValue('CONTENT_LENGTH') > $eventSizeLimit) {
+ throw new Forbidden("VEvent or VTodo object exceeds $eventSizeLimit bytes");
+ }
+ // all tests passed return true
+ return true;
+ }
+
+}
diff --git a/apps/dav/lib/CalDAV/WebcalCaching/Connection.php b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php
new file mode 100644
index 00000000000..3d12c92c49a
--- /dev/null
+++ b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php
@@ -0,0 +1,143 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\WebcalCaching;
+
+use Exception;
+use GuzzleHttp\RequestOptions;
+use OCP\Http\Client\IClientService;
+use OCP\Http\Client\LocalServerException;
+use OCP\IAppConfig;
+use Psr\Log\LoggerInterface;
+use Sabre\VObject\Reader;
+
+class Connection {
+ public function __construct(
+ private IClientService $clientService,
+ private IAppConfig $config,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * gets webcal feed from remote server
+ */
+ public function queryWebcalFeed(array $subscription): ?string {
+ $subscriptionId = $subscription['id'];
+ $url = $this->cleanURL($subscription['source']);
+ if ($url === null) {
+ return null;
+ }
+
+ $allowLocalAccess = $this->config->getValueString('dav', 'webcalAllowLocalAccess', 'no');
+
+ $params = [
+ 'nextcloud' => [
+ 'allow_local_address' => $allowLocalAccess === 'yes',
+ ],
+ RequestOptions::HEADERS => [
+ 'User-Agent' => 'Nextcloud Webcal Service',
+ 'Accept' => 'text/calendar, application/calendar+json, application/calendar+xml',
+ ],
+ ];
+
+ $user = parse_url($subscription['source'], PHP_URL_USER);
+ $pass = parse_url($subscription['source'], PHP_URL_PASS);
+ if ($user !== null && $pass !== null) {
+ $params[RequestOptions::AUTH] = [$user, $pass];
+ }
+
+ try {
+ $client = $this->clientService->newClient();
+ $response = $client->get($url, $params);
+ } catch (LocalServerException $ex) {
+ $this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules", [
+ 'exception' => $ex,
+ ]);
+ return null;
+ } catch (Exception $ex) {
+ $this->logger->warning("Subscription $subscriptionId could not be refreshed due to a network error", [
+ 'exception' => $ex,
+ ]);
+ return null;
+ }
+
+ $body = $response->getBody();
+
+ $contentType = $response->getHeader('Content-Type');
+ $contentType = explode(';', $contentType, 2)[0];
+ switch ($contentType) {
+ case 'application/calendar+json':
+ try {
+ $jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING);
+ } catch (Exception $ex) {
+ // In case of a parsing error return null
+ $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
+ return null;
+ }
+ return $jCalendar->serialize();
+
+ case 'application/calendar+xml':
+ try {
+ $xCalendar = Reader::readXML($body);
+ } catch (Exception $ex) {
+ // In case of a parsing error return null
+ $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
+ return null;
+ }
+ return $xCalendar->serialize();
+
+ case 'text/calendar':
+ default:
+ try {
+ $vCalendar = Reader::read($body);
+ } catch (Exception $ex) {
+ // In case of a parsing error return null
+ $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]);
+ return null;
+ }
+ return $vCalendar->serialize();
+ }
+ }
+
+ /**
+ * This method will strip authentication information and replace the
+ * 'webcal' or 'webcals' protocol scheme
+ *
+ * @param string $url
+ * @return string|null
+ */
+ private function cleanURL(string $url): ?string {
+ $parsed = parse_url($url);
+ if ($parsed === false) {
+ return null;
+ }
+
+ if (isset($parsed['scheme']) && $parsed['scheme'] === 'http') {
+ $scheme = 'http';
+ } else {
+ $scheme = 'https';
+ }
+
+ $host = $parsed['host'] ?? '';
+ $port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
+ $path = $parsed['path'] ?? '';
+ $query = isset($parsed['query']) ? '?' . $parsed['query'] : '';
+ $fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : '';
+
+ $cleanURL = "$scheme://$host$port$path$query$fragment";
+ // parse_url is giving some weird results if no url and no :// is given,
+ // so let's test the url again
+ $parsedClean = parse_url($cleanURL);
+ if ($parsedClean === false || !isset($parsedClean['host'])) {
+ return null;
+ }
+
+ return $cleanURL;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php b/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php
new file mode 100644
index 00000000000..e07be39c7b4
--- /dev/null
+++ b/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php
@@ -0,0 +1,141 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\WebcalCaching;
+
+use OCA\DAV\CalDAV\CalendarRoot;
+use OCP\IRequest;
+use Sabre\DAV\Exception\NotFound;
+use Sabre\DAV\Server;
+use Sabre\DAV\ServerPlugin;
+use Sabre\HTTP\RequestInterface;
+use Sabre\HTTP\ResponseInterface;
+
+class Plugin extends ServerPlugin {
+
+ /**
+ * list of regular expressions for calendar user agents,
+ * that do not support subscriptions on their own
+ *
+ * /^MSFT-WIN-3/ - Windows 10 Calendar
+ * /Evolution/ - Gnome Calendar/Evolution
+ * /KIO/ - KDE PIM/Akonadi
+ * @var string[]
+ */
+ public const ENABLE_FOR_CLIENTS = [
+ '/^MSFT-WIN-3/',
+ '/Evolution/',
+ '/KIO/'
+ ];
+
+ /**
+ * @var bool
+ */
+ private $enabled = false;
+
+ /**
+ * @var Server
+ */
+ private $server;
+
+ /**
+ * Plugin constructor.
+ *
+ * @param IRequest $request
+ */
+ public function __construct(IRequest $request) {
+ if ($request->isUserAgent(self::ENABLE_FOR_CLIENTS)) {
+ $this->enabled = true;
+ }
+
+ $magicHeader = $request->getHeader('X-NC-CalDAV-Webcal-Caching');
+ if ($magicHeader === 'On') {
+ $this->enabled = true;
+ }
+
+ $isExportRequest = $request->getMethod() === 'GET' && array_key_exists('export', $request->getParams());
+ if ($isExportRequest) {
+ $this->enabled = true;
+ }
+ }
+
+ /**
+ * This initializes the plugin.
+ *
+ * This function is called by Sabre\DAV\Server, after
+ * addPlugin is called.
+ *
+ * This method should set up the required event subscriptions.
+ *
+ * @param Server $server
+ */
+ public function initialize(Server $server) {
+ $this->server = $server;
+ $server->on('beforeMethod:*', [$this, 'beforeMethod'], 15);
+ }
+
+ /**
+ * @param RequestInterface $request
+ * @param ResponseInterface $response
+ */
+ public function beforeMethod(RequestInterface $request, ResponseInterface $response) {
+ if (!$this->enabled) {
+ return;
+ }
+
+ $path = $request->getPath();
+ if (!str_starts_with($path, 'calendars/')) {
+ return;
+ }
+
+ $pathParts = explode('/', ltrim($path, '/'));
+ if (\count($pathParts) < 2) {
+ return;
+ }
+
+ try {
+ $calendarRoot = $this->server->tree->getNodeForPath($pathParts[0]);
+ if ($calendarRoot instanceof CalendarRoot) {
+ $calendarRoot->enableReturnCachedSubscriptions($pathParts[1]);
+ }
+ } catch (NotFound $ex) {
+ return;
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public function isCachingEnabledForThisRequest():bool {
+ return $this->enabled;
+ }
+
+ /**
+ * This method should return a list of server-features.
+ *
+ * This is for example 'versioning' and is added to the DAV: header
+ * in an OPTIONS response.
+ *
+ * @return string[]
+ */
+ public function getFeatures():array {
+ return ['nc-calendar-webcal-cache'];
+ }
+
+ /**
+ * Returns a plugin name.
+ *
+ * Using this name other plugins will be able to access other plugins
+ * using Sabre\DAV\Server::getPlugin
+ *
+ * @return string
+ */
+ public function getPluginName():string {
+ return 'nc-calendar-webcal-cache';
+ }
+}
diff --git a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php
new file mode 100644
index 00000000000..a0981e6dec1
--- /dev/null
+++ b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php
@@ -0,0 +1,264 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OCA\DAV\CalDAV\WebcalCaching;
+
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCP\AppFramework\Utility\ITimeFactory;
+use Psr\Log\LoggerInterface;
+use Sabre\DAV\Exception\BadRequest;
+use Sabre\DAV\Exception\Forbidden;
+use Sabre\DAV\PropPatch;
+use Sabre\VObject\Component;
+use Sabre\VObject\DateTimeParser;
+use Sabre\VObject\InvalidDataException;
+use Sabre\VObject\ParseException;
+use Sabre\VObject\Reader;
+use Sabre\VObject\Recur\NoInstancesException;
+use Sabre\VObject\Splitter\ICalendar;
+use Sabre\VObject\UUIDUtil;
+use function count;
+
+class RefreshWebcalService {
+
+ public const REFRESH_RATE = '{http://apple.com/ns/ical/}refreshrate';
+ public const STRIP_ALARMS = '{http://calendarserver.org/ns/}subscribed-strip-alarms';
+ public const STRIP_ATTACHMENTS = '{http://calendarserver.org/ns/}subscribed-strip-attachments';
+ public const STRIP_TODOS = '{http://calendarserver.org/ns/}subscribed-strip-todos';
+
+ public function __construct(
+ private CalDavBackend $calDavBackend,
+ private LoggerInterface $logger,
+ private Connection $connection,
+ private ITimeFactory $time,
+ ) {
+ }
+
+ public function refreshSubscription(string $principalUri, string $uri) {
+ $subscription = $this->getSubscription($principalUri, $uri);
+ $mutations = [];
+ if (!$subscription) {
+ return;
+ }
+
+ // Check the refresh rate if there is any
+ if (!empty($subscription['{http://apple.com/ns/ical/}refreshrate'])) {
+ // add the refresh interval to the lastmodified timestamp
+ $refreshInterval = new \DateInterval($subscription['{http://apple.com/ns/ical/}refreshrate']);
+ $updateTime = $this->time->getDateTime();
+ $updateTime->setTimestamp($subscription['lastmodified'])->add($refreshInterval);
+ if ($updateTime->getTimestamp() > $this->time->getTime()) {
+ return;
+ }
+ }
+
+
+ $webcalData = $this->connection->queryWebcalFeed($subscription);
+ if (!$webcalData) {
+ return;
+ }
+
+ $localData = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
+
+ $stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1;
+ $stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1;
+ $stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1;
+
+ try {
+ $splitter = new ICalendar($webcalData, Reader::OPTION_FORGIVING);
+
+ while ($vObject = $splitter->getNext()) {
+ /** @var Component $vObject */
+ $compName = null;
+ $uid = null;
+
+ foreach ($vObject->getComponents() as $component) {
+ if ($component->name === 'VTIMEZONE') {
+ continue;
+ }
+
+ $compName = $component->name;
+
+ if ($stripAlarms) {
+ unset($component->{'VALARM'});
+ }
+ if ($stripAttachments) {
+ unset($component->{'ATTACH'});
+ }
+
+ $uid = $component->{ 'UID' }->getValue();
+ }
+
+ if ($stripTodos && $compName === 'VTODO') {
+ continue;
+ }
+
+ if (!isset($uid)) {
+ continue;
+ }
+
+ try {
+ $denormalized = $this->calDavBackend->getDenormalizedData($vObject->serialize());
+ } catch (InvalidDataException|Forbidden $ex) {
+ $this->logger->warning('Unable to denormalize calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]);
+ continue;
+ }
+
+ // Find all identical sets and remove them from the update
+ if (isset($localData[$uid]) && $denormalized['etag'] === $localData[$uid]['etag']) {
+ unset($localData[$uid]);
+ continue;
+ }
+
+ $vObjectCopy = clone $vObject;
+ $identical = isset($localData[$uid]) && $this->compareWithoutDtstamp($vObjectCopy, $localData[$uid]);
+ if ($identical) {
+ unset($localData[$uid]);
+ continue;
+ }
+
+ // Find all modified sets and update them
+ if (isset($localData[$uid]) && $denormalized['etag'] !== $localData[$uid]['etag']) {
+ $this->calDavBackend->updateCalendarObject($subscription['id'], $localData[$uid]['uri'], $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
+ unset($localData[$uid]);
+ continue;
+ }
+
+ // Only entirely new events get created here
+ try {
+ $objectUri = $this->getRandomCalendarObjectUri();
+ $this->calDavBackend->createCalendarObject($subscription['id'], $objectUri, $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);
+ } catch (NoInstancesException|BadRequest $ex) {
+ $this->logger->warning('Unable to create calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]);
+ }
+ }
+
+ $ids = array_map(static function ($dataSet): int {
+ return (int)$dataSet['id'];
+ }, $localData);
+ $uris = array_map(static function ($dataSet): string {
+ return $dataSet['uri'];
+ }, $localData);
+
+ if (!empty($ids) && !empty($uris)) {
+ // Clean up on aisle 5
+ // The only events left over in the $localData array should be those that don't exist upstream
+ // All deleted VObjects from upstream are removed
+ $this->calDavBackend->purgeCachedEventsForSubscription($subscription['id'], $ids, $uris);
+ }
+
+ $newRefreshRate = $this->checkWebcalDataForRefreshRate($subscription, $webcalData);
+ if ($newRefreshRate) {
+ $mutations[self::REFRESH_RATE] = $newRefreshRate;
+ }
+
+ $this->updateSubscription($subscription, $mutations);
+ } catch (ParseException $ex) {
+ $this->logger->error('Subscription {subscriptionId} could not be refreshed due to a parsing error', ['exception' => $ex, 'subscriptionId' => $subscription['id']]);
+ }
+ }
+
+ /**
+ * loads subscription from backend
+ */
+ public function getSubscription(string $principalUri, string $uri): ?array {
+ $subscriptions = array_values(array_filter(
+ $this->calDavBackend->getSubscriptionsForUser($principalUri),
+ function ($sub) use ($uri) {
+ return $sub['uri'] === $uri;
+ }
+ ));
+
+ if (count($subscriptions) === 0) {
+ return null;
+ }
+
+ return $subscriptions[0];
+ }
+
+
+ /**
+ * check if:
+ * - current subscription stores a refreshrate
+ * - the webcal feed suggests a refreshrate
+ * - return suggested refreshrate if user didn't set a custom one
+ *
+ */
+ private function checkWebcalDataForRefreshRate(array $subscription, string $webcalData): ?string {
+ // if there is no refreshrate stored in the database, check the webcal feed
+ // whether it suggests any refresh rate and store that in the database
+ if (isset($subscription[self::REFRESH_RATE]) && $subscription[self::REFRESH_RATE] !== null) {
+ return null;
+ }
+
+ /** @var Component\VCalendar $vCalendar */
+ $vCalendar = Reader::read($webcalData);
+
+ $newRefreshRate = null;
+ if (isset($vCalendar->{'X-PUBLISHED-TTL'})) {
+ $newRefreshRate = $vCalendar->{'X-PUBLISHED-TTL'}->getValue();
+ }
+ if (isset($vCalendar->{'REFRESH-INTERVAL'})) {
+ $newRefreshRate = $vCalendar->{'REFRESH-INTERVAL'}->getValue();
+ }
+
+ if (!$newRefreshRate) {
+ return null;
+ }
+
+ // check if new refresh rate is even valid
+ try {
+ DateTimeParser::parseDuration($newRefreshRate);
+ } catch (InvalidDataException $ex) {
+ return null;
+ }
+
+ return $newRefreshRate;
+ }
+
+ /**
+ * update subscription stored in database
+ * used to set:
+ * - refreshrate
+ * - source
+ *
+ * @param array $subscription
+ * @param array $mutations
+ */
+ private function updateSubscription(array $subscription, array $mutations) {
+ if (empty($mutations)) {
+ return;
+ }
+
+ $propPatch = new PropPatch($mutations);
+ $this->calDavBackend->updateSubscription($subscription['id'], $propPatch);
+ $propPatch->commit();
+ }
+
+ /**
+ * Returns a random uri for a calendar-object
+ *
+ * @return string
+ */
+ public function getRandomCalendarObjectUri():string {
+ return UUIDUtil::getUUID() . '.ics';
+ }
+
+ private function compareWithoutDtstamp(Component $vObject, array $calendarObject): bool {
+ foreach ($vObject->getComponents() as $component) {
+ unset($component->{'DTSTAMP'});
+ }
+
+ $localVobject = Reader::read($calendarObject['calendardata']);
+ foreach ($localVobject->getComponents() as $component) {
+ unset($component->{'DTSTAMP'});
+ }
+
+ return strcasecmp($localVobject->serialize(), $vObject->serialize()) === 0;
+ }
+}