Browse Source

Merge pull request #43117 from nextcloud/enh/1653/enable-unsharing-for-dav-shares

enh(sharing): enable unsharing for sharees for DAV shares
Anna 4 months ago
parent
commit
57787f23e8

+ 1 - 1
apps/dav/appinfo/v1/caldav.php

@@ -69,11 +69,11 @@ $calDavBackend = new CalDavBackend(
 	$db,
 	$principalBackend,
 	$userManager,
-	\OC::$server->getGroupManager(),
 	$random,
 	$logger,
 	$dispatcher,
 	$config,
+	OC::$server->get(\OCA\DAV\CalDAV\Sharing\Backend::class),
 	true
 );
 

+ 7 - 1
apps/dav/appinfo/v1/carddav.php

@@ -64,7 +64,13 @@ $principalBackend = new Principal(
 	'principals/'
 );
 $db = \OC::$server->getDatabaseConnection();
-$cardDavBackend = new CardDavBackend($db, $principalBackend, \OC::$server->getUserManager(), \OC::$server->getGroupManager(), \OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class));
+$cardDavBackend = new CardDavBackend(
+	$db,
+	$principalBackend,
+	\OC::$server->getUserManager(),
+	\OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class),
+	\OC::$server->get(\OCA\DAV\CardDAV\Sharing\Backend::class),
+);
 
 $debugging = \OC::$server->getConfig()->getSystemValue('debug', false);
 

+ 6 - 0
apps/dav/composer/composer/autoload_classmap.php

@@ -99,6 +99,8 @@ return array(
     'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/PropFilter.php',
     'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter' => $baseDir . '/../lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php',
     'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => $baseDir . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php',
+    'OCA\\DAV\\CalDAV\\Sharing\\Backend' => $baseDir . '/../lib/CalDAV/Sharing/Backend.php',
+    'OCA\\DAV\\CalDAV\\Sharing\\Service' => $baseDir . '/../lib/CalDAV/Sharing/Service.php',
     'OCA\\DAV\\CalDAV\\Status\\StatusService' => $baseDir . '/../lib/CalDAV/Status/StatusService.php',
     'OCA\\DAV\\CalDAV\\TimezoneService' => $baseDir . '/../lib/CalDAV/TimezoneService.php',
     'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => $baseDir . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php',
@@ -129,6 +131,8 @@ return array(
     'OCA\\DAV\\CardDAV\\MultiGetExportPlugin' => $baseDir . '/../lib/CardDAV/MultiGetExportPlugin.php',
     'OCA\\DAV\\CardDAV\\PhotoCache' => $baseDir . '/../lib/CardDAV/PhotoCache.php',
     'OCA\\DAV\\CardDAV\\Plugin' => $baseDir . '/../lib/CardDAV/Plugin.php',
+    'OCA\\DAV\\CardDAV\\Sharing\\Backend' => $baseDir . '/../lib/CardDAV/Sharing/Backend.php',
+    'OCA\\DAV\\CardDAV\\Sharing\\Service' => $baseDir . '/../lib/CardDAV/Sharing/Service.php',
     'OCA\\DAV\\CardDAV\\SyncService' => $baseDir . '/../lib/CardDAV/SyncService.php',
     'OCA\\DAV\\CardDAV\\SystemAddressbook' => $baseDir . '/../lib/CardDAV/SystemAddressbook.php',
     'OCA\\DAV\\CardDAV\\UserAddressBooks' => $baseDir . '/../lib/CardDAV/UserAddressBooks.php',
@@ -203,6 +207,8 @@ return array(
     'OCA\\DAV\\DAV\\Sharing\\Backend' => $baseDir . '/../lib/DAV/Sharing/Backend.php',
     'OCA\\DAV\\DAV\\Sharing\\IShareable' => $baseDir . '/../lib/DAV/Sharing/IShareable.php',
     'OCA\\DAV\\DAV\\Sharing\\Plugin' => $baseDir . '/../lib/DAV/Sharing/Plugin.php',
+    'OCA\\DAV\\DAV\\Sharing\\SharingMapper' => $baseDir . '/../lib/DAV/Sharing/SharingMapper.php',
+    'OCA\\DAV\\DAV\\Sharing\\SharingService' => $baseDir . '/../lib/DAV/Sharing/SharingService.php',
     'OCA\\DAV\\DAV\\Sharing\\Xml\\Invite' => $baseDir . '/../lib/DAV/Sharing/Xml/Invite.php',
     'OCA\\DAV\\DAV\\Sharing\\Xml\\ShareRequest' => $baseDir . '/../lib/DAV/Sharing/Xml/ShareRequest.php',
     'OCA\\DAV\\DAV\\SystemPrincipalBackend' => $baseDir . '/../lib/DAV/SystemPrincipalBackend.php',

+ 6 - 0
apps/dav/composer/composer/autoload_static.php

@@ -114,6 +114,8 @@ class ComposerStaticInitDAV
         'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\PropFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/PropFilter.php',
         'OCA\\DAV\\CalDAV\\Search\\Xml\\Filter\\SearchTermFilter' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php',
         'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php',
+        'OCA\\DAV\\CalDAV\\Sharing\\Backend' => __DIR__ . '/..' . '/../lib/CalDAV/Sharing/Backend.php',
+        'OCA\\DAV\\CalDAV\\Sharing\\Service' => __DIR__ . '/..' . '/../lib/CalDAV/Sharing/Service.php',
         'OCA\\DAV\\CalDAV\\Status\\StatusService' => __DIR__ . '/..' . '/../lib/CalDAV/Status/StatusService.php',
         'OCA\\DAV\\CalDAV\\TimezoneService' => __DIR__ . '/..' . '/../lib/CalDAV/TimezoneService.php',
         'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php',
@@ -144,6 +146,8 @@ class ComposerStaticInitDAV
         'OCA\\DAV\\CardDAV\\MultiGetExportPlugin' => __DIR__ . '/..' . '/../lib/CardDAV/MultiGetExportPlugin.php',
         'OCA\\DAV\\CardDAV\\PhotoCache' => __DIR__ . '/..' . '/../lib/CardDAV/PhotoCache.php',
         'OCA\\DAV\\CardDAV\\Plugin' => __DIR__ . '/..' . '/../lib/CardDAV/Plugin.php',
+        'OCA\\DAV\\CardDAV\\Sharing\\Backend' => __DIR__ . '/..' . '/../lib/CardDAV/Sharing/Backend.php',
+        'OCA\\DAV\\CardDAV\\Sharing\\Service' => __DIR__ . '/..' . '/../lib/CardDAV/Sharing/Service.php',
         'OCA\\DAV\\CardDAV\\SyncService' => __DIR__ . '/..' . '/../lib/CardDAV/SyncService.php',
         'OCA\\DAV\\CardDAV\\SystemAddressbook' => __DIR__ . '/..' . '/../lib/CardDAV/SystemAddressbook.php',
         'OCA\\DAV\\CardDAV\\UserAddressBooks' => __DIR__ . '/..' . '/../lib/CardDAV/UserAddressBooks.php',
@@ -218,6 +222,8 @@ class ComposerStaticInitDAV
         'OCA\\DAV\\DAV\\Sharing\\Backend' => __DIR__ . '/..' . '/../lib/DAV/Sharing/Backend.php',
         'OCA\\DAV\\DAV\\Sharing\\IShareable' => __DIR__ . '/..' . '/../lib/DAV/Sharing/IShareable.php',
         'OCA\\DAV\\DAV\\Sharing\\Plugin' => __DIR__ . '/..' . '/../lib/DAV/Sharing/Plugin.php',
+        'OCA\\DAV\\DAV\\Sharing\\SharingMapper' => __DIR__ . '/..' . '/../lib/DAV/Sharing/SharingMapper.php',
+        'OCA\\DAV\\DAV\\Sharing\\SharingService' => __DIR__ . '/..' . '/../lib/DAV/Sharing/SharingService.php',
         'OCA\\DAV\\DAV\\Sharing\\Xml\\Invite' => __DIR__ . '/..' . '/../lib/DAV/Sharing/Xml/Invite.php',
         'OCA\\DAV\\DAV\\Sharing\\Xml\\ShareRequest' => __DIR__ . '/..' . '/../lib/DAV/Sharing/Xml/ShareRequest.php',
         'OCA\\DAV\\DAV\\SystemPrincipalBackend' => __DIR__ . '/..' . '/../lib/DAV/SystemPrincipalBackend.php',

+ 25 - 18
apps/dav/lib/CalDAV/CalDavBackend.php

@@ -42,8 +42,8 @@ namespace OCA\DAV\CalDAV;
 use DateTime;
 use DateTimeInterface;
 use OCA\DAV\AppInfo\Application;
+use OCA\DAV\CalDAV\Sharing\Backend;
 use OCA\DAV\Connector\Sabre\Principal;
-use OCA\DAV\DAV\Sharing\Backend;
 use OCA\DAV\DAV\Sharing\IShareable;
 use OCA\DAV\Events\CachedCalendarObjectCreatedEvent;
 use OCA\DAV\Events\CachedCalendarObjectDeletedEvent;
@@ -72,7 +72,6 @@ use OCP\DB\QueryBuilder\IQueryBuilder;
 use OCP\EventDispatcher\IEventDispatcher;
 use OCP\IConfig;
 use OCP\IDBConnection;
-use OCP\IGroupManager;
 use OCP\IUserManager;
 use OCP\Security\ISecureRandom;
 use Psr\Log\LoggerInterface;
@@ -208,7 +207,6 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
 	 */
 	protected array $userDisplayNames;
 
-	private Backend $calendarSharingBackend;
 	private string $dbObjectPropertiesTable = 'calendarobjects_props';
 	private array $cachedObjects = [];
 
@@ -216,14 +214,13 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
 		private IDBConnection $db,
 		private Principal $principalBackend,
 		private IUserManager $userManager,
-		IGroupManager $groupManager,
 		private ISecureRandom $random,
 		private LoggerInterface $logger,
 		private IEventDispatcher $dispatcher,
 		private IConfig $config,
+		private Sharing\Backend $calendarSharingBackend,
 		private bool $legacyEndpoint = false,
 	) {
-		$this->calendarSharingBackend = new Backend($this->db, $this->userManager, $groupManager, $principalBackend, 'calendar');
 	}
 
 	/**
@@ -361,10 +358,12 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
 			// 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';
@@ -372,19 +371,26 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
 			$fields[] = 'a.principaluri';
 			$fields[] = 'a.transparent';
 			$fields[] = 's.access';
-			$query = $this->db->getQueryBuilder();
-			$query->select($fields)
+
+			$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', $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);
+				->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));
 
-			$result = $query->executeQuery();
+			$results = $select->executeQuery();
 
 			$readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
-			while ($row = $result->fetch()) {
+			while ($row = $results->fetch()) {
 				$row['principaluri'] = (string) $row['principaluri'];
 				if ($row['principaluri'] === $principalUri) {
 					continue;
@@ -393,7 +399,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
 				$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.
+						// New share can not have more permissions than the old one.
 						continue;
 					}
 					if (isset($calendars[$row['id']][$readOnlyPropertyName]) &&
@@ -2891,7 +2897,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
 			}
 			$oldShares = $this->getShares($calendarId);
 
-			$this->calendarSharingBackend->updateShares($shareable, $add, $remove);
+			$this->calendarSharingBackend->updateShares($shareable, $add, $remove, $oldShares);
 
 			$this->dispatcher->dispatchTyped(new CalendarShareUpdatedEvent($calendarId, $calendarRow, $oldShares, $add, $remove));
 		}, $this->db);
@@ -2967,7 +2973,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
 	 * @return list<array{privilege: string, principal: string, protected: bool}>
 	 */
 	public function applyShareAcl(int $resourceId, array $acl): array {
-		return $this->calendarSharingBackend->applyShareAcl($resourceId, $acl);
+		$shares = $this->calendarSharingBackend->getShares($resourceId);
+		return $this->calendarSharingBackend->applyShareAcl($shares, $acl);
 	}
 
 	/**

+ 0 - 8
apps/dav/lib/CalDAV/Calendar.php

@@ -236,14 +236,6 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable
 		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, [], [
 				$principal
 			]);

+ 43 - 0
apps/dav/lib/CalDAV/Sharing/Backend.php

@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2024 Anna Larch <anna.larch@gmx.net>
+ *
+ * @author Anna Larch <anna.larch@gmx.net>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version.
+ *
+ * This library 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 library.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+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);
+	}
+}

+ 33 - 0
apps/dav/lib/CalDAV/Sharing/Service.php

@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2024 Anna Larch <anna.larch@gmx.net>
+ *
+ * @author Anna Larch <anna.larch@gmx.net>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version.
+ *
+ * This library 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 library.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+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);
+	}
+}

+ 29 - 40
apps/dav/lib/CardDAV/CardDavBackend.php

@@ -52,7 +52,6 @@ use OCP\DB\Exception;
 use OCP\DB\QueryBuilder\IQueryBuilder;
 use OCP\EventDispatcher\IEventDispatcher;
 use OCP\IDBConnection;
-use OCP\IGroupManager;
 use OCP\IUserManager;
 use PDO;
 use Sabre\CardDAV\Backend\BackendInterface;
@@ -64,15 +63,11 @@ use Sabre\VObject\Reader;
 
 class CardDavBackend implements BackendInterface, SyncSupport {
 	use TTransactional;
-
 	public const PERSONAL_ADDRESSBOOK_URI = 'contacts';
 	public const PERSONAL_ADDRESSBOOK_NAME = 'Contacts';
 
-	private Principal $principalBackend;
 	private string $dbCardsTable = 'cards';
 	private string $dbCardsPropertiesTable = 'cards_properties';
-	private IDBConnection $db;
-	private Backend $sharingBackend;
 
 	/** @var array properties to index */
 	public static array $indexProperties = [
@@ -84,29 +79,15 @@ class CardDavBackend implements BackendInterface, SyncSupport {
 	 * @var string[] Map of uid => display name
 	 */
 	protected array $userDisplayNames;
-	private IUserManager $userManager;
-	private IEventDispatcher $dispatcher;
 	private array $etagCache = [];
 
-	/**
-	 * CardDavBackend constructor.
-	 *
-	 * @param IDBConnection $db
-	 * @param Principal $principalBackend
-	 * @param IUserManager $userManager
-	 * @param IGroupManager $groupManager
-	 * @param IEventDispatcher $dispatcher
-	 */
-	public function __construct(IDBConnection $db,
-		Principal $principalBackend,
-		IUserManager $userManager,
-		IGroupManager $groupManager,
-		IEventDispatcher $dispatcher) {
-		$this->db = $db;
-		$this->principalBackend = $principalBackend;
-		$this->userManager = $userManager;
-		$this->dispatcher = $dispatcher;
-		$this->sharingBackend = new Backend($this->db, $this->userManager, $groupManager, $principalBackend, 'addressbook');
+	public function __construct(
+		private IDBConnection $db,
+		private Principal $principalBackend,
+		private IUserManager $userManager,
+		private IEventDispatcher $dispatcher,
+		private Sharing\Backend $sharingBackend,
+	) {
 	}
 
 	/**
@@ -149,14 +130,14 @@ class CardDavBackend implements BackendInterface, SyncSupport {
 		return $this->atomic(function () use ($principalUri) {
 			$principalUriOriginal = $principalUri;
 			$principalUri = $this->convertPrincipal($principalUri, true);
-			$query = $this->db->getQueryBuilder();
-			$query->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
+			$select = $this->db->getQueryBuilder();
+			$select->select(['id', 'uri', 'displayname', 'principaluri', 'description', 'synctoken'])
 				->from('addressbooks')
-				->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)));
+				->where($select->expr()->eq('principaluri', $select->createNamedParameter($principalUri)));
 
 			$addressBooks = [];
 
-			$result = $query->execute();
+			$result = $select->executeQuery();
 			while ($row = $result->fetch()) {
 				$addressBooks[$row['id']] = [
 					'id' => $row['id'],
@@ -178,15 +159,22 @@ class CardDavBackend implements BackendInterface, SyncSupport {
 
 			$principals[] = $principalUri;
 
-			$query = $this->db->getQueryBuilder();
-			$result = $query->select(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
+			$select = $this->db->getQueryBuilder();
+			$subSelect = $this->db->getQueryBuilder();
+
+			$subSelect->select('id')
+				->from('dav_shares', 'd')
+				->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(\OCA\DAV\CardDAV\Sharing\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(['a.id', 'a.uri', 'a.displayname', 'a.principaluri', 'a.description', 'a.synctoken', 's.access'])
 				->from('dav_shares', 's')
-				->join('s', 'addressbooks', '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', 'addressbook')
-				->setParameter('principaluri', $principals, IQueryBuilder::PARAM_STR_ARRAY)
-				->execute();
+				->join('s', 'addressbooks', 'a', $select->expr()->eq('s.resourceid', 'a.id'))
+				->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY)))
+				->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('addressbook', IQueryBuilder::PARAM_STR)))
+				->andWhere($select->expr()->notIn('s.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
+			$result = $select->executeQuery();
 
 			$readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only';
 			while ($row = $result->fetch()) {
@@ -1056,7 +1044,7 @@ class CardDavBackend implements BackendInterface, SyncSupport {
 			$addressBookData = $this->getAddressBookById($addressBookId);
 			$oldShares = $this->getShares($addressBookId);
 
-			$this->sharingBackend->updateShares($shareable, $add, $remove);
+			$this->sharingBackend->updateShares($shareable, $add, $remove, $oldShares);
 
 			$this->dispatcher->dispatchTyped(new AddressBookShareUpdatedEvent($addressBookId, $addressBookData, $oldShares, $add, $remove));
 		}, $this->db);
@@ -1418,7 +1406,8 @@ class CardDavBackend implements BackendInterface, SyncSupport {
 	 * @return list<array{privilege: string, principal: string, protected: bool}>
 	 */
 	public function applyShareAcl(int $addressBookId, array $acl): array {
-		return $this->sharingBackend->applyShareAcl($addressBookId, $acl);
+		$shares = $this->sharingBackend->getShares($addressBookId);
+		return $this->sharingBackend->applyShareAcl($shares, $acl);
 	}
 
 	/**

+ 42 - 0
apps/dav/lib/CardDAV/Sharing/Backend.php

@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2024 Anna Larch <anna.larch@gmx.net>
+ *
+ * @author Anna Larch <anna.larch@gmx.net>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version.
+ *
+ * This library 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 library.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace OCA\DAV\CardDAV\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);
+	}
+}

+ 33 - 0
apps/dav/lib/CardDAV/Sharing/Service.php

@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2024 Anna Larch <anna.larch@gmx.net>
+ *
+ * @author Anna Larch <anna.larch@gmx.net>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version.
+ *
+ * This library 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 library.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace OCA\DAV\CardDAV\Sharing;
+
+use OCA\DAV\DAV\Sharing\SharingMapper;
+use OCA\DAV\DAV\Sharing\SharingService;
+
+class Service extends SharingService {
+	protected string $resourceType = 'addressbook';
+	public function __construct(protected SharingMapper $mapper) {
+		parent::__construct($mapper);
+	}
+}

+ 3 - 3
apps/dav/lib/Command/CreateCalendar.php

@@ -28,6 +28,7 @@ namespace OCA\DAV\Command;
 use OC\KnownUser\KnownUserService;
 use OCA\DAV\CalDAV\CalDavBackend;
 use OCA\DAV\CalDAV\Proxy\ProxyMapper;
+use OCA\DAV\CalDAV\Sharing\Backend;
 use OCA\DAV\Connector\Sabre\Principal;
 use OCP\Accounts\IAccountManager;
 use OCP\EventDispatcher\IEventDispatcher;
@@ -83,17 +84,16 @@ class CreateCalendar extends Command {
 		$logger = \OC::$server->get(LoggerInterface::class);
 		$dispatcher = \OC::$server->get(IEventDispatcher::class);
 		$config = \OC::$server->get(IConfig::class);
-
 		$name = $input->getArgument('name');
 		$caldav = new CalDavBackend(
 			$this->dbConnection,
 			$principalBackend,
 			$this->userManager,
-			$this->groupManager,
 			$random,
 			$logger,
 			$dispatcher,
-			$config
+			$config,
+			\OC::$server->get(Backend::class),
 		);
 		$caldav->createCalendar("principals/users/$user", $name, []);
 		return self::SUCCESS;

+ 4 - 0
apps/dav/lib/Connector/Sabre/DavAclPlugin.php

@@ -26,6 +26,7 @@
  */
 namespace OCA\DAV\Connector\Sabre;
 
+use OCA\DAV\CalDAV\Calendar;
 use OCA\DAV\CardDAV\AddressBook;
 use Sabre\CalDAV\Principal\User;
 use Sabre\DAV\Exception\NotFound;
@@ -58,6 +59,9 @@ class DavAclPlugin extends \Sabre\DAVACL\Plugin {
 				case AddressBook::class:
 					$type = 'Addressbook';
 					break;
+				case Calendar::class:
+					$type = 'Calendar';
+					break;
 				default:
 					$type = 'Node';
 					break;

+ 85 - 136
apps/dav/lib/DAV/Sharing/Backend.php

@@ -1,4 +1,6 @@
 <?php
+
+declare(strict_types=1);
 /**
  * @copyright Copyright (c) 2016, ownCloud, Inc.
  *
@@ -10,6 +12,7 @@
  * @author Roeland Jago Douma <roeland@famdouma.nl>
  * @author Thomas Citharel <nextcloud@tcit.fr>
  * @author Thomas Müller <thomas.mueller@tmit.eu>
+ * @author Anna Larch <anna.larch@gmx.net>
  *
  * @license AGPL-3.0
  *
@@ -30,142 +33,104 @@ namespace OCA\DAV\DAV\Sharing;
 
 use OCA\DAV\Connector\Sabre\Principal;
 use OCP\AppFramework\Db\TTransactional;
-use OCP\Cache\CappedMemoryCache;
-use OCP\DB\QueryBuilder\IQueryBuilder;
-use OCP\IDBConnection;
+use OCP\ICache;
+use OCP\ICacheFactory;
 use OCP\IGroupManager;
 use OCP\IUserManager;
+use Psr\Log\LoggerInterface;
 
-class Backend {
+abstract class Backend {
 	use TTransactional;
-
-	private IDBConnection $db;
-	private IUserManager $userManager;
-	private IGroupManager $groupManager;
-	private Principal $principalBackend;
-	private string $resourceType;
-
 	public const ACCESS_OWNER = 1;
+
 	public const ACCESS_READ_WRITE = 2;
 	public const ACCESS_READ = 3;
-
-	private CappedMemoryCache $shareCache;
-
-	public function __construct(IDBConnection $db, IUserManager $userManager, IGroupManager $groupManager, Principal $principalBackend, string $resourceType) {
-		$this->db = $db;
-		$this->userManager = $userManager;
-		$this->groupManager = $groupManager;
-		$this->principalBackend = $principalBackend;
-		$this->resourceType = $resourceType;
-		$this->shareCache = new CappedMemoryCache();
+	// 4 is already in use for public calendars
+	public const ACCESS_UNSHARED = 5;
+
+	private ICache $shareCache;
+
+	public function __construct(private IUserManager $userManager,
+		private IGroupManager $groupManager,
+		private Principal $principalBackend,
+		private ICacheFactory $cacheFactory,
+		private SharingService $service,
+		private LoggerInterface $logger,
+	) {
+		$this->shareCache = $this->cacheFactory->createInMemory();
 	}
 
 	/**
 	 * @param list<array{href: string, commonName: string, readOnly: bool}> $add
 	 * @param list<string> $remove
 	 */
-	public function updateShares(IShareable $shareable, array $add, array $remove): void {
+	public function updateShares(IShareable $shareable, array $add, array $remove, array $oldShares = []): void {
 		$this->shareCache->clear();
-		$this->atomic(function () use ($shareable, $add, $remove) {
-			foreach ($add as $element) {
-				$principal = $this->principalBackend->findByUri($element['href'], '');
-				if ($principal !== '') {
-					$this->shareWith($shareable, $element);
-				}
+		foreach ($add as $element) {
+			$principal = $this->principalBackend->findByUri($element['href'], '');
+			if (empty($principal)) {
+				continue;
 			}
-			foreach ($remove as $element) {
-				$principal = $this->principalBackend->findByUri($element, '');
-				if ($principal !== '') {
-					$this->unshare($shareable, $element);
-				}
+
+			// We need to validate manually because some principals are only virtual
+			// i.e. Group principals
+			$principalparts = explode('/', $principal, 3);
+			if (count($principalparts) !== 3 || $principalparts[0] !== 'principals' || !in_array($principalparts[1], ['users', 'groups', 'circles'], true)) {
+				// Invalid principal
+				continue;
 			}
-		}, $this->db);
-	}
 
-	/**
-	 * @param array{href: string, commonName: string, readOnly: bool} $element
-	 */
-	private function shareWith(IShareable $shareable, array $element): void {
-		$this->shareCache->clear();
-		$user = $element['href'];
-		$parts = explode(':', $user, 2);
-		if ($parts[0] !== 'principal') {
-			return;
-		}
+			// Don't add share for owner
+			if($shareable->getOwner() !== null && strcasecmp($shareable->getOwner(), $principal) === 0) {
+				continue;
+			}
 
-		// don't share with owner
-		if ($shareable->getOwner() === $parts[1]) {
-			return;
-		}
+			$principalparts[2] = urldecode($principalparts[2]);
+			if (($principalparts[1] === 'users' && !$this->userManager->userExists($principalparts[2])) ||
+				($principalparts[1] === 'groups' && !$this->groupManager->groupExists($principalparts[2]))) {
+				// User or group does not exist
+				continue;
+			}
 
-		$principal = explode('/', $parts[1], 3);
-		if (count($principal) !== 3 || $principal[0] !== 'principals' || !in_array($principal[1], ['users', 'groups', 'circles'], true)) {
-			// Invalid principal
-			return;
-		}
+			$access = Backend::ACCESS_READ;
+			if (isset($element['readOnly'])) {
+				$access = $element['readOnly'] ? Backend::ACCESS_READ : Backend::ACCESS_READ_WRITE;
+			}
 
-		$principal[2] = urldecode($principal[2]);
-		if (($principal[1] === 'users' && !$this->userManager->userExists($principal[2])) ||
-			($principal[1] === 'groups' && !$this->groupManager->groupExists($principal[2]))) {
-			// User or group does not exist
-			return;
+			$this->service->shareWith($shareable->getResourceId(), $principal, $access);
 		}
+		foreach ($remove as $element) {
+			$principal = $this->principalBackend->findByUri($element, '');
+			if (empty($principal)) {
+				continue;
+			}
 
-		// remove the share if it already exists
-		$this->unshare($shareable, $element['href']);
-		$access = self::ACCESS_READ;
-		if (isset($element['readOnly'])) {
-			$access = $element['readOnly'] ? self::ACCESS_READ : self::ACCESS_READ_WRITE;
-		}
+			// Don't add unshare for owner
+			if($shareable->getOwner() !== null && strcasecmp($shareable->getOwner(), $principal) === 0) {
+				continue;
+			}
+
+			// Delete any possible direct shares (since the frontend does not separate between them)
+			$this->service->deleteShare($shareable->getResourceId(), $principal);
 
-		$query = $this->db->getQueryBuilder();
-		$query->insert('dav_shares')
-			->values([
-				'principaluri' => $query->createNamedParameter($parts[1]),
-				'type' => $query->createNamedParameter($this->resourceType),
-				'access' => $query->createNamedParameter($access),
-				'resourceid' => $query->createNamedParameter($shareable->getResourceId())
-			]);
-		$query->executeStatement();
+			// Check if a user has a groupshare that they're trying to free themselves from
+			// If so we need to add a self::ACCESS_UNSHARED row
+			if(!str_contains($principal, 'group')
+				&& $this->service->hasGroupShare($oldShares)
+			) {
+				$this->service->unshare($shareable->getResourceId(), $principal);
+			}
+		}
 	}
 
 	public function deleteAllShares(int $resourceId): void {
 		$this->shareCache->clear();
-		$query = $this->db->getQueryBuilder();
-		$query->delete('dav_shares')
-			->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId)))
-			->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType)))
-			->executeStatement();
+		$this->service->deleteAllShares($resourceId);
 	}
 
 	public function deleteAllSharesByUser(string $principaluri): void {
 		$this->shareCache->clear();
-		$query = $this->db->getQueryBuilder();
-		$query->delete('dav_shares')
-			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principaluri)))
-			->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType)))
-			->executeStatement();
-	}
-
-	private function unshare(IShareable $shareable, string $element): void {
-		$this->shareCache->clear();
-		$parts = explode(':', $element, 2);
-		if ($parts[0] !== 'principal') {
-			return;
-		}
-
-		// don't share with owner
-		if ($shareable->getOwner() === $parts[1]) {
-			return;
-		}
-
-		$query = $this->db->getQueryBuilder();
-		$query->delete('dav_shares')
-			->where($query->expr()->eq('resourceid', $query->createNamedParameter($shareable->getResourceId())))
-			->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType)))
-			->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($parts[1])))
-		;
-		$query->executeStatement();
+		$this->service->deleteAllSharesByUser($principaluri);
 	}
 
 	/**
@@ -181,52 +146,39 @@ class Backend {
 	 * @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(int $resourceId): array {
-		$cached = $this->shareCache->get($resourceId);
+		$cached = $this->shareCache->get((string)$resourceId);
 		if ($cached) {
 			return $cached;
 		}
-		$query = $this->db->getQueryBuilder();
-		$result = $query->select(['principaluri', 'access'])
-			->from('dav_shares')
-			->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT)))
-			->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType)))
-			->groupBy(['principaluri', 'access'])
-			->executeQuery();
 
+		$rows = $this->service->getShares($resourceId);
 		$shares = [];
-		while ($row = $result->fetch()) {
+		foreach($rows as $row) {
 			$p = $this->principalBackend->getPrincipalByPath($row['principaluri']);
 			$shares[] = [
 				'href' => "principal:{$row['principaluri']}",
 				'commonName' => isset($p['{DAV:}displayname']) ? (string)$p['{DAV:}displayname'] : '',
 				'status' => 1,
-				'readOnly' => (int) $row['access'] === self::ACCESS_READ,
+				'readOnly' => (int) $row['access'] === Backend::ACCESS_READ,
 				'{http://owncloud.org/ns}principal' => (string)$row['principaluri'],
-				'{http://owncloud.org/ns}group-share' => isset($p['uri']) ? str_starts_with($p['uri'], 'principals/groups') : false
+				'{http://owncloud.org/ns}group-share' => isset($p['uri']) && str_starts_with($p['uri'], 'principals/groups')
 			];
 		}
-
 		$this->shareCache->set((string)$resourceId, $shares);
 		return $shares;
 	}
 
 	public function preloadShares(array $resourceIds): void {
 		$resourceIds = array_filter($resourceIds, function (int $resourceId) {
-			return !isset($this->shareCache[$resourceId]);
+			return empty($this->shareCache->get((string)$resourceId));
 		});
-		if (count($resourceIds) === 0) {
+		if (empty($resourceIds)) {
 			return;
 		}
-		$query = $this->db->getQueryBuilder();
-		$result = $query->select(['resourceid', 'principaluri', 'access'])
-			->from('dav_shares')
-			->where($query->expr()->in('resourceid', $query->createNamedParameter($resourceIds, IQueryBuilder::PARAM_INT_ARRAY)))
-			->andWhere($query->expr()->eq('type', $query->createNamedParameter($this->resourceType)))
-			->groupBy(['principaluri', 'access', 'resourceid'])
-			->executeQuery();
 
+		$rows = $this->service->getSharesForIds($resourceIds);
 		$sharesByResource = array_fill_keys($resourceIds, []);
-		while ($row = $result->fetch()) {
+		foreach($rows as $row) {
 			$resourceId = (int)$row['resourceid'];
 			$p = $this->principalBackend->getPrincipalByPath($row['principaluri']);
 			$sharesByResource[$resourceId][] = [
@@ -235,12 +187,9 @@ class Backend {
 				'status' => 1,
 				'readOnly' => (int) $row['access'] === self::ACCESS_READ,
 				'{http://owncloud.org/ns}principal' => (string)$row['principaluri'],
-				'{http://owncloud.org/ns}group-share' => isset($p['uri']) ? str_starts_with($p['uri'], 'principals/groups') : false
+				'{http://owncloud.org/ns}group-share' => isset($p['uri']) && str_starts_with($p['uri'], 'principals/groups')
 			];
-		}
-
-		foreach ($resourceIds as $resourceId) {
-			$this->shareCache->set($resourceId, $sharesByResource[$resourceId]);
+			$this->shareCache->set((string)$resourceId, $sharesByResource[$resourceId]);
 		}
 	}
 
@@ -249,10 +198,10 @@ class Backend {
 	 *
 	 * @param int $resourceId
 	 * @param list<array{privilege: string, principal: string, protected: bool}> $acl
-	 * @return list<array{privilege: string, principal: string, protected: bool}>
+	 * @param list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> $shares
+	 * @return list<array{principal: string, privilege: string, protected: bool}>
 	 */
-	public function applyShareAcl(int $resourceId, array $acl): array {
-		$shares = $this->getShares($resourceId);
+	public function applyShareAcl(array $shares, array $acl): array {
 		foreach ($shares as $share) {
 			$acl[] = [
 				'privilege' => '{DAV:}read',
@@ -265,7 +214,7 @@ class Backend {
 					'principal' => $share['{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}principal'],
 					'protected' => true,
 				];
-			} elseif ($this->resourceType === 'calendar') {
+			} elseif ($this->service->getResourceType() === 'calendar') {
 				// Allow changing the properties of read only calendars,
 				// so users can change the visibility.
 				$acl[] = [

+ 113 - 0
apps/dav/lib/DAV/Sharing/SharingMapper.php

@@ -0,0 +1,113 @@
+<?php
+
+declare(strict_types=1);
+/*
+ * @copyright 2024 Anna Larch <anna.larch@gmx.net>
+ *
+ * @author Anna Larch <anna.larch@gmx.net>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version.
+ *
+ * This library 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 library.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+namespace OCA\DAV\DAV\Sharing;
+
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+
+class SharingMapper {
+	public function __construct(private IDBConnection $db) {
+	}
+
+	public function getSharesForId(int $resourceId, string $resourceType): array {
+		$query = $this->db->getQueryBuilder();
+		$result = $query->select(['principaluri', 'access'])
+			->from('dav_shares')
+			->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT)))
+			->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType, IQueryBuilder::PARAM_STR)))
+			->andWhere($query->expr()->neq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT)))
+			->groupBy(['principaluri', 'access'])
+			->executeQuery();
+
+		$rows = $result->fetchAll();
+		$result->closeCursor();
+		return $rows;
+	}
+
+	public function getSharesForIds(array $resourceIds, string $resourceType): array {
+		$query = $this->db->getQueryBuilder();
+		$result = $query->select(['resourceid', 'principaluri', 'access'])
+			->from('dav_shares')
+			->where($query->expr()->in('resourceid', $query->createNamedParameter($resourceIds, IQueryBuilder::PARAM_INT_ARRAY)))
+			->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
+			->andWhere($query->expr()->neq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT)))
+			->groupBy(['principaluri', 'access', 'resourceid'])
+			->executeQuery();
+
+		$rows = $result->fetchAll();
+		$result->closeCursor();
+		return $rows;
+	}
+
+	public function unshare(int $resourceId, string $resourceType, string $principal): void {
+		$query = $this->db->getQueryBuilder();
+		$query->insert('dav_shares')
+			->values([
+				'principaluri' => $query->createNamedParameter($principal),
+				'type' => $query->createNamedParameter($resourceType),
+				'access' => $query->createNamedParameter(Backend::ACCESS_UNSHARED),
+				'resourceid' => $query->createNamedParameter($resourceId)
+			]);
+		$query->executeStatement();
+	}
+
+	public function share(int $resourceId, string $resourceType, int $access, string $principal): void {
+		$query = $this->db->getQueryBuilder();
+		$query->insert('dav_shares')
+			->values([
+				'principaluri' => $query->createNamedParameter($principal),
+				'type' => $query->createNamedParameter($resourceType),
+				'access' => $query->createNamedParameter($access),
+				'resourceid' => $query->createNamedParameter($resourceId)
+			]);
+		$query->executeStatement();
+	}
+
+	public function deleteShare(int $resourceId, string $resourceType, string $principal): void {
+		$query = $this->db->getQueryBuilder();
+		$query->delete('dav_shares');
+		$query->where(
+			$query->expr()->eq('resourceid', $query->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT)),
+			$query->expr()->eq('type', $query->createNamedParameter($resourceType)),
+			$query->expr()->eq('principaluri', $query->createNamedParameter($principal))
+		);
+		$query->executeStatement();
+
+	}
+
+	public function deleteAllShares(int $resourceId, string $resourceType): void {
+		$query = $this->db->getQueryBuilder();
+		$query->delete('dav_shares')
+			->where($query->expr()->eq('resourceid', $query->createNamedParameter($resourceId)))
+			->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
+			->executeStatement();
+	}
+
+	public function deleteAllSharesByUser(string $principaluri, string $resourceType): void {
+		$query = $this->db->getQueryBuilder();
+		$query->delete('dav_shares')
+			->where($query->expr()->eq('principaluri', $query->createNamedParameter($principaluri)))
+			->andWhere($query->expr()->eq('type', $query->createNamedParameter($resourceType)))
+			->executeStatement();
+	}
+}

+ 71 - 0
apps/dav/lib/DAV/Sharing/SharingService.php

@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2024 Anna Larch <anna.larch@gmx.net>
+ *
+ * @author Anna Larch <anna.larch@gmx.net>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version.
+ *
+ * This library 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 library.  If not, see <http://www.gnu.org/licenses/>.
+ */
+namespace OCA\DAV\DAV\Sharing;
+
+abstract class SharingService {
+	protected string $resourceType = '';
+	public function __construct(protected SharingMapper $mapper) {
+	}
+
+	public function getResourceType(): string {
+		return $this->resourceType;
+	}
+	public function shareWith(int $resourceId, string $principal, int $access): void {
+		// remove the share if it already exists
+		$this->mapper->deleteShare($resourceId, $this->getResourceType(), $principal);
+		$this->mapper->share($resourceId, $this->getResourceType(), $access, $principal);
+	}
+
+	public function unshare(int $resourceId, string $principal): void {
+		$this->mapper->unshare($resourceId, $this->getResourceType(), $principal);
+	}
+
+	public function deleteShare(int $resourceId, string $principal): void {
+		$this->mapper->deleteShare($resourceId, $this->getResourceType(), $principal);
+	}
+
+	public function deleteAllShares(int $resourceId): void {
+		$this->mapper->deleteAllShares($resourceId, $this->getResourceType());
+	}
+
+	public function deleteAllSharesByUser(string $principaluri): void {
+		$this->mapper->deleteAllSharesByUser($principaluri, $this->getResourceType());
+	}
+
+	public function getShares(int $resourceId): array {
+		return $this->mapper->getSharesForId($resourceId, $this->getResourceType());
+	}
+
+	public function getSharesForIds(array $resourceIds): array {
+		return $this->mapper->getSharesForIds($resourceIds, $this->getResourceType());
+	}
+
+	/**
+	 * @param array $oldShares
+	 * @return bool
+	 */
+	public function hasGroupShare(array $oldShares): bool {
+		return !empty(array_filter($oldShares, function (array $share) {
+			return $share['{http://owncloud.org/ns}group-share'] === true;
+		}));
+	}
+}

+ 22 - 5
apps/dav/lib/RootCollection.php

@@ -37,6 +37,7 @@ use OCA\DAV\CalDAV\Proxy\ProxyMapper;
 use OCA\DAV\CalDAV\PublicCalendarRoot;
 use OCA\DAV\CalDAV\ResourceBooking\ResourcePrincipalBackend;
 use OCA\DAV\CalDAV\ResourceBooking\RoomPrincipalBackend;
+use OCA\DAV\CalDAV\Sharing\Backend;
 use OCA\DAV\CardDAV\AddressBookRoot;
 use OCA\DAV\CardDAV\CardDavBackend;
 use OCA\DAV\Connector\Sabre\Principal;
@@ -80,6 +81,7 @@ class RootCollection extends SimpleCollection {
 			\OC::$server->getConfig(),
 			\OC::$server->getL10NFactory()
 		);
+
 		$groupPrincipalBackend = new GroupPrincipalBackend($groupManager, $userSession, $shareManager, $config);
 		$calendarResourcePrincipalBackend = new ResourcePrincipalBackend($db, $userSession, $groupManager, $logger, $proxyMapper);
 		$calendarRoomPrincipalBackend = new RoomPrincipalBackend($db, $userSession, $groupManager, $logger, $proxyMapper);
@@ -97,7 +99,7 @@ class RootCollection extends SimpleCollection {
 		$calendarResourcePrincipals->disableListing = $disableListing;
 		$calendarRoomPrincipals = new Collection($calendarRoomPrincipalBackend, 'principals/calendar-rooms');
 		$calendarRoomPrincipals->disableListing = $disableListing;
-
+		$calendarSharingBackend = \OC::$server->get(Backend::class);
 
 		$filesCollection = new Files\RootCollection($userPrincipalBackend, 'principals/users');
 		$filesCollection->disableListing = $disableListing;
@@ -105,11 +107,12 @@ class RootCollection extends SimpleCollection {
 			$db,
 			$userPrincipalBackend,
 			$userManager,
-			$groupManager,
 			$random,
 			$logger,
 			$dispatcher,
-			$config
+			$config,
+			$calendarSharingBackend,
+			false,
 		);
 		$userCalendarRoot = new CalendarRoot($userPrincipalBackend, $caldavBackend, 'principals/users', $logger);
 		$userCalendarRoot->disableListing = $disableListing;
@@ -142,12 +145,26 @@ class RootCollection extends SimpleCollection {
 			$logger
 		);
 
+		$contactsSharingBackend = \OC::$server->get(\OCA\DAV\CardDAV\Sharing\Backend::class);
+
 		$pluginManager = new PluginManager(\OC::$server, \OC::$server->query(IAppManager::class));
-		$usersCardDavBackend = new CardDavBackend($db, $userPrincipalBackend, $userManager, $groupManager, $dispatcher);
+		$usersCardDavBackend = new CardDavBackend(
+			$db,
+			$userPrincipalBackend,
+			$userManager,
+			$dispatcher,
+			$contactsSharingBackend,
+		);
 		$usersAddressBookRoot = new AddressBookRoot($userPrincipalBackend, $usersCardDavBackend, $pluginManager, $userSession->getUser(), $groupManager, 'principals/users');
 		$usersAddressBookRoot->disableListing = $disableListing;
 
-		$systemCardDavBackend = new CardDavBackend($db, $userPrincipalBackend, $userManager, $groupManager, $dispatcher);
+		$systemCardDavBackend = new CardDavBackend(
+			$db,
+			$userPrincipalBackend,
+			$userManager,
+			$dispatcher,
+			$contactsSharingBackend,
+		);
 		$systemAddressBookRoot = new AddressBookRoot(new SystemPrincipalBackend(), $systemCardDavBackend, $pluginManager, $userSession->getUser(), $groupManager, 'principals/system');
 		$systemAddressBookRoot->disableListing = $disableListing;
 

+ 109 - 0
apps/dav/tests/integration/DAV/Sharing/SharingMapperTest.php

@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * @copyright 2024 Anna Larch <anna.larch@gmx.net>
+ *
+ * @author Anna Larch <anna.larch@gmx.net>
+ *
+ * @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/>.
+ */
+
+use OCA\DAV\DAV\Sharing\SharingMapper;
+use OCP\IDBConnection;
+use OCP\Server;
+use Test\TestCase;
+
+/**
+ * @group DB
+ */
+class SharingMapperTest extends TestCase {
+
+	private SharingMapper $mapper;
+	private IDBConnection $db;
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		$this->db = Server::get(IDBConnection::class);
+		$this->mapper = new SharingMapper($this->db);
+		$qb = $this->db->getQueryBuilder();
+		$qb->delete('dav_shares')->executeStatement();
+	}
+
+	public function testShareAndGet(): void {
+		$resourceId = 42;
+		$resourceType = 'calendar';
+		$access = 3;
+		$principal = 'principals/users/bob';
+		$this->mapper->share($resourceId, $resourceType, $access, $principal);
+		$shares = $this->mapper->getSharesForId($resourceId, $resourceType);
+		$this->assertCount(1, $shares);
+	}
+
+	public function testShareDelete(): void {
+		$resourceId = 42;
+		$resourceType = 'calendar';
+		$access = 3;
+		$principal = 'principals/users/bob';
+		$this->mapper->share($resourceId, $resourceType, $access, $principal);
+		$this->mapper->deleteShare($resourceId, $resourceType, $principal);
+		$shares = $this->mapper->getSharesForId($resourceId, $resourceType);
+		$this->assertEmpty($shares);
+	}
+
+	public function testShareUnshare(): void {
+		$resourceId = 42;
+		$resourceType = 'calendar';
+		$access = 3;
+		$principal = 'principals/groups/alicegroup';
+		$userPrincipal = 'principals/users/alice';
+		$this->mapper->share($resourceId, $resourceType, $access, $principal);
+		$this->mapper->unshare($resourceId, $resourceType, $userPrincipal);
+		$shares = $this->mapper->getSharesForId($resourceId, $resourceType);
+		$this->assertCount(1, $shares);
+	}
+
+	public function testShareDeleteAll(): void {
+		$resourceId = 42;
+		$resourceType = 'calendar';
+		$access = 3;
+		$principal = 'principals/groups/alicegroup';
+		$userPrincipal = 'principals/users/alice';
+		$this->mapper->share($resourceId, $resourceType, $access, $principal);
+		$this->mapper->unshare($resourceId, $resourceType, $userPrincipal);
+		$shares = $this->mapper->getSharesForId($resourceId, $resourceType);
+		$this->assertCount(1, $shares);
+		$this->mapper->deleteAllShares($resourceId, $resourceType);
+		$shares = $this->mapper->getSharesForId($resourceId, $resourceType);
+		$this->assertEmpty($shares);
+	}
+
+	public function testShareDeleteAllForUser(): void {
+		$resourceId = 42;
+		$resourceType = 'calendar';
+		$access = 3;
+		$principal = 'principals/groups/alicegroup';
+		$this->mapper->share($resourceId, $resourceType, $access, $principal);
+		$shares = $this->mapper->getSharesForId($resourceId, $resourceType);
+		$this->assertCount(1, $shares);
+		$this->mapper->deleteAllSharesByUser($principal, $resourceType);
+		$shares = $this->mapper->getSharesForId($resourceId, $resourceType);
+		$this->assertEmpty($shares);
+	}
+
+}

+ 24 - 22
apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php

@@ -30,10 +30,14 @@ namespace OCA\DAV\Tests\unit\CalDAV;
 use OC\KnownUser\KnownUserService;
 use OCA\DAV\CalDAV\CalDavBackend;
 use OCA\DAV\CalDAV\Proxy\ProxyMapper;
+use OCA\DAV\CalDAV\Sharing\Backend as SharingBackend;
+use OCA\DAV\CalDAV\Sharing\Service;
 use OCA\DAV\Connector\Sabre\Principal;
+use OCA\DAV\DAV\Sharing\SharingMapper;
 use OCP\Accounts\IAccountManager;
 use OCP\App\IAppManager;
 use OCP\EventDispatcher\IEventDispatcher;
+use OCP\ICacheFactory;
 use OCP\IConfig;
 use OCP\IGroupManager;
 use OCP\IUserManager;
@@ -56,26 +60,16 @@ use Test\TestCase;
  */
 abstract class AbstractCalDavBackend extends TestCase {
 
-	/** @var CalDavBackend */
-	protected $backend;
-
-	/** @var Principal | MockObject */
-	protected $principal;
-	/** @var IUserManager|MockObject */
-	protected $userManager;
-	/** @var IGroupManager|MockObject */
-	protected $groupManager;
-	/** @var IEventDispatcher|MockObject */
-	protected $dispatcher;
-
-
-	/** @var IConfig | MockObject */
-	private $config;
-	/** @var ISecureRandom */
-	private $random;
-	/** @var LoggerInterface*/
-	private $logger;
 
+	protected CalDavBackend $backend;
+	protected Principal|MockObject $principal;
+	protected IUserManager|MockObject $userManager;
+	protected IGroupManager|MockObject $groupManager;
+	protected IEventDispatcher|MockObject $dispatcher;
+	private LoggerInterface|MockObject $logger;
+	private IConfig|MockObject $config;
+	private ISecureRandom $random;
+	protected SharingBackend $sharingBackend;
 	public const UNIT_TEST_USER = 'principals/users/caldav-unit-test';
 	public const UNIT_TEST_USER1 = 'principals/users/caldav-unit-test1';
 	public const UNIT_TEST_GROUP = 'principals/groups/caldav-unit-test-group';
@@ -100,7 +94,7 @@ abstract class AbstractCalDavBackend extends TestCase {
 				$this->createMock(IConfig::class),
 				$this->createMock(IFactory::class)
 			])
-			->setMethods(['getPrincipalByPath', 'getGroupMembership'])
+			->setMethods(['getPrincipalByPath', 'getGroupMembership', 'findByUri'])
 			->getMock();
 		$this->principal->expects($this->any())->method('getPrincipalByPath')
 			->willReturn([
@@ -115,15 +109,23 @@ abstract class AbstractCalDavBackend extends TestCase {
 		$this->random = \OC::$server->getSecureRandom();
 		$this->logger = $this->createMock(LoggerInterface::class);
 		$this->config = $this->createMock(IConfig::class);
+		$this->sharingBackend = new SharingBackend(
+			$this->userManager,
+			$this->groupManager,
+			$this->principal,
+			$this->createMock(ICacheFactory::class),
+			new Service(new SharingMapper($db)),
+			$this->logger);
 		$this->backend = new CalDavBackend(
 			$db,
 			$this->principal,
 			$this->userManager,
-			$this->groupManager,
 			$this->random,
 			$this->logger,
 			$this->dispatcher,
-			$this->config
+			$this->config,
+			$this->sharingBackend,
+			false,
 		);
 
 		$this->cleanUpBackend();

+ 22 - 9
apps/dav/tests/unit/CalDAV/CalDavBackendTest.php

@@ -93,6 +93,9 @@ class CalDavBackendTest extends AbstractCalDavBackend {
 					'href' => 'principal:' . self::UNIT_TEST_GROUP,
 					'readOnly' => true
 				]
+			], [
+				self::UNIT_TEST_USER1,
+				self::UNIT_TEST_GROUP,
 			]],
 			[true, true, true, false, [
 				[
@@ -103,6 +106,9 @@ class CalDavBackendTest extends AbstractCalDavBackend {
 					'href' => 'principal:' . self::UNIT_TEST_GROUP2,
 					'readOnly' => false,
 				],
+			], [
+				self::UNIT_TEST_GROUP,
+				self::UNIT_TEST_GROUP2,
 			]],
 			[true, true, true, true, [
 				[
@@ -113,12 +119,17 @@ class CalDavBackendTest extends AbstractCalDavBackend {
 					'href' => 'principal:' . self::UNIT_TEST_GROUP2,
 					'readOnly' => true,
 				],
+			], [
+				self::UNIT_TEST_GROUP,
+				self::UNIT_TEST_GROUP2,
 			]],
 			[true, false, false, false, [
 				[
 					'href' => 'principal:' . self::UNIT_TEST_USER1,
 					'readOnly' => true
 				],
+			], [
+				self::UNIT_TEST_USER1,
 			]],
 
 		];
@@ -127,27 +138,26 @@ class CalDavBackendTest extends AbstractCalDavBackend {
 	/**
 	 * @dataProvider providesSharingData
 	 */
-	public function testCalendarSharing($userCanRead, $userCanWrite, $groupCanRead, $groupCanWrite, $add): void {
-		/** @var IL10N|\PHPUnit\Framework\MockObject\MockObject $l10n */
+	public function testCalendarSharing($userCanRead, $userCanWrite, $groupCanRead, $groupCanWrite, $add, $principals): void {
+		$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
+		$config = $this->createMock(IConfig::class);
+		/** @var IL10N|MockObject $l10n */
 		$l10n = $this->createMock(IL10N::class);
-		$l10n
-			->expects($this->any())
+		$l10n->expects($this->any())
 			->method('t')
 			->willReturnCallback(function ($text, $parameters = []) {
 				return vsprintf($text, $parameters);
 			});
 
-		$logger = $this->createMock(\Psr\Log\LoggerInterface::class);
-
-		$config = $this->createMock(IConfig::class);
-
 		$this->userManager->expects($this->any())
 			->method('userExists')
 			->willReturn(true);
-
 		$this->groupManager->expects($this->any())
 			->method('groupExists')
 			->willReturn(true);
+		$this->principal->expects(self::atLeastOnce())
+			->method('findByUri')
+			->willReturnOnConsecutiveCalls(...$principals);
 
 		$calendarId = $this->createTestCalendar();
 		$calendars = $this->backend->getCalendarsForUser(self::UNIT_TEST_USER);
@@ -1250,6 +1260,9 @@ EOD;
 		$this->groupManager->expects($this->any())
 			->method('groupExists')
 			->willReturn(true);
+		$this->principal->expects(self::atLeastOnce())
+			->method('findByUri')
+			->willReturn(self::UNIT_TEST_USER);
 
 		$me = self::UNIT_TEST_USER;
 		$sharer = self::UNIT_TEST_USER1;

+ 1 - 3
apps/dav/tests/unit/CalDAV/CalendarTest.php

@@ -83,11 +83,9 @@ class CalendarTest extends TestCase {
 
 
 	public function testDeleteFromGroup(): void {
-		$this->expectException(\Sabre\DAV\Exception\Forbidden::class);
-
 		/** @var MockObject | CalDavBackend $backend */
 		$backend = $this->getMockBuilder(CalDavBackend::class)->disableOriginalConstructor()->getMock();
-		$backend->expects($this->never())->method('updateShares');
+		$backend->expects($this->once())->method('updateShares');
 		$backend->expects($this->any())->method('getShares')->willReturn([
 			['href' => 'principal:group2']
 		]);

+ 4 - 2
apps/dav/tests/unit/CalDAV/PublicCalendarRootTest.php

@@ -84,6 +84,7 @@ class PublicCalendarRootTest extends TestCase {
 		$this->logger = $this->createMock(LoggerInterface::class);
 		$dispatcher = $this->createMock(IEventDispatcher::class);
 		$config = $this->createMock(IConfig::class);
+		$sharingBackend = $this->createMock(\OCA\DAV\CalDAV\Sharing\Backend::class);
 
 		$this->principal->expects($this->any())->method('getGroupMembership')
 			->withAnyParameters()
@@ -97,11 +98,12 @@ class PublicCalendarRootTest extends TestCase {
 			$db,
 			$this->principal,
 			$this->userManager,
-			$this->groupManager,
 			$this->random,
 			$this->logger,
 			$dispatcher,
-			$config
+			$config,
+			$sharingBackend,
+			false,
 		);
 		$this->l10n = $this->getMockBuilder(IL10N::class)
 			->disableOriginalConstructor()->getMock();

+ 35 - 14
apps/dav/tests/unit/CardDAV/CardDavBackendTest.php

@@ -37,11 +37,15 @@ use OC\KnownUser\KnownUserService;
 use OCA\DAV\CalDAV\Proxy\ProxyMapper;
 use OCA\DAV\CardDAV\AddressBook;
 use OCA\DAV\CardDAV\CardDavBackend;
+use OCA\DAV\CardDAV\Sharing\Backend;
+use OCA\DAV\CardDAV\Sharing\Service;
 use OCA\DAV\Connector\Sabre\Principal;
+use OCA\DAV\DAV\Sharing\SharingMapper;
 use OCP\Accounts\IAccountManager;
 use OCP\App\IAppManager;
 use OCP\DB\QueryBuilder\IQueryBuilder;
 use OCP\EventDispatcher\IEventDispatcher;
+use OCP\ICacheFactory;
 use OCP\IConfig;
 use OCP\IDBConnection;
 use OCP\IGroupManager;
@@ -50,6 +54,7 @@ use OCP\IUserManager;
 use OCP\IUserSession;
 use OCP\L10N\IFactory;
 use OCP\Share\IManager as ShareManager;
+use Psr\Log\LoggerInterface;
 use Sabre\DAV\Exception\BadRequest;
 use Sabre\DAV\PropPatch;
 use Sabre\VObject\Component\VCard;
@@ -78,7 +83,7 @@ class CardDavBackendTest extends TestCase {
 
 	/** @var IEventDispatcher|MockObject */
 	private $dispatcher;
-
+	private Backend $sharingBackend;
 	/** @var  IDBConnection */
 	private $db;
 
@@ -141,7 +146,7 @@ class CardDavBackendTest extends TestCase {
 				$this->createMock(IConfig::class),
 				$this->createMock(IFactory::class)
 			])
-			->setMethods(['getPrincipalByPath', 'getGroupMembership'])
+			->setMethods(['getPrincipalByPath', 'getGroupMembership', 'findByUri'])
 			->getMock();
 		$this->principal->method('getPrincipalByPath')
 			->willReturn([
@@ -154,8 +159,20 @@ class CardDavBackendTest extends TestCase {
 		$this->dispatcher = $this->createMock(IEventDispatcher::class);
 
 		$this->db = \OC::$server->getDatabaseConnection();
-
-		$this->backend = new CardDavBackend($this->db, $this->principal, $this->userManager, $this->groupManager, $this->dispatcher);
+		$this->sharingBackend = new Backend($this->userManager,
+			$this->groupManager,
+			$this->principal,
+			$this->createMock(ICacheFactory::class),
+			new Service(new SharingMapper($this->db)),
+			$this->createMock(LoggerInterface::class)
+		);
+
+		$this->backend = new CardDavBackend($this->db,
+			$this->principal,
+			$this->userManager,
+			$this->dispatcher,
+			$this->sharingBackend,
+		);
 		// start every test with a empty cards_properties and cards table
 		$query = $this->db->getQueryBuilder();
 		$query->delete('cards_properties')->execute();
@@ -213,10 +230,12 @@ class CardDavBackendTest extends TestCase {
 		$this->userManager->expects($this->any())
 			->method('userExists')
 			->willReturn(true);
-
 		$this->groupManager->expects($this->any())
 			->method('groupExists')
 			->willReturn(true);
+		$this->principal->expects(self::atLeastOnce())
+			->method('findByUri')
+			->willReturnOnConsecutiveCalls(self::UNIT_TEST_USER1, self::UNIT_TEST_GROUP);
 
 		$this->backend->createAddressBook(self::UNIT_TEST_USER, 'Example', []);
 		$books = $this->backend->getAddressBooksForUser(self::UNIT_TEST_USER);
@@ -243,7 +262,7 @@ class CardDavBackendTest extends TestCase {
 	public function testCardOperations(): void {
 		/** @var CardDavBackend | \PHPUnit\Framework\MockObject\MockObject $backend */
 		$backend = $this->getMockBuilder(CardDavBackend::class)
-				->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->groupManager, $this->dispatcher])
+				->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
 				->onlyMethods(['updateProperties', 'purgeProperties'])->getMock();
 
 		// create a new address book
@@ -298,7 +317,7 @@ class CardDavBackendTest extends TestCase {
 
 	public function testMultiCard(): void {
 		$this->backend = $this->getMockBuilder(CardDavBackend::class)
-			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->groupManager, $this->dispatcher])
+			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
 			->setMethods(['updateProperties'])->getMock();
 
 		// create a new address book
@@ -351,7 +370,7 @@ class CardDavBackendTest extends TestCase {
 
 	public function testMultipleUIDOnDifferentAddressbooks(): void {
 		$this->backend = $this->getMockBuilder(CardDavBackend::class)
-			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->groupManager, $this->dispatcher])
+			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
 			->onlyMethods(['updateProperties'])->getMock();
 
 		// create 2 new address books
@@ -373,7 +392,7 @@ class CardDavBackendTest extends TestCase {
 
 	public function testMultipleUIDDenied(): void {
 		$this->backend = $this->getMockBuilder(CardDavBackend::class)
-			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->groupManager, $this->dispatcher])
+			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
 			->setMethods(['updateProperties'])->getMock();
 
 		// create a new address book
@@ -394,7 +413,7 @@ class CardDavBackendTest extends TestCase {
 
 	public function testNoValidUID(): void {
 		$this->backend = $this->getMockBuilder(CardDavBackend::class)
-			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->groupManager, $this->dispatcher])
+			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
 			->setMethods(['updateProperties'])->getMock();
 
 		// create a new address book
@@ -411,7 +430,7 @@ class CardDavBackendTest extends TestCase {
 
 	public function testDeleteWithoutCard(): void {
 		$this->backend = $this->getMockBuilder(CardDavBackend::class)
-			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->groupManager, $this->dispatcher])
+			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
 			->onlyMethods([
 				'getCardId',
 				'addChange',
@@ -451,7 +470,7 @@ class CardDavBackendTest extends TestCase {
 
 	public function testSyncSupport(): void {
 		$this->backend = $this->getMockBuilder(CardDavBackend::class)
-			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->groupManager, $this->dispatcher])
+			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
 			->setMethods(['updateProperties'])->getMock();
 
 		// create a new address book
@@ -477,10 +496,12 @@ class CardDavBackendTest extends TestCase {
 		$this->userManager->expects($this->any())
 			->method('userExists')
 			->willReturn(true);
-
 		$this->groupManager->expects($this->any())
 			->method('groupExists')
 			->willReturn(true);
+		$this->principal->expects(self::any())
+			->method('findByUri')
+			->willReturn(self::UNIT_TEST_USER1);
 
 		$this->backend->createAddressBook(self::UNIT_TEST_USER, 'Example', []);
 		$books = $this->backend->getAddressBooksForUser(self::UNIT_TEST_USER);
@@ -517,7 +538,7 @@ class CardDavBackendTest extends TestCase {
 		$cardId = 2;
 
 		$backend = $this->getMockBuilder(CardDavBackend::class)
-			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->groupManager, $this->dispatcher])
+			->setConstructorArgs([$this->db, $this->principal, $this->userManager, $this->dispatcher, $this->sharingBackend])
 			->onlyMethods(['getCardId'])->getMock();
 
 		$backend->expects($this->any())->method('getCardId')->willReturn($cardId);

+ 427 - 0
apps/dav/tests/unit/DAV/Sharing/BackendTest.php

@@ -0,0 +1,427 @@
+<?php
+
+declare(strict_types=1);
+/*
+ * @copyright 2024 Anna Larch <anna.larch@gmx.net>
+ *
+ * @author Anna Larch <anna.larch@gmx.net>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version.
+ *
+ * This library 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 library.  If not, see <http://www.gnu.org/licenses/>.
+ */
+namespace OCA\DAV\Tests\unit\DAV\Sharing;
+
+use OCA\DAV\CalDAV\Sharing\Backend as CalendarSharingBackend;
+use OCA\DAV\CalDAV\Sharing\Service;
+use OCA\DAV\CardDAV\Sharing\Backend as ContactsSharingBackend;
+use OCA\DAV\Connector\Sabre\Principal;
+use OCA\DAV\DAV\Sharing\Backend;
+use OCA\DAV\DAV\Sharing\IShareable;
+use OCP\ICache;
+use OCP\ICacheFactory;
+use OCP\IDBConnection;
+use OCP\IGroupManager;
+use OCP\IUserManager;
+use PHPUnit\Framework\MockObject\MockObject;
+use Psr\Log\LoggerInterface;
+use Test\TestCase;
+
+class BackendTest extends TestCase {
+
+	private IDBConnection|MockObject $db;
+	private IUserManager|MockObject $userManager;
+	private IGroupManager|MockObject $groupManager;
+	private MockObject|Principal $principalBackend;
+	private MockObject|ICache $shareCache;
+	private LoggerInterface|MockObject $logger;
+	private MockObject|ICacheFactory $cacheFactory;
+	private Service|MockObject $calendarService;
+	private CalendarSharingBackend $backend;
+
+	protected function setUp(): void {
+		parent::setUp();
+		$this->db = $this->createMock(IDBConnection::class);
+		$this->userManager = $this->createMock(IUserManager::class);
+		$this->groupManager = $this->createMock(IGroupManager::class);
+		$this->principalBackend = $this->createMock(Principal::class);
+		$this->cacheFactory = $this->createMock(ICacheFactory::class);
+		$this->shareCache = $this->createMock(ICache::class);
+		$this->logger = $this->createMock(LoggerInterface::class);
+		$this->calendarService = $this->createMock(Service::class);
+		$this->cacheFactory->expects(self::any())
+			->method('createInMemory')
+			->willReturn($this->shareCache);
+
+		$this->backend = new CalendarSharingBackend(
+			$this->userManager,
+			$this->groupManager,
+			$this->principalBackend,
+			$this->cacheFactory,
+			$this->calendarService,
+			$this->logger,
+		);
+	}
+
+	public function testUpdateShareCalendarBob(): void {
+		$shareable = $this->createConfiguredMock(IShareable::class, [
+			'getOwner' => 'principals/users/alice',
+			'getResourceId' => 42,
+		]);
+		$add = [
+			[
+				'href' => 'principal:principals/users/bob',
+				'readOnly' => true,
+			]
+		];
+		$principal = 'principals/users/bob';
+
+		$this->shareCache->expects(self::once())
+			->method('clear');
+		$this->principalBackend->expects(self::once())
+			->method('findByUri')
+			->willReturn($principal);
+		$this->userManager->expects(self::once())
+			->method('userExists')
+			->willReturn(true);
+		$this->groupManager->expects(self::never())
+			->method('groupExists');
+		$this->calendarService->expects(self::once())
+			->method('shareWith')
+			->with($shareable->getResourceId(), $principal, Backend::ACCESS_READ);
+
+		$this->backend->updateShares($shareable, $add, []);
+	}
+
+	public function testUpdateShareCalendarGroup(): void {
+		$shareable = $this->createConfiguredMock(IShareable::class, [
+			'getOwner' => 'principals/users/alice',
+			'getResourceId' => 42,
+		]);
+		$add = [
+			[
+				'href' => 'principal:principals/groups/bob',
+				'readOnly' => true,
+			]
+		];
+		$principal = 'principals/groups/bob';
+
+		$this->shareCache->expects(self::once())
+			->method('clear');
+		$this->principalBackend->expects(self::once())
+			->method('findByUri')
+			->willReturn($principal);
+		$this->userManager->expects(self::never())
+			->method('userExists');
+		$this->groupManager->expects(self::once())
+			->method('groupExists')
+			->willReturn(true);
+		$this->calendarService->expects(self::once())
+			->method('shareWith')
+			->with($shareable->getResourceId(), $principal, Backend::ACCESS_READ);
+
+		$this->backend->updateShares($shareable, $add, []);
+	}
+
+	public function testUpdateShareContactsBob(): void {
+		$shareable = $this->createConfiguredMock(IShareable::class, [
+			'getOwner' => 'principals/users/alice',
+			'getResourceId' => 42,
+		]);
+		$add = [
+			[
+				'href' => 'principal:principals/users/bob',
+				'readOnly' => true,
+			]
+		];
+		$principal = 'principals/users/bob';
+
+		$this->shareCache->expects(self::once())
+			->method('clear');
+		$this->principalBackend->expects(self::once())
+			->method('findByUri')
+			->willReturn($principal);
+		$this->userManager->expects(self::once())
+			->method('userExists')
+			->willReturn(true);
+		$this->groupManager->expects(self::never())
+			->method('groupExists');
+		$this->calendarService->expects(self::once())
+			->method('shareWith')
+			->with($shareable->getResourceId(), $principal, Backend::ACCESS_READ);
+
+		$this->backend->updateShares($shareable, $add, []);
+	}
+
+	public function testUpdateShareContactsGroup(): void {
+		$shareable = $this->createConfiguredMock(IShareable::class, [
+			'getOwner' => 'principals/users/alice',
+			'getResourceId' => 42,
+		]);
+		$add = [
+			[
+				'href' => 'principal:principals/groups/bob',
+				'readOnly' => true,
+			]
+		];
+		$principal = 'principals/groups/bob';
+
+		$this->shareCache->expects(self::once())
+			->method('clear');
+		$this->principalBackend->expects(self::once())
+			->method('findByUri')
+			->willReturn($principal);
+		$this->userManager->expects(self::never())
+			->method('userExists');
+		$this->groupManager->expects(self::once())
+			->method('groupExists')
+			->willReturn(true);
+		$this->calendarService->expects(self::once())
+			->method('shareWith')
+			->with($shareable->getResourceId(), $principal, Backend::ACCESS_READ);
+
+		$this->backend->updateShares($shareable, $add, []);
+	}
+
+	public function testUpdateShareCircle(): void {
+		$shareable = $this->createConfiguredMock(IShareable::class, [
+			'getOwner' => 'principals/users/alice',
+			'getResourceId' => 42,
+		]);
+		$add = [
+			[
+				'href' => 'principal:principals/circles/bob',
+				'readOnly' => true,
+			]
+		];
+		$principal = 'principals/groups/bob';
+
+		$this->shareCache->expects(self::once())
+			->method('clear');
+		$this->principalBackend->expects(self::once())
+			->method('findByUri')
+			->willReturn($principal);
+		$this->userManager->expects(self::never())
+			->method('userExists');
+		$this->groupManager->expects(self::once())
+			->method('groupExists')
+			->willReturn(true);
+		$this->calendarService->expects(self::once())
+			->method('shareWith')
+			->with($shareable->getResourceId(), $principal, Backend::ACCESS_READ);
+
+		$this->backend->updateShares($shareable, $add, []);
+	}
+
+	public function testUnshareBob(): void {
+		$shareable = $this->createConfiguredMock(IShareable::class, [
+			'getOwner' => 'principals/users/alice',
+			'getResourceId' => 42,
+		]);
+		$remove = [
+			[
+				'href' => 'principal:principals/users/bob',
+				'readOnly' => true,
+			]
+		];
+		$principal = 'principals/users/bob';
+
+		$this->shareCache->expects(self::once())
+			->method('clear');
+		$this->principalBackend->expects(self::once())
+			->method('findByUri')
+			->willReturn($principal);
+		$this->calendarService->expects(self::once())
+			->method('deleteShare')
+			->with($shareable->getResourceId(), $principal);
+		$this->calendarService->expects(self::once())
+			->method('hasGroupShare')
+			->willReturn(false);
+		$this->calendarService->expects(self::never())
+			->method('unshare');
+
+		$this->backend->updateShares($shareable, [], $remove);
+	}
+
+	public function testUnshareWithBobGroup(): void {
+		$shareable = $this->createConfiguredMock(IShareable::class, [
+			'getOwner' => 'principals/users/alice',
+			'getResourceId' => 42,
+		]);
+		$remove = [
+			[
+				'href' => 'principal:principals/users/bob',
+				'readOnly' => true,
+			]
+		];
+		$oldShares = [
+			[
+				'href' => 'principal:principals/groups/bob',
+				'commonName' => 'bob',
+				'status' => 1,
+				'readOnly' => true,
+				'{http://owncloud.org/ns}principal' => 'principals/groups/bob',
+				'{http://owncloud.org/ns}group-share' => true,
+			]
+		];
+
+
+		$this->shareCache->expects(self::once())
+			->method('clear');
+		$this->principalBackend->expects(self::once())
+			->method('findByUri')
+			->willReturn('principals/users/bob');
+		$this->calendarService->expects(self::once())
+			->method('deleteShare')
+			->with($shareable->getResourceId(), 'principals/users/bob');
+		$this->calendarService->expects(self::once())
+			->method('hasGroupShare')
+			->with($oldShares)
+			->willReturn(true);
+		$this->calendarService->expects(self::once())
+			->method('unshare')
+			->with($shareable->getResourceId(), 'principals/users/bob');
+
+		$this->backend->updateShares($shareable, [], $remove, $oldShares);
+	}
+
+	public function testGetShares(): void {
+		$resourceId = 42;
+		$principal = 'principals/groups/bob';
+		$rows = [
+			[
+				'principaluri' => $principal,
+				'access' => Backend::ACCESS_READ,
+			]
+		];
+		$expected = [
+			[
+				'href' => 'principal:principals/groups/bob',
+				'commonName' => 'bob',
+				'status' => 1,
+				'readOnly' => true,
+				'{http://owncloud.org/ns}principal' => $principal,
+				'{http://owncloud.org/ns}group-share' => true,
+			]
+		];
+
+
+		$this->shareCache->expects(self::once())
+			->method('get')
+			->with((string)$resourceId)
+			->willReturn(null);
+		$this->calendarService->expects(self::once())
+			->method('getShares')
+			->with($resourceId)
+			->willReturn($rows);
+		$this->principalBackend->expects(self::once())
+			->method('getPrincipalByPath')
+			->with($principal)
+			->willReturn(['uri' => $principal, '{DAV:}displayname' => 'bob']);
+		$this->shareCache->expects(self::once())
+			->method('set')
+			->with((string)$resourceId, $expected);
+
+		$result = $this->backend->getShares($resourceId);
+		$this->assertEquals($expected, $result);
+	}
+
+	public function testGetSharesAddressbooks(): void {
+		$service = $this->createMock(\OCA\DAV\CardDAV\Sharing\Service::class);
+		$backend = new ContactsSharingBackend(
+			$this->userManager,
+			$this->groupManager,
+			$this->principalBackend,
+			$this->cacheFactory,
+			$service,
+			$this->logger);
+		$resourceId = 42;
+		$principal = 'principals/groups/bob';
+		$rows = [
+			[
+				'principaluri' => $principal,
+				'access' => Backend::ACCESS_READ,
+			]
+		];
+		$expected = [
+			[
+				'href' => 'principal:principals/groups/bob',
+				'commonName' => 'bob',
+				'status' => 1,
+				'readOnly' => true,
+				'{http://owncloud.org/ns}principal' => $principal,
+				'{http://owncloud.org/ns}group-share' => true,
+			]
+		];
+
+		$this->shareCache->expects(self::once())
+			->method('get')
+			->with((string)$resourceId)
+			->willReturn(null);
+		$service->expects(self::once())
+			->method('getShares')
+			->with($resourceId)
+			->willReturn($rows);
+		$this->principalBackend->expects(self::once())
+			->method('getPrincipalByPath')
+			->with($principal)
+			->willReturn(['uri' => $principal, '{DAV:}displayname' => 'bob']);
+		$this->shareCache->expects(self::once())
+			->method('set')
+			->with((string)$resourceId, $expected);
+
+		$result = $backend->getShares($resourceId);
+		$this->assertEquals($expected, $result);
+	}
+
+	public function testPreloadShares(): void {
+		$resourceIds = [42, 99];
+		$rows = [
+			[
+				'resourceid' => 42,
+				'principaluri' => 'principals/groups/bob',
+				'access' => Backend::ACCESS_READ,
+			],
+			[
+				'resourceid' => 99,
+				'principaluri' => 'principals/users/carlos',
+				'access' => Backend::ACCESS_READ_WRITE,
+			]
+		];
+		$principalResults = [
+			['uri' => 'principals/groups/bob', '{DAV:}displayname' => 'bob'],
+			['uri' => 'principals/users/carlos', '{DAV:}displayname' => 'carlos'],
+		];
+
+		$this->shareCache->expects(self::exactly(2))
+			->method('get')
+			->willReturn(null);
+		$this->calendarService->expects(self::once())
+			->method('getSharesForIds')
+			->with($resourceIds)
+			->willReturn($rows);
+		$this->principalBackend->expects(self::exactly(2))
+			->method('getPrincipalByPath')
+			->willReturnCallback(function (string $principal) use ($principalResults) {
+				switch ($principal) {
+					case 'principals/groups/bob':
+						return $principalResults[0];
+					default:
+						return $principalResults[1];
+				}
+			});
+		$this->shareCache->expects(self::exactly(2))
+			->method('set');
+
+		$this->backend->preloadShares($resourceIds);
+	}
+}

+ 72 - 0
apps/dav/tests/unit/DAV/Sharing/SharingServiceTest.php

@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+/*
+ * @copyright 2024 Anna Larch <anna.larch@gmx.net>
+ *
+ * @author Anna Larch <anna.larch@gmx.net>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or any later version.
+ *
+ * This library 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 library.  If not, see <http://www.gnu.org/licenses/>.
+ */
+namespace OCA\DAV\Tests\unit\DAV\Sharing;
+
+use OCA\DAV\CalDAV\Sharing\Service;
+use OCA\DAV\DAV\Sharing\SharingMapper;
+use OCA\DAV\DAV\Sharing\SharingService;
+use Test\TestCase;
+
+class SharingServiceTest extends TestCase {
+
+	private SharingService $service;
+
+	protected function setUp(): void {
+		parent::setUp();
+		$this->service = new Service($this->createMock(SharingMapper::class));
+	}
+
+	public function testHasGroupShare(): void {
+		$oldShares = [
+			[
+				'href' => 'principal:principals/groups/bob',
+				'commonName' => 'bob',
+				'status' => 1,
+				'readOnly' => true,
+				'{http://owncloud.org/ns}principal' => 'principals/groups/bob',
+				'{http://owncloud.org/ns}group-share' => true,
+			],
+			[
+				'href' => 'principal:principals/users/bob',
+				'commonName' => 'bob',
+				'status' => 1,
+				'readOnly' => true,
+				'{http://owncloud.org/ns}principal' => 'principals/users/bob',
+				'{http://owncloud.org/ns}group-share' => false,
+			]
+		];
+
+		$this->assertTrue($this->service->hasGroupShare($oldShares));
+
+		$oldShares = [
+			[
+				'href' => 'principal:principals/users/bob',
+				'commonName' => 'bob',
+				'status' => 1,
+				'readOnly' => true,
+				'{http://owncloud.org/ns}principal' => 'principals/users/bob',
+				'{http://owncloud.org/ns}group-share' => false,
+			]
+		];
+		$this->assertFalse($this->service->hasGroupShare($oldShares));
+	}
+}

+ 2 - 2
build/integration/dav_features/caldav.feature

@@ -13,7 +13,7 @@ Feature: caldav
     When "user0" requests calendar "admin/MyCalendar" on the endpoint "/remote.php/dav/calendars/"
     Then The CalDAV HTTP status code should be "404"
     And The exception is "Sabre\DAV\Exception\NotFound"
-    And The error message is "Node with name 'MyCalendar' could not be found"
+    And The error message is "Calendar with name 'MyCalendar' could not be found"
 
   Scenario: Accessing a not shared calendar of another user via the legacy endpoint
     Given user "user0" exists
@@ -22,7 +22,7 @@ Feature: caldav
     When "user0" requests calendar "admin/MyCalendar" on the endpoint "/remote.php/caldav/calendars/"
     Then The CalDAV HTTP status code should be "404"
     And The exception is "Sabre\DAV\Exception\NotFound"
-    And The error message is "Node with name 'MyCalendar' could not be found"
+    And The error message is "Calendar with name 'MyCalendar' could not be found"
 
   Scenario: Accessing a not existing calendar of another user
     Given user "user0" exists