aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Kesselberg <mail@danielkesselberg.de>2025-04-15 18:03:04 +0200
committerDaniel Kesselberg <mail@danielkesselberg.de>2025-04-15 18:11:52 +0200
commitb9d6702912951166871411e2b8a2c92d3bd3b26f (patch)
tree13252182409a4e3589347c91aba0eb02c487656a
parentcd6e5ababb97cd69e742076fcba315d29c422667 (diff)
downloadnextcloud-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.xml1
-rw-r--r--apps/dav/composer/autoload.php5
-rw-r--r--apps/dav/composer/composer/autoload_classmap.php1
-rw-r--r--apps/dav/composer/composer/autoload_static.php1
-rw-r--r--apps/dav/lib/CardDAV/CardDavBackend.php15
-rw-r--r--apps/dav/lib/Command/ListAddressbookShares.php131
-rw-r--r--apps/dav/tests/unit/Command/ListAddressbookSharesTest.php172
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()
+ );
+ }
+}