diff --git a/apps/contactsinteraction/lib/Db/CardSearchDao.php b/apps/contactsinteraction/lib/Db/CardSearchDao.php index 11ec273d64a40..5291ab9fdde70 100644 --- a/apps/contactsinteraction/lib/Db/CardSearchDao.php +++ b/apps/contactsinteraction/lib/Db/CardSearchDao.php @@ -9,9 +9,13 @@ namespace OCA\ContactsInteraction\Db; +use OCA\DAV\CardDAV\SystemAddressBookVisibility; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\IUser; +use OCP\IUserManager; +use Sabre\VObject\Component\VCard; +use Sabre\VObject\Reader; use function is_resource; use function stream_get_contents; @@ -19,13 +23,63 @@ class CardSearchDao { public function __construct( private IDBConnection $db, + private IUserManager $userManager, + private SystemAddressBookVisibility $systemAddressBookVisibility, ) { } + /** + * Find a matching card in the address books of $user, or in the system + * address book if that card is visible to $user + */ public function findExisting(IUser $user, ?string $uid, ?string $email, ?string $cloudId): ?string { + $cardQuery = $this->getMatchingCardsQuery('principals/users/' . $user->getUID(), null, $uid, $email, $cloudId); + $cardQuery->setMaxResults(1); + $result = $cardQuery->executeQuery(); + /** @var string|resource|false $card */ + $card = $result->fetchOne(); + $result->closeCursor(); + + if ($card !== false) { + return $this->readCardData($card); + } + + return $this->findVisibleInSystemAddressBook($user, $uid, $email, $cloudId); + } + + private function findVisibleInSystemAddressBook(IUser $user, + ?string $uid, + ?string $email, + ?string $cloudId): ?string { + $result = $this->getMatchingCardsQuery('principals/system/system', 'system', $uid, $email, $cloudId) + ->executeQuery(); + try { + /** @var string|resource|false $card */ + while (($card = $result->fetchOne()) !== false) { + $cardData = $this->readCardData($card); + /** @var VCard $vCard */ + $vCard = Reader::read($cardData); + $targetUid = $vCard->UID?->getValue(); + $target = $targetUid !== null ? $this->userManager->get($targetUid) : null; + if ($target !== null && $this->systemAddressBookVisibility->isUserCardVisible($user, $target)) { + return $cardData; + } + } + } finally { + $result->closeCursor(); + } + + return null; + } + + private function getMatchingCardsQuery(string $principalUri, + ?string $addressBookUri, + ?string $uid, + ?string $email, + ?string $cloudId): IQueryBuilder { $addressbooksQuery = $this->db->getQueryBuilder(); $cardQuery = $this->db->getQueryBuilder(); $propQuery = $this->db->getQueryBuilder(); @@ -51,7 +105,10 @@ public function findExisting(IUser $user, } $addressbooksQuery->selectDistinct('id') ->from('addressbooks') - ->where($addressbooksQuery->expr()->eq('principaluri', $cardQuery->createNamedParameter('principals/users/' . $user->getUID()))); + ->where($addressbooksQuery->expr()->eq('principaluri', $cardQuery->createNamedParameter($principalUri))); + if ($addressBookUri !== null) { + $addressbooksQuery->andWhere($addressbooksQuery->expr()->eq('uri', $cardQuery->createNamedParameter($addressBookUri))); + } $propQuery->selectDistinct('cardid') ->from('cards_properties') ->where($propQuery->expr()->in('addressbookid', $propQuery->createFunction($addressbooksQuery->getSQL()), IQueryBuilder::PARAM_INT_ARRAY)) @@ -61,18 +118,16 @@ public function findExisting(IUser $user, $propQuery->andWhere($propQuery->expr()->orX(...$additionalWheres)); } - $cardQuery->select('carddata') + return $cardQuery->select('carddata') ->from('cards') ->where($cardQuery->expr()->in('id', $cardQuery->createFunction($propQuery->getSQL()), IQueryBuilder::PARAM_INT_ARRAY)) - ->andWhere($cardQuery->expr()->in('addressbookid', $cardQuery->createFunction($addressbooksQuery->getSQL()), IQueryBuilder::PARAM_INT_ARRAY)) - ->setMaxResults(1); - $result = $cardQuery->executeQuery(); - /** @var string|resource|false $card */ - $card = $result->fetchOne(); + ->andWhere($cardQuery->expr()->in('addressbookid', $cardQuery->createFunction($addressbooksQuery->getSQL()), IQueryBuilder::PARAM_INT_ARRAY)); + } - if ($card === false) { - return null; - } + /** + * @param string|resource $card + */ + private function readCardData($card): string { if (is_resource($card)) { return stream_get_contents($card); } diff --git a/apps/contactsinteraction/tests/Db/CardSearchDaoTest.php b/apps/contactsinteraction/tests/Db/CardSearchDaoTest.php new file mode 100644 index 0000000000000..e5a2e9b2b46ac --- /dev/null +++ b/apps/contactsinteraction/tests/Db/CardSearchDaoTest.php @@ -0,0 +1,131 @@ + */ + private array $createdCards = []; + + protected function setUp(): void { + parent::setUp(); + + $this->backend = Server::get(CardDavBackend::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->visibility = $this->createMock(SystemAddressBookVisibility::class); + $this->dao = new CardSearchDao(Server::get(IDBConnection::class), $this->userManager, $this->visibility); + + $this->actor = $this->createMock(IUser::class); + $this->actor->method('getUID')->willReturn('card-search-dao-actor'); + + $systemAddressBook = $this->backend->getAddressBooksByUri('principals/system/system', 'system'); + if ($systemAddressBook === null) { + $this->systemAddressBookId = $this->backend->createAddressBook('principals/system/system', 'system', []); + $this->createdSystemAddressBook = true; + } else { + $this->systemAddressBookId = (int)$systemAddressBook['id']; + } + } + + protected function tearDown(): void { + foreach ($this->createdCards as [$addressBookId, $uri]) { + $this->backend->deleteCard($addressBookId, $uri); + } + if ($this->userAddressBookId !== null) { + $this->backend->deleteAddressBook($this->userAddressBookId); + } + if ($this->createdSystemAddressBook) { + $this->backend->deleteAddressBook($this->systemAddressBookId); + } + + parent::tearDown(); + } + + private function createCard(int $addressBookId, string $uid, string $email): string { + $cardData = (new VCard([ + 'UID' => $uid, + 'FN' => $uid, + 'EMAIL' => $email, + ]))->serialize(); + $uri = UUIDUtil::getUUID() . '.vcf'; + $this->backend->createCard($addressBookId, $uri, $cardData); + $this->createdCards[] = [$addressBookId, $uri]; + return $cardData; + } + + private function mockTargetUser(string $uid): IUser&MockObject { + $target = $this->createMock(IUser::class); + $target->method('getUID')->willReturn($uid); + $this->userManager->method('get')->with($uid)->willReturn($target); + return $target; + } + + public function testFindExistingInOwnAddressBook(): void { + $this->userAddressBookId = $this->backend->createAddressBook('principals/users/card-search-dao-actor', 'contacts', []); + $uid = UUIDUtil::getUUID(); + $cardData = $this->createCard($this->userAddressBookId, $uid, $uid . '@example.com'); + $this->visibility->expects(self::never())->method('isUserCardVisible'); + + $this->assertSame($cardData, $this->dao->findExisting($this->actor, null, $uid . '@example.com', null)); + } + + public function testFindExistingInSystemAddressBookWhenVisible(): void { + $uid = UUIDUtil::getUUID(); + $cardData = $this->createCard($this->systemAddressBookId, $uid, $uid . '@example.com'); + $target = $this->mockTargetUser($uid); + $this->visibility->expects(self::once()) + ->method('isUserCardVisible') + ->with($this->actor, $target) + ->willReturn(true); + + $this->assertSame($cardData, $this->dao->findExisting($this->actor, $uid, null, null)); + } + + public function testFindExistingIgnoresSystemAddressBookCardWhenNotVisible(): void { + $uid = UUIDUtil::getUUID(); + $this->createCard($this->systemAddressBookId, $uid, $uid . '@example.com'); + $target = $this->mockTargetUser($uid); + $this->visibility->expects(self::once()) + ->method('isUserCardVisible') + ->with($this->actor, $target) + ->willReturn(false); + + $this->assertNull($this->dao->findExisting($this->actor, null, $uid . '@example.com', null)); + } + + public function testFindExistingIgnoresSystemAddressBookCardOfUnknownUser(): void { + $uid = UUIDUtil::getUUID(); + $this->createCard($this->systemAddressBookId, $uid, $uid . '@example.com'); + $this->userManager->method('get')->with($uid)->willReturn(null); + $this->visibility->expects(self::never())->method('isUserCardVisible'); + + $this->assertNull($this->dao->findExisting($this->actor, $uid, null, null)); + } +} diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index a15bb5d49e352..4d6b28cbbe06d 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -192,6 +192,7 @@ '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\\SystemAddressBookVisibility' => $baseDir . '/../lib/CardDAV/SystemAddressBookVisibility.php', 'OCA\\DAV\\CardDAV\\SystemAddressbook' => $baseDir . '/../lib/CardDAV/SystemAddressbook.php', 'OCA\\DAV\\CardDAV\\UserAddressBooks' => $baseDir . '/../lib/CardDAV/UserAddressBooks.php', 'OCA\\DAV\\CardDAV\\Validation\\CardDavValidatePlugin' => $baseDir . '/../lib/CardDAV/Validation/CardDavValidatePlugin.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 16788a2db444a..34d70656eef51 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -207,6 +207,7 @@ class ComposerStaticInitDAV '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\\SystemAddressBookVisibility' => __DIR__ . '/..' . '/../lib/CardDAV/SystemAddressBookVisibility.php', 'OCA\\DAV\\CardDAV\\SystemAddressbook' => __DIR__ . '/..' . '/../lib/CardDAV/SystemAddressbook.php', 'OCA\\DAV\\CardDAV\\UserAddressBooks' => __DIR__ . '/..' . '/../lib/CardDAV/UserAddressBooks.php', 'OCA\\DAV\\CardDAV\\Validation\\CardDavValidatePlugin' => __DIR__ . '/..' . '/../lib/CardDAV/Validation/CardDavValidatePlugin.php', diff --git a/apps/dav/lib/CardDAV/SystemAddressBookVisibility.php b/apps/dav/lib/CardDAV/SystemAddressBookVisibility.php new file mode 100644 index 0000000000000..363cf80fc9f64 --- /dev/null +++ b/apps/dav/lib/CardDAV/SystemAddressBookVisibility.php @@ -0,0 +1,61 @@ +appConfig->getValueBool(Application::APP_ID, ConfigLexicon::SYSTEM_ADDRESSBOOK_EXPOSED)) { + return false; + } + if ($viewer->getUID() === $target->getUID()) { + return true; + } + if ($viewer->getBackendClassName() === 'Guests' || $target->getBackendClassName() === 'Guests') { + return false; + } + + $shareEnumeration = $this->appConfig->getValueString('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes'; + $shareEnumerationGroup = $this->appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes'; + $shareEnumerationPhone = $this->appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes'; + if (!$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) { + return false; + } + if ($shareEnumerationGroup) { + return array_intersect( + $this->groupManager->getUserGroupIds($viewer), + $this->groupManager->getUserGroupIds($target), + ) !== []; + } + + return true; + } +} diff --git a/apps/dav/tests/unit/CardDAV/SystemAddressBookVisibilityTest.php b/apps/dav/tests/unit/CardDAV/SystemAddressBookVisibilityTest.php new file mode 100644 index 0000000000000..d5c3b4930c0ed --- /dev/null +++ b/apps/dav/tests/unit/CardDAV/SystemAddressBookVisibilityTest.php @@ -0,0 +1,91 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->groupManager->method('getUserGroupIds') + ->willReturnCallback(fn (IUser $user): array => match ($user->getUID()) { + 'viewer' => ['g1', 'g2'], + 'groupmate' => ['g2'], + default => ['g3'], + }); + + $this->visibility = new SystemAddressBookVisibility($this->appConfig, $this->groupManager); + } + + private function createUser(string $uid, string $backend = 'Database'): IUser&MockObject { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $user->method('getBackendClassName')->willReturn($backend); + return $user; + } + + public static function visibilityData(): array { + return [ + 'not exposed' => [false, 'yes', 'no', 'no', 'viewer', 'Database', 'other', 'Database', false], + 'not exposed, own card' => [false, 'yes', 'no', 'no', 'viewer', 'Database', 'viewer', 'Database', false], + 'full enumeration' => [true, 'yes', 'no', 'no', 'viewer', 'Database', 'other', 'Database', true], + 'enumeration disabled' => [true, 'no', 'no', 'no', 'viewer', 'Database', 'other', 'Database', false], + 'enumeration disabled, own card' => [true, 'no', 'no', 'no', 'viewer', 'Database', 'viewer', 'Database', true], + 'phone only' => [true, 'yes', 'no', 'yes', 'viewer', 'Database', 'other', 'Database', false], + 'group, shared group' => [true, 'yes', 'yes', 'no', 'viewer', 'Database', 'groupmate', 'Database', true], + 'group, no shared group' => [true, 'yes', 'yes', 'no', 'viewer', 'Database', 'other', 'Database', false], + 'group and phone, shared group' => [true, 'yes', 'yes', 'yes', 'viewer', 'Database', 'groupmate', 'Database', true], + 'guest viewer' => [true, 'yes', 'no', 'no', 'viewer', 'Guests', 'other', 'Database', false], + 'guest viewer, own card' => [true, 'yes', 'no', 'no', 'viewer', 'Guests', 'viewer', 'Guests', true], + 'guest target' => [true, 'yes', 'no', 'no', 'viewer', 'Database', 'groupmate', 'Guests', false], + ]; + } + + #[DataProvider('visibilityData')] + public function testIsUserCardVisible( + bool $exposed, + string $enumeration, + string $restrictToGroup, + string $restrictToPhone, + string $viewerUid, + string $viewerBackend, + string $targetUid, + string $targetBackend, + bool $expected, + ): void { + $this->appConfig->method('getValueBool') + ->with('dav', 'system_addressbook_exposed') + ->willReturn($exposed); + $this->appConfig->method('getValueString') + ->willReturnMap([ + ['core', 'shareapi_allow_share_dialog_user_enumeration', 'yes', false, $enumeration], + ['core', 'shareapi_restrict_user_enumeration_to_group', 'no', false, $restrictToGroup], + ['core', 'shareapi_restrict_user_enumeration_to_phone', 'no', false, $restrictToPhone], + ]); + + $this->assertSame($expected, $this->visibility->isUserCardVisible( + $this->createUser($viewerUid, $viewerBackend), + $this->createUser($targetUid, $targetBackend), + )); + } +}