diff options
author | Daniel Kesselberg <mail@danielkesselberg.de> | 2025-04-15 18:03:04 +0200 |
---|---|---|
committer | Daniel Kesselberg <mail@danielkesselberg.de> | 2025-04-15 18:11:52 +0200 |
commit | b9d6702912951166871411e2b8a2c92d3bd3b26f (patch) | |
tree | 13252182409a4e3589347c91aba0eb02c487656a | |
parent | cd6e5ababb97cd69e742076fcba315d29c422667 (diff) | |
download | nextcloud-server-feat/noid/list-addressbook-shares.tar.gz nextcloud-server-feat/noid/list-addressbook-shares.zip |
feat: command to list addressbook sharesfeat/noid/list-addressbook-shares
Signed-off-by: Daniel Kesselberg <mail@danielkesselberg.de>
-rw-r--r-- | apps/dav/appinfo/info.xml | 1 | ||||
-rw-r--r-- | apps/dav/composer/autoload.php | 5 | ||||
-rw-r--r-- | apps/dav/composer/composer/autoload_classmap.php | 1 | ||||
-rw-r--r-- | apps/dav/composer/composer/autoload_static.php | 1 | ||||
-rw-r--r-- | apps/dav/lib/CardDAV/CardDavBackend.php | 15 | ||||
-rw-r--r-- | apps/dav/lib/Command/ListAddressbookShares.php | 131 | ||||
-rw-r--r-- | apps/dav/tests/unit/Command/ListAddressbookSharesTest.php | 172 |
7 files changed, 321 insertions, 5 deletions
diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml index a99bea224b6..7ff537ae471 100644 --- a/apps/dav/appinfo/info.xml +++ b/apps/dav/appinfo/info.xml @@ -61,6 +61,7 @@ <command>OCA\DAV\Command\DeleteCalendar</command> <command>OCA\DAV\Command\DeleteSubscription</command> <command>OCA\DAV\Command\FixCalendarSyncCommand</command> + <command>OCA\DAV\Command\ListAddressbookShares</command> <command>OCA\DAV\Command\ListAddressbooks</command> <command>OCA\DAV\Command\ListCalendars</command> <command>OCA\DAV\Command\ListSubscriptions</command> diff --git a/apps/dav/composer/autoload.php b/apps/dav/composer/autoload.php index 0103857e976..471b9a75ce1 100644 --- a/apps/dav/composer/autoload.php +++ b/apps/dav/composer/autoload.php @@ -14,10 +14,7 @@ if (PHP_VERSION_ID < 50600) { echo $err; } } - trigger_error( - $err, - E_USER_ERROR - ); + throw new RuntimeException($err); } require_once __DIR__ . '/composer/autoload_real.php'; diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 71d590f85a9..5cc6c23c364 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -159,6 +159,7 @@ return array( 'OCA\\DAV\\Command\\DeleteCalendar' => $baseDir . '/../lib/Command/DeleteCalendar.php', 'OCA\\DAV\\Command\\DeleteSubscription' => $baseDir . '/../lib/Command/DeleteSubscription.php', 'OCA\\DAV\\Command\\FixCalendarSyncCommand' => $baseDir . '/../lib/Command/FixCalendarSyncCommand.php', + 'OCA\\DAV\\Command\\ListAddressbookShares' => $baseDir . '/../lib/Command/ListAddressbookShares.php', 'OCA\\DAV\\Command\\ListAddressbooks' => $baseDir . '/../lib/Command/ListAddressbooks.php', 'OCA\\DAV\\Command\\ListCalendars' => $baseDir . '/../lib/Command/ListCalendars.php', 'OCA\\DAV\\Command\\ListSubscriptions' => $baseDir . '/../lib/Command/ListSubscriptions.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 9e652acc01e..448ecbf67c0 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -174,6 +174,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Command\\DeleteCalendar' => __DIR__ . '/..' . '/../lib/Command/DeleteCalendar.php', 'OCA\\DAV\\Command\\DeleteSubscription' => __DIR__ . '/..' . '/../lib/Command/DeleteSubscription.php', 'OCA\\DAV\\Command\\FixCalendarSyncCommand' => __DIR__ . '/..' . '/../lib/Command/FixCalendarSyncCommand.php', + 'OCA\\DAV\\Command\\ListAddressbookShares' => __DIR__ . '/..' . '/../lib/Command/ListAddressbookShares.php', 'OCA\\DAV\\Command\\ListAddressbooks' => __DIR__ . '/..' . '/../lib/Command/ListAddressbooks.php', 'OCA\\DAV\\Command\\ListCalendars' => __DIR__ . '/..' . '/../lib/Command/ListCalendars.php', 'OCA\\DAV\\Command\\ListSubscriptions' => __DIR__ . '/..' . '/../lib/Command/ListSubscriptions.php', diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php index b15ed607076..4df4b344046 100644 --- a/apps/dav/lib/CardDAV/CardDavBackend.php +++ b/apps/dav/lib/CardDAV/CardDavBackend.php @@ -33,6 +33,18 @@ use Sabre\DAV\Exception\BadRequest; use Sabre\VObject\Component\VCard; use Sabre\VObject\Reader; +/** + * @psalm-type AddressbookInfo = array{ + * id: int, + * uri: string, + * principaluri: string, + * '{http://calendarserver.org/ns/}getctag': string, + * '{http://sabredav.org/ns}sync-token': int, + * '{urn:ietf:params:xml:ns:carddav}addressbook-description': string, + * '{DAV:}displayname': string, + * '{http://nextcloud.com/ns}owner-displayname': string, + * } + */ class CardDavBackend implements BackendInterface, SyncSupport { use TTransactional; public const PERSONAL_ADDRESSBOOK_URI = 'contacts'; @@ -220,7 +232,8 @@ class CardDavBackend implements BackendInterface, SyncSupport { } /** - * @param int $addressBookId + * @psalm-return AddressbookInfo|null + * @return array|null */ public function getAddressBookById(int $addressBookId): ?array { $query = $this->db->getQueryBuilder(); diff --git a/apps/dav/lib/Command/ListAddressbookShares.php b/apps/dav/lib/Command/ListAddressbookShares.php new file mode 100644 index 00000000000..93b562f694e --- /dev/null +++ b/apps/dav/lib/Command/ListAddressbookShares.php @@ -0,0 +1,131 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Command; + +use OCA\DAV\CalDAV\Sharing\Backend; +use OCA\DAV\CardDAV\CardDavBackend; +use OCA\DAV\Connector\Sabre\Principal; +use OCA\DAV\DAV\Sharing\SharingMapper; +use OCP\IUserManager; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name: 'dav:list-addressbook-shares', + description: 'List all addressbook shares for a user', + hidden: false, +)] +class ListAddressbookShares extends Command { + public function __construct( + private IUserManager $userManager, + private Principal $principal, + private CardDavBackend $carddav, + private SharingMapper $mapper, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this->addArgument( + 'uid', + InputArgument::REQUIRED, + 'User whose addressbook shares will be listed' + ); + $this->addOption( + 'addressbook-id', + '', + InputOption::VALUE_REQUIRED, + 'List only shares for the given addressbook id id', + null, + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = (string)$input->getArgument('uid'); + if (!$this->userManager->userExists($user)) { + throw new \InvalidArgumentException("User $user is unknown"); + } + + $principal = $this->principal->getPrincipalByPath('principals/users/' . $user); + if ($principal === null) { + throw new \InvalidArgumentException("Unable to fetch principal for user $user"); + } + + $memberships = array_merge( + [$principal['uri']], + $this->principal->getGroupMembership($principal['uri']), + $this->principal->getCircleMembership($principal['uri']), + ); + + $shares = $this->mapper->getSharesByPrincipals($memberships, 'addressbook'); + + $addressbookId = $input->getOption('addressbook-id'); + if ($addressbookId !== null) { + $shares = array_filter($shares, fn ($share) => $share['resourceid'] === (int)$addressbookId); + } + + $rows = array_map(fn ($share) => $this->formatAddressbookShare($share), $shares); + + if (count($rows) > 0) { + $table = new Table($output); + $table + ->setHeaders(['Share Id', 'Addressbook Id', 'Addressbook URI', 'Addressbook Name', 'Addressbook Owner', 'Access By', 'Permissions']) + ->setRows($rows) + ->render(); + } else { + $output->writeln("User $user has no addressbook shares"); + } + + return self::SUCCESS; + } + + private function formatAddressbookShare(array $share): array { + $addressbookInfo = $this->carddav->getAddressBookById($share['resourceid']); + + $addressbookUri = 'Resource not found'; + $addressbookName = ''; + $addressbookOwner = ''; + + if ($addressbookInfo !== null) { + $addressbookUri = $addressbookInfo['uri']; + $addressbookName = $addressbookInfo['{DAV:}displayname']; + $addressbookOwner = $addressbookInfo['{http://nextcloud.com/ns}owner-displayname'] . ' (' . $addressbookInfo['principaluri'] . ')'; + } + + $accessBy = match (true) { + str_starts_with($share['principaluri'], 'principals/users/') => 'Individual', + str_starts_with($share['principaluri'], 'principals/groups/') => 'Group (' . $share['principaluri'] . ')', + str_starts_with($share['principaluri'], 'principals/circles/') => 'Team (' . $share['principaluri'] . ')', + default => $share['principaluri'], + }; + + $permissions = match ($share['access']) { + Backend::ACCESS_READ => 'Read', + Backend::ACCESS_READ_WRITE => 'Read/Write', + Backend::ACCESS_UNSHARED => 'Unshare', + default => $share['access'], + }; + + return [ + $share['id'], + $share['resourceid'], + $addressbookUri, + $addressbookName, + $addressbookOwner, + $accessBy, + $permissions, + ]; + } +} diff --git a/apps/dav/tests/unit/Command/ListAddressbookSharesTest.php b/apps/dav/tests/unit/Command/ListAddressbookSharesTest.php new file mode 100644 index 00000000000..dafcf5c1b30 --- /dev/null +++ b/apps/dav/tests/unit/Command/ListAddressbookSharesTest.php @@ -0,0 +1,172 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\Tests\Command; + +use OCA\DAV\CardDAV\CardDavBackend; +use OCA\DAV\Command\ListAddressbookShares; +use OCA\DAV\Connector\Sabre\Principal; +use OCA\DAV\DAV\Sharing\SharingMapper; +use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; +use Symfony\Component\Console\Tester\CommandTester; +use Test\TestCase; + +class ListAddressbookSharesTest extends TestCase { + + private IUserManager&MockObject $userManager; + private Principal&MockObject $principal; + private CardDavBackend&MockObject $carddav; + private SharingMapper $sharingMapper; + private ListAddressbookShares $command; + + protected function setUp(): void { + parent::setUp(); + + $this->userManager = $this->createMock(IUserManager::class); + $this->principal = $this->createMock(Principal::class); + $this->carddav = $this->createMock(CardDavBackend::class); + $this->sharingMapper = $this->createMock(SharingMapper::class); + + $this->command = new ListAddressbookShares( + $this->userManager, + $this->principal, + $this->carddav, + $this->sharingMapper, + ); + } + + public function testUserUnknown(): void { + $user = 'bob'; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("User $user is unknown"); + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with($user) + ->willReturn(false); + + $commandTester = new CommandTester($this->command); + $commandTester->execute([ + 'uid' => $user, + ]); + } + + public function testPrincipalNotFound(): void { + $user = 'bob'; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("Unable to fetch principal for user $user"); + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with($user) + ->willReturn(true); + + $this->principal->expects($this->once()) + ->method('getPrincipalByPath') + ->with('principals/users/' . $user) + ->willReturn(null); + + $commandTester = new CommandTester($this->command); + $commandTester->execute([ + 'uid' => $user, + ]); + } + + public function testNoAddressbookShares(): void { + $user = 'bob'; + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with($user) + ->willReturn(true); + + $this->principal->expects($this->once()) + ->method('getPrincipalByPath') + ->with('principals/users/' . $user) + ->willReturn([ + 'uri' => 'principals/users/' . $user, + ]); + + $this->principal->expects($this->once()) + ->method('getGroupMembership') + ->willReturn([]); + $this->principal->expects($this->once()) + ->method('getCircleMembership') + ->willReturn([]); + + $this->sharingMapper->expects($this->once()) + ->method('getSharesByPrincipals') + ->willReturn([]); + + $commandTester = new CommandTester($this->command); + $commandTester->execute([ + 'uid' => $user, + ]); + + $this->assertStringContainsString( + "User $user has no addressbook shares", + $commandTester->getDisplay() + ); + } + + public function testFilterByAddressbookId(): void { + $user = 'bob'; + + $this->userManager->expects($this->once()) + ->method('userExists') + ->with($user) + ->willReturn(true); + + $this->principal->expects($this->once()) + ->method('getPrincipalByPath') + ->with('principals/users/' . $user) + ->willReturn([ + 'uri' => 'principals/users/' . $user, + ]); + + $this->principal->expects($this->once()) + ->method('getGroupMembership') + ->willReturn([]); + $this->principal->expects($this->once()) + ->method('getCircleMembership') + ->willReturn([]); + + $this->sharingMapper->expects($this->once()) + ->method('getSharesByPrincipals') + ->willReturn([ + [ + 'id' => 1000, + 'principaluri' => 'principals/users/bob', + 'type' => 'addressbook', + 'access' => 2, + 'resourceid' => 10 + ], + [ + 'id' => 1001, + 'principaluri' => 'principals/users/bob', + 'type' => 'addressbook', + 'access' => 3, + 'resourceid' => 11 + ], + ]); + + $commandTester = new CommandTester($this->command); + $commandTester->execute([ + 'uid' => $user, + '--addressbook-id' => 10, + ]); + + $this->assertStringNotContainsString( + '1001', + $commandTester->getDisplay() + ); + } +} |