diff options
Diffstat (limited to 'apps/files_sharing/lib/SharedStorage.php')
-rw-r--r-- | apps/files_sharing/lib/SharedStorage.php | 564 |
1 files changed, 564 insertions, 0 deletions
diff --git a/apps/files_sharing/lib/SharedStorage.php b/apps/files_sharing/lib/SharedStorage.php new file mode 100644 index 00000000000..e310c5f3138 --- /dev/null +++ b/apps/files_sharing/lib/SharedStorage.php @@ -0,0 +1,564 @@ +<?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; + +use OC\Files\Cache\CacheDependencies; +use OC\Files\Cache\CacheEntry; +use OC\Files\Cache\FailedCache; +use OC\Files\Cache\NullWatcher; +use OC\Files\ObjectStore\HomeObjectStoreStorage; +use OC\Files\Storage\Common; +use OC\Files\Storage\FailedStorage; +use OC\Files\Storage\Home; +use OC\Files\Storage\Storage; +use OC\Files\Storage\Wrapper\Jail; +use OC\Files\Storage\Wrapper\PermissionsMask; +use OC\Files\Storage\Wrapper\Wrapper; +use OC\Files\View; +use OC\Share\Share; +use OC\User\NoUserException; +use OCA\Files_Sharing\ISharedStorage as LegacyISharedStorage; +use OCP\Constants; +use OCP\Files\Cache\ICache; +use OCP\Files\Cache\ICacheEntry; +use OCP\Files\Cache\IScanner; +use OCP\Files\Cache\IWatcher; +use OCP\Files\Folder; +use OCP\Files\IHomeStorage; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\IDisableEncryptionStorage; +use OCP\Files\Storage\ILockingStorage; +use OCP\Files\Storage\ISharedStorage; +use OCP\Files\Storage\IStorage; +use OCP\Lock\ILockingProvider; +use OCP\Server; +use OCP\Share\IShare; +use OCP\Util; +use Psr\Log\LoggerInterface; + +/** + * Convert target path to source path and pass the function call to the correct storage provider + */ +class SharedStorage extends Jail implements LegacyISharedStorage, ISharedStorage, IDisableEncryptionStorage { + /** @var IShare */ + private $superShare; + + /** @var IShare[] */ + private $groupedShares; + + /** + * @var View + */ + private $ownerView; + + private $initialized = false; + + /** + * @var ICacheEntry + */ + private $sourceRootInfo; + + /** @var string */ + private $user; + + private LoggerInterface $logger; + + /** @var IStorage */ + private $nonMaskedStorage; + + private array $mountOptions = []; + + /** @var boolean */ + private $sharingDisabledForUser; + + /** @var ?Folder $ownerUserFolder */ + private $ownerUserFolder = null; + + private string $sourcePath = ''; + + private static int $initDepth = 0; + + /** + * @psalm-suppress NonInvariantDocblockPropertyType + * @var ?Storage $storage + */ + protected $storage; + + public function __construct(array $parameters) { + $this->ownerView = $parameters['ownerView']; + $this->logger = Server::get(LoggerInterface::class); + + $this->superShare = $parameters['superShare']; + $this->groupedShares = $parameters['groupedShares']; + + $this->user = $parameters['user']; + if (isset($parameters['sharingDisabledForUser'])) { + $this->sharingDisabledForUser = $parameters['sharingDisabledForUser']; + } else { + $this->sharingDisabledForUser = false; + } + + parent::__construct([ + 'storage' => null, + 'root' => null, + ]); + } + + /** + * @return ICacheEntry + */ + private function getSourceRootInfo() { + if (is_null($this->sourceRootInfo)) { + if (is_null($this->superShare->getNodeCacheEntry())) { + $this->init(); + $this->sourceRootInfo = $this->nonMaskedStorage->getCache()->get($this->rootPath); + } else { + $this->sourceRootInfo = $this->superShare->getNodeCacheEntry(); + } + } + return $this->sourceRootInfo; + } + + /** + * @psalm-assert Storage $this->storage + */ + private function init() { + if ($this->initialized) { + if (!$this->storage) { + // marked as initialized but no storage set + // this is probably because some code path has caused recursion during the share setup + // we setup a "failed storage" so `getWrapperStorage` doesn't return null. + // If the share setup completes after this the "failed storage" will be overwritten by the correct one + $this->logger->warning('Possible share setup recursion detected'); + $this->storage = new FailedStorage(['exception' => new \Exception('Possible share setup recursion detected')]); + $this->cache = new FailedCache(); + $this->rootPath = ''; + } + return; + } + + $this->initialized = true; + self::$initDepth++; + + try { + if (self::$initDepth > 10) { + throw new \Exception('Maximum share depth reached'); + } + + /** @var IRootFolder $rootFolder */ + $rootFolder = Server::get(IRootFolder::class); + $this->ownerUserFolder = $rootFolder->getUserFolder($this->superShare->getShareOwner()); + $sourceId = $this->superShare->getNodeId(); + $ownerNodes = $this->ownerUserFolder->getById($sourceId); + + if (count($ownerNodes) === 0) { + $this->storage = new FailedStorage(['exception' => new NotFoundException("File by id $sourceId not found")]); + $this->cache = new FailedCache(); + $this->rootPath = ''; + } else { + foreach ($ownerNodes as $ownerNode) { + $nonMaskedStorage = $ownerNode->getStorage(); + + // check if potential source node would lead to a recursive share setup + if ($nonMaskedStorage instanceof Wrapper && $nonMaskedStorage->isWrapperOf($this)) { + continue; + } + $this->nonMaskedStorage = $nonMaskedStorage; + $this->sourcePath = $ownerNode->getPath(); + $this->rootPath = $ownerNode->getInternalPath(); + $this->cache = null; + break; + } + if (!$this->nonMaskedStorage) { + // all potential source nodes would have been recursive + throw new \Exception('recursive share detected'); + } + $this->storage = new PermissionsMask([ + 'storage' => $this->nonMaskedStorage, + 'mask' => $this->superShare->getPermissions(), + ]); + } + } catch (NotFoundException $e) { + // original file not accessible or deleted, set FailedStorage + $this->storage = new FailedStorage(['exception' => $e]); + $this->cache = new FailedCache(); + $this->rootPath = ''; + } catch (NoUserException $e) { + // sharer user deleted, set FailedStorage + $this->storage = new FailedStorage(['exception' => $e]); + $this->cache = new FailedCache(); + $this->rootPath = ''; + } catch (\Exception $e) { + $this->storage = new FailedStorage(['exception' => $e]); + $this->cache = new FailedCache(); + $this->rootPath = ''; + $this->logger->error($e->getMessage(), ['exception' => $e]); + } + + if (!$this->nonMaskedStorage) { + $this->nonMaskedStorage = $this->storage; + } + self::$initDepth--; + } + + public function instanceOfStorage(string $class): bool { + if ($class === '\OC\Files\Storage\Common' || $class == Common::class) { + return true; + } + if (in_array($class, [ + '\OC\Files\Storage\Home', + '\OC\Files\ObjectStore\HomeObjectStoreStorage', + '\OCP\Files\IHomeStorage', + Home::class, + HomeObjectStoreStorage::class, + IHomeStorage::class + ])) { + return false; + } + return parent::instanceOfStorage($class); + } + + /** + * @return string + */ + public function getShareId() { + return $this->superShare->getId(); + } + + private function isValid(): bool { + return $this->getSourceRootInfo() && ($this->getSourceRootInfo()->getPermissions() & Constants::PERMISSION_SHARE) === Constants::PERMISSION_SHARE; + } + + public function getId(): string { + return 'shared::' . $this->getMountPoint(); + } + + public function getPermissions(string $path = ''): int { + if (!$this->isValid()) { + return 0; + } + $permissions = parent::getPermissions($path) & $this->superShare->getPermissions(); + + // part files and the mount point always have delete permissions + if ($path === '' || pathinfo($path, PATHINFO_EXTENSION) === 'part') { + $permissions |= Constants::PERMISSION_DELETE; + } + + if ($this->sharingDisabledForUser) { + $permissions &= ~Constants::PERMISSION_SHARE; + } + + return $permissions; + } + + public function isCreatable(string $path): bool { + return (bool)($this->getPermissions($path) & Constants::PERMISSION_CREATE); + } + + public function isReadable(string $path): bool { + if (!$this->isValid()) { + return false; + } + if (!$this->file_exists($path)) { + return false; + } + /** @var IStorage $storage */ + /** @var string $internalPath */ + [$storage, $internalPath] = $this->resolvePath($path); + return $storage->isReadable($internalPath); + } + + public function isUpdatable(string $path): bool { + return (bool)($this->getPermissions($path) & Constants::PERMISSION_UPDATE); + } + + public function isDeletable(string $path): bool { + return (bool)($this->getPermissions($path) & Constants::PERMISSION_DELETE); + } + + public function isSharable(string $path): bool { + if (Util::isSharingDisabledForUser() || !Share::isResharingAllowed()) { + return false; + } + return (bool)($this->getPermissions($path) & Constants::PERMISSION_SHARE); + } + + public function fopen(string $path, string $mode) { + $source = $this->getUnjailedPath($path); + switch ($mode) { + case 'r+': + case 'rb+': + case 'w+': + case 'wb+': + case 'x+': + case 'xb+': + case 'a+': + case 'ab+': + case 'w': + case 'wb': + case 'x': + case 'xb': + case 'a': + case 'ab': + $creatable = $this->isCreatable(dirname($path)); + $updatable = $this->isUpdatable($path); + // if neither permissions given, no need to continue + if (!$creatable && !$updatable) { + if (pathinfo($path, PATHINFO_EXTENSION) === 'part') { + $updatable = $this->isUpdatable(dirname($path)); + } + + if (!$updatable) { + return false; + } + } + + $exists = $this->file_exists($path); + // if a file exists, updatable permissions are required + if ($exists && !$updatable) { + return false; + } + + // part file is allowed if !$creatable but the final file is $updatable + if (pathinfo($path, PATHINFO_EXTENSION) !== 'part') { + if (!$exists && !$creatable) { + return false; + } + } + } + $info = [ + 'target' => $this->getMountPoint() . '/' . $path, + 'source' => $source, + 'mode' => $mode, + ]; + Util::emitHook('\OC\Files\Storage\Shared', 'fopen', $info); + return $this->nonMaskedStorage->fopen($this->getUnjailedPath($path), $mode); + } + + public function rename(string $source, string $target): bool { + $this->init(); + $isPartFile = pathinfo($source, PATHINFO_EXTENSION) === 'part'; + $targetExists = $this->file_exists($target); + $sameFolder = dirname($source) === dirname($target); + + if ($targetExists || ($sameFolder && !$isPartFile)) { + if (!$this->isUpdatable('')) { + return false; + } + } else { + if (!$this->isCreatable('')) { + return false; + } + } + + return $this->nonMaskedStorage->rename($this->getUnjailedPath($source), $this->getUnjailedPath($target)); + } + + /** + * return mount point of share, relative to data/user/files + * + * @return string + */ + public function getMountPoint(): string { + return $this->superShare->getTarget(); + } + + public function setMountPoint(string $path): void { + $this->superShare->setTarget($path); + + foreach ($this->groupedShares as $share) { + $share->setTarget($path); + } + } + + /** + * get the user who shared the file + * + * @return string + */ + public function getSharedFrom(): string { + return $this->superShare->getShareOwner(); + } + + public function getShare(): IShare { + return $this->superShare; + } + + /** + * return share type, can be "file" or "folder" + * + * @return string + */ + public function getItemType(): string { + return $this->superShare->getNodeType(); + } + + public function getCache(string $path = '', ?IStorage $storage = null): ICache { + if ($this->cache) { + return $this->cache; + } + if (!$storage) { + $storage = $this; + } + $sourceRoot = $this->getSourceRootInfo(); + if ($this->storage instanceof FailedStorage) { + return new FailedCache(); + } + + $this->cache = new Cache( + $storage, + $sourceRoot, + Server::get(CacheDependencies::class), + $this->getShare() + ); + return $this->cache; + } + + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner { + if (!$storage) { + $storage = $this; + } + return new Scanner($storage); + } + + public function getOwner(string $path): string|false { + return $this->superShare->getShareOwner(); + } + + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { + if ($this->watcher) { + return $this->watcher; + } + + // Get node information + $node = $this->getShare()->getNodeCacheEntry(); + if ($node instanceof CacheEntry) { + $storageId = $node->getData()['storage_string_id'] ?? null; + // for shares from the home storage we can rely on the home storage to keep itself up to date + // for other storages we need use the proper watcher + if ($storageId !== null && !(str_starts_with($storageId, 'home::') || str_starts_with($storageId, 'object::user'))) { + $cache = $this->getCache(); + $this->watcher = parent::getWatcher($path, $storage); + if ($cache instanceof Cache) { + $this->watcher->onUpdate($cache->markRootChanged(...)); + } + return $this->watcher; + } + } + + // cache updating is handled by the share source + $this->watcher = new NullWatcher(); + return $this->watcher; + } + + /** + * unshare complete storage, also the grouped shares + * + * @return bool + */ + public function unshareStorage(): bool { + foreach ($this->groupedShares as $share) { + Server::get(\OCP\Share\IManager::class)->deleteFromSelf($share, $this->user); + } + return true; + } + + public function acquireLock(string $path, int $type, ILockingProvider $provider): void { + /** @var ILockingStorage $targetStorage */ + [$targetStorage, $targetInternalPath] = $this->resolvePath($path); + $targetStorage->acquireLock($targetInternalPath, $type, $provider); + // lock the parent folders of the owner when locking the share as recipient + if ($path === '') { + $sourcePath = $this->ownerUserFolder->getRelativePath($this->sourcePath); + $this->ownerView->lockFile(dirname($sourcePath), ILockingProvider::LOCK_SHARED, true); + } + } + + public function releaseLock(string $path, int $type, ILockingProvider $provider): void { + /** @var ILockingStorage $targetStorage */ + [$targetStorage, $targetInternalPath] = $this->resolvePath($path); + $targetStorage->releaseLock($targetInternalPath, $type, $provider); + // unlock the parent folders of the owner when unlocking the share as recipient + if ($path === '') { + $sourcePath = $this->ownerUserFolder->getRelativePath($this->sourcePath); + $this->ownerView->unlockFile(dirname($sourcePath), ILockingProvider::LOCK_SHARED, true); + } + } + + public function changeLock(string $path, int $type, ILockingProvider $provider): void { + /** @var ILockingStorage $targetStorage */ + [$targetStorage, $targetInternalPath] = $this->resolvePath($path); + $targetStorage->changeLock($targetInternalPath, $type, $provider); + } + + public function getAvailability(): array { + // shares do not participate in availability logic + return [ + 'available' => true, + 'last_checked' => 0, + ]; + } + + public function setAvailability(bool $isAvailable): void { + // shares do not participate in availability logic + } + + public function getSourceStorage() { + $this->init(); + return $this->nonMaskedStorage; + } + + public function getWrapperStorage(): Storage { + $this->init(); + + /** + * @psalm-suppress DocblockTypeContradiction + */ + if (!$this->storage) { + $message = 'no storage set after init for share ' . $this->getShareId(); + $this->logger->error($message); + $this->storage = new FailedStorage(['exception' => new \Exception($message)]); + } + + return $this->storage; + } + + public function file_get_contents(string $path): string|false { + $info = [ + 'target' => $this->getMountPoint() . '/' . $path, + 'source' => $this->getUnjailedPath($path), + ]; + Util::emitHook('\OC\Files\Storage\Shared', 'file_get_contents', $info); + return parent::file_get_contents($path); + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + $info = [ + 'target' => $this->getMountPoint() . '/' . $path, + 'source' => $this->getUnjailedPath($path), + ]; + Util::emitHook('\OC\Files\Storage\Shared', 'file_put_contents', $info); + return parent::file_put_contents($path, $data); + } + + public function setMountOptions(array $options): void { + /* Note: This value is never read */ + $this->mountOptions = $options; + } + + public function getUnjailedPath(string $path): string { + $this->init(); + return parent::getUnjailedPath($path); + } + + public function getDirectDownload(string $path): array|false { + // disable direct download for shares + return []; + } +} |