aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/Collaboration
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/Collaboration')
-rw-r--r--lib/private/Collaboration/AutoComplete/Manager.php73
-rw-r--r--lib/private/Collaboration/Collaborators/GroupPlugin.php130
-rw-r--r--lib/private/Collaboration/Collaborators/LookupPlugin.php97
-rw-r--r--lib/private/Collaboration/Collaborators/MailPlugin.php262
-rw-r--r--lib/private/Collaboration/Collaborators/RemoteGroupPlugin.php73
-rw-r--r--lib/private/Collaboration/Collaborators/RemotePlugin.php166
-rw-r--r--lib/private/Collaboration/Collaborators/Search.php120
-rw-r--r--lib/private/Collaboration/Collaborators/SearchResult.php89
-rw-r--r--lib/private/Collaboration/Collaborators/UserPlugin.php266
-rw-r--r--lib/private/Collaboration/Reference/File/FileReferenceEventListener.php57
-rw-r--r--lib/private/Collaboration/Reference/File/FileReferenceProvider.php161
-rw-r--r--lib/private/Collaboration/Reference/LinkReferenceProvider.php15
-rw-r--r--lib/private/Collaboration/Reference/ReferenceManager.php262
-rw-r--r--lib/private/Collaboration/Reference/RenderReferenceEventListener.php48
-rw-r--r--lib/private/Collaboration/Resources/Collection.php180
-rw-r--r--lib/private/Collaboration/Resources/Listener.php53
-rw-r--r--lib/private/Collaboration/Resources/Manager.php467
-rw-r--r--lib/private/Collaboration/Resources/ProviderManager.php50
-rw-r--r--lib/private/Collaboration/Resources/Resource.php113
19 files changed, 2682 insertions, 0 deletions
diff --git a/lib/private/Collaboration/AutoComplete/Manager.php b/lib/private/Collaboration/AutoComplete/Manager.php
new file mode 100644
index 00000000000..cc5df78beea
--- /dev/null
+++ b/lib/private/Collaboration/AutoComplete/Manager.php
@@ -0,0 +1,73 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\AutoComplete;
+
+use OCP\Collaboration\AutoComplete\IManager;
+use OCP\Collaboration\AutoComplete\ISorter;
+use Psr\Container\ContainerExceptionInterface;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+
+class Manager implements IManager {
+ /** @var string[] */
+ protected array $sorters = [];
+
+ /** @var ISorter[] */
+ protected array $sorterInstances = [];
+
+ public function __construct(
+ private ContainerInterface $container,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ public function runSorters(array $sorters, array &$sortArray, array $context): void {
+ $sorterInstances = $this->getSorters();
+ while ($sorter = array_shift($sorters)) {
+ if (isset($sorterInstances[$sorter])) {
+ $sorterInstances[$sorter]->sort($sortArray, $context);
+ } else {
+ $this->logger->warning('No sorter for ID "{id}", skipping', [
+ 'app' => 'core', 'id' => $sorter
+ ]);
+ }
+ }
+ }
+
+ public function registerSorter($className): void {
+ $this->sorters[] = $className;
+ }
+
+ protected function getSorters(): array {
+ if (count($this->sorterInstances) === 0) {
+ foreach ($this->sorters as $sorter) {
+ try {
+ $instance = $this->container->get($sorter);
+ } catch (ContainerExceptionInterface) {
+ $this->logger->notice(
+ 'Skipping not registered sorter. Class name: {class}',
+ ['app' => 'core', 'class' => $sorter],
+ );
+ continue;
+ }
+ if (!$instance instanceof ISorter) {
+ $this->logger->notice('Skipping sorter which is not an instance of ISorter. Class name: {class}',
+ ['app' => 'core', 'class' => $sorter]);
+ continue;
+ }
+ $sorterId = trim($instance->getId());
+ if (trim($sorterId) === '') {
+ $this->logger->notice('Skipping sorter with empty ID. Class name: {class}',
+ ['app' => 'core', 'class' => $sorter]);
+ continue;
+ }
+ $this->sorterInstances[$sorterId] = $instance;
+ }
+ }
+ return $this->sorterInstances;
+ }
+}
diff --git a/lib/private/Collaboration/Collaborators/GroupPlugin.php b/lib/private/Collaboration/Collaborators/GroupPlugin.php
new file mode 100644
index 00000000000..a59d5981825
--- /dev/null
+++ b/lib/private/Collaboration/Collaborators/GroupPlugin.php
@@ -0,0 +1,130 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Collaborators;
+
+use OCP\Collaboration\Collaborators\ISearchPlugin;
+use OCP\Collaboration\Collaborators\ISearchResult;
+use OCP\Collaboration\Collaborators\SearchResultType;
+use OCP\IConfig;
+use OCP\IGroup;
+use OCP\IGroupManager;
+use OCP\IUserSession;
+use OCP\Share\IShare;
+
+class GroupPlugin implements ISearchPlugin {
+ protected bool $shareeEnumeration;
+
+ protected bool $shareWithGroupOnly;
+
+ protected bool $shareeEnumerationInGroupOnly;
+
+ protected bool $groupSharingDisabled;
+
+ public function __construct(
+ private IConfig $config,
+ private IGroupManager $groupManager,
+ private IUserSession $userSession,
+ private mixed $shareWithGroupOnlyExcludeGroupsList = [],
+ ) {
+ $this->shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
+ $this->shareWithGroupOnly = $this->config->getAppValue('core', 'shareapi_only_share_with_group_members', 'no') === 'yes';
+ $this->shareeEnumerationInGroupOnly = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
+ $this->groupSharingDisabled = $this->config->getAppValue('core', 'shareapi_allow_group_sharing', 'yes') === 'no';
+
+ if ($this->shareWithGroupOnly) {
+ $this->shareWithGroupOnlyExcludeGroupsList = json_decode($this->config->getAppValue('core', 'shareapi_only_share_with_group_members_exclude_group_list', ''), true) ?? [];
+ }
+ }
+
+ public function search($search, $limit, $offset, ISearchResult $searchResult): bool {
+ if ($this->groupSharingDisabled) {
+ return false;
+ }
+
+ $hasMoreResults = false;
+ $result = ['wide' => [], 'exact' => []];
+
+ $groups = $this->groupManager->search($search, $limit, $offset);
+ $groupIds = array_map(function (IGroup $group) {
+ return $group->getGID();
+ }, $groups);
+
+ if (!$this->shareeEnumeration || count($groups) < $limit) {
+ $hasMoreResults = true;
+ }
+
+ $userGroups = [];
+ if (!empty($groups) && ($this->shareWithGroupOnly || $this->shareeEnumerationInGroupOnly)) {
+ // Intersect all the groups that match with the groups this user is a member of
+ $userGroups = $this->groupManager->getUserGroups($this->userSession->getUser());
+ $userGroups = array_map(function (IGroup $group) {
+ return $group->getGID();
+ }, $userGroups);
+ $groupIds = array_intersect($groupIds, $userGroups);
+
+ // ShareWithGroupOnly filtering
+ $groupIds = array_diff($groupIds, $this->shareWithGroupOnlyExcludeGroupsList);
+ }
+
+ $lowerSearch = strtolower($search);
+ foreach ($groups as $group) {
+ if ($group->hideFromCollaboration()) {
+ continue;
+ }
+
+ // FIXME: use a more efficient approach
+ $gid = $group->getGID();
+ if (!in_array($gid, $groupIds)) {
+ continue;
+ }
+ if (strtolower($gid) === $lowerSearch || strtolower($group->getDisplayName()) === $lowerSearch) {
+ $result['exact'][] = [
+ 'label' => $group->getDisplayName(),
+ 'value' => [
+ 'shareType' => IShare::TYPE_GROUP,
+ 'shareWith' => $gid,
+ ],
+ ];
+ } else {
+ if ($this->shareeEnumerationInGroupOnly && !in_array($group->getGID(), $userGroups, true)) {
+ continue;
+ }
+ $result['wide'][] = [
+ 'label' => $group->getDisplayName(),
+ 'value' => [
+ 'shareType' => IShare::TYPE_GROUP,
+ 'shareWith' => $gid,
+ ],
+ ];
+ }
+ }
+
+ if ($offset === 0 && empty($result['exact'])) {
+ // On page one we try if the search result has a direct hit on the
+ // user id and if so, we add that to the exact match list
+ $group = $this->groupManager->get($search);
+ if ($group instanceof IGroup && !$group->hideFromCollaboration() && (!$this->shareWithGroupOnly || in_array($group->getGID(), $userGroups))) {
+ $result['exact'][] = [
+ 'label' => $group->getDisplayName(),
+ 'value' => [
+ 'shareType' => IShare::TYPE_GROUP,
+ 'shareWith' => $group->getGID(),
+ ],
+ ];
+ }
+ }
+
+ if (!$this->shareeEnumeration) {
+ $result['wide'] = [];
+ }
+
+ $type = new SearchResultType('groups');
+ $searchResult->addResultSet($type, $result['wide'], $result['exact']);
+
+ return $hasMoreResults;
+ }
+}
diff --git a/lib/private/Collaboration/Collaborators/LookupPlugin.php b/lib/private/Collaboration/Collaborators/LookupPlugin.php
new file mode 100644
index 00000000000..fb6b9f2e0e8
--- /dev/null
+++ b/lib/private/Collaboration/Collaborators/LookupPlugin.php
@@ -0,0 +1,97 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Collaborators;
+
+use OCP\Collaboration\Collaborators\ISearchPlugin;
+use OCP\Collaboration\Collaborators\ISearchResult;
+use OCP\Collaboration\Collaborators\SearchResultType;
+use OCP\Federation\ICloudIdManager;
+use OCP\Http\Client\IClientService;
+use OCP\IConfig;
+use OCP\IUserSession;
+use OCP\Share\IShare;
+use Psr\Log\LoggerInterface;
+
+class LookupPlugin implements ISearchPlugin {
+ /** @var string remote part of the current user's cloud id */
+ private string $currentUserRemote;
+
+ public function __construct(
+ private IConfig $config,
+ private IClientService $clientService,
+ IUserSession $userSession,
+ private ICloudIdManager $cloudIdManager,
+ private LoggerInterface $logger,
+ ) {
+ $currentUserCloudId = $userSession->getUser()->getCloudId();
+ $this->currentUserRemote = $cloudIdManager->resolveCloudId($currentUserCloudId)->getRemote();
+ }
+
+ public function search($search, $limit, $offset, ISearchResult $searchResult): bool {
+ $isGlobalScaleEnabled = $this->config->getSystemValueBool('gs.enabled', false);
+ $isLookupServerEnabled = $this->config->getAppValue('files_sharing', 'lookupServerEnabled', 'no') === 'yes';
+ $hasInternetConnection = $this->config->getSystemValueBool('has_internet_connection', true);
+
+ // If case of Global Scale we always search the lookup server
+ // TODO: Reconsider using the lookup server for non-global scale
+ // if (!$isGlobalScaleEnabled && (!$isLookupServerEnabled || !$hasInternetConnection || $disableLookupServer)) {
+ if (!$isGlobalScaleEnabled) {
+ return false;
+ }
+
+ $lookupServerUrl = $this->config->getSystemValueString('lookup_server', 'https://lookup.nextcloud.com');
+ if (empty($lookupServerUrl)) {
+ return false;
+ }
+ $lookupServerUrl = rtrim($lookupServerUrl, '/');
+ $result = [];
+
+ try {
+ $client = $this->clientService->newClient();
+ $response = $client->get(
+ $lookupServerUrl . '/users?search=' . urlencode($search),
+ [
+ 'timeout' => 10,
+ 'connect_timeout' => 3,
+ ]
+ );
+
+ $body = json_decode($response->getBody(), true);
+
+ foreach ($body as $lookup) {
+ try {
+ $remote = $this->cloudIdManager->resolveCloudId($lookup['federationId'])->getRemote();
+ } catch (\Exception $e) {
+ $this->logger->error('Can not parse federated cloud ID "' . $lookup['federationId'] . '"', [
+ 'exception' => $e,
+ ]);
+ continue;
+ }
+ if ($this->currentUserRemote === $remote) {
+ continue;
+ }
+ $name = $lookup['name']['value'] ?? '';
+ $label = empty($name) ? $lookup['federationId'] : $name . ' (' . $lookup['federationId'] . ')';
+ $result[] = [
+ 'label' => $label,
+ 'value' => [
+ 'shareType' => IShare::TYPE_REMOTE,
+ 'globalScale' => $isGlobalScaleEnabled,
+ 'shareWith' => $lookup['federationId'],
+ ],
+ 'extra' => $lookup,
+ ];
+ }
+ } catch (\Exception $e) {
+ }
+
+ $type = new SearchResultType('lookup');
+ $searchResult->addResultSet($type, $result, []);
+
+ return false;
+ }
+}
diff --git a/lib/private/Collaboration/Collaborators/MailPlugin.php b/lib/private/Collaboration/Collaborators/MailPlugin.php
new file mode 100644
index 00000000000..55e3945ace2
--- /dev/null
+++ b/lib/private/Collaboration/Collaborators/MailPlugin.php
@@ -0,0 +1,262 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Collaborators;
+
+use OC\KnownUser\KnownUserService;
+use OCP\Collaboration\Collaborators\ISearchPlugin;
+use OCP\Collaboration\Collaborators\ISearchResult;
+use OCP\Collaboration\Collaborators\SearchResultType;
+use OCP\Contacts\IManager;
+use OCP\Federation\ICloudId;
+use OCP\Federation\ICloudIdManager;
+use OCP\IConfig;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserSession;
+use OCP\Mail\IMailer;
+use OCP\Share\IShare;
+
+class MailPlugin implements ISearchPlugin {
+ protected bool $shareWithGroupOnly;
+
+ protected bool $shareeEnumeration;
+
+ protected bool $shareeEnumerationInGroupOnly;
+
+ protected bool $shareeEnumerationPhone;
+
+ protected bool $shareeEnumerationFullMatch;
+
+ protected bool $shareeEnumerationFullMatchEmail;
+
+ public function __construct(
+ private IManager $contactsManager,
+ private ICloudIdManager $cloudIdManager,
+ private IConfig $config,
+ private IGroupManager $groupManager,
+ private KnownUserService $knownUserService,
+ private IUserSession $userSession,
+ private IMailer $mailer,
+ private mixed $shareWithGroupOnlyExcludeGroupsList = [],
+ ) {
+ $this->shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
+ $this->shareWithGroupOnly = $this->config->getAppValue('core', 'shareapi_only_share_with_group_members', 'no') === 'yes';
+ $this->shareeEnumerationInGroupOnly = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
+ $this->shareeEnumerationPhone = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
+ $this->shareeEnumerationFullMatch = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') === 'yes';
+ $this->shareeEnumerationFullMatchEmail = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match_email', 'yes') === 'yes';
+
+ if ($this->shareWithGroupOnly) {
+ $this->shareWithGroupOnlyExcludeGroupsList = json_decode($this->config->getAppValue('core', 'shareapi_only_share_with_group_members_exclude_group_list', ''), true) ?? [];
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function search($search, $limit, $offset, ISearchResult $searchResult): bool {
+ if ($this->shareeEnumerationFullMatch && !$this->shareeEnumerationFullMatchEmail) {
+ return false;
+ }
+
+ // Extract the email address from "Foo Bar <foo.bar@example.tld>" and then search with "foo.bar@example.tld" instead
+ $result = preg_match('/<([^@]+@.+)>$/', $search, $matches);
+ if ($result && filter_var($matches[1], FILTER_VALIDATE_EMAIL)) {
+ return $this->search($matches[1], $limit, $offset, $searchResult);
+ }
+
+ $currentUserId = $this->userSession->getUser()->getUID();
+
+ $result = $userResults = ['wide' => [], 'exact' => []];
+ $userType = new SearchResultType('users');
+ $emailType = new SearchResultType('emails');
+
+ // Search in contacts
+ $addressBookContacts = $this->contactsManager->search(
+ $search,
+ ['EMAIL', 'FN'],
+ [
+ 'limit' => $limit,
+ 'offset' => $offset,
+ 'enumeration' => $this->shareeEnumeration,
+ 'fullmatch' => $this->shareeEnumerationFullMatch,
+ ]
+ );
+ $lowerSearch = strtolower($search);
+ foreach ($addressBookContacts as $contact) {
+ if (isset($contact['EMAIL'])) {
+ $emailAddresses = $contact['EMAIL'];
+ if (\is_string($emailAddresses)) {
+ $emailAddresses = [$emailAddresses];
+ }
+ foreach ($emailAddresses as $type => $emailAddress) {
+ $displayName = $emailAddress;
+ $emailAddressType = null;
+ if (\is_array($emailAddress)) {
+ $emailAddressData = $emailAddress;
+ $emailAddress = $emailAddressData['value'];
+ $emailAddressType = $emailAddressData['type'];
+ }
+
+ if (!filter_var($emailAddress, FILTER_VALIDATE_EMAIL)) {
+ continue;
+ }
+
+ if (isset($contact['FN'])) {
+ $displayName = $contact['FN'] . ' (' . $emailAddress . ')';
+ }
+ $exactEmailMatch = strtolower($emailAddress) === $lowerSearch;
+
+ if (isset($contact['isLocalSystemBook'])) {
+ if ($this->shareWithGroupOnly) {
+ /*
+ * Check if the user may share with the user associated with the e-mail of the just found contact
+ */
+ $userGroups = $this->groupManager->getUserGroupIds($this->userSession->getUser());
+
+ // ShareWithGroupOnly filtering
+ $userGroups = array_diff($userGroups, $this->shareWithGroupOnlyExcludeGroupsList);
+
+ $found = false;
+ foreach ($userGroups as $userGroup) {
+ if ($this->groupManager->isInGroup($contact['UID'], $userGroup)) {
+ $found = true;
+ break;
+ }
+ }
+ if (!$found) {
+ continue;
+ }
+ }
+ if ($exactEmailMatch && $this->shareeEnumerationFullMatch) {
+ try {
+ $cloud = $this->cloudIdManager->resolveCloudId($contact['CLOUD'][0] ?? '');
+ } catch (\InvalidArgumentException $e) {
+ continue;
+ }
+
+ if (!$this->isCurrentUser($cloud) && !$searchResult->hasResult($userType, $cloud->getUser())) {
+ $singleResult = [[
+ 'label' => $displayName,
+ 'uuid' => $contact['UID'] ?? $emailAddress,
+ 'name' => $contact['FN'] ?? $displayName,
+ 'value' => [
+ 'shareType' => IShare::TYPE_USER,
+ 'shareWith' => $cloud->getUser(),
+ ],
+ 'shareWithDisplayNameUnique' => !empty($emailAddress) ? $emailAddress : $cloud->getUser()
+
+ ]];
+ $searchResult->addResultSet($userType, [], $singleResult);
+ $searchResult->markExactIdMatch($emailType);
+ }
+ return false;
+ }
+
+ if ($this->shareeEnumeration) {
+ try {
+ $cloud = $this->cloudIdManager->resolveCloudId($contact['CLOUD'][0] ?? '');
+ } catch (\InvalidArgumentException $e) {
+ continue;
+ }
+
+ $addToWide = !($this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone);
+ if (!$addToWide && $this->shareeEnumerationPhone && $this->knownUserService->isKnownToUser($currentUserId, $contact['UID'])) {
+ $addToWide = true;
+ }
+
+ if (!$addToWide && $this->shareeEnumerationInGroupOnly) {
+ $addToWide = false;
+ $userGroups = $this->groupManager->getUserGroupIds($this->userSession->getUser());
+ foreach ($userGroups as $userGroup) {
+ if ($this->groupManager->isInGroup($contact['UID'], $userGroup)) {
+ $addToWide = true;
+ break;
+ }
+ }
+ }
+ if ($addToWide && !$this->isCurrentUser($cloud) && !$searchResult->hasResult($userType, $cloud->getUser())) {
+ $userResults['wide'][] = [
+ 'label' => $displayName,
+ 'uuid' => $contact['UID'] ?? $emailAddress,
+ 'name' => $contact['FN'] ?? $displayName,
+ 'value' => [
+ 'shareType' => IShare::TYPE_USER,
+ 'shareWith' => $cloud->getUser(),
+ ],
+ 'shareWithDisplayNameUnique' => !empty($emailAddress) ? $emailAddress : $cloud->getUser()
+ ];
+ continue;
+ }
+ }
+ continue;
+ }
+
+ if ($exactEmailMatch
+ || (isset($contact['FN']) && strtolower($contact['FN']) === $lowerSearch)) {
+ if ($exactEmailMatch) {
+ $searchResult->markExactIdMatch($emailType);
+ }
+ $result['exact'][] = [
+ 'label' => $displayName,
+ 'uuid' => $contact['UID'] ?? $emailAddress,
+ 'name' => $contact['FN'] ?? $displayName,
+ 'type' => $emailAddressType ?? '',
+ 'value' => [
+ 'shareType' => IShare::TYPE_EMAIL,
+ 'shareWith' => $emailAddress,
+ ],
+ ];
+ } else {
+ $result['wide'][] = [
+ 'label' => $displayName,
+ 'uuid' => $contact['UID'] ?? $emailAddress,
+ 'name' => $contact['FN'] ?? $displayName,
+ 'type' => $emailAddressType ?? '',
+ 'value' => [
+ 'shareType' => IShare::TYPE_EMAIL,
+ 'shareWith' => $emailAddress,
+ ],
+ ];
+ }
+ }
+ }
+ }
+
+ $reachedEnd = true;
+ if ($this->shareeEnumeration) {
+ $reachedEnd = (count($result['wide']) < $offset + $limit)
+ && (count($userResults['wide']) < $offset + $limit);
+
+ $result['wide'] = array_slice($result['wide'], $offset, $limit);
+ $userResults['wide'] = array_slice($userResults['wide'], $offset, $limit);
+ }
+
+ if (!$searchResult->hasExactIdMatch($emailType) && $this->mailer->validateMailAddress($search)) {
+ $result['exact'][] = [
+ 'label' => $search,
+ 'uuid' => $search,
+ 'value' => [
+ 'shareType' => IShare::TYPE_EMAIL,
+ 'shareWith' => $search,
+ ],
+ ];
+ }
+
+ if (!empty($userResults['wide'])) {
+ $searchResult->addResultSet($userType, $userResults['wide'], []);
+ }
+ $searchResult->addResultSet($emailType, $result['wide'], $result['exact']);
+
+ return !$reachedEnd;
+ }
+
+ public function isCurrentUser(ICloudId $cloud): bool {
+ $currentUser = $this->userSession->getUser();
+ return $currentUser instanceof IUser && $currentUser->getUID() === $cloud->getUser();
+ }
+}
diff --git a/lib/private/Collaboration/Collaborators/RemoteGroupPlugin.php b/lib/private/Collaboration/Collaborators/RemoteGroupPlugin.php
new file mode 100644
index 00000000000..f4c1793ea0a
--- /dev/null
+++ b/lib/private/Collaboration/Collaborators/RemoteGroupPlugin.php
@@ -0,0 +1,73 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Collaborators;
+
+use OCP\Collaboration\Collaborators\ISearchPlugin;
+use OCP\Collaboration\Collaborators\ISearchResult;
+use OCP\Collaboration\Collaborators\SearchResultType;
+use OCP\Federation\ICloudFederationProviderManager;
+use OCP\Federation\ICloudIdManager;
+use OCP\Share;
+use OCP\Share\IShare;
+
+class RemoteGroupPlugin implements ISearchPlugin {
+ private bool $enabled = false;
+
+ public function __construct(
+ ICloudFederationProviderManager $cloudFederationProviderManager,
+ private ICloudIdManager $cloudIdManager,
+ ) {
+ try {
+ $fileSharingProvider = $cloudFederationProviderManager->getCloudFederationProvider('file');
+ $supportedShareTypes = $fileSharingProvider->getSupportedShareTypes();
+ if (in_array('group', $supportedShareTypes)) {
+ $this->enabled = true;
+ }
+ } catch (\Exception $e) {
+ // do nothing, just don't enable federated group shares
+ }
+ }
+
+ public function search($search, $limit, $offset, ISearchResult $searchResult): bool {
+ $result = ['wide' => [], 'exact' => []];
+ $resultType = new SearchResultType('remote_groups');
+
+ if ($this->enabled && $this->cloudIdManager->isValidCloudId($search) && $offset === 0) {
+ [$remoteGroup, $serverUrl] = $this->splitGroupRemote($search);
+ $result['exact'][] = [
+ 'label' => $remoteGroup . " ($serverUrl)",
+ 'guid' => $remoteGroup,
+ 'name' => $remoteGroup,
+ 'value' => [
+ 'shareType' => IShare::TYPE_REMOTE_GROUP,
+ 'shareWith' => $search,
+ 'server' => $serverUrl,
+ ],
+ ];
+ }
+
+ $searchResult->addResultSet($resultType, $result['wide'], $result['exact']);
+
+ return true;
+ }
+
+ /**
+ * split group and remote from federated cloud id
+ *
+ * @param string $address federated share address
+ * @return array [user, remoteURL]
+ * @throws \InvalidArgumentException
+ */
+ public function splitGroupRemote($address): array {
+ try {
+ $cloudId = $this->cloudIdManager->resolveCloudId($address);
+ return [$cloudId->getUser(), $cloudId->getRemote()];
+ } catch (\InvalidArgumentException $e) {
+ throw new \InvalidArgumentException('Invalid Federated Cloud ID', 0, $e);
+ }
+ }
+}
diff --git a/lib/private/Collaboration/Collaborators/RemotePlugin.php b/lib/private/Collaboration/Collaborators/RemotePlugin.php
new file mode 100644
index 00000000000..037c6f6cbea
--- /dev/null
+++ b/lib/private/Collaboration/Collaborators/RemotePlugin.php
@@ -0,0 +1,166 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Collaborators;
+
+use OCP\Collaboration\Collaborators\ISearchPlugin;
+use OCP\Collaboration\Collaborators\ISearchResult;
+use OCP\Collaboration\Collaborators\SearchResultType;
+use OCP\Contacts\IManager;
+use OCP\Federation\ICloudIdManager;
+use OCP\IConfig;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use OCP\Share\IShare;
+
+class RemotePlugin implements ISearchPlugin {
+ protected bool $shareeEnumeration;
+
+ private string $userId;
+
+ public function __construct(
+ private IManager $contactsManager,
+ private ICloudIdManager $cloudIdManager,
+ private IConfig $config,
+ private IUserManager $userManager,
+ IUserSession $userSession,
+ ) {
+ $this->userId = $userSession->getUser()?->getUID() ?? '';
+ $this->shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
+ }
+
+ public function search($search, $limit, $offset, ISearchResult $searchResult): bool {
+ $result = ['wide' => [], 'exact' => []];
+ $resultType = new SearchResultType('remotes');
+
+ // Search in contacts
+ $addressBookContacts = $this->contactsManager->search($search, ['CLOUD', 'FN'], [
+ 'limit' => $limit,
+ 'offset' => $offset,
+ 'enumeration' => false,
+ 'fullmatch' => false,
+ ]);
+ foreach ($addressBookContacts as $contact) {
+ if (isset($contact['isLocalSystemBook'])) {
+ continue;
+ }
+ if (isset($contact['CLOUD'])) {
+ $cloudIds = $contact['CLOUD'];
+ if (is_string($cloudIds)) {
+ $cloudIds = [$cloudIds];
+ }
+ $lowerSearch = strtolower($search);
+ foreach ($cloudIds as $cloudId) {
+ $cloudIdType = '';
+ if (\is_array($cloudId)) {
+ $cloudIdData = $cloudId;
+ $cloudId = $cloudIdData['value'];
+ $cloudIdType = $cloudIdData['type'];
+ }
+ try {
+ [$remoteUser, $serverUrl] = $this->splitUserRemote($cloudId);
+ } catch (\InvalidArgumentException $e) {
+ continue;
+ }
+
+ $localUser = $this->userManager->get($remoteUser);
+ /**
+ * Add local share if remote cloud id matches a local user ones
+ */
+ if ($localUser !== null && $remoteUser !== $this->userId && $cloudId === $localUser->getCloudId()) {
+ $result['wide'][] = [
+ 'label' => $contact['FN'],
+ 'uuid' => $contact['UID'],
+ 'value' => [
+ 'shareType' => IShare::TYPE_USER,
+ 'shareWith' => $remoteUser
+ ],
+ 'shareWithDisplayNameUnique' => $contact['EMAIL'] !== null && $contact['EMAIL'] !== '' ? $contact['EMAIL'] : $contact['UID'],
+ ];
+ }
+
+ if (strtolower($contact['FN']) === $lowerSearch || strtolower($cloudId) === $lowerSearch) {
+ if (strtolower($cloudId) === $lowerSearch) {
+ $searchResult->markExactIdMatch($resultType);
+ }
+ $result['exact'][] = [
+ 'label' => $contact['FN'] . " ($cloudId)",
+ 'uuid' => $contact['UID'],
+ 'name' => $contact['FN'],
+ 'type' => $cloudIdType,
+ 'value' => [
+ 'shareType' => IShare::TYPE_REMOTE,
+ 'shareWith' => $cloudId,
+ 'server' => $serverUrl,
+ ],
+ ];
+ } else {
+ $result['wide'][] = [
+ 'label' => $contact['FN'] . " ($cloudId)",
+ 'uuid' => $contact['UID'],
+ 'name' => $contact['FN'],
+ 'type' => $cloudIdType,
+ 'value' => [
+ 'shareType' => IShare::TYPE_REMOTE,
+ 'shareWith' => $cloudId,
+ 'server' => $serverUrl,
+ ],
+ ];
+ }
+ }
+ }
+ }
+
+ if (!$this->shareeEnumeration) {
+ $result['wide'] = [];
+ } else {
+ $result['wide'] = array_slice($result['wide'], $offset, $limit);
+ }
+
+ /**
+ * Add generic share with remote item for valid cloud ids that are not users of the local instance
+ */
+ if (!$searchResult->hasExactIdMatch($resultType) && $this->cloudIdManager->isValidCloudId($search) && $offset === 0) {
+ try {
+ [$remoteUser, $serverUrl] = $this->splitUserRemote($search);
+ $localUser = $this->userManager->get($remoteUser);
+ if ($localUser === null || $search !== $localUser->getCloudId()) {
+ $result['exact'][] = [
+ 'label' => $remoteUser . " ($serverUrl)",
+ 'uuid' => $remoteUser,
+ 'name' => $remoteUser,
+ 'value' => [
+ 'shareType' => IShare::TYPE_REMOTE,
+ 'shareWith' => $search,
+ 'server' => $serverUrl,
+ ],
+ ];
+ }
+ } catch (\InvalidArgumentException $e) {
+ }
+ }
+
+ $searchResult->addResultSet($resultType, $result['wide'], $result['exact']);
+
+ return true;
+ }
+
+ /**
+ * split user and remote from federated cloud id
+ *
+ * @param string $address federated share address
+ * @return array [user, remoteURL]
+ * @throws \InvalidArgumentException
+ */
+ public function splitUserRemote(string $address): array {
+ try {
+ $cloudId = $this->cloudIdManager->resolveCloudId($address);
+ return [$cloudId->getUser(), $this->cloudIdManager->removeProtocolFromUrl($cloudId->getRemote(), true)];
+ } catch (\InvalidArgumentException $e) {
+ throw new \InvalidArgumentException('Invalid Federated Cloud ID', 0, $e);
+ }
+ }
+}
diff --git a/lib/private/Collaboration/Collaborators/Search.php b/lib/private/Collaboration/Collaborators/Search.php
new file mode 100644
index 00000000000..ea39f885fc6
--- /dev/null
+++ b/lib/private/Collaboration/Collaborators/Search.php
@@ -0,0 +1,120 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Collaborators;
+
+use OCP\Collaboration\Collaborators\ISearch;
+use OCP\Collaboration\Collaborators\ISearchPlugin;
+use OCP\Collaboration\Collaborators\ISearchResult;
+use OCP\Collaboration\Collaborators\SearchResultType;
+use OCP\IContainer;
+use OCP\Share\IShare;
+
+class Search implements ISearch {
+ protected array $pluginList = [];
+
+ public function __construct(
+ private IContainer $container,
+ ) {
+ }
+
+ /**
+ * @param string $search
+ * @param bool $lookup
+ * @param int|null $limit
+ * @param int|null $offset
+ * @throws \OCP\AppFramework\QueryException
+ */
+ public function search($search, array $shareTypes, $lookup, $limit, $offset): array {
+ $hasMoreResults = false;
+
+ // Trim leading and trailing whitespace characters, e.g. when query is copy-pasted
+ $search = trim($search);
+
+ /** @var ISearchResult $searchResult */
+ $searchResult = $this->container->resolve(SearchResult::class);
+
+ foreach ($shareTypes as $type) {
+ if (!isset($this->pluginList[$type])) {
+ continue;
+ }
+ foreach ($this->pluginList[$type] as $plugin) {
+ /** @var ISearchPlugin $searchPlugin */
+ $searchPlugin = $this->container->resolve($plugin);
+ $hasMoreResults = $searchPlugin->search($search, $limit, $offset, $searchResult) || $hasMoreResults;
+ }
+ }
+
+ // Get from lookup server, not a separate share type
+ if ($lookup) {
+ $searchPlugin = $this->container->resolve(LookupPlugin::class);
+ $hasMoreResults = $searchPlugin->search($search, $limit, $offset, $searchResult) || $hasMoreResults;
+ }
+
+ // sanitizing, could go into the plugins as well
+
+ // if we have an exact match, either for the federated cloud id or for the
+ // email address, we only return the exact match. It is highly unlikely
+ // that the exact same email address and federated cloud id exists
+ $emailType = new SearchResultType('emails');
+ $remoteType = new SearchResultType('remotes');
+ if ($searchResult->hasExactIdMatch($emailType) && !$searchResult->hasExactIdMatch($remoteType)) {
+ $searchResult->unsetResult($remoteType);
+ } elseif (!$searchResult->hasExactIdMatch($emailType) && $searchResult->hasExactIdMatch($remoteType)) {
+ $searchResult->unsetResult($emailType);
+ }
+
+ $this->dropMailSharesWhereRemoteShareIsPossible($searchResult);
+
+ // if we have an exact local user match with an email-a-like query,
+ // there is no need to show the remote and email matches.
+ $userType = new SearchResultType('users');
+ if (str_contains($search, '@') && $searchResult->hasExactIdMatch($userType)) {
+ $searchResult->unsetResult($remoteType);
+ $searchResult->unsetResult($emailType);
+ }
+
+ return [$searchResult->asArray(), $hasMoreResults];
+ }
+
+ public function registerPlugin(array $pluginInfo): void {
+ $shareType = constant(IShare::class . '::' . substr($pluginInfo['shareType'], strlen('SHARE_')));
+ if ($shareType === null) {
+ throw new \InvalidArgumentException('Provided ShareType is invalid');
+ }
+ $this->pluginList[$shareType][] = $pluginInfo['class'];
+ }
+
+ protected function dropMailSharesWhereRemoteShareIsPossible(ISearchResult $searchResult): void {
+ $allResults = $searchResult->asArray();
+
+ $emailType = new SearchResultType('emails');
+ $remoteType = new SearchResultType('remotes');
+
+ if (!isset($allResults[$remoteType->getLabel()])
+ || !isset($allResults[$emailType->getLabel()])) {
+ return;
+ }
+
+ $mailIdMap = [];
+ foreach ($allResults[$emailType->getLabel()] as $mailRow) {
+ // sure, array_reduce looks nicer, but foreach needs less resources and is faster
+ if (!isset($mailRow['uuid'])) {
+ continue;
+ }
+ $mailIdMap[$mailRow['uuid']] = $mailRow['value']['shareWith'];
+ }
+
+ foreach ($allResults[$remoteType->getLabel()] as $resultRow) {
+ if (!isset($resultRow['uuid'])) {
+ continue;
+ }
+ if (isset($mailIdMap[$resultRow['uuid']])) {
+ $searchResult->removeCollaboratorResult($emailType, $mailIdMap[$resultRow['uuid']]);
+ }
+ }
+ }
+}
diff --git a/lib/private/Collaboration/Collaborators/SearchResult.php b/lib/private/Collaboration/Collaborators/SearchResult.php
new file mode 100644
index 00000000000..c9c2f032f36
--- /dev/null
+++ b/lib/private/Collaboration/Collaborators/SearchResult.php
@@ -0,0 +1,89 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Collaborators;
+
+use OCP\Collaboration\Collaborators\ISearchResult;
+use OCP\Collaboration\Collaborators\SearchResultType;
+
+class SearchResult implements ISearchResult {
+ protected array $result = [
+ 'exact' => [],
+ ];
+
+ protected array $exactIdMatches = [];
+
+ public function addResultSet(SearchResultType $type, array $matches, ?array $exactMatches = null): void {
+ $type = $type->getLabel();
+ if (!isset($this->result[$type])) {
+ $this->result[$type] = [];
+ $this->result['exact'][$type] = [];
+ }
+
+ $this->result[$type] = array_merge($this->result[$type], $matches);
+ if (is_array($exactMatches)) {
+ $this->result['exact'][$type] = array_merge($this->result['exact'][$type], $exactMatches);
+ }
+ }
+
+ public function markExactIdMatch(SearchResultType $type): void {
+ $this->exactIdMatches[$type->getLabel()] = 1;
+ }
+
+ public function hasExactIdMatch(SearchResultType $type): bool {
+ return isset($this->exactIdMatches[$type->getLabel()]);
+ }
+
+ public function hasResult(SearchResultType $type, $collaboratorId): bool {
+ $type = $type->getLabel();
+ if (!isset($this->result[$type])) {
+ return false;
+ }
+
+ $resultArrays = [$this->result['exact'][$type], $this->result[$type]];
+ foreach ($resultArrays as $resultArray) {
+ foreach ($resultArray as $result) {
+ if ($result['value']['shareWith'] === $collaboratorId) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ public function asArray(): array {
+ return $this->result;
+ }
+
+ public function unsetResult(SearchResultType $type): void {
+ $type = $type->getLabel();
+ $this->result[$type] = [];
+ if (isset($this->result['exact'][$type])) {
+ $this->result['exact'][$type] = [];
+ }
+ }
+
+ public function removeCollaboratorResult(SearchResultType $type, string $collaboratorId): bool {
+ $type = $type->getLabel();
+ if (!isset($this->result[$type])) {
+ return false;
+ }
+
+ $actionDone = false;
+ $resultArrays = [&$this->result['exact'][$type], &$this->result[$type]];
+ foreach ($resultArrays as &$resultArray) {
+ foreach ($resultArray as $k => $result) {
+ if ($result['value']['shareWith'] === $collaboratorId) {
+ unset($resultArray[$k]);
+ $actionDone = true;
+ }
+ }
+ }
+
+ return $actionDone;
+ }
+}
diff --git a/lib/private/Collaboration/Collaborators/UserPlugin.php b/lib/private/Collaboration/Collaborators/UserPlugin.php
new file mode 100644
index 00000000000..671181aea35
--- /dev/null
+++ b/lib/private/Collaboration/Collaborators/UserPlugin.php
@@ -0,0 +1,266 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Collaborators;
+
+use OC\KnownUser\KnownUserService;
+use OCP\Collaboration\Collaborators\ISearchPlugin;
+use OCP\Collaboration\Collaborators\ISearchResult;
+use OCP\Collaboration\Collaborators\SearchResultType;
+use OCP\IConfig;
+use OCP\IGroupManager;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\IUserSession;
+use OCP\Share\IShare;
+use OCP\UserStatus\IManager as IUserStatusManager;
+
+class UserPlugin implements ISearchPlugin {
+ protected bool $shareWithGroupOnly;
+
+ protected bool $shareeEnumeration;
+
+ protected bool $shareeEnumerationInGroupOnly;
+
+ protected bool $shareeEnumerationPhone;
+
+ protected bool $shareeEnumerationFullMatch;
+
+ protected bool $shareeEnumerationFullMatchUserId;
+
+ protected bool $shareeEnumerationFullMatchEmail;
+
+ protected bool $shareeEnumerationFullMatchIgnoreSecondDisplayName;
+
+ public function __construct(
+ private IConfig $config,
+ private IUserManager $userManager,
+ private IGroupManager $groupManager,
+ private IUserSession $userSession,
+ private KnownUserService $knownUserService,
+ private IUserStatusManager $userStatusManager,
+ private mixed $shareWithGroupOnlyExcludeGroupsList = [],
+ ) {
+ $this->shareWithGroupOnly = $this->config->getAppValue('core', 'shareapi_only_share_with_group_members', 'no') === 'yes';
+ $this->shareeEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
+ $this->shareeEnumerationInGroupOnly = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
+ $this->shareeEnumerationPhone = $this->shareeEnumeration && $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
+ $this->shareeEnumerationFullMatch = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') === 'yes';
+ $this->shareeEnumerationFullMatchUserId = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match_userid', 'yes') === 'yes';
+ $this->shareeEnumerationFullMatchEmail = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match_email', 'yes') === 'yes';
+ $this->shareeEnumerationFullMatchIgnoreSecondDisplayName = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match_ignore_second_dn', 'no') === 'yes';
+
+ if ($this->shareWithGroupOnly) {
+ $this->shareWithGroupOnlyExcludeGroupsList = json_decode($this->config->getAppValue('core', 'shareapi_only_share_with_group_members_exclude_group_list', ''), true) ?? [];
+ }
+ }
+
+ public function search($search, $limit, $offset, ISearchResult $searchResult): bool {
+ $result = ['wide' => [], 'exact' => []];
+ $users = [];
+ $hasMoreResults = false;
+
+ $currentUserId = $this->userSession->getUser()->getUID();
+ $currentUserGroups = $this->groupManager->getUserGroupIds($this->userSession->getUser());
+
+ // ShareWithGroupOnly filtering
+ $currentUserGroups = array_diff($currentUserGroups, $this->shareWithGroupOnlyExcludeGroupsList);
+
+ if ($this->shareWithGroupOnly || $this->shareeEnumerationInGroupOnly) {
+ // Search in all the groups this user is part of
+ foreach ($currentUserGroups as $userGroupId) {
+ $usersInGroup = $this->groupManager->displayNamesInGroup($userGroupId, $search, $limit, $offset);
+ foreach ($usersInGroup as $userId => $displayName) {
+ $userId = (string)$userId;
+ $user = $this->userManager->get($userId);
+ if (!$user->isEnabled()) {
+ // Ignore disabled users
+ continue;
+ }
+ $users[$userId] = $user;
+ }
+ if (count($usersInGroup) >= $limit) {
+ $hasMoreResults = true;
+ }
+ }
+
+ if (!$this->shareWithGroupOnly && $this->shareeEnumerationPhone) {
+ $usersTmp = $this->userManager->searchKnownUsersByDisplayName($currentUserId, $search, $limit, $offset);
+ if (!empty($usersTmp)) {
+ foreach ($usersTmp as $user) {
+ if ($user->isEnabled()) { // Don't keep deactivated users
+ $users[$user->getUID()] = $user;
+ }
+ }
+
+ uasort($users, function ($a, $b) {
+ /**
+ * @var \OC\User\User $a
+ * @var \OC\User\User $b
+ */
+ return strcasecmp($a->getDisplayName(), $b->getDisplayName());
+ });
+ }
+ }
+ } else {
+ // Search in all users
+ if ($this->shareeEnumerationPhone) {
+ $usersTmp = $this->userManager->searchKnownUsersByDisplayName($currentUserId, $search, $limit, $offset);
+ } else {
+ $usersTmp = $this->userManager->searchDisplayName($search, $limit, $offset);
+ }
+ foreach ($usersTmp as $user) {
+ if ($user->isEnabled()) { // Don't keep deactivated users
+ $users[$user->getUID()] = $user;
+ }
+ }
+ }
+
+ $this->takeOutCurrentUser($users);
+
+ if (!$this->shareeEnumeration || count($users) < $limit) {
+ $hasMoreResults = true;
+ }
+
+ $foundUserById = false;
+ $lowerSearch = strtolower($search);
+ $userStatuses = $this->userStatusManager->getUserStatuses(array_keys($users));
+ foreach ($users as $uid => $user) {
+ $userDisplayName = $user->getDisplayName();
+ $userEmail = $user->getSystemEMailAddress();
+ $uid = (string)$uid;
+
+ $status = [];
+ if (array_key_exists($uid, $userStatuses)) {
+ $userStatus = $userStatuses[$uid];
+ $status = [
+ 'status' => $userStatus->getStatus(),
+ 'message' => $userStatus->getMessage(),
+ 'icon' => $userStatus->getIcon(),
+ 'clearAt' => $userStatus->getClearAt()
+ ? (int)$userStatus->getClearAt()->format('U')
+ : null,
+ ];
+ }
+
+
+ if (
+ $this->shareeEnumerationFullMatch
+ && $lowerSearch !== '' && (strtolower($uid) === $lowerSearch
+ || strtolower($userDisplayName) === $lowerSearch
+ || ($this->shareeEnumerationFullMatchIgnoreSecondDisplayName && trim(strtolower(preg_replace('/ \(.*\)$/', '', $userDisplayName))) === $lowerSearch)
+ || ($this->shareeEnumerationFullMatchEmail && strtolower($userEmail ?? '') === $lowerSearch))
+ ) {
+ if (strtolower($uid) === $lowerSearch) {
+ $foundUserById = true;
+ }
+ $result['exact'][] = [
+ 'label' => $userDisplayName,
+ 'subline' => $status['message'] ?? '',
+ 'icon' => 'icon-user',
+ 'value' => [
+ 'shareType' => IShare::TYPE_USER,
+ 'shareWith' => $uid,
+ ],
+ 'shareWithDisplayNameUnique' => !empty($userEmail) ? $userEmail : $uid,
+ 'status' => $status,
+ ];
+ } else {
+ $addToWideResults = false;
+ if ($this->shareeEnumeration
+ && !($this->shareeEnumerationInGroupOnly || $this->shareeEnumerationPhone)) {
+ $addToWideResults = true;
+ }
+
+ if ($this->shareeEnumerationPhone && $this->knownUserService->isKnownToUser($currentUserId, $user->getUID())) {
+ $addToWideResults = true;
+ }
+
+ if (!$addToWideResults && $this->shareeEnumerationInGroupOnly) {
+ $commonGroups = array_intersect($currentUserGroups, $this->groupManager->getUserGroupIds($user));
+ if (!empty($commonGroups)) {
+ $addToWideResults = true;
+ }
+ }
+
+ if ($addToWideResults) {
+ $result['wide'][] = [
+ 'label' => $userDisplayName,
+ 'subline' => $status['message'] ?? '',
+ 'icon' => 'icon-user',
+ 'value' => [
+ 'shareType' => IShare::TYPE_USER,
+ 'shareWith' => $uid,
+ ],
+ 'shareWithDisplayNameUnique' => !empty($userEmail) ? $userEmail : $uid,
+ 'status' => $status,
+ ];
+ }
+ }
+ }
+
+ if ($this->shareeEnumerationFullMatch && $this->shareeEnumerationFullMatchUserId && $offset === 0 && !$foundUserById) {
+ // On page one we try if the search result has a direct hit on the
+ // user id and if so, we add that to the exact match list
+ $user = $this->userManager->get($search);
+ if ($user instanceof IUser) {
+ $addUser = true;
+
+ if ($this->shareWithGroupOnly) {
+ // Only add, if we have a common group
+ $commonGroups = array_intersect($currentUserGroups, $this->groupManager->getUserGroupIds($user));
+ $addUser = !empty($commonGroups);
+ }
+
+ if ($addUser) {
+ $status = [];
+ $uid = $user->getUID();
+ $userEmail = $user->getSystemEMailAddress();
+ if (array_key_exists($user->getUID(), $userStatuses)) {
+ $userStatus = $userStatuses[$user->getUID()];
+ $status = [
+ 'status' => $userStatus->getStatus(),
+ 'message' => $userStatus->getMessage(),
+ 'icon' => $userStatus->getIcon(),
+ 'clearAt' => $userStatus->getClearAt()
+ ? (int)$userStatus->getClearAt()->format('U')
+ : null,
+ ];
+ }
+
+ $result['exact'][] = [
+ 'label' => $user->getDisplayName(),
+ 'icon' => 'icon-user',
+ 'subline' => $status['message'] ?? '',
+ 'value' => [
+ 'shareType' => IShare::TYPE_USER,
+ 'shareWith' => $user->getUID(),
+ ],
+ 'shareWithDisplayNameUnique' => $userEmail !== null && $userEmail !== '' ? $userEmail : $uid,
+ 'status' => $status,
+ ];
+ }
+ }
+ }
+
+ $type = new SearchResultType('users');
+ $searchResult->addResultSet($type, $result['wide'], $result['exact']);
+ if (count($result['exact'])) {
+ $searchResult->markExactIdMatch($type);
+ }
+
+ return $hasMoreResults;
+ }
+
+ public function takeOutCurrentUser(array &$users): void {
+ $currentUser = $this->userSession->getUser();
+ if (!is_null($currentUser)) {
+ if (isset($users[$currentUser->getUID()])) {
+ unset($users[$currentUser->getUID()]);
+ }
+ }
+ }
+}
diff --git a/lib/private/Collaboration/Reference/File/FileReferenceEventListener.php b/lib/private/Collaboration/Reference/File/FileReferenceEventListener.php
new file mode 100644
index 00000000000..9c18531c8e7
--- /dev/null
+++ b/lib/private/Collaboration/Reference/File/FileReferenceEventListener.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Collaboration\Reference\File;
+
+use OC\Files\Node\NonExistingFile;
+use OC\Files\Node\NonExistingFolder;
+use OCP\Collaboration\Reference\IReferenceManager;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Files\Events\Node\NodeDeletedEvent;
+use OCP\Files\Events\Node\NodeRenamedEvent;
+use OCP\Share\Events\ShareCreatedEvent;
+use OCP\Share\Events\ShareDeletedEvent;
+
+/** @template-implements IEventListener<Event|NodeDeletedEvent|ShareDeletedEvent|ShareCreatedEvent> */
+class FileReferenceEventListener implements IEventListener {
+ public function __construct(
+ private IReferenceManager $manager,
+ ) {
+ }
+
+ public static function register(IEventDispatcher $eventDispatcher): void {
+ $eventDispatcher->addServiceListener(NodeDeletedEvent::class, FileReferenceEventListener::class);
+ $eventDispatcher->addServiceListener(NodeRenamedEvent::class, FileReferenceEventListener::class);
+ $eventDispatcher->addServiceListener(ShareDeletedEvent::class, FileReferenceEventListener::class);
+ $eventDispatcher->addServiceListener(ShareCreatedEvent::class, FileReferenceEventListener::class);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function handle(Event $event): void {
+ if ($event instanceof NodeDeletedEvent) {
+ if ($event->getNode() instanceof NonExistingFolder || $event->getNode() instanceof NonExistingFile) {
+ return;
+ }
+
+ $this->manager->invalidateCache((string)$event->getNode()->getId());
+ }
+ if ($event instanceof NodeRenamedEvent) {
+ $this->manager->invalidateCache((string)$event->getTarget()->getId());
+ }
+ if ($event instanceof ShareDeletedEvent) {
+ $this->manager->invalidateCache((string)$event->getShare()->getNodeId());
+ }
+ if ($event instanceof ShareCreatedEvent) {
+ $this->manager->invalidateCache((string)$event->getShare()->getNodeId());
+ }
+ }
+}
diff --git a/lib/private/Collaboration/Reference/File/FileReferenceProvider.php b/lib/private/Collaboration/Reference/File/FileReferenceProvider.php
new file mode 100644
index 00000000000..3cb174d9607
--- /dev/null
+++ b/lib/private/Collaboration/Reference/File/FileReferenceProvider.php
@@ -0,0 +1,161 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Collaboration\Reference\File;
+
+use OC\User\NoUserException;
+use OCP\Collaboration\Reference\ADiscoverableReferenceProvider;
+use OCP\Collaboration\Reference\IReference;
+use OCP\Collaboration\Reference\Reference;
+use OCP\Files\IMimeTypeDetector;
+use OCP\Files\InvalidPathException;
+use OCP\Files\IRootFolder;
+use OCP\Files\NotFoundException;
+use OCP\Files\NotPermittedException;
+use OCP\IL10N;
+use OCP\IPreview;
+use OCP\IURLGenerator;
+use OCP\IUserSession;
+use OCP\L10N\IFactory;
+
+class FileReferenceProvider extends ADiscoverableReferenceProvider {
+ private ?string $userId;
+ private IL10N $l10n;
+
+ public function __construct(
+ private IURLGenerator $urlGenerator,
+ private IRootFolder $rootFolder,
+ IUserSession $userSession,
+ private IMimeTypeDetector $mimeTypeDetector,
+ private IPreview $previewManager,
+ IFactory $l10n,
+ ) {
+ $this->userId = $userSession->getUser()?->getUID();
+ $this->l10n = $l10n->get('files');
+ }
+
+ public function matchReference(string $referenceText): bool {
+ return $this->getFilesAppLinkId($referenceText) !== null;
+ }
+
+ private function getFilesAppLinkId(string $referenceText): ?int {
+ $start = $this->urlGenerator->getAbsoluteURL('/apps/files/');
+ $startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/files/');
+
+ $fileId = null;
+
+ if (mb_strpos($referenceText, $start) === 0) {
+ $parts = parse_url($referenceText);
+ parse_str($parts['query'] ?? '', $query);
+ $fileId = isset($query['fileid']) ? (int)$query['fileid'] : $fileId;
+ $fileId = isset($query['openfile']) ? (int)$query['openfile'] : $fileId;
+ }
+
+ if (mb_strpos($referenceText, $startIndex) === 0) {
+ $parts = parse_url($referenceText);
+ parse_str($parts['query'] ?? '', $query);
+ $fileId = isset($query['fileid']) ? (int)$query['fileid'] : $fileId;
+ $fileId = isset($query['openfile']) ? (int)$query['openfile'] : $fileId;
+ }
+
+ if (mb_strpos($referenceText, $this->urlGenerator->getAbsoluteURL('/index.php/f/')) === 0) {
+ $fileId = str_replace($this->urlGenerator->getAbsoluteURL('/index.php/f/'), '', $referenceText);
+ }
+
+ if (mb_strpos($referenceText, $this->urlGenerator->getAbsoluteURL('/f/')) === 0) {
+ $fileId = str_replace($this->urlGenerator->getAbsoluteURL('/f/'), '', $referenceText);
+ }
+
+ return $fileId !== null ? (int)$fileId : null;
+ }
+
+ public function resolveReference(string $referenceText): ?IReference {
+ if ($this->matchReference($referenceText)) {
+ $reference = new Reference($referenceText);
+ try {
+ $this->fetchReference($reference);
+ } catch (NotFoundException $e) {
+ $reference->setRichObject('file', null);
+ $reference->setAccessible(false);
+ }
+ return $reference;
+ }
+
+ return null;
+ }
+
+ /**
+ * @throws NotFoundException
+ */
+ private function fetchReference(Reference $reference): void {
+ if ($this->userId === null) {
+ throw new NotFoundException();
+ }
+
+ $fileId = $this->getFilesAppLinkId($reference->getId());
+ if ($fileId === null) {
+ throw new NotFoundException();
+ }
+
+ try {
+ $userFolder = $this->rootFolder->getUserFolder($this->userId);
+ $file = $userFolder->getFirstNodeById($fileId);
+
+ if (!$file) {
+ throw new NotFoundException();
+ }
+
+ $reference->setTitle($file->getName());
+ $reference->setDescription($file->getMimetype());
+ $reference->setUrl($this->urlGenerator->getAbsoluteURL('/index.php/f/' . $fileId));
+ if ($this->previewManager->isMimeSupported($file->getMimeType())) {
+ $reference->setImageUrl($this->urlGenerator->linkToRouteAbsolute('core.Preview.getPreviewByFileId', ['x' => 1600, 'y' => 630, 'fileId' => $fileId]));
+ } else {
+ $fileTypeIconUrl = $this->mimeTypeDetector->mimeTypeIcon($file->getMimeType());
+ $reference->setImageUrl($fileTypeIconUrl);
+ }
+
+ $reference->setRichObject('file', [
+ 'id' => $file->getId(),
+ 'name' => $file->getName(),
+ 'size' => $file->getSize(),
+ 'path' => $userFolder->getRelativePath($file->getPath()),
+ 'link' => $reference->getUrl(),
+ 'mimetype' => $file->getMimetype(),
+ 'mtime' => $file->getMTime(),
+ 'preview-available' => $this->previewManager->isAvailable($file)
+ ]);
+ } catch (InvalidPathException|NotFoundException|NotPermittedException|NoUserException $e) {
+ throw new NotFoundException();
+ }
+ }
+
+ public function getCachePrefix(string $referenceId): string {
+ return (string)$this->getFilesAppLinkId($referenceId);
+ }
+
+ public function getCacheKey(string $referenceId): ?string {
+ return $this->userId ?? '';
+ }
+
+ public function getId(): string {
+ return 'files';
+ }
+
+ public function getTitle(): string {
+ return $this->l10n->t('Files');
+ }
+
+ public function getOrder(): int {
+ return 0;
+ }
+
+ public function getIconUrl(): string {
+ return $this->urlGenerator->imagePath('files', 'folder.svg');
+ }
+}
diff --git a/lib/private/Collaboration/Reference/LinkReferenceProvider.php b/lib/private/Collaboration/Reference/LinkReferenceProvider.php
new file mode 100644
index 00000000000..5af23bf633d
--- /dev/null
+++ b/lib/private/Collaboration/Reference/LinkReferenceProvider.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Collaboration\Reference;
+
+use OCP\Collaboration\Reference\LinkReferenceProvider as OCPLinkReferenceProvider;
+
+/** @deprecated 29.0.0 Use OCP\Collaboration\Reference\LinkReferenceProvider instead */
+class LinkReferenceProvider extends OCPLinkReferenceProvider {
+}
diff --git a/lib/private/Collaboration/Reference/ReferenceManager.php b/lib/private/Collaboration/Reference/ReferenceManager.php
new file mode 100644
index 00000000000..9287b66b2a2
--- /dev/null
+++ b/lib/private/Collaboration/Reference/ReferenceManager.php
@@ -0,0 +1,262 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Collaboration\Reference;
+
+use OC\AppFramework\Bootstrap\Coordinator;
+use OC\Collaboration\Reference\File\FileReferenceProvider;
+use OCP\Collaboration\Reference\IDiscoverableReferenceProvider;
+use OCP\Collaboration\Reference\IPublicReferenceProvider;
+use OCP\Collaboration\Reference\IReference;
+use OCP\Collaboration\Reference\IReferenceManager;
+use OCP\Collaboration\Reference\IReferenceProvider;
+use OCP\Collaboration\Reference\Reference;
+use OCP\ICache;
+use OCP\ICacheFactory;
+use OCP\IConfig;
+use OCP\IURLGenerator;
+use OCP\IUserSession;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use Throwable;
+
+class ReferenceManager implements IReferenceManager {
+ public const CACHE_TTL = 3600;
+
+ /** @var IReferenceProvider[]|null */
+ private ?array $providers = null;
+ private ICache $cache;
+
+ public function __construct(
+ private LinkReferenceProvider $linkReferenceProvider,
+ ICacheFactory $cacheFactory,
+ private Coordinator $coordinator,
+ private ContainerInterface $container,
+ private LoggerInterface $logger,
+ private IConfig $config,
+ private IUserSession $userSession,
+ ) {
+ $this->cache = $cacheFactory->createDistributed('reference');
+ }
+
+ /**
+ * Extract a list of URLs from a text
+ *
+ * @return string[]
+ */
+ public function extractReferences(string $text): array {
+ preg_match_all(IURLGenerator::URL_REGEX, $text, $matches);
+ $references = $matches[0] ?? [];
+ return array_map(function ($reference) {
+ return trim($reference);
+ }, $references);
+ }
+
+ /**
+ * Try to get a cached reference object from a reference string
+ */
+ public function getReferenceFromCache(string $referenceId, bool $public = false, string $sharingToken = ''): ?IReference {
+ $matchedProvider = $this->getMatchedProvider($referenceId, $public);
+
+ if ($matchedProvider === null) {
+ return null;
+ }
+
+ $cacheKey = $this->getFullCacheKey($matchedProvider, $referenceId, $public, $sharingToken);
+ return $this->getReferenceByCacheKey($cacheKey);
+ }
+
+ /**
+ * Try to get a cached reference object from a full cache key
+ */
+ public function getReferenceByCacheKey(string $cacheKey): ?IReference {
+ $cached = $this->cache->get($cacheKey);
+ if ($cached) {
+ return Reference::fromCache($cached);
+ }
+
+ return null;
+ }
+
+ /**
+ * Get a reference object from a reference string with a matching provider
+ * Use a cached reference if possible
+ */
+ public function resolveReference(string $referenceId, bool $public = false, $sharingToken = ''): ?IReference {
+ $matchedProvider = $this->getMatchedProvider($referenceId, $public);
+
+ if ($matchedProvider === null) {
+ return null;
+ }
+
+ $cacheKey = $this->getFullCacheKey($matchedProvider, $referenceId, $public, $sharingToken);
+ $cached = $this->cache->get($cacheKey);
+ if ($cached) {
+ return Reference::fromCache($cached);
+ }
+
+ $reference = null;
+ if ($public && $matchedProvider instanceof IPublicReferenceProvider) {
+ $reference = $matchedProvider->resolveReferencePublic($referenceId, $sharingToken);
+ } elseif ($matchedProvider instanceof IReferenceProvider) {
+ $reference = $matchedProvider->resolveReference($referenceId);
+ }
+ if ($reference) {
+ $cachePrefix = $matchedProvider->getCachePrefix($referenceId);
+ if ($cachePrefix !== '') {
+ // If a prefix is used we set an additional key to know when we need to delete by prefix during invalidateCache()
+ $this->cache->set('hasPrefix-' . md5($cachePrefix), true, self::CACHE_TTL);
+ }
+ $this->cache->set($cacheKey, Reference::toCache($reference), self::CACHE_TTL);
+ return $reference;
+ }
+
+ return null;
+ }
+
+ /**
+ * Try to match a reference string with all the registered providers
+ * Fallback to the link reference provider (using OpenGraph)
+ *
+ * @return IReferenceProvider|IPublicReferenceProvider|null the first matching provider
+ */
+ private function getMatchedProvider(string $referenceId, bool $public): null|IReferenceProvider|IPublicReferenceProvider {
+ $matchedProvider = null;
+ foreach ($this->getProviders() as $provider) {
+ if ($public && !($provider instanceof IPublicReferenceProvider)) {
+ continue;
+ }
+ $matchedProvider = $provider->matchReference($referenceId) ? $provider : null;
+ if ($matchedProvider !== null) {
+ break;
+ }
+ }
+
+ if ($matchedProvider === null && $this->linkReferenceProvider->matchReference($referenceId)) {
+ $matchedProvider = $this->linkReferenceProvider;
+ }
+
+ return $matchedProvider;
+ }
+
+ /**
+ * Get a hashed full cache key from a key and prefix given by a provider
+ */
+ private function getFullCacheKey(IReferenceProvider $provider, string $referenceId, bool $public, string $sharingToken): string {
+ if ($public && !($provider instanceof IPublicReferenceProvider)) {
+ throw new \RuntimeException('Provider doesn\'t support public lookups');
+ }
+ $cacheKey = $public
+ ? $provider->getCacheKeyPublic($referenceId, $sharingToken)
+ : $provider->getCacheKey($referenceId);
+ return md5($provider->getCachePrefix($referenceId)) . (
+ $cacheKey !== null ? ('-' . md5($cacheKey)) : ''
+ );
+ }
+
+ /**
+ * Remove a specific cache entry from its key+prefix
+ */
+ public function invalidateCache(string $cachePrefix, ?string $cacheKey = null): void {
+ if ($cacheKey === null) {
+ // clear might be a heavy operation, so we only do it if there have actually been keys set
+ if ($this->cache->remove('hasPrefix-' . md5($cachePrefix))) {
+ $this->cache->clear(md5($cachePrefix));
+ }
+
+ return;
+ }
+
+ $this->cache->remove(md5($cachePrefix) . '-' . md5($cacheKey));
+ }
+
+ /**
+ * @return IReferenceProvider[]
+ */
+ public function getProviders(): array {
+ if ($this->providers === null) {
+ $context = $this->coordinator->getRegistrationContext();
+ if ($context === null) {
+ return [];
+ }
+
+ $this->providers = array_filter(array_map(function ($registration): ?IReferenceProvider {
+ try {
+ /** @var IReferenceProvider $provider */
+ $provider = $this->container->get($registration->getService());
+ } catch (Throwable $e) {
+ $this->logger->error('Could not load reference provider ' . $registration->getService() . ': ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ return null;
+ }
+
+ return $provider;
+ }, $context->getReferenceProviders()));
+
+ $this->providers[] = $this->container->get(FileReferenceProvider::class);
+ }
+
+ return $this->providers;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getDiscoverableProviders(): array {
+ // preserve 0 based index to avoid returning an object in data responses
+ return array_values(
+ array_filter($this->getProviders(), static function (IReferenceProvider $provider) {
+ return $provider instanceof IDiscoverableReferenceProvider;
+ })
+ );
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function touchProvider(string $userId, string $providerId, ?int $timestamp = null): bool {
+ $providers = $this->getDiscoverableProviders();
+ $matchingProviders = array_filter($providers, static function (IDiscoverableReferenceProvider $provider) use ($providerId) {
+ return $provider->getId() === $providerId;
+ });
+ if (!empty($matchingProviders)) {
+ if ($timestamp === null) {
+ $timestamp = time();
+ }
+
+ $configKey = 'provider-last-use_' . $providerId;
+ $this->config->setUserValue($userId, 'references', $configKey, (string)$timestamp);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getUserProviderTimestamps(): array {
+ $user = $this->userSession->getUser();
+ if ($user === null) {
+ return [];
+ }
+ $userId = $user->getUID();
+ $keys = $this->config->getUserKeys($userId, 'references');
+ $prefix = 'provider-last-use_';
+ $keys = array_filter($keys, static function (string $key) use ($prefix) {
+ return str_starts_with($key, $prefix);
+ });
+ $timestamps = [];
+ foreach ($keys as $key) {
+ $providerId = substr($key, strlen($prefix));
+ $timestamp = (int)$this->config->getUserValue($userId, 'references', $key);
+ $timestamps[$providerId] = $timestamp;
+ }
+ return $timestamps;
+ }
+}
diff --git a/lib/private/Collaboration/Reference/RenderReferenceEventListener.php b/lib/private/Collaboration/Reference/RenderReferenceEventListener.php
new file mode 100644
index 00000000000..9e6192314cb
--- /dev/null
+++ b/lib/private/Collaboration/Reference/RenderReferenceEventListener.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Collaboration\Reference;
+
+use OCP\Collaboration\Reference\IDiscoverableReferenceProvider;
+use OCP\Collaboration\Reference\IReferenceManager;
+use OCP\Collaboration\Reference\RenderReferenceEvent;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\EventDispatcher\IEventListener;
+use OCP\IInitialStateService;
+
+/** @template-implements IEventListener<Event|RenderReferenceEvent> */
+class RenderReferenceEventListener implements IEventListener {
+ public function __construct(
+ private IReferenceManager $manager,
+ private IInitialStateService $initialStateService,
+ ) {
+ }
+
+ public static function register(IEventDispatcher $eventDispatcher): void {
+ $eventDispatcher->addServiceListener(RenderReferenceEvent::class, RenderReferenceEventListener::class);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function handle(Event $event): void {
+ if (!($event instanceof RenderReferenceEvent)) {
+ return;
+ }
+
+ $providers = $this->manager->getDiscoverableProviders();
+ $jsonProviders = array_map(static function (IDiscoverableReferenceProvider $provider) {
+ return $provider->jsonSerialize();
+ }, $providers);
+ $this->initialStateService->provideInitialState('core', 'reference-provider-list', $jsonProviders);
+
+ $timestamps = $this->manager->getUserProviderTimestamps();
+ $this->initialStateService->provideInitialState('core', 'reference-provider-timestamps', $timestamps);
+ }
+}
diff --git a/lib/private/Collaboration/Resources/Collection.php b/lib/private/Collaboration/Resources/Collection.php
new file mode 100644
index 00000000000..2481a3e9a09
--- /dev/null
+++ b/lib/private/Collaboration/Resources/Collection.php
@@ -0,0 +1,180 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Resources;
+
+use Doctrine\DBAL\Exception\ConstraintViolationException;
+use OCP\Collaboration\Resources\ICollection;
+use OCP\Collaboration\Resources\IManager;
+use OCP\Collaboration\Resources\IResource;
+use OCP\Collaboration\Resources\ResourceException;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+use OCP\IUser;
+
+class Collection implements ICollection {
+ /** @var IResource[] */
+ protected array $resources = [];
+
+ public function __construct(
+ /** @var Manager $manager */
+ protected IManager $manager,
+ protected IDBConnection $connection,
+ protected int $id,
+ protected string $name,
+ protected ?IUser $userForAccess = null,
+ protected ?bool $access = null,
+ ) {
+ }
+
+ /**
+ * @since 16.0.0
+ */
+ public function getId(): int {
+ return $this->id;
+ }
+
+ /**
+ * @since 16.0.0
+ */
+ public function getName(): string {
+ return $this->name;
+ }
+
+ /**
+ * @since 16.0.0
+ */
+ public function setName(string $name): void {
+ $query = $this->connection->getQueryBuilder();
+ $query->update(Manager::TABLE_COLLECTIONS)
+ ->set('name', $query->createNamedParameter($name))
+ ->where($query->expr()->eq('id', $query->createNamedParameter($this->getId(), IQueryBuilder::PARAM_INT)));
+ $query->executeStatement();
+
+ $this->name = $name;
+ }
+
+ /**
+ * @return IResource[]
+ * @since 16.0.0
+ */
+ public function getResources(): array {
+ if (empty($this->resources)) {
+ $this->resources = $this->manager->getResourcesByCollectionForUser($this, $this->userForAccess);
+ }
+
+ return $this->resources;
+ }
+
+ /**
+ * Adds a resource to a collection
+ *
+ * @throws ResourceException when the resource is already part of the collection
+ * @since 16.0.0
+ */
+ public function addResource(IResource $resource): void {
+ array_map(function (IResource $r) use ($resource) {
+ if ($this->isSameResource($r, $resource)) {
+ throw new ResourceException('Already part of the collection');
+ }
+ }, $this->getResources());
+
+ $this->resources[] = $resource;
+
+ $query = $this->connection->getQueryBuilder();
+ $query->insert(Manager::TABLE_RESOURCES)
+ ->values([
+ 'collection_id' => $query->createNamedParameter($this->id, IQueryBuilder::PARAM_INT),
+ 'resource_type' => $query->createNamedParameter($resource->getType()),
+ 'resource_id' => $query->createNamedParameter($resource->getId()),
+ ]);
+
+ try {
+ $query->execute();
+ } catch (ConstraintViolationException $e) {
+ throw new ResourceException('Already part of the collection');
+ }
+
+ $this->manager->invalidateAccessCacheForCollection($this);
+ }
+
+ /**
+ * Removes a resource from a collection
+ *
+ * @since 16.0.0
+ */
+ public function removeResource(IResource $resource): void {
+ $this->resources = array_filter($this->getResources(), function (IResource $r) use ($resource) {
+ return !$this->isSameResource($r, $resource);
+ });
+
+ $query = $this->connection->getQueryBuilder();
+ $query->delete(Manager::TABLE_RESOURCES)
+ ->where($query->expr()->eq('collection_id', $query->createNamedParameter($this->id, IQueryBuilder::PARAM_INT)))
+ ->andWhere($query->expr()->eq('resource_type', $query->createNamedParameter($resource->getType())))
+ ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resource->getId())));
+ $query->executeStatement();
+
+ if (empty($this->resources)) {
+ $this->removeCollection();
+ } else {
+ $this->manager->invalidateAccessCacheForCollection($this);
+ }
+ }
+
+ /**
+ * Can a user/guest access the collection
+ *
+ * @since 16.0.0
+ */
+ public function canAccess(?IUser $user): bool {
+ if ($user instanceof IUser) {
+ return $this->canUserAccess($user);
+ }
+ return $this->canGuestAccess();
+ }
+
+ protected function canUserAccess(IUser $user): bool {
+ if (\is_bool($this->access) && $this->userForAccess instanceof IUser && $user->getUID() === $this->userForAccess->getUID()) {
+ return $this->access;
+ }
+
+ $access = $this->manager->canAccessCollection($this, $user);
+ if ($this->userForAccess instanceof IUser && $user->getUID() === $this->userForAccess->getUID()) {
+ $this->access = $access;
+ }
+ return $access;
+ }
+
+ protected function canGuestAccess(): bool {
+ if (\is_bool($this->access) && !$this->userForAccess instanceof IUser) {
+ return $this->access;
+ }
+
+ $access = $this->manager->canAccessCollection($this, null);
+ if (!$this->userForAccess instanceof IUser) {
+ $this->access = $access;
+ }
+ return $access;
+ }
+
+ protected function isSameResource(IResource $resource1, IResource $resource2): bool {
+ return $resource1->getType() === $resource2->getType()
+ && $resource1->getId() === $resource2->getId();
+ }
+
+ protected function removeCollection(): void {
+ $query = $this->connection->getQueryBuilder();
+ $query->delete(Manager::TABLE_COLLECTIONS)
+ ->where($query->expr()->eq('id', $query->createNamedParameter($this->id, IQueryBuilder::PARAM_INT)));
+ $query->executeStatement();
+
+ $this->manager->invalidateAccessCacheForCollection($this);
+ $this->id = 0;
+ }
+}
diff --git a/lib/private/Collaboration/Resources/Listener.php b/lib/private/Collaboration/Resources/Listener.php
new file mode 100644
index 00000000000..dfdde24d78e
--- /dev/null
+++ b/lib/private/Collaboration/Resources/Listener.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Resources;
+
+use OCP\Collaboration\Resources\IManager;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Group\Events\BeforeGroupDeletedEvent;
+use OCP\Group\Events\UserAddedEvent;
+use OCP\Group\Events\UserRemovedEvent;
+use OCP\User\Events\UserDeletedEvent;
+
+class Listener {
+ public static function register(IEventDispatcher $eventDispatcher): void {
+ $eventDispatcher->addListener(UserAddedEvent::class, function (UserAddedEvent $event) {
+ $user = $event->getUser();
+ /** @var IManager $resourceManager */
+ $resourceManager = \OCP\Server::get(IManager::class);
+
+ $resourceManager->invalidateAccessCacheForUser($user);
+ });
+ $eventDispatcher->addListener(UserRemovedEvent::class, function (UserRemovedEvent $event) {
+ $user = $event->getUser();
+ /** @var IManager $resourceManager */
+ $resourceManager = \OCP\Server::get(IManager::class);
+
+ $resourceManager->invalidateAccessCacheForUser($user);
+ });
+
+ $eventDispatcher->addListener(UserDeletedEvent::class, function (UserDeletedEvent $event) {
+ $user = $event->getUser();
+ /** @var IManager $resourceManager */
+ $resourceManager = \OCP\Server::get(IManager::class);
+
+ $resourceManager->invalidateAccessCacheForUser($user);
+ });
+
+ $eventDispatcher->addListener(BeforeGroupDeletedEvent::class, function (BeforeGroupDeletedEvent $event) {
+ $group = $event->getGroup();
+ /** @var IManager $resourceManager */
+ $resourceManager = \OCP\Server::get(IManager::class);
+
+ foreach ($group->getUsers() as $user) {
+ $resourceManager->invalidateAccessCacheForUser($user);
+ }
+ });
+ }
+}
diff --git a/lib/private/Collaboration/Resources/Manager.php b/lib/private/Collaboration/Resources/Manager.php
new file mode 100644
index 00000000000..8d1e4b13287
--- /dev/null
+++ b/lib/private/Collaboration/Resources/Manager.php
@@ -0,0 +1,467 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Resources;
+
+use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
+use OCP\Collaboration\Resources\CollectionException;
+use OCP\Collaboration\Resources\ICollection;
+use OCP\Collaboration\Resources\IManager;
+use OCP\Collaboration\Resources\IProvider;
+use OCP\Collaboration\Resources\IProviderManager;
+use OCP\Collaboration\Resources\IResource;
+use OCP\Collaboration\Resources\ResourceException;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\IDBConnection;
+use OCP\IUser;
+use Psr\Log\LoggerInterface;
+
+class Manager implements IManager {
+ public const TABLE_COLLECTIONS = 'collres_collections';
+ public const TABLE_RESOURCES = 'collres_resources';
+ public const TABLE_ACCESS_CACHE = 'collres_accesscache';
+
+ /** @var string[] */
+ protected array $providers = [];
+
+ public function __construct(
+ protected IDBConnection $connection,
+ protected IProviderManager $providerManager,
+ protected LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * @throws CollectionException when the collection could not be found
+ * @since 16.0.0
+ */
+ public function getCollection(int $id): ICollection {
+ $query = $this->connection->getQueryBuilder();
+ $query->select('*')
+ ->from(self::TABLE_COLLECTIONS)
+ ->where($query->expr()->eq('id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
+ $result = $query->execute();
+ $row = $result->fetch();
+ $result->closeCursor();
+
+ if (!$row) {
+ throw new CollectionException('Collection not found');
+ }
+
+ return new Collection($this, $this->connection, (int)$row['id'], (string)$row['name']);
+ }
+
+ /**
+ * @throws CollectionException when the collection could not be found
+ * @since 16.0.0
+ */
+ public function getCollectionForUser(int $id, ?IUser $user): ICollection {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user instanceof IUser ? $user->getUID() : '';
+
+ $query->select('*')
+ ->from(self::TABLE_COLLECTIONS, 'c')
+ ->leftJoin(
+ 'c', self::TABLE_ACCESS_CACHE, 'a',
+ $query->expr()->andX(
+ $query->expr()->eq('c.id', 'a.collection_id'),
+ $query->expr()->eq('a.user_id', $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
+ )
+ )
+ ->where($query->expr()->eq('c.id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
+ $result = $query->execute();
+ $row = $result->fetch();
+ $result->closeCursor();
+
+ if (!$row) {
+ throw new CollectionException('Collection not found');
+ }
+
+ $access = $row['access'] === null ? null : (bool)$row['access'];
+ if ($user instanceof IUser) {
+ return new Collection($this, $this->connection, (int)$row['id'], (string)$row['name'], $user, $access);
+ }
+
+ return new Collection($this, $this->connection, (int)$row['id'], (string)$row['name'], $user, $access);
+ }
+
+ /**
+ * @return ICollection[]
+ * @since 16.0.0
+ */
+ public function searchCollections(IUser $user, string $filter, int $limit = 50, int $start = 0): array {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user->getUID();
+
+ $query->select('c.*', 'a.access')
+ ->from(self::TABLE_COLLECTIONS, 'c')
+ ->leftJoin(
+ 'c', self::TABLE_ACCESS_CACHE, 'a',
+ $query->expr()->andX(
+ $query->expr()->eq('c.id', 'a.collection_id'),
+ $query->expr()->eq('a.user_id', $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
+ )
+ )
+ ->where($query->expr()->eq('a.access', $query->createNamedParameter(1, IQueryBuilder::PARAM_INT)))
+ ->orderBy('c.id')
+ ->setMaxResults($limit)
+ ->setFirstResult($start);
+
+ if ($filter !== '') {
+ $query->andWhere($query->expr()->iLike('c.name', $query->createNamedParameter('%' . $this->connection->escapeLikeParameter($filter) . '%')));
+ }
+
+ $result = $query->execute();
+ $collections = [];
+
+ $foundResults = 0;
+ while ($row = $result->fetch()) {
+ $foundResults++;
+ $access = $row['access'] === null ? null : (bool)$row['access'];
+ $collection = new Collection($this, $this->connection, (int)$row['id'], (string)$row['name'], $user, $access);
+ if ($collection->canAccess($user)) {
+ $collections[] = $collection;
+ }
+ }
+ $result->closeCursor();
+
+ if (empty($collections) && $foundResults === $limit) {
+ return $this->searchCollections($user, $filter, $limit, $start + $limit);
+ }
+
+ return $collections;
+ }
+
+ /**
+ * @since 16.0.0
+ */
+ public function newCollection(string $name): ICollection {
+ $query = $this->connection->getQueryBuilder();
+ $query->insert(self::TABLE_COLLECTIONS)
+ ->values([
+ 'name' => $query->createNamedParameter($name),
+ ]);
+ $query->execute();
+
+ return new Collection($this, $this->connection, $query->getLastInsertId(), $name);
+ }
+
+ /**
+ * @since 16.0.0
+ */
+ public function createResource(string $type, string $id): IResource {
+ return new Resource($this, $this->connection, $type, $id);
+ }
+
+ /**
+ * @throws ResourceException
+ * @since 16.0.0
+ */
+ public function getResourceForUser(string $type, string $id, ?IUser $user): IResource {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user instanceof IUser ? $user->getUID() : '';
+
+ $query->select('r.*', 'a.access')
+ ->from(self::TABLE_RESOURCES, 'r')
+ ->leftJoin(
+ 'r', self::TABLE_ACCESS_CACHE, 'a',
+ $query->expr()->andX(
+ $query->expr()->eq('r.resource_id', 'a.resource_id'),
+ $query->expr()->eq('r.resource_type', 'a.resource_type'),
+ $query->expr()->eq('a.user_id', $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
+ )
+ )
+ ->where($query->expr()->eq('r.resource_type', $query->createNamedParameter($type, IQueryBuilder::PARAM_STR)))
+ ->andWhere($query->expr()->eq('r.resource_id', $query->createNamedParameter($id, IQueryBuilder::PARAM_STR)));
+ $result = $query->execute();
+ $row = $result->fetch();
+ $result->closeCursor();
+
+ if (!$row) {
+ throw new ResourceException('Resource not found');
+ }
+
+ $access = $row['access'] === null ? null : (bool)$row['access'];
+ if ($user instanceof IUser) {
+ return new Resource($this, $this->connection, $type, $id, $user, $access);
+ }
+
+ return new Resource($this, $this->connection, $type, $id, null, $access);
+ }
+
+ /**
+ * @return IResource[]
+ * @since 16.0.0
+ */
+ public function getResourcesByCollectionForUser(ICollection $collection, ?IUser $user): array {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user instanceof IUser ? $user->getUID() : '';
+
+ $query->select('r.*', 'a.access')
+ ->from(self::TABLE_RESOURCES, 'r')
+ ->leftJoin(
+ 'r', self::TABLE_ACCESS_CACHE, 'a',
+ $query->expr()->andX(
+ $query->expr()->eq('r.resource_id', 'a.resource_id'),
+ $query->expr()->eq('r.resource_type', 'a.resource_type'),
+ $query->expr()->eq('a.user_id', $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
+ )
+ )
+ ->where($query->expr()->eq('r.collection_id', $query->createNamedParameter($collection->getId(), IQueryBuilder::PARAM_INT)));
+
+ $resources = [];
+ $result = $query->execute();
+ while ($row = $result->fetch()) {
+ $access = $row['access'] === null ? null : (bool)$row['access'];
+ $resources[] = new Resource($this, $this->connection, $row['resource_type'], $row['resource_id'], $user, $access);
+ }
+ $result->closeCursor();
+
+ return $resources;
+ }
+
+ /**
+ * Get the rich object data of a resource
+ *
+ * @since 16.0.0
+ */
+ public function getResourceRichObject(IResource $resource): array {
+ foreach ($this->providerManager->getResourceProviders() as $provider) {
+ if ($provider->getType() === $resource->getType()) {
+ try {
+ return $provider->getResourceRichObject($resource);
+ } catch (ResourceException $e) {
+ }
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * Can a user/guest access the collection
+ *
+ * @since 16.0.0
+ */
+ public function canAccessResource(IResource $resource, ?IUser $user): bool {
+ $access = $this->checkAccessCacheForUserByResource($resource, $user);
+ if (\is_bool($access)) {
+ return $access;
+ }
+
+ $access = false;
+ foreach ($this->providerManager->getResourceProviders() as $provider) {
+ if ($provider->getType() === $resource->getType()) {
+ try {
+ if ($provider->canAccessResource($resource, $user)) {
+ $access = true;
+ break;
+ }
+ } catch (ResourceException $e) {
+ }
+ }
+ }
+
+ $this->cacheAccessForResource($resource, $user, $access);
+ return $access;
+ }
+
+ /**
+ * Can a user/guest access the collection
+ *
+ * @since 16.0.0
+ */
+ public function canAccessCollection(ICollection $collection, ?IUser $user): bool {
+ $access = $this->checkAccessCacheForUserByCollection($collection, $user);
+ if (\is_bool($access)) {
+ return $access;
+ }
+
+ $access = null;
+ // Access is granted when a user can access all resources
+ foreach ($collection->getResources() as $resource) {
+ if (!$resource->canAccess($user)) {
+ $access = false;
+ break;
+ }
+
+ $access = true;
+ }
+
+ $this->cacheAccessForCollection($collection, $user, $access);
+ return $access;
+ }
+
+ protected function checkAccessCacheForUserByResource(IResource $resource, ?IUser $user): ?bool {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user instanceof IUser ? $user->getUID() : '';
+
+ $query->select('access')
+ ->from(self::TABLE_ACCESS_CACHE)
+ ->where($query->expr()->eq('resource_id', $query->createNamedParameter($resource->getId(), IQueryBuilder::PARAM_STR)))
+ ->andWhere($query->expr()->eq('resource_type', $query->createNamedParameter($resource->getType(), IQueryBuilder::PARAM_STR)))
+ ->andWhere($query->expr()->eq('user_id', $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR)))
+ ->setMaxResults(1);
+
+ $hasAccess = null;
+ $result = $query->execute();
+ if ($row = $result->fetch()) {
+ $hasAccess = (bool)$row['access'];
+ }
+ $result->closeCursor();
+
+ return $hasAccess;
+ }
+
+ protected function checkAccessCacheForUserByCollection(ICollection $collection, ?IUser $user): ?bool {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user instanceof IUser ? $user->getUID() : '';
+
+ $query->select('access')
+ ->from(self::TABLE_ACCESS_CACHE)
+ ->where($query->expr()->eq('collection_id', $query->createNamedParameter($collection->getId(), IQueryBuilder::PARAM_INT)))
+ ->andWhere($query->expr()->eq('user_id', $query->createNamedParameter($userId, IQueryBuilder::PARAM_STR)))
+ ->setMaxResults(1);
+
+ $hasAccess = null;
+ $result = $query->execute();
+ if ($row = $result->fetch()) {
+ $hasAccess = (bool)$row['access'];
+ }
+ $result->closeCursor();
+
+ return $hasAccess;
+ }
+
+ public function cacheAccessForResource(IResource $resource, ?IUser $user, bool $access): void {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user instanceof IUser ? $user->getUID() : '';
+
+ $query->insert(self::TABLE_ACCESS_CACHE)
+ ->values([
+ 'user_id' => $query->createNamedParameter($userId),
+ 'resource_id' => $query->createNamedParameter($resource->getId()),
+ 'resource_type' => $query->createNamedParameter($resource->getType()),
+ 'access' => $query->createNamedParameter($access, IQueryBuilder::PARAM_BOOL),
+ ]);
+ try {
+ $query->execute();
+ } catch (UniqueConstraintViolationException $e) {
+ }
+ }
+
+ public function cacheAccessForCollection(ICollection $collection, ?IUser $user, bool $access): void {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user instanceof IUser ? $user->getUID() : '';
+
+ $query->insert(self::TABLE_ACCESS_CACHE)
+ ->values([
+ 'user_id' => $query->createNamedParameter($userId),
+ 'collection_id' => $query->createNamedParameter($collection->getId()),
+ 'access' => $query->createNamedParameter($access, IQueryBuilder::PARAM_BOOL),
+ ]);
+ try {
+ $query->execute();
+ } catch (UniqueConstraintViolationException $e) {
+ }
+ }
+
+ public function invalidateAccessCacheForUser(?IUser $user): void {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user instanceof IUser ? $user->getUID() : '';
+
+ $query->delete(self::TABLE_ACCESS_CACHE)
+ ->where($query->expr()->eq('user_id', $query->createNamedParameter($userId)));
+ $query->execute();
+ }
+
+ public function invalidateAccessCacheForResource(IResource $resource): void {
+ $query = $this->connection->getQueryBuilder();
+
+ $query->delete(self::TABLE_ACCESS_CACHE)
+ ->where($query->expr()->eq('resource_id', $query->createNamedParameter($resource->getId())))
+ ->andWhere($query->expr()->eq('resource_type', $query->createNamedParameter($resource->getType(), IQueryBuilder::PARAM_STR)));
+ $query->execute();
+
+ foreach ($resource->getCollections() as $collection) {
+ $this->invalidateAccessCacheForCollection($collection);
+ }
+ }
+
+ public function invalidateAccessCacheForAllCollections(): void {
+ $query = $this->connection->getQueryBuilder();
+
+ $query->delete(self::TABLE_ACCESS_CACHE)
+ ->where($query->expr()->neq('collection_id', $query->createNamedParameter(0)));
+ $query->execute();
+ }
+
+ public function invalidateAccessCacheForCollection(ICollection $collection): void {
+ $query = $this->connection->getQueryBuilder();
+
+ $query->delete(self::TABLE_ACCESS_CACHE)
+ ->where($query->expr()->eq('collection_id', $query->createNamedParameter($collection->getId())));
+ $query->execute();
+ }
+
+ public function invalidateAccessCacheForProvider(IProvider $provider): void {
+ $query = $this->connection->getQueryBuilder();
+
+ $query->delete(self::TABLE_ACCESS_CACHE)
+ ->where($query->expr()->eq('resource_type', $query->createNamedParameter($provider->getType(), IQueryBuilder::PARAM_STR)));
+ $query->execute();
+ }
+
+ public function invalidateAccessCacheForResourceByUser(IResource $resource, ?IUser $user): void {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user instanceof IUser ? $user->getUID() : '';
+
+ $query->delete(self::TABLE_ACCESS_CACHE)
+ ->where($query->expr()->eq('resource_id', $query->createNamedParameter($resource->getId())))
+ ->andWhere($query->expr()->eq('user_id', $query->createNamedParameter($userId)));
+ $query->execute();
+
+ foreach ($resource->getCollections() as $collection) {
+ $this->invalidateAccessCacheForCollectionByUser($collection, $user);
+ }
+ }
+
+ protected function invalidateAccessCacheForCollectionByUser(ICollection $collection, ?IUser $user): void {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user instanceof IUser ? $user->getUID() : '';
+
+ $query->delete(self::TABLE_ACCESS_CACHE)
+ ->where($query->expr()->eq('collection_id', $query->createNamedParameter($collection->getId())))
+ ->andWhere($query->expr()->eq('user_id', $query->createNamedParameter($userId)));
+ $query->execute();
+ }
+
+ public function invalidateAccessCacheForProviderByUser(IProvider $provider, ?IUser $user): void {
+ $query = $this->connection->getQueryBuilder();
+ $userId = $user instanceof IUser ? $user->getUID() : '';
+
+ $query->delete(self::TABLE_ACCESS_CACHE)
+ ->where($query->expr()->eq('resource_type', $query->createNamedParameter($provider->getType(), IQueryBuilder::PARAM_STR)))
+ ->andWhere($query->expr()->eq('user_id', $query->createNamedParameter($userId)));
+ $query->execute();
+ }
+
+ public function registerResourceProvider(string $provider): void {
+ $this->logger->debug('\OC\Collaboration\Resources\Manager::registerResourceProvider is deprecated', ['provider' => $provider]);
+ $this->providerManager->registerResourceProvider($provider);
+ }
+
+ /**
+ * Get the resource type of the provider
+ *
+ * @since 16.0.0
+ */
+ public function getType(): string {
+ return '';
+ }
+}
diff --git a/lib/private/Collaboration/Resources/ProviderManager.php b/lib/private/Collaboration/Resources/ProviderManager.php
new file mode 100644
index 00000000000..0ce4ae7155a
--- /dev/null
+++ b/lib/private/Collaboration/Resources/ProviderManager.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Resources;
+
+use OCP\AppFramework\QueryException;
+use OCP\Collaboration\Resources\IProvider;
+use OCP\Collaboration\Resources\IProviderManager;
+use OCP\IServerContainer;
+use Psr\Log\LoggerInterface;
+
+class ProviderManager implements IProviderManager {
+ /** @var string[] */
+ protected array $providers = [];
+
+ /** @var IProvider[] */
+ protected array $providerInstances = [];
+
+ public function __construct(
+ protected IServerContainer $serverContainer,
+ protected LoggerInterface $logger,
+ ) {
+ }
+
+ public function getResourceProviders(): array {
+ if ($this->providers !== []) {
+ foreach ($this->providers as $provider) {
+ try {
+ $this->providerInstances[] = $this->serverContainer->query($provider);
+ } catch (QueryException $e) {
+ $this->logger->error("Could not query resource provider $provider: " . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ }
+ }
+ $this->providers = [];
+ }
+
+ return $this->providerInstances;
+ }
+
+ public function registerResourceProvider(string $provider): void {
+ $this->providers[] = $provider;
+ }
+}
diff --git a/lib/private/Collaboration/Resources/Resource.php b/lib/private/Collaboration/Resources/Resource.php
new file mode 100644
index 00000000000..19da3da7e7d
--- /dev/null
+++ b/lib/private/Collaboration/Resources/Resource.php
@@ -0,0 +1,113 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Collaboration\Resources;
+
+use OCP\Collaboration\Resources\ICollection;
+use OCP\Collaboration\Resources\IManager;
+use OCP\Collaboration\Resources\IResource;
+use OCP\IDBConnection;
+use OCP\IUser;
+
+class Resource implements IResource {
+ protected ?array $data = null;
+
+ public function __construct(
+ protected IManager $manager,
+ protected IDBConnection $connection,
+ protected string $type,
+ protected string $id,
+ protected ?IUser $userForAccess = null,
+ protected ?bool $access = null,
+ ) {
+ }
+
+ /**
+ * @since 16.0.0
+ */
+ public function getType(): string {
+ return $this->type;
+ }
+
+ /**
+ * @since 16.0.0
+ */
+ public function getId(): string {
+ return $this->id;
+ }
+
+ /**
+ * @since 16.0.0
+ */
+ public function getRichObject(): array {
+ if ($this->data === null) {
+ $this->data = $this->manager->getResourceRichObject($this);
+ }
+
+ return $this->data;
+ }
+
+ /**
+ * Can a user/guest access the resource
+ *
+ * @since 16.0.0
+ */
+ public function canAccess(?IUser $user): bool {
+ if ($user instanceof IUser) {
+ return $this->canUserAccess($user);
+ }
+ return $this->canGuestAccess();
+ }
+
+ protected function canUserAccess(IUser $user): bool {
+ if (\is_bool($this->access) && $this->userForAccess instanceof IUser && $user->getUID() === $this->userForAccess->getUID()) {
+ return $this->access;
+ }
+
+ $access = $this->manager->canAccessResource($this, $user);
+ if ($this->userForAccess instanceof IUser && $user->getUID() === $this->userForAccess->getUID()) {
+ $this->access = $access;
+ }
+ return $access;
+ }
+
+ protected function canGuestAccess(): bool {
+ if (\is_bool($this->access) && !$this->userForAccess instanceof IUser) {
+ return $this->access;
+ }
+
+ $access = $this->manager->canAccessResource($this, null);
+ if (!$this->userForAccess instanceof IUser) {
+ $this->access = $access;
+ }
+ return $access;
+ }
+
+ /**
+ * @return ICollection[]
+ * @since 16.0.0
+ */
+ public function getCollections(): array {
+ $collections = [];
+
+ $query = $this->connection->getQueryBuilder();
+
+ $query->select('collection_id')
+ ->from('collres_resources')
+ ->where($query->expr()->eq('resource_type', $query->createNamedParameter($this->getType())))
+ ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($this->getId())));
+
+ $result = $query->execute();
+ while ($row = $result->fetch()) {
+ $collections[] = $this->manager->getCollection((int)$row['collection_id']);
+ }
+ $result->closeCursor();
+
+ return $collections;
+ }
+}