diff options
Diffstat (limited to 'lib/private/Collaboration/Collaborators')
8 files changed, 1203 insertions, 0 deletions
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()]); + } + } + } +} |