diff options
Diffstat (limited to 'apps/files_sharing/lib/External')
-rw-r--r-- | apps/files_sharing/lib/External/Cache.php | 51 | ||||
-rw-r--r-- | apps/files_sharing/lib/External/Manager.php | 838 | ||||
-rw-r--r-- | apps/files_sharing/lib/External/Mount.php | 63 | ||||
-rw-r--r-- | apps/files_sharing/lib/External/MountProvider.php | 68 | ||||
-rw-r--r-- | apps/files_sharing/lib/External/Scanner.php | 55 | ||||
-rw-r--r-- | apps/files_sharing/lib/External/Storage.php | 429 | ||||
-rw-r--r-- | apps/files_sharing/lib/External/Watcher.php | 19 |
7 files changed, 1523 insertions, 0 deletions
diff --git a/apps/files_sharing/lib/External/Cache.php b/apps/files_sharing/lib/External/Cache.php new file mode 100644 index 00000000000..027f682d818 --- /dev/null +++ b/apps/files_sharing/lib/External/Cache.php @@ -0,0 +1,51 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Sharing\External; + +use OCP\Federation\ICloudId; + +class Cache extends \OC\Files\Cache\Cache { + private $remote; + private $remoteUser; + + /** + * @param Storage $storage + * @param ICloudId $cloudId + */ + public function __construct( + private $storage, + private ICloudId $cloudId, + ) { + [, $remote] = explode('://', $this->cloudId->getRemote(), 2); + $this->remote = $remote; + $this->remoteUser = $this->cloudId->getUser(); + parent::__construct($this->storage); + } + + public function get($file) { + $result = parent::get($file); + if (!$result) { + return false; + } + $result['displayname_owner'] = $this->cloudId->getDisplayId(); + if (!$file || $file === '') { + $result['is_share_mount_point'] = true; + $mountPoint = rtrim($this->storage->getMountPoint()); + $result['name'] = basename($mountPoint); + } + return $result; + } + + public function getFolderContentsById($fileId) { + $results = parent::getFolderContentsById($fileId); + foreach ($results as &$file) { + $file['displayname_owner'] = $this->cloudId->getDisplayId(); + } + return $results; + } +} diff --git a/apps/files_sharing/lib/External/Manager.php b/apps/files_sharing/lib/External/Manager.php new file mode 100644 index 00000000000..ff4781eba0f --- /dev/null +++ b/apps/files_sharing/lib/External/Manager.php @@ -0,0 +1,838 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\Files_Sharing\External; + +use Doctrine\DBAL\Driver\Exception; +use OC\Files\Filesystem; +use OCA\FederatedFileSharing\Events\FederatedShareAddedEvent; +use OCA\Files_Sharing\Helper; +use OCA\Files_Sharing\ResponseDefinitions; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Federation\ICloudFederationFactory; +use OCP\Federation\ICloudFederationProviderManager; +use OCP\Files; +use OCP\Files\Events\InvalidateMountCacheEvent; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\IStorageFactory; +use OCP\Http\Client\IClientService; +use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IUserManager; +use OCP\IUserSession; +use OCP\Notification\IManager; +use OCP\OCS\IDiscoveryService; +use OCP\Share; +use OCP\Share\IShare; +use Psr\Log\LoggerInterface; + +/** + * @psalm-import-type Files_SharingRemoteShare from ResponseDefinitions + */ +class Manager { + public const STORAGE = '\OCA\Files_Sharing\External\Storage'; + + /** @var string|null */ + private $uid; + + public function __construct( + private IDBConnection $connection, + private \OC\Files\Mount\Manager $mountManager, + private IStorageFactory $storageLoader, + private IClientService $clientService, + private IManager $notificationManager, + private IDiscoveryService $discoveryService, + private ICloudFederationProviderManager $cloudFederationProviderManager, + private ICloudFederationFactory $cloudFederationFactory, + private IGroupManager $groupManager, + private IUserManager $userManager, + IUserSession $userSession, + private IEventDispatcher $eventDispatcher, + private LoggerInterface $logger, + ) { + $user = $userSession->getUser(); + $this->uid = $user ? $user->getUID() : null; + } + + /** + * add new server-to-server share + * + * @param string $remote + * @param string $token + * @param string $password + * @param string $name + * @param string $owner + * @param int $shareType + * @param boolean $accepted + * @param string $user + * @param string $remoteId + * @param int $parent + * @return Mount|null + * @throws \Doctrine\DBAL\Exception + */ + public function addShare($remote, $token, $password, $name, $owner, $shareType, $accepted = false, $user = null, $remoteId = '', $parent = -1) { + $user = $user ?? $this->uid; + $accepted = $accepted ? IShare::STATUS_ACCEPTED : IShare::STATUS_PENDING; + $name = Filesystem::normalizePath('/' . $name); + + if ($accepted !== IShare::STATUS_ACCEPTED) { + // To avoid conflicts with the mount point generation later, + // we only use a temporary mount point name here. The real + // mount point name will be generated when accepting the share, + // using the original share item name. + $tmpMountPointName = '{{TemporaryMountPointName#' . $name . '}}'; + $mountPoint = $tmpMountPointName; + $hash = md5($tmpMountPointName); + $data = [ + 'remote' => $remote, + 'share_token' => $token, + 'password' => $password, + 'name' => $name, + 'owner' => $owner, + 'user' => $user, + 'mountpoint' => $mountPoint, + 'mountpoint_hash' => $hash, + 'accepted' => $accepted, + 'remote_id' => $remoteId, + 'share_type' => $shareType, + ]; + + $i = 1; + while (!$this->connection->insertIfNotExist('*PREFIX*share_external', $data, ['user', 'mountpoint_hash'])) { + // The external share already exists for the user + $data['mountpoint'] = $tmpMountPointName . '-' . $i; + $data['mountpoint_hash'] = md5($data['mountpoint']); + $i++; + } + return null; + } + + $mountPoint = Files::buildNotExistingFileName('/', $name); + $mountPoint = Filesystem::normalizePath('/' . $mountPoint); + $hash = md5($mountPoint); + + $this->writeShareToDb($remote, $token, $password, $name, $owner, $user, $mountPoint, $hash, $accepted, $remoteId, $parent, $shareType); + + $options = [ + 'remote' => $remote, + 'token' => $token, + 'password' => $password, + 'mountpoint' => $mountPoint, + 'owner' => $owner + ]; + return $this->mountShare($options, $user); + } + + /** + * write remote share to the database + * + * @param $remote + * @param $token + * @param $password + * @param $name + * @param $owner + * @param $user + * @param $mountPoint + * @param $hash + * @param $accepted + * @param $remoteId + * @param $parent + * @param $shareType + * + * @return void + * @throws \Doctrine\DBAL\Driver\Exception + */ + private function writeShareToDb($remote, $token, $password, $name, $owner, $user, $mountPoint, $hash, $accepted, $remoteId, $parent, $shareType): void { + $query = $this->connection->prepare(' + INSERT INTO `*PREFIX*share_external` + (`remote`, `share_token`, `password`, `name`, `owner`, `user`, `mountpoint`, `mountpoint_hash`, `accepted`, `remote_id`, `parent`, `share_type`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + $query->execute([$remote, $token, $password, $name, $owner, $user, $mountPoint, $hash, $accepted, $remoteId, $parent, $shareType]); + } + + private function fetchShare(int $id): array|false { + $getShare = $this->connection->prepare(' + SELECT `id`, `remote`, `remote_id`, `share_token`, `name`, `owner`, `user`, `mountpoint`, `accepted`, `parent`, `share_type`, `password`, `mountpoint_hash` + FROM `*PREFIX*share_external` + WHERE `id` = ?'); + $result = $getShare->execute([$id]); + $share = $result->fetch(); + $result->closeCursor(); + return $share; + } + + /** + * get share by token + * + * @param string $token + * @return mixed share of false + */ + private function fetchShareByToken($token) { + $getShare = $this->connection->prepare(' + SELECT `id`, `remote`, `remote_id`, `share_token`, `name`, `owner`, `user`, `mountpoint`, `accepted`, `parent`, `share_type`, `password`, `mountpoint_hash` + FROM `*PREFIX*share_external` + WHERE `share_token` = ?'); + $result = $getShare->execute([$token]); + $share = $result->fetch(); + $result->closeCursor(); + return $share; + } + + private function fetchUserShare($parentId, $uid) { + $getShare = $this->connection->prepare(' + SELECT `id`, `remote`, `remote_id`, `share_token`, `name`, `owner`, `user`, `mountpoint`, `accepted`, `parent`, `share_type`, `password`, `mountpoint_hash` + FROM `*PREFIX*share_external` + WHERE `parent` = ? AND `user` = ?'); + $result = $getShare->execute([$parentId, $uid]); + $share = $result->fetch(); + $result->closeCursor(); + if ($share !== false) { + return $share; + } + return null; + } + + public function getShare(int $id, ?string $user = null): array|false { + $user = $user ?? $this->uid; + $share = $this->fetchShare($id); + if ($share === false) { + return false; + } + + // check if the user is allowed to access it + if ($this->canAccessShare($share, $user)) { + return $share; + } + + return false; + } + + /** + * Get share by token + * + * @param string $token + * @return array|false + */ + public function getShareByToken(string $token): array|false { + $share = $this->fetchShareByToken($token); + + // We do not check if the user is allowed to access it here, + // as this is not used from a user context. + if ($share === false) { + return false; + } + + return $share; + } + + private function canAccessShare(array $share, string $user): bool { + $validShare = isset($share['share_type']) && isset($share['user']); + + if (!$validShare) { + return false; + } + + // If the share is a user share, check if the user is the recipient + if ((int)$share['share_type'] === IShare::TYPE_USER + && $share['user'] === $user) { + return true; + } + + // If the share is a group share, check if the user is in the group + if ((int)$share['share_type'] === IShare::TYPE_GROUP) { + $parentId = (int)$share['parent']; + if ($parentId !== -1) { + // we just retrieved a sub-share, switch to the parent entry for verification + $groupShare = $this->fetchShare($parentId); + } else { + $groupShare = $share; + } + + $user = $this->userManager->get($user); + if ($this->groupManager->get($groupShare['user'])->inGroup($user)) { + return true; + } + } + + return false; + } + + /** + * Updates accepted flag in the database + * + * @param int $id + */ + private function updateAccepted(int $shareId, bool $accepted) : void { + $query = $this->connection->prepare(' + UPDATE `*PREFIX*share_external` + SET `accepted` = ? + WHERE `id` = ?'); + $updateResult = $query->execute([$accepted ? 1 : 0, $shareId]); + $updateResult->closeCursor(); + } + + /** + * accept server-to-server share + * + * @param int $id + * @return bool True if the share could be accepted, false otherwise + */ + public function acceptShare(int $id, ?string $user = null) { + // If we're auto-accepting a share, we need to know the user id + // as there is no session available while processing the share + // from the remote server request. + $user = $user ?? $this->uid; + if ($user === null) { + $this->logger->error('No user specified for accepting share'); + return false; + } + + $share = $this->getShare($id, $user); + $result = false; + + if ($share) { + \OC_Util::setupFS($user); + $shareFolder = Helper::getShareFolder(null, $user); + $mountPoint = Files::buildNotExistingFileName($shareFolder, $share['name']); + $mountPoint = Filesystem::normalizePath($mountPoint); + $hash = md5($mountPoint); + $userShareAccepted = false; + + if ((int)$share['share_type'] === IShare::TYPE_USER) { + $acceptShare = $this->connection->prepare(' + UPDATE `*PREFIX*share_external` + SET `accepted` = ?, + `mountpoint` = ?, + `mountpoint_hash` = ? + WHERE `id` = ? AND `user` = ?'); + $userShareAccepted = $acceptShare->execute([1, $mountPoint, $hash, $id, $user]); + } else { + $parentId = (int)$share['parent']; + if ($parentId !== -1) { + // this is the sub-share + $subshare = $share; + } else { + $subshare = $this->fetchUserShare($id, $user); + } + + if ($subshare !== null) { + try { + $acceptShare = $this->connection->prepare(' + UPDATE `*PREFIX*share_external` + SET `accepted` = ?, + `mountpoint` = ?, + `mountpoint_hash` = ? + WHERE `id` = ? AND `user` = ?'); + $acceptShare->execute([1, $mountPoint, $hash, $subshare['id'], $user]); + $result = true; + } catch (Exception $e) { + $this->logger->emergency('Could not update share', ['exception' => $e]); + $result = false; + } + } else { + try { + $this->writeShareToDb( + $share['remote'], + $share['share_token'], + $share['password'], + $share['name'], + $share['owner'], + $user, + $mountPoint, $hash, 1, + $share['remote_id'], + $id, + $share['share_type']); + $result = true; + } catch (Exception $e) { + $this->logger->emergency('Could not create share', ['exception' => $e]); + $result = false; + } + } + } + + if ($userShareAccepted !== false) { + $this->sendFeedbackToRemote($share['remote'], $share['share_token'], $share['remote_id'], 'accept'); + $event = new FederatedShareAddedEvent($share['remote']); + $this->eventDispatcher->dispatchTyped($event); + $this->eventDispatcher->dispatchTyped(new InvalidateMountCacheEvent($this->userManager->get($user))); + $result = true; + } + } + + // Make sure the user has no notification for something that does not exist anymore. + $this->processNotification($id, $user); + + return $result; + } + + /** + * decline server-to-server share + * + * @param int $id + * @return bool True if the share could be declined, false otherwise + */ + public function declineShare(int $id, ?string $user = null) { + $user = $user ?? $this->uid; + if ($user === null) { + $this->logger->error('No user specified for declining share'); + return false; + } + + $share = $this->getShare($id, $user); + $result = false; + + if ($share && (int)$share['share_type'] === IShare::TYPE_USER) { + $removeShare = $this->connection->prepare(' + DELETE FROM `*PREFIX*share_external` WHERE `id` = ? AND `user` = ?'); + $removeShare->execute([$id, $user]); + $this->sendFeedbackToRemote($share['remote'], $share['share_token'], $share['remote_id'], 'decline'); + + $this->processNotification($id, $user); + $result = true; + } elseif ($share && (int)$share['share_type'] === IShare::TYPE_GROUP) { + $parentId = (int)$share['parent']; + if ($parentId !== -1) { + // this is the sub-share + $subshare = $share; + } else { + $subshare = $this->fetchUserShare($id, $user); + } + + if ($subshare !== null) { + try { + $this->updateAccepted((int)$subshare['id'], false); + $result = true; + } catch (Exception $e) { + $this->logger->emergency('Could not update share', ['exception' => $e]); + $result = false; + } + } else { + try { + $this->writeShareToDb( + $share['remote'], + $share['share_token'], + $share['password'], + $share['name'], + $share['owner'], + $user, + $share['mountpoint'], + $share['mountpoint_hash'], + 0, + $share['remote_id'], + $id, + $share['share_type']); + $result = true; + } catch (Exception $e) { + $this->logger->emergency('Could not create share', ['exception' => $e]); + $result = false; + } + } + $this->processNotification($id, $user); + } + + return $result; + } + + public function processNotification(int $remoteShare, ?string $user = null): void { + $user = $user ?? $this->uid; + if ($user === null) { + $this->logger->error('No user specified for processing notification'); + return; + } + + $share = $this->fetchShare($remoteShare); + if ($share === false) { + return; + } + + $filter = $this->notificationManager->createNotification(); + $filter->setApp('files_sharing') + ->setUser($user) + ->setObject('remote_share', (string)$remoteShare); + $this->notificationManager->markProcessed($filter); + } + + /** + * inform remote server whether server-to-server share was accepted/declined + * + * @param string $remote + * @param string $token + * @param string $remoteId Share id on the remote host + * @param string $feedback + * @return boolean + */ + private function sendFeedbackToRemote($remote, $token, $remoteId, $feedback) { + $result = $this->tryOCMEndPoint($remote, $token, $remoteId, $feedback); + + if (is_array($result)) { + return true; + } + + $federationEndpoints = $this->discoveryService->discover($remote, 'FEDERATED_SHARING'); + $endpoint = $federationEndpoints['share'] ?? '/ocs/v2.php/cloud/shares'; + + $url = rtrim($remote, '/') . $endpoint . '/' . $remoteId . '/' . $feedback . '?format=' . Share::RESPONSE_FORMAT; + $fields = ['token' => $token]; + + $client = $this->clientService->newClient(); + + try { + $response = $client->post( + $url, + [ + 'body' => $fields, + 'connect_timeout' => 10, + ] + ); + } catch (\Exception $e) { + return false; + } + + $status = json_decode($response->getBody(), true); + + return ($status['ocs']['meta']['statuscode'] === 100 || $status['ocs']['meta']['statuscode'] === 200); + } + + /** + * try send accept message to ocm end-point + * + * @param string $remoteDomain + * @param string $token + * @param string $remoteId id of the share + * @param string $feedback + * @return array|false + */ + protected function tryOCMEndPoint($remoteDomain, $token, $remoteId, $feedback) { + switch ($feedback) { + case 'accept': + $notification = $this->cloudFederationFactory->getCloudFederationNotification(); + $notification->setMessage( + 'SHARE_ACCEPTED', + 'file', + $remoteId, + [ + 'sharedSecret' => $token, + 'message' => 'Recipient accept the share' + ] + + ); + return $this->cloudFederationProviderManager->sendNotification($remoteDomain, $notification); + case 'decline': + $notification = $this->cloudFederationFactory->getCloudFederationNotification(); + $notification->setMessage( + 'SHARE_DECLINED', + 'file', + $remoteId, + [ + 'sharedSecret' => $token, + 'message' => 'Recipient declined the share' + ] + + ); + return $this->cloudFederationProviderManager->sendNotification($remoteDomain, $notification); + } + + return false; + } + + + /** + * remove '/user/files' from the path and trailing slashes + * + * @param string $path + * @return string + */ + protected function stripPath($path) { + $prefix = '/' . $this->uid . '/files'; + return rtrim(substr($path, strlen($prefix)), '/'); + } + + public function getMount($data, ?string $user = null) { + $user = $user ?? $this->uid; + $data['manager'] = $this; + $mountPoint = '/' . $user . '/files' . $data['mountpoint']; + $data['mountpoint'] = $mountPoint; + $data['certificateManager'] = \OC::$server->getCertificateManager(); + return new Mount(self::STORAGE, $mountPoint, $data, $this, $this->storageLoader); + } + + /** + * @param array $data + * @return Mount + */ + protected function mountShare($data, ?string $user = null) { + $mount = $this->getMount($data, $user); + $this->mountManager->addMount($mount); + return $mount; + } + + /** + * @return \OC\Files\Mount\Manager + */ + public function getMountManager() { + return $this->mountManager; + } + + /** + * @param string $source + * @param string $target + * @return bool + */ + public function setMountPoint($source, $target) { + $source = $this->stripPath($source); + $target = $this->stripPath($target); + $sourceHash = md5($source); + $targetHash = md5($target); + + $query = $this->connection->prepare(' + UPDATE `*PREFIX*share_external` + SET `mountpoint` = ?, `mountpoint_hash` = ? + WHERE `mountpoint_hash` = ? + AND `user` = ? + '); + $result = (bool)$query->execute([$target, $targetHash, $sourceHash, $this->uid]); + + $this->eventDispatcher->dispatchTyped(new InvalidateMountCacheEvent($this->userManager->get($this->uid))); + + return $result; + } + + public function removeShare($mountPoint): bool { + try { + $mountPointObj = $this->mountManager->find($mountPoint); + } catch (NotFoundException $e) { + $this->logger->error('Mount point to remove share not found', ['mountPoint' => $mountPoint]); + return false; + } + if (!$mountPointObj instanceof Mount) { + $this->logger->error('Mount point to remove share is not an external share, share probably doesn\'t exist', ['mountPoint' => $mountPoint]); + return false; + } + $id = $mountPointObj->getStorage()->getCache()->getId(''); + + $mountPoint = $this->stripPath($mountPoint); + $hash = md5($mountPoint); + + try { + $getShare = $this->connection->prepare(' + SELECT `remote`, `share_token`, `remote_id`, `share_type`, `id` + FROM `*PREFIX*share_external` + WHERE `mountpoint_hash` = ? AND `user` = ?'); + $result = $getShare->execute([$hash, $this->uid]); + $share = $result->fetch(); + $result->closeCursor(); + if ($share !== false && (int)$share['share_type'] === IShare::TYPE_USER) { + try { + $this->sendFeedbackToRemote($share['remote'], $share['share_token'], $share['remote_id'], 'decline'); + } catch (\Throwable $e) { + // if we fail to notify the remote (probably cause the remote is down) + // we still want the share to be gone to prevent undeletable remotes + } + + $query = $this->connection->prepare(' + DELETE FROM `*PREFIX*share_external` + WHERE `id` = ? + '); + $deleteResult = $query->execute([(int)$share['id']]); + $deleteResult->closeCursor(); + } elseif ($share !== false && (int)$share['share_type'] === IShare::TYPE_GROUP) { + $this->updateAccepted((int)$share['id'], false); + } + + $this->removeReShares($id); + } catch (\Doctrine\DBAL\Exception $ex) { + $this->logger->emergency('Could not update share', ['exception' => $ex]); + return false; + } + + return true; + } + + /** + * remove re-shares from share table and mapping in the federated_reshares table + * + * @param $mountPointId + */ + protected function removeReShares($mountPointId) { + $selectQuery = $this->connection->getQueryBuilder(); + $query = $this->connection->getQueryBuilder(); + $selectQuery->select('id')->from('share') + ->where($selectQuery->expr()->eq('file_source', $query->createNamedParameter($mountPointId))); + $select = $selectQuery->getSQL(); + + + $query->delete('federated_reshares') + ->where($query->expr()->in('share_id', $query->createFunction($select))); + $query->execute(); + + $deleteReShares = $this->connection->getQueryBuilder(); + $deleteReShares->delete('share') + ->where($deleteReShares->expr()->eq('file_source', $deleteReShares->createNamedParameter($mountPointId))); + $deleteReShares->execute(); + } + + /** + * remove all shares for user $uid if the user was deleted + * + * @param string $uid + */ + public function removeUserShares($uid): bool { + try { + // TODO: use query builder + $getShare = $this->connection->prepare(' + SELECT `id`, `remote`, `share_type`, `share_token`, `remote_id` + FROM `*PREFIX*share_external` + WHERE `user` = ? + AND `share_type` = ?'); + $result = $getShare->execute([$uid, IShare::TYPE_USER]); + $shares = $result->fetchAll(); + $result->closeCursor(); + + foreach ($shares as $share) { + $this->sendFeedbackToRemote($share['remote'], $share['share_token'], $share['remote_id'], 'decline'); + } + + $qb = $this->connection->getQueryBuilder(); + $qb->delete('share_external') + // user field can specify a user or a group + ->where($qb->expr()->eq('user', $qb->createNamedParameter($uid))) + ->andWhere( + $qb->expr()->orX( + // delete direct shares + $qb->expr()->eq('share_type', $qb->expr()->literal(IShare::TYPE_USER)), + // delete sub-shares of group shares for that user + $qb->expr()->andX( + $qb->expr()->eq('share_type', $qb->expr()->literal(IShare::TYPE_GROUP)), + $qb->expr()->neq('parent', $qb->expr()->literal(-1)), + ) + ) + ); + $qb->execute(); + } catch (\Doctrine\DBAL\Exception $ex) { + $this->logger->emergency('Could not delete user shares', ['exception' => $ex]); + return false; + } + + return true; + } + + public function removeGroupShares($gid): bool { + try { + $getShare = $this->connection->prepare(' + SELECT `id`, `remote`, `share_type`, `share_token`, `remote_id` + FROM `*PREFIX*share_external` + WHERE `user` = ? + AND `share_type` = ?'); + $result = $getShare->execute([$gid, IShare::TYPE_GROUP]); + $shares = $result->fetchAll(); + $result->closeCursor(); + + $deletedGroupShares = []; + $qb = $this->connection->getQueryBuilder(); + // delete group share entry and matching sub-entries + $qb->delete('share_external') + ->where( + $qb->expr()->orX( + $qb->expr()->eq('id', $qb->createParameter('share_id')), + $qb->expr()->eq('parent', $qb->createParameter('share_parent_id')) + ) + ); + + foreach ($shares as $share) { + $qb->setParameter('share_id', $share['id']); + $qb->setParameter('share_parent_id', $share['id']); + $qb->execute(); + } + } catch (\Doctrine\DBAL\Exception $ex) { + $this->logger->emergency('Could not delete user shares', ['exception' => $ex]); + return false; + } + + return true; + } + + /** + * return a list of shares which are not yet accepted by the user + * + * @return list<Files_SharingRemoteShare> list of open server-to-server shares + */ + public function getOpenShares() { + return $this->getShares(false); + } + + /** + * return a list of shares which are accepted by the user + * + * @return list<Files_SharingRemoteShare> list of accepted server-to-server shares + */ + public function getAcceptedShares() { + return $this->getShares(true); + } + + /** + * return a list of shares for the user + * + * @param bool|null $accepted True for accepted only, + * false for not accepted, + * null for all shares of the user + * @return list<Files_SharingRemoteShare> list of open server-to-server shares + */ + private function getShares($accepted) { + // Not allowing providing a user here, + // as we only want to retrieve shares for the current user. + $user = $this->userManager->get($this->uid); + $groups = $this->groupManager->getUserGroups($user); + $userGroups = []; + foreach ($groups as $group) { + $userGroups[] = $group->getGID(); + } + + $qb = $this->connection->getQueryBuilder(); + $qb->select('id', 'share_type', 'parent', 'remote', 'remote_id', 'share_token', 'name', 'owner', 'user', 'mountpoint', 'accepted') + ->from('share_external') + ->where( + $qb->expr()->orX( + $qb->expr()->eq('user', $qb->createNamedParameter($this->uid)), + $qb->expr()->in( + 'user', + $qb->createNamedParameter($userGroups, IQueryBuilder::PARAM_STR_ARRAY) + ) + ) + ) + ->orderBy('id', 'ASC'); + + try { + $result = $qb->execute(); + $shares = $result->fetchAll(); + $result->closeCursor(); + + // remove parent group share entry if we have a specific user share entry for the user + $toRemove = []; + foreach ($shares as $share) { + if ((int)$share['share_type'] === IShare::TYPE_GROUP && (int)$share['parent'] > 0) { + $toRemove[] = $share['parent']; + } + } + $shares = array_filter($shares, function ($share) use ($toRemove) { + return !in_array($share['id'], $toRemove, true); + }); + + if (!is_null($accepted)) { + $shares = array_filter($shares, function ($share) use ($accepted) { + return (bool)$share['accepted'] === $accepted; + }); + } + return array_values($shares); + } catch (\Doctrine\DBAL\Exception $e) { + $this->logger->emergency('Error when retrieving shares', ['exception' => $e]); + return []; + } + } +} diff --git a/apps/files_sharing/lib/External/Mount.php b/apps/files_sharing/lib/External/Mount.php new file mode 100644 index 00000000000..f50c379f85f --- /dev/null +++ b/apps/files_sharing/lib/External/Mount.php @@ -0,0 +1,63 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Sharing\External; + +use OC\Files\Mount\MountPoint; +use OC\Files\Mount\MoveableMount; +use OC\Files\Storage\Storage; +use OCA\Files_Sharing\ISharedMountPoint; + +class Mount extends MountPoint implements MoveableMount, ISharedMountPoint { + + /** + * @param string|Storage $storage + * @param string $mountpoint + * @param array $options + * @param \OCA\Files_Sharing\External\Manager $manager + * @param \OC\Files\Storage\StorageFactory $loader + */ + public function __construct( + $storage, + $mountpoint, + $options, + protected $manager, + $loader = null, + ) { + parent::__construct($storage, $mountpoint, $options, $loader, null, null, MountProvider::class); + } + + /** + * Move the mount point to $target + * + * @param string $target the target mount point + * @return bool + */ + public function moveMount($target) { + $result = $this->manager->setMountPoint($this->mountPoint, $target); + $this->setMountPoint($target); + + return $result; + } + + /** + * Remove the mount points + */ + public function removeMount(): bool { + return $this->manager->removeShare($this->mountPoint); + } + + /** + * Get the type of mount point, used to distinguish things like shares and external storage + * in the web interface + * + * @return string + */ + public function getMountType() { + return 'shared'; + } +} diff --git a/apps/files_sharing/lib/External/MountProvider.php b/apps/files_sharing/lib/External/MountProvider.php new file mode 100644 index 00000000000..a5781d5d35a --- /dev/null +++ b/apps/files_sharing/lib/External/MountProvider.php @@ -0,0 +1,68 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Sharing\External; + +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Federation\ICloudIdManager; +use OCP\Files\Config\IMountProvider; +use OCP\Files\Storage\IStorageFactory; +use OCP\Http\Client\IClientService; +use OCP\IDBConnection; +use OCP\IUser; +use OCP\Server; + +class MountProvider implements IMountProvider { + public const STORAGE = '\OCA\Files_Sharing\External\Storage'; + + /** + * @var callable + */ + private $managerProvider; + + /** + * @param IDBConnection $connection + * @param callable $managerProvider due to setup order we need a callable that return the manager instead of the manager itself + * @param ICloudIdManager $cloudIdManager + */ + public function __construct( + private IDBConnection $connection, + callable $managerProvider, + private ICloudIdManager $cloudIdManager, + ) { + $this->managerProvider = $managerProvider; + } + + public function getMount(IUser $user, $data, IStorageFactory $storageFactory) { + $managerProvider = $this->managerProvider; + $manager = $managerProvider(); + $data['manager'] = $manager; + $mountPoint = '/' . $user->getUID() . '/files/' . ltrim($data['mountpoint'], '/'); + $data['mountpoint'] = $mountPoint; + $data['cloudId'] = $this->cloudIdManager->getCloudId($data['owner'], $data['remote']); + $data['certificateManager'] = \OC::$server->getCertificateManager(); + $data['HttpClientService'] = Server::get(IClientService::class); + return new Mount(self::STORAGE, $mountPoint, $data, $manager, $storageFactory); + } + + public function getMountsForUser(IUser $user, IStorageFactory $loader) { + $qb = $this->connection->getQueryBuilder(); + $qb->select('remote', 'share_token', 'password', 'mountpoint', 'owner') + ->from('share_external') + ->where($qb->expr()->eq('user', $qb->createNamedParameter($user->getUID()))) + ->andWhere($qb->expr()->eq('accepted', $qb->createNamedParameter(1, IQueryBuilder::PARAM_INT))); + $result = $qb->executeQuery(); + $mounts = []; + while ($row = $result->fetch()) { + $row['manager'] = $this; + $row['token'] = $row['share_token']; + $mounts[] = $this->getMount($user, $row, $loader); + } + $result->closeCursor(); + return $mounts; + } +} diff --git a/apps/files_sharing/lib/External/Scanner.php b/apps/files_sharing/lib/External/Scanner.php new file mode 100644 index 00000000000..0d57248595b --- /dev/null +++ b/apps/files_sharing/lib/External/Scanner.php @@ -0,0 +1,55 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Sharing\External; + +use OC\Files\Cache\CacheEntry; +use OC\ForbiddenException; +use OCP\Files\NotFoundException; +use OCP\Files\StorageInvalidException; +use OCP\Files\StorageNotAvailableException; + +class Scanner extends \OC\Files\Cache\Scanner { + /** @var Storage */ + protected $storage; + + public function scan($path, $recursive = self::SCAN_RECURSIVE, $reuse = -1, $lock = true) { + // Disable locking for federated shares + parent::scan($path, $recursive, $reuse, false); + } + + /** + * Scan a single file and store it in the cache. + * If an exception happened while accessing the external storage, + * the storage will be checked for availability and removed + * if it is not available any more. + * + * @param string $file file to scan + * @param int $reuseExisting + * @param int $parentId + * @param CacheEntry|array|null|false $cacheData existing data in the cache for the file to be scanned + * @param bool $lock set to false to disable getting an additional read lock during scanning + * @param array|null $data the metadata for the file, as returned by the storage + * @return array|null an array of metadata of the scanned file + */ + public function scanFile($file, $reuseExisting = 0, $parentId = -1, $cacheData = null, $lock = true, $data = null) { + try { + return parent::scanFile($file, $reuseExisting, $parentId, $cacheData, $lock, $data); + } catch (ForbiddenException $e) { + $this->storage->checkStorageAvailability(); + } catch (NotFoundException $e) { + // if the storage isn't found, the call to + // checkStorageAvailable() will verify it and remove it + // if appropriate + $this->storage->checkStorageAvailability(); + } catch (StorageInvalidException $e) { + $this->storage->checkStorageAvailability(); + } catch (StorageNotAvailableException $e) { + $this->storage->checkStorageAvailability(); + } + } +} diff --git a/apps/files_sharing/lib/External/Storage.php b/apps/files_sharing/lib/External/Storage.php new file mode 100644 index 00000000000..a9781b91a6c --- /dev/null +++ b/apps/files_sharing/lib/External/Storage.php @@ -0,0 +1,429 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Sharing\External; + +use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\RequestException; +use OC\Files\Storage\DAV; +use OC\ForbiddenException; +use OC\Share\Share; +use OCA\Files_Sharing\External\Manager as ExternalShareManager; +use OCA\Files_Sharing\ISharedStorage; +use OCP\AppFramework\Http; +use OCP\Constants; +use OCP\Federation\ICloudId; +use OCP\Files\Cache\ICache; +use OCP\Files\Cache\IScanner; +use OCP\Files\Cache\IWatcher; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\IDisableEncryptionStorage; +use OCP\Files\Storage\IReliableEtagStorage; +use OCP\Files\Storage\IStorage; +use OCP\Files\StorageInvalidException; +use OCP\Files\StorageNotAvailableException; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\LocalServerException; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\OCM\Exceptions\OCMArgumentException; +use OCP\OCM\Exceptions\OCMProviderException; +use OCP\OCM\IOCMDiscoveryService; +use OCP\Server; +use OCP\Util; +use Psr\Log\LoggerInterface; + +class Storage extends DAV implements ISharedStorage, IDisableEncryptionStorage, IReliableEtagStorage { + private ICloudId $cloudId; + private string $mountPoint; + private string $token; + private ICacheFactory $memcacheFactory; + private IClientService $httpClient; + private bool $updateChecked = false; + private ExternalShareManager $manager; + private IConfig $config; + + /** + * @param array{HttpClientService: IClientService, manager: ExternalShareManager, cloudId: ICloudId, mountpoint: string, token: string, password: ?string}|array $options + */ + public function __construct($options) { + $this->memcacheFactory = Server::get(ICacheFactory::class); + $this->httpClient = $options['HttpClientService']; + $this->manager = $options['manager']; + $this->cloudId = $options['cloudId']; + $this->logger = Server::get(LoggerInterface::class); + $discoveryService = Server::get(IOCMDiscoveryService::class); + $this->config = Server::get(IConfig::class); + + // use default path to webdav if not found on discovery + try { + $ocmProvider = $discoveryService->discover($this->cloudId->getRemote()); + $webDavEndpoint = $ocmProvider->extractProtocolEntry('file', 'webdav'); + $remote = $ocmProvider->getEndPoint(); + } catch (OCMProviderException|OCMArgumentException $e) { + $this->logger->notice('exception while retrieving webdav endpoint', ['exception' => $e]); + $webDavEndpoint = '/public.php/webdav'; + $remote = $this->cloudId->getRemote(); + } + + $host = parse_url($remote, PHP_URL_HOST); + $port = parse_url($remote, PHP_URL_PORT); + $host .= ($port === null) ? '' : ':' . $port; // we add port if available + + // in case remote NC is on a sub folder and using deprecated ocm provider + $tmpPath = rtrim(parse_url($this->cloudId->getRemote(), PHP_URL_PATH) ?? '', '/'); + if (!str_starts_with($webDavEndpoint, $tmpPath)) { + $webDavEndpoint = $tmpPath . $webDavEndpoint; + } + + $this->mountPoint = $options['mountpoint']; + $this->token = $options['token']; + + parent::__construct( + [ + 'secure' => ((parse_url($remote, PHP_URL_SCHEME) ?? 'https') === 'https'), + 'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false), + 'host' => $host, + 'root' => $webDavEndpoint, + 'user' => $options['token'], + 'authType' => \Sabre\DAV\Client::AUTH_BASIC, + 'password' => (string)$options['password'] + ] + ); + } + + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { + if (!$storage) { + $storage = $this; + } + if (!isset($this->watcher)) { + $this->watcher = new Watcher($storage); + $this->watcher->setPolicy(\OC\Files\Cache\Watcher::CHECK_ONCE); + } + return $this->watcher; + } + + public function getRemoteUser(): string { + return $this->cloudId->getUser(); + } + + public function getRemote(): string { + return $this->cloudId->getRemote(); + } + + public function getMountPoint(): string { + return $this->mountPoint; + } + + public function getToken(): string { + return $this->token; + } + + public function getPassword(): ?string { + return $this->password; + } + + public function getId(): string { + return 'shared::' . md5($this->token . '@' . $this->getRemote()); + } + + public function getCache(string $path = '', ?IStorage $storage = null): ICache { + if (is_null($this->cache)) { + $this->cache = new Cache($this, $this->cloudId); + } + return $this->cache; + } + + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner { + if (!$storage) { + $storage = $this; + } + if (!isset($this->scanner)) { + $this->scanner = new Scanner($storage); + } + /** @var Scanner */ + return $this->scanner; + } + + public function hasUpdated(string $path, int $time): bool { + // since for owncloud webdav servers we can rely on etag propagation we only need to check the root of the storage + // because of that we only do one check for the entire storage per request + if ($this->updateChecked) { + return false; + } + $this->updateChecked = true; + try { + return parent::hasUpdated('', $time); + } catch (StorageInvalidException $e) { + // check if it needs to be removed + $this->checkStorageAvailability(); + throw $e; + } catch (StorageNotAvailableException $e) { + // check if it needs to be removed or just temp unavailable + $this->checkStorageAvailability(); + throw $e; + } + } + + public function test(): bool { + try { + return parent::test(); + } catch (StorageInvalidException $e) { + // check if it needs to be removed + $this->checkStorageAvailability(); + throw $e; + } catch (StorageNotAvailableException $e) { + // check if it needs to be removed or just temp unavailable + $this->checkStorageAvailability(); + throw $e; + } + } + + /** + * Check whether this storage is permanently or temporarily + * unavailable + * + * @throws StorageNotAvailableException + * @throws StorageInvalidException + */ + public function checkStorageAvailability() { + // see if we can find out why the share is unavailable + try { + $this->getShareInfo(0); + } catch (NotFoundException $e) { + // a 404 can either mean that the share no longer exists or there is no Nextcloud on the remote + if ($this->testRemote()) { + // valid Nextcloud instance means that the public share no longer exists + // since this is permanent (re-sharing the file will create a new token) + // we remove the invalid storage + $this->manager->removeShare($this->mountPoint); + $this->manager->getMountManager()->removeMount($this->mountPoint); + throw new StorageInvalidException('Remote share not found', 0, $e); + } else { + // Nextcloud instance is gone, likely to be a temporary server configuration error + throw new StorageNotAvailableException('No nextcloud instance found at remote', 0, $e); + } + } catch (ForbiddenException $e) { + // auth error, remove share for now (provide a dialog in the future) + $this->manager->removeShare($this->mountPoint); + $this->manager->getMountManager()->removeMount($this->mountPoint); + throw new StorageInvalidException('Auth error when getting remote share'); + } catch (\GuzzleHttp\Exception\ConnectException $e) { + throw new StorageNotAvailableException('Failed to connect to remote instance', 0, $e); + } catch (\GuzzleHttp\Exception\RequestException $e) { + throw new StorageNotAvailableException('Error while sending request to remote instance', 0, $e); + } + } + + public function file_exists(string $path): bool { + if ($path === '') { + return true; + } else { + return parent::file_exists($path); + } + } + + /** + * Check if the configured remote is a valid federated share provider + * + * @return bool + */ + protected function testRemote(): bool { + try { + return $this->testRemoteUrl($this->getRemote() . '/ocm-provider/index.php') + || $this->testRemoteUrl($this->getRemote() . '/ocm-provider/') + || $this->testRemoteUrl($this->getRemote() . '/status.php'); + } catch (\Exception $e) { + return false; + } + } + + private function testRemoteUrl(string $url): bool { + $cache = $this->memcacheFactory->createDistributed('files_sharing_remote_url'); + $cached = $cache->get($url); + if ($cached !== null) { + return (bool)$cached; + } + + $client = $this->httpClient->newClient(); + try { + $result = $client->get($url, $this->getDefaultRequestOptions())->getBody(); + $data = json_decode($result); + $returnValue = (is_object($data) && !empty($data->version)); + } catch (ConnectException|ClientException|RequestException $e) { + $returnValue = false; + $this->logger->warning('Failed to test remote URL', ['exception' => $e]); + } + + $cache->set($url, $returnValue, 60 * 60 * 24); + return $returnValue; + } + + /** + * Check whether the remote is an ownCloud/Nextcloud. This is needed since some sharing + * features are not standardized. + * + * @throws LocalServerException + */ + public function remoteIsOwnCloud(): bool { + if (defined('PHPUNIT_RUN') || !$this->testRemoteUrl($this->getRemote() . '/status.php')) { + return false; + } + return true; + } + + /** + * @return mixed + * @throws ForbiddenException + * @throws NotFoundException + * @throws \Exception + */ + public function getShareInfo(int $depth = -1) { + $remote = $this->getRemote(); + $token = $this->getToken(); + $password = $this->getPassword(); + + try { + // If remote is not an ownCloud do not try to get any share info + if (!$this->remoteIsOwnCloud()) { + return ['status' => 'unsupported']; + } + } catch (LocalServerException $e) { + // throw this to be on the safe side: the share will still be visible + // in the UI in case the failure is intermittent, and the user will + // be able to decide whether to remove it if it's really gone + throw new StorageNotAvailableException(); + } + + $url = rtrim($remote, '/') . '/index.php/apps/files_sharing/shareinfo?t=' . $token; + + // TODO: DI + $client = Server::get(IClientService::class)->newClient(); + try { + $response = $client->post($url, array_merge($this->getDefaultRequestOptions(), [ + 'body' => ['password' => $password, 'depth' => $depth], + ])); + } catch (\GuzzleHttp\Exception\RequestException $e) { + $this->logger->warning('Failed to fetch share info', ['exception' => $e]); + if ($e->getCode() === Http::STATUS_UNAUTHORIZED || $e->getCode() === Http::STATUS_FORBIDDEN) { + throw new ForbiddenException(); + } + if ($e->getCode() === Http::STATUS_NOT_FOUND) { + throw new NotFoundException(); + } + // throw this to be on the safe side: the share will still be visible + // in the UI in case the failure is intermittent, and the user will + // be able to decide whether to remove it if it's really gone + throw new StorageNotAvailableException(); + } + + return json_decode($response->getBody(), true); + } + + public function getOwner(string $path): string|false { + return $this->cloudId->getDisplayId(); + } + + public function isSharable(string $path): bool { + if (Util::isSharingDisabledForUser() || !Share::isResharingAllowed()) { + return false; + } + return (bool)($this->getPermissions($path) & Constants::PERMISSION_SHARE); + } + + public function getPermissions(string $path): int { + $response = $this->propfind($path); + if ($response === false) { + return 0; + } + + $ocsPermissions = $response['{http://open-collaboration-services.org/ns}share-permissions'] ?? null; + $ocmPermissions = $response['{http://open-cloud-mesh.org/ns}share-permissions'] ?? null; + $ocPermissions = $response['{http://owncloud.org/ns}permissions'] ?? null; + // old federated sharing permissions + if ($ocsPermissions !== null) { + $permissions = (int)$ocsPermissions; + } elseif ($ocmPermissions !== null) { + // permissions provided by the OCM API + $permissions = $this->ocmPermissions2ncPermissions($ocmPermissions, $path); + } elseif ($ocPermissions !== null) { + return $this->parsePermissions($ocPermissions); + } else { + // use default permission if remote server doesn't provide the share permissions + $permissions = $this->getDefaultPermissions($path); + } + + return $permissions; + } + + public function needsPartFile(): bool { + return false; + } + + /** + * Translate OCM Permissions to Nextcloud permissions + * + * @param string $ocmPermissions json encoded OCM permissions + * @param string $path path to file + * @return int + */ + protected function ocmPermissions2ncPermissions(string $ocmPermissions, string $path): int { + try { + $ocmPermissions = json_decode($ocmPermissions); + $ncPermissions = 0; + foreach ($ocmPermissions as $permission) { + switch (strtolower($permission)) { + case 'read': + $ncPermissions += Constants::PERMISSION_READ; + break; + case 'write': + $ncPermissions += Constants::PERMISSION_CREATE + Constants::PERMISSION_UPDATE; + break; + case 'share': + $ncPermissions += Constants::PERMISSION_SHARE; + break; + default: + throw new \Exception(); + } + } + } catch (\Exception $e) { + $ncPermissions = $this->getDefaultPermissions($path); + } + + return $ncPermissions; + } + + /** + * Calculate the default permissions in case no permissions are provided + */ + protected function getDefaultPermissions(string $path): int { + if ($this->is_dir($path)) { + $permissions = Constants::PERMISSION_ALL; + } else { + $permissions = Constants::PERMISSION_ALL & ~Constants::PERMISSION_CREATE; + } + + return $permissions; + } + + public function free_space(string $path): int|float|false { + return parent::free_space(''); + } + + private function getDefaultRequestOptions(): array { + $options = [ + 'timeout' => 10, + 'connect_timeout' => 10, + ]; + if ($this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates')) { + $options['verify'] = false; + } + return $options; + } +} diff --git a/apps/files_sharing/lib/External/Watcher.php b/apps/files_sharing/lib/External/Watcher.php new file mode 100644 index 00000000000..f3616feabba --- /dev/null +++ b/apps/files_sharing/lib/External/Watcher.php @@ -0,0 +1,19 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Sharing\External; + +class Watcher extends \OC\Files\Cache\Watcher { + /** + * remove deleted files in $path from the cache + * + * @param string $path + */ + public function cleanFolder($path) { + // not needed, the scanner takes care of this + } +} |