aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/Files/Storage
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/Files/Storage')
-rw-r--r--lib/private/Files/Storage/Common.php769
-rw-r--r--lib/private/Files/Storage/CommonTest.php60
-rw-r--r--lib/private/Files/Storage/DAV.php841
-rw-r--r--lib/private/Files/Storage/FailedStorage.php191
-rw-r--r--lib/private/Files/Storage/Home.php76
-rw-r--r--lib/private/Files/Storage/Local.php594
-rw-r--r--lib/private/Files/Storage/LocalRootStorage.php22
-rw-r--r--lib/private/Files/Storage/LocalTempFileTrait.php54
-rw-r--r--lib/private/Files/Storage/PolyFill/CopyDirectory.php71
-rw-r--r--lib/private/Files/Storage/Storage.php53
-rw-r--r--lib/private/Files/Storage/StorageFactory.php73
-rw-r--r--lib/private/Files/Storage/Temporary.php34
-rw-r--r--lib/private/Files/Storage/Wrapper/Availability.php277
-rw-r--r--lib/private/Files/Storage/Wrapper/Encoding.php296
-rw-r--r--lib/private/Files/Storage/Wrapper/EncodingDirectoryWrapper.php34
-rw-r--r--lib/private/Files/Storage/Wrapper/Encryption.php946
-rw-r--r--lib/private/Files/Storage/Wrapper/Jail.php267
-rw-r--r--lib/private/Files/Storage/Wrapper/KnownMtime.php146
-rw-r--r--lib/private/Files/Storage/Wrapper/PermissionsMask.php138
-rw-r--r--lib/private/Files/Storage/Wrapper/Quota.php208
-rw-r--r--lib/private/Files/Storage/Wrapper/Wrapper.php351
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);
+ }
+}