diff options
Diffstat (limited to 'lib/private/Collaboration')
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; + } +} |