diff --git a/appinfo/info.xml b/appinfo/info.xml index 37522262..7ae59943 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -28,6 +28,7 @@ + OCA\GlobalSiteSelector\Command\GlobalScaleDiscovery OCA\GlobalSiteSelector\Command\UsersUpdate diff --git a/appinfo/routes.php b/appinfo/routes.php index e67133f5..6d1f187d 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -9,6 +9,8 @@ return [ 'ocs' => [ ['name' => 'Slave#createAppToken', 'url' => '/v1/createapptoken', 'verb' => 'GET'], + ['name' => 'Slave#discovery', 'url' => '/discovery', 'verb' => 'GET'], + ['name' => 'Slave#sharedFile', 'url' => '/sharedfile', 'verb' => 'GET'], ], 'routes' => [ [ @@ -21,5 +23,11 @@ 'url' => '/autologout', 'verb' => 'GET' ], + [ + 'name' => 'Slave#findFile', + 'url' => '/gf/{token}/{fileId}', + 'verb' => 'GET', + 'root' => '', + ], ], ]; diff --git a/lib/BackgroundJobs/UpdateLookupServer.php b/lib/BackgroundJobs/UpdateLookupServer.php index e3071ffc..4d92de88 100644 --- a/lib/BackgroundJobs/UpdateLookupServer.php +++ b/lib/BackgroundJobs/UpdateLookupServer.php @@ -6,10 +6,10 @@ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OCA\GlobalSiteSelector\BackgroundJobs; use OCA\GlobalSiteSelector\GlobalSiteSelector; +use OCA\GlobalSiteSelector\Service\GlobalScaleService; use OCA\GlobalSiteSelector\Slave; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\IJob; @@ -17,13 +17,12 @@ use OCP\IConfig; class UpdateLookupServer extends TimedJob { - - public function __construct( ITimeFactory $time, IConfig $config, - private GlobalSiteSelector $globalSiteSelector, - private Slave $slave, + private readonly GlobalScaleService $globalScaleService, + private readonly GlobalSiteSelector $globalSiteSelector, + private readonly Slave $slave, ) { parent::__construct($time); @@ -36,6 +35,7 @@ protected function run($argument) { return; } + $this->globalScaleService->refreshTokenFromGlobalScale(); $this->slave->batchUpdate(); } } diff --git a/lib/Command/GlobalScaleDiscovery.php b/lib/Command/GlobalScaleDiscovery.php new file mode 100644 index 00000000..05ada827 --- /dev/null +++ b/lib/Command/GlobalScaleDiscovery.php @@ -0,0 +1,45 @@ +setName('globalsiteselector:discovery') + ->addOption('current', '', InputOption::VALUE_NONE, 'display current data') + ->setDescription('run a discovery request over Global Scale to get details about each instances'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if ($input->getOption('current')) { + $output->writeln(json_encode($this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::GS_TOKENS), JSON_PRETTY_PRINT)); + return self::SUCCESS; + } + + // currently, the only available data is a unique token that helps identify each instance + $this->globalScaleService->refreshTokenFromGlobalScale(); + return self::SUCCESS; + } +} diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php new file mode 100644 index 00000000..60ce602f --- /dev/null +++ b/lib/ConfigLexicon.php @@ -0,0 +1,41 @@ + $this->globalScaleService->getLocalToken()]); + } + + /** + * return sharing details about a file. + * request must contain encoded jwt. + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function findFile(string $token, int $fileId): RedirectResponse { + return new RedirectResponse($this->urlGenerator->linkToRouteAbsolute('files.viewcontroller.showFile', ['fileid' => $this->globalShareService->getNewFileId($token, $fileId) ?? 1])); + } + + /** + * return sharing details about a file. + * request must contain encoded jwt. + */ + #[PublicPage] + #[NoCSRFRequired] + public function sharedFile(string $jwt): DataResponse { + $key = $this->gss->getJwtKey(); + $decoded = (array)JWT::decode($jwt, new Key($key, Application::JWT_ALGORITHM)); + // JWT store data as stdClass, not array + $decoded = json_decode(json_encode($decoded), true); + + $this->logger->debug('decoded request', ['data' => $decoded]); + + $fileId = (int)($decoded['fileId'] ?? 0); + $shareId = (int)($decoded['shareId'] ?? 0); + $instance = $decoded['instance'] ?? ''; + + $target = new LocalFile(); + $target->import($decoded['target'] ?? []); + + try { + // the file is local and returns shares related to it + return new DataResponse($this->globalShareService->getSharedFiles($fileId, $shareId, $instance, $target)); + } catch (SharedFileException $e) { + // file not found + return new DataResponse(['message' => $e->getMessage()], Http::STATUS_NOT_FOUND); + } catch (LocalFederatedShareException $e) { + // the file is not local and returns the shared folder and the path to the file + return new DataResponse($e->getFederatedShare(), Http::STATUS_MOVED_PERMANENTLY); + } + } + + /** * @PublicPage * @NoCSRFRequired diff --git a/lib/Db/FileRequest.php b/lib/Db/FileRequest.php new file mode 100644 index 00000000..367c4a75 --- /dev/null +++ b/lib/Db/FileRequest.php @@ -0,0 +1,200 @@ +getCachedMountInfoFromNodeId($fileId); + if ($cachedMount === null) { + return null; + } + + try { + $rootFolder = $this->rootFolder->getUserFolder($cachedMount->getUser()->getUID()); + } catch (Exception $e) { + $this->logger->warning('could not get root folder for user ' . $cachedMount->getUser()->getUID(), ['exception' => $e, 'fileId' => $fileId, 'userId' => $cachedMount->getUser()->getUID()]); + return null; + } + $node = $rootFolder->getFirstNodeById($fileId); + if ($node === null) { + return null; + } + + $details = new LocalFile(); + $details->setId($fileId) + ->setName($node->getName()) + ->setStorageId($cachedMount->getStorageId()) + ->setParent($node->getParentId()); + + return $details; + } + + /** + * return details about the mount point from a LocalFile + */ + public function getMountFromTarget(LocalFile $target): ?LocalMount { + $cachedMount = $this->getCachedMountInfoFromNodeId($target->getId()); + if ($cachedMount === null) { + return null; + } + + $mount = new LocalMount(); + $mount->setProviderClass($cachedMount->getMountProvider()) + ->setMountPoint(rtrim(explode('/files', $cachedMount->getMountPoint(), 2)[1] ?? '', '/')) + ->setUserId($cachedMount->getUser()->getUID()); + + return $mount; + } + + /** + * returns remote details about a team share mount point + */ + public function getFederatedTeamMount(LocalMount $mount, array $teamIds): ?FederatedShare { + $qb = $this->connection->getQueryBuilder(); + $qb->select('remote', 'remote_id') + ->from('circles_mount') + ->where( + $qb->expr()->eq('mountpoint_hash', $qb->createNamedParameter(md5($mount->getMountPoint()))), + $qb->expr()->in('circle_id', $qb->createNamedParameter($teamIds, IQueryBuilder::PARAM_STR_ARRAY)), + ); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + if ($row === false || ($row['remote'] ?? '') === '') { + return null; + } + + $federatedShare = new FederatedShare(); + $federatedShare->setRemote($row['remote']) + ->setRemoteId($row['remote_id']) + ->setBounce(true); + + $result->closeCursor(); + + return $federatedShare; + } + + /** + * returns id from a storage mount point + */ + public function getFilesFromExternalShareStorage(string $storageKey): int { + $qb = $this->connection->getQueryBuilder(); + $qb->select('c.fileid') + ->from('filecache', 'c') + ->from('storages', 's') + ->where( + $qb->expr()->andX( + $qb->expr()->eq('s.numeric_id', 'c.storage'), + $qb->expr()->eq('s.id', $qb->createNamedParameter($storageKey)), + $qb->expr()->eq('c.parent', $qb->createNamedParameter(-1, IQueryBuilder::PARAM_INT)), + ) + ); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + if ($row !== false) { + $fileId = (int)$row['fileid']; + } + $result->closeCursor(); + + return $fileId ?? 0; + } + + /** + * returns the storage key related to federated share from share_external + */ + public function getFederatedShareStorageKey(FederatedShare $federatedShare, string $instance): ?string { + $qb = $this->connection->getQueryBuilder(); + $qb->select('share_token', 'owner', 'remote') + ->from('share_external') + ->where( + $qb->expr()->andX( + $qb->expr()->like('remote', $qb->createNamedParameter('%://' . $instance . '/')), + $qb->expr()->eq('remote_id', $qb->createNamedParameter($federatedShare->getId(), IQueryBuilder::PARAM_INT)), + $qb->expr()->eq('user', $qb->createNamedParameter($federatedShare->getShareWith())) + ) + ); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + if ($row === false) { + return null; + } + $cloudId = $this->cloudIdManager->getCloudId($row['owner'], $row['remote']); + $storage = 'shared::' . md5($row['share_token'] . '@' . $cloudId->getRemote()); + $result->closeCursor(); + + return $storage; + } + + /** + * returns the storage key related to a federated share from circles_mount + */ + public function getTeamStorages(FederatedShare $federatedShare, string $instance): ?string { + $qb = $this->connection->getQueryBuilder(); + $qb->select('token', 'remote') + ->from('circles_mount') + ->where( + $qb->expr()->andX( + $qb->expr()->eq('remote', $qb->createNamedParameter($instance)), + $qb->expr()->eq('remote_id', $qb->createNamedParameter($federatedShare->getId(), IQueryBuilder::PARAM_INT)), + $qb->expr()->eq('circle_id', $qb->createNamedParameter($federatedShare->getShareWith())) + ) + ); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + if ($row === false) { + return null; + } + // why not storing md5 into circles_mount ? + $storage = 'shared::' . md5($row['token'] . '@https://' . $row['remote']); + $result->closeCursor(); + + return $storage; + } + + /** + * returns the mount using the id of a node, + * userid can then be extracted and used to retrieve the file's root folder + */ + private function getCachedMountInfoFromNodeId(int $nodeId): ?ICachedMountFileInfo { + $mounts = $this->userMountCache->getMountsForFileId($nodeId); + if (empty($mounts ?? [])) { + $this->logger->warning('mount not found for node id ' . $nodeId); + } + + return reset($mounts); + } +} diff --git a/lib/Db/ShareRequest.php b/lib/Db/ShareRequest.php new file mode 100644 index 00000000..9ca88d34 --- /dev/null +++ b/lib/Db/ShareRequest.php @@ -0,0 +1,129 @@ +getId()] = $entry; + $ids[] = $entry->getId(); + } + + $qb = $this->connection->getQueryBuilder(); + $qb->select('s.id', 's.file_source', 's.share_type', 's.share_with', 's.permissions') + ->from('share', 's') + ->where( + $qb->expr()->andX( + $qb->expr()->in('file_source', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)), + $qb->expr()->orX( + $qb->expr()->andX( + $qb->expr()->in('share_type', $qb->createNamedParameter([IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP], IQueryBuilder::PARAM_INT_ARRAY)), + $qb->expr()->like('share_with', $qb->createNamedParameter('%@' . $instance)), + ), + $qb->expr()->in('share_type', $qb->createNamedParameter([IShare::TYPE_CIRCLE], IQueryBuilder::PARAM_INT_ARRAY)), + ) + ) + ); + + $result = $qb->executeQuery(); + $shares = []; + while ($row = $result->fetch()) { + $shareWith = $row['share_with']; + if (str_ends_with(strtolower($shareWith), '@' . strtolower($instance))) { + $shareWith = substr($shareWith, 0, -strlen('@' . $instance)); + } + + $federatedShare = new FederatedShare(); + $federatedShare->setId($row['id']) + ->setFileId($row['file_source']) + ->setShareType($row['share_type']) + ->setShareWith($shareWith) + ->setPermissions($row['permissions']) + ->setTarget($indexedFiles[$row['file_source']]); + $shares[] = $federatedShare; + } + $result->closeCursor(); + + return $shares; + } + + /** + * return id and owner about a file. + * + * @return array{int, string} [fileId, fileOwner] + */ + public function getFileOwnerFromShareId(int $shareId): array { + $qb = $this->connection->getQueryBuilder(); + $qb->select('uid_owner', 'file_source') + ->from('share', 's') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($shareId, IQueryBuilder::PARAM_INT))); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + if ($row === false) { + return []; + } + $fileId = (int)$row['file_source']; + $owner = $row['uid_owner']; + $result->closeCursor(); + + return [$fileId, $owner]; + } + + /** + * returns details about the remote share linked to a local mount and + * how it is identified by the remote instance + */ + public function getBouncedShareFromLocalMount(LocalMount $mount): ?FederatedShare { + $qb = $this->connection->getQueryBuilder(); + $qb->select('remote', 'remote_id') + ->from('share_external') + ->where( + $qb->expr()->andX( + $qb->expr()->eq('user', $qb->createNamedParameter($mount->getUserId())), + $qb->expr()->eq('mountpoint_hash', $qb->createNamedParameter(md5($mount->getMountPoint()))), + ) + ); + + $result = $qb->executeQuery(); + $row = $result->fetch(); + if ($row === false) { + return null; + } + $bouncedShare = new FederatedShare(); + $bouncedShare->setBounce(true) + ->setRemote($row['remote']) + ->setRemoteId((int)$row['remote_id']); + $result->closeCursor(); + + return $bouncedShare; + } +} diff --git a/lib/Exceptions/LocalFederatedShareException.php b/lib/Exceptions/LocalFederatedShareException.php new file mode 100644 index 00000000..60324bec --- /dev/null +++ b/lib/Exceptions/LocalFederatedShareException.php @@ -0,0 +1,28 @@ +federatedShare; + } +} diff --git a/lib/Exceptions/SharedFileException.php b/lib/Exceptions/SharedFileException.php new file mode 100644 index 00000000..e8579e7e --- /dev/null +++ b/lib/Exceptions/SharedFileException.php @@ -0,0 +1,15 @@ +lookupServerUrl = $this->config->getSystemValueString('lookup_server', ''); @@ -148,6 +150,21 @@ private function getUserLocation_Sanitize(string $address, string &$uid): string return $address; } + /** + * get addresses of each instance of the global scale from lus + * + * @return string[] + */ + public function getInstances(): array { + $client = $this->clientService->newClient(); + $response = $client->get($this->lookupServerUrl . '/gs/instances', $this->configureClient(['body' => json_encode(['authKey' => $this->gss->getJwtKey()])])); + + try { + return json_decode($response->getBody(), true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException) { + return []; + } + } public function sanitizeUid(string &$uid = ''): void { if ($this->config->getSystemValueString('gss.username_format', '') !== 'sanitize') { diff --git a/lib/Master.php b/lib/Master.php index ca3a0609..a5524033 100644 --- a/lib/Master.php +++ b/lib/Master.php @@ -328,9 +328,7 @@ protected function getAppToken($location, $uid, $password, $options) { $data = json_decode($body, true); $jsonErrorCode = json_last_error(); if ($jsonErrorCode !== JSON_ERROR_NONE) { - $info = 'getAppToken - Decoding the JSON failed ' - . $jsonErrorCode . ' ' - . json_last_error_msg(); + $info = 'getAppToken - Decoding the JSON failed ' . $jsonErrorCode . ' ' . json_last_error_msg(); throw new Exception($info); } if (!isset($data['ocs']['data']['token'])) { diff --git a/lib/Model/FederatedShare.php b/lib/Model/FederatedShare.php new file mode 100644 index 00000000..c6881e74 --- /dev/null +++ b/lib/Model/FederatedShare.php @@ -0,0 +1,158 @@ +id = $id; + return $this; + } + + public function getId(): int { + return $this->id; + } + + public function setFileId(int $fileId): self { + $this->fileId = $fileId; + return $this; + } + + public function getFileId(): int { + return $this->fileId; + } + + public function setShareType(int $shareType): self { + $this->shareType = $shareType; + return $this; + } + + public function getShareType(): int { + return $this->shareType; + } + + public function setShareWith(string $shareWith): self { + $this->shareWith = $shareWith; + return $this; + } + + public function getShareWith(): string { + return $this->shareWith; + } + + public function setPermissions(int $permissions): self { + $this->permissions = $permissions; + return $this; + } + + public function getPermissions(): int { + return $this->permissions; + } + + public function setTarget(LocalFile $target): self { + $this->target = $target; + return $this; + } + + public function getTarget(): ?LocalFile { + return $this->target; + } + + public function setBounce(bool $bounce): self { + $this->bounce = $bounce; + return $this; + } + + public function isBounce(): bool { + return $this->bounce; + } + + public function setRemote(string $remote): self { + $this->remote = $remote; + return $this; + } + + public function getRemote(): string { + return $this->remote; + } + + public function setRemoteId(int $remoteId): self { + $this->remoteId = $remoteId; + return $this; + } + + public function getRemoteId(): int { + return $this->remoteId; + } + + /** + * deserialize model + */ + public function import(array $data): self { + $this->setBounce($data['bounce'] ?? false); + if ($this->isBounce()) { + $this->setRemoteId($data['remoteId'] ?? 0) + ->setRemote($data['remote'] ?? ''); + } else { + $this->setId($data['id'] ?? 0) + ->setFileId($data['fileId'] ?? 0) + ->setShareType($data['shareType'] ?? 0) + ->setShareWith($data['shareWith'] ?? '') + ->setPermissions($data['permissions'] ?? 0); + } + + if (array_key_exists('target', $data)) { + $target = new LocalFile(); + $target->import($data['target']); + $this->setTarget($target); + } + + return $this; + } + + /** + * @return array{id: int, fileId: int, shareType: int, shareWith: string, permissions: int, target: array, remote: string, remoteId: int} + */ + public function jsonSerialize(): array { + if ($this->isBounce()) { + return [ + 'remote' => $this->getRemote(), + 'remoteId' => $this->getRemoteId(), + 'target' => $this->getTarget(), + 'bounce' => $this->isBounce(), + ]; + } + + return [ + 'id' => $this->getId(), + 'fileId' => $this->getFileId(), + 'shareType' => $this->getShareType(), + 'shareWith' => $this->getShareWith(), + 'permissions' => $this->getPermissions(), + 'target' => $this->getTarget(), + ]; + + } +} diff --git a/lib/Model/LocalFile.php b/lib/Model/LocalFile.php new file mode 100644 index 00000000..e59a8174 --- /dev/null +++ b/lib/Model/LocalFile.php @@ -0,0 +1,102 @@ +id; + } + + public function setId(int $id): self { + $this->id = $id; + return $this; + } + + public function getName(): string { + return $this->name; + } + + public function setName(string $name): self { + $this->name = $name; + return $this; + } + + public function getStorageId(): int { + return $this->storageId; + } + + public function setStorageId(int $storageId): self { + $this->storageId = $storageId; + return $this; + } + + public function getParent(): int { + return $this->parent; + } + + public function setParent(int $parent): self { + $this->parent = $parent; + return $this; + } + + /** + * @return string[] + */ + public function getPath(): array { + return $this->path; + } + + /** + * @param string[] $path + * + * @return $this + */ + public function setPath(array $path): self { + $this->path = $path; + return $this; + } + + /** + * deserialize model + */ + public function import(array $data): self { + $this->setId($data['id'] ?? 0) + ->setName($data['name'] ?? '') + ->setStorageId($data['storageId'] ?? -1) + ->setParent($data['parent'] ?? -1) + ->setPath($data['path'] ?? []); + + return $this; + } + + /** + * @return array{id: int, name: string, storageId: int, parent: int, path: string[]} + */ + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'name' => $this->getName(), + 'storageId' => $this->getStorageId(), + 'parent' => $this->getParent(), + 'path' => $this->getPath(), + ]; + } +} diff --git a/lib/Model/LocalMount.php b/lib/Model/LocalMount.php new file mode 100644 index 00000000..2bbeb879 --- /dev/null +++ b/lib/Model/LocalMount.php @@ -0,0 +1,58 @@ +providerClass = $providerClass; + return $this; + } + + public function getProviderClass(): string { + return $this->providerClass; + } + + public function setMountPoint(string $mountPoint): self { + $this->mountPoint = $mountPoint; + return $this; + } + + public function getMountPoint(): string { + return $this->mountPoint; + } + + public function setUserId(string $userId): self { + $this->userId = $userId; + return $this; + } + + public function getUserId(): string { + return $this->userId; + } + + /** + * @return array{provider: string, mountPoint: string, userId: string} + */ + public function jsonSerialize(): array { + return [ + 'provider' => $this->getProviderClass(), + 'mountPoint' => $this->getMountPoint(), + 'userId' => $this->getUserId(), + ]; + } +} diff --git a/lib/PublicCapabilities.php b/lib/PublicCapabilities.php index c7bfd041..4fc8c270 100644 --- a/lib/PublicCapabilities.php +++ b/lib/PublicCapabilities.php @@ -8,14 +8,21 @@ namespace OCA\GlobalSiteSelector; +use OCA\GlobalSiteSelector\Service\GlobalScaleService; use OCP\Capabilities\IPublicCapability; class PublicCapabilities implements IPublicCapability { + public function __construct( + private readonly GlobalScaleService $globalScaleService, + ) { + + } public function getCapabilities(): array { return [ 'globalscale' => [ 'enabled' => true, 'desktoplogin' => 1, + 'token' => $this->globalScaleService->getLocalToken(), ] ]; } diff --git a/lib/Service/GlobalScaleService.php b/lib/Service/GlobalScaleService.php new file mode 100644 index 00000000..edc7d29c --- /dev/null +++ b/lib/Service/GlobalScaleService.php @@ -0,0 +1,169 @@ +appConfig->hasKey(Application::APP_ID, ConfigLexicon::LOCAL_TOKEN)) { + $this->appConfig->setValueString(Application::APP_ID, ConfigLexicon::LOCAL_TOKEN, $this->secureRandom->generate(5, 'abcdefghijklmnopqrstuvwxyz0123456789')); + } + + return $this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::LOCAL_TOKEN); + } + + /** + * return local address as known by lus + */ + public function getLocalAddress(): ?string { + return $this->getAddressFromToken($this->getLocalToken()); + } + + /** + * confirm a specific global scale token identify local instance + */ + public function isLocalToken(string $token): bool { + return ($this->appConfig->getValueString(Application::APP_ID, ConfigLexicon::LOCAL_TOKEN) === $token); + } + + /** + * confirm that a url (or a host) is related to local instance + */ + public function isLocalAddress(string $address): bool { + if (str_contains($address, '://')) { + $address = parse_url($address, PHP_URL_HOST); + } + return ($this->getLocalAddress() === $address); + } + + /** + * get global scale identity token from each instance of the global scale + */ + public function refreshTokenFromGlobalScale(): void { + if (!$this->gss->isSlave()) { + return; + } + + foreach ($this->lookup->getInstances() as $address) { + $this->refreshTokenFromAddress($address); + } + } + + /** + * request global scale token from a remote instance using public discovery and store it in local cache + */ + public function refreshTokenFromAddress(string $address): void { + if (!$this->gss->isSlave()) { + return; + } + + $token = $this->getRemotePublicDiscovery($address)['token'] ?? ''; + if ($token === '' || strlen($token) < 5) { + return; + } + + $tokens = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::GS_TOKENS); + if (($tokens[$address] ?? '') === $token) { + return; + } + + $tokens[$address] = $token; + $this->appConfig->setValueArray(Application::APP_ID, ConfigLexicon::GS_TOKENS, $tokens); + } + + /** + * get address from a global scale token + */ + public function getAddressFromToken(string $token): ?string { + $tokens = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::GS_TOKENS); + $address = array_search($token, $tokens, true); + if (!$address) { + return null; + } + return $address; + } + + /** + * returns global scale token from a specific address + */ + public function getTokenFromAddress(string $address): ?string { + $tokens = $this->appConfig->getValueArray(Application::APP_ID, ConfigLexicon::GS_TOKENS); + return $tokens[$address] ?? null; + } + + /** + * returns discovery data from a remote address + */ + public function getRemotePublicDiscovery(string $address): array { + return $this->requestGssOcs($address, 'Slave.discovery'); + } + + /** + * get data from a remote globalsiteselector ocs endpoint. + * + * @param string $address remote global scale instance + * @param string $route route name to the ocs endpoint + * @param array $data added to the request + * @param int $responseCode contains the response code from the request + * + * @return array decoded version of the json response + */ + public function requestGssOcs(string $address, string $route, array $data = [], int &$responseCode = 0): array { + $client = $this->clientService->newClient(); + try { + $response = $client->get( + 'https://' . $address . parse_url($this->urlGenerator->linkToOCSRouteAbsolute('globalsiteselector.' . $route), PHP_URL_PATH), + [ + 'headers' => ['OCS-APIRequest' => 'true'], + 'verify' => !$this->config->getSystemValueBool('gss.selfsigned.allow', false), + 'query' => array_merge($data, ['format' => 'json']) + ] + ); + } catch (Exception $e) { + $this->logger->warning('could not reach remote gss ocs', ['exception' => $e]); + return []; + } + + try { + $responseCode = $response->getStatusCode(); + return json_decode($response->getBody(), true, flags: JSON_THROW_ON_ERROR)['ocs']['data'] ?? []; + } catch (JsonException $e) { + $this->logger->warning('could not decode json', ['exception' => $e]); + return []; + } + } +} diff --git a/lib/Service/GlobalShareService.php b/lib/Service/GlobalShareService.php new file mode 100644 index 00000000..175b192d --- /dev/null +++ b/lib/Service/GlobalShareService.php @@ -0,0 +1,436 @@ +userSession->getUser()?->getUID(); + // There is no valid reason for getUser() to be null, + if ($currentUser === null) { + $this->logger->warning('internal link request', ['exception' => new Exception('could not assign current user')]); + return null; + } + + // if token represents the local instance, fall back to normal behavior using file id + if ($this->globalScaleService->isLocalToken($token)) { + try { + $this->getSharedFiles($fileId); + return $fileId; + } catch (SharedFileException) { + return null; + } catch (LocalFederatedShareException $e) { + // file is not local + $federatedShare = $e->getFederatedShare(); + $remote = $federatedShare->getRemote(); + + // this should never be the case, but it confirms the file is not local + if (!$federatedShare->isBounce() || $this->globalScaleService->isLocalAddress($remote)) { + return null; + } + + // Get the list of federated shares between both instances that would provide access to the file. + // The file is identified by share mount point id and path to the final file. + $federatedShares = $this->requestRemoteFederatedShares($remote, ['shareId' => $federatedShare->getRemoteId(), 'target' => $federatedShare->getTarget()?->jsonSerialize() ?? []], true); + return $this->getLastFileIdFromShares($currentUser, $federatedShares, $remote); + } + } + + // extract instance linked to token + $instance = $this->globalScaleService->getAddressFromToken($token); + + // unknown instance, make it file not found + if ($instance === null) { + return null; + } + + // request the remote instance to get the list of existing federated shares between both instances and about the remote file id + return $this->getSharedFileRemoteDetails($instance, $fileId); + } + + + /** + * @param string|null $instance set to NULL when assuming local + * @return FederatedShare[] + * @throws SharedFileException + * @throws LocalFederatedShareException + */ + public function getSharedFiles(int $fileId, int $shareId = 0, ?string $instance = null, ?LocalFile $target = null): array { + // in case of redirection, we get the final file id from share mount id and path to the file + if ($shareId > 0 && $target !== null) { + $fileId = $this->getIdFromSharedTarget($shareId, $target); + } + + if ($fileId === 0 || $instance === '') { + throw new SharedFileException('missing argument'); + } + + // from a file id, get all parents until mount point + $files = $this->getRelatedFiles((int)$fileId); + if (empty($files)) { + throw new SharedFileException('file not found'); + } + + // based on the mount point (last element of the list, top parent folder) we know if the file is local or a federated share from another instance + $mountPoint = array_slice($files, -1)[0]; + + // In case the mount point is a remote share, we send the correct remote instance and the remote share id + $remoteShare = $this->getFederatedShareFromTargetLocalFile($mountPoint); + + if ($remoteShare?->isBounce() === true) { + // from the base mount point we add the target to reach the destination filew + $remoteShare->setTarget($mountPoint); + throw new LocalFederatedShareException($remoteShare); + } + + if ($instance === null) { + return []; + } + + // mount point is local, we return the list of shares between the remote instance and the related files + return $this->shareRequest->getFederatedSharesRelatedToRemoteInstance($files, $instance); + } + + + /** + * get details about a shared remote file based on the address of the remote + * instance and the id of the file as stored on that remote instance + * + * @param string $remote address of the remote instance + * @param int $remoteFileId id of the file as stored on the remote instance + * @return int local file id, 1 if not found + */ + private function getSharedFileRemoteDetails(string $remote, int $remoteFileId): int { + $currentUser = $this->userSession->getUser()?->getUID(); + if ($currentUser === null || $this->globalScaleService->getLocalAddress() === null) { + return 1; + } + + try { + $federatedShares = $this->requestRemoteFederatedShares($remote, ['fileId' => $remoteFileId]); + } catch (LocalFederatedShareException $e) { + // share is local, meaning we should be able to locally find the id of the file + $federatedShare = $e->getFederatedShare(); + [$fileId, $fileOwner] = $this->shareRequest->getFileOwnerFromShareId($federatedShare->getRemoteId()); + return $this->getFinalFileId($fileOwner, $fileId, $federatedShare->getTarget()); + } + + if (empty($federatedShares)) { + return 1; + } + + $this->logger->warning('federated shares', ['remote' => $remote, 'remoteFileId' => $remoteFileId, 'federatedShares' => json_decode(json_encode($federatedShares), true)]); + + return $this->getLastFileIdFromShares($currentUser, $federatedShares, $remote); + } + + /** + * returns details about a local file and (recursively) about all parent folders + * + * @return LocalFile[] + */ + private function getRelatedFiles(int $fileId): array { + if ($fileId === 0) { + return []; + } + + $files = $path = []; + for ($i = 0; $i < self::LIMIT_PARENTS; $i++) { + $fileDetails = $this->fileRequest->getFileDetails($fileId); + if ($fileDetails === null) { + break; + } + + $fileId = $fileDetails->getParent(); + $fileDetails->setPath($path); + $files[] = $fileDetails; + if ($fileId === -1) { + break; + } + $path[] = $fileDetails->getName(); + } + + return $files; + } + + /** + * returns details about the remote share linked to a local mount, how + * it is identified by the remote instance and the path to get back to + * the target file from the mount point. + * + * @return FederatedShare|null if no federated share were found + */ + private function getFederatedShareFromTargetLocalFile(LocalFile $target): ?FederatedShare { + $mount = $this->fileRequest->getMountFromTarget($target); + if ($mount === null) { + return null; + } + + if ($mount->getProviderClass() === '') { + // could be from a federated team + $teamMount = $this->fileRequest->getFederatedTeamMount($mount, $this->getCurrentTeams($mount->getUserId())); + if ($teamMount !== null) { + return $teamMount->setTarget($target); + } + } + + if ($mount->getProviderClass() !== MountProvider::class) { + return null; + } + + $federatedShare = $this->shareRequest->getBouncedShareFromLocalMount($mount); + $federatedShare?->setTarget($target); + + return $federatedShare; + } + + private function getIdFromSharedTarget(int $shareId, LocalFile $target): int { + [$fileId, $fileOwner] = $this->shareRequest->getFileOwnerFromShareId($shareId); + return $this->getFinalFileId($fileOwner, $fileId, $target); + } + + /** + * Return a file id based on a list of available shares. + * A preferred share is selected based on permissions. + * Path will be applied to share mount point. + * + * @param FederatedShare[] $federatedShares + */ + private function getLastFileIdFromShares(string $userId, array $federatedShares, string $instance): int { + if (str_contains($instance, '://')) { + $instance = parse_url($instance, PHP_URL_HOST); + } + + $permission = -1; + $higherPermissionShare = null; + foreach ($federatedShares as $federatedShare) { + if ($this->compareShare($userId, $federatedShare, $permission)) { + $higherPermissionShare = $federatedShare; + } + } + + // no shares are linked to current user. + if ($higherPermissionShare === null) { + return 1; + } + + try { + $storageKey = match ($higherPermissionShare->getShareType()) { + IShare::TYPE_REMOTE, IShare::TYPE_REMOTE_GROUP => $this->fileRequest->getFederatedShareStorageKey($higherPermissionShare, $instance), + IShare::TYPE_CIRCLE => $this->fileRequest->getTeamStorages($higherPermissionShare, $instance), + }; + } catch (UnhandledMatchError) { + return 1; + } + + if ($storageKey === null) { + return 1; + } + + // returns the id of the file at the end of the mount point + path to the file + $rootFileId = $this->fileRequest->getFilesFromExternalShareStorage($storageKey); + return $this->getFinalFileId($userId, $rootFileId, $higherPermissionShare->getTarget()); + } + + /** + * compare shares and extract the higher permission, + * confirm the link between current user and share. + * + * @return bool true if share has better permissions + */ + private function compareShare(string $userId, FederatedShare $federatedShare, int &$currentPermission): bool { + if ($federatedShare->getId() === 0 || $federatedShare->getShareWith() === '') { + return false; + } + + if ($currentPermission >= $federatedShare->getPermissions()) { + return false; + } + + if (($federatedShare->getShareType() === IShare::TYPE_REMOTE && $federatedShare->getShareWith() === $userId) + || ($federatedShare->getShareType() === IShare::TYPE_REMOTE_GROUP && in_array($federatedShare->getShareWith(), $this->getCurrentGroups($userId), true)) + || ($federatedShare->getShareType() === IShare::TYPE_CIRCLE && in_array($federatedShare->getShareWith(), $this->getCurrentTeams($userId), true))) { + $currentPermission = $federatedShare->getPermissions(); + return true; + } + + return false; + } + + /** + * Return the id of a local file based on the node id of the top + * folder / mount point and the path to reach the file + * + * @return int 1 if file not found + */ + private function getFinalFileId(string $user, int $nodeId, LocalFile $target): int { + try { + $userFolder = $this->rootFolder->getUserFolder($user); + } catch (Exception $e) { + $this->logger->debug('could not get final file id', ['exception' => $e]); + return 1; + } + + $folder = $userFolder->getFirstNodeById($nodeId); + if ($folder === null) { + return 1; + } + + foreach (array_reverse($target->getPath()) as $name) { + try { + $folder = $folder->get($name); + } catch (NotFoundException|NotPermittedException $e) { + $this->logger->debug('could not get final file id', ['exception' => $e]); + return 1; + } + } + + return $folder->getId(); + } + + /** + * request remote instance to get the list of federated shares between both instances that would + * provide access to file id search can also be performed on the share id. + * + * @return FederatedShare[] + * @throws LocalFederatedShareException if the federated share is not remote + */ + private function requestRemoteFederatedShares(string &$remote, array $search, bool $redirected = false): array { + if (str_contains($remote, '://')) { + $remote = parse_url($remote, PHP_URL_HOST); + } + + // this should not happen, but we keep a trace + if ($this->globalScaleService->isLocalAddress($remote)) { + $this->logger->warning('remote is local', ['exception' => new Exception(), 'remote' => $remote]); + return []; + } + + $responseCode = 0; + $result = $this->globalScaleService->requestGssOcs( + $remote, + 'Slave.sharedFile', + ['jwt' => JWT::encode(array_merge($search, ['instance' => $this->globalScaleService->getLocalAddress()]), $this->gss->getJwtKey(), Application::JWT_ALGORITHM)], + $responseCode); + + $this->logger->warning('result from remote gss ocs', ['remote' => $remote, 'search' => $search, 'data' => $result, 'responseCode' => $responseCode]); + + // in case file is not on remote instance, we get a redirection + if (!$redirected && $responseCode === Http::STATUS_MOVED_PERMANENTLY) { + $federatedShare = new FederatedShare(); + $federatedShare->import($result); + if (!$federatedShare->isBounce()) { + return []; + } + + // on redirection, we update &$remote + $remote = $federatedShare->getRemote(); + + /** + * in case of redirection (the file belongs to a different instance than the one that generates the internal-link), + * we check the new remote it is not current (local) instance. + * + * remoteId is the share id, so we extract the id of the shared file. + */ + if ($this->globalScaleService->isLocalAddress($remote)) { + throw new LocalFederatedShareException($federatedShare); + } + + return $this->requestRemoteFederatedShares($remote, ['shareId' => $federatedShare->getRemoteId(), 'target' => $federatedShare->getTarget()?->jsonSerialize() ?? []], true); + } + + if ($responseCode !== Http::STATUS_OK) { + return []; + } + + $federatedShares = []; + foreach ($result as $entry) { + $federatedShare = new FederatedShare(); + $federatedShare->import($entry); + if (!$federatedShare->isBounce()) { + $federatedShares[] = $federatedShare; + } + } + + return $federatedShares; + } + + /** + * cache and returns list of current groups a userId belongs to + */ + private function getCurrentGroups(string $userId): array { + if (!array_key_exists($userId, $this->currentGroups)) { + $user = $this->userManager->get($userId); + if ($user === null) { + return []; + } + $this->currentGroups[$userId] = $this->groupManager->getUserGroupIds($user); + } + + return $this->currentGroups[$userId]; + } + + /** + * cache and returns list of current teams a userId belongs to + */ + private function getCurrentTeams(string $userId): array { + if (!array_key_exists($userId, $this->currentTeams)) { + $this->circlesManager->startSession($this->circlesManager->getLocalFederatedUser($userId)); + $teams = array_map(fn (Circle $team): string => $team->getSingleId(), $this->circlesManager->probeCircles()); + + $this->currentTeams[$userId] = $teams; + } + + return $this->currentTeams[$userId]; + } +} diff --git a/tests/unit/lib/Controller/SlaveControllerTest.php b/tests/unit/lib/Controller/SlaveControllerTest.php index 5724ccbd..25f1fbff 100644 --- a/tests/unit/lib/Controller/SlaveControllerTest.php +++ b/tests/unit/lib/Controller/SlaveControllerTest.php @@ -11,6 +11,8 @@ use OCA\GlobalSiteSelector\AppInfo\Application; use OCA\GlobalSiteSelector\Controller\SlaveController; use OCA\GlobalSiteSelector\GlobalSiteSelector; +use OCA\GlobalSiteSelector\Service\GlobalScaleService; +use OCA\GlobalSiteSelector\Service\GlobalShareService; use OCA\GlobalSiteSelector\Service\SlaveService; use OCA\GlobalSiteSelector\TokenHandler; use OCA\GlobalSiteSelector\UserBackend; @@ -36,6 +38,8 @@ class SlaveControllerTest extends TestCase { private IUserManager $userManager; private UserBackend $userBackend; private ISession $session; + private GlobalScaleService $globalScaleService; + private GlobalShareService $globalShareService; private SlaveService $slaveService; private IConfig $config; @@ -56,6 +60,8 @@ public function setUp(): void { ->disableOriginalConstructor()->getMock(); $this->session = $this->createMock(ISession::class); $this->slaveService = $this->createMock(SlaveService::class); + $this->globalScaleService = $this->createMock(GlobalScaleService::class); + $this->globalShareService = $this->createMock(GlobalShareService::class); $this->config = $this->createMock(IConfig::class); } @@ -78,6 +84,8 @@ private function getInstance(array $mockMathods = []) { $this->userBackend, $this->session, $this->slaveService, + $this->globalScaleService, + $this->globalShareService, $this->config, $this->logger ] diff --git a/tests/unit/lib/LookupTest.php b/tests/unit/lib/LookupTest.php index 82c8ee63..b8b16818 100644 --- a/tests/unit/lib/LookupTest.php +++ b/tests/unit/lib/LookupTest.php @@ -8,6 +8,7 @@ namespace OCA\GlobalSiteSelector\Tests\Unit; +use OCA\GlobalSiteSelector\GlobalSiteSelector; use OCA\GlobalSiteSelector\Lookup; use OCP\Federation\ICloudId; use OCP\Federation\ICloudIdManager; @@ -21,6 +22,7 @@ class LookupTest extends TestCase { private IConfig $config; private LoggerInterface $logger; private ICloudIdManager $cloudIdManager; + private GlobalSiteSelector $gss; public function setUp(): void { parent::setUp(); @@ -28,6 +30,7 @@ public function setUp(): void { $this->httpClientService = $this->createMock(IClientService::class); $this->config = $this->createMock(IConfig::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->gss = $this->createMock(GlobalSiteSelector::class); $this->cloudIdManager = $this->createMock(ICloudIdManager::class); } @@ -44,6 +47,7 @@ private function getInstance(array $mockMethods = []) { $this->httpClientService, $this->logger, $this->cloudIdManager, + $this->gss, $this->config ] )->onlyMethods($mockMethods)->getMock();