Repository: nextcloud/server
Pull request #51399: add command to list shares
Author: login: icewind1991
Labels: name: 3. to review
description: Waiting for reviews
name: pending documentation
description: This pull request needs an associated documentation update
Created: 2025-03-11T15:36:57Z | Merged: 2025-03-31T12:29:39Z

Adds command `occ share:list` to show all shares on the system with a number of filtering options.

Obvious room for improvements in the performance of querying the shares and increasing the information shown and the number of filters available. But it should still be useful in it's current state.


Diff:
diff --git a/apps/federatedfilesharing/lib/FederatedShareProvider.php b/apps/federatedfilesharing/lib/FederatedShareProvider.php
index 379cd7931164e..d993b35845cc1 100644
--- a/apps/federatedfilesharing/lib/FederatedShareProvider.php
+++ b/apps/federatedfilesharing/lib/FederatedShareProvider.php
@@ -812,6 +812,7 @@ private function createShareObject($data) {
 			->setPermissions((int)$data['permissions'])
 			->setTarget($data['file_target'])
 			->setMailSend((bool)$data['mail_send'])
+			->setStatus((int)$data['accepted'])
 			->setToken($data['token']);
 
 		$shareTime = new \DateTime();
diff --git a/apps/files_sharing/appinfo/info.xml b/apps/files_sharing/appinfo/info.xml
index c92559635ea08..03078b5404064 100644
--- a/apps/files_sharing/appinfo/info.xml
+++ b/apps/files_sharing/appinfo/info.xml
@@ -50,6 +50,7 @@ Turning the feature off removes shared files and folders on the server for all s
 		<command>OCA\Files_Sharing\Command\ExiprationNotification</command>
 		<command>OCA\Files_Sharing\Command\DeleteOrphanShares</command>
 		<command>OCA\Files_Sharing\Command\FixShareOwners</command>
+		<command>OCA\Files_Sharing\Command\ListShares</command>
 	</commands>
 
 	<settings>
diff --git a/apps/files_sharing/composer/composer/autoload_classmap.php b/apps/files_sharing/composer/composer/autoload_classmap.php
index 400df9c977187..8682fd5f2385f 100644
--- a/apps/files_sharing/composer/composer/autoload_classmap.php
+++ b/apps/files_sharing/composer/composer/autoload_classmap.php
@@ -28,6 +28,7 @@
     'OCA\\Files_Sharing\\Command\\DeleteOrphanShares' => $baseDir . '/../lib/Command/DeleteOrphanShares.php',
     'OCA\\Files_Sharing\\Command\\ExiprationNotification' => $baseDir . '/../lib/Command/ExiprationNotification.php',
     'OCA\\Files_Sharing\\Command\\FixShareOwners' => $baseDir . '/../lib/Command/FixShareOwners.php',
+    'OCA\\Files_Sharing\\Command\\ListShares' => $baseDir . '/../lib/Command/ListShares.php',
     'OCA\\Files_Sharing\\Controller\\AcceptController' => $baseDir . '/../lib/Controller/AcceptController.php',
     'OCA\\Files_Sharing\\Controller\\DeletedShareAPIController' => $baseDir . '/../lib/Controller/DeletedShareAPIController.php',
     'OCA\\Files_Sharing\\Controller\\ExternalSharesController' => $baseDir . '/../lib/Controller/ExternalSharesController.php',
diff --git a/apps/files_sharing/composer/composer/autoload_static.php b/apps/files_sharing/composer/composer/autoload_static.php
index 02bb6d08bb580..3bf5bcdffda04 100644
--- a/apps/files_sharing/composer/composer/autoload_static.php
+++ b/apps/files_sharing/composer/composer/autoload_static.php
@@ -43,6 +43,7 @@ class ComposerStaticInitFiles_Sharing
         'OCA\\Files_Sharing\\Command\\DeleteOrphanShares' => __DIR__ . '/..' . '/../lib/Command/DeleteOrphanShares.php',
         'OCA\\Files_Sharing\\Command\\ExiprationNotification' => __DIR__ . '/..' . '/../lib/Command/ExiprationNotification.php',
         'OCA\\Files_Sharing\\Command\\FixShareOwners' => __DIR__ . '/..' . '/../lib/Command/FixShareOwners.php',
+        'OCA\\Files_Sharing\\Command\\ListShares' => __DIR__ . '/..' . '/../lib/Command/ListShares.php',
         'OCA\\Files_Sharing\\Controller\\AcceptController' => __DIR__ . '/..' . '/../lib/Controller/AcceptController.php',
         'OCA\\Files_Sharing\\Controller\\DeletedShareAPIController' => __DIR__ . '/..' . '/../lib/Controller/DeletedShareAPIController.php',
         'OCA\\Files_Sharing\\Controller\\ExternalSharesController' => __DIR__ . '/..' . '/../lib/Controller/ExternalSharesController.php',
diff --git a/apps/files_sharing/lib/Command/ListShares.php b/apps/files_sharing/lib/Command/ListShares.php
new file mode 100644
index 0000000000000..2d5cdbf78129b
--- /dev/null
+++ b/apps/files_sharing/lib/Command/ListShares.php
@@ -0,0 +1,161 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OCA\Files_Sharing\Command;
+
+use OC\Core\Command\Base;
+use OCP\Files\Folder;
+use OCP\Files\IRootFolder;
+use OCP\Files\Node;
+use OCP\Files\NotFoundException;
+use OCP\Share\IManager;
+use OCP\Share\IShare;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class ListShares extends Base {
+	/** @var array<string, Node> */
+	private array $fileCache = [];
+
+	private const SHARE_TYPE_NAMES = [
+		IShare::TYPE_USER => 'user',
+		IShare::TYPE_GROUP => 'group',
+		IShare::TYPE_LINK => 'link',
+		IShare::TYPE_EMAIL => 'email',
+		IShare::TYPE_REMOTE => 'remote',
+		IShare::TYPE_REMOTE_GROUP => 'group',
+		IShare::TYPE_ROOM => 'room',
+		IShare::TYPE_DECK => 'deck',
+	];
+
+	public function __construct(
+		private readonly IManager $shareManager,
+		private readonly IRootFolder $rootFolder,
+	) {
+		parent::__construct();
+	}
+
+	protected function configure() {
+		parent::configure();
+		$this
+			->setName('share:list')
+			->setDescription('List available shares')
+			->addOption('owner', null, InputOption::VALUE_REQUIRED, 'only show shares owned by a specific user')
+			->addOption('recipient', null, InputOption::VALUE_REQUIRED, 'only show shares with a specific recipient')
+			->addOption('by', null, InputOption::VALUE_REQUIRED, 'only show shares with by as specific user')
+			->addOption('file', null, InputOption::VALUE_REQUIRED, 'only show shares of a specific file')
+			->addOption('parent', null, InputOption::VALUE_REQUIRED, 'only show shares of files inside a specific folder')
+			->addOption('recursive', null, InputOption::VALUE_NONE, 'also show shares nested deep inside the specified parent folder')
+			->addOption('type', null, InputOption::VALUE_REQUIRED, 'only show shares of a specific type')
+			->addOption('status', null, InputOption::VALUE_REQUIRED, 'only show shares with a specific status');
+	}
+
+	public function execute(InputInterface $input, OutputInterface $output): int {
+		if ($input->getOption('recursive') && !$input->getOption('parent')) {
+			$output->writeln("<error>recursive option can't be used without parent option</error>");
+			return 1;
+		}
+
+		// todo: do some pre-filtering instead of first querying all shares
+		/** @var \Iterator<IShare> $allShares */
+		$allShares = $this->shareManager->getAllShares();
+		$shares = new \CallbackFilterIterator($allShares, function (IShare $share) use ($input) {
+			return $this->shouldShowShare($input, $share);
+		});
+		$shares = iterator_to_array($shares);
+		$data = array_map(function (IShare $share) {
+			return [
+				'id' => $share->getId(),
+				'file' => $share->getNodeId(),
+				'target-path' => $share->getTarget(),
+				'source-path' => $share->getNode()->getPath(),
+				'owner' => $share->getShareOwner(),
+				'recipient' => $share->getSharedWith(),
+				'by' => $share->getSharedBy(),
+				'type' => self::SHARE_TYPE_NAMES[$share->getShareType()] ?? 'unknown',
+			];
+		}, $shares);
+
+		$this->writeTableInOutputFormat($input, $output, $data);
+		return 0;
+	}
+
+	private function getFileId(string $file): int {
+		if (is_numeric($file)) {
+			return (int)$file;
+		}
+		return $this->getFile($file)->getId();
+	}
+
+	private function getFile(string $file): Node {
+		if (isset($this->fileCache[$file])) {
+			return $this->fileCache[$file];
+		}
+
+		if (is_numeric($file)) {
+			$node = $this->rootFolder->getFirstNodeById((int)$file);
+			if (!$node) {
+				throw new NotFoundException("File with id $file not found");
+			}
+		} else {
+			$node = $this->rootFolder->get($file);
+		}
+		$this->fileCache[$file] = $node;
+		return $node;
+	}
+
+	private function getShareType(string $type): int {
+		foreach (self::SHARE_TYPE_NAMES as $shareType => $shareTypeName) {
+			if ($shareTypeName === $type) {
+				return $shareType;
+			}
+		}
+		throw new \Exception("Unknown share type $type");
+	}
+
+	private function shouldShowShare(InputInterface $input, IShare $share): bool {
+		if ($input->getOption('owner') && $share->getShareOwner() !== $input->getOption('owner')) {
+			return false;
+		}
+		if ($input->getOption('recipient') && $share->getSharedWith() !== $input->getOption('recipient')) {
+			return false;
+		}
+		if ($input->getOption('by') && $share->getSharedBy() !== $input->getOption('by')) {
+			return false;
+		}
+		if ($input->getOption('file') && $share->getNodeId() !== $this->getFileId($input->getOption('file'))) {
+			return false;
+		}
+		if ($input->getOption('parent')) {
+			$parent = $this->getFile($input->getOption('parent'));
+			if (!$parent instanceof Folder) {
+				throw new \Exception("Parent {$parent->getPath()} is not a folder");
+			}
+			$recursive = $input->getOption('recursive');
+			if (!$recursive) {
+				$shareCacheEntry = $share->getNodeCacheEntry();
+				if (!$shareCacheEntry) {
+					$shareCacheEntry = $share->getNode();
+				}
+				if ($shareCacheEntry->getParentId() !== $parent->getId()) {
+					return false;
+				}
+			} else {
+				$shareNode = $share->getNode();
+				if ($parent->getRelativePath($shareNode->getPath()) === null) {
+					return false;
+				}
+			}
+		}
+		if ($input->getOption('type') && $share->getShareType() !== $this->getShareType($input->getOption('type'))) {
+			return false;
+		}
+		return true;
+	}
+}
diff --git a/lib/private/Files/Cache/CacheEntry.php b/lib/private/Files/Cache/CacheEntry.php
index e9417c8012a3f..ab5bae316f455 100644
--- a/lib/private/Files/Cache/CacheEntry.php
+++ b/lib/private/Files/Cache/CacheEntry.php
@@ -110,6 +110,10 @@ public function getUploadTime(): ?int {
 		return $this->data['upload_time'] ?? null;
 	}
 
+	public function getParentId(): int {
+		return $this->data['parent'];
+	}
+
 	public function getData() {
 		return $this->data;
 	}
diff --git a/lib/public/Files/Cache/ICacheEntry.php b/lib/public/Files/Cache/ICacheEntry.php
index 11d91e741056c..28e673071fd4c 100644
--- a/lib/public/Files/Cache/ICacheEntry.php
+++ b/lib/public/Files/Cache/ICacheEntry.php
@@ -161,4 +161,12 @@ public function getUploadTime(): ?int;
 	 * @since 25.0.0
 	 */
 	public function getUnencryptedSize(): int;
+
+	/**
+	 * Get the file id of the parent folder
+	 *
+	 * @return int
+	 * @since 32.0.0
+	 */
+	public function getParentId(): int;
 }


Reviews:
user: login: kesselb
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T16:52:10Z
user: login: come-nc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T09:41:53Z
user: login: icewind1991
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T14:51:35Z
user: login: icewind1991
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T14:59:59Z
user: login: come-nc
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T08:48:11Z

Review comments:
diff_hunk: @@ -110,6 +110,10 @@ public function getUploadTime(): ?int {
 		return $this->data['upload_time'] ?? null;
 	}
 
+	public function getParent(): int {
path: lib/private/Files/Cache/CacheEntry.php
user: login: come-nc
body: Where is that used? Should it be getParentId? In ListShares.php `$shareCacheEntry->getParentId()` is used.
created_at: 2025-03-20T09:40:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -815,6 +815,7 @@ private function createShareObject($data) {
 			->setPermissions((int)$data['permissions'])
 			->setTarget($data['file_target'])
 			->setMailSend((bool)$data['mail_send'])
+			->setStatus((int)$data['accepted'])
path: apps/federatedfilesharing/lib/FederatedShareProvider.php
user: login: come-nc
body: Is that still needed as you removed status from the command?
created_at: 2025-03-20T09:41:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -815,6 +815,7 @@ private function createShareObject($data) {
 			->setPermissions((int)$data['permissions'])
 			->setTarget($data['file_target'])
 			->setMailSend((bool)$data['mail_send'])
+			->setStatus((int)$data['accepted'])
path: apps/federatedfilesharing/lib/FederatedShareProvider.php
user: login: icewind1991
body: Other code might still run into the same issue
created_at: 2025-03-28T14:51:34Z
author_association: MEMBER
diff_hunk: @@ -110,6 +110,10 @@ public function getUploadTime(): ?int {
 		return $this->data['upload_time'] ?? null;
 	}
 
+	public function getParent(): int {
path: lib/private/Files/Cache/CacheEntry.php
user: login: icewind1991
body: It should be `getParentId` yes, fixed
created_at: 2025-03-28T14:59:59Z
author_association: MEMBER

PR comments:
user: login: kesselb
created_at: 2025-03-11T20:37:50Z
body: Nice, Thank you :+1: 

The command is failing on my remote share from bob to `alice@https://server30.internal` with 

```
occ share:list 
An unhandled exception has been thrown:
TypeError: OC\Share20\Share::getStatus(): Return value must be of type int, null returned in /var/www/html/lib/private/Share20/Share.php:349
Stack trace:
#0 /var/www/html/apps/files_sharing/lib/Command/ListShares.php(88): OC\Share20\Share->getStatus()
#1 [internal function]: OCA\Files_Sharing\Command\ListShares->OCA\Files_Sharing\Command\{closure}(Object(OC\Share20\Share))
#2 /var/www/html/apps/files_sharing/lib/Command/ListShares.php(78): array_map(Object(Closure), Array)
#3 /var/www/html/3rdparty/symfony/console/Command/Command.php(326): OCA\Files_Sharing\Command\ListShares->execute(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#4 /var/www/html/core/Command/Base.php(161): Symfony\Component\Console\Command\Command->run(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#5 /var/www/html/3rdparty/symfony/console/Application.php(1078): OC\Core\Command\Base->run(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#6 /var/www/html/3rdparty/symfony/console/Application.php(324): Symfony\Component\Console\Application->doRunCommand(Object(OCA\Files_Sharing\Command\ListShares), Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#7 /var/www/html/3rdparty/symfony/console/Application.php(175): Symfony\Component\Console\Application->doRun(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#8 /var/www/html/lib/private/Console/Application.php(187): Symfony\Component\Console\Application->run(Object(Symfony\Component\Console\Input\ArgvInput), Object(Symfony\Component\Console\Output\ConsoleOutput))
#9 /var/www/html/console.php(87): OC\Console\Application->run(Object(Symfony\Component\Console\Input\ArgvInput))
#10 /var/www/html/occ(33): require_once('/var/www/html/c...')
#11 {main}
```

author_association: CONTRIBUTOR
user: login: icewind1991
created_at: 2025-03-19T16:03:34Z
body: > Nice, Thank you 👍
> 
> The command is failing on my remote share from bob to `alice@https://server30.internal` with
> ```

Fixed


author_association: MEMBER
user: login: skjnldsv
created_at: 2025-04-02T16:02:57Z
body: Please write documentation on the occ page https://github.com/nextcloud/documentation/edit/master/admin_manual/occ_command.rst
author_association: MEMBER