Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 65 additions & 10 deletions apps/contactsinteraction/lib/Db/CardSearchDao.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,77 @@

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;

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();

Check failure on line 65 in apps/contactsinteraction/lib/Db/CardSearchDao.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedMagicPropertyFetch

apps/contactsinteraction/lib/Db/CardSearchDao.php:65:18: UndefinedMagicPropertyFetch: Magic instance property Sabre\VObject\Component\VCard::$UID is not defined (see https://psalm.dev/218)
$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();
Expand All @@ -51,7 +105,10 @@
}
$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))
Expand All @@ -61,18 +118,16 @@
$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);
}
Expand Down
131 changes: 131 additions & 0 deletions apps/contactsinteraction/tests/Db/CardSearchDaoTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\ContactsInteraction\Tests\Db;

use OCA\ContactsInteraction\Db\CardSearchDao;
use OCA\DAV\CardDAV\CardDavBackend;
use OCA\DAV\CardDAV\SystemAddressBookVisibility;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Server;
use PHPUnit\Framework\MockObject\MockObject;
use Sabre\VObject\Component\VCard;
use Sabre\VObject\UUIDUtil;
use Test\TestCase;

#[\PHPUnit\Framework\Attributes\Group(name: 'DB')]
class CardSearchDaoTest extends TestCase {
private CardDavBackend $backend;
private IUserManager&MockObject $userManager;
private SystemAddressBookVisibility&MockObject $visibility;
private CardSearchDao $dao;
private IUser&MockObject $actor;
private int $systemAddressBookId;
private bool $createdSystemAddressBook = false;
private ?int $userAddressBookId = null;
/** @var list<array{int, string}> */
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));
}
}
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
61 changes: 61 additions & 0 deletions apps/dav/lib/CardDAV/SystemAddressBookVisibility.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\CardDAV;

use OCA\DAV\AppInfo\Application;
use OCA\DAV\ConfigLexicon;
use OCP\IAppConfig;
use OCP\IGroupManager;
use OCP\IUser;
use function array_intersect;

/**
* Applies the access rules of {@see SystemAddressbook} for a given viewer
* outside of a DAV request.
*/
class SystemAddressBookVisibility {
public function __construct(
private IAppConfig $appConfig,
private IGroupManager $groupManager,
) {
}

/**
* Whether $viewer can see the system address book card of $target
* This depends on things such as the system address book being exposed,
* the viewer and target not being guests, and the share enumeration settings.
*/
public function isUserCardVisible(IUser $viewer, IUser $target): bool {
if (!$this->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;
}
}
Loading
Loading