diff options
Diffstat (limited to 'lib/private/Files/Storage')
21 files changed, 5501 insertions, 0 deletions
diff --git a/lib/private/Files/Storage/Common.php b/lib/private/Files/Storage/Common.php new file mode 100644 index 00000000000..2dc359169d7 --- /dev/null +++ b/lib/private/Files/Storage/Common.php @@ -0,0 +1,769 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage; + +use OC\Files\Cache\Cache; +use OC\Files\Cache\CacheDependencies; +use OC\Files\Cache\Propagator; +use OC\Files\Cache\Scanner; +use OC\Files\Cache\Updater; +use OC\Files\Cache\Watcher; +use OC\Files\FilenameValidator; +use OC\Files\Filesystem; +use OC\Files\ObjectStore\ObjectStoreStorage; +use OC\Files\Storage\Wrapper\Encryption; +use OC\Files\Storage\Wrapper\Jail; +use OC\Files\Storage\Wrapper\Wrapper; +use OCP\Files; +use OCP\Files\Cache\ICache; +use OCP\Files\Cache\IPropagator; +use OCP\Files\Cache\IScanner; +use OCP\Files\Cache\IUpdater; +use OCP\Files\Cache\IWatcher; +use OCP\Files\ForbiddenException; +use OCP\Files\GenericFileException; +use OCP\Files\IFilenameValidator; +use OCP\Files\InvalidPathException; +use OCP\Files\Storage\IConstructableStorage; +use OCP\Files\Storage\ILockingStorage; +use OCP\Files\Storage\IStorage; +use OCP\Files\Storage\IWriteStreamStorage; +use OCP\Files\StorageNotAvailableException; +use OCP\IConfig; +use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; +use OCP\Server; +use Psr\Log\LoggerInterface; + +/** + * Storage backend class for providing common filesystem operation methods + * which are not storage-backend specific. + * + * \OC\Files\Storage\Common is never used directly; it is extended by all other + * storage backends, where its methods may be overridden, and additional + * (backend-specific) methods are defined. + * + * Some \OC\Files\Storage\Common methods call functions which are first defined + * in classes which extend it, e.g. $this->stat() . + */ +abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage, IConstructableStorage { + use LocalTempFileTrait; + + protected ?Cache $cache = null; + protected ?Scanner $scanner = null; + protected ?Watcher $watcher = null; + protected ?Propagator $propagator = null; + protected $storageCache; + protected ?Updater $updater = null; + + protected array $mountOptions = []; + protected $owner = null; + + private ?bool $shouldLogLocks = null; + private ?LoggerInterface $logger = null; + private ?IFilenameValidator $filenameValidator = null; + + public function __construct(array $parameters) { + } + + protected function remove(string $path): bool { + if ($this->file_exists($path)) { + if ($this->is_dir($path)) { + return $this->rmdir($path); + } elseif ($this->is_file($path)) { + return $this->unlink($path); + } + } + return false; + } + + public function is_dir(string $path): bool { + return $this->filetype($path) === 'dir'; + } + + public function is_file(string $path): bool { + return $this->filetype($path) === 'file'; + } + + public function filesize(string $path): int|float|false { + if ($this->is_dir($path)) { + return 0; //by definition + } else { + $stat = $this->stat($path); + return isset($stat['size']) ? $stat['size'] : 0; + } + } + + public function isReadable(string $path): bool { + // at least check whether it exists + // subclasses might want to implement this more thoroughly + return $this->file_exists($path); + } + + public function isUpdatable(string $path): bool { + // at least check whether it exists + // subclasses might want to implement this more thoroughly + // a non-existing file/folder isn't updatable + return $this->file_exists($path); + } + + public function isCreatable(string $path): bool { + if ($this->is_dir($path) && $this->isUpdatable($path)) { + return true; + } + return false; + } + + public function isDeletable(string $path): bool { + if ($path === '' || $path === '/') { + return $this->isUpdatable($path); + } + $parent = dirname($path); + return $this->isUpdatable($parent) && $this->isUpdatable($path); + } + + public function isSharable(string $path): bool { + return $this->isReadable($path); + } + + public function getPermissions(string $path): int { + $permissions = 0; + if ($this->isCreatable($path)) { + $permissions |= \OCP\Constants::PERMISSION_CREATE; + } + if ($this->isReadable($path)) { + $permissions |= \OCP\Constants::PERMISSION_READ; + } + if ($this->isUpdatable($path)) { + $permissions |= \OCP\Constants::PERMISSION_UPDATE; + } + if ($this->isDeletable($path)) { + $permissions |= \OCP\Constants::PERMISSION_DELETE; + } + if ($this->isSharable($path)) { + $permissions |= \OCP\Constants::PERMISSION_SHARE; + } + return $permissions; + } + + public function filemtime(string $path): int|false { + $stat = $this->stat($path); + if (isset($stat['mtime']) && $stat['mtime'] > 0) { + return $stat['mtime']; + } else { + return 0; + } + } + + public function file_get_contents(string $path): string|false { + $handle = $this->fopen($path, 'r'); + if (!$handle) { + return false; + } + $data = stream_get_contents($handle); + fclose($handle); + return $data; + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + $handle = $this->fopen($path, 'w'); + if (!$handle) { + return false; + } + $this->removeCachedFile($path); + $count = fwrite($handle, $data); + fclose($handle); + return $count; + } + + public function rename(string $source, string $target): bool { + $this->remove($target); + + $this->removeCachedFile($source); + return $this->copy($source, $target) and $this->remove($source); + } + + public function copy(string $source, string $target): bool { + if ($this->is_dir($source)) { + $this->remove($target); + $dir = $this->opendir($source); + $this->mkdir($target); + while (($file = readdir($dir)) !== false) { + if (!Filesystem::isIgnoredDir($file)) { + if (!$this->copy($source . '/' . $file, $target . '/' . $file)) { + closedir($dir); + return false; + } + } + } + closedir($dir); + return true; + } else { + $sourceStream = $this->fopen($source, 'r'); + $targetStream = $this->fopen($target, 'w'); + [, $result] = Files::streamCopy($sourceStream, $targetStream, true); + if (!$result) { + Server::get(LoggerInterface::class)->warning("Failed to write data while copying $source to $target"); + } + $this->removeCachedFile($target); + return $result; + } + } + + public function getMimeType(string $path): string|false { + if ($this->is_dir($path)) { + return 'httpd/unix-directory'; + } elseif ($this->file_exists($path)) { + return \OC::$server->getMimeTypeDetector()->detectPath($path); + } else { + return false; + } + } + + public function hash(string $type, string $path, bool $raw = false): string|false { + $fh = $this->fopen($path, 'rb'); + if (!$fh) { + return false; + } + $ctx = hash_init($type); + hash_update_stream($ctx, $fh); + fclose($fh); + return hash_final($ctx, $raw); + } + + public function getLocalFile(string $path): string|false { + return $this->getCachedFile($path); + } + + private function addLocalFolder(string $path, string $target): void { + $dh = $this->opendir($path); + if (is_resource($dh)) { + while (($file = readdir($dh)) !== false) { + if (!Filesystem::isIgnoredDir($file)) { + if ($this->is_dir($path . '/' . $file)) { + mkdir($target . '/' . $file); + $this->addLocalFolder($path . '/' . $file, $target . '/' . $file); + } else { + $tmp = $this->toTmpFile($path . '/' . $file); + rename($tmp, $target . '/' . $file); + } + } + } + } + } + + protected function searchInDir(string $query, string $dir = ''): array { + $files = []; + $dh = $this->opendir($dir); + if (is_resource($dh)) { + while (($item = readdir($dh)) !== false) { + if (Filesystem::isIgnoredDir($item)) { + continue; + } + if (strstr(strtolower($item), strtolower($query)) !== false) { + $files[] = $dir . '/' . $item; + } + if ($this->is_dir($dir . '/' . $item)) { + $files = array_merge($files, $this->searchInDir($query, $dir . '/' . $item)); + } + } + } + closedir($dh); + return $files; + } + + /** + * @inheritDoc + * Check if a file or folder has been updated since $time + * + * The method is only used to check if the cache needs to be updated. Storage backends that don't support checking + * the mtime should always return false here. As a result storage implementations that always return false expect + * exclusive access to the backend and will not pick up files that have been added in a way that circumvents + * Nextcloud filesystem. + */ + public function hasUpdated(string $path, int $time): bool { + return $this->filemtime($path) > $time; + } + + protected function getCacheDependencies(): CacheDependencies { + static $dependencies = null; + if (!$dependencies) { + $dependencies = Server::get(CacheDependencies::class); + } + return $dependencies; + } + + public function getCache(string $path = '', ?IStorage $storage = null): ICache { + if (!$storage) { + $storage = $this; + } + /** @var self $storage */ + if (!isset($storage->cache)) { + $storage->cache = new Cache($storage, $this->getCacheDependencies()); + } + return $storage->cache; + } + + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner { + if (!$storage) { + $storage = $this; + } + if (!$storage->instanceOfStorage(self::class)) { + throw new \InvalidArgumentException('Storage is not of the correct class'); + } + if (!isset($storage->scanner)) { + $storage->scanner = new Scanner($storage); + } + return $storage->scanner; + } + + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { + if (!$storage) { + $storage = $this; + } + if (!isset($this->watcher)) { + $this->watcher = new Watcher($storage); + $globalPolicy = Server::get(IConfig::class)->getSystemValueInt('filesystem_check_changes', Watcher::CHECK_NEVER); + $this->watcher->setPolicy((int)$this->getMountOption('filesystem_check_changes', $globalPolicy)); + } + return $this->watcher; + } + + public function getPropagator(?IStorage $storage = null): IPropagator { + if (!$storage) { + $storage = $this; + } + if (!$storage->instanceOfStorage(self::class)) { + throw new \InvalidArgumentException('Storage is not of the correct class'); + } + /** @var self $storage */ + if (!isset($storage->propagator)) { + $config = Server::get(IConfig::class); + $storage->propagator = new Propagator($storage, \OC::$server->getDatabaseConnection(), ['appdata_' . $config->getSystemValueString('instanceid')]); + } + return $storage->propagator; + } + + public function getUpdater(?IStorage $storage = null): IUpdater { + if (!$storage) { + $storage = $this; + } + if (!$storage->instanceOfStorage(self::class)) { + throw new \InvalidArgumentException('Storage is not of the correct class'); + } + /** @var self $storage */ + if (!isset($storage->updater)) { + $storage->updater = new Updater($storage); + } + return $storage->updater; + } + + public function getStorageCache(?IStorage $storage = null): \OC\Files\Cache\Storage { + /** @var Cache $cache */ + $cache = $this->getCache(storage: $storage); + return $cache->getStorageCache(); + } + + public function getOwner(string $path): string|false { + if ($this->owner === null) { + $this->owner = \OC_User::getUser(); + } + + return $this->owner; + } + + public function getETag(string $path): string|false { + return uniqid(); + } + + /** + * clean a path, i.e. remove all redundant '.' and '..' + * making sure that it can't point to higher than '/' + * + * @param string $path The path to clean + * @return string cleaned path + */ + public function cleanPath(string $path): string { + if (strlen($path) == 0 || $path[0] != '/') { + $path = '/' . $path; + } + + $output = []; + foreach (explode('/', $path) as $chunk) { + if ($chunk == '..') { + array_pop($output); + } elseif ($chunk == '.') { + } else { + $output[] = $chunk; + } + } + return implode('/', $output); + } + + /** + * Test a storage for availability + */ + public function test(): bool { + try { + if ($this->stat('')) { + return true; + } + Server::get(LoggerInterface::class)->info('External storage not available: stat() failed'); + return false; + } catch (\Exception $e) { + Server::get(LoggerInterface::class)->warning( + 'External storage not available: ' . $e->getMessage(), + ['exception' => $e] + ); + return false; + } + } + + public function free_space(string $path): int|float|false { + return \OCP\Files\FileInfo::SPACE_UNKNOWN; + } + + public function isLocal(): bool { + // the common implementation returns a temporary file by + // default, which is not local + return false; + } + + /** + * Check if the storage is an instance of $class or is a wrapper for a storage that is an instance of $class + */ + public function instanceOfStorage(string $class): bool { + if (ltrim($class, '\\') === 'OC\Files\Storage\Shared') { + // FIXME Temporary fix to keep existing checks working + $class = '\OCA\Files_Sharing\SharedStorage'; + } + return is_a($this, $class); + } + + /** + * A custom storage implementation can return an url for direct download of a give file. + * + * For now the returned array can hold the parameter url - in future more attributes might follow. + */ + public function getDirectDownload(string $path): array|false { + return []; + } + + public function verifyPath(string $path, string $fileName): void { + $this->getFilenameValidator() + ->validateFilename($fileName); + + // verify also the path is valid + if ($path && $path !== '/' && $path !== '.') { + try { + $this->verifyPath(dirname($path), basename($path)); + } catch (InvalidPathException $e) { + // Ignore invalid file type exceptions on directories + if ($e->getCode() !== FilenameValidator::INVALID_FILE_TYPE) { + $l = \OCP\Util::getL10N('lib'); + throw new InvalidPathException($l->t('Invalid parent path'), previous: $e); + } + } + } + } + + /** + * Get the filename validator + * (cached for performance) + */ + protected function getFilenameValidator(): IFilenameValidator { + if ($this->filenameValidator === null) { + $this->filenameValidator = Server::get(IFilenameValidator::class); + } + return $this->filenameValidator; + } + + public function setMountOptions(array $options): void { + $this->mountOptions = $options; + } + + public function getMountOption(string $name, mixed $default = null): mixed { + return $this->mountOptions[$name] ?? $default; + } + + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath, bool $preserveMtime = false): bool { + if ($sourceStorage === $this) { + return $this->copy($sourceInternalPath, $targetInternalPath); + } + + if ($sourceStorage->is_dir($sourceInternalPath)) { + $dh = $sourceStorage->opendir($sourceInternalPath); + $result = $this->mkdir($targetInternalPath); + if (is_resource($dh)) { + $result = true; + while ($result && ($file = readdir($dh)) !== false) { + if (!Filesystem::isIgnoredDir($file)) { + $result &= $this->copyFromStorage($sourceStorage, $sourceInternalPath . '/' . $file, $targetInternalPath . '/' . $file); + } + } + } + } else { + $source = $sourceStorage->fopen($sourceInternalPath, 'r'); + $result = false; + if ($source) { + try { + $this->writeStream($targetInternalPath, $source); + $result = true; + } catch (\Exception $e) { + Server::get(LoggerInterface::class)->warning('Failed to copy stream to storage', ['exception' => $e]); + } + } + + if ($result && $preserveMtime) { + $mtime = $sourceStorage->filemtime($sourceInternalPath); + $this->touch($targetInternalPath, is_int($mtime) ? $mtime : null); + } + + if (!$result) { + // delete partially written target file + $this->unlink($targetInternalPath); + // delete cache entry that was created by fopen + $this->getCache()->remove($targetInternalPath); + } + } + return (bool)$result; + } + + /** + * Check if a storage is the same as the current one, including wrapped storages + */ + private function isSameStorage(IStorage $storage): bool { + while ($storage->instanceOfStorage(Wrapper::class)) { + /** + * @var Wrapper $storage + */ + $storage = $storage->getWrapperStorage(); + } + + return $storage === $this; + } + + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + if ( + !$sourceStorage->instanceOfStorage(Encryption::class) + && $this->isSameStorage($sourceStorage) + ) { + // resolve any jailed paths + while ($sourceStorage->instanceOfStorage(Jail::class)) { + /** + * @var Jail $sourceStorage + */ + $sourceInternalPath = $sourceStorage->getUnjailedPath($sourceInternalPath); + $sourceStorage = $sourceStorage->getUnjailedStorage(); + } + + return $this->rename($sourceInternalPath, $targetInternalPath); + } + + if (!$sourceStorage->isDeletable($sourceInternalPath)) { + return false; + } + + $result = $this->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath, true); + if ($result) { + if ($sourceStorage->instanceOfStorage(ObjectStoreStorage::class)) { + /** @var ObjectStoreStorage $sourceStorage */ + $sourceStorage->setPreserveCacheOnDelete(true); + } + try { + if ($sourceStorage->is_dir($sourceInternalPath)) { + $result = $sourceStorage->rmdir($sourceInternalPath); + } else { + $result = $sourceStorage->unlink($sourceInternalPath); + } + } finally { + if ($sourceStorage->instanceOfStorage(ObjectStoreStorage::class)) { + /** @var ObjectStoreStorage $sourceStorage */ + $sourceStorage->setPreserveCacheOnDelete(false); + } + } + } + return $result; + } + + public function getMetaData(string $path): ?array { + if (Filesystem::isFileBlacklisted($path)) { + throw new ForbiddenException('Invalid path: ' . $path, false); + } + + $permissions = $this->getPermissions($path); + if (!$permissions & \OCP\Constants::PERMISSION_READ) { + //can't read, nothing we can do + return null; + } + + $data = []; + $data['mimetype'] = $this->getMimeType($path); + $data['mtime'] = $this->filemtime($path); + if ($data['mtime'] === false) { + $data['mtime'] = time(); + } + if ($data['mimetype'] == 'httpd/unix-directory') { + $data['size'] = -1; //unknown + } else { + $data['size'] = $this->filesize($path); + } + $data['etag'] = $this->getETag($path); + $data['storage_mtime'] = $data['mtime']; + $data['permissions'] = $permissions; + $data['name'] = basename($path); + + return $data; + } + + public function acquireLock(string $path, int $type, ILockingProvider $provider): void { + $logger = $this->getLockLogger(); + if ($logger) { + $typeString = ($type === ILockingProvider::LOCK_SHARED) ? 'shared' : 'exclusive'; + $logger->info( + sprintf( + 'acquire %s lock on "%s" on storage "%s"', + $typeString, + $path, + $this->getId() + ), + [ + 'app' => 'locking', + ] + ); + } + try { + $provider->acquireLock('files/' . md5($this->getId() . '::' . trim($path, '/')), $type, $this->getId() . '::' . $path); + } catch (LockedException $e) { + $e = new LockedException($e->getPath(), $e, $e->getExistingLock(), $path); + if ($logger) { + $logger->info($e->getMessage(), ['exception' => $e]); + } + throw $e; + } + } + + public function releaseLock(string $path, int $type, ILockingProvider $provider): void { + $logger = $this->getLockLogger(); + if ($logger) { + $typeString = ($type === ILockingProvider::LOCK_SHARED) ? 'shared' : 'exclusive'; + $logger->info( + sprintf( + 'release %s lock on "%s" on storage "%s"', + $typeString, + $path, + $this->getId() + ), + [ + 'app' => 'locking', + ] + ); + } + try { + $provider->releaseLock('files/' . md5($this->getId() . '::' . trim($path, '/')), $type); + } catch (LockedException $e) { + $e = new LockedException($e->getPath(), $e, $e->getExistingLock(), $path); + if ($logger) { + $logger->info($e->getMessage(), ['exception' => $e]); + } + throw $e; + } + } + + public function changeLock(string $path, int $type, ILockingProvider $provider): void { + $logger = $this->getLockLogger(); + if ($logger) { + $typeString = ($type === ILockingProvider::LOCK_SHARED) ? 'shared' : 'exclusive'; + $logger->info( + sprintf( + 'change lock on "%s" to %s on storage "%s"', + $path, + $typeString, + $this->getId() + ), + [ + 'app' => 'locking', + ] + ); + } + try { + $provider->changeLock('files/' . md5($this->getId() . '::' . trim($path, '/')), $type); + } catch (LockedException $e) { + $e = new LockedException($e->getPath(), $e, $e->getExistingLock(), $path); + if ($logger) { + $logger->info($e->getMessage(), ['exception' => $e]); + } + throw $e; + } + } + + private function getLockLogger(): ?LoggerInterface { + if (is_null($this->shouldLogLocks)) { + $this->shouldLogLocks = Server::get(IConfig::class)->getSystemValueBool('filelocking.debug', false); + $this->logger = $this->shouldLogLocks ? Server::get(LoggerInterface::class) : null; + } + return $this->logger; + } + + /** + * @return array [ available, last_checked ] + */ + public function getAvailability(): array { + return $this->getStorageCache()->getAvailability(); + } + + public function setAvailability(bool $isAvailable): void { + $this->getStorageCache()->setAvailability($isAvailable); + } + + public function setOwner(?string $user): void { + $this->owner = $user; + } + + public function needsPartFile(): bool { + return true; + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + $target = $this->fopen($path, 'w'); + if (!$target) { + throw new GenericFileException("Failed to open $path for writing"); + } + try { + [$count, $result] = Files::streamCopy($stream, $target, true); + if (!$result) { + throw new GenericFileException('Failed to copy stream'); + } + } finally { + fclose($target); + fclose($stream); + } + return $count; + } + + public function getDirectoryContent(string $directory): \Traversable { + $dh = $this->opendir($directory); + + if ($dh === false) { + throw new StorageNotAvailableException('Directory listing failed'); + } + + if (is_resource($dh)) { + $basePath = rtrim($directory, '/'); + while (($file = readdir($dh)) !== false) { + if (!Filesystem::isIgnoredDir($file)) { + $childPath = $basePath . '/' . trim($file, '/'); + $metadata = $this->getMetaData($childPath); + if ($metadata !== null) { + yield $metadata; + } + } + } + } + } +} diff --git a/lib/private/Files/Storage/CommonTest.php b/lib/private/Files/Storage/CommonTest.php new file mode 100644 index 00000000000..da796130899 --- /dev/null +++ b/lib/private/Files/Storage/CommonTest.php @@ -0,0 +1,60 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage; + +class CommonTest extends \OC\Files\Storage\Common { + /** + * underlying local storage used for missing functions + * @var \OC\Files\Storage\Local + */ + private $storage; + + public function __construct(array $parameters) { + $this->storage = new \OC\Files\Storage\Local($parameters); + } + + public function getId(): string { + return 'test::' . $this->storage->getId(); + } + public function mkdir(string $path): bool { + return $this->storage->mkdir($path); + } + public function rmdir(string $path): bool { + return $this->storage->rmdir($path); + } + public function opendir(string $path) { + return $this->storage->opendir($path); + } + public function stat(string $path): array|false { + return $this->storage->stat($path); + } + public function filetype(string $path): string|false { + return @$this->storage->filetype($path); + } + public function isReadable(string $path): bool { + return $this->storage->isReadable($path); + } + public function isUpdatable(string $path): bool { + return $this->storage->isUpdatable($path); + } + public function file_exists(string $path): bool { + return $this->storage->file_exists($path); + } + public function unlink(string $path): bool { + return $this->storage->unlink($path); + } + public function fopen(string $path, string $mode) { + return $this->storage->fopen($path, $mode); + } + public function free_space(string $path): int|float|false { + return $this->storage->free_space($path); + } + public function touch(string $path, ?int $mtime = null): bool { + return $this->storage->touch($path, $mtime); + } +} diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php new file mode 100644 index 00000000000..2d166b5438d --- /dev/null +++ b/lib/private/Files/Storage/DAV.php @@ -0,0 +1,841 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage; + +use Exception; +use Icewind\Streams\CallbackWrapper; +use Icewind\Streams\IteratorDirectory; +use OC\Files\Filesystem; +use OC\MemCache\ArrayCache; +use OCP\AppFramework\Http; +use OCP\Constants; +use OCP\Diagnostics\IEventLogger; +use OCP\Files\FileInfo; +use OCP\Files\ForbiddenException; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\StorageInvalidException; +use OCP\Files\StorageNotAvailableException; +use OCP\Http\Client\IClient; +use OCP\Http\Client\IClientService; +use OCP\ICertificateManager; +use OCP\IConfig; +use OCP\Server; +use OCP\Util; +use Psr\Http\Message\ResponseInterface; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Client; +use Sabre\DAV\Xml\Property\ResourceType; +use Sabre\HTTP\ClientException; +use Sabre\HTTP\ClientHttpException; +use Sabre\HTTP\RequestInterface; + +/** + * Class DAV + * + * @package OC\Files\Storage + */ +class DAV extends Common { + /** @var string */ + protected $password; + /** @var string */ + protected $user; + /** @var string|null */ + protected $authType; + /** @var string */ + protected $host; + /** @var bool */ + protected $secure; + /** @var string */ + protected $root; + /** @var string */ + protected $certPath; + /** @var bool */ + protected $ready; + /** @var Client */ + protected $client; + /** @var ArrayCache */ + protected $statCache; + /** @var IClientService */ + protected $httpClientService; + /** @var ICertificateManager */ + protected $certManager; + protected LoggerInterface $logger; + protected IEventLogger $eventLogger; + protected IMimeTypeDetector $mimeTypeDetector; + + /** @var int */ + private $timeout; + + protected const PROPFIND_PROPS = [ + '{DAV:}getlastmodified', + '{DAV:}getcontentlength', + '{DAV:}getcontenttype', + '{http://owncloud.org/ns}permissions', + '{http://open-collaboration-services.org/ns}share-permissions', + '{DAV:}resourcetype', + '{DAV:}getetag', + '{DAV:}quota-available-bytes', + ]; + + /** + * @param array $parameters + * @throws \Exception + */ + public function __construct(array $parameters) { + $this->statCache = new ArrayCache(); + $this->httpClientService = Server::get(IClientService::class); + if (isset($parameters['host']) && isset($parameters['user']) && isset($parameters['password'])) { + $host = $parameters['host']; + //remove leading http[s], will be generated in createBaseUri() + if (str_starts_with($host, 'https://')) { + $host = substr($host, 8); + } elseif (str_starts_with($host, 'http://')) { + $host = substr($host, 7); + } + $this->host = $host; + $this->user = $parameters['user']; + $this->password = $parameters['password']; + if (isset($parameters['authType'])) { + $this->authType = $parameters['authType']; + } + if (isset($parameters['secure'])) { + if (is_string($parameters['secure'])) { + $this->secure = ($parameters['secure'] === 'true'); + } else { + $this->secure = (bool)$parameters['secure']; + } + } else { + $this->secure = false; + } + if ($this->secure === true) { + // inject mock for testing + $this->certManager = \OC::$server->getCertificateManager(); + } + $this->root = rawurldecode($parameters['root'] ?? '/'); + $this->root = '/' . ltrim($this->root, '/'); + $this->root = rtrim($this->root, '/') . '/'; + } else { + throw new \Exception('Invalid webdav storage configuration'); + } + $this->logger = Server::get(LoggerInterface::class); + $this->eventLogger = Server::get(IEventLogger::class); + // This timeout value will be used for the download and upload of files + $this->timeout = Server::get(IConfig::class)->getSystemValueInt('davstorage.request_timeout', IClient::DEFAULT_REQUEST_TIMEOUT); + $this->mimeTypeDetector = \OC::$server->getMimeTypeDetector(); + } + + protected function init(): void { + if ($this->ready) { + return; + } + $this->ready = true; + + $settings = [ + 'baseUri' => $this->createBaseUri(), + 'userName' => $this->user, + 'password' => $this->password, + ]; + if ($this->authType !== null) { + $settings['authType'] = $this->authType; + } + + $proxy = Server::get(IConfig::class)->getSystemValueString('proxy', ''); + if ($proxy !== '') { + $settings['proxy'] = $proxy; + } + + $this->client = new Client($settings); + $this->client->setThrowExceptions(true); + + if ($this->secure === true) { + $certPath = $this->certManager->getAbsoluteBundlePath(); + if (file_exists($certPath)) { + $this->certPath = $certPath; + } + if ($this->certPath) { + $this->client->addCurlSetting(CURLOPT_CAINFO, $this->certPath); + } + } + + $lastRequestStart = 0; + $this->client->on('beforeRequest', function (RequestInterface $request) use (&$lastRequestStart) { + $this->logger->debug('sending dav ' . $request->getMethod() . ' request to external storage: ' . $request->getAbsoluteUrl(), ['app' => 'dav']); + $lastRequestStart = microtime(true); + $this->eventLogger->start('fs:storage:dav:request', 'Sending dav request to external storage'); + }); + $this->client->on('afterRequest', function (RequestInterface $request) use (&$lastRequestStart) { + $elapsed = microtime(true) - $lastRequestStart; + $this->logger->debug('dav ' . $request->getMethod() . ' request to external storage: ' . $request->getAbsoluteUrl() . ' took ' . round($elapsed * 1000, 1) . 'ms', ['app' => 'dav']); + $this->eventLogger->end('fs:storage:dav:request'); + }); + } + + /** + * Clear the stat cache + */ + public function clearStatCache(): void { + $this->statCache->clear(); + } + + public function getId(): string { + return 'webdav::' . $this->user . '@' . $this->host . '/' . $this->root; + } + + public function createBaseUri(): string { + $baseUri = 'http'; + if ($this->secure) { + $baseUri .= 's'; + } + $baseUri .= '://' . $this->host . $this->encodePath($this->root); + return $baseUri; + } + + public function mkdir(string $path): bool { + $this->init(); + $path = $this->cleanPath($path); + $result = $this->simpleResponse('MKCOL', $path, null, 201); + if ($result) { + $this->statCache->set($path, true); + } + return $result; + } + + public function rmdir(string $path): bool { + $this->init(); + $path = $this->cleanPath($path); + // FIXME: some WebDAV impl return 403 when trying to DELETE + // a non-empty folder + $result = $this->simpleResponse('DELETE', $path . '/', null, 204); + $this->statCache->clear($path . '/'); + $this->statCache->remove($path); + return $result; + } + + public function opendir(string $path) { + $this->init(); + $path = $this->cleanPath($path); + try { + $content = $this->getDirectoryContent($path); + $files = []; + foreach ($content as $child) { + $files[] = $child['name']; + } + return IteratorDirectory::wrap($files); + } catch (\Exception $e) { + $this->convertException($e, $path); + } + return false; + } + + /** + * Propfind call with cache handling. + * + * First checks if information is cached. + * If not, request it from the server then store to cache. + * + * @param string $path path to propfind + * + * @return array|false propfind response or false if the entry was not found + * + * @throws ClientHttpException + */ + protected function propfind(string $path): array|false { + $path = $this->cleanPath($path); + $cachedResponse = $this->statCache->get($path); + // we either don't know it, or we know it exists but need more details + if (is_null($cachedResponse) || $cachedResponse === true) { + $this->init(); + $response = false; + try { + $response = $this->client->propFind( + $this->encodePath($path), + self::PROPFIND_PROPS + ); + $this->statCache->set($path, $response); + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 404 || $e->getHttpStatus() === 405) { + $this->statCache->clear($path . '/'); + $this->statCache->set($path, false); + } else { + $this->convertException($e, $path); + } + } catch (\Exception $e) { + $this->convertException($e, $path); + } + } else { + $response = $cachedResponse; + } + return $response; + } + + public function filetype(string $path): string|false { + try { + $response = $this->propfind($path); + if ($response === false) { + return false; + } + $responseType = []; + if (isset($response['{DAV:}resourcetype'])) { + /** @var ResourceType[] $response */ + $responseType = $response['{DAV:}resourcetype']->getValue(); + } + return (count($responseType) > 0 && $responseType[0] == '{DAV:}collection') ? 'dir' : 'file'; + } catch (\Exception $e) { + $this->convertException($e, $path); + } + return false; + } + + public function file_exists(string $path): bool { + try { + $path = $this->cleanPath($path); + $cachedState = $this->statCache->get($path); + if ($cachedState === false) { + // we know the file doesn't exist + return false; + } elseif (!is_null($cachedState)) { + return true; + } + // need to get from server + return ($this->propfind($path) !== false); + } catch (\Exception $e) { + $this->convertException($e, $path); + } + return false; + } + + public function unlink(string $path): bool { + $this->init(); + $path = $this->cleanPath($path); + $result = $this->simpleResponse('DELETE', $path, null, 204); + $this->statCache->clear($path . '/'); + $this->statCache->remove($path); + return $result; + } + + public function fopen(string $path, string $mode) { + $this->init(); + $path = $this->cleanPath($path); + switch ($mode) { + case 'r': + case 'rb': + try { + $response = $this->httpClientService + ->newClient() + ->get($this->createBaseUri() . $this->encodePath($path), [ + 'auth' => [$this->user, $this->password], + 'stream' => true, + // set download timeout for users with slow connections or large files + 'timeout' => $this->timeout + ]); + } catch (\GuzzleHttp\Exception\ClientException $e) { + if ($e->getResponse() instanceof ResponseInterface + && $e->getResponse()->getStatusCode() === 404) { + return false; + } else { + throw $e; + } + } + + if ($response->getStatusCode() !== Http::STATUS_OK) { + if ($response->getStatusCode() === Http::STATUS_LOCKED) { + throw new \OCP\Lock\LockedException($path); + } else { + $this->logger->error('Guzzle get returned status code ' . $response->getStatusCode(), ['app' => 'webdav client']); + } + } + + $content = $response->getBody(); + + if ($content === null || is_string($content)) { + return false; + } + + return $content; + case 'w': + case 'wb': + case 'a': + case 'ab': + case 'r+': + case 'w+': + case 'wb+': + case 'a+': + case 'x': + case 'x+': + case 'c': + case 'c+': + //emulate these + $tempManager = \OC::$server->getTempManager(); + if (strrpos($path, '.') !== false) { + $ext = substr($path, strrpos($path, '.')); + } else { + $ext = ''; + } + if ($this->file_exists($path)) { + if (!$this->isUpdatable($path)) { + return false; + } + if ($mode === 'w' || $mode === 'w+') { + $tmpFile = $tempManager->getTemporaryFile($ext); + } else { + $tmpFile = $this->getCachedFile($path); + } + } else { + if (!$this->isCreatable(dirname($path))) { + return false; + } + $tmpFile = $tempManager->getTemporaryFile($ext); + } + $handle = fopen($tmpFile, $mode); + return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile) { + $this->writeBack($tmpFile, $path); + }); + } + + return false; + } + + public function writeBack(string $tmpFile, string $path): void { + $this->uploadFile($tmpFile, $path); + unlink($tmpFile); + } + + public function free_space(string $path): int|float|false { + $this->init(); + $path = $this->cleanPath($path); + try { + $response = $this->propfind($path); + if ($response === false) { + return FileInfo::SPACE_UNKNOWN; + } + if (isset($response['{DAV:}quota-available-bytes'])) { + return Util::numericToNumber($response['{DAV:}quota-available-bytes']); + } else { + return FileInfo::SPACE_UNKNOWN; + } + } catch (\Exception $e) { + return FileInfo::SPACE_UNKNOWN; + } + } + + public function touch(string $path, ?int $mtime = null): bool { + $this->init(); + if (is_null($mtime)) { + $mtime = time(); + } + $path = $this->cleanPath($path); + + // if file exists, update the mtime, else create a new empty file + if ($this->file_exists($path)) { + try { + $this->statCache->remove($path); + $this->client->proppatch($this->encodePath($path), ['{DAV:}lastmodified' => $mtime]); + // non-owncloud clients might not have accepted the property, need to recheck it + $response = $this->client->propfind($this->encodePath($path), ['{DAV:}getlastmodified'], 0); + if (isset($response['{DAV:}getlastmodified'])) { + $remoteMtime = strtotime($response['{DAV:}getlastmodified']); + if ($remoteMtime !== $mtime) { + // server has not accepted the mtime + return false; + } + } + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 501) { + return false; + } + $this->convertException($e, $path); + return false; + } catch (\Exception $e) { + $this->convertException($e, $path); + return false; + } + } else { + $this->file_put_contents($path, ''); + } + return true; + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + $path = $this->cleanPath($path); + $result = parent::file_put_contents($path, $data); + $this->statCache->remove($path); + return $result; + } + + protected function uploadFile(string $path, string $target): void { + $this->init(); + + // invalidate + $target = $this->cleanPath($target); + $this->statCache->remove($target); + $source = fopen($path, 'r'); + + $this->httpClientService + ->newClient() + ->put($this->createBaseUri() . $this->encodePath($target), [ + 'body' => $source, + 'auth' => [$this->user, $this->password], + // set upload timeout for users with slow connections or large files + 'timeout' => $this->timeout + ]); + + $this->removeCachedFile($target); + } + + public function rename(string $source, string $target): bool { + $this->init(); + $source = $this->cleanPath($source); + $target = $this->cleanPath($target); + try { + // overwrite directory ? + if ($this->is_dir($target)) { + // needs trailing slash in destination + $target = rtrim($target, '/') . '/'; + } + $this->client->request( + 'MOVE', + $this->encodePath($source), + null, + [ + 'Destination' => $this->createBaseUri() . $this->encodePath($target), + ] + ); + $this->statCache->clear($source . '/'); + $this->statCache->clear($target . '/'); + $this->statCache->set($source, false); + $this->statCache->set($target, true); + $this->removeCachedFile($source); + $this->removeCachedFile($target); + return true; + } catch (\Exception $e) { + $this->convertException($e); + } + return false; + } + + public function copy(string $source, string $target): bool { + $this->init(); + $source = $this->cleanPath($source); + $target = $this->cleanPath($target); + try { + // overwrite directory ? + if ($this->is_dir($target)) { + // needs trailing slash in destination + $target = rtrim($target, '/') . '/'; + } + $this->client->request( + 'COPY', + $this->encodePath($source), + null, + [ + 'Destination' => $this->createBaseUri() . $this->encodePath($target), + ] + ); + $this->statCache->clear($target . '/'); + $this->statCache->set($target, true); + $this->removeCachedFile($target); + return true; + } catch (\Exception $e) { + $this->convertException($e); + } + return false; + } + + public function getMetaData(string $path): ?array { + if (Filesystem::isFileBlacklisted($path)) { + throw new ForbiddenException('Invalid path: ' . $path, false); + } + $response = $this->propfind($path); + if (!$response) { + return null; + } else { + return $this->getMetaFromPropfind($path, $response); + } + } + private function getMetaFromPropfind(string $path, array $response): array { + if (isset($response['{DAV:}getetag'])) { + $etag = trim($response['{DAV:}getetag'], '"'); + if (strlen($etag) > 40) { + $etag = md5($etag); + } + } else { + $etag = parent::getETag($path); + } + + $responseType = []; + if (isset($response['{DAV:}resourcetype'])) { + /** @var ResourceType[] $response */ + $responseType = $response['{DAV:}resourcetype']->getValue(); + } + $type = (count($responseType) > 0 && $responseType[0] == '{DAV:}collection') ? 'dir' : 'file'; + if ($type === 'dir') { + $mimeType = 'httpd/unix-directory'; + } elseif (isset($response['{DAV:}getcontenttype'])) { + $mimeType = $response['{DAV:}getcontenttype']; + } else { + $mimeType = $this->mimeTypeDetector->detectPath($path); + } + + if (isset($response['{http://owncloud.org/ns}permissions'])) { + $permissions = $this->parsePermissions($response['{http://owncloud.org/ns}permissions']); + } elseif ($type === 'dir') { + $permissions = Constants::PERMISSION_ALL; + } else { + $permissions = Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE; + } + + $mtime = isset($response['{DAV:}getlastmodified']) ? strtotime($response['{DAV:}getlastmodified']) : null; + + if ($type === 'dir') { + $size = -1; + } else { + $size = Util::numericToNumber($response['{DAV:}getcontentlength'] ?? 0); + } + + return [ + 'name' => basename($path), + 'mtime' => $mtime, + 'storage_mtime' => $mtime, + 'size' => $size, + 'permissions' => $permissions, + 'etag' => $etag, + 'mimetype' => $mimeType, + ]; + } + + public function stat(string $path): array|false { + $meta = $this->getMetaData($path); + return $meta ?: false; + + } + + public function getMimeType(string $path): string|false { + $meta = $this->getMetaData($path); + return $meta ? $meta['mimetype'] : false; + } + + public function cleanPath(string $path): string { + if ($path === '') { + return $path; + } + $path = Filesystem::normalizePath($path); + // remove leading slash + return substr($path, 1); + } + + /** + * URL encodes the given path but keeps the slashes + * + * @param string $path to encode + * @return string encoded path + */ + protected function encodePath(string $path): string { + // slashes need to stay + return str_replace('%2F', '/', rawurlencode($path)); + } + + /** + * @return bool + * @throws StorageInvalidException + * @throws StorageNotAvailableException + */ + protected function simpleResponse(string $method, string $path, ?string $body, int $expected): bool { + $path = $this->cleanPath($path); + try { + $response = $this->client->request($method, $this->encodePath($path), $body); + return $response['statusCode'] == $expected; + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 404 && $method === 'DELETE') { + $this->statCache->clear($path . '/'); + $this->statCache->set($path, false); + return false; + } + + $this->convertException($e, $path); + } catch (\Exception $e) { + $this->convertException($e, $path); + } + return false; + } + + /** + * check if curl is installed + */ + public static function checkDependencies(): bool { + return true; + } + + public function isUpdatable(string $path): bool { + return (bool)($this->getPermissions($path) & Constants::PERMISSION_UPDATE); + } + + public function isCreatable(string $path): bool { + return (bool)($this->getPermissions($path) & Constants::PERMISSION_CREATE); + } + + public function isSharable(string $path): bool { + return (bool)($this->getPermissions($path) & Constants::PERMISSION_SHARE); + } + + public function isDeletable(string $path): bool { + return (bool)($this->getPermissions($path) & Constants::PERMISSION_DELETE); + } + + public function getPermissions(string $path): int { + $stat = $this->getMetaData($path); + return $stat ? $stat['permissions'] : 0; + } + + public function getETag(string $path): string|false { + $meta = $this->getMetaData($path); + return $meta ? $meta['etag'] : false; + } + + protected function parsePermissions(string $permissionsString): int { + $permissions = Constants::PERMISSION_READ; + if (str_contains($permissionsString, 'R')) { + $permissions |= Constants::PERMISSION_SHARE; + } + if (str_contains($permissionsString, 'D')) { + $permissions |= Constants::PERMISSION_DELETE; + } + if (str_contains($permissionsString, 'W')) { + $permissions |= Constants::PERMISSION_UPDATE; + } + if (str_contains($permissionsString, 'CK')) { + $permissions |= Constants::PERMISSION_CREATE; + $permissions |= Constants::PERMISSION_UPDATE; + } + return $permissions; + } + + public function hasUpdated(string $path, int $time): bool { + $this->init(); + $path = $this->cleanPath($path); + try { + // force refresh for $path + $this->statCache->remove($path); + $response = $this->propfind($path); + if ($response === false) { + if ($path === '') { + // if root is gone it means the storage is not available + throw new StorageNotAvailableException('root is gone'); + } + return false; + } + if (isset($response['{DAV:}getetag'])) { + $cachedData = $this->getCache()->get($path); + $etag = trim($response['{DAV:}getetag'], '"'); + if (($cachedData === false) || (!empty($etag) && ($cachedData['etag'] !== $etag))) { + return true; + } elseif (isset($response['{http://open-collaboration-services.org/ns}share-permissions'])) { + $sharePermissions = (int)$response['{http://open-collaboration-services.org/ns}share-permissions']; + return $sharePermissions !== $cachedData['permissions']; + } elseif (isset($response['{http://owncloud.org/ns}permissions'])) { + $permissions = $this->parsePermissions($response['{http://owncloud.org/ns}permissions']); + return $permissions !== $cachedData['permissions']; + } else { + return false; + } + } elseif (isset($response['{DAV:}getlastmodified'])) { + $remoteMtime = strtotime($response['{DAV:}getlastmodified']); + return $remoteMtime > $time; + } else { + // neither `getetag` nor `getlastmodified` is set + return false; + } + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 405) { + if ($path === '') { + // if root is gone it means the storage is not available + throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage()); + } + return false; + } + $this->convertException($e, $path); + return false; + } catch (\Exception $e) { + $this->convertException($e, $path); + return false; + } + } + + /** + * Interpret the given exception and decide whether it is due to an + * unavailable storage, invalid storage or other. + * This will either throw StorageInvalidException, StorageNotAvailableException + * or do nothing. + * + * @param Exception $e sabre exception + * @param string $path optional path from the operation + * + * @throws StorageInvalidException if the storage is invalid, for example + * when the authentication expired or is invalid + * @throws StorageNotAvailableException if the storage is not available, + * which might be temporary + * @throws ForbiddenException if the action is not allowed + */ + protected function convertException(Exception $e, string $path = ''): void { + $this->logger->debug($e->getMessage(), ['app' => 'files_external', 'exception' => $e]); + if ($e instanceof ClientHttpException) { + if ($e->getHttpStatus() === Http::STATUS_LOCKED) { + throw new \OCP\Lock\LockedException($path); + } + if ($e->getHttpStatus() === Http::STATUS_UNAUTHORIZED) { + // either password was changed or was invalid all along + throw new StorageInvalidException(get_class($e) . ': ' . $e->getMessage()); + } elseif ($e->getHttpStatus() === Http::STATUS_METHOD_NOT_ALLOWED) { + // ignore exception for MethodNotAllowed, false will be returned + return; + } elseif ($e->getHttpStatus() === Http::STATUS_FORBIDDEN) { + // The operation is forbidden. Fail somewhat gracefully + throw new ForbiddenException(get_class($e) . ':' . $e->getMessage(), false); + } + throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage()); + } elseif ($e instanceof ClientException) { + // connection timeout or refused, server could be temporarily down + throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage()); + } elseif ($e instanceof \InvalidArgumentException) { + // parse error because the server returned HTML instead of XML, + // possibly temporarily down + throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage()); + } elseif (($e instanceof StorageNotAvailableException) || ($e instanceof StorageInvalidException)) { + // rethrow + throw $e; + } + + // TODO: only log for now, but in the future need to wrap/rethrow exception + } + + public function getDirectoryContent(string $directory): \Traversable { + $this->init(); + $directory = $this->cleanPath($directory); + try { + $responses = $this->client->propFind( + $this->encodePath($directory), + self::PROPFIND_PROPS, + 1 + ); + + array_shift($responses); //the first entry is the current directory + if (!$this->statCache->hasKey($directory)) { + $this->statCache->set($directory, true); + } + + foreach ($responses as $file => $response) { + $file = rawurldecode($file); + $file = substr($file, strlen($this->root)); + $file = $this->cleanPath($file); + $this->statCache->set($file, $response); + yield $this->getMetaFromPropfind($file, $response); + } + } catch (\Exception $e) { + $this->convertException($e, $directory); + } + } +} diff --git a/lib/private/Files/Storage/FailedStorage.php b/lib/private/Files/Storage/FailedStorage.php new file mode 100644 index 00000000000..a8288de48d0 --- /dev/null +++ b/lib/private/Files/Storage/FailedStorage.php @@ -0,0 +1,191 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage; + +use OC\Files\Cache\FailedCache; +use OCP\Files\Storage\IStorage; +use OCP\Files\StorageNotAvailableException; +use OCP\Lock\ILockingProvider; + +/** + * Storage placeholder to represent a missing precondition, storage unavailable + */ +class FailedStorage extends Common { + /** @var \Exception */ + protected $e; + + /** + * @param array $parameters ['exception' => \Exception] + */ + public function __construct(array $parameters) { + $this->e = $parameters['exception']; + if (!$this->e) { + throw new \InvalidArgumentException('Missing "exception" argument in FailedStorage constructor'); + } + } + + public function getId(): string { + // we can't return anything sane here + return 'failedstorage'; + } + + public function mkdir(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function rmdir(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function opendir(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function is_dir(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function is_file(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function stat(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function filetype(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function filesize(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function isCreatable(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function isReadable(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function isUpdatable(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function isDeletable(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function isSharable(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function getPermissions(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function file_exists(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function filemtime(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function file_get_contents(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function file_put_contents(string $path, mixed $data): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function unlink(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function rename(string $source, string $target): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function copy(string $source, string $target): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function fopen(string $path, string $mode): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function getMimeType(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function hash(string $type, string $path, bool $raw = false): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function free_space(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function touch(string $path, ?int $mtime = null): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function getLocalFile(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function hasUpdated(string $path, int $time): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function getETag(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function getDirectDownload(string $path): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function verifyPath(string $path, string $fileName): void { + } + + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath, bool $preserveMtime = false): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function acquireLock(string $path, int $type, ILockingProvider $provider): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function releaseLock(string $path, int $type, ILockingProvider $provider): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function changeLock(string $path, int $type, ILockingProvider $provider): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function getAvailability(): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function setAvailability(bool $isAvailable): never { + throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); + } + + public function getCache(string $path = '', ?IStorage $storage = null): FailedCache { + return new FailedCache(); + } +} diff --git a/lib/private/Files/Storage/Home.php b/lib/private/Files/Storage/Home.php new file mode 100644 index 00000000000..91b8071ac30 --- /dev/null +++ b/lib/private/Files/Storage/Home.php @@ -0,0 +1,76 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage; + +use OC\Files\Cache\HomePropagator; +use OCP\Files\Cache\ICache; +use OCP\Files\Cache\IPropagator; +use OCP\Files\Storage\IStorage; +use OCP\IUser; + +/** + * Specialized version of Local storage for home directory usage + */ +class Home extends Local implements \OCP\Files\IHomeStorage { + /** + * @var string + */ + protected $id; + + /** + * @var \OC\User\User $user + */ + protected $user; + + /** + * Construct a Home storage instance + * + * @param array $parameters array with "user" containing the + * storage owner + */ + public function __construct(array $parameters) { + $this->user = $parameters['user']; + $datadir = $this->user->getHome(); + $this->id = 'home::' . $this->user->getUID(); + + parent::__construct(['datadir' => $datadir]); + } + + public function getId(): string { + return $this->id; + } + + public function getCache(string $path = '', ?IStorage $storage = null): ICache { + if (!$storage) { + $storage = $this; + } + if (!isset($this->cache)) { + $this->cache = new \OC\Files\Cache\HomeCache($storage, $this->getCacheDependencies()); + } + return $this->cache; + } + + public function getPropagator(?IStorage $storage = null): IPropagator { + if (!$storage) { + $storage = $this; + } + if (!isset($this->propagator)) { + $this->propagator = new HomePropagator($storage, \OC::$server->getDatabaseConnection()); + } + return $this->propagator; + } + + + public function getUser(): IUser { + return $this->user; + } + + public function getOwner(string $path): string|false { + return $this->user->getUID(); + } +} diff --git a/lib/private/Files/Storage/Local.php b/lib/private/Files/Storage/Local.php new file mode 100644 index 00000000000..260f9218a88 --- /dev/null +++ b/lib/private/Files/Storage/Local.php @@ -0,0 +1,594 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage; + +use OC\Files\Filesystem; +use OC\Files\Storage\Wrapper\Encryption; +use OC\Files\Storage\Wrapper\Jail; +use OCP\Constants; +use OCP\Files\ForbiddenException; +use OCP\Files\GenericFileException; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\Storage\IStorage; +use OCP\Files\StorageNotAvailableException; +use OCP\IConfig; +use OCP\Server; +use OCP\Util; +use Psr\Log\LoggerInterface; + +/** + * for local filestore, we only have to map the paths + */ +class Local extends \OC\Files\Storage\Common { + protected $datadir; + + protected $dataDirLength; + + protected $realDataDir; + + private IConfig $config; + + private IMimeTypeDetector $mimeTypeDetector; + + private $defUMask; + + protected bool $unlinkOnTruncate; + + protected bool $caseInsensitive = false; + + public function __construct(array $parameters) { + if (!isset($parameters['datadir']) || !is_string($parameters['datadir'])) { + throw new \InvalidArgumentException('No data directory set for local storage'); + } + $this->datadir = str_replace('//', '/', $parameters['datadir']); + // some crazy code uses a local storage on root... + if ($this->datadir === '/') { + $this->realDataDir = $this->datadir; + } else { + $realPath = realpath($this->datadir) ?: $this->datadir; + $this->realDataDir = rtrim($realPath, '/') . '/'; + } + if (!str_ends_with($this->datadir, '/')) { + $this->datadir .= '/'; + } + $this->dataDirLength = strlen($this->realDataDir); + $this->config = Server::get(IConfig::class); + $this->mimeTypeDetector = Server::get(IMimeTypeDetector::class); + $this->defUMask = $this->config->getSystemValue('localstorage.umask', 0022); + $this->caseInsensitive = $this->config->getSystemValueBool('localstorage.case_insensitive', false); + + // support Write-Once-Read-Many file systems + $this->unlinkOnTruncate = $this->config->getSystemValueBool('localstorage.unlink_on_truncate', false); + + if (isset($parameters['isExternal']) && $parameters['isExternal'] && !$this->stat('')) { + // data dir not accessible or available, can happen when using an external storage of type Local + // on an unmounted system mount point + throw new StorageNotAvailableException('Local storage path does not exist "' . $this->getSourcePath('') . '"'); + } + } + + public function __destruct() { + } + + public function getId(): string { + return 'local::' . $this->datadir; + } + + public function mkdir(string $path): bool { + $sourcePath = $this->getSourcePath($path); + $oldMask = umask($this->defUMask); + $result = @mkdir($sourcePath, 0777, true); + umask($oldMask); + return $result; + } + + public function rmdir(string $path): bool { + if (!$this->isDeletable($path)) { + return false; + } + try { + $it = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->getSourcePath($path)), + \RecursiveIteratorIterator::CHILD_FIRST + ); + /** + * RecursiveDirectoryIterator on an NFS path isn't iterable with foreach + * This bug is fixed in PHP 5.5.9 or before + * See #8376 + */ + $it->rewind(); + while ($it->valid()) { + /** + * @var \SplFileInfo $file + */ + $file = $it->current(); + clearstatcache(true, $file->getRealPath()); + if (in_array($file->getBasename(), ['.', '..'])) { + $it->next(); + continue; + } elseif ($file->isFile() || $file->isLink()) { + unlink($file->getPathname()); + } elseif ($file->isDir()) { + rmdir($file->getPathname()); + } + $it->next(); + } + unset($it); // Release iterator and thereby its potential directory lock (e.g. in case of VirtualBox shared folders) + clearstatcache(true, $this->getSourcePath($path)); + return rmdir($this->getSourcePath($path)); + } catch (\UnexpectedValueException $e) { + return false; + } + } + + public function opendir(string $path) { + return opendir($this->getSourcePath($path)); + } + + public function is_dir(string $path): bool { + if ($this->caseInsensitive && !$this->file_exists($path)) { + return false; + } + if (str_ends_with($path, '/')) { + $path = substr($path, 0, -1); + } + return is_dir($this->getSourcePath($path)); + } + + public function is_file(string $path): bool { + if ($this->caseInsensitive && !$this->file_exists($path)) { + return false; + } + return is_file($this->getSourcePath($path)); + } + + public function stat(string $path): array|false { + $fullPath = $this->getSourcePath($path); + clearstatcache(true, $fullPath); + if (!file_exists($fullPath)) { + return false; + } + $statResult = @stat($fullPath); + if (PHP_INT_SIZE === 4 && $statResult && !$this->is_dir($path)) { + $filesize = $this->filesize($path); + $statResult['size'] = $filesize; + $statResult[7] = $filesize; + } + if (is_array($statResult)) { + $statResult['full_path'] = $fullPath; + } + return $statResult; + } + + public function getMetaData(string $path): ?array { + try { + $stat = $this->stat($path); + } catch (ForbiddenException $e) { + return null; + } + if (!$stat) { + return null; + } + + $permissions = Constants::PERMISSION_SHARE; + $statPermissions = $stat['mode']; + $isDir = ($statPermissions & 0x4000) === 0x4000 && !($statPermissions & 0x8000); + if ($statPermissions & 0x0100) { + $permissions += Constants::PERMISSION_READ; + } + if ($statPermissions & 0x0080) { + $permissions += Constants::PERMISSION_UPDATE; + if ($isDir) { + $permissions += Constants::PERMISSION_CREATE; + } + } + + if (!($path === '' || $path === '/')) { // deletable depends on the parents unix permissions + $parent = dirname($stat['full_path']); + if (is_writable($parent)) { + $permissions += Constants::PERMISSION_DELETE; + } + } + + $data = []; + $data['mimetype'] = $isDir ? 'httpd/unix-directory' : $this->mimeTypeDetector->detectPath($path); + $data['mtime'] = $stat['mtime']; + if ($data['mtime'] === false) { + $data['mtime'] = time(); + } + if ($isDir) { + $data['size'] = -1; //unknown + } else { + $data['size'] = $stat['size']; + } + $data['etag'] = $this->calculateEtag($path, $stat); + $data['storage_mtime'] = $data['mtime']; + $data['permissions'] = $permissions; + $data['name'] = basename($path); + + return $data; + } + + public function filetype(string $path): string|false { + $filetype = filetype($this->getSourcePath($path)); + if ($filetype == 'link') { + $filetype = filetype(realpath($this->getSourcePath($path))); + } + return $filetype; + } + + public function filesize(string $path): int|float|false { + if (!$this->is_file($path)) { + return 0; + } + $fullPath = $this->getSourcePath($path); + if (PHP_INT_SIZE === 4) { + $helper = new \OC\LargeFileHelper; + return $helper->getFileSize($fullPath); + } + return filesize($fullPath); + } + + public function isReadable(string $path): bool { + return is_readable($this->getSourcePath($path)); + } + + public function isUpdatable(string $path): bool { + return is_writable($this->getSourcePath($path)); + } + + public function file_exists(string $path): bool { + if ($this->caseInsensitive) { + $fullPath = $this->getSourcePath($path); + $parentPath = dirname($fullPath); + if (!is_dir($parentPath)) { + return false; + } + $content = scandir($parentPath, SCANDIR_SORT_NONE); + return is_array($content) && array_search(basename($fullPath), $content) !== false; + } else { + return file_exists($this->getSourcePath($path)); + } + } + + public function filemtime(string $path): int|false { + $fullPath = $this->getSourcePath($path); + clearstatcache(true, $fullPath); + if (!$this->file_exists($path)) { + return false; + } + if (PHP_INT_SIZE === 4) { + $helper = new \OC\LargeFileHelper(); + return $helper->getFileMtime($fullPath); + } + return filemtime($fullPath); + } + + public function touch(string $path, ?int $mtime = null): bool { + // sets the modification time of the file to the given value. + // If mtime is nil the current time is set. + // note that the access time of the file always changes to the current time. + if ($this->file_exists($path) && !$this->isUpdatable($path)) { + return false; + } + $oldMask = umask($this->defUMask); + if (!is_null($mtime)) { + $result = @touch($this->getSourcePath($path), $mtime); + } else { + $result = @touch($this->getSourcePath($path)); + } + umask($oldMask); + if ($result) { + clearstatcache(true, $this->getSourcePath($path)); + } + + return $result; + } + + public function file_get_contents(string $path): string|false { + return file_get_contents($this->getSourcePath($path)); + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + $oldMask = umask($this->defUMask); + if ($this->unlinkOnTruncate) { + $this->unlink($path); + } + $result = file_put_contents($this->getSourcePath($path), $data); + umask($oldMask); + return $result; + } + + public function unlink(string $path): bool { + if ($this->is_dir($path)) { + return $this->rmdir($path); + } elseif ($this->is_file($path)) { + return unlink($this->getSourcePath($path)); + } else { + return false; + } + } + + private function checkTreeForForbiddenItems(string $path): void { + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path)); + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if (Filesystem::isFileBlacklisted($file->getBasename())) { + throw new ForbiddenException('Invalid path: ' . $file->getPathname(), false); + } + } + } + + public function rename(string $source, string $target): bool { + $srcParent = dirname($source); + $dstParent = dirname($target); + + if (!$this->isUpdatable($srcParent)) { + Server::get(LoggerInterface::class)->error('unable to rename, source directory is not writable : ' . $srcParent, ['app' => 'core']); + return false; + } + + if (!$this->isUpdatable($dstParent)) { + Server::get(LoggerInterface::class)->error('unable to rename, destination directory is not writable : ' . $dstParent, ['app' => 'core']); + return false; + } + + if (!$this->file_exists($source)) { + Server::get(LoggerInterface::class)->error('unable to rename, file does not exists : ' . $source, ['app' => 'core']); + return false; + } + + if ($this->file_exists($target)) { + if ($this->is_dir($target)) { + $this->rmdir($target); + } elseif ($this->is_file($target)) { + $this->unlink($target); + } + } + + if ($this->is_dir($source)) { + $this->checkTreeForForbiddenItems($this->getSourcePath($source)); + } + + if (@rename($this->getSourcePath($source), $this->getSourcePath($target))) { + if ($this->caseInsensitive) { + if (mb_strtolower($target) === mb_strtolower($source) && !$this->file_exists($target)) { + return false; + } + } + return true; + } + + return $this->copy($source, $target) && $this->unlink($source); + } + + public function copy(string $source, string $target): bool { + if ($this->is_dir($source)) { + return parent::copy($source, $target); + } else { + $oldMask = umask($this->defUMask); + if ($this->unlinkOnTruncate) { + $this->unlink($target); + } + $result = copy($this->getSourcePath($source), $this->getSourcePath($target)); + umask($oldMask); + if ($this->caseInsensitive) { + if (mb_strtolower($target) === mb_strtolower($source) && !$this->file_exists($target)) { + return false; + } + } + return $result; + } + } + + public function fopen(string $path, string $mode) { + $sourcePath = $this->getSourcePath($path); + if (!file_exists($sourcePath) && $mode === 'r') { + return false; + } + $oldMask = umask($this->defUMask); + if (($mode === 'w' || $mode === 'w+') && $this->unlinkOnTruncate) { + $this->unlink($path); + } + $result = @fopen($sourcePath, $mode); + umask($oldMask); + return $result; + } + + public function hash(string $type, string $path, bool $raw = false): string|false { + return hash_file($type, $this->getSourcePath($path), $raw); + } + + public function free_space(string $path): int|float|false { + $sourcePath = $this->getSourcePath($path); + // using !is_dir because $sourcePath might be a part file or + // non-existing file, so we'd still want to use the parent dir + // in such cases + if (!is_dir($sourcePath)) { + // disk_free_space doesn't work on files + $sourcePath = dirname($sourcePath); + } + $space = (function_exists('disk_free_space') && is_dir($sourcePath)) ? disk_free_space($sourcePath) : false; + if ($space === false || is_null($space)) { + return \OCP\Files\FileInfo::SPACE_UNKNOWN; + } + return Util::numericToNumber($space); + } + + public function search(string $query): array { + return $this->searchInDir($query); + } + + public function getLocalFile(string $path): string|false { + return $this->getSourcePath($path); + } + + protected function searchInDir(string $query, string $dir = ''): array { + $files = []; + $physicalDir = $this->getSourcePath($dir); + foreach (scandir($physicalDir) as $item) { + if (\OC\Files\Filesystem::isIgnoredDir($item)) { + continue; + } + $physicalItem = $physicalDir . '/' . $item; + + if (strstr(strtolower($item), strtolower($query)) !== false) { + $files[] = $dir . '/' . $item; + } + if (is_dir($physicalItem)) { + $files = array_merge($files, $this->searchInDir($query, $dir . '/' . $item)); + } + } + return $files; + } + + public function hasUpdated(string $path, int $time): bool { + if ($this->file_exists($path)) { + return $this->filemtime($path) > $time; + } else { + return true; + } + } + + /** + * Get the source path (on disk) of a given path + * + * @throws ForbiddenException + */ + public function getSourcePath(string $path): string { + if (Filesystem::isFileBlacklisted($path)) { + throw new ForbiddenException('Invalid path: ' . $path, false); + } + + $fullPath = $this->datadir . $path; + $currentPath = $path; + $allowSymlinks = $this->config->getSystemValueBool('localstorage.allowsymlinks', false); + if ($allowSymlinks || $currentPath === '') { + return $fullPath; + } + $pathToResolve = $fullPath; + $realPath = realpath($pathToResolve); + while ($realPath === false) { // for non existing files check the parent directory + $currentPath = dirname($currentPath); + /** @psalm-suppress TypeDoesNotContainType Let's be extra cautious and still check for empty string */ + if ($currentPath === '' || $currentPath === '.') { + return $fullPath; + } + $realPath = realpath($this->datadir . $currentPath); + } + if ($realPath) { + $realPath = $realPath . '/'; + } + if (substr($realPath, 0, $this->dataDirLength) === $this->realDataDir) { + return $fullPath; + } + + Server::get(LoggerInterface::class)->error("Following symlinks is not allowed ('$fullPath' -> '$realPath' not inside '{$this->realDataDir}')", ['app' => 'core']); + throw new ForbiddenException('Following symlinks is not allowed', false); + } + + public function isLocal(): bool { + return true; + } + + public function getETag(string $path): string|false { + return $this->calculateEtag($path, $this->stat($path)); + } + + private function calculateEtag(string $path, array $stat): string|false { + if ($stat['mode'] & 0x4000 && !($stat['mode'] & 0x8000)) { // is_dir & not socket + return parent::getETag($path); + } else { + if ($stat === false) { + return md5(''); + } + + $toHash = ''; + if (isset($stat['mtime'])) { + $toHash .= $stat['mtime']; + } + if (isset($stat['ino'])) { + $toHash .= $stat['ino']; + } + if (isset($stat['dev'])) { + $toHash .= $stat['dev']; + } + if (isset($stat['size'])) { + $toHash .= $stat['size']; + } + + return md5($toHash); + } + } + + private function canDoCrossStorageMove(IStorage $sourceStorage): bool { + /** @psalm-suppress UndefinedClass,InvalidArgument */ + return $sourceStorage->instanceOfStorage(Local::class) + // Don't treat ACLStorageWrapper like local storage where copy can be done directly. + // Instead, use the slower recursive copying in php from Common::copyFromStorage with + // more permissions checks. + && !$sourceStorage->instanceOfStorage('OCA\GroupFolders\ACL\ACLStorageWrapper') + // Same for access control + && !$sourceStorage->instanceOfStorage(\OCA\FilesAccessControl\StorageWrapper::class) + // when moving encrypted files we have to handle keys and the target might not be encrypted + && !$sourceStorage->instanceOfStorage(Encryption::class); + } + + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath, bool $preserveMtime = false): bool { + if ($this->canDoCrossStorageMove($sourceStorage)) { + // resolve any jailed paths + while ($sourceStorage->instanceOfStorage(Jail::class)) { + /** + * @var \OC\Files\Storage\Wrapper\Jail $sourceStorage + */ + $sourceInternalPath = $sourceStorage->getUnjailedPath($sourceInternalPath); + $sourceStorage = $sourceStorage->getUnjailedStorage(); + } + /** + * @var \OC\Files\Storage\Local $sourceStorage + */ + $rootStorage = new Local(['datadir' => '/']); + return $rootStorage->copy($sourceStorage->getSourcePath($sourceInternalPath), $this->getSourcePath($targetInternalPath)); + } else { + return parent::copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } + } + + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + if ($this->canDoCrossStorageMove($sourceStorage)) { + // resolve any jailed paths + while ($sourceStorage->instanceOfStorage(Jail::class)) { + /** + * @var \OC\Files\Storage\Wrapper\Jail $sourceStorage + */ + $sourceInternalPath = $sourceStorage->getUnjailedPath($sourceInternalPath); + $sourceStorage = $sourceStorage->getUnjailedStorage(); + } + /** + * @var \OC\Files\Storage\Local $sourceStorage + */ + $rootStorage = new Local(['datadir' => '/']); + return $rootStorage->rename($sourceStorage->getSourcePath($sourceInternalPath), $this->getSourcePath($targetInternalPath)); + } else { + return parent::moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + /** @var int|false $result We consider here that returned size will never be a float because we write less than 4GB */ + $result = $this->file_put_contents($path, $stream); + if (is_resource($stream)) { + fclose($stream); + } + if ($result === false) { + throw new GenericFileException("Failed write stream to $path"); + } else { + return $result; + } + } +} diff --git a/lib/private/Files/Storage/LocalRootStorage.php b/lib/private/Files/Storage/LocalRootStorage.php new file mode 100644 index 00000000000..2e0645e092a --- /dev/null +++ b/lib/private/Files/Storage/LocalRootStorage.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Storage; + +use OC\Files\Cache\LocalRootScanner; +use OCP\Files\Cache\IScanner; +use OCP\Files\Storage\IStorage; + +class LocalRootStorage extends Local { + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner { + if (!$storage) { + $storage = $this; + } + return $storage->scanner ?? ($storage->scanner = new LocalRootScanner($storage)); + } +} diff --git a/lib/private/Files/Storage/LocalTempFileTrait.php b/lib/private/Files/Storage/LocalTempFileTrait.php new file mode 100644 index 00000000000..fffc3e789f3 --- /dev/null +++ b/lib/private/Files/Storage/LocalTempFileTrait.php @@ -0,0 +1,54 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage; + +use OCP\Files; + +/** + * Storage backend class for providing common filesystem operation methods + * which are not storage-backend specific. + * + * \OC\Files\Storage\Common is never used directly; it is extended by all other + * storage backends, where its methods may be overridden, and additional + * (backend-specific) methods are defined. + * + * Some \OC\Files\Storage\Common methods call functions which are first defined + * in classes which extend it, e.g. $this->stat() . + */ +trait LocalTempFileTrait { + /** @var array<string,string|false> */ + protected array $cachedFiles = []; + + protected function getCachedFile(string $path): string|false { + if (!isset($this->cachedFiles[$path])) { + $this->cachedFiles[$path] = $this->toTmpFile($path); + } + return $this->cachedFiles[$path]; + } + + protected function removeCachedFile(string $path): void { + unset($this->cachedFiles[$path]); + } + + protected function toTmpFile(string $path): string|false { //no longer in the storage api, still useful here + $source = $this->fopen($path, 'r'); + if (!$source) { + return false; + } + if ($pos = strrpos($path, '.')) { + $extension = substr($path, $pos); + } else { + $extension = ''; + } + $tmpFile = \OC::$server->getTempManager()->getTemporaryFile($extension); + $target = fopen($tmpFile, 'w'); + Files::streamCopy($source, $target); + fclose($target); + return $tmpFile; + } +} diff --git a/lib/private/Files/Storage/PolyFill/CopyDirectory.php b/lib/private/Files/Storage/PolyFill/CopyDirectory.php new file mode 100644 index 00000000000..2f6167ef85e --- /dev/null +++ b/lib/private/Files/Storage/PolyFill/CopyDirectory.php @@ -0,0 +1,71 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage\PolyFill; + +trait CopyDirectory { + /** + * Check if a path is a directory + */ + abstract public function is_dir(string $path): bool; + + /** + * Check if a file or folder exists + */ + abstract public function file_exists(string $path): bool; + + /** + * Delete a file or folder + */ + abstract public function unlink(string $path): bool; + + /** + * Open a directory handle for a folder + * + * @return resource|false + */ + abstract public function opendir(string $path); + + /** + * Create a new folder + */ + abstract public function mkdir(string $path): bool; + + public function copy(string $source, string $target): bool { + if ($this->is_dir($source)) { + if ($this->file_exists($target)) { + $this->unlink($target); + } + $this->mkdir($target); + return $this->copyRecursive($source, $target); + } else { + return parent::copy($source, $target); + } + } + + /** + * For adapters that don't support copying folders natively + */ + protected function copyRecursive(string $source, string $target): bool { + $dh = $this->opendir($source); + $result = true; + while (($file = readdir($dh)) !== false) { + if (!\OC\Files\Filesystem::isIgnoredDir($file)) { + if ($this->is_dir($source . '/' . $file)) { + $this->mkdir($target . '/' . $file); + $result = $this->copyRecursive($source . '/' . $file, $target . '/' . $file); + } else { + $result = parent::copy($source . '/' . $file, $target . '/' . $file); + } + if (!$result) { + break; + } + } + } + return $result; + } +} diff --git a/lib/private/Files/Storage/Storage.php b/lib/private/Files/Storage/Storage.php new file mode 100644 index 00000000000..aa17c12b309 --- /dev/null +++ b/lib/private/Files/Storage/Storage.php @@ -0,0 +1,53 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OC\Files\Storage; + +use OCP\Files\Cache\ICache; +use OCP\Files\Cache\IPropagator; +use OCP\Files\Cache\IScanner; +use OCP\Files\Cache\IUpdater; +use OCP\Files\Cache\IWatcher; +use OCP\Files\Storage\ILockingStorage; +use OCP\Files\Storage\IStorage; + +/** + * Provide a common interface to all different storage options + * + * All paths passed to the storage are relative to the storage and should NOT have a leading slash. + */ +interface Storage extends IStorage, ILockingStorage { + public function getCache(string $path = '', ?IStorage $storage = null): ICache; + + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner; + + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher; + + public function getPropagator(?IStorage $storage = null): IPropagator; + + public function getUpdater(?IStorage $storage = null): IUpdater; + + public function getStorageCache(): \OC\Files\Cache\Storage; + + public function getMetaData(string $path): ?array; + + /** + * Get the contents of a directory with metadata + * + * The metadata array will contain the following fields + * + * - name + * - mimetype + * - mtime + * - size + * - etag + * - storage_mtime + * - permissions + */ + public function getDirectoryContent(string $directory): \Traversable; +} diff --git a/lib/private/Files/Storage/StorageFactory.php b/lib/private/Files/Storage/StorageFactory.php new file mode 100644 index 00000000000..603df7fe007 --- /dev/null +++ b/lib/private/Files/Storage/StorageFactory.php @@ -0,0 +1,73 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage; + +use OCP\Files\Mount\IMountPoint; +use OCP\Files\Storage\IConstructableStorage; +use OCP\Files\Storage\IStorage; +use OCP\Files\Storage\IStorageFactory; +use Psr\Log\LoggerInterface; + +class StorageFactory implements IStorageFactory { + /** + * @var array[] [$name=>['priority'=>$priority, 'wrapper'=>$callable] $storageWrappers + */ + private $storageWrappers = []; + + public function addStorageWrapper(string $wrapperName, callable $callback, int $priority = 50, array $existingMounts = []): bool { + if (isset($this->storageWrappers[$wrapperName])) { + return false; + } + + // apply to existing mounts before registering it to prevent applying it double in MountPoint::createStorage + foreach ($existingMounts as $mount) { + $mount->wrapStorage($callback); + } + + $this->storageWrappers[$wrapperName] = ['wrapper' => $callback, 'priority' => $priority]; + return true; + } + + /** + * Remove a storage wrapper by name. + * Note: internal method only to be used for cleanup + * + * @internal + */ + public function removeStorageWrapper(string $wrapperName): void { + unset($this->storageWrappers[$wrapperName]); + } + + /** + * Create an instance of a storage and apply the registered storage wrappers + */ + public function getInstance(IMountPoint $mountPoint, string $class, array $arguments): IStorage { + if (!is_a($class, IConstructableStorage::class, true)) { + \OCP\Server::get(LoggerInterface::class)->warning('Building a storage not implementing IConstructableStorage is deprecated since 31.0.0', ['class' => $class]); + } + return $this->wrap($mountPoint, new $class($arguments)); + } + + public function wrap(IMountPoint $mountPoint, IStorage $storage): IStorage { + $wrappers = array_values($this->storageWrappers); + usort($wrappers, function ($a, $b) { + return $b['priority'] - $a['priority']; + }); + /** @var callable[] $wrappers */ + $wrappers = array_map(function ($wrapper) { + return $wrapper['wrapper']; + }, $wrappers); + foreach ($wrappers as $wrapper) { + $storage = $wrapper($mountPoint->getMountPoint(), $storage, $mountPoint); + if (!($storage instanceof IStorage)) { + throw new \Exception('Invalid result from storage wrapper'); + } + } + return $storage; + } +} diff --git a/lib/private/Files/Storage/Temporary.php b/lib/private/Files/Storage/Temporary.php new file mode 100644 index 00000000000..ecf8a1315a9 --- /dev/null +++ b/lib/private/Files/Storage/Temporary.php @@ -0,0 +1,34 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage; + +use OCP\Files; +use OCP\ITempManager; +use OCP\Server; + +/** + * local storage backend in temporary folder for testing purpose + */ +class Temporary extends Local { + public function __construct(array $parameters = []) { + parent::__construct(['datadir' => Server::get(ITempManager::class)->getTemporaryFolder()]); + } + + public function cleanUp(): void { + Files::rmdirr($this->datadir); + } + + public function __destruct() { + parent::__destruct(); + $this->cleanUp(); + } + + public function getDataDir(): array|string { + return $this->datadir; + } +} diff --git a/lib/private/Files/Storage/Wrapper/Availability.php b/lib/private/Files/Storage/Wrapper/Availability.php new file mode 100644 index 00000000000..32c51a1b25e --- /dev/null +++ b/lib/private/Files/Storage/Wrapper/Availability.php @@ -0,0 +1,277 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage\Wrapper; + +use OCP\Files\Storage\IStorage; +use OCP\Files\StorageAuthException; +use OCP\Files\StorageNotAvailableException; +use OCP\IConfig; + +/** + * Availability checker for storages + * + * Throws a StorageNotAvailableException for storages with known failures + */ +class Availability extends Wrapper { + public const RECHECK_TTL_SEC = 600; // 10 minutes + + /** @var IConfig */ + protected $config; + + public function __construct(array $parameters) { + $this->config = $parameters['config'] ?? \OCP\Server::get(IConfig::class); + parent::__construct($parameters); + } + + public static function shouldRecheck($availability): bool { + if (!$availability['available']) { + // trigger a recheck if TTL reached + if ((time() - $availability['last_checked']) > self::RECHECK_TTL_SEC) { + return true; + } + } + return false; + } + + /** + * Only called if availability === false + */ + private function updateAvailability(): bool { + // reset availability to false so that multiple requests don't recheck concurrently + $this->setAvailability(false); + try { + $result = $this->test(); + } catch (\Exception $e) { + $result = false; + } + $this->setAvailability($result); + return $result; + } + + private function isAvailable(): bool { + $availability = $this->getAvailability(); + if (self::shouldRecheck($availability)) { + return $this->updateAvailability(); + } + return $availability['available']; + } + + /** + * @throws StorageNotAvailableException + */ + private function checkAvailability(): void { + if (!$this->isAvailable()) { + throw new StorageNotAvailableException(); + } + } + + /** + * Handles availability checks and delegates method calls dynamically + */ + private function handleAvailability(string $method, mixed ...$args): mixed { + $this->checkAvailability(); + try { + return call_user_func_array([parent::class, $method], $args); + } catch (StorageNotAvailableException $e) { + $this->setUnavailable($e); + return false; + } + } + + public function mkdir(string $path): bool { + return $this->handleAvailability('mkdir', $path); + } + + public function rmdir(string $path): bool { + return $this->handleAvailability('rmdir', $path); + } + + public function opendir(string $path) { + return $this->handleAvailability('opendir', $path); + } + + public function is_dir(string $path): bool { + return $this->handleAvailability('is_dir', $path); + } + + public function is_file(string $path): bool { + return $this->handleAvailability('is_file', $path); + } + + public function stat(string $path): array|false { + return $this->handleAvailability('stat', $path); + } + + public function filetype(string $path): string|false { + return $this->handleAvailability('filetype', $path); + } + + public function filesize(string $path): int|float|false { + return $this->handleAvailability('filesize', $path); + } + + public function isCreatable(string $path): bool { + return $this->handleAvailability('isCreatable', $path); + } + + public function isReadable(string $path): bool { + return $this->handleAvailability('isReadable', $path); + } + + public function isUpdatable(string $path): bool { + return $this->handleAvailability('isUpdatable', $path); + } + + public function isDeletable(string $path): bool { + return $this->handleAvailability('isDeletable', $path); + } + + public function isSharable(string $path): bool { + return $this->handleAvailability('isSharable', $path); + } + + public function getPermissions(string $path): int { + return $this->handleAvailability('getPermissions', $path); + } + + public function file_exists(string $path): bool { + if ($path === '') { + return true; + } + return $this->handleAvailability('file_exists', $path); + } + + public function filemtime(string $path): int|false { + return $this->handleAvailability('filemtime', $path); + } + + public function file_get_contents(string $path): string|false { + return $this->handleAvailability('file_get_contents', $path); + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + return $this->handleAvailability('file_put_contents', $path, $data); + } + + public function unlink(string $path): bool { + return $this->handleAvailability('unlink', $path); + } + + public function rename(string $source, string $target): bool { + return $this->handleAvailability('rename', $source, $target); + } + + public function copy(string $source, string $target): bool { + return $this->handleAvailability('copy', $source, $target); + } + + public function fopen(string $path, string $mode) { + return $this->handleAvailability('fopen', $path, $mode); + } + + public function getMimeType(string $path): string|false { + return $this->handleAvailability('getMimeType', $path); + } + + public function hash(string $type, string $path, bool $raw = false): string|false { + return $this->handleAvailability('hash', $type, $path, $raw); + } + + public function free_space(string $path): int|float|false { + return $this->handleAvailability('free_space', $path); + } + + public function touch(string $path, ?int $mtime = null): bool { + return $this->handleAvailability('touch', $path, $mtime); + } + + public function getLocalFile(string $path): string|false { + return $this->handleAvailability('getLocalFile', $path); + } + + public function hasUpdated(string $path, int $time): bool { + if (!$this->isAvailable()) { + return false; + } + try { + return parent::hasUpdated($path, $time); + } catch (StorageNotAvailableException $e) { + // set unavailable but don't rethrow + $this->setUnavailable(null); + return false; + } + } + + public function getOwner(string $path): string|false { + try { + return parent::getOwner($path); + } catch (StorageNotAvailableException $e) { + $this->setUnavailable($e); + return false; + } + } + + public function getETag(string $path): string|false { + return $this->handleAvailability('getETag', $path); + } + + public function getDirectDownload(string $path): array|false { + return $this->handleAvailability('getDirectDownload', $path); + } + + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + return $this->handleAvailability('copyFromStorage', $sourceStorage, $sourceInternalPath, $targetInternalPath); + } + + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + return $this->handleAvailability('moveFromStorage', $sourceStorage, $sourceInternalPath, $targetInternalPath); + } + + public function getMetaData(string $path): ?array { + $this->checkAvailability(); + try { + return parent::getMetaData($path); + } catch (StorageNotAvailableException $e) { + $this->setUnavailable($e); + return null; + } + } + + /** + * @template T of StorageNotAvailableException|null + * @param T $e + * @psalm-return (T is null ? void : never) + * @throws StorageNotAvailableException + */ + protected function setUnavailable(?StorageNotAvailableException $e): void { + $delay = self::RECHECK_TTL_SEC; + if ($e instanceof StorageAuthException) { + $delay = max( + // 30min + $this->config->getSystemValueInt('external_storage.auth_availability_delay', 1800), + self::RECHECK_TTL_SEC + ); + } + $this->getStorageCache()->setAvailability(false, $delay); + if ($e !== null) { + throw $e; + } + } + + + + public function getDirectoryContent(string $directory): \Traversable { + $this->checkAvailability(); + try { + return parent::getDirectoryContent($directory); + } catch (StorageNotAvailableException $e) { + $this->setUnavailable($e); + return new \EmptyIterator(); + } + } +} diff --git a/lib/private/Files/Storage/Wrapper/Encoding.php b/lib/private/Files/Storage/Wrapper/Encoding.php new file mode 100644 index 00000000000..92e20cfb3df --- /dev/null +++ b/lib/private/Files/Storage/Wrapper/Encoding.php @@ -0,0 +1,296 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage\Wrapper; + +use OC\Files\Filesystem; +use OCP\Cache\CappedMemoryCache; +use OCP\Files\Cache\IScanner; +use OCP\Files\Storage\IStorage; +use OCP\ICache; + +/** + * Encoding wrapper that deals with file names that use unsupported encodings like NFD. + * + * When applied and a UTF-8 path name was given, the wrapper will first attempt to access + * the actual given name and then try its NFD form. + */ +class Encoding extends Wrapper { + /** + * @var ICache + */ + private $namesCache; + + /** + * @param array $parameters + */ + public function __construct(array $parameters) { + $this->storage = $parameters['storage']; + $this->namesCache = new CappedMemoryCache(); + } + + /** + * Returns whether the given string is only made of ASCII characters + */ + private function isAscii(string $str): bool { + return !preg_match('/[\\x80-\\xff]+/', $str); + } + + /** + * Checks whether the given path exists in NFC or NFD form after checking + * each form for each path section and returns the correct form. + * If no existing path found, returns the path as it was given. + * + * @return string original or converted path + */ + private function findPathToUse(string $fullPath): string { + $cachedPath = $this->namesCache[$fullPath]; + if ($cachedPath !== null) { + return $cachedPath; + } + + $sections = explode('/', $fullPath); + $path = ''; + foreach ($sections as $section) { + $convertedPath = $this->findPathToUseLastSection($path, $section); + if ($convertedPath === null) { + // no point in continuing if the section was not found, use original path + return $fullPath; + } + $path = $convertedPath . '/'; + } + $path = rtrim($path, '/'); + return $path; + } + + /** + * Checks whether the last path section of the given path exists in NFC or NFD form + * and returns the correct form. If no existing path found, returns null. + * + * @param string $lastSection last section of the path to check for NFD/NFC variations + * + * @return string|null original or converted path, or null if none of the forms was found + */ + private function findPathToUseLastSection(string $basePath, string $lastSection): ?string { + $fullPath = $basePath . $lastSection; + if ($lastSection === '' || $this->isAscii($lastSection) || $this->storage->file_exists($fullPath)) { + $this->namesCache[$fullPath] = $fullPath; + return $fullPath; + } + + // swap encoding + if (\Normalizer::isNormalized($lastSection, \Normalizer::FORM_C)) { + $otherFormPath = \Normalizer::normalize($lastSection, \Normalizer::FORM_D); + } else { + $otherFormPath = \Normalizer::normalize($lastSection, \Normalizer::FORM_C); + } + $otherFullPath = $basePath . $otherFormPath; + if ($this->storage->file_exists($otherFullPath)) { + $this->namesCache[$fullPath] = $otherFullPath; + return $otherFullPath; + } + + // return original path, file did not exist at all + $this->namesCache[$fullPath] = $fullPath; + return null; + } + + public function mkdir(string $path): bool { + // note: no conversion here, method should not be called with non-NFC names! + $result = $this->storage->mkdir($path); + if ($result) { + $this->namesCache[$path] = $path; + } + return $result; + } + + public function rmdir(string $path): bool { + $result = $this->storage->rmdir($this->findPathToUse($path)); + if ($result) { + unset($this->namesCache[$path]); + } + return $result; + } + + public function opendir(string $path) { + $handle = $this->storage->opendir($this->findPathToUse($path)); + return EncodingDirectoryWrapper::wrap($handle); + } + + public function is_dir(string $path): bool { + return $this->storage->is_dir($this->findPathToUse($path)); + } + + public function is_file(string $path): bool { + return $this->storage->is_file($this->findPathToUse($path)); + } + + public function stat(string $path): array|false { + return $this->storage->stat($this->findPathToUse($path)); + } + + public function filetype(string $path): string|false { + return $this->storage->filetype($this->findPathToUse($path)); + } + + public function filesize(string $path): int|float|false { + return $this->storage->filesize($this->findPathToUse($path)); + } + + public function isCreatable(string $path): bool { + return $this->storage->isCreatable($this->findPathToUse($path)); + } + + public function isReadable(string $path): bool { + return $this->storage->isReadable($this->findPathToUse($path)); + } + + public function isUpdatable(string $path): bool { + return $this->storage->isUpdatable($this->findPathToUse($path)); + } + + public function isDeletable(string $path): bool { + return $this->storage->isDeletable($this->findPathToUse($path)); + } + + public function isSharable(string $path): bool { + return $this->storage->isSharable($this->findPathToUse($path)); + } + + public function getPermissions(string $path): int { + return $this->storage->getPermissions($this->findPathToUse($path)); + } + + public function file_exists(string $path): bool { + return $this->storage->file_exists($this->findPathToUse($path)); + } + + public function filemtime(string $path): int|false { + return $this->storage->filemtime($this->findPathToUse($path)); + } + + public function file_get_contents(string $path): string|false { + return $this->storage->file_get_contents($this->findPathToUse($path)); + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + return $this->storage->file_put_contents($this->findPathToUse($path), $data); + } + + public function unlink(string $path): bool { + $result = $this->storage->unlink($this->findPathToUse($path)); + if ($result) { + unset($this->namesCache[$path]); + } + return $result; + } + + public function rename(string $source, string $target): bool { + // second name always NFC + return $this->storage->rename($this->findPathToUse($source), $this->findPathToUse($target)); + } + + public function copy(string $source, string $target): bool { + return $this->storage->copy($this->findPathToUse($source), $this->findPathToUse($target)); + } + + public function fopen(string $path, string $mode) { + $result = $this->storage->fopen($this->findPathToUse($path), $mode); + if ($result && $mode !== 'r' && $mode !== 'rb') { + unset($this->namesCache[$path]); + } + return $result; + } + + public function getMimeType(string $path): string|false { + return $this->storage->getMimeType($this->findPathToUse($path)); + } + + public function hash(string $type, string $path, bool $raw = false): string|false { + return $this->storage->hash($type, $this->findPathToUse($path), $raw); + } + + public function free_space(string $path): int|float|false { + return $this->storage->free_space($this->findPathToUse($path)); + } + + public function touch(string $path, ?int $mtime = null): bool { + return $this->storage->touch($this->findPathToUse($path), $mtime); + } + + public function getLocalFile(string $path): string|false { + return $this->storage->getLocalFile($this->findPathToUse($path)); + } + + public function hasUpdated(string $path, int $time): bool { + return $this->storage->hasUpdated($this->findPathToUse($path), $time); + } + + public function getCache(string $path = '', ?IStorage $storage = null): \OCP\Files\Cache\ICache { + if (!$storage) { + $storage = $this; + } + return $this->storage->getCache($this->findPathToUse($path), $storage); + } + + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner { + if (!$storage) { + $storage = $this; + } + return $this->storage->getScanner($this->findPathToUse($path), $storage); + } + + public function getETag(string $path): string|false { + return $this->storage->getETag($this->findPathToUse($path)); + } + + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + if ($sourceStorage === $this) { + return $this->copy($sourceInternalPath, $this->findPathToUse($targetInternalPath)); + } + + $result = $this->storage->copyFromStorage($sourceStorage, $sourceInternalPath, $this->findPathToUse($targetInternalPath)); + if ($result) { + unset($this->namesCache[$targetInternalPath]); + } + return $result; + } + + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + if ($sourceStorage === $this) { + $result = $this->rename($sourceInternalPath, $this->findPathToUse($targetInternalPath)); + if ($result) { + unset($this->namesCache[$sourceInternalPath]); + unset($this->namesCache[$targetInternalPath]); + } + return $result; + } + + $result = $this->storage->moveFromStorage($sourceStorage, $sourceInternalPath, $this->findPathToUse($targetInternalPath)); + if ($result) { + unset($this->namesCache[$sourceInternalPath]); + unset($this->namesCache[$targetInternalPath]); + } + return $result; + } + + public function getMetaData(string $path): ?array { + $entry = $this->storage->getMetaData($this->findPathToUse($path)); + if ($entry !== null) { + $entry['name'] = trim(Filesystem::normalizePath($entry['name']), '/'); + } + return $entry; + } + + public function getDirectoryContent(string $directory): \Traversable { + $entries = $this->storage->getDirectoryContent($this->findPathToUse($directory)); + foreach ($entries as $entry) { + $entry['name'] = trim(Filesystem::normalizePath($entry['name']), '/'); + yield $entry; + } + } +} diff --git a/lib/private/Files/Storage/Wrapper/EncodingDirectoryWrapper.php b/lib/private/Files/Storage/Wrapper/EncodingDirectoryWrapper.php new file mode 100644 index 00000000000..0a90b49f0f1 --- /dev/null +++ b/lib/private/Files/Storage/Wrapper/EncodingDirectoryWrapper.php @@ -0,0 +1,34 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage\Wrapper; + +use Icewind\Streams\DirectoryWrapper; +use OC\Files\Filesystem; + +/** + * Normalize file names while reading directory entries + */ +class EncodingDirectoryWrapper extends DirectoryWrapper { + public function dir_readdir(): string|false { + $file = readdir($this->source); + if ($file !== false && $file !== '.' && $file !== '..') { + $file = trim(Filesystem::normalizePath($file), '/'); + } + + return $file; + } + + /** + * @param resource $source + * @return resource|false + */ + public static function wrap($source) { + return self::wrapSource($source, [ + 'source' => $source, + ]); + } +} diff --git a/lib/private/Files/Storage/Wrapper/Encryption.php b/lib/private/Files/Storage/Wrapper/Encryption.php new file mode 100644 index 00000000000..58bd4dfddcf --- /dev/null +++ b/lib/private/Files/Storage/Wrapper/Encryption.php @@ -0,0 +1,946 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage\Wrapper; + +use OC\Encryption\Exceptions\ModuleDoesNotExistsException; +use OC\Encryption\Util; +use OC\Files\Cache\CacheEntry; +use OC\Files\Filesystem; +use OC\Files\Mount\Manager; +use OC\Files\ObjectStore\ObjectStoreStorage; +use OC\Files\Storage\Common; +use OC\Files\Storage\LocalTempFileTrait; +use OC\Memcache\ArrayCache; +use OCP\Cache\CappedMemoryCache; +use OCP\Encryption\Exceptions\InvalidHeaderException; +use OCP\Encryption\IFile; +use OCP\Encryption\IManager; +use OCP\Encryption\Keys\IStorage; +use OCP\Files; +use OCP\Files\Cache\ICacheEntry; +use OCP\Files\GenericFileException; +use OCP\Files\Mount\IMountPoint; +use OCP\Files\Storage; +use Psr\Log\LoggerInterface; + +class Encryption extends Wrapper { + use LocalTempFileTrait; + + private string $mountPoint; + protected array $unencryptedSize = []; + private IMountPoint $mount; + /** for which path we execute the repair step to avoid recursions */ + private array $fixUnencryptedSizeOf = []; + /** @var CappedMemoryCache<bool> */ + private CappedMemoryCache $encryptedPaths; + private bool $enabled = true; + + /** + * @param array $parameters + */ + public function __construct( + array $parameters, + private IManager $encryptionManager, + private Util $util, + private LoggerInterface $logger, + private IFile $fileHelper, + private ?string $uid, + private IStorage $keyStorage, + private Manager $mountManager, + private ArrayCache $arrayCache, + ) { + $this->mountPoint = $parameters['mountPoint']; + $this->mount = $parameters['mount']; + $this->encryptedPaths = new CappedMemoryCache(); + parent::__construct($parameters); + } + + public function filesize(string $path): int|float|false { + $fullPath = $this->getFullPath($path); + + $info = $this->getCache()->get($path); + if ($info === false) { + /* Pass call to wrapped storage, it may be a special file like a part file */ + return $this->storage->filesize($path); + } + if (isset($this->unencryptedSize[$fullPath])) { + $size = $this->unencryptedSize[$fullPath]; + + // Update file cache (only if file is already cached). + // Certain files are not cached (e.g. *.part). + if (isset($info['fileid'])) { + if ($info instanceof ICacheEntry) { + $info['encrypted'] = $info['encryptedVersion']; + } else { + /** + * @psalm-suppress RedundantCondition + */ + if (!is_array($info)) { + $info = []; + } + $info['encrypted'] = true; + $info = new CacheEntry($info); + } + + if ($size !== $info->getUnencryptedSize()) { + $this->getCache()->update($info->getId(), [ + 'unencrypted_size' => $size + ]); + } + } + + return $size; + } + + if (isset($info['fileid']) && $info['encrypted']) { + return $this->verifyUnencryptedSize($path, $info->getUnencryptedSize()); + } + + return $this->storage->filesize($path); + } + + private function modifyMetaData(string $path, array $data): array { + $fullPath = $this->getFullPath($path); + $info = $this->getCache()->get($path); + + if (isset($this->unencryptedSize[$fullPath])) { + $data['encrypted'] = true; + $data['size'] = $this->unencryptedSize[$fullPath]; + $data['unencrypted_size'] = $data['size']; + } else { + if (isset($info['fileid']) && $info['encrypted']) { + $data['size'] = $this->verifyUnencryptedSize($path, $info->getUnencryptedSize()); + $data['encrypted'] = true; + $data['unencrypted_size'] = $data['size']; + } + } + + if (isset($info['encryptedVersion']) && $info['encryptedVersion'] > 1) { + $data['encryptedVersion'] = $info['encryptedVersion']; + } + + return $data; + } + + public function getMetaData(string $path): ?array { + $data = $this->storage->getMetaData($path); + if (is_null($data)) { + return null; + } + return $this->modifyMetaData($path, $data); + } + + public function getDirectoryContent(string $directory): \Traversable { + $parent = rtrim($directory, '/'); + foreach ($this->getWrapperStorage()->getDirectoryContent($directory) as $data) { + yield $this->modifyMetaData($parent . '/' . $data['name'], $data); + } + } + + public function file_get_contents(string $path): string|false { + $encryptionModule = $this->getEncryptionModule($path); + + if ($encryptionModule) { + $handle = $this->fopen($path, 'r'); + if (!$handle) { + return false; + } + $data = stream_get_contents($handle); + fclose($handle); + return $data; + } + return $this->storage->file_get_contents($path); + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + // file put content will always be translated to a stream write + $handle = $this->fopen($path, 'w'); + if (is_resource($handle)) { + $written = fwrite($handle, $data); + fclose($handle); + return $written; + } + + return false; + } + + public function unlink(string $path): bool { + $fullPath = $this->getFullPath($path); + if ($this->util->isExcluded($fullPath)) { + return $this->storage->unlink($path); + } + + $encryptionModule = $this->getEncryptionModule($path); + if ($encryptionModule) { + $this->keyStorage->deleteAllFileKeys($fullPath); + } + + return $this->storage->unlink($path); + } + + public function rename(string $source, string $target): bool { + $result = $this->storage->rename($source, $target); + + if ($result + // versions always use the keys from the original file, so we can skip + // this step for versions + && $this->isVersion($target) === false + && $this->encryptionManager->isEnabled()) { + $sourcePath = $this->getFullPath($source); + if (!$this->util->isExcluded($sourcePath)) { + $targetPath = $this->getFullPath($target); + if (isset($this->unencryptedSize[$sourcePath])) { + $this->unencryptedSize[$targetPath] = $this->unencryptedSize[$sourcePath]; + } + $this->keyStorage->renameKeys($sourcePath, $targetPath); + $module = $this->getEncryptionModule($target); + if ($module) { + $module->update($targetPath, $this->uid, []); + } + } + } + + return $result; + } + + public function rmdir(string $path): bool { + $result = $this->storage->rmdir($path); + $fullPath = $this->getFullPath($path); + if ($result + && $this->util->isExcluded($fullPath) === false + && $this->encryptionManager->isEnabled() + ) { + $this->keyStorage->deleteAllFileKeys($fullPath); + } + + return $result; + } + + public function isReadable(string $path): bool { + $isReadable = true; + + $metaData = $this->getMetaData($path); + if ( + !$this->is_dir($path) + && isset($metaData['encrypted']) + && $metaData['encrypted'] === true + ) { + $fullPath = $this->getFullPath($path); + $module = $this->getEncryptionModule($path); + $isReadable = $module->isReadable($fullPath, $this->uid); + } + + return $this->storage->isReadable($path) && $isReadable; + } + + public function copy(string $source, string $target): bool { + $sourcePath = $this->getFullPath($source); + + if ($this->util->isExcluded($sourcePath)) { + return $this->storage->copy($source, $target); + } + + // need to stream copy file by file in case we copy between a encrypted + // and a unencrypted storage + $this->unlink($target); + return $this->copyFromStorage($this, $source, $target); + } + + public function fopen(string $path, string $mode) { + // check if the file is stored in the array cache, this means that we + // copy a file over to the versions folder, in this case we don't want to + // decrypt it + if ($this->arrayCache->hasKey('encryption_copy_version_' . $path)) { + $this->arrayCache->remove('encryption_copy_version_' . $path); + return $this->storage->fopen($path, $mode); + } + + if (!$this->enabled) { + return $this->storage->fopen($path, $mode); + } + + $encryptionEnabled = $this->encryptionManager->isEnabled(); + $shouldEncrypt = false; + $encryptionModule = null; + $header = $this->getHeader($path); + $signed = isset($header['signed']) && $header['signed'] === 'true'; + $fullPath = $this->getFullPath($path); + $encryptionModuleId = $this->util->getEncryptionModuleId($header); + + if ($this->util->isExcluded($fullPath) === false) { + $size = $unencryptedSize = 0; + $realFile = $this->util->stripPartialFileExtension($path); + $targetExists = $this->is_file($realFile) || $this->file_exists($path); + $targetIsEncrypted = false; + if ($targetExists) { + // in case the file exists we require the explicit module as + // specified in the file header - otherwise we need to fail hard to + // prevent data loss on client side + if (!empty($encryptionModuleId)) { + $targetIsEncrypted = true; + $encryptionModule = $this->encryptionManager->getEncryptionModule($encryptionModuleId); + } + + if ($this->file_exists($path)) { + $size = $this->storage->filesize($path); + $unencryptedSize = $this->filesize($path); + } else { + $size = $unencryptedSize = 0; + } + } + + try { + if ( + $mode === 'w' + || $mode === 'w+' + || $mode === 'wb' + || $mode === 'wb+' + ) { + // if we update a encrypted file with a un-encrypted one we change the db flag + if ($targetIsEncrypted && $encryptionEnabled === false) { + $cache = $this->storage->getCache(); + $entry = $cache->get($path); + $cache->update($entry->getId(), ['encrypted' => 0]); + } + if ($encryptionEnabled) { + // if $encryptionModuleId is empty, the default module will be used + $encryptionModule = $this->encryptionManager->getEncryptionModule($encryptionModuleId); + $shouldEncrypt = $encryptionModule->shouldEncrypt($fullPath); + $signed = true; + } + } else { + $info = $this->getCache()->get($path); + // only get encryption module if we found one in the header + // or if file should be encrypted according to the file cache + if (!empty($encryptionModuleId)) { + $encryptionModule = $this->encryptionManager->getEncryptionModule($encryptionModuleId); + $shouldEncrypt = true; + } elseif ($info !== false && $info['encrypted'] === true) { + // we come from a old installation. No header and/or no module defined + // but the file is encrypted. In this case we need to use the + // OC_DEFAULT_MODULE to read the file + $encryptionModule = $this->encryptionManager->getEncryptionModule('OC_DEFAULT_MODULE'); + $shouldEncrypt = true; + $targetIsEncrypted = true; + } + } + } catch (ModuleDoesNotExistsException $e) { + $this->logger->warning('Encryption module "' . $encryptionModuleId . '" not found, file will be stored unencrypted', [ + 'exception' => $e, + 'app' => 'core', + ]); + } + + // encryption disabled on write of new file and write to existing unencrypted file -> don't encrypt + if (!$encryptionEnabled || !$this->shouldEncrypt($path)) { + if (!$targetExists || !$targetIsEncrypted) { + $shouldEncrypt = false; + } + } + + if ($shouldEncrypt === true && $encryptionModule !== null) { + $this->encryptedPaths->set($this->util->stripPartialFileExtension($path), true); + $headerSize = $this->getHeaderSize($path); + if ($mode === 'r' && $headerSize === 0) { + $firstBlock = $this->readFirstBlock($path); + if (!$firstBlock) { + throw new InvalidHeaderException("Unable to get header block for $path"); + } elseif (!str_starts_with($firstBlock, Util::HEADER_START)) { + throw new InvalidHeaderException("Unable to get header size for $path, file doesn't start with encryption header"); + } else { + throw new InvalidHeaderException("Unable to get header size for $path, even though file does start with encryption header"); + } + } + $source = $this->storage->fopen($path, $mode); + if (!is_resource($source)) { + return false; + } + $handle = \OC\Files\Stream\Encryption::wrap($source, $path, $fullPath, $header, + $this->uid, $encryptionModule, $this->storage, $this, $this->util, $this->fileHelper, $mode, + $size, $unencryptedSize, $headerSize, $signed); + + return $handle; + } + } + + return $this->storage->fopen($path, $mode); + } + + + /** + * perform some plausibility checks if the unencrypted size is correct. + * If not, we calculate the correct unencrypted size and return it + * + * @param string $path internal path relative to the storage root + * @param int $unencryptedSize size of the unencrypted file + * + * @return int unencrypted size + */ + protected function verifyUnencryptedSize(string $path, int $unencryptedSize): int { + $size = $this->storage->filesize($path); + $result = $unencryptedSize; + + if ($unencryptedSize < 0 + || ($size > 0 && $unencryptedSize === $size) + || $unencryptedSize > $size + ) { + // check if we already calculate the unencrypted size for the + // given path to avoid recursions + if (isset($this->fixUnencryptedSizeOf[$this->getFullPath($path)]) === false) { + $this->fixUnencryptedSizeOf[$this->getFullPath($path)] = true; + try { + $result = $this->fixUnencryptedSize($path, $size, $unencryptedSize); + } catch (\Exception $e) { + $this->logger->error('Couldn\'t re-calculate unencrypted size for ' . $path, ['exception' => $e]); + } + unset($this->fixUnencryptedSizeOf[$this->getFullPath($path)]); + } + } + + return $result; + } + + /** + * calculate the unencrypted size + * + * @param string $path internal path relative to the storage root + * @param int $size size of the physical file + * @param int $unencryptedSize size of the unencrypted file + */ + protected function fixUnencryptedSize(string $path, int $size, int $unencryptedSize): int|float { + $headerSize = $this->getHeaderSize($path); + $header = $this->getHeader($path); + $encryptionModule = $this->getEncryptionModule($path); + + $stream = $this->storage->fopen($path, 'r'); + + // if we couldn't open the file we return the old unencrypted size + if (!is_resource($stream)) { + $this->logger->error('Could not open ' . $path . '. Recalculation of unencrypted size aborted.'); + return $unencryptedSize; + } + + $newUnencryptedSize = 0; + $size -= $headerSize; + $blockSize = $this->util->getBlockSize(); + + // if a header exists we skip it + if ($headerSize > 0) { + $this->fread_block($stream, $headerSize); + } + + // fast path, else the calculation for $lastChunkNr is bogus + if ($size === 0) { + return 0; + } + + $signed = isset($header['signed']) && $header['signed'] === 'true'; + $unencryptedBlockSize = $encryptionModule->getUnencryptedBlockSize($signed); + + // calculate last chunk nr + // next highest is end of chunks, one subtracted is last one + // we have to read the last chunk, we can't just calculate it (because of padding etc) + + $lastChunkNr = ceil($size / $blockSize) - 1; + // calculate last chunk position + $lastChunkPos = ($lastChunkNr * $blockSize); + // try to fseek to the last chunk, if it fails we have to read the whole file + if (@fseek($stream, $lastChunkPos, SEEK_CUR) === 0) { + $newUnencryptedSize += $lastChunkNr * $unencryptedBlockSize; + } + + $lastChunkContentEncrypted = ''; + $count = $blockSize; + + while ($count > 0) { + $data = $this->fread_block($stream, $blockSize); + $count = strlen($data); + $lastChunkContentEncrypted .= $data; + if (strlen($lastChunkContentEncrypted) > $blockSize) { + $newUnencryptedSize += $unencryptedBlockSize; + $lastChunkContentEncrypted = substr($lastChunkContentEncrypted, $blockSize); + } + } + + fclose($stream); + + // we have to decrypt the last chunk to get it actual size + $encryptionModule->begin($this->getFullPath($path), $this->uid, 'r', $header, []); + $decryptedLastChunk = $encryptionModule->decrypt($lastChunkContentEncrypted, $lastChunkNr . 'end'); + $decryptedLastChunk .= $encryptionModule->end($this->getFullPath($path), $lastChunkNr . 'end'); + + // calc the real file size with the size of the last chunk + $newUnencryptedSize += strlen($decryptedLastChunk); + + $this->updateUnencryptedSize($this->getFullPath($path), $newUnencryptedSize); + + // write to cache if applicable + $cache = $this->storage->getCache(); + $entry = $cache->get($path); + $cache->update($entry['fileid'], [ + 'unencrypted_size' => $newUnencryptedSize + ]); + + return $newUnencryptedSize; + } + + /** + * fread_block + * + * This function is a wrapper around the fread function. It is based on the + * stream_read_block function from lib/private/Files/Streams/Encryption.php + * It calls stream read until the requested $blockSize was received or no remaining data is present. + * This is required as stream_read only returns smaller chunks of data when the stream fetches from a + * remote storage over the internet and it does not care about the given $blockSize. + * + * @param resource $handle the stream to read from + * @param int $blockSize Length of requested data block in bytes + * @return string Data fetched from stream. + */ + private function fread_block($handle, int $blockSize): string { + $remaining = $blockSize; + $data = ''; + + do { + $chunk = fread($handle, $remaining); + $chunk_len = strlen($chunk); + $data .= $chunk; + $remaining -= $chunk_len; + } while (($remaining > 0) && ($chunk_len > 0)); + + return $data; + } + + public function moveFromStorage( + Storage\IStorage $sourceStorage, + string $sourceInternalPath, + string $targetInternalPath, + $preserveMtime = true, + ): bool { + if ($sourceStorage === $this) { + return $this->rename($sourceInternalPath, $targetInternalPath); + } + + // TODO clean this up once the underlying moveFromStorage in OC\Files\Storage\Wrapper\Common is fixed: + // - call $this->storage->moveFromStorage() instead of $this->copyBetweenStorage + // - copy the file cache update from $this->copyBetweenStorage to this method + // - copy the copyKeys() call from $this->copyBetweenStorage to this method + // - remove $this->copyBetweenStorage + + if (!$sourceStorage->isDeletable($sourceInternalPath)) { + return false; + } + + $result = $this->copyBetweenStorage($sourceStorage, $sourceInternalPath, $targetInternalPath, $preserveMtime, true); + if ($result) { + $setPreserveCacheOnDelete = $sourceStorage->instanceOfStorage(ObjectStoreStorage::class) && !$this->instanceOfStorage(ObjectStoreStorage::class); + if ($setPreserveCacheOnDelete) { + /** @var ObjectStoreStorage $sourceStorage */ + $sourceStorage->setPreserveCacheOnDelete(true); + } + try { + if ($sourceStorage->is_dir($sourceInternalPath)) { + $result = $sourceStorage->rmdir($sourceInternalPath); + } else { + $result = $sourceStorage->unlink($sourceInternalPath); + } + } finally { + if ($setPreserveCacheOnDelete) { + /** @var ObjectStoreStorage $sourceStorage */ + $sourceStorage->setPreserveCacheOnDelete(false); + } + } + } + return $result; + } + + public function copyFromStorage( + Storage\IStorage $sourceStorage, + string $sourceInternalPath, + string $targetInternalPath, + $preserveMtime = false, + $isRename = false, + ): bool { + // TODO clean this up once the underlying moveFromStorage in OC\Files\Storage\Wrapper\Common is fixed: + // - call $this->storage->copyFromStorage() instead of $this->copyBetweenStorage + // - copy the file cache update from $this->copyBetweenStorage to this method + // - copy the copyKeys() call from $this->copyBetweenStorage to this method + // - remove $this->copyBetweenStorage + + return $this->copyBetweenStorage($sourceStorage, $sourceInternalPath, $targetInternalPath, $preserveMtime, $isRename); + } + + /** + * Update the encrypted cache version in the database + */ + private function updateEncryptedVersion( + Storage\IStorage $sourceStorage, + string $sourceInternalPath, + string $targetInternalPath, + bool $isRename, + bool $keepEncryptionVersion, + ): void { + $isEncrypted = $this->encryptionManager->isEnabled() && $this->shouldEncrypt($targetInternalPath); + $cacheInformation = [ + 'encrypted' => $isEncrypted, + ]; + if ($isEncrypted) { + $sourceCacheEntry = $sourceStorage->getCache()->get($sourceInternalPath); + $targetCacheEntry = $this->getCache()->get($targetInternalPath); + + // Rename of the cache already happened, so we do the cleanup on the target + if ($sourceCacheEntry === false && $targetCacheEntry !== false) { + $encryptedVersion = $targetCacheEntry['encryptedVersion']; + $isRename = false; + } else { + $encryptedVersion = $sourceCacheEntry['encryptedVersion']; + } + + // In case of a move operation from an unencrypted to an encrypted + // storage the old encrypted version would stay with "0" while the + // correct value would be "1". Thus we manually set the value to "1" + // for those cases. + // See also https://github.com/owncloud/core/issues/23078 + if ($encryptedVersion === 0 || !$keepEncryptionVersion) { + $encryptedVersion = 1; + } + + $cacheInformation['encryptedVersion'] = $encryptedVersion; + } + + // in case of a rename we need to manipulate the source cache because + // this information will be kept for the new target + if ($isRename) { + $sourceStorage->getCache()->put($sourceInternalPath, $cacheInformation); + } else { + $this->getCache()->put($targetInternalPath, $cacheInformation); + } + } + + /** + * copy file between two storages + * @throws \Exception + */ + private function copyBetweenStorage( + Storage\IStorage $sourceStorage, + string $sourceInternalPath, + string $targetInternalPath, + bool $preserveMtime, + bool $isRename, + ): bool { + // for versions we have nothing to do, because versions should always use the + // key from the original file. Just create a 1:1 copy and done + if ($this->isVersion($targetInternalPath) + || $this->isVersion($sourceInternalPath)) { + // remember that we try to create a version so that we can detect it during + // fopen($sourceInternalPath) and by-pass the encryption in order to + // create a 1:1 copy of the file + $this->arrayCache->set('encryption_copy_version_' . $sourceInternalPath, true); + $result = $this->storage->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + $this->arrayCache->remove('encryption_copy_version_' . $sourceInternalPath); + if ($result) { + $info = $this->getCache('', $sourceStorage)->get($sourceInternalPath); + // make sure that we update the unencrypted size for the version + if (isset($info['encrypted']) && $info['encrypted'] === true) { + $this->updateUnencryptedSize( + $this->getFullPath($targetInternalPath), + $info->getUnencryptedSize() + ); + } + $this->updateEncryptedVersion($sourceStorage, $sourceInternalPath, $targetInternalPath, $isRename, true); + } + return $result; + } + + // first copy the keys that we reuse the existing file key on the target location + // and don't create a new one which would break versions for example. + if ($sourceStorage->instanceOfStorage(Common::class) && $sourceStorage->getMountOption('mount_point')) { + $mountPoint = $sourceStorage->getMountOption('mount_point'); + $source = $mountPoint . '/' . $sourceInternalPath; + $target = $this->getFullPath($targetInternalPath); + $this->copyKeys($source, $target); + } else { + $this->logger->error('Could not find mount point, can\'t keep encryption keys'); + } + + if ($sourceStorage->is_dir($sourceInternalPath)) { + $dh = $sourceStorage->opendir($sourceInternalPath); + if (!$this->is_dir($targetInternalPath)) { + $result = $this->mkdir($targetInternalPath); + } else { + $result = true; + } + if (is_resource($dh)) { + while ($result && ($file = readdir($dh)) !== false) { + if (!Filesystem::isIgnoredDir($file)) { + $result = $this->copyFromStorage($sourceStorage, $sourceInternalPath . '/' . $file, $targetInternalPath . '/' . $file, $preserveMtime, $isRename); + } + } + } + } else { + try { + $source = $sourceStorage->fopen($sourceInternalPath, 'r'); + $target = $this->fopen($targetInternalPath, 'w'); + if ($source === false || $target === false) { + $result = false; + } else { + [, $result] = Files::streamCopy($source, $target, true); + } + } finally { + if (isset($source) && $source !== false) { + fclose($source); + } + if (isset($target) && $target !== false) { + fclose($target); + } + } + if ($result) { + if ($preserveMtime) { + $this->touch($targetInternalPath, $sourceStorage->filemtime($sourceInternalPath)); + } + $this->updateEncryptedVersion($sourceStorage, $sourceInternalPath, $targetInternalPath, $isRename, false); + } else { + // delete partially written target file + $this->unlink($targetInternalPath); + // delete cache entry that was created by fopen + $this->getCache()->remove($targetInternalPath); + } + } + return (bool)$result; + } + + public function getLocalFile(string $path): string|false { + if ($this->encryptionManager->isEnabled()) { + $cachedFile = $this->getCachedFile($path); + if (is_string($cachedFile)) { + return $cachedFile; + } + } + return $this->storage->getLocalFile($path); + } + + public function isLocal(): bool { + if ($this->encryptionManager->isEnabled()) { + return false; + } + return $this->storage->isLocal(); + } + + public function stat(string $path): array|false { + $stat = $this->storage->stat($path); + if (!$stat) { + return false; + } + $fileSize = $this->filesize($path); + $stat['size'] = $fileSize; + $stat[7] = $fileSize; + $stat['hasHeader'] = $this->getHeaderSize($path) > 0; + return $stat; + } + + public function hash(string $type, string $path, bool $raw = false): string|false { + $fh = $this->fopen($path, 'rb'); + if ($fh === false) { + return false; + } + $ctx = hash_init($type); + hash_update_stream($ctx, $fh); + fclose($fh); + return hash_final($ctx, $raw); + } + + /** + * return full path, including mount point + * + * @param string $path relative to mount point + * @return string full path including mount point + */ + protected function getFullPath(string $path): string { + return Filesystem::normalizePath($this->mountPoint . '/' . $path); + } + + /** + * read first block of encrypted file, typically this will contain the + * encryption header + */ + protected function readFirstBlock(string $path): string { + $firstBlock = ''; + if ($this->storage->is_file($path)) { + $handle = $this->storage->fopen($path, 'r'); + if ($handle === false) { + return ''; + } + $firstBlock = fread($handle, $this->util->getHeaderSize()); + fclose($handle); + } + return $firstBlock; + } + + /** + * return header size of given file + */ + protected function getHeaderSize(string $path): int { + $headerSize = 0; + $realFile = $this->util->stripPartialFileExtension($path); + if ($this->storage->is_file($realFile)) { + $path = $realFile; + } + $firstBlock = $this->readFirstBlock($path); + + if (str_starts_with($firstBlock, Util::HEADER_START)) { + $headerSize = $this->util->getHeaderSize(); + } + + return $headerSize; + } + + /** + * read header from file + */ + protected function getHeader(string $path): array { + $realFile = $this->util->stripPartialFileExtension($path); + $exists = $this->storage->is_file($realFile); + if ($exists) { + $path = $realFile; + } + + $result = []; + + $isEncrypted = $this->encryptedPaths->get($realFile); + if (is_null($isEncrypted)) { + $info = $this->getCache()->get($path); + $isEncrypted = isset($info['encrypted']) && $info['encrypted'] === true; + } + + if ($isEncrypted) { + $firstBlock = $this->readFirstBlock($path); + $result = $this->util->parseRawHeader($firstBlock); + + // if the header doesn't contain a encryption module we check if it is a + // legacy file. If true, we add the default encryption module + if (!isset($result[Util::HEADER_ENCRYPTION_MODULE_KEY]) && (!empty($result) || $exists)) { + $result[Util::HEADER_ENCRYPTION_MODULE_KEY] = 'OC_DEFAULT_MODULE'; + } + } + + return $result; + } + + /** + * read encryption module needed to read/write the file located at $path + * + * @throws ModuleDoesNotExistsException + * @throws \Exception + */ + protected function getEncryptionModule(string $path): ?\OCP\Encryption\IEncryptionModule { + $encryptionModule = null; + $header = $this->getHeader($path); + $encryptionModuleId = $this->util->getEncryptionModuleId($header); + if (!empty($encryptionModuleId)) { + try { + $encryptionModule = $this->encryptionManager->getEncryptionModule($encryptionModuleId); + } catch (ModuleDoesNotExistsException $e) { + $this->logger->critical('Encryption module defined in "' . $path . '" not loaded!'); + throw $e; + } + } + + return $encryptionModule; + } + + public function updateUnencryptedSize(string $path, int|float $unencryptedSize): void { + $this->unencryptedSize[$path] = $unencryptedSize; + } + + /** + * copy keys to new location + * + * @param string $source path relative to data/ + * @param string $target path relative to data/ + */ + protected function copyKeys(string $source, string $target): bool { + if (!$this->util->isExcluded($source)) { + return $this->keyStorage->copyKeys($source, $target); + } + + return false; + } + + /** + * check if path points to a files version + */ + protected function isVersion(string $path): bool { + $normalized = Filesystem::normalizePath($path); + return substr($normalized, 0, strlen('/files_versions/')) === '/files_versions/'; + } + + /** + * check if the given storage should be encrypted or not + */ + protected function shouldEncrypt(string $path): bool { + $fullPath = $this->getFullPath($path); + $mountPointConfig = $this->mount->getOption('encrypt', true); + if ($mountPointConfig === false) { + return false; + } + + try { + $encryptionModule = $this->getEncryptionModule($fullPath); + } catch (ModuleDoesNotExistsException $e) { + return false; + } + + if ($encryptionModule === null) { + $encryptionModule = $this->encryptionManager->getEncryptionModule(); + } + + return $encryptionModule->shouldEncrypt($fullPath); + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + // always fall back to fopen + $target = $this->fopen($path, 'w'); + if ($target === false) { + throw new GenericFileException("Failed to open $path for writing"); + } + [$count, $result] = Files::streamCopy($stream, $target, true); + fclose($stream); + fclose($target); + + // object store, stores the size after write and doesn't update this during scan + // manually store the unencrypted size + if ($result && $this->getWrapperStorage()->instanceOfStorage(ObjectStoreStorage::class) && $this->shouldEncrypt($path)) { + $this->getCache()->put($path, ['unencrypted_size' => $count]); + } + + return $count; + } + + public function clearIsEncryptedCache(): void { + $this->encryptedPaths->clear(); + } + + /** + * Allow temporarily disabling the wrapper + */ + public function setEnabled(bool $enabled): void { + $this->enabled = $enabled; + } + + /** + * Check if the on-disk data for a file has a valid encrypted header + * + * @param string $path + * @return bool + */ + public function hasValidHeader(string $path): bool { + $firstBlock = $this->readFirstBlock($path); + $header = $this->util->parseRawHeader($firstBlock); + return (count($header) > 0); + } +} diff --git a/lib/private/Files/Storage/Wrapper/Jail.php b/lib/private/Files/Storage/Wrapper/Jail.php new file mode 100644 index 00000000000..38b113cef88 --- /dev/null +++ b/lib/private/Files/Storage/Wrapper/Jail.php @@ -0,0 +1,267 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage\Wrapper; + +use OC\Files\Cache\Wrapper\CacheJail; +use OC\Files\Cache\Wrapper\JailPropagator; +use OC\Files\Cache\Wrapper\JailWatcher; +use OC\Files\Filesystem; +use OCP\Files; +use OCP\Files\Cache\ICache; +use OCP\Files\Cache\IPropagator; +use OCP\Files\Cache\IWatcher; +use OCP\Files\Storage\IStorage; +use OCP\Files\Storage\IWriteStreamStorage; +use OCP\Lock\ILockingProvider; + +/** + * Jail to a subdirectory of the wrapped storage + * + * This restricts access to a subfolder of the wrapped storage with the subfolder becoming the root folder new storage + */ +class Jail extends Wrapper { + /** + * @var string + */ + protected $rootPath; + + /** + * @param array $parameters ['storage' => $storage, 'root' => $root] + * + * $storage: The storage that will be wrapper + * $root: The folder in the wrapped storage that will become the root folder of the wrapped storage + */ + public function __construct(array $parameters) { + parent::__construct($parameters); + $this->rootPath = $parameters['root']; + } + + public function getUnjailedPath(string $path): string { + return trim(Filesystem::normalizePath($this->rootPath . '/' . $path), '/'); + } + + /** + * This is separate from Wrapper::getWrapperStorage so we can get the jailed storage consistently even if the jail is inside another wrapper + */ + public function getUnjailedStorage(): IStorage { + return $this->storage; + } + + + public function getJailedPath(string $path): ?string { + $root = rtrim($this->rootPath, '/') . '/'; + + if ($path !== $this->rootPath && !str_starts_with($path, $root)) { + return null; + } else { + $path = substr($path, strlen($this->rootPath)); + return trim($path, '/'); + } + } + + public function getId(): string { + return parent::getId(); + } + + public function mkdir(string $path): bool { + return $this->getWrapperStorage()->mkdir($this->getUnjailedPath($path)); + } + + public function rmdir(string $path): bool { + return $this->getWrapperStorage()->rmdir($this->getUnjailedPath($path)); + } + + public function opendir(string $path) { + return $this->getWrapperStorage()->opendir($this->getUnjailedPath($path)); + } + + public function is_dir(string $path): bool { + return $this->getWrapperStorage()->is_dir($this->getUnjailedPath($path)); + } + + public function is_file(string $path): bool { + return $this->getWrapperStorage()->is_file($this->getUnjailedPath($path)); + } + + public function stat(string $path): array|false { + return $this->getWrapperStorage()->stat($this->getUnjailedPath($path)); + } + + public function filetype(string $path): string|false { + return $this->getWrapperStorage()->filetype($this->getUnjailedPath($path)); + } + + public function filesize(string $path): int|float|false { + return $this->getWrapperStorage()->filesize($this->getUnjailedPath($path)); + } + + public function isCreatable(string $path): bool { + return $this->getWrapperStorage()->isCreatable($this->getUnjailedPath($path)); + } + + public function isReadable(string $path): bool { + return $this->getWrapperStorage()->isReadable($this->getUnjailedPath($path)); + } + + public function isUpdatable(string $path): bool { + return $this->getWrapperStorage()->isUpdatable($this->getUnjailedPath($path)); + } + + public function isDeletable(string $path): bool { + return $this->getWrapperStorage()->isDeletable($this->getUnjailedPath($path)); + } + + public function isSharable(string $path): bool { + return $this->getWrapperStorage()->isSharable($this->getUnjailedPath($path)); + } + + public function getPermissions(string $path): int { + return $this->getWrapperStorage()->getPermissions($this->getUnjailedPath($path)); + } + + public function file_exists(string $path): bool { + return $this->getWrapperStorage()->file_exists($this->getUnjailedPath($path)); + } + + public function filemtime(string $path): int|false { + return $this->getWrapperStorage()->filemtime($this->getUnjailedPath($path)); + } + + public function file_get_contents(string $path): string|false { + return $this->getWrapperStorage()->file_get_contents($this->getUnjailedPath($path)); + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + return $this->getWrapperStorage()->file_put_contents($this->getUnjailedPath($path), $data); + } + + public function unlink(string $path): bool { + return $this->getWrapperStorage()->unlink($this->getUnjailedPath($path)); + } + + public function rename(string $source, string $target): bool { + return $this->getWrapperStorage()->rename($this->getUnjailedPath($source), $this->getUnjailedPath($target)); + } + + public function copy(string $source, string $target): bool { + return $this->getWrapperStorage()->copy($this->getUnjailedPath($source), $this->getUnjailedPath($target)); + } + + public function fopen(string $path, string $mode) { + return $this->getWrapperStorage()->fopen($this->getUnjailedPath($path), $mode); + } + + public function getMimeType(string $path): string|false { + return $this->getWrapperStorage()->getMimeType($this->getUnjailedPath($path)); + } + + public function hash(string $type, string $path, bool $raw = false): string|false { + return $this->getWrapperStorage()->hash($type, $this->getUnjailedPath($path), $raw); + } + + public function free_space(string $path): int|float|false { + return $this->getWrapperStorage()->free_space($this->getUnjailedPath($path)); + } + + public function touch(string $path, ?int $mtime = null): bool { + return $this->getWrapperStorage()->touch($this->getUnjailedPath($path), $mtime); + } + + public function getLocalFile(string $path): string|false { + return $this->getWrapperStorage()->getLocalFile($this->getUnjailedPath($path)); + } + + public function hasUpdated(string $path, int $time): bool { + return $this->getWrapperStorage()->hasUpdated($this->getUnjailedPath($path), $time); + } + + public function getCache(string $path = '', ?IStorage $storage = null): ICache { + $sourceCache = $this->getWrapperStorage()->getCache($this->getUnjailedPath($path)); + return new CacheJail($sourceCache, $this->rootPath); + } + + public function getOwner(string $path): string|false { + return $this->getWrapperStorage()->getOwner($this->getUnjailedPath($path)); + } + + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { + $sourceWatcher = $this->getWrapperStorage()->getWatcher($this->getUnjailedPath($path), $this->getWrapperStorage()); + return new JailWatcher($sourceWatcher, $this->rootPath); + } + + public function getETag(string $path): string|false { + return $this->getWrapperStorage()->getETag($this->getUnjailedPath($path)); + } + + public function getMetaData(string $path): ?array { + return $this->getWrapperStorage()->getMetaData($this->getUnjailedPath($path)); + } + + public function acquireLock(string $path, int $type, ILockingProvider $provider): void { + $this->getWrapperStorage()->acquireLock($this->getUnjailedPath($path), $type, $provider); + } + + public function releaseLock(string $path, int $type, ILockingProvider $provider): void { + $this->getWrapperStorage()->releaseLock($this->getUnjailedPath($path), $type, $provider); + } + + public function changeLock(string $path, int $type, ILockingProvider $provider): void { + $this->getWrapperStorage()->changeLock($this->getUnjailedPath($path), $type, $provider); + } + + /** + * Resolve the path for the source of the share + */ + public function resolvePath(string $path): array { + return [$this->getWrapperStorage(), $this->getUnjailedPath($path)]; + } + + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + if ($sourceStorage === $this) { + return $this->copy($sourceInternalPath, $targetInternalPath); + } + return $this->getWrapperStorage()->copyFromStorage($sourceStorage, $sourceInternalPath, $this->getUnjailedPath($targetInternalPath)); + } + + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + if ($sourceStorage === $this) { + return $this->rename($sourceInternalPath, $targetInternalPath); + } + return $this->getWrapperStorage()->moveFromStorage($sourceStorage, $sourceInternalPath, $this->getUnjailedPath($targetInternalPath)); + } + + public function getPropagator(?IStorage $storage = null): IPropagator { + if (isset($this->propagator)) { + return $this->propagator; + } + + if (!$storage) { + $storage = $this; + } + $this->propagator = new JailPropagator($storage, \OC::$server->getDatabaseConnection()); + return $this->propagator; + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + $storage = $this->getWrapperStorage(); + if ($storage->instanceOfStorage(IWriteStreamStorage::class)) { + /** @var IWriteStreamStorage $storage */ + return $storage->writeStream($this->getUnjailedPath($path), $stream, $size); + } else { + $target = $this->fopen($path, 'w'); + $count = Files::streamCopy($stream, $target); + fclose($stream); + fclose($target); + return $count; + } + } + + public function getDirectoryContent(string $directory): \Traversable { + return $this->getWrapperStorage()->getDirectoryContent($this->getUnjailedPath($directory)); + } +} diff --git a/lib/private/Files/Storage/Wrapper/KnownMtime.php b/lib/private/Files/Storage/Wrapper/KnownMtime.php new file mode 100644 index 00000000000..657c6c9250c --- /dev/null +++ b/lib/private/Files/Storage/Wrapper/KnownMtime.php @@ -0,0 +1,146 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Storage\Wrapper; + +use OCP\Cache\CappedMemoryCache; +use OCP\Files\Storage\IStorage; +use Psr\Clock\ClockInterface; + +/** + * Wrapper that overwrites the mtime return by stat/getMetaData if the returned value + * is lower than when we last modified the file. + * + * This is useful because some storage servers can return an outdated mtime right after writes + */ +class KnownMtime extends Wrapper { + private CappedMemoryCache $knowMtimes; + private ClockInterface $clock; + + public function __construct(array $parameters) { + parent::__construct($parameters); + $this->knowMtimes = new CappedMemoryCache(); + $this->clock = $parameters['clock']; + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + $result = parent::file_put_contents($path, $data); + if ($result) { + $now = $this->clock->now()->getTimestamp(); + $this->knowMtimes->set($path, $this->clock->now()->getTimestamp()); + } + return $result; + } + + public function stat(string $path): array|false { + $stat = parent::stat($path); + if ($stat) { + $this->applyKnownMtime($path, $stat); + } + return $stat; + } + + public function getMetaData(string $path): ?array { + $stat = parent::getMetaData($path); + if ($stat) { + $this->applyKnownMtime($path, $stat); + } + return $stat; + } + + private function applyKnownMtime(string $path, array &$stat): void { + if (isset($stat['mtime'])) { + $knownMtime = $this->knowMtimes->get($path) ?? 0; + $stat['mtime'] = max($stat['mtime'], $knownMtime); + } + } + + public function filemtime(string $path): int|false { + $knownMtime = $this->knowMtimes->get($path) ?? 0; + return max(parent::filemtime($path), $knownMtime); + } + + public function mkdir(string $path): bool { + $result = parent::mkdir($path); + if ($result) { + $this->knowMtimes->set($path, $this->clock->now()->getTimestamp()); + } + return $result; + } + + public function rmdir(string $path): bool { + $result = parent::rmdir($path); + if ($result) { + $this->knowMtimes->set($path, $this->clock->now()->getTimestamp()); + } + return $result; + } + + public function unlink(string $path): bool { + $result = parent::unlink($path); + if ($result) { + $this->knowMtimes->set($path, $this->clock->now()->getTimestamp()); + } + return $result; + } + + public function rename(string $source, string $target): bool { + $result = parent::rename($source, $target); + if ($result) { + $this->knowMtimes->set($target, $this->clock->now()->getTimestamp()); + $this->knowMtimes->set($source, $this->clock->now()->getTimestamp()); + } + return $result; + } + + public function copy(string $source, string $target): bool { + $result = parent::copy($source, $target); + if ($result) { + $this->knowMtimes->set($target, $this->clock->now()->getTimestamp()); + } + return $result; + } + + public function fopen(string $path, string $mode) { + $result = parent::fopen($path, $mode); + if ($result && $mode === 'w') { + $this->knowMtimes->set($path, $this->clock->now()->getTimestamp()); + } + return $result; + } + + public function touch(string $path, ?int $mtime = null): bool { + $result = parent::touch($path, $mtime); + if ($result) { + $this->knowMtimes->set($path, $mtime ?? $this->clock->now()->getTimestamp()); + } + return $result; + } + + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + $result = parent::copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + if ($result) { + $this->knowMtimes->set($targetInternalPath, $this->clock->now()->getTimestamp()); + } + return $result; + } + + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + $result = parent::moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + if ($result) { + $this->knowMtimes->set($targetInternalPath, $this->clock->now()->getTimestamp()); + } + return $result; + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + $result = parent::writeStream($path, $stream, $size); + if ($result) { + $this->knowMtimes->set($path, $this->clock->now()->getTimestamp()); + } + return $result; + } +} diff --git a/lib/private/Files/Storage/Wrapper/PermissionsMask.php b/lib/private/Files/Storage/Wrapper/PermissionsMask.php new file mode 100644 index 00000000000..684040146ba --- /dev/null +++ b/lib/private/Files/Storage/Wrapper/PermissionsMask.php @@ -0,0 +1,138 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage\Wrapper; + +use OC\Files\Cache\Wrapper\CachePermissionsMask; +use OCP\Constants; +use OCP\Files\Storage\IStorage; + +/** + * Mask the permissions of a storage + * + * This can be used to restrict update, create, delete and/or share permissions of a storage + * + * Note that the read permissions can't be masked + */ +class PermissionsMask extends Wrapper { + /** + * @var int the permissions bits we want to keep + */ + private $mask; + + /** + * @param array $parameters ['storage' => $storage, 'mask' => $mask] + * + * $storage: The storage the permissions mask should be applied on + * $mask: The permission bits that should be kept, a combination of the \OCP\Constant::PERMISSION_ constants + */ + public function __construct(array $parameters) { + parent::__construct($parameters); + $this->mask = $parameters['mask']; + } + + private function checkMask(int $permissions): bool { + return ($this->mask & $permissions) === $permissions; + } + + public function isUpdatable(string $path): bool { + return $this->checkMask(Constants::PERMISSION_UPDATE) and parent::isUpdatable($path); + } + + public function isCreatable(string $path): bool { + return $this->checkMask(Constants::PERMISSION_CREATE) and parent::isCreatable($path); + } + + public function isDeletable(string $path): bool { + return $this->checkMask(Constants::PERMISSION_DELETE) and parent::isDeletable($path); + } + + public function isSharable(string $path): bool { + return $this->checkMask(Constants::PERMISSION_SHARE) and parent::isSharable($path); + } + + public function getPermissions(string $path): int { + return $this->storage->getPermissions($path) & $this->mask; + } + + public function rename(string $source, string $target): bool { + //This is a rename of the transfer file to the original file + if (dirname($source) === dirname($target) && strpos($source, '.ocTransferId') > 0) { + return $this->checkMask(Constants::PERMISSION_CREATE) and parent::rename($source, $target); + } + return $this->checkMask(Constants::PERMISSION_UPDATE) and parent::rename($source, $target); + } + + public function copy(string $source, string $target): bool { + return $this->checkMask(Constants::PERMISSION_CREATE) and parent::copy($source, $target); + } + + public function touch(string $path, ?int $mtime = null): bool { + $permissions = $this->file_exists($path) ? Constants::PERMISSION_UPDATE : Constants::PERMISSION_CREATE; + return $this->checkMask($permissions) and parent::touch($path, $mtime); + } + + public function mkdir(string $path): bool { + return $this->checkMask(Constants::PERMISSION_CREATE) and parent::mkdir($path); + } + + public function rmdir(string $path): bool { + return $this->checkMask(Constants::PERMISSION_DELETE) and parent::rmdir($path); + } + + public function unlink(string $path): bool { + return $this->checkMask(Constants::PERMISSION_DELETE) and parent::unlink($path); + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + $permissions = $this->file_exists($path) ? Constants::PERMISSION_UPDATE : Constants::PERMISSION_CREATE; + return $this->checkMask($permissions) ? parent::file_put_contents($path, $data) : false; + } + + public function fopen(string $path, string $mode) { + if ($mode === 'r' or $mode === 'rb') { + return parent::fopen($path, $mode); + } else { + $permissions = $this->file_exists($path) ? Constants::PERMISSION_UPDATE : Constants::PERMISSION_CREATE; + return $this->checkMask($permissions) ? parent::fopen($path, $mode) : false; + } + } + + public function getCache(string $path = '', ?IStorage $storage = null): \OCP\Files\Cache\ICache { + if (!$storage) { + $storage = $this; + } + $sourceCache = parent::getCache($path, $storage); + return new CachePermissionsMask($sourceCache, $this->mask); + } + + public function getMetaData(string $path): ?array { + $data = parent::getMetaData($path); + + if ($data && isset($data['permissions'])) { + $data['scan_permissions'] = $data['scan_permissions'] ?? $data['permissions']; + $data['permissions'] &= $this->mask; + } + return $data; + } + + public function getScanner(string $path = '', ?IStorage $storage = null): \OCP\Files\Cache\IScanner { + if (!$storage) { + $storage = $this->storage; + } + return parent::getScanner($path, $storage); + } + + public function getDirectoryContent(string $directory): \Traversable { + foreach ($this->getWrapperStorage()->getDirectoryContent($directory) as $data) { + $data['scan_permissions'] = $data['scan_permissions'] ?? $data['permissions']; + $data['permissions'] &= $this->mask; + + yield $data; + } + } +} diff --git a/lib/private/Files/Storage/Wrapper/Quota.php b/lib/private/Files/Storage/Wrapper/Quota.php new file mode 100644 index 00000000000..35a265f8c8e --- /dev/null +++ b/lib/private/Files/Storage/Wrapper/Quota.php @@ -0,0 +1,208 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage\Wrapper; + +use OC\Files\Filesystem; +use OC\SystemConfig; +use OCP\Files\Cache\ICacheEntry; +use OCP\Files\FileInfo; +use OCP\Files\Storage\IStorage; + +class Quota extends Wrapper { + /** @var callable|null */ + protected $quotaCallback; + /** @var int|float|null int on 64bits, float on 32bits for bigint */ + protected int|float|null $quota; + protected string $sizeRoot; + private SystemConfig $config; + private bool $quotaIncludeExternalStorage; + private bool $enabled = true; + + /** + * @param array $parameters + */ + public function __construct(array $parameters) { + parent::__construct($parameters); + $this->quota = $parameters['quota'] ?? null; + $this->quotaCallback = $parameters['quotaCallback'] ?? null; + $this->sizeRoot = $parameters['root'] ?? ''; + $this->quotaIncludeExternalStorage = $parameters['include_external_storage'] ?? false; + } + + public function getQuota(): int|float { + if ($this->quota === null) { + $quotaCallback = $this->quotaCallback; + if ($quotaCallback === null) { + throw new \Exception('No quota or quota callback provider'); + } + $this->quota = $quotaCallback(); + } + + return $this->quota; + } + + private function hasQuota(): bool { + if (!$this->enabled) { + return false; + } + return $this->getQuota() !== FileInfo::SPACE_UNLIMITED; + } + + protected function getSize(string $path, ?IStorage $storage = null): int|float { + if ($this->quotaIncludeExternalStorage) { + $rootInfo = Filesystem::getFileInfo('', 'ext'); + if ($rootInfo) { + return $rootInfo->getSize(true); + } + return FileInfo::SPACE_NOT_COMPUTED; + } else { + $cache = is_null($storage) ? $this->getCache() : $storage->getCache(); + $data = $cache->get($path); + if ($data instanceof ICacheEntry && isset($data['size'])) { + return $data['size']; + } else { + return FileInfo::SPACE_NOT_COMPUTED; + } + } + } + + public function free_space(string $path): int|float|false { + if (!$this->hasQuota()) { + return $this->storage->free_space($path); + } + if ($this->getQuota() < 0 || str_starts_with($path, 'cache') || str_starts_with($path, 'uploads')) { + return $this->storage->free_space($path); + } else { + $used = $this->getSize($this->sizeRoot); + if ($used < 0) { + return FileInfo::SPACE_NOT_COMPUTED; + } else { + $free = $this->storage->free_space($path); + $quotaFree = max($this->getQuota() - $used, 0); + // if free space is known + $free = $free >= 0 ? min($free, $quotaFree) : $quotaFree; + return $free; + } + } + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + if (!$this->hasQuota()) { + return $this->storage->file_put_contents($path, $data); + } + $free = $this->free_space($path); + if ($free < 0 || strlen($data) < $free) { + return $this->storage->file_put_contents($path, $data); + } else { + return false; + } + } + + public function copy(string $source, string $target): bool { + if (!$this->hasQuota()) { + return $this->storage->copy($source, $target); + } + $free = $this->free_space($target); + if ($free < 0 || $this->getSize($source) < $free) { + return $this->storage->copy($source, $target); + } else { + return false; + } + } + + public function fopen(string $path, string $mode) { + if (!$this->hasQuota()) { + return $this->storage->fopen($path, $mode); + } + $source = $this->storage->fopen($path, $mode); + + // don't apply quota for part files + if (!$this->isPartFile($path)) { + $free = $this->free_space($path); + if ($source && (is_int($free) || is_float($free)) && $free >= 0 && $mode !== 'r' && $mode !== 'rb') { + // only apply quota for files, not metadata, trash or others + if ($this->shouldApplyQuota($path)) { + return \OC\Files\Stream\Quota::wrap($source, $free); + } + } + } + + return $source; + } + + /** + * Checks whether the given path is a part file + * + * @param string $path Path that may identify a .part file + * @note this is needed for reusing keys + */ + private function isPartFile(string $path): bool { + $extension = pathinfo($path, PATHINFO_EXTENSION); + + return ($extension === 'part'); + } + + /** + * Only apply quota for files, not metadata, trash or others + */ + protected function shouldApplyQuota(string $path): bool { + return str_starts_with(ltrim($path, '/'), 'files/'); + } + + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + if (!$this->hasQuota()) { + return $this->storage->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } + $free = $this->free_space($targetInternalPath); + if ($free < 0 || $this->getSize($sourceInternalPath, $sourceStorage) < $free) { + return $this->storage->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } else { + return false; + } + } + + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + if (!$this->hasQuota()) { + return $this->storage->moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } + $free = $this->free_space($targetInternalPath); + if ($free < 0 || $this->getSize($sourceInternalPath, $sourceStorage) < $free) { + return $this->storage->moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } else { + return false; + } + } + + public function mkdir(string $path): bool { + if (!$this->hasQuota()) { + return $this->storage->mkdir($path); + } + $free = $this->free_space($path); + if ($this->shouldApplyQuota($path) && $free == 0) { + return false; + } + + return parent::mkdir($path); + } + + public function touch(string $path, ?int $mtime = null): bool { + if (!$this->hasQuota()) { + return $this->storage->touch($path, $mtime); + } + $free = $this->free_space($path); + if ($free == 0) { + return false; + } + + return parent::touch($path, $mtime); + } + + public function enableQuota(bool $enabled): void { + $this->enabled = $enabled; + } +} diff --git a/lib/private/Files/Storage/Wrapper/Wrapper.php b/lib/private/Files/Storage/Wrapper/Wrapper.php new file mode 100644 index 00000000000..7af11dd5ef7 --- /dev/null +++ b/lib/private/Files/Storage/Wrapper/Wrapper.php @@ -0,0 +1,351 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\Storage\Wrapper; + +use OC\Files\Storage\FailedStorage; +use OC\Files\Storage\Storage; +use OCP\Files; +use OCP\Files\Cache\ICache; +use OCP\Files\Cache\IPropagator; +use OCP\Files\Cache\IScanner; +use OCP\Files\Cache\IUpdater; +use OCP\Files\Cache\IWatcher; +use OCP\Files\Storage\ILockingStorage; +use OCP\Files\Storage\IStorage; +use OCP\Files\Storage\IWriteStreamStorage; +use OCP\Lock\ILockingProvider; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class Wrapper implements \OC\Files\Storage\Storage, ILockingStorage, IWriteStreamStorage { + /** + * @var \OC\Files\Storage\Storage $storage + */ + protected $storage; + + public $cache; + public $scanner; + public $watcher; + public $propagator; + public $updater; + + /** + * @param array $parameters + */ + public function __construct(array $parameters) { + $this->storage = $parameters['storage']; + } + + public function getWrapperStorage(): Storage { + if (!$this->storage) { + $message = 'storage wrapper ' . get_class($this) . " doesn't have a wrapped storage set"; + $logger = Server::get(LoggerInterface::class); + $logger->error($message); + $this->storage = new FailedStorage(['exception' => new \Exception($message)]); + } + return $this->storage; + } + + public function getId(): string { + return $this->getWrapperStorage()->getId(); + } + + public function mkdir(string $path): bool { + return $this->getWrapperStorage()->mkdir($path); + } + + public function rmdir(string $path): bool { + return $this->getWrapperStorage()->rmdir($path); + } + + public function opendir(string $path) { + return $this->getWrapperStorage()->opendir($path); + } + + public function is_dir(string $path): bool { + return $this->getWrapperStorage()->is_dir($path); + } + + public function is_file(string $path): bool { + return $this->getWrapperStorage()->is_file($path); + } + + public function stat(string $path): array|false { + return $this->getWrapperStorage()->stat($path); + } + + public function filetype(string $path): string|false { + return $this->getWrapperStorage()->filetype($path); + } + + public function filesize(string $path): int|float|false { + return $this->getWrapperStorage()->filesize($path); + } + + public function isCreatable(string $path): bool { + return $this->getWrapperStorage()->isCreatable($path); + } + + public function isReadable(string $path): bool { + return $this->getWrapperStorage()->isReadable($path); + } + + public function isUpdatable(string $path): bool { + return $this->getWrapperStorage()->isUpdatable($path); + } + + public function isDeletable(string $path): bool { + return $this->getWrapperStorage()->isDeletable($path); + } + + public function isSharable(string $path): bool { + return $this->getWrapperStorage()->isSharable($path); + } + + public function getPermissions(string $path): int { + return $this->getWrapperStorage()->getPermissions($path); + } + + public function file_exists(string $path): bool { + return $this->getWrapperStorage()->file_exists($path); + } + + public function filemtime(string $path): int|false { + return $this->getWrapperStorage()->filemtime($path); + } + + public function file_get_contents(string $path): string|false { + return $this->getWrapperStorage()->file_get_contents($path); + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + return $this->getWrapperStorage()->file_put_contents($path, $data); + } + + public function unlink(string $path): bool { + return $this->getWrapperStorage()->unlink($path); + } + + public function rename(string $source, string $target): bool { + return $this->getWrapperStorage()->rename($source, $target); + } + + public function copy(string $source, string $target): bool { + return $this->getWrapperStorage()->copy($source, $target); + } + + public function fopen(string $path, string $mode) { + return $this->getWrapperStorage()->fopen($path, $mode); + } + + public function getMimeType(string $path): string|false { + return $this->getWrapperStorage()->getMimeType($path); + } + + public function hash(string $type, string $path, bool $raw = false): string|false { + return $this->getWrapperStorage()->hash($type, $path, $raw); + } + + public function free_space(string $path): int|float|false { + return $this->getWrapperStorage()->free_space($path); + } + + public function touch(string $path, ?int $mtime = null): bool { + return $this->getWrapperStorage()->touch($path, $mtime); + } + + public function getLocalFile(string $path): string|false { + return $this->getWrapperStorage()->getLocalFile($path); + } + + public function hasUpdated(string $path, int $time): bool { + return $this->getWrapperStorage()->hasUpdated($path, $time); + } + + public function getCache(string $path = '', ?IStorage $storage = null): ICache { + if (!$storage) { + $storage = $this; + } + return $this->getWrapperStorage()->getCache($path, $storage); + } + + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner { + if (!$storage) { + $storage = $this; + } + return $this->getWrapperStorage()->getScanner($path, $storage); + } + + public function getOwner(string $path): string|false { + return $this->getWrapperStorage()->getOwner($path); + } + + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { + if (!$storage) { + $storage = $this; + } + return $this->getWrapperStorage()->getWatcher($path, $storage); + } + + public function getPropagator(?IStorage $storage = null): IPropagator { + if (!$storage) { + $storage = $this; + } + return $this->getWrapperStorage()->getPropagator($storage); + } + + public function getUpdater(?IStorage $storage = null): IUpdater { + if (!$storage) { + $storage = $this; + } + return $this->getWrapperStorage()->getUpdater($storage); + } + + public function getStorageCache(): \OC\Files\Cache\Storage { + return $this->getWrapperStorage()->getStorageCache(); + } + + public function getETag(string $path): string|false { + return $this->getWrapperStorage()->getETag($path); + } + + public function test(): bool { + return $this->getWrapperStorage()->test(); + } + + public function isLocal(): bool { + return $this->getWrapperStorage()->isLocal(); + } + + public function instanceOfStorage(string $class): bool { + if (ltrim($class, '\\') === 'OC\Files\Storage\Shared') { + // FIXME Temporary fix to keep existing checks working + $class = '\OCA\Files_Sharing\SharedStorage'; + } + return is_a($this, $class) or $this->getWrapperStorage()->instanceOfStorage($class); + } + + /** + * @psalm-template T of IStorage + * @psalm-param class-string<T> $class + * @psalm-return T|null + */ + public function getInstanceOfStorage(string $class): ?IStorage { + $storage = $this; + while ($storage instanceof Wrapper) { + if ($storage instanceof $class) { + break; + } + $storage = $storage->getWrapperStorage(); + } + if (!($storage instanceof $class)) { + return null; + } + return $storage; + } + + /** + * Pass any methods custom to specific storage implementations to the wrapped storage + * + * @return mixed + */ + public function __call(string $method, array $args) { + return call_user_func_array([$this->getWrapperStorage(), $method], $args); + } + + public function getDirectDownload(string $path): array|false { + return $this->getWrapperStorage()->getDirectDownload($path); + } + + public function getAvailability(): array { + return $this->getWrapperStorage()->getAvailability(); + } + + public function setAvailability(bool $isAvailable): void { + $this->getWrapperStorage()->setAvailability($isAvailable); + } + + public function verifyPath(string $path, string $fileName): void { + $this->getWrapperStorage()->verifyPath($path, $fileName); + } + + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + if ($sourceStorage === $this) { + return $this->copy($sourceInternalPath, $targetInternalPath); + } + + return $this->getWrapperStorage()->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } + + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + if ($sourceStorage === $this) { + return $this->rename($sourceInternalPath, $targetInternalPath); + } + + return $this->getWrapperStorage()->moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } + + public function getMetaData(string $path): ?array { + return $this->getWrapperStorage()->getMetaData($path); + } + + public function acquireLock(string $path, int $type, ILockingProvider $provider): void { + if ($this->getWrapperStorage()->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { + $this->getWrapperStorage()->acquireLock($path, $type, $provider); + } + } + + public function releaseLock(string $path, int $type, ILockingProvider $provider): void { + if ($this->getWrapperStorage()->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { + $this->getWrapperStorage()->releaseLock($path, $type, $provider); + } + } + + public function changeLock(string $path, int $type, ILockingProvider $provider): void { + if ($this->getWrapperStorage()->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { + $this->getWrapperStorage()->changeLock($path, $type, $provider); + } + } + + public function needsPartFile(): bool { + return $this->getWrapperStorage()->needsPartFile(); + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + $storage = $this->getWrapperStorage(); + if ($storage->instanceOfStorage(IWriteStreamStorage::class)) { + /** @var IWriteStreamStorage $storage */ + return $storage->writeStream($path, $stream, $size); + } else { + $target = $this->fopen($path, 'w'); + $count = Files::streamCopy($stream, $target); + fclose($stream); + fclose($target); + return $count; + } + } + + public function getDirectoryContent(string $directory): \Traversable { + return $this->getWrapperStorage()->getDirectoryContent($directory); + } + + public function isWrapperOf(IStorage $storage): bool { + $wrapped = $this->getWrapperStorage(); + if ($wrapped === $storage) { + return true; + } + if ($wrapped instanceof Wrapper) { + return $wrapped->isWrapperOf($storage); + } + return false; + } + + public function setOwner(?string $user): void { + $this->getWrapperStorage()->setOwner($user); + } +} |